commit 6f58a6c19aa6f01a38093c5e52a4e312dbbfcb6b Author: Ant Browser Release Bot Date: Fri Mar 13 23:19:29 2026 +0800 publish: 1.0.0 snapshot (bad2ec1) channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7d6ad73f --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# OS +.DS_Store +Thumbs.db + +# IDE / Editor +.vscode/ +.idea/ +.history/ +.kiro/ +*.swp +*.swo + +# Local agent/editor rules +.windsurfrules + +# Logs +*.log +*.err +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.ant-license.json + +# Build outputs +bin/ +build/bin/ +build/dist/ +chrome/ +*.exe +*.dll +*.so +*.dylib + +# Runtime binaries (must be versioned for release/dev convenience) +!/bin/ +!/bin/xray.exe +!/bin/sing-box.exe + +# Publish artifacts +publish/staging/ +publish/output/ + +# Internal docs / scratch files +docs/ +DEPLOYMENT_GUIDE.md +plan.md +pic/ + +# Runtime data +data/ +snapshots/ +app.db +*.db +*.sqlite +*.sqlite3 +proxies.yaml +profiles.yaml + +# Frontend deps/cache +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# Test outputs / coverage +coverage/ +*.out + +# Env files +.env +.env.* +!.env.example diff --git a/README.md b/README.md new file mode 100644 index 00000000..5aa40410 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Ant Browser + +> 面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。 + +[![Release](https://img.shields.io/github/v/release/black-ant/Ant-Browser?sort=semver)](https://github.com/black-ant/Ant-Browser/releases) +[![Platform](https://img.shields.io/badge/Platform-Windows%2010%2F11-blue)](https://github.com/black-ant/Ant-Browser/releases) +[![Issues](https://img.shields.io/github/issues/black-ant/Ant-Browser)](https://github.com/black-ant/Ant-Browser/issues) + +## 推荐内核项目 + +Ant Browser 当前推荐配套使用的浏览器内核,来源于开源项目 [fingerprint-chromium](https://github.com/adryfish/fingerprint-chromium)。 + +如果你正在寻找可直接下载和维护的指纹内核版本,建议先查看它的 Releases 页面: + +- + +这个项目为 Ant Browser 的内核准备提供了直接可用的基础来源,这里先对原项目做明确推荐与致谢。 + +Ant Browser 的目标很明确:在一台 Windows 设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。 + +## 目录 + +- [项目简介](#项目简介) +- [核心特性](#核心特性) +- [界面预览](#界面预览) +- [快速开始](#快速开始) +- [常用操作](#常用操作) +- [常见问题](#常见问题) +- [Roadmap](#roadmap) +- [贡献](#贡献) +- [支持与反馈](#支持与反馈) +- [License](#license) + +## 项目简介 + +Ant Browser 适合以下场景: + +- 多账号环境隔离 +- 跨境电商与社媒账号运营 +- 需要独立代理出口的本地测试 +- 需要统一管理浏览器内核和实例配置的团队 + +这个项目当前提供的核心价值是: + +- 给每个账号分配独立浏览器实例 +- 给每个实例绑定独立代理 +- 统一管理浏览器内核、标签、关键字和快捷打开码 +- 在本地保存配置和运行数据,便于自主控制 + +## 源码分支说明 + +- `master`:面向开发者的干净基线分支,不提交 `data/app.db`、实例目录或其他用户数据。首次启动时会自动初始化空数据库。 +- `user_data`:在 `master` 基础上额外提交一份 `data/app.db` 测试快照,便于演示、联调和复现问题。 +- 代理运行时 `bin/xray.exe`、`bin/sing-box.exe` 已随源码仓库提供;开发和发布打包不需要再单独下载这些运行时文件。 + +## 核心特性 + +- 实例隔离管理:支持创建、编辑、启动、停止、重启、克隆和删除浏览器实例 +- 代理池配置:支持统一维护代理节点,并将代理分配到具体实例 +- 多协议支持:支持常见代理配置方式,并支持导入 Clash +- 内核管理:支持维护多个 Chrome 内核版本,并设置默认内核 +- 快捷启动:支持通过实例 Code 和 `Ctrl + K` 快速打开目标实例 +- 标签与检索:支持按标签、关键字、状态、代理、内核、分组进行筛选 +- 本地化存储:配置和实例数据保存在本地,适合长期使用和备份 + +## 界面预览 + +### 1. 控制台 + +控制台 + +对应功能点: + +- 查看实例总数、运行中实例、代理节点数量和内核版本 +- 从首页快速进入 `实例列表`、`代理池配置`、`内核管理`、`系统设置` +- 查看客户端版本、运行环境、数据存储和当前实例运行状态 + +### 2. 实例列表 + +实例列表 + +对应功能点: + +- 统一查看和管理所有浏览器实例 +- 按状态、代理、内核、分组、关键字筛选实例 +- 支持 `新建配置`、启动、停止、重启、配置、克隆、删除 +- 给实例分配快捷打开码,后续可以直接快速启动 + +### 3. 代理池配置 + +代理池配置 + +对应功能点: + +- 统一管理代理节点 +- 支持按协议、分组筛选代理 +- 支持手动维护代理和导入 Clash +- 支持查看延迟、IP 健康并挑选可用节点 + +### 4. 代理生效验证 + +代理生效验证 + +对应功能点: + +- 启动实例后访问 IP 检测网站验证代理是否真正生效 +- 检查 IP 地区、ASN、运营商和风险值等信息 +- 用于确认当前实例是否已经走目标代理出口 + +## 快速开始 + +### 环境要求 + +- 操作系统:Windows 10 / Windows 11(64 位) +- 建议内存:8 GB 及以上 +- 建议磁盘空间:2 GB 以上 + +### 下载与运行 + +1. 前往 Releases 页面下载最新版本: +2. 安装版直接运行 `AntBrowser-Setup-*.exe` +3. 便携版解压后运行 `ant-chrome.exe` + +### 从源码运行 + +1. 开发默认使用 `master` 分支;该分支不带测试用户数据,适合作为日常开发基线。 +2. 如需带测试库的演示环境,请切换到 `user_data` 分支。 +3. 执行 `bat\dev.bat` 或直接使用 `wails dev` 启动项目。 +4. 仓库已内置 `bin/xray.exe` 和 `bin/sing-box.exe`,不需要额外下载代理运行时。 + +### 准备浏览器内核 + +代理运行时已经随仓库提供,你只需要准备浏览器内核。 + +1. 打开应用,进入 `指纹浏览器 > 内核管理` +2. 优先使用应用内下载功能准备内核 +3. 如果手动准备内核,请确保目录下存在 `chrome.exe` + +建议目录结构: + +```text +chrome/ + chrom-142/ + chrome.exe + ... +``` + +### 第一次使用建议流程 + +1. 在 `代理池配置` 中先导入或新增可用代理节点 +2. 在 `实例列表` 中点击 `新建配置` +3. 选择实例名称、内核、代理、标签和需要的启动参数 +4. 返回实例列表,点击启动按钮运行实例 +5. 打开 IP 检测网站,确认代理结果是否符合预期 + +## 常用操作 + +| 目标 | 入口 | 说明 | +| --- | --- | --- | +| 新建浏览器实例 | `实例列表 > 新建配置` | 创建一个新的独立浏览器环境 | +| 配置代理池 | `代理池配置` | 维护代理节点并检查延迟、健康状态 | +| 绑定实例代理 | `实例编辑页` | 给指定实例分配目标代理节点 | +| 启动实例 | `实例列表` | 单击启动按钮即可运行目标实例 | +| 快速打开实例 | `Ctrl + K` | 可按 Code、实例名、标签、关键字快速检索 | +| 管理浏览器内核 | `内核管理` | 新增、编辑、删除和设置默认内核 | +| 验证代理结果 | 启动实例后访问 IP 检测网站 | 核对 IP、地区、ASN、风险值 | + +## 常见问题 + +### 1. 应用无法启动怎么办? + +先检查浏览器内核路径是否有效,并确认目标目录下存在 `chrome.exe`。 + +### 2. 实例启动了但代理没有生效怎么办? + +先检查代理节点本身是否可用,再确认该实例已经正确绑定代理。建议启动后访问 IP 检测网站复核当前出口。 + +### 3. 实例太多,怎么快速找到目标实例? + +可以在 `实例列表` 中按状态、代理、内核、分组、关键字筛选,也可以通过 `Ctrl + K` 使用实例 Code 或名称快速启动。 + +### 4. 多个账号怎么避免串号? + +建议采用一账号一实例、一实例一稳定代理的方式,不要混用浏览器环境,也不要频繁切换同一实例的出口 IP。 + +## Roadmap + +- 完善自动化模块能力 +- 持续补充使用文档和接口说明 +- 增强实例模板、批量管理和检索体验 + +## 贡献 + +欢迎通过 Issue 和 Pull Request 参与改进。 + +- Bug 反馈:请附带版本号、系统版本、复现步骤和截图 +- 功能建议:请说明业务场景、预期行为和现有问题 +- 文档优化:欢迎直接提交 README、教程和截图说明相关改进 + +如果是较大改动,建议先开 Issue 对齐需求再提交 PR。 + +## 支持与反馈 + +- Releases: +- Issues: + +## License + +当前仓库暂未附带独立的 `LICENSE` 文件,后续会补充。 diff --git a/backend/app.go b/backend/app.go new file mode 100644 index 00000000..42da5891 --- /dev/null +++ b/backend/app.go @@ -0,0 +1,1168 @@ +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))) + } + } +} diff --git a/backend/app_backup.go b/backend/app_backup.go new file mode 100644 index 00000000..5503b74a --- /dev/null +++ b/backend/app_backup.go @@ -0,0 +1,33 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "strings" + "time" +) + +type BackupScope = backup.Scope +type BackupManifest = backup.Manifest + +// BackupGetScopeDefinition 返回当前环境下的备份范围定义(第一阶段:范围与包格式)。 +func (a *App) BackupGetScopeDefinition() (BackupScope, error) { + return backup.BuildScope(backup.BuildOptions{ + AppRoot: a.appRoot, + Config: a.config, + }) +} + +// BackupGetManifestTemplate 返回 manifest 结构预览(不执行实际导出)。 +func (a *App) BackupGetManifestTemplate() (BackupManifest, error) { + scope, err := a.BackupGetScopeDefinition() + if err != nil { + return BackupManifest{}, err + } + appName := "Ant Browser" + if a.config != nil { + if name := strings.TrimSpace(a.config.App.Name); name != "" { + appName = name + } + } + return backup.BuildManifest(scope, appName, "1.0.0", time.Now()), nil +} diff --git a/backend/app_backup_ops.go b/backend/app_backup_ops.go new file mode 100644 index 00000000..b5665379 --- /dev/null +++ b/backend/app_backup_ops.go @@ -0,0 +1,1606 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "ant-chrome/backend/internal/proxy" + "archive/zip" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// BackupInitializeSystem 初始化系统到最开始状态。 +func (a *App) BackupInitializeSystem() (map[string]interface{}, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + return a.backupInitializeLocked(true) +} + +// BackupExportPackage 导出全量配置与数据到 ZIP。 +func (a *App) BackupExportPackage() (map[string]interface{}, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + a.backupEmitExportProgress("starting", 0, "等待选择导出路径...") + + defaultName := fmt.Sprintf("ant-chrome-backup-%s.zip", time.Now().Format("20060102-150405")) + savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{ + Title: "导出配置", + DefaultFilename: defaultName, + Filters: []wailsruntime.FileFilter{ + {DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"}, + }, + }) + if err != nil { + return nil, fmt.Errorf("打开保存对话框失败: %w", err) + } + if strings.TrimSpace(savePath) == "" { + a.backupEmitExportProgress("cancelled", 0, "已取消导出") + return map[string]interface{}{ + "cancelled": true, + "message": "已取消导出", + }, nil + } + savePath = backupEnsureZipSuffix(savePath) + a.backupEmitExportProgress("preparing", 8, "正在收集导出范围...") + + scope, err := backup.BuildScope(backup.BuildOptions{AppRoot: a.appRoot, Config: a.config}) + if err != nil { + a.backupEmitExportProgress("error", 100, fmt.Sprintf("导出失败: %v", err)) + return nil, err + } + appName := "Ant Browser" + if a.config != nil && strings.TrimSpace(a.config.App.Name) != "" { + appName = strings.TrimSpace(a.config.App.Name) + } + manifest := backup.BuildManifest(scope, appName, "1.0.0", time.Now()) + a.backupEmitExportProgress("preparing", 15, "开始写入备份包...") + + includedEntries, skippedEntries, fileCount, err := backupWritePackageZip(savePath, scope, manifest, a.backupEmitExportProgressMeta) + if err != nil { + a.backupEmitExportProgress("error", 100, fmt.Sprintf("导出失败: %v", err)) + return nil, err + } + + return map[string]interface{}{ + "cancelled": false, + "zipPath": savePath, + "includedEntries": includedEntries, + "skippedEntries": skippedEntries, + "fileCount": fileCount, + "message": "导出完成", + }, nil +} + +// BackupImportPackage 从 ZIP 加载配置与数据。 +// resetFirst=true: 先初始化,再全量导入。 +// resetFirst=false: 直接导入并执行判重合并。 +func (a *App) BackupImportPackage(resetFirst bool) (map[string]interface{}, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + a.backupEmitImportProgress("starting", 0, "等待选择 ZIP 配置文件...") + + zipPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "加载配置", + Filters: []wailsruntime.FileFilter{ + {DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"}, + }, + }) + if err != nil { + a.backupEmitImportProgress("error", 100, fmt.Sprintf("打开文件对话框失败: %v", err)) + return nil, fmt.Errorf("打开文件对话框失败: %w", err) + } + if strings.TrimSpace(zipPath) == "" { + a.backupEmitImportProgress("cancelled", 0, "已取消加载") + return map[string]interface{}{ + "cancelled": true, + "message": "已取消加载", + }, nil + } + a.backupEmitImportProgress("preparing", 5, "正在校验备份包...") + + result, importErr := a.backupImportFromPathLocked(zipPath, resetFirst) + if importErr != nil { + a.backupEmitImportProgress("error", 100, fmt.Sprintf("加载失败: %v", importErr)) + return nil, importErr + } + return result, nil +} + +type backupMergeStats struct { + Imported int + Skipped int + Conflicts int +} + +type backupImportIssue struct { + ComponentID string `json:"componentId"` + ComponentName string `json:"componentName"` + Error string `json:"error"` +} + +type backupProgressMeta struct { + ComponentID string + ComponentName string + EntryIndex int + EntryTotal int +} + +type backupProgressEvent struct { + Phase string `json:"phase"` + Progress int `json:"progress"` + Message string `json:"message"` + ComponentID string `json:"componentId,omitempty"` + ComponentName string `json:"componentName,omitempty"` + EntryIndex int `json:"entryIndex,omitempty"` + EntryTotal int `json:"entryTotal,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +func (a *App) backupEmitExportProgress(phase string, progress int, message string) { + a.backupEmitExportProgressMeta(phase, progress, message, nil) +} + +func (a *App) backupEmitExportProgressMeta(phase string, progress int, message string, meta *backupProgressMeta) { + a.backupEmitProgress("backup:export:progress", phase, progress, message, meta) +} + +func (a *App) backupEmitImportProgress(phase string, progress int, message string) { + a.backupEmitImportProgressMeta(phase, progress, message, nil) +} + +func (a *App) backupEmitImportProgressMeta(phase string, progress int, message string, meta *backupProgressMeta) { + a.backupEmitProgress("backup:import:progress", phase, progress, message, meta) +} + +func (a *App) backupEmitProgress(eventName, phase string, progress int, message string, meta *backupProgressMeta) { + if a == nil || a.ctx == nil { + return + } + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + evt := backupProgressEvent{ + Phase: strings.TrimSpace(phase), + Progress: progress, + Message: strings.TrimSpace(message), + Timestamp: time.Now().Format("15:04:05"), + } + if meta != nil { + evt.ComponentID = strings.TrimSpace(meta.ComponentID) + evt.ComponentName = strings.TrimSpace(meta.ComponentName) + evt.EntryIndex = meta.EntryIndex + evt.EntryTotal = meta.EntryTotal + } + wailsruntime.EventsEmit(a.ctx, eventName, backupProgressEvent{ + Phase: evt.Phase, + Progress: evt.Progress, + Message: evt.Message, + ComponentID: evt.ComponentID, + ComponentName: evt.ComponentName, + EntryIndex: evt.EntryIndex, + EntryTotal: evt.EntryTotal, + Timestamp: evt.Timestamp, + }) +} + +func (a *App) backupInitializeLocked(applyReload bool) (map[string]interface{}, error) { + log := logger.New("Backup") + a.backupStopRuntimeForMaintenance() + + defaultCfg := config.DefaultConfig() + oldCfg := a.config + if oldCfg == nil { + oldCfg = config.DefaultConfig() + } + activeDBPath := a.backupResolveDBPath(oldCfg) + keepFiles := map[string]struct{}{ + backupNormalizePath(activeDBPath): {}, + backupNormalizePath(activeDBPath + "-wal"): {}, + backupNormalizePath(activeDBPath + "-shm"): {}, + } + + if err := defaultCfg.Save(a.resolveAppPath("config.yaml")); err != nil { + return nil, fmt.Errorf("写入默认配置失败: %w", err) + } + a.config = defaultCfg + a.applyRuntimeConfig(defaultCfg.Runtime) + _ = os.Remove(a.resolveAppPath("proxies.yaml")) + + if err := a.backupClearBusinessTables(); err != nil { + return nil, err + } + + cleared := make([]string, 0, 3) + dataRoot := a.resolveAppPath("data") + if err := backupRemoveContentsExcept(dataRoot, keepFiles); err == nil { + cleared = append(cleared, dataRoot) + } + oldUserRoot := a.backupResolveUserDataRoot(oldCfg) + newUserRoot := a.backupResolveUserDataRoot(defaultCfg) + for _, p := range backupUniqueNonEmpty([]string{oldUserRoot, newUserRoot}) { + if backupSamePath(p, dataRoot) { + continue + } + if err := backupRemoveContentsExcept(p, keepFiles); err == nil { + cleared = append(cleared, p) + } + } + + if applyReload { + if err := a.backupReloadAfterMutation(); err != nil { + return nil, err + } + } + + log.Info("系统初始化完成", logger.F("cleared_dirs", strings.Join(cleared, ";"))) + return map[string]interface{}{ + "cancelled": false, + "resetDone": true, + "clearedDirs": cleared, + "message": "系统已初始化到默认状态", + }, nil +} + +func (a *App) backupImportFromPathLocked(zipPath string, resetFirst bool) (map[string]interface{}, error) { + a.backupStopRuntimeForMaintenance() + a.backupEmitImportProgress("preparing", 10, "正在解压并校验备份包...") + + extractRoot, manifest, err := backupExtractAndValidate(zipPath) + if err != nil { + return nil, err + } + defer os.RemoveAll(extractRoot) + a.backupEmitImportProgress("preparing", 20, "备份包校验通过,开始加载数据...") + + componentEntries := backupDetectPresentManifestEntries(extractRoot, manifest) + componentUniverse := make(map[string]struct{}, len(componentEntries)) + for id := range componentEntries { + componentUniverse[id] = struct{}{} + } + failedComponentIDs := map[string]struct{}{} + issues := make([]backupImportIssue, 0) + recordIssue := func(componentID, componentName string, err error) { + if err == nil { + return + } + componentID = strings.TrimSpace(componentID) + componentName = strings.TrimSpace(componentName) + if componentID != "" { + componentUniverse[componentID] = struct{}{} + failedComponentIDs[componentID] = struct{}{} + if componentName == "" { + if entry, ok := componentEntries[componentID]; ok { + componentName = backupResolveManifestComponentName(entry) + } + } + } + if componentName == "" { + componentName = "未知模块" + } + issues = append(issues, backupImportIssue{ + ComponentID: componentID, + ComponentName: componentName, + Error: err.Error(), + }) + } + + stats := &backupMergeStats{} + + if resetFirst { + a.backupEmitImportProgress("preparing", 30, "正在初始化系统数据...") + if _, err := a.backupInitializeLocked(false); err != nil { + return nil, err + } + a.backupEmitImportProgress("preparing", 40, "初始化完成,继续加载备份内容...") + } + + payloadRoot := filepath.Join(extractRoot, "payload") + a.backupEmitImportProgress("importing", 50, "正在解析备份配置...") + incomingCfg, hasIncomingCfg, err := backupLoadIncomingConfig(payloadRoot) + if err != nil { + recordIssue("system_config_main", "主配置文件", fmt.Errorf("解析配置失败: %w", err)) + incomingCfg = nil + hasIncomingCfg = false + } + if resetFirst && !hasIncomingCfg { + recordIssue("system_config_main", "主配置文件", fmt.Errorf("备份包缺少 payload/system/config.yaml,已保留默认配置继续加载其余模块")) + } + + if hasIncomingCfg { + a.backupEmitImportProgress("importing", 58, "正在应用系统配置...") + if err := a.backupApplyIncomingConfig(incomingCfg, resetFirst); err != nil { + recordIssue("system_config_main", "主配置文件", err) + } + } + + a.backupEmitImportProgress("importing", 66, "正在合并代理配置...") + if err := a.backupMergeProxiesFile(payloadRoot, resetFirst, stats); err != nil { + recordIssue("system_config_proxies", "代理配置文件", err) + } + + if dbSrc := backupFindDatabaseFile(payloadRoot); dbSrc != "" { + a.backupEmitImportProgress("importing", 76, "正在合并数据库数据...") + if err := a.backupMergeDatabaseFromSource(dbSrc, resetFirst, stats); err != nil { + recordIssue("database_sqlite_main", "SQLite 主数据库", err) + } + } else if _, ok := componentEntries["database_sqlite_main"]; ok { + recordIssue("database_sqlite_main", "SQLite 主数据库", fmt.Errorf("备份包缺少数据库文件")) + } + + a.backupEmitImportProgress("importing", 86, "正在同步文件数据...") + a.backupImportFileTrees(payloadRoot, incomingCfg, resetFirst, stats, recordIssue) + + a.backupEmitImportProgress("importing", 94, "正在刷新运行时配置...") + if err := a.backupReloadAfterMutation(); err != nil { + return nil, err + } + + totalComponents := len(componentUniverse) + failedCount := len(failedComponentIDs) + successCount := totalComponents - failedCount + if successCount < 0 { + successCount = 0 + } + partial := failedCount > 0 + message := "加载完成" + if partial { + message = fmt.Sprintf("加载完成(部分成功):成功 %d 个模块,异常 %d 个模块", successCount, failedCount) + } + a.backupEmitImportProgress("done", 100, message) + + failedComponents := make([]map[string]string, 0, len(issues)) + for _, item := range issues { + failedComponents = append(failedComponents, map[string]string{ + "componentId": item.ComponentID, + "componentName": item.ComponentName, + "error": item.Error, + }) + } + + return map[string]interface{}{ + "cancelled": false, + "zipPath": zipPath, + "resetFirst": resetFirst, + "imported": stats.Imported, + "skipped": stats.Skipped, + "conflicts": stats.Conflicts, + "partial": partial, + "componentTotal": totalComponents, + "componentSuccess": successCount, + "componentFailed": failedCount, + "failedComponents": failedComponents, + "message": message, + }, nil +} + +func (a *App) backupStopRuntimeForMaintenance() { + if a.browserMgr != nil { + a.browserMgr.Mutex.Lock() + for _, cmd := range a.browserMgr.BrowserProcesses { + if cmd != nil && cmd.Process != nil { + _ = a.stopProcessCmd(cmd) + } + } + a.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + a.browserMgr.Mutex.Unlock() + } + + if a.xrayMgr != nil { + a.xrayMgr.StopAll() + } + a.clearProfileXrayBridges() + if a.singboxMgr != nil { + a.singboxMgr.StopAll() + } + if a.speedScheduler != nil { + a.speedScheduler.Stop() + a.speedScheduler = nil + } +} + +func (a *App) backupReloadAfterMutation() error { + if err := a.ReloadConfig(); err != nil { + return err + } + + if a.browserMgr != nil { + a.browserMgr.Config = a.config + a.browserMgr.Mutex.Lock() + a.browserMgr.Profiles = make(map[string]*browser.Profile) + a.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + a.browserMgr.XrayBridges = make(map[string]*browser.XrayBridge) + a.browserMgr.Mutex.Unlock() + } + if a.xrayMgr != nil { + a.xrayMgr.Config = a.config + } + if a.clashMgr != nil { + a.clashMgr.Config = a.config + } + if a.singboxMgr != nil { + a.singboxMgr.Config = a.config + } + + a.migrateToSQLite() + if a.browserMgr != nil { + a.browserMgr.InitData() + } + a.autoDetectCores() + a.loadProxies() + + if a.launchCodeSvc != nil { + _ = a.launchCodeSvc.LoadAll() + } + if a.browserMgr != nil { + a.browserMgr.CodeProvider = a.launchCodeSvc + } + + if a.browserMgr != nil && a.browserMgr.ProxyDAO != nil { + 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() + } + return nil +} +func (a *App) backupResolveDBPath(cfg *config.Config) string { + if cfg == nil { + return a.resolveAppPath("data/app.db") + } + path := strings.TrimSpace(cfg.Database.SQLite.Path) + if path == "" { + path = "data/app.db" + } + return a.resolveAppPath(path) +} + +func (a *App) backupResolveUserDataRoot(cfg *config.Config) string { + if cfg == nil { + return a.resolveAppPath("data") + } + root := strings.TrimSpace(cfg.Browser.UserDataRoot) + if root == "" { + root = "data" + } + return a.resolveAppPath(root) +} + +func (a *App) backupClearBusinessTables() error { + if a.db == nil || a.db.GetConn() == nil { + return fmt.Errorf("数据库未初始化") + } + tx, err := a.db.GetConn().Begin() + if err != nil { + return fmt.Errorf("开启事务失败: %w", err) + } + defer tx.Rollback() + + tables := []string{"launch_codes", "browser_profiles", "browser_proxies", "browser_cores", "browser_bookmarks", "browser_groups"} + for _, table := range tables { + if _, err := tx.Exec("DELETE FROM " + table); err != nil && !backupIsNoSuchTableError(err) { + return fmt.Errorf("清空数据表失败(%s): %w", table, err) + } + } + _, _ = tx.Exec(`DELETE FROM sqlite_sequence WHERE name IN ('browser_bookmarks')`) + return tx.Commit() +} + +func backupWritePackageZip(zipPath string, scope backup.Scope, manifest backup.Manifest, emitProgress func(phase string, progress int, message string, meta *backupProgressMeta)) (int, int, int, error) { + emit := func(phase string, progress int, message string, meta *backupProgressMeta) { + if emitProgress != nil { + emitProgress(phase, progress, message, meta) + } + } + if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil { + return 0, 0, 0, fmt.Errorf("创建导出目录失败: %w", err) + } + emit("writing", 18, "正在创建导出文件...", nil) + + tmpPath := zipPath + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return 0, 0, 0, fmt.Errorf("创建导出文件失败: %w", err) + } + w := zip.NewWriter(f) + + includedEntries := 0 + skippedEntries := 0 + fileCount := 0 + + writeErr := func() error { + emit("writing", 20, "正在写入备份清单...", nil) + manifestData, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + mw, err := w.Create("manifest.json") + if err != nil { + return err + } + if _, err := mw.Write(manifestData); err != nil { + return err + } + fileCount++ + + totalEntries := len(scope.Entries) + if totalEntries == 0 { + emit("writing", 90, "没有可导出的目录条目", nil) + } + for i, entry := range scope.Entries { + meta := &backupProgressMeta{ + ComponentID: entry.ID, + ComponentName: backupResolveEntryComponentName(entry), + EntryIndex: i + 1, + EntryTotal: totalEntries, + } + startProgress := 20 + int(float64(i)/float64(totalEntries)*70) + emit("writing", startProgress, fmt.Sprintf("开始处理组件 %d/%d:%s", i+1, totalEntries, meta.ComponentName), meta) + + info, err := os.Stat(entry.SourcePath) + if err != nil { + if os.IsNotExist(err) && !entry.Required { + skippedEntries++ + progress := 20 + int(float64(i+1)/float64(totalEntries)*70) + emit("writing", progress, fmt.Sprintf("组件跳过:%s(源路径不存在)", meta.ComponentName), meta) + continue + } + return fmt.Errorf("读取导出源失败(%s): %w", entry.ID, err) + } + entryAddedFiles := 0 + if info.IsDir() { + n, err := backupZipAddDir(w, entry.SourcePath, entry.ArchivePath, zipPath) + if err != nil { + return fmt.Errorf("写入目录失败(%s): %w", entry.ID, err) + } + fileCount += n + entryAddedFiles = n + } else { + if backupSamePath(entry.SourcePath, zipPath) { + skippedEntries++ + progress := 20 + int(float64(i+1)/float64(totalEntries)*70) + emit("writing", progress, fmt.Sprintf("组件跳过:%s(导出文件本身)", meta.ComponentName), meta) + continue + } + if err := backupZipAddFile(w, entry.SourcePath, strings.TrimSuffix(entry.ArchivePath, "/")); err != nil { + return fmt.Errorf("写入文件失败(%s): %w", entry.ID, err) + } + fileCount++ + entryAddedFiles = 1 + } + includedEntries++ + progress := 20 + int(float64(i+1)/float64(totalEntries)*70) + emit("writing", progress, fmt.Sprintf("组件完成:%s(新增 %d 个文件)", meta.ComponentName, entryAddedFiles), meta) + } + return nil + }() + + closeErr := w.Close() + fileCloseErr := f.Close() + if writeErr != nil { + emit("error", 100, writeErr.Error(), nil) + _ = os.Remove(tmpPath) + return 0, 0, 0, writeErr + } + if closeErr != nil { + emit("error", 100, closeErr.Error(), nil) + _ = os.Remove(tmpPath) + return 0, 0, 0, closeErr + } + if fileCloseErr != nil { + emit("error", 100, fileCloseErr.Error(), nil) + _ = os.Remove(tmpPath) + return 0, 0, 0, fileCloseErr + } + if err := os.Rename(tmpPath, zipPath); err != nil { + emit("error", 100, err.Error(), nil) + _ = os.Remove(tmpPath) + return 0, 0, 0, fmt.Errorf("写入导出文件失败: %w", err) + } + emit("done", 100, "导出完成", nil) + return includedEntries, skippedEntries, fileCount, nil +} + +func backupResolveEntryComponentName(entry backup.ScopeEntry) string { + if desc := strings.TrimSpace(entry.Description); desc != "" { + return desc + } + if entry.ID != "" { + return entry.ID + } + switch entry.Category { + case backup.CategorySystemConfig: + return "系统配置" + case backup.CategoryAppData: + return "应用数据" + case backup.CategoryBrowserData: + return "浏览器数据" + case backup.CategoryCoreData: + return "内核数据" + case backup.CategoryLogs: + return "日志数据" + default: + return "未知组件" + } +} + +func backupZipAddDir(w *zip.Writer, srcDir, archiveBase, outputZipPath string) (int, error) { + base := strings.TrimSuffix(filepath.ToSlash(strings.TrimSpace(archiveBase)), "/") + if base == "" { + return 0, fmt.Errorf("archive base 不能为空") + } + fileCount := 0 + err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if backupSamePath(path, outputZipPath) { + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + rel = filepath.ToSlash(rel) + targetName := base + "/" + rel + if d.IsDir() { + _, err := w.Create(strings.TrimSuffix(targetName, "/") + "/") + return err + } + if err := backupZipAddFile(w, path, targetName); err != nil { + return err + } + fileCount++ + return nil + }) + return fileCount, err +} + +func backupZipAddFile(w *zip.Writer, srcFile, archivePath string) error { + info, err := os.Stat(srcFile) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("不支持将目录按文件写入: %s", srcFile) + } + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(archivePath)), "/") + header.Method = zip.Deflate + if header.Name == "" { + return fmt.Errorf("archivePath 不能为空") + } + writer, err := w.CreateHeader(header) + if err != nil { + return err + } + in, err := os.Open(srcFile) + if err != nil { + return err + } + defer in.Close() + _, err = io.Copy(writer, in) + return err +} + +func backupExtractAndValidate(zipPath string) (string, backup.Manifest, error) { + tmpDir, err := os.MkdirTemp("", "ant-chrome-import-*") + if err != nil { + return "", backup.Manifest{}, err + } + if err := unzipTo(zipPath, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + return "", backup.Manifest{}, fmt.Errorf("解压备份包失败: %w", err) + } + + manifestPath := filepath.Join(tmpDir, "manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + _ = os.RemoveAll(tmpDir) + return "", backup.Manifest{}, fmt.Errorf("备份包缺少 manifest.json") + } + var manifest backup.Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + _ = os.RemoveAll(tmpDir) + return "", backup.Manifest{}, fmt.Errorf("manifest.json 解析失败: %w", err) + } + if manifest.Format != backup.PackageFormat { + _ = os.RemoveAll(tmpDir) + return "", backup.Manifest{}, fmt.Errorf("不支持的备份格式: %s", manifest.Format) + } + if manifest.ManifestVersion != backup.ManifestVersion { + _ = os.RemoveAll(tmpDir) + return "", backup.Manifest{}, fmt.Errorf("不支持的 manifest 版本: %d", manifest.ManifestVersion) + } + if _, err := os.Stat(filepath.Join(tmpDir, "payload")); err != nil { + _ = os.RemoveAll(tmpDir) + return "", backup.Manifest{}, fmt.Errorf("备份包缺少 payload 目录") + } + return tmpDir, manifest, nil +} + +func backupLoadIncomingConfig(payloadRoot string) (*config.Config, bool, error) { + cfgPath := filepath.Join(payloadRoot, "system", "config.yaml") + if _, err := os.Stat(cfgPath); err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + cfg, err := config.Load(cfgPath) + if err != nil { + return nil, false, err + } + return cfg, true, nil +} + +func backupDetectPresentManifestEntries(extractRoot string, manifest backup.Manifest) map[string]backup.ManifestEntry { + result := make(map[string]backup.ManifestEntry, len(manifest.Entries)) + for _, entry := range manifest.Entries { + id := strings.TrimSpace(entry.ID) + if id == "" { + continue + } + archivePath := strings.TrimSpace(strings.TrimSuffix(entry.ArchivePath, "/")) + if archivePath == "" { + continue + } + absPath := filepath.Join(extractRoot, filepath.FromSlash(archivePath)) + if _, err := os.Stat(absPath); err == nil { + result[id] = entry + } + } + return result +} + +func backupResolveManifestComponentName(entry backup.ManifestEntry) string { + if desc := strings.TrimSpace(entry.Description); desc != "" { + return desc + } + if id := strings.TrimSpace(entry.ID); id != "" { + return id + } + return "未知模块" +} + +func (a *App) backupApplyIncomingConfig(incoming *config.Config, resetFirst bool) error { + if incoming == nil { + return nil + } + current := a.config + if current == nil { + current = config.DefaultConfig() + } + + var target *config.Config + if resetFirst { + cloned := *incoming + target = &cloned + } else { + target = backupMergeConfig(current, incoming) + } + target.Database = current.Database + target.App.MaxProfileLimit = current.App.MaxProfileLimit + target.App.UsedCDKeys = append([]string{}, current.App.UsedCDKeys...) + + if err := target.Save(a.resolveAppPath("config.yaml")); err != nil { + return fmt.Errorf("保存导入配置失败: %w", err) + } + a.config = target + a.applyRuntimeConfig(target.Runtime) + return nil +} + +func backupMergeConfig(current, incoming *config.Config) *config.Config { + if current == nil { + cp := *incoming + return &cp + } + if incoming == nil { + cp := *current + return &cp + } + merged := *current + if strings.TrimSpace(merged.App.Name) == "" { + merged.App.Name = incoming.App.Name + } + merged.Browser.DefaultBookmarks = backupMergeBookmarks(merged.Browser.DefaultBookmarks, incoming.Browser.DefaultBookmarks) + merged.Browser.Cores = backupMergeCores(merged.Browser.Cores, incoming.Browser.Cores) + merged.Browser.Proxies = backupMergeProxies(merged.Browser.Proxies, incoming.Browser.Proxies) + merged.Browser.Profiles = backupMergeProfiles(merged.Browser.Profiles, incoming.Browser.Profiles) + return &merged +} +func (a *App) backupMergeProxiesFile(payloadRoot string, resetFirst bool, stats *backupMergeStats) error { + srcPath := filepath.Join(payloadRoot, "system", "proxies.yaml") + dstPath := a.resolveAppPath("proxies.yaml") + + if _, err := os.Stat(srcPath); err != nil { + if os.IsNotExist(err) { + if resetFirst { + _ = os.Remove(dstPath) + } + return nil + } + return err + } + + if resetFirst { + return backupCopyFile(srcPath, dstPath) + } + + incoming, err := config.LoadProxies(srcPath) + if err != nil { + return err + } + current, err := config.LoadProxies(dstPath) + if err != nil { + return err + } + + merged := append([]config.BrowserProxy{}, current...) + existingID := make(map[string]struct{}, len(current)) + existingCfg := make(map[string]struct{}, len(current)) + for _, p := range current { + existingID[strings.ToLower(strings.TrimSpace(p.ProxyId))] = struct{}{} + existingCfg[strings.ToLower(strings.TrimSpace(p.ProxyConfig))] = struct{}{} + } + for _, p := range incoming { + idKey := strings.ToLower(strings.TrimSpace(p.ProxyId)) + cfgKey := strings.ToLower(strings.TrimSpace(p.ProxyConfig)) + if _, ok := existingID[idKey]; ok { + stats.Skipped++ + continue + } + if cfgKey != "" { + if _, ok := existingCfg[cfgKey]; ok { + stats.Skipped++ + continue + } + } + merged = append(merged, p) + existingID[idKey] = struct{}{} + if cfgKey != "" { + existingCfg[cfgKey] = struct{}{} + } + stats.Imported++ + } + + return config.SaveProxies(dstPath, merged) +} + +func backupFindDatabaseFile(payloadRoot string) string { + candidates := []string{ + filepath.Join(payloadRoot, "app", "database", "app.db"), + filepath.Join(payloadRoot, "app", "data", "app.db"), + } + for _, p := range candidates { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p + } + } + return "" +} + +func (a *App) backupMergeDatabaseFromSource(srcDBPath string, resetFirst bool, stats *backupMergeStats) error { + if a.db == nil || a.db.GetConn() == nil { + return fmt.Errorf("数据库未初始化") + } + tx, err := a.db.GetConn().Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`ATTACH DATABASE ? AS src`, srcDBPath); err != nil { + return fmt.Errorf("挂载备份数据库失败: %w", err) + } + defer tx.Exec(`DETACH DATABASE src`) + + mergeTables := []struct { + name string + insertAll string + insertSafe string + }{ + { + name: "browser_groups", + insertAll: `INSERT INTO browser_groups (group_id, group_name, parent_id, sort_order, created_at, updated_at) +SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at FROM src.browser_groups`, + insertSafe: `INSERT INTO browser_groups (group_id, group_name, parent_id, sort_order, created_at, updated_at) +SELECT s.group_id, s.group_name, s.parent_id, s.sort_order, s.created_at, s.updated_at +FROM src.browser_groups s +WHERE NOT EXISTS ( + SELECT 1 FROM browser_groups t + WHERE t.group_id = s.group_id OR (t.parent_id = s.parent_id AND lower(t.group_name) = lower(s.group_name)) +)`, + }, + { + name: "browser_cores", + insertAll: `INSERT INTO browser_cores (core_id, core_name, core_path, is_default, sort_order, created_at) +SELECT core_id, core_name, core_path, is_default, sort_order, created_at FROM src.browser_cores`, + insertSafe: `INSERT INTO browser_cores (core_id, core_name, core_path, is_default, sort_order, created_at) +SELECT s.core_id, s.core_name, s.core_path, s.is_default, s.sort_order, s.created_at +FROM src.browser_cores s +WHERE NOT EXISTS ( + SELECT 1 FROM browser_cores t + WHERE t.core_id = s.core_id OR lower(t.core_path) = lower(s.core_path) +)`, + }, + { + name: "browser_proxies", + insertAll: `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, last_latency_ms, last_test_ok, last_tested_at, last_ip_health_json, sort_order, created_at) +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, created_at +FROM src.browser_proxies`, + insertSafe: `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, last_latency_ms, last_test_ok, last_tested_at, last_ip_health_json, sort_order, created_at) +SELECT s.proxy_id, s.proxy_name, s.proxy_config, s.dns_servers, COALESCE(s.group_name,''), COALESCE(s.source_id,''), COALESCE(s.source_url,''), COALESCE(s.source_name_prefix,''), COALESCE(s.source_auto_refresh,0), COALESCE(s.source_refresh_interval_m,0), COALESCE(s.source_last_refresh_at,''), COALESCE(s.last_latency_ms,-1), COALESCE(s.last_test_ok,0), COALESCE(s.last_tested_at,''), COALESCE(s.last_ip_health_json,''), s.sort_order, s.created_at +FROM src.browser_proxies s +WHERE NOT EXISTS ( + SELECT 1 FROM browser_proxies t + WHERE t.proxy_id = s.proxy_id OR lower(t.proxy_config) = lower(s.proxy_config) +)`, + }, + { + name: "browser_profiles", + insertAll: `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) +SELECT profile_id, profile_name, user_data_dir, core_id, fingerprint_args, proxy_id, proxy_config, launch_args, tags, keywords, COALESCE(group_id,''), created_at, updated_at +FROM src.browser_profiles`, + insertSafe: `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) +SELECT s.profile_id, s.profile_name, s.user_data_dir, s.core_id, s.fingerprint_args, s.proxy_id, s.proxy_config, s.launch_args, s.tags, s.keywords, COALESCE(s.group_id,''), s.created_at, s.updated_at +FROM src.browser_profiles s +WHERE NOT EXISTS ( + SELECT 1 FROM browser_profiles t + WHERE t.profile_id = s.profile_id OR lower(t.user_data_dir) = lower(s.user_data_dir) +)`, + }, + { + name: "browser_bookmarks", + insertAll: `INSERT INTO browser_bookmarks (name, url, sort_order) +SELECT name, url, sort_order FROM src.browser_bookmarks`, + insertSafe: `INSERT INTO browser_bookmarks (name, url, sort_order) +SELECT s.name, s.url, s.sort_order +FROM src.browser_bookmarks s +WHERE NOT EXISTS ( + SELECT 1 FROM browser_bookmarks t WHERE lower(t.url) = lower(s.url) +)`, + }, + { + name: "launch_codes", + insertAll: `INSERT INTO launch_codes (profile_id, code, created_at, updated_at) +SELECT profile_id, code, created_at, updated_at FROM src.launch_codes`, + insertSafe: `INSERT INTO launch_codes (profile_id, code, created_at, updated_at) +SELECT s.profile_id, s.code, s.created_at, s.updated_at +FROM src.launch_codes s +WHERE NOT EXISTS ( + SELECT 1 FROM launch_codes t + WHERE t.profile_id = s.profile_id OR t.code = s.code +)`, + }, + } + + for _, item := range mergeTables { + exists, err := backupSrcTableExists(tx, item.name) + if err != nil { + return err + } + if !exists { + continue + } + + total, err := backupCountRows(tx, "src."+item.name) + if err != nil { + return err + } + if total == 0 { + continue + } + + sqlText := item.insertAll + if !resetFirst { + sqlText = item.insertSafe + } + res, err := tx.Exec(sqlText) + if err != nil { + return fmt.Errorf("导入数据表失败(%s): %w", item.name, err) + } + affected, _ := res.RowsAffected() + inserted := int(affected) + if inserted < 0 { + inserted = total + } + stats.Imported += inserted + if !resetFirst && total > inserted { + stats.Skipped += total - inserted + } + } + + return tx.Commit() +} + +func (a *App) backupImportFileTrees(payloadRoot string, incomingCfg *config.Config, resetFirst bool, stats *backupMergeStats, onIssue func(componentID, componentName string, err error)) { + report := func(componentID, componentName string, err error) { + if onIssue != nil && err != nil { + onIssue(componentID, componentName, err) + } + } + + appDataSrc := filepath.Join(payloadRoot, "app", "data") + appDataDst := a.resolveAppPath("data") + dbPath := a.backupResolveDBPath(a.config) + keepDB := map[string]struct{}{ + backupNormalizePath(dbPath): {}, + backupNormalizePath(dbPath + "-wal"): {}, + backupNormalizePath(dbPath + "-shm"): {}, + } + + if backupPathExists(appDataSrc) { + if resetFirst { + if err := backupRemoveContentsExcept(appDataDst, keepDB); err != nil { + report("app_data_root", "应用数据目录(含数据库、快照及默认浏览器数据)", err) + } else if err := backupSyncDir(appDataSrc, appDataDst, true, stats, backupShouldSkipAppDBFile); err != nil { + report("app_data_root", "应用数据目录(含数据库、快照及默认浏览器数据)", err) + } + } else { + if err := backupSyncDir(appDataSrc, appDataDst, false, stats, backupShouldSkipAppDBFile); err != nil { + report("app_data_root", "应用数据目录(含数据库、快照及默认浏览器数据)", err) + } + } + } + + userDataSrc := filepath.Join(payloadRoot, "browser", "user-data") + userDataDst := a.backupResolveUserDataRoot(a.config) + if backupPathExists(userDataSrc) { + if resetFirst { + _ = os.RemoveAll(userDataDst) + if err := os.MkdirAll(userDataDst, 0755); err != nil { + report("browser_user_data_root", "浏览器用户数据根目录(若与 data 重合则自动去重)", err) + } else if err := backupSyncDir(userDataSrc, userDataDst, true, stats, nil); err != nil { + report("browser_user_data_root", "浏览器用户数据根目录(若与 data 重合则自动去重)", err) + } + } else { + if err := backupSyncDir(userDataSrc, userDataDst, false, stats, nil); err != nil { + report("browser_user_data_root", "浏览器用户数据根目录(若与 data 重合则自动去重)", err) + } + } + } + + chromeSrc := filepath.Join(payloadRoot, "browser", "cores", "chrome") + chromeDst := a.resolveAppPath("chrome") + if backupPathExists(chromeSrc) { + if resetFirst { + _ = os.RemoveAll(chromeDst) + if err := os.MkdirAll(chromeDst, 0755); err != nil { + report("browser_core_root", "默认内核目录", err) + } else if err := backupSyncDir(chromeSrc, chromeDst, true, stats, nil); err != nil { + report("browser_core_root", "默认内核目录", err) + } + } else { + if err := backupSyncDir(chromeSrc, chromeDst, false, stats, nil); err != nil { + report("browser_core_root", "默认内核目录", err) + } + } + } + + externalSrcRoot := filepath.Join(payloadRoot, "browser", "cores", "external") + if backupPathExists(externalSrcRoot) { + sourceExternal := make([]string, 0) + entries, err := os.ReadDir(externalSrcRoot) + if err != nil { + report("browser_core_external", "额外内核目录(来自配置 cores)", err) + return + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + sourceExternal = append(sourceExternal, entry.Name()) + } + sort.Strings(sourceExternal) + + if incomingCfg == nil { + for _, folder := range sourceExternal { + componentID := "browser_core_external_" + folder + report(componentID, "额外内核目录(来自配置 cores)", fmt.Errorf("缺少可用配置,无法映射目标路径")) + } + return + } + + targetExternal := a.backupCollectExternalCorePaths(incomingCfg) + for i, folder := range sourceExternal { + src := filepath.Join(externalSrcRoot, folder) + componentID := "browser_core_external_" + folder + if i >= len(targetExternal) { + stats.Skipped++ + report(componentID, "额外内核目录(来自配置 cores)", fmt.Errorf("目标配置缺失,无法导入该外部内核目录")) + continue + } + dst := targetExternal[i] + if resetFirst { + _ = os.RemoveAll(dst) + if err := os.MkdirAll(dst, 0755); err != nil { + report(componentID, "额外内核目录(来自配置 cores)", err) + continue + } + if err := backupSyncDir(src, dst, true, stats, nil); err != nil { + report(componentID, "额外内核目录(来自配置 cores)", err) + continue + } + } else { + if err := backupSyncDir(src, dst, false, stats, nil); err != nil { + report(componentID, "额外内核目录(来自配置 cores)", err) + continue + } + } + } + } +} + +func (a *App) backupCollectExternalCorePaths(cfg *config.Config) []string { + if cfg == nil { + return nil + } + defaultChromeRoot := a.resolveAppPath("chrome") + seen := map[string]struct{}{} + result := make([]string, 0) + for _, core := range cfg.Browser.Cores { + p := strings.TrimSpace(core.CorePath) + if p == "" { + continue + } + abs := a.resolveAppPath(p) + if backupPathWithin(abs, defaultChromeRoot) { + continue + } + norm := backupNormalizePath(abs) + if _, ok := seen[norm]; ok { + continue + } + seen[norm] = struct{}{} + result = append(result, abs) + } + sort.Strings(result) + return result +} +func backupSyncDir(src, dst string, overwrite bool, stats *backupMergeStats, shouldSkip func(rel string) bool) error { + if !backupPathExists(src) { + return nil + } + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&os.ModeSymlink != 0 { + stats.Skipped++ + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + rel = filepath.ToSlash(rel) + if shouldSkip != nil && shouldSkip(rel) { + if d.IsDir() { + return filepath.SkipDir + } + stats.Skipped++ + return nil + } + + target := filepath.Join(dst, filepath.FromSlash(rel)) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + if overwrite { + if err := backupCopyFile(path, target); err != nil { + return err + } + stats.Imported++ + return nil + } + + if _, err := os.Stat(target); os.IsNotExist(err) { + if err := backupCopyFile(path, target); err != nil { + return err + } + stats.Imported++ + return nil + } else if err != nil { + return err + } + + same, err := backupFilesSame(path, target) + if err != nil { + return err + } + if same { + stats.Skipped++ + } else { + stats.Conflicts++ + } + return nil + }) +} + +func backupCopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + tmpPath := dst + ".tmp" + out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + _ = os.Remove(tmpPath) + return err + } + if err := out.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, dst); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil +} + +func backupFilesSame(a, b string) (bool, error) { + ainfo, err := os.Stat(a) + if err != nil { + return false, err + } + binfo, err := os.Stat(b) + if err != nil { + return false, err + } + if ainfo.Size() != binfo.Size() { + return false, nil + } + ah, err := backupSHA256File(a) + if err != nil { + return false, err + } + bh, err := backupSHA256File(b) + if err != nil { + return false, err + } + return ah == bh, nil +} + +func backupSHA256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func backupShouldSkipAppDBFile(rel string) bool { + r := strings.TrimSpace(filepath.ToSlash(rel)) + return r == "app.db" || r == "app.db-wal" || r == "app.db-shm" +} + +func backupRemoveContentsExcept(dir string, keep map[string]struct{}) error { + dir = strings.TrimSpace(dir) + if dir == "" { + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + p := filepath.Join(dir, entry.Name()) + if backupPathInSet(p, keep) { + continue + } + if err := os.RemoveAll(p); err != nil { + return err + } + } + return nil +} + +func backupPathInSet(path string, set map[string]struct{}) bool { + if len(set) == 0 { + return false + } + _, ok := set[backupNormalizePath(path)] + return ok +} + +func backupNormalizePath(path string) string { + return strings.ToLower(filepath.Clean(strings.TrimSpace(path))) +} + +func backupEnsureZipSuffix(path string) string { + if strings.EqualFold(filepath.Ext(path), ".zip") { + return path + } + return path + ".zip" +} + +func backupPathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func backupSamePath(a, b string) bool { + return backupNormalizePath(a) == backupNormalizePath(b) +} + +func backupPathWithin(path, root string) bool { + p := backupNormalizePath(path) + r := backupNormalizePath(root) + if p == r { + return true + } + if !strings.HasSuffix(r, string(filepath.Separator)) { + r += string(filepath.Separator) + } + return strings.HasPrefix(p, r) +} + +func backupIsNoSuchTableError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "no such table") +} + +func backupUniqueNonEmpty(list []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(list)) + for _, item := range list { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := backupNormalizePath(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func backupUnionStrings(a, b []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(a)+len(b)) + for _, item := range append(append([]string{}, a...), b...) { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func backupMergeBookmarks(a, b []config.BrowserBookmark) []config.BrowserBookmark { + seen := map[string]struct{}{} + out := make([]config.BrowserBookmark, 0, len(a)+len(b)) + appendOne := func(item config.BrowserBookmark) { + urlKey := strings.ToLower(strings.TrimSpace(item.URL)) + if urlKey == "" { + return + } + if _, ok := seen[urlKey]; ok { + return + } + seen[urlKey] = struct{}{} + out = append(out, item) + } + for _, item := range a { + appendOne(item) + } + for _, item := range b { + appendOne(item) + } + return out +} + +func backupMergeCores(a, b []config.BrowserCore) []config.BrowserCore { + seenID := map[string]struct{}{} + seenPath := map[string]struct{}{} + out := make([]config.BrowserCore, 0, len(a)+len(b)) + appendOne := func(item config.BrowserCore) { + idKey := strings.ToLower(strings.TrimSpace(item.CoreId)) + pathKey := strings.ToLower(strings.TrimSpace(item.CorePath)) + if idKey == "" && pathKey == "" { + return + } + if idKey != "" { + if _, ok := seenID[idKey]; ok { + return + } + } + if pathKey != "" { + if _, ok := seenPath[pathKey]; ok { + return + } + } + if idKey != "" { + seenID[idKey] = struct{}{} + } + if pathKey != "" { + seenPath[pathKey] = struct{}{} + } + out = append(out, item) + } + for _, item := range a { + appendOne(item) + } + for _, item := range b { + appendOne(item) + } + return out +} + +func backupMergeProxies(a, b []config.BrowserProxy) []config.BrowserProxy { + seenID := map[string]struct{}{} + seenCfg := map[string]struct{}{} + out := make([]config.BrowserProxy, 0, len(a)+len(b)) + appendOne := func(item config.BrowserProxy) { + idKey := strings.ToLower(strings.TrimSpace(item.ProxyId)) + cfgKey := strings.ToLower(strings.TrimSpace(item.ProxyConfig)) + if idKey == "" && cfgKey == "" { + return + } + if idKey != "" { + if _, ok := seenID[idKey]; ok { + return + } + } + if cfgKey != "" { + if _, ok := seenCfg[cfgKey]; ok { + return + } + } + if idKey != "" { + seenID[idKey] = struct{}{} + } + if cfgKey != "" { + seenCfg[cfgKey] = struct{}{} + } + out = append(out, item) + } + for _, item := range a { + appendOne(item) + } + for _, item := range b { + appendOne(item) + } + return out +} + +func backupMergeProfiles(a, b []config.BrowserProfileConfig) []config.BrowserProfileConfig { + seenID := map[string]struct{}{} + seenDir := map[string]struct{}{} + out := make([]config.BrowserProfileConfig, 0, len(a)+len(b)) + appendOne := func(item config.BrowserProfileConfig) { + idKey := strings.ToLower(strings.TrimSpace(item.ProfileId)) + dirKey := strings.ToLower(strings.TrimSpace(item.UserDataDir)) + if idKey == "" && dirKey == "" { + return + } + if idKey != "" { + if _, ok := seenID[idKey]; ok { + return + } + } + if dirKey != "" { + if _, ok := seenDir[dirKey]; ok { + return + } + } + if idKey != "" { + seenID[idKey] = struct{}{} + } + if dirKey != "" { + seenDir[dirKey] = struct{}{} + } + out = append(out, item) + } + for _, item := range a { + appendOne(item) + } + for _, item := range b { + appendOne(item) + } + return out +} + +func backupSrcTableExists(tx *sql.Tx, table string) (bool, error) { + var cnt int + err := tx.QueryRow(`SELECT COUNT(1) FROM src.sqlite_master WHERE type='table' AND name=?`, table).Scan(&cnt) + if err != nil { + return false, err + } + return cnt > 0, nil +} + +func backupCountRows(tx *sql.Tx, tableName string) (int, error) { + var cnt int + row := tx.QueryRow("SELECT COUNT(1) FROM " + tableName) + if err := row.Scan(&cnt); err != nil { + return 0, err + } + return cnt, nil +} diff --git a/backend/app_backup_ops_test.go b/backend/app_backup_ops_test.go new file mode 100644 index 00000000..002c2b81 --- /dev/null +++ b/backend/app_backup_ops_test.go @@ -0,0 +1,128 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "os" + "path/filepath" + "testing" +) + +func TestBackupEnsureZipSuffix(t *testing.T) { + if got := backupEnsureZipSuffix("c:/tmp/a.zip"); got != "c:/tmp/a.zip" { + t.Fatalf("zip 后缀重复追加: %s", got) + } + if got := backupEnsureZipSuffix("c:/tmp/a"); got != "c:/tmp/a.zip" { + t.Fatalf("zip 后缀追加失败: %s", got) + } +} + +func TestBackupMergeConfigDedup(t *testing.T) { + current := config.DefaultConfig() + current.App.MaxProfileLimit = 12 + current.App.UsedCDKeys = []string{"A1", "B2"} + current.Browser.DefaultBookmarks = []config.BrowserBookmark{ + {Name: "Google", URL: "https://www.google.com/"}, + } + current.Browser.Proxies = []config.BrowserProxy{ + {ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://127.0.0.1:7890"}, + } + current.Browser.Cores = []config.BrowserCore{ + {CoreId: "c1", CoreName: "C1", CorePath: "chrome/c1"}, + } + current.Browser.Profiles = []config.BrowserProfileConfig{ + {ProfileId: "u1", ProfileName: "U1", UserDataDir: "u1"}, + } + + incoming := config.DefaultConfig() + incoming.App.UsedCDKeys = []string{"b2", "C3"} + incoming.Browser.DefaultBookmarks = []config.BrowserBookmark{ + {Name: "Google Dup", URL: "https://www.google.com/"}, + {Name: "ChatGPT", URL: "https://chatgpt.com/"}, + } + incoming.Browser.Proxies = []config.BrowserProxy{ + {ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "p2", ProxyName: "P2", ProxyConfig: "socks5://127.0.0.1:1080"}, + } + incoming.Browser.Cores = []config.BrowserCore{ + {CoreId: "c1", CoreName: "C1 Dup", CorePath: "chrome/c1"}, + {CoreId: "c2", CoreName: "C2", CorePath: "chrome/c2"}, + } + incoming.Browser.Profiles = []config.BrowserProfileConfig{ + {ProfileId: "u1", ProfileName: "U1 Dup", UserDataDir: "u1"}, + {ProfileId: "u2", ProfileName: "U2", UserDataDir: "u2"}, + } + + merged := backupMergeConfig(current, incoming) + if merged == nil { + t.Fatalf("merged 为空") + } + + if merged.App.MaxProfileLimit != 12 { + t.Fatalf("license limit 不应被导入配置改写: got=%d", merged.App.MaxProfileLimit) + } + if len(merged.App.UsedCDKeys) != 2 { + t.Fatalf("used cd keys 不应被导入配置改写: %+v", merged.App.UsedCDKeys) + } + if len(merged.Browser.DefaultBookmarks) != 2 { + t.Fatalf("bookmarks 判重失败: %+v", merged.Browser.DefaultBookmarks) + } + if len(merged.Browser.Proxies) != 2 { + t.Fatalf("proxies 判重失败: %+v", merged.Browser.Proxies) + } + if len(merged.Browser.Cores) != 2 { + t.Fatalf("cores 判重失败: %+v", merged.Browser.Cores) + } + if len(merged.Browser.Profiles) != 2 { + t.Fatalf("profiles 判重失败: %+v", merged.Browser.Profiles) + } +} + +func TestBackupSyncDirConflictAndOverwrite(t *testing.T) { + src := filepath.Join(t.TempDir(), "src") + dst := filepath.Join(t.TempDir(), "dst") + if err := os.MkdirAll(src, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dst, 0755); err != nil { + t.Fatal(err) + } + + srcFile := filepath.Join(src, "a.txt") + dstFile := filepath.Join(dst, "a.txt") + if err := os.WriteFile(srcFile, []byte("new-content"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dstFile, []byte("old-content"), 0644); err != nil { + t.Fatal(err) + } + + stats := &backupMergeStats{} + if err := backupSyncDir(src, dst, false, stats, nil); err != nil { + t.Fatal(err) + } + if stats.Conflicts != 1 || stats.Imported != 0 { + t.Fatalf("非覆盖模式统计异常: %+v", stats) + } + got, err := os.ReadFile(dstFile) + if err != nil { + t.Fatal(err) + } + if string(got) != "old-content" { + t.Fatalf("非覆盖模式不应改写目标文件: %s", string(got)) + } + + stats2 := &backupMergeStats{} + if err := backupSyncDir(src, dst, true, stats2, nil); err != nil { + t.Fatal(err) + } + if stats2.Imported != 1 { + t.Fatalf("覆盖模式导入统计异常: %+v", stats2) + } + got2, err := os.ReadFile(dstFile) + if err != nil { + t.Fatal(err) + } + if string(got2) != "new-content" { + t.Fatalf("覆盖模式应改写目标文件: %s", string(got2)) + } +} diff --git a/backend/app_bookmark.go b/backend/app_bookmark.go new file mode 100644 index 00000000..2bb3be4c --- /dev/null +++ b/backend/app_bookmark.go @@ -0,0 +1,64 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" +) + +type BrowserBookmark = config.BrowserBookmark + +var defaultBookmarkList = []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/"}, +} + +// BookmarkList 获取默认书签列表(优先 SQLite,降级 config.yaml) +func (a *App) BookmarkList() []BrowserBookmark { + if a.browserMgr.BookmarkDAO != nil { + list, err := a.browserMgr.BookmarkDAO.List() + if err == nil && len(list) > 0 { + return list + } + } + if len(a.config.Browser.DefaultBookmarks) > 0 { + return append([]BrowserBookmark{}, a.config.Browser.DefaultBookmarks...) + } + return append([]BrowserBookmark{}, defaultBookmarkList...) +} + +// BookmarkSave 保存默认书签列表(优先 SQLite,降级 config.yaml) +func (a *App) BookmarkSave(items []BrowserBookmark) error { + log := logger.New("Bookmark") + valid := make([]BrowserBookmark, 0, len(items)) + for _, item := range items { + if item.Name != "" && item.URL != "" { + valid = append(valid, item) + } + } + + if a.browserMgr.BookmarkDAO != nil { + if err := a.browserMgr.BookmarkDAO.ReplaceAll(valid); err != nil { + log.Error("书签保存到数据库失败", logger.F("error", err.Error())) + return err + } + log.Info("书签已保存到数据库", logger.F("count", len(valid))) + return nil + } + + // 降级:写入 config.yaml + a.config.Browser.DefaultBookmarks = valid + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + log.Error("书签保存失败", logger.F("error", err.Error())) + return err + } + log.Info("书签已保存到 config.yaml", logger.F("count", len(valid))) + return nil +} + +// BookmarkReset 恢复默认书签 +func (a *App) BookmarkReset() error { + return a.BookmarkSave(append([]BrowserBookmark{}, defaultBookmarkList...)) +} diff --git a/backend/app_cookie.go b/backend/app_cookie.go new file mode 100644 index 00000000..a40e6120 --- /dev/null +++ b/backend/app_cookie.go @@ -0,0 +1,184 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +// ============================================================================ +// Cookie 管理 API(通过 CDP) +// ============================================================================ + +// CookieInfo 表示单条浏览器 Cookie +type CookieInfo struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path"` + Expires float64 `json:"expires"` + HttpOnly bool `json:"httpOnly"` + Secure bool `json:"secure"` + SameSite string `json:"sameSite"` +} + +// cdpTarget 表示 /json 接口返回的调试目标 +type cdpTarget struct { + WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"` + Type string `json:"type"` +} + +// cdpMessage 是 CDP 协议消息结构 +type cdpMessage struct { + Id int `json:"id"` + Method string `json:"method,omitempty"` + Params map[string]any `json:"params,omitempty"` +} + +// cdpResponse 是 CDP 协议响应结构 +type cdpResponse struct { + Id int `json:"id"` + Result map[string]any `json:"result,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// cdpCall 向指定 debugPort 发送单次 CDP 命令并返回 result 字段 +func cdpCall(debugPort int, method string, params map[string]any) (map[string]any, error) { + // 1. 获取 WebSocket 调试地址 + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json", debugPort)) + if err != nil { + return nil, fmt.Errorf("CDP /json 请求失败: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var targets []cdpTarget + if err := json.Unmarshal(body, &targets); err != nil || len(targets) == 0 { + return nil, fmt.Errorf("CDP targets 解析失败或为空") + } + + wsURL := "" + for _, t := range targets { + if t.Type == "page" && t.WebSocketDebuggerUrl != "" { + wsURL = t.WebSocketDebuggerUrl + break + } + } + if wsURL == "" && targets[0].WebSocketDebuggerUrl != "" { + wsURL = targets[0].WebSocketDebuggerUrl + } + if wsURL == "" { + return nil, fmt.Errorf("未找到可用的 WebSocket 调试地址") + } + + // 2. 建立 WebSocket 连接 + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return nil, fmt.Errorf("WebSocket 连接失败: %w", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + // 3. 发送 CDP 命令 + msg := cdpMessage{Id: 1, Method: method, Params: params} + if err := conn.WriteJSON(msg); err != nil { + return nil, fmt.Errorf("CDP 命令发送失败: %w", err) + } + + // 4. 等待响应 + var cdpResp cdpResponse + if err := conn.ReadJSON(&cdpResp); err != nil { + return nil, fmt.Errorf("CDP 响应读取失败: %w", err) + } + if cdpResp.Error != nil { + return nil, fmt.Errorf("CDP 错误: %s", cdpResp.Error.Message) + } + return cdpResp.Result, nil +} + +// getDebugPort 获取运行中实例的调试端口 +func (a *App) getDebugPort(profileId string) (int, error) { + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + return 0, fmt.Errorf("profile not found: %s", profileId) + } + if !profile.Running || profile.DebugPort == 0 { + return 0, fmt.Errorf("实例未运行或调试端口不可用") + } + return profile.DebugPort, nil +} + +// BrowserGetCookies 通过 CDP 获取实例所有 Cookie +func (a *App) BrowserGetCookies(profileId string) ([]CookieInfo, error) { + debugPort, err := a.getDebugPort(profileId) + if err != nil { + return nil, err + } + + result, err := cdpCall(debugPort, "Network.getAllCookies", nil) + if err != nil { + return nil, err + } + + cookiesRaw, ok := result["cookies"] + if !ok { + return []CookieInfo{}, nil + } + + // 通过 JSON 二次解析 + data, _ := json.Marshal(cookiesRaw) + var cookies []CookieInfo + if err := json.Unmarshal(data, &cookies); err != nil { + return nil, fmt.Errorf("Cookie 解析失败: %w", err) + } + return cookies, nil +} + +// BrowserClearCookies 通过 CDP 清除实例所有 Cookie +func (a *App) BrowserClearCookies(profileId string) error { + debugPort, err := a.getDebugPort(profileId) + if err != nil { + return err + } + _, err = cdpCall(debugPort, "Network.clearBrowserCookies", nil) + return err +} + +// BrowserExportCookies 导出 Netscape 格式 Cookie 字符串 +func (a *App) BrowserExportCookies(profileId string) (string, error) { + cookies, err := a.BrowserGetCookies(profileId) + if err != nil { + return "", err + } + + var sb strings.Builder + sb.WriteString("# Netscape HTTP Cookie File\n") + sb.WriteString("# Generated by BrowserManager\n\n") + + for _, c := range cookies { + includeSubdomains := "FALSE" + if strings.HasPrefix(c.Domain, ".") { + includeSubdomains = "TRUE" + } + secure := "FALSE" + if c.Secure { + secure = "TRUE" + } + expires := int64(c.Expires) + if expires < 0 { + expires = 0 + } + sb.WriteString(fmt.Sprintf("%s\t%s\t%s\t%s\t%d\t%s\t%s\n", + c.Domain, includeSubdomains, c.Path, secure, expires, c.Name, c.Value)) + } + return sb.String(), nil +} diff --git a/backend/app_group.go b/backend/app_group.go new file mode 100644 index 00000000..cacc34cb --- /dev/null +++ b/backend/app_group.go @@ -0,0 +1,115 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/logger" + "fmt" +) + +// ============================================================================ +// 分组类型别名 (保持 Wails 绑定兼容) +// ============================================================================ + +type BrowserGroup = browser.Group +type BrowserGroupInput = browser.GroupInput +type BrowserGroupWithCount = browser.GroupWithCount + +// ============================================================================ +// 分组管理 API +// ============================================================================ + +// ListGroups 获取所有分组(带实例计数) +func (a *App) ListGroups() []BrowserGroupWithCount { + log := logger.New("Group") + if a.browserMgr.GroupDAO == nil { + log.Error("GroupDAO 未初始化") + return []BrowserGroupWithCount{} + } + + groups, err := a.browserMgr.GroupDAO.List() + if err != nil { + log.Error("获取分组列表失败", logger.F("error", err)) + return []BrowserGroupWithCount{} + } + + // 统计每个分组的实例数量 + profiles, _ := a.browserMgr.ProfileDAO.List() + countMap := make(map[string]int) + for _, p := range profiles { + if p.GroupId != "" { + countMap[p.GroupId]++ + } + } + + result := make([]BrowserGroupWithCount, 0, len(groups)) + for _, g := range groups { + result = append(result, BrowserGroupWithCount{ + Group: *g, + InstanceCount: countMap[g.GroupId], + }) + } + return result +} + +// CreateGroup 创建分组 +func (a *App) CreateGroup(input BrowserGroupInput) (*BrowserGroup, error) { + log := logger.New("Group") + if a.browserMgr.GroupDAO == nil { + return nil, fmt.Errorf("GroupDAO 未初始化") + } + + group, err := a.browserMgr.GroupDAO.Create(input) + if err != nil { + log.Error("创建分组失败", logger.F("error", err)) + return nil, err + } + log.Info("分组已创建", logger.F("group_id", group.GroupId), logger.F("group_name", group.GroupName)) + return group, nil +} + +// UpdateGroup 更新分组 +func (a *App) UpdateGroup(groupId string, input BrowserGroupInput) (*BrowserGroup, error) { + log := logger.New("Group") + if a.browserMgr.GroupDAO == nil { + return nil, fmt.Errorf("GroupDAO 未初始化") + } + + group, err := a.browserMgr.GroupDAO.Update(groupId, input) + if err != nil { + log.Error("更新分组失败", logger.F("group_id", groupId), logger.F("error", err)) + return nil, err + } + log.Info("分组已更新", logger.F("group_id", groupId)) + return group, nil +} + +// DeleteGroup 删除分组 +func (a *App) DeleteGroup(groupId string) error { + log := logger.New("Group") + if a.browserMgr.GroupDAO == nil { + return fmt.Errorf("GroupDAO 未初始化") + } + + if err := a.browserMgr.GroupDAO.Delete(groupId); err != nil { + log.Error("删除分组失败", logger.F("group_id", groupId), logger.F("error", err)) + return err + } + log.Info("分组已删除", logger.F("group_id", groupId)) + return nil +} + +// MoveInstancesToGroup 批量移动实例到分组 +func (a *App) MoveInstancesToGroup(profileIds []string, groupId string) error { + log := logger.New("Group") + dao, ok := a.browserMgr.ProfileDAO.(*browser.SQLiteProfileDAO) + if !ok { + return fmt.Errorf("ProfileDAO 不支持批量移动") + } + + if err := dao.MoveToGroup(profileIds, groupId); err != nil { + log.Error("批量移动实例失败", logger.F("count", len(profileIds)), logger.F("error", err)) + return err + } + log.Info("实例已移动到分组", logger.F("count", len(profileIds)), logger.F("group_id", groupId)) + return nil +} diff --git a/backend/app_instance.go b/backend/app_instance.go new file mode 100644 index 00000000..2cf2f487 --- /dev/null +++ b/backend/app_instance.go @@ -0,0 +1,571 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/logger" + "ant-chrome/backend/internal/proxy" + "fmt" + "os" + "os/exec" + "path/filepath" + stdruntime "runtime" + "strings" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ============================================================================ +// 浏览器实例管理 API +// ============================================================================ + +func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) { + return a.browserInstanceStartInternal(profileId, nil, nil, false) +} + +// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库) +func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { + return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs) +} + +func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { + log := logger.New("Browser") + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + err := fmt.Errorf("实例启动失败:未找到实例配置(ID=%s)。请刷新列表后重试。", profileId) + log.Error("实例不存在", logger.F("profile_id", profileId), logger.F("reason", err.Error())) + return nil, err + } + if profile.Running { + return profile, nil + } + + proxyChanged := a.browserMgr.ApplyDefaults(profile) + if proxyChanged { + _ = a.browserMgr.SaveProfiles() + } + + chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile) + if err != nil { + startErr := fmt.Errorf("实例启动失败:%w", err) + log.Error("内核路径解析失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + return profile, startErr + } + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if err := os.MkdirAll(userDataDir, 0755); err != nil { + startErr := fmt.Errorf("实例启动失败:无法创建用户数据目录 %s。原因:%w。请检查目录权限或路径配置。", userDataDir, err) + log.Error("用户数据目录创建失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + return profile, startErr + } + // 每次启动时合并默认书签(已存在的 URL 不重复添加) + if err := browser.EnsureDefaultBookmarks(userDataDir, a.BookmarkList()); err != nil { + log.Error("默认书签写入失败", logger.F("error", err.Error())) + } + + proxies := a.getLatestProxies() + acquiredXrayBridgeKey := "" + releaseXrayBridge := false + defer func() { + if releaseXrayBridge && acquiredXrayBridgeKey != "" && a.xrayMgr != nil { + a.xrayMgr.ReleaseBridge(acquiredXrayBridgeKey) + } + }() + + // 解析实际代理配置(可能来自 proxyId 引用) + resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig) + if profile.ProxyId != "" { + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, profile.ProxyId) { + resolvedProxyConfig = strings.TrimSpace(item.ProxyConfig) + break + } + } + } + effectiveProxy := resolvedProxyConfig + log.Info("代理配置检查", + logger.F("profile_id", profileId), + logger.F("proxy_id", profile.ProxyId), + logger.F("profile_proxy_config", profile.ProxyConfig), + logger.F("resolved_proxy_config", resolvedProxyConfig), + ) + if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported { + startErr := fmt.Errorf("实例启动失败:%s", errorMsg) + profile.LastError = startErr.Error() + log.Error("代理配置无效", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId), logger.F("error", errorMsg), logger.F("reason", startErr.Error())) + return profile, startErr + } + + if proxy.IsSingBoxProtocol(resolvedProxyConfig) { + // hysteria2 / tuic → sing-box 桥接 + socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId) + if bridgeErr != nil { + startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box.exe 是否存在,以及本地端口是否被占用。", bridgeErr) + log.Error("代理桥接失败(sing-box)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{ + "profileId": profileId, + "profileName": profile.ProfileName, + "error": startErr.Error(), + }) + } + return profile, startErr + } + effectiveProxy = socksURL + log.Info("sing-box 桥接成功", logger.F("socks_url", socksURL)) + } else if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) { + // vmess / vless / trojan / ss → xray 桥接 + socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId) + if bridgeErr != nil { + startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray.exe 是否存在,以及本地端口是否被占用。", bridgeErr) + log.Error("代理桥接失败(xray)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{ + "profileId": profileId, + "profileName": profile.ProfileName, + "error": startErr.Error(), + }) + } + return profile, startErr + } + acquiredXrayBridgeKey = bridgeKey + releaseXrayBridge = bridgeKey != "" + effectiveProxy = socksURL + log.Info("xray 桥接成功", logger.F("socks_url", socksURL)) + } + + debugPort, err := nextAvailablePort() + if err != nil { + startErr := fmt.Errorf("实例启动失败:本地调试端口分配失败。原因:%v。请关闭占用端口的程序后重试。", err) + log.Error("调试端口分配失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + return profile, startErr + } + + args := []string{ + fmt.Sprintf("--user-data-dir=%s", userDataDir), + fmt.Sprintf("--remote-debugging-port=%d", debugPort), + "--disable-session-crashed-bubble", + } + + hasFingerprint := false + for _, arg := range profile.FingerprintArgs { + if strings.HasPrefix(arg, "--fingerprint=") { + hasFingerprint = true + break + } + } + if !hasFingerprint { + seed := 0 + for _, char := range profile.ProfileId { + seed = (seed << 5) - seed + int(char) + } + if seed < 0 { + seed = -seed + } + args = append(args, fmt.Sprintf("--fingerprint=%d", seed)) + } + + if effectiveProxy == "direct://" { + // 强制直连,覆盖系统全局代理 + args = append(args, "--proxy-server=direct://") + } else if effectiveProxy != "" { + args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy)) + } + args = append(args, profile.FingerprintArgs...) + args = append(args, profile.LaunchArgs...) + args = append(args, normalizeNonEmptyStrings(extraLaunchArgs)...) + + if normalizedURLs := normalizeNonEmptyStrings(startURLs); len(normalizedURLs) > 0 { + args = append(args, normalizedURLs...) + } else if !skipDefaultStartURLs { + args = browser.BuildLaunchArgs(args, profile) + } + + cmd := exec.Command(chromeBinaryPath, args...) + hideWindow(cmd) + cmd.Dir = filepath.Dir(chromeBinaryPath) + if err := cmd.Start(); err != nil { + startErr := fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) + log.Error("浏览器进程启动失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + return profile, startErr + } + if err := waitBrowserDebugPortReady(debugPort, browserStartReadyTimeout); err != nil { + startErr := fmt.Errorf("%s", describeBrowserReadyTimeout(debugPort, browserStartReadyTimeout)) + log.Error("浏览器启动未就绪", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error())) + _ = a.stopProcessCmd(cmd) + go func() { + _ = cmd.Wait() + }() + profile.LastError = startErr.Error() + return profile, startErr + } + + a.browserMgr.BrowserProcesses[profileId] = cmd + profile.Running = true + profile.DebugPort = debugPort + profile.Pid = cmd.Process.Pid + profile.LastStartAt = time.Now().Format(time.RFC3339) + profile.LastError = "" + if acquiredXrayBridgeKey != "" { + a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) + releaseXrayBridge = false + } + + log.Info("实例启动", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("pid", profile.Pid), logger.F("proxy", effectiveProxy), logger.F("args", strings.Join(args, " "))) + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "browser:instance:started", profileId) + } + + go a.waitBrowserProcess(profileId, cmd) + return profile, nil +} + +func (a *App) BrowserInstanceStop(profileId string) (*BrowserProfile, error) { + log := logger.New("Browser") + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + return nil, fmt.Errorf("profile not found") + } + + cmd := a.browserMgr.BrowserProcesses[profileId] + if cmd != nil && cmd.Process != nil { + if err := a.stopBrowserProcess(cmd); err != nil { + log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("error", err)) + profile.LastError = err.Error() + return profile, err + } + } + + profile.Running = false + profile.LastStopAt = time.Now().Format(time.RFC3339) + delete(a.browserMgr.BrowserProcesses, profileId) + a.releaseProfileXrayBridge(profileId) + + log.Info("实例停止", logger.F("profile_id", profileId)) + return profile, nil +} + +func (a *App) BrowserInstanceRestart(profileId string) (*BrowserProfile, error) { + if _, err := a.BrowserInstanceStop(profileId); err != nil { + return nil, err + } + return a.BrowserInstanceStart(profileId) +} + +// BrowserProfileBatchSetTags 批量为实例设置标签(追加模式:将 tags 加入已有标签;replace 模式:直接替换) +func (a *App) BrowserProfileBatchSetTags(profileIds []string, tags []string, replace bool) error { + log := logger.New("Browser") + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + for _, profileId := range profileIds { + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + continue + } + if replace { + profile.Tags = tags + } else { + // 追加去重 + existing := make(map[string]struct{}) + for _, t := range profile.Tags { + existing[t] = struct{}{} + } + for _, t := range tags { + if _, ok := existing[t]; !ok { + profile.Tags = append(profile.Tags, t) + existing[t] = struct{}{} + } + } + } + profile.UpdatedAt = time.Now().Format(time.RFC3339) + if a.browserMgr.ProfileDAO != nil { + if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil { + log.Error("批量设置标签失败", logger.F("profile_id", profileId), logger.F("error", err)) + return err + } + } + } + return nil +} + +// BrowserProfileBatchRemoveTags 批量从实例移除指定标签 +func (a *App) BrowserProfileBatchRemoveTags(profileIds []string, tags []string) error { + log := logger.New("Browser") + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + removeSet := make(map[string]struct{}) + for _, t := range tags { + removeSet[t] = struct{}{} + } + + for _, profileId := range profileIds { + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + continue + } + filtered := profile.Tags[:0] + for _, t := range profile.Tags { + if _, ok := removeSet[t]; !ok { + filtered = append(filtered, t) + } + } + profile.Tags = filtered + profile.UpdatedAt = time.Now().Format(time.RFC3339) + if a.browserMgr.ProfileDAO != nil { + if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil { + log.Error("批量移除标签失败", logger.F("profile_id", profileId), logger.F("error", err)) + return err + } + } + } + return nil +} + +// BrowserRenameTag 重命名所有实例中的指定标签 +func (a *App) BrowserRenameTag(oldName string, newName string) error { + log := logger.New("Browser") + oldName = strings.TrimSpace(oldName) + newName = strings.TrimSpace(newName) + if oldName == "" || newName == "" { + return fmt.Errorf("标签名称不能为空") + } + + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + changedCount := 0 + for profileId, profile := range a.browserMgr.Profiles { + tagChanged := false + var newTags []string + for _, t := range profile.Tags { + if strings.EqualFold(t, oldName) { + newTags = append(newTags, newName) + tagChanged = true + } else { + newTags = append(newTags, t) + } + } + + if tagChanged { + // 去重 + uniqueTags := make([]string, 0) + seen := make(map[string]struct{}) + for _, t := range newTags { + if _, ok := seen[t]; !ok { + uniqueTags = append(uniqueTags, t) + seen[t] = struct{}{} + } + } + + profile.Tags = uniqueTags + profile.UpdatedAt = time.Now().Format(time.RFC3339) + if a.browserMgr.ProfileDAO != nil { + if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil { + log.Error("重命名标签保存失败", logger.F("profile_id", profileId), logger.F("error", err)) + return err + } + } + changedCount++ + } + } + + if changedCount > 0 && a.browserMgr.ProfileDAO == nil { + if err := a.browserMgr.SaveProfiles(); err != nil { + return err + } + } + + if changedCount > 0 { + log.Info("重命名标签成功", logger.F("old", oldName), logger.F("new", newName), logger.F("changed_profiles", changedCount)) + } + return nil +} + +func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) { + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + return nil, fmt.Errorf("profile not found") + } + return profile, nil +} + +func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) bool { + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + a.browserMgr.Mutex.Unlock() + if !exists || !profile.Running { + return false + } + return true +} + +func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab { + return []BrowserTab{ + {TabId: "tab-1", Title: "新标签页", Url: "about:blank", Active: true}, + {TabId: "tab-2", Title: "示例站点", Url: "https://example.com", Active: false}, + } +} + +func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) { + err := cmd.Wait() + + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + wasRunning := exists && profile.Running + if exists { + profile.Running = false + profile.LastStopAt = time.Now().Format(time.RFC3339) + } + delete(a.browserMgr.BrowserProcesses, profileId) + a.browserMgr.Mutex.Unlock() + a.releaseProfileXrayBridge(profileId) + + if a.ctx == nil { + return + } + + // 进程是正常退出(用户手动关闭)还是异常崩溃 + if wasRunning && err != nil { + log := logger.New("Browser") + // 异常退出,推送崩溃通知 + profileName := profileId + if exists { + profileName = profile.ProfileName + profile.LastError = fmt.Sprintf("实例运行异常退出:%s", err.Error()) + } + log.Error("浏览器进程异常退出", logger.F("profile_id", profileId), logger.F("profile_name", profileName), logger.F("error", err)) + runtime.EventsEmit(a.ctx, "browser:instance:crashed", map[string]interface{}{ + "profileId": profileId, + "profileName": profileName, + "error": err.Error(), + }) + } else { + runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId) + } +} + +func normalizeNonEmptyStrings(items []string) []string { + if len(items) == 0 { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + v := strings.TrimSpace(item) + if v != "" { + out = append(out, v) + } + } + return out +} + +func (a *App) stopBrowserProcess(cmd *exec.Cmd) error { + return a.stopProcessCmd(cmd) +} + +func (a *App) stopProcessCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + + // Windows 下优先非强制 taskkill,尽量让 Chromium 走正常退出路径,减少“恢复页面”提示。 + if stdruntime.GOOS == "windows" { + pid := cmd.Process.Pid + if pid > 0 { + softKillCmd := exec.Command("taskkill", "/PID", fmt.Sprintf("%d", pid), "/T") + hideWindow(softKillCmd) + if err := softKillCmd.Run(); err == nil { + if waitProcessExitWindows(pid, 3*time.Second) { + return nil + } + forceKillCmd := exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid), "/T") + hideWindow(forceKillCmd) + if forceErr := forceKillCmd.Run(); forceErr == nil { + _ = waitProcessExitWindows(pid, 2*time.Second) + return nil + } + } + } + } + + err := cmd.Process.Kill() + if err == nil || isProcessAlreadyFinished(err) { + return nil + } + return err +} + +func isProcessAlreadyFinished(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if msg == "" { + return false + } + if strings.Contains(msg, "process already finished") { + return true + } + if strings.Contains(msg, "not found") { + return true + } + if strings.Contains(msg, "no process") { + return true + } + if strings.Contains(msg, "不存在") { + return true + } + return false +} + +func waitProcessExitWindows(pid int, timeout time.Duration) bool { + if pid <= 0 { + return true + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + alive, err := isProcessAliveWindows(pid) + if err == nil && !alive { + return true + } + time.Sleep(150 * time.Millisecond) + } + alive, err := isProcessAliveWindows(pid) + if err != nil { + return false + } + return !alive +} + +func isProcessAliveWindows(pid int) (bool, error) { + cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return false, err + } + line := strings.TrimSpace(string(out)) + if line == "" { + return false, nil + } + if strings.HasPrefix(strings.ToUpper(line), "INFO:") { + return false, nil + } + token := fmt.Sprintf("\",\"%d\",", pid) + return strings.Contains(line, token), nil +} diff --git a/backend/app_instance_errors.go b/backend/app_instance_errors.go new file mode 100644 index 00000000..b7d88f44 --- /dev/null +++ b/backend/app_instance_errors.go @@ -0,0 +1,57 @@ +package backend + +import ( + "fmt" + "net" + "strings" + "time" +) + +const browserStartReadyTimeout = 10 * time.Second + +func waitBrowserDebugPortReady(debugPort int, timeout time.Duration) error { + address := fmt.Sprintf("127.0.0.1:%d", debugPort) + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", address, 250*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + time.Sleep(150 * time.Millisecond) + } + + return fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort) +} + +func describeChromeProcessStartError(chromeBinaryPath string, err error) string { + raw := strings.TrimSpace(err.Error()) + lower := strings.ToLower(raw) + + switch { + case strings.Contains(lower, "access is denied"), + strings.Contains(lower, "permission denied"), + strings.Contains(raw, "拒绝访问"): + return fmt.Sprintf("实例启动失败:系统拒绝启动浏览器进程。可执行文件:%s。请检查文件权限、杀毒软件拦截,或尝试以管理员身份运行。", chromeBinaryPath) + case strings.Contains(lower, "not a valid win32 application"), + strings.Contains(raw, "不是有效的 win32 应用程序"), + strings.Contains(raw, "不是有效的 Win32 应用程序"), + strings.Contains(raw, "bad exe format"): + return fmt.Sprintf("实例启动失败:当前浏览器内核与系统架构不兼容。可执行文件:%s。请更换为可用的 Windows 64 位 Chrome 内核。", chromeBinaryPath) + case strings.Contains(raw, "系统找不到指定的文件"), + strings.Contains(lower, "file not found"), + strings.Contains(lower, "no such file"), + strings.Contains(lower, "cannot find the file"): + return fmt.Sprintf("实例启动失败:浏览器可执行文件不存在。可执行文件:%s。请检查内核路径是否正确,或重新下载内核。", chromeBinaryPath) + case strings.Contains(raw, "目录名称无效"), + strings.Contains(lower, "directory name is invalid"): + return fmt.Sprintf("实例启动失败:浏览器工作目录无效。当前目录:%s。请检查内核路径配置是否正确。", chromeBinaryPath) + default: + return fmt.Sprintf("实例启动失败:浏览器进程拉起失败。可执行文件:%s。原因:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath, raw) + } +} + +func describeBrowserReadyTimeout(debugPort int, timeout time.Duration) string { + return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,调试端口 %d 未开启。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second), debugPort) +} diff --git a/backend/app_instance_errors_test.go b/backend/app_instance_errors_test.go new file mode 100644 index 00000000..3407ebda --- /dev/null +++ b/backend/app_instance_errors_test.go @@ -0,0 +1,48 @@ +package backend + +import ( + "fmt" + "strings" + "testing" + "time" +) + +func TestDescribeChromeProcessStartError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + { + name: "file not found", + err: fmt.Errorf("fork/exec C:\\chrome.exe: The system cannot find the file specified."), + want: "浏览器可执行文件不存在", + }, + { + name: "access denied", + err: fmt.Errorf("fork/exec C:\\chrome.exe: Access is denied."), + want: "系统拒绝启动浏览器进程", + }, + { + name: "invalid win32", + err: fmt.Errorf("%%1 is not a valid Win32 application"), + want: "与系统架构不兼容", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeChromeProcessStartError(`C:\chrome.exe`, tt.err) + if !strings.Contains(got, tt.want) { + t.Fatalf("expected %q to contain %q", got, tt.want) + } + }) + } +} + +func TestDescribeBrowserReadyTimeout(t *testing.T) { + got := describeBrowserReadyTimeout(9222, 10*time.Second) + if !strings.Contains(got, "调试端口 9222 未开启") { + t.Fatalf("unexpected timeout message: %q", got) + } +} diff --git a/backend/app_launchcode.go b/backend/app_launchcode.go new file mode 100644 index 00000000..1f8db48b --- /dev/null +++ b/backend/app_launchcode.go @@ -0,0 +1,83 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/launchcode" + "fmt" +) + +// StartInstance 实现 launchcode.BrowserStarter 接口 +func (a *App) StartInstance(profileId string) (*browser.Profile, error) { + return a.BrowserInstanceStart(profileId) +} + +// StartInstanceWithParams 实现 launchcode.BrowserStarterWithParams 接口 +func (a *App) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { + return a.BrowserInstanceStartWithParams(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs) +} + +// BrowserProfileGetCode 获取实例的 LaunchCode(Wails 绑定) +func (a *App) BrowserProfileGetCode(profileId string) (string, error) { + if a.launchCodeSvc == nil { + return "", nil + } + return a.launchCodeSvc.EnsureCode(profileId) +} + +// BrowserProfileRegenerateCode 重新生成实例的 LaunchCode(Wails 绑定) +func (a *App) BrowserProfileRegenerateCode(profileId string) (string, error) { + if a.launchCodeSvc == nil { + return "", nil + } + return a.launchCodeSvc.RegenerateCode(profileId) +} + +// BrowserProfileSetCode 自定义设置实例 LaunchCode(Wails 绑定) +func (a *App) BrowserProfileSetCode(profileId string, code string) (string, error) { + if a.launchCodeSvc == nil { + return "", nil + } + return a.launchCodeSvc.SetCode(profileId, code) +} + +// BrowserInstanceStartByCode 通过 LaunchCode 启动实例(Wails 绑定) +func (a *App) BrowserInstanceStartByCode(code string) (*browser.Profile, error) { + if a.launchCodeSvc == nil { + return nil, fmt.Errorf("launch code service not initialized") + } + profileId, err := a.launchCodeSvc.Resolve(code) + if err != nil { + return nil, err + } + return a.BrowserInstanceStart(profileId) +} + +// GetLaunchServerInfo 返回 LaunchServer 的当前监听信息(Wails 绑定) +func (a *App) GetLaunchServerInfo() map[string]interface{} { + preferredPort := 0 + if a.config != nil { + preferredPort = a.config.LaunchServer.Port + } + + actualPort := 0 + if a.launchServer != nil { + actualPort = a.launchServer.Port() + } + + info := map[string]interface{}{ + "host": "127.0.0.1", + "preferredPort": preferredPort, + "port": actualPort, + "ready": actualPort > 0, + } + if actualPort > 0 { + info["baseUrl"] = fmt.Sprintf("http://127.0.0.1:%d", actualPort) + } else { + info["baseUrl"] = "" + } + return info +} + +// 确保编译器检查 App 实现了 BrowserStarter 接口 +var _ launchcode.BrowserStarter = (*App)(nil) +var _ launchcode.BrowserStarterWithParams = (*App)(nil) diff --git a/backend/app_license.go b/backend/app_license.go new file mode 100644 index 00000000..5c7d3079 --- /dev/null +++ b/backend/app_license.go @@ -0,0 +1,162 @@ +package backend + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math/rand" + "strings" + "time" +) + +// LicenseStatus 授权状态 +type LicenseStatus struct { + MaxLimit int `json:"maxLimit"` + UsedCount int `json:"usedCount"` + UsedKeys []string `json:"usedKeys"` +} + +// GetLicenseStatus 获取当前授权状态(给前端使用) +func (a *App) GetLicenseStatus() LicenseStatus { + profilesCount := 0 + if a.browserMgr != nil { + profilesCount = len(a.browserMgr.List()) + } + usedKeys := a.config.App.UsedCDKeys + if usedKeys == nil { + usedKeys = []string{} + } + + return LicenseStatus{ + MaxLimit: a.config.App.MaxProfileLimit, + UsedCount: profilesCount, + UsedKeys: usedKeys, + } +} + +// RedeemCDKey 验证并核销兑换码,成功返回新配置 +func (a *App) RedeemCDKey(cdkey string) error { + if a.config == nil { + a.config = DefaultConfig() + } + cdkey = strings.ToUpper(cdkey) + // 去除所有可能的不小心复制进去的空格、制表符、换行符 + cdkey = strings.ReplaceAll(cdkey, " ", "") + cdkey = strings.ReplaceAll(cdkey, "\t", "") + cdkey = strings.ReplaceAll(cdkey, "\n", "") + cdkey = strings.ReplaceAll(cdkey, "\r", "") + + if cdkey == "" { + return fmt.Errorf("兑换码不能为空") + } + + // 1. 基本校验机制(非常简单:比如前缀必须是 ANT-,并且后面加上一个特定的哈希位能匹配) + // 生成规则我们在 keygen 里实现。校验规则: + // 假设 cdkey 长这样: ANT-XXXX-XXXX-XXXX-XXXX-CHECKSUM + // 为了最简单的极简方案,我们这就不搞太复杂的非对称,纯用带盐的 SHA256 截断作为校验和。 + if !strings.HasPrefix(cdkey, "ANT-") { + return fmt.Errorf("无效的兑换码格式") + } + + parts := strings.Split(cdkey, "-") + if len(parts) < 3 { + return fmt.Errorf("无效的兑换码长度") + } + + // 验证校验和 + checksumIndex := len(parts) - 1 + payload := strings.Join(parts[:checksumIndex], "-") // "ANT-XXXX-XXXX..." + expectedChecksum := generateChecksum(payload) + actualChecksum := parts[checksumIndex] + + if actualChecksum != expectedChecksum { + return fmt.Errorf("无效的兑换码 (Checksum Error)") + } + + // 2. 防重放校验 + for _, usedKey := range a.config.App.UsedCDKeys { + if usedKey == cdkey { + return fmt.Errorf("该兑换码已被使用过") + } + } + + // 3. 兑现与本地保存 + a.config.App.MaxProfileLimit += 3 + a.config.App.UsedCDKeys = append(a.config.App.UsedCDKeys, cdkey) + + configPath := a.resolveAppPath("config.yaml") + if _, _, err := reconcileConfigWithLocalLicense(configPath, a.config); err != nil { + return fmt.Errorf("保存本机额度状态失败: %v", err) + } + if err := a.config.Save(configPath); err != nil { + return fmt.Errorf("保存配置失败: %v", err) + } + + return nil +} + +// generateChecksum 生成简易校验和 +func generateChecksum(payload string) string { + salt := "ANT-LITE-KEY-SALT-VER-1" + hash := sha256.Sum256([]byte(payload + salt)) + return strings.ToUpper(hex.EncodeToString(hash[:])[0:8]) // 取前8位作为校验 +} + +// RedeemGithubStar 给予用户一个 github star 的一次性奖励 +func (a *App) RedeemGithubStar() error { + if a.config == nil { + a.config = DefaultConfig() + } + cdkey := "GITHUB_STAR_REWARD" + // 防重复领取 + for _, usedKey := range a.config.App.UsedCDKeys { + if usedKey == cdkey { + return fmt.Errorf("您已经领取过 GitHub Star 的赠送额度啦!") + } + } + + // 兑现与本地保存: 增加 3 个 + a.config.App.MaxProfileLimit += 3 + a.config.App.UsedCDKeys = append(a.config.App.UsedCDKeys, cdkey) + + configPath := a.resolveAppPath("config.yaml") + if _, _, err := reconcileConfigWithLocalLicense(configPath, a.config); err != nil { + return fmt.Errorf("保存本机额度状态失败: %v", err) + } + if err := a.config.Save(configPath); err != nil { + return fmt.Errorf("保存配置失败: %v", err) + } + + return nil +} + +// GenerateCDKeys 供内部隐藏管理员页面使用的发卡器接口 +func (a *App) GenerateCDKeys(count int) ([]string, error) { + if count <= 0 || count > 1000 { + return nil, fmt.Errorf("生成数量无效 (1-1000)") + } + + rand.Seed(time.Now().UnixNano()) + var keys []string + + for i := 0; i < count; i++ { + // A basic random 16-char string ABCDEFGH-IJKLMNOP... + charset := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 16) + for j := range b { + b[j] = charset[rand.Intn(len(charset))] + } + + part1 := string(b[0:4]) + part2 := string(b[4:8]) + part3 := string(b[8:12]) + part4 := string(b[12:16]) + + payload := fmt.Sprintf("ANT-%s-%s-%s-%s", part1, part2, part3, part4) + checksum := generateChecksum(payload) + + keys = append(keys, fmt.Sprintf("%s-%s", payload, checksum)) + } + + return keys, nil +} diff --git a/backend/app_paths.go b/backend/app_paths.go new file mode 100644 index 00000000..65c0c019 --- /dev/null +++ b/backend/app_paths.go @@ -0,0 +1,34 @@ +package backend + +import ( + "os" + "path/filepath" + "strings" +) + +// appRootAbs 返回应用根目录的绝对路径,优先使用 App 注入的 appRoot。 +func (a *App) appRootAbs() string { + root := strings.TrimSpace(a.appRoot) + if root == "" { + if cwd, err := os.Getwd(); err == nil { + root = cwd + } + } + if root == "" { + return "" + } + if abs, err := filepath.Abs(root); err == nil { + return abs + } + return root +} + +// appDataDir 返回 data 根目录绝对路径。 +func (a *App) appDataDir() string { + return a.resolveAppPath("data") +} + +// appChromeDir 返回 chrome 根目录绝对路径。 +func (a *App) appChromeDir() string { + return a.resolveAppPath("chrome") +} diff --git a/backend/app_profile.go b/backend/app_profile.go new file mode 100644 index 00000000..9fba255a --- /dev/null +++ b/backend/app_profile.go @@ -0,0 +1,93 @@ +package backend + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "ant-chrome/backend/internal/logger" +) + +const maxRemoteProfileConfigBytes = 512 * 1024 + +// FetchRemoteAuthorProfile 拉取远程作者配置 JSON,供前端与本地默认配置合并。 +func (a *App) FetchRemoteAuthorProfile(rawURL string, timeoutMs int) (map[string]interface{}, error) { + targetURL := strings.TrimSpace(rawURL) + if targetURL == "" { + return nil, fmt.Errorf("远程作者配置地址不能为空") + } + + parsedURL, err := url.Parse(targetURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, fmt.Errorf("远程作者配置地址无效") + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, fmt.Errorf("远程作者配置仅支持 HTTP/HTTPS 地址") + } + + if timeoutMs <= 0 { + timeoutMs = 3000 + } + if timeoutMs > 15000 { + timeoutMs = 15000 + } + + timeout := time.Duration(timeoutMs) * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, fmt.Errorf("创建远程作者配置请求失败: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "AntBrowser/1.0 profile-fetch") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log := logger.New("ProfileConfig") + if isTimeoutError(err) { + log.Warn("远程作者配置请求超时", logger.F("url", targetURL), logger.F("timeout_ms", timeoutMs)) + return nil, fmt.Errorf("远程作者配置请求超时") + } + log.Warn("远程作者配置请求失败", logger.F("url", targetURL), logger.F("error", err)) + return nil, fmt.Errorf("拉取远程作者配置失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("远程作者配置返回异常状态码: %d", resp.StatusCode) + } + + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxRemoteProfileConfigBytes)) + decoder.UseNumber() + + var payload map[string]interface{} + if err := decoder.Decode(&payload); err != nil { + return nil, fmt.Errorf("解析远程作者配置失败: %w", err) + } + if len(payload) == 0 { + return nil, fmt.Errorf("远程作者配置为空") + } + + return payload, nil +} + +func isTimeoutError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/backend/app_proxy_import.go b/backend/app_proxy_import.go new file mode 100644 index 00000000..7bf92143 --- /dev/null +++ b/backend/app_proxy_import.go @@ -0,0 +1,248 @@ +package backend + +import ( + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + maxClashSubscriptionBytes = 8 * 1024 * 1024 + clashSubscriptionTimeout = 25 * time.Second +) + +// BrowserProxyFetchClashByURL 拉取 Clash 订阅 URL,并返回可直接导入的 YAML 文本与建议配置。 +func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, fmt.Errorf("订阅 URL 不能为空") + } + + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Host == "" { + return nil, fmt.Errorf("URL 格式无效") + } + scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme)) + if scheme != "http" && scheme != "https" { + return nil, fmt.Errorf("仅支持 http/https URL") + } + + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("User-Agent", "clash-verge/2.0 ant-chrome/1.0") + req.Header.Set("Accept", "application/yaml,text/yaml,text/plain,*/*") + req.Header.Set("Cache-Control", "no-cache") + + client := &http.Client{ + Timeout: clashSubscriptionTimeout, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("拉取订阅失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("拉取订阅失败: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxClashSubscriptionBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取订阅内容失败: %w", err) + } + if len(body) > maxClashSubscriptionBytes { + return nil, fmt.Errorf("订阅内容过大(超过 8MB)") + } + + content, payload, err := normalizeClashSubscriptionContent(body) + if err != nil { + return nil, err + } + + proxyCount := clashProxyCount(payload) + if proxyCount <= 0 { + return nil, fmt.Errorf("未检测到可导入的 proxies 节点") + } + + dnsYAML := extractClashDNSYAML(payload) + suggestedGroup := suggestClashGroupName(payload, parsedURL.Hostname()) + + return map[string]interface{}{ + "url": parsedURL.String(), + "content": content, + "proxyCount": proxyCount, + "dnsServers": dnsYAML, + "suggestedGroup": suggestedGroup, + }, nil +} + +func normalizeClashSubscriptionContent(body []byte) (string, interface{}, error) { + baseText := strings.TrimSpace(strings.ReplaceAll(string(body), "\r\n", "\n")) + if baseText == "" { + return "", nil, fmt.Errorf("订阅内容为空") + } + + tryTexts := make([]string, 0, 4) + tryTexts = append(tryTexts, baseText) + + if unescaped, err := url.QueryUnescape(baseText); err == nil { + unescaped = strings.TrimSpace(strings.ReplaceAll(unescaped, "\r\n", "\n")) + if unescaped != "" && unescaped != baseText { + tryTexts = append(tryTexts, unescaped) + } + } + + if decoded, ok := decodeBase64Text(baseText); ok { + tryTexts = append(tryTexts, decoded) + } + + for _, text := range tryTexts { + payload, ok := parseClashPayload(text) + if !ok { + continue + } + if clashProxyCount(payload) > 0 { + return text, payload, nil + } + } + + return "", nil, fmt.Errorf("URL 内容不是有效 Clash YAML(需包含 proxies)") +} + +func decodeBase64Text(raw string) (string, bool) { + candidate := strings.TrimSpace(raw) + if candidate == "" { + return "", false + } + // 一些订阅会返回 URL-safe base64 或缺少 padding,这里都尝试一遍。 + padded := candidate + if mod := len(padded) % 4; mod != 0 { + padded += strings.Repeat("=", 4-mod) + } + + encoders := []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } + for _, enc := range encoders { + if data, err := enc.DecodeString(candidate); err == nil { + decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n")) + if decoded != "" { + return decoded, true + } + } + if data, err := enc.DecodeString(padded); err == nil { + decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n")) + if decoded != "" { + return decoded, true + } + } + } + return "", false +} + +func parseClashPayload(text string) (interface{}, bool) { + var payload interface{} + if err := yaml.Unmarshal([]byte(text), &payload); err != nil { + return nil, false + } + return payload, true +} + +func clashProxyCount(payload interface{}) int { + if m := toStringMap(payload); m != nil { + if arr, ok := m["proxies"].([]interface{}); ok { + return len(arr) + } + if arr, ok := m["proxy"].([]interface{}); ok { + return len(arr) + } + if arr, ok := m["Proxy"].([]interface{}); ok { + return len(arr) + } + } + if arr, ok := payload.([]interface{}); ok { + return len(arr) + } + return 0 +} + +func extractClashDNSYAML(payload interface{}) string { + m := toStringMap(payload) + if m == nil { + return "" + } + dnsRaw, exists := m["dns"] + if !exists || dnsRaw == nil { + return "" + } + data, err := yaml.Marshal(map[string]interface{}{ + "dns": dnsRaw, + }) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func suggestClashGroupName(payload interface{}, fallbackHost string) string { + fallbackHost = strings.TrimSpace(fallbackHost) + m := toStringMap(payload) + if m != nil { + if groups, ok := m["proxy-groups"].([]interface{}); ok { + for _, item := range groups { + if groupMap := toStringMap(item); groupMap != nil { + if name := strings.TrimSpace(getMapString(groupMap, "name")); name != "" { + return name + } + } + } + } + } + if strings.HasPrefix(strings.ToLower(fallbackHost), "www.") { + fallbackHost = fallbackHost[4:] + } + return fallbackHost +} + +func toStringMap(value interface{}) map[string]interface{} { + switch m := value.(type) { + case map[string]interface{}: + return m + case map[interface{}]interface{}: + out := make(map[string]interface{}, len(m)) + for k, v := range m { + key := fmt.Sprint(k) + out[key] = v + } + return out + default: + return nil + } +} + +func getMapString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + value, ok := m[key] + if !ok || value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + default: + return fmt.Sprint(v) + } +} diff --git a/backend/app_reload_test.go b/backend/app_reload_test.go new file mode 100644 index 00000000..afdd589d --- /dev/null +++ b/backend/app_reload_test.go @@ -0,0 +1,60 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "path/filepath" + "testing" +) + +func TestReloadConfigLoadsFromDisk(t *testing.T) { + root := t.TempDir() + + cfg := config.DefaultConfig() + cfg.App.Name = "Reload-Test-App" + if err := cfg.Save(filepath.Join(root, "config.yaml")); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + app := NewApp(root) + app.config = config.DefaultConfig() + + if err := app.ReloadConfig(); err != nil { + t.Fatalf("ReloadConfig 失败: %v", err) + } + + if app.config == nil { + t.Fatalf("ReloadConfig 后 config 为空") + } + if app.config.App.Name != "Reload-Test-App" { + t.Fatalf("ReloadConfig 未生效,got=%q", app.config.App.Name) + } +} + +func TestReloadConfigKeepsLocalLicenseState(t *testing.T) { + root := t.TempDir() + + cfg := config.DefaultConfig() + if err := cfg.Save(filepath.Join(root, "config.yaml")); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + if err := saveLocalLicenseState(filepath.Join(root, "config.yaml"), &localLicenseState{ + MaxProfileLimit: 9, + UsedCDKeys: []string{"ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE", "GITHUB_STAR_REWARD"}, + }); err != nil { + t.Fatalf("写入本机额度状态失败: %v", err) + } + + app := NewApp(root) + app.config = config.DefaultConfig() + + if err := app.ReloadConfig(); err != nil { + t.Fatalf("ReloadConfig 失败: %v", err) + } + + if app.config.App.MaxProfileLimit != 9 { + t.Fatalf("ReloadConfig 未恢复本机额度状态: got=%d", app.config.App.MaxProfileLimit) + } + if len(app.config.App.UsedCDKeys) != 2 { + t.Fatalf("ReloadConfig 未恢复兑换记录: %+v", app.config.App.UsedCDKeys) + } +} diff --git a/backend/app_shutdown.go b/backend/app_shutdown.go new file mode 100644 index 00000000..c1616e0f --- /dev/null +++ b/backend/app_shutdown.go @@ -0,0 +1,151 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "os/exec" + stdruntime "runtime" + "sync" + "time" +) + +type browserProcessSnapshot struct { + profileID string + cmd *exec.Cmd +} + +func (a *App) stopRuntimeServices() { + a.stopServicesOnce.Do(func() { + log := logger.New("App") + + a.stopAllBrowserProcessesForExit(log) + + if a.xrayMgr != nil { + a.xrayMgr.StopAll() + } + a.clearProfileXrayBridges() + + if a.singboxMgr != nil { + a.singboxMgr.StopAll() + } + + if a.clashMgr != nil { + a.clashMgr.StopAll() + } + + if a.speedScheduler != nil { + a.speedScheduler.Stop() + a.speedScheduler = nil + } + + if a.launchServer != nil { + if err := a.launchServer.Stop(); err != nil { + log.Error("LaunchServer 关闭失败", logger.F("error", err)) + } + a.launchServer = nil + } + + if err := killResidualRuntimeProcesses(a.appRoot); err != nil { + log.Error("退出前清理残留进程失败", logger.F("error", err.Error())) + } + }) +} + +func (a *App) finalizeShutdown() { + a.finalizeOnce.Do(func() { + if a.db != nil { + a.db.Close() + a.db = nil + } + if err := logger.Close(); err != nil { + fmt.Printf("关闭日志系统失败: %v\n", err) + } + }) +} + +func (a *App) stopAllBrowserProcessesForExit(log *logger.Logger) { + if a.browserMgr == nil { + return + } + + stoppedAt := time.Now().Format(time.RFC3339) + + a.browserMgr.Mutex.Lock() + processes := make([]browserProcessSnapshot, 0, len(a.browserMgr.BrowserProcesses)) + for profileID, cmd := range a.browserMgr.BrowserProcesses { + if profile, ok := a.browserMgr.Profiles[profileID]; ok && profile != nil { + profile.Running = false + profile.LastStopAt = stoppedAt + } + if cmd != nil && cmd.Process != nil { + processes = append(processes, browserProcessSnapshot{ + profileID: profileID, + cmd: cmd, + }) + } + } + a.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + a.browserMgr.Mutex.Unlock() + + if len(processes) == 0 { + return + } + + var wg sync.WaitGroup + for _, item := range processes { + wg.Add(1) + go func(item browserProcessSnapshot) { + defer wg.Done() + + pid := 0 + if item.cmd != nil && item.cmd.Process != nil { + pid = item.cmd.Process.Pid + } + log.Info("退出前关闭浏览器实例", logger.F("profile_id", item.profileID), logger.F("pid", pid)) + if err := stopProcessCmdForShutdown(item.cmd); err != nil { + log.Error("退出前关闭浏览器实例失败", logger.F("profile_id", item.profileID), logger.F("pid", pid), logger.F("error", err.Error())) + } + }(item) + } + wg.Wait() +} + +func stopProcessCmdForShutdown(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + + pid := cmd.Process.Pid + if pid > 0 { + if err := forceKillProcessTree(pid); err == nil || isProcessAlreadyFinished(err) { + return nil + } + } + + err := cmd.Process.Kill() + if err == nil || isProcessAlreadyFinished(err) { + return nil + } + return err +} + +func forceKillProcessTree(pid int) error { + if pid <= 0 { + return nil + } + if stdruntime.GOOS != "windows" { + return fmt.Errorf("force kill process tree unsupported on %s", stdruntime.GOOS) + } + + killCmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", pid)) + hideWindow(killCmd) + err := killCmd.Run() + if err == nil { + _ = waitProcessExitWindows(pid, 1500*time.Millisecond) + return nil + } + if waitProcessExitWindows(pid, 300*time.Millisecond) { + return nil + } + return err +} diff --git a/backend/app_snapshot.go b/backend/app_snapshot.go new file mode 100644 index 00000000..78d807d7 --- /dev/null +++ b/backend/app_snapshot.go @@ -0,0 +1,293 @@ +package backend + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// 实例数据快照 API +// ============================================================================ + +// SnapshotInfo 快照元数据 +type SnapshotInfo struct { + SnapshotId string `json:"snapshotId"` + ProfileId string `json:"profileId"` + Name string `json:"name"` + SizeMB float64 `json:"sizeMB"` + CreatedAt string `json:"createdAt"` + FilePath string `json:"filePath,omitempty"` +} + +// snapshotDir 返回指定实例的快照目录路径(存放在 data/snapshots 下) +func (a *App) snapshotDir(profileId string) (string, error) { + dir := filepath.Join(a.resolveAppPath("data"), "snapshots", profileId) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return dir, nil +} + +// zipDir 递归压缩 src 目录为 dest zip 文件 +func zipDir(src, dest string) error { + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + + w := zip.NewWriter(f) + defer w.Close() + + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + // 统一使用正斜杠 + rel = filepath.ToSlash(rel) + if d.IsDir() { + if rel == "." { + return nil + } + _, err = w.Create(rel + "/") + return err + } + fw, err := w.Create(rel) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(fw, file) + return err + }) +} + +// unzipTo 解压 src zip 文件到 dest 目录 +func unzipTo(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + target := filepath.Join(dest, filepath.FromSlash(f.Name)) + // 防止 zip slip + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) && + filepath.Clean(target) != filepath.Clean(dest) { + return fmt.Errorf("非法路径: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + out, err := os.Create(target) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + out.Close() + return err + } + _, copyErr := io.Copy(out, rc) + rc.Close() + out.Close() + if copyErr != nil { + return copyErr + } + } + return nil +} + +// getProfileForSnapshot 获取实例信息(加锁) +func (a *App) getProfileForSnapshot(profileId string) (*BrowserProfile, error) { + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists { + return nil, fmt.Errorf("实例不存在: %s", profileId) + } + return profile, nil +} + +// BrowserSnapshotCreate 创建快照 +func (a *App) BrowserSnapshotCreate(profileId, name string) (SnapshotInfo, error) { + profile, err := a.getProfileForSnapshot(profileId) + if err != nil { + return SnapshotInfo{}, err + } + if profile.Running { + return SnapshotInfo{}, fmt.Errorf("请先停止实例再创建快照") + } + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if _, err := os.Stat(userDataDir); os.IsNotExist(err) { + return SnapshotInfo{}, fmt.Errorf("用户数据目录不存在,无法创建快照") + } + + snapDir, err := a.snapshotDir(profileId) + if err != nil { + return SnapshotInfo{}, err + } + + snapshotId := uuid.NewString() + safeName := strings.ReplaceAll(name, string(os.PathSeparator), "_") + zipPath := filepath.Join(snapDir, snapshotId+"_"+safeName+".zip") + metaPath := filepath.Join(snapDir, snapshotId+"_"+safeName+".meta.json") + + if err := zipDir(userDataDir, zipPath); err != nil { + return SnapshotInfo{}, fmt.Errorf("压缩失败: %w", err) + } + + fi, err := os.Stat(zipPath) + if err != nil { + return SnapshotInfo{}, err + } + sizeMB := float64(fi.Size()) / 1024 / 1024 + + info := SnapshotInfo{ + SnapshotId: snapshotId, + ProfileId: profileId, + Name: name, + SizeMB: sizeMB, + CreatedAt: time.Now().Format(time.RFC3339), + FilePath: zipPath, + } + + metaData, _ := json.Marshal(info) + if err := os.WriteFile(metaPath, metaData, 0644); err != nil { + return SnapshotInfo{}, err + } + + // 返回给前端时不暴露 FilePath + info.FilePath = "" + return info, nil +} + +// BrowserSnapshotList 列出实例的所有快照 +func (a *App) BrowserSnapshotList(profileId string) ([]SnapshotInfo, error) { + snapDir, err := a.snapshotDir(profileId) + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(snapDir) + if err != nil { + if os.IsNotExist(err) { + return []SnapshotInfo{}, nil + } + return nil, err + } + + var list []SnapshotInfo + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + data, err := os.ReadFile(filepath.Join(snapDir, entry.Name())) + if err != nil { + continue + } + var info SnapshotInfo + if err := json.Unmarshal(data, &info); err != nil { + continue + } + info.FilePath = "" + list = append(list, info) + } + + sort.Slice(list, func(i, j int) bool { + return list[i].CreatedAt > list[j].CreatedAt + }) + return list, nil +} + +// BrowserSnapshotRestore 恢复快照 +func (a *App) BrowserSnapshotRestore(profileId, snapshotId string) error { + profile, err := a.getProfileForSnapshot(profileId) + if err != nil { + return err + } + if profile.Running { + return fmt.Errorf("请先停止实例再恢复快照") + } + + snapDir, err := a.snapshotDir(profileId) + if err != nil { + return err + } + + // 找到对应 meta.json + metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId) + if err != nil { + return err + } + _ = metaPath + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if err := os.RemoveAll(userDataDir); err != nil { + return fmt.Errorf("清空用户数据目录失败: %w", err) + } + if err := os.MkdirAll(userDataDir, 0755); err != nil { + return err + } + return unzipTo(zipPath, userDataDir) +} + +// BrowserSnapshotDelete 删除快照 +func (a *App) BrowserSnapshotDelete(profileId, snapshotId string) error { + snapDir, err := a.snapshotDir(profileId) + if err != nil { + return err + } + metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId) + if err != nil { + return err + } + _ = os.Remove(zipPath) + _ = os.Remove(metaPath) + return nil +} + +// findSnapshotFiles 在快照目录中找到指定 snapshotId 的 meta 和 zip 路径 +func findSnapshotFiles(snapDir, snapshotId string) (metaPath, zipPath string, err error) { + entries, err := os.ReadDir(snapDir) + if err != nil { + return "", "", err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), snapshotId) && strings.HasSuffix(entry.Name(), ".meta.json") { + metaPath = filepath.Join(snapDir, entry.Name()) + zipPath = strings.TrimSuffix(metaPath, ".meta.json") + ".zip" + if _, err := os.Stat(zipPath); err != nil { + return "", "", fmt.Errorf("快照文件不存在: %s", zipPath) + } + return metaPath, zipPath, nil + } + } + return "", "", fmt.Errorf("快照不存在: %s", snapshotId) +} diff --git a/backend/app_utils.go b/backend/app_utils.go new file mode 100644 index 00000000..6dcc137e --- /dev/null +++ b/backend/app_utils.go @@ -0,0 +1,237 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "fmt" + "net" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// 工具函数 +// ============================================================================ + +// resolveAppPath 将相对路径解析为绝对路径(基于 appRoot)。 +// 如果传入的已经是绝对路径则直接返回。 +func (a *App) resolveAppPath(p string) string { + if filepath.IsAbs(p) { + return p + } + if a.appRoot != "" { + return filepath.Join(a.appRoot, p) + } + if cwd, err := os.Getwd(); err == nil { + return filepath.Join(cwd, p) + } + return p +} + +func generateUUID() string { + return uuid.NewString() +} + +func nextAvailablePort() (int, error) { + // 二次验证策略:分配端口后立即再次绑定确认未被抢占,最多重试 10 次 + for i := 0; i < 10; i++ { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + continue + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + // 短暂等待 OS 释放端口 + time.Sleep(5 * time.Millisecond) + // 二次验证端口未被其他进程抢占 + v, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + continue + } + v.Close() + return port, nil + } + return 0, fmt.Errorf("无法分配可用端口") +} + +// ============================================================================ +// 内核初始化 +// ============================================================================ + +func (a *App) ensureDefaultCores() { + log := logger.New("Browser") + + // 扫描 chrome/ 目录,无论配置是否已有内核都执行一次,确保新增子目录被发现 + detected := a.scanChromeDir("chrome") + + if len(a.config.Browser.Cores) == 0 { + // 配置为空:直接用扫描结果,或兜底写一个占位 + if len(detected) > 0 { + a.config.Browser.Cores = detected + } else { + a.config.Browser.Cores = []browser.Core{} + } + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + log.Error("内核配置初始化失败", logger.F("error", err)) + return + } + log.Info("内核配置初始化完成", logger.F("count", len(a.config.Browser.Cores))) + return + } + + // 配置已有内核:将扫描到的新目录追加进去(不覆盖已有的) + changed := false + for _, newCore := range detected { + exists := false + for _, existing := range a.config.Browser.Cores { + if existing.CorePath == newCore.CorePath { + exists = true + break + } + } + if !exists { + a.config.Browser.Cores = append(a.config.Browser.Cores, newCore) + log.Info("发现新内核,已注册", logger.F("path", newCore.CorePath)) + changed = true + } + } + if changed { + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + log.Error("新内核注册保存失败", logger.F("error", err)) + } + } +} + +func (a *App) autoDetectCores() { + log := logger.New("Browser") + // ensureDefaultCores 已完成扫描注册,这里只做路径有效性日志。 + // SQLite 模式下以内核表为准,避免与 config.yaml 历史条目不一致。 + cores := a.config.Browser.Cores + if a.browserMgr != nil { + cores = a.browserMgr.ListCores() + } + for _, core := range cores { + result := a.browserMgr.ValidateCorePath(core.CorePath) + if result.Valid { + log.Debug("内核路径有效", logger.F("core_id", core.CoreId), logger.F("path", core.CorePath)) + } else { + log.Warn("内核路径无效", logger.F("core_id", core.CoreId), logger.F("path", core.CorePath), logger.F("message", result.Message)) + } + } +} + +// scanChromeDir 扫描指定目录,将包含 chrome.exe 的子文件夹识别为内核。 +// 如果目录本身包含 chrome.exe(旧版单内核结构),则直接返回该目录作为内核。 +func (a *App) scanChromeDir(chromeRoot string) []browser.Core { + log := logger.New("Browser") + + baseDir := a.resolveAppPath(chromeRoot) + + if _, err := os.Stat(baseDir); os.IsNotExist(err) { + return nil + } + + // 如果根目录本身就有 chrome.exe,视为单内核结构 + if _, err := os.Stat(filepath.Join(baseDir, "chrome.exe")); err == nil { + return []browser.Core{ + { + CoreId: "default", + CoreName: "默认内核", + CorePath: chromeRoot, + IsDefault: true, + }, + } + } + + // 扫描子文件夹 + entries, err := os.ReadDir(baseDir) + if err != nil { + log.Warn("扫描 chrome 目录失败", logger.F("path", baseDir), logger.F("error", err.Error())) + return nil + } + + var cores []browser.Core + for _, entry := range entries { + if !entry.IsDir() { + continue + } + subPath := filepath.Join(chromeRoot, entry.Name()) + absExe := filepath.Join(baseDir, entry.Name(), "chrome.exe") + if _, err := os.Stat(absExe); err != nil { + continue // 没有 chrome.exe,跳过 + } + isDefault := len(cores) == 0 + cores = append(cores, browser.Core{ + CoreId: fmt.Sprintf("core-%s", entry.Name()), + CoreName: fmt.Sprintf("Chrome %s", entry.Name()), + CorePath: subPath, + IsDefault: isDefault, + }) + log.Debug("发现内核", logger.F("name", entry.Name()), logger.F("path", subPath)) + } + return cores +} + +// ============================================================================ +// 代理数据加载 +// ============================================================================ + +// loadProxies 启动时加载代理数据。 +// 优先从 ProxyDAO(SQLite)读取;若 DAO 未注入则降级到 proxies.yaml,最后降级到 config.yaml。 +func (a *App) loadProxies() { + log := logger.New("Browser") + + builtins := []browser.Proxy{ + {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, + } + + ensureBuiltins := func(list []browser.Proxy) []browser.Proxy { + for _, b := range builtins { + found := false + for _, p := range list { + if p.ProxyId == b.ProxyId { + found = true + break + } + } + if !found { + list = append([]browser.Proxy{b}, list...) + } + } + return list + } + + // 优先从 SQLite 读取 + if a.browserMgr.ProxyDAO != nil { + list, err := a.browserMgr.ProxyDAO.List() + if err != nil { + log.Error("从数据库读取代理失败", logger.F("error", err.Error())) + } else if len(list) > 0 { + a.config.Browser.Proxies = list + log.Info("代理数据从数据库加载完成", logger.F("count", len(list))) + return + } + } + + // 降级:从 proxies.yaml 加载 + loaded, err := config.LoadProxies(a.resolveAppPath("proxies.yaml")) + if err != nil { + log.Warn("读取 proxies.yaml 失败", logger.F("error", err.Error())) + } + if loaded != nil { + proxies := ensureBuiltins(loaded) + a.config.Browser.Proxies = proxies + log.Info("代理数据从 proxies.yaml 加载完成", logger.F("count", len(proxies))) + return + } + + // 最终降级:使用 config.yaml 中的数据 + proxies := ensureBuiltins(a.config.Browser.Proxies) + a.config.Browser.Proxies = proxies + log.Info("代理数据使用 config.yaml 默认值", logger.F("count", len(proxies))) +} diff --git a/backend/bootstrap.go b/backend/bootstrap.go new file mode 100644 index 00000000..317dc294 --- /dev/null +++ b/backend/bootstrap.go @@ -0,0 +1,69 @@ +package backend + +import ( + appconfig "ant-chrome/backend/internal/config" + apptray "ant-chrome/backend/internal/tray" + "fmt" + "os" + "path/filepath" + "time" +) + +type Config = appconfig.Config +type TrayCallbacks = apptray.Callbacks + +func LoadConfig(path string) (*Config, error) { + cfg, err := appconfig.Load(path) + configChanged := false + repairedConfig := false + if err == nil { + if changed, _, syncErr := reconcileConfigWithLocalLicense(path, cfg); syncErr != nil { + return cfg, syncErr + } else { + configChanged = changed + } + if configChanged { + if saveErr := cfg.Save(path); saveErr != nil { + return cfg, fmt.Errorf("写回配置失败: %w", saveErr) + } + } + return cfg, nil + } + + // 配置文件存在但内容损坏时,自动备份并重建默认配置,避免启动阶段反复报错。 + if data, readErr := os.ReadFile(path); readErr == nil && len(data) > 0 { + backupPath := fmt.Sprintf("%s.broken-%s", path, time.Now().Format("20060102-150405")) + if writeErr := os.WriteFile(backupPath, data, 0644); writeErr != nil { + return appconfig.DefaultConfig(), fmt.Errorf("加载配置失败: %w;备份损坏配置失败: %v", err, writeErr) + } + } + + defaultCfg := appconfig.DefaultConfig() + repairedConfig = true + if changed, _, syncErr := reconcileConfigWithLocalLicense(path, defaultCfg); syncErr != nil { + return defaultCfg, syncErr + } else { + configChanged = changed + } + if repairedConfig || configChanged { + if saveErr := os.MkdirAll(filepath.Dir(path), 0755); saveErr != nil { + return defaultCfg, fmt.Errorf("加载配置失败: %w;创建配置目录失败: %v", err, saveErr) + } + if saveErr := defaultCfg.Save(path); saveErr != nil { + return defaultCfg, fmt.Errorf("加载配置失败: %w;重建默认配置失败: %v", err, saveErr) + } + } + return defaultCfg, nil +} + +func DefaultConfig() *Config { + return appconfig.DefaultConfig() +} + +func RunTray(cb TrayCallbacks) { + apptray.Run(cb) +} + +func QuitTray() { + apptray.Quit() +} diff --git a/backend/internal/backup/spec.go b/backend/internal/backup/spec.go new file mode 100644 index 00000000..c576f02d --- /dev/null +++ b/backend/internal/backup/spec.go @@ -0,0 +1,409 @@ +package backup + +import ( + "ant-chrome/backend/internal/config" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +const ( + // PackageFormat 标识导出包格式类型。 + PackageFormat = "ant-chrome-full-backup" + // ManifestVersion 标识 manifest.json 的结构版本。 + ManifestVersion = 1 +) + +type Category string + +const ( + CategorySystemConfig Category = "system_config" + CategoryAppData Category = "app_data" + CategoryBrowserData Category = "browser_data" + CategoryCoreData Category = "core_data" + CategoryLogs Category = "logs" +) + +type EntryType string + +const ( + EntryTypeFile EntryType = "file" + EntryTypeDir EntryType = "dir" +) + +// ScopeEntry 描述一个需要进入备份包的源条目。 +type ScopeEntry struct { + ID string `json:"id"` + Category Category `json:"category"` + EntryType EntryType `json:"entryType"` + Required bool `json:"required"` + SourcePath string `json:"sourcePath"` + ArchivePath string `json:"archivePath"` + Exists bool `json:"exists"` + Description string `json:"description,omitempty"` +} + +// Scope 为导出范围定义。 +type Scope struct { + Format string `json:"format"` + ManifestVersion int `json:"manifestVersion"` + AppRoot string `json:"appRoot"` + Entries []ScopeEntry `json:"entries"` +} + +// Manifest 用于写入 zip 根目录下的 manifest.json。 +type Manifest struct { + Format string `json:"format"` + ManifestVersion int `json:"manifestVersion"` + CreatedAt string `json:"createdAt"` + App ManifestAppInfo `json:"app"` + Entries []ManifestEntry `json:"entries"` +} + +type ManifestAppInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ManifestEntry 为写入 manifest 的条目(不包含本机绝对路径)。 +type ManifestEntry struct { + ID string `json:"id"` + Category Category `json:"category"` + EntryType EntryType `json:"entryType"` + Required bool `json:"required"` + ArchivePath string `json:"archivePath"` + Description string `json:"description,omitempty"` +} + +type BuildOptions struct { + AppRoot string + Config *config.Config +} + +// BuildScope 构建第一阶段的导出范围定义(不执行实际导出)。 +func BuildScope(opts BuildOptions) (Scope, error) { + appRoot := strings.TrimSpace(opts.AppRoot) + if appRoot == "" { + return Scope{}, fmt.Errorf("app root 不能为空") + } + appRootAbs, err := filepath.Abs(appRoot) + if err != nil { + return Scope{}, fmt.Errorf("解析 app root 失败: %w", err) + } + + cfg := opts.Config + if cfg == nil { + cfg = config.DefaultConfig() + } + + builder := newScopeBuilder(appRootAbs) + + builder.add(ScopeEntry{ + ID: "system_config_main", + Category: CategorySystemConfig, + EntryType: EntryTypeFile, + Required: true, + SourcePath: resolvePath(appRootAbs, "config.yaml"), + ArchivePath: "payload/system/config.yaml", + Description: "主配置文件", + }) + + builder.add(ScopeEntry{ + ID: "system_config_proxies", + Category: CategorySystemConfig, + EntryType: EntryTypeFile, + Required: false, + SourcePath: resolvePath(appRootAbs, "proxies.yaml"), + ArchivePath: "payload/system/proxies.yaml", + Description: "代理配置文件(存在时导出)", + }) + + appDataRoot := resolvePath(appRootAbs, "data") + builder.add(ScopeEntry{ + ID: "app_data_root", + Category: CategoryAppData, + EntryType: EntryTypeDir, + Required: true, + SourcePath: appDataRoot, + ArchivePath: "payload/app/data/", + Description: "应用数据目录(含数据库、快照及默认浏览器数据)", + }) + + userDataRootSetting := strings.TrimSpace(cfg.Browser.UserDataRoot) + if userDataRootSetting == "" { + userDataRootSetting = "data" + } + userDataRoot := resolvePath(appRootAbs, userDataRootSetting) + builder.add(ScopeEntry{ + ID: "browser_user_data_root", + Category: CategoryBrowserData, + EntryType: EntryTypeDir, + Required: true, + SourcePath: userDataRoot, + ArchivePath: "payload/browser/user-data/", + Description: "浏览器用户数据根目录(若与 data 重合则自动去重)", + }) + + chromeRoot := resolvePath(appRootAbs, "chrome") + builder.add(ScopeEntry{ + ID: "browser_core_root", + Category: CategoryCoreData, + EntryType: EntryTypeDir, + Required: false, + SourcePath: chromeRoot, + ArchivePath: "payload/browser/cores/chrome/", + Description: "默认内核目录", + }) + + corePaths := collectExtraCorePaths(cfg.Browser.Cores, appRootAbs, chromeRoot) + for idx, corePath := range corePaths { + coreID := fmt.Sprintf("external-%02d", idx+1) + builder.add(ScopeEntry{ + ID: "browser_core_external_" + coreID, + Category: CategoryCoreData, + EntryType: EntryTypeDir, + Required: false, + SourcePath: corePath, + ArchivePath: "payload/browser/cores/external/" + coreID + "/", + Description: "额外内核目录(来自配置 cores)", + }) + } + + dbType := strings.TrimSpace(cfg.Database.Type) + if dbType == "" || strings.EqualFold(dbType, "sqlite") { + dbPath := strings.TrimSpace(cfg.Database.SQLite.Path) + if dbPath == "" { + dbPath = "data/app.db" + } + dbAbs := resolvePath(appRootAbs, dbPath) + builder.add(ScopeEntry{ + ID: "database_sqlite_main", + Category: CategoryAppData, + EntryType: EntryTypeFile, + Required: true, + SourcePath: dbAbs, + ArchivePath: "payload/app/database/app.db", + Description: "SQLite 主数据库(若已被 data 覆盖则自动去重)", + }) + builder.add(ScopeEntry{ + ID: "database_sqlite_wal", + Category: CategoryAppData, + EntryType: EntryTypeFile, + Required: false, + SourcePath: dbAbs + "-wal", + ArchivePath: "payload/app/database/app.db-wal", + Description: "SQLite WAL 文件(存在时导出)", + }) + builder.add(ScopeEntry{ + ID: "database_sqlite_shm", + Category: CategoryAppData, + EntryType: EntryTypeFile, + Required: false, + SourcePath: dbAbs + "-shm", + ArchivePath: "payload/app/database/app.db-shm", + Description: "SQLite SHM 文件(存在时导出)", + }) + } + + logDir := detectLogDir(appRootAbs, strings.TrimSpace(cfg.Logging.FilePath)) + if logDir != "" { + builder.add(ScopeEntry{ + ID: "logs_root", + Category: CategoryLogs, + EntryType: EntryTypeDir, + Required: false, + SourcePath: logDir, + ArchivePath: "payload/app/logs/", + Description: "日志目录(存在时导出)", + }) + } + + scope := Scope{ + Format: PackageFormat, + ManifestVersion: ManifestVersion, + AppRoot: appRootAbs, + Entries: builder.entries, + } + return scope, nil +} + +// BuildManifest 根据 Scope 生成 manifest 结构体。 +func BuildManifest(scope Scope, appName, appVersion string, createdAt time.Time) Manifest { + if createdAt.IsZero() { + createdAt = time.Now() + } + name := strings.TrimSpace(appName) + if name == "" { + name = "Ant Browser" + } + version := strings.TrimSpace(appVersion) + if version == "" { + version = "1.0.0" + } + + entries := make([]ManifestEntry, 0, len(scope.Entries)) + for _, item := range scope.Entries { + entries = append(entries, ManifestEntry{ + ID: item.ID, + Category: item.Category, + EntryType: item.EntryType, + Required: item.Required, + ArchivePath: item.ArchivePath, + Description: item.Description, + }) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].ID < entries[j].ID + }) + + return Manifest{ + Format: PackageFormat, + ManifestVersion: ManifestVersion, + CreatedAt: createdAt.UTC().Format(time.RFC3339), + App: ManifestAppInfo{ + Name: name, + Version: version, + }, + Entries: entries, + } +} + +func collectExtraCorePaths(cores []config.BrowserCore, appRootAbs, defaultChromeRoot string) []string { + result := make([]string, 0) + seen := make(map[string]struct{}) + for _, core := range cores { + corePath := strings.TrimSpace(core.CorePath) + if corePath == "" { + continue + } + coreAbs := resolvePath(appRootAbs, corePath) + if isPathWithin(coreAbs, defaultChromeRoot) { + continue + } + key := normalizeForCompare(coreAbs) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, coreAbs) + } + sort.Strings(result) + return result +} + +func detectLogDir(appRootAbs, logPath string) string { + if logPath == "" { + return "" + } + resolved := resolvePath(appRootAbs, logPath) + dir := filepath.Dir(resolved) + if strings.TrimSpace(dir) == "" || dir == "." { + return "" + } + return filepath.Clean(dir) +} + +type scopeBuilder struct { + entries []ScopeEntry +} + +func newScopeBuilder(_ string) *scopeBuilder { + return &scopeBuilder{ + entries: make([]ScopeEntry, 0, 12), + } +} + +func (b *scopeBuilder) add(entry ScopeEntry) { + if strings.TrimSpace(entry.SourcePath) == "" { + return + } + entry.SourcePath = filepath.Clean(entry.SourcePath) + entry.ArchivePath = filepath.ToSlash(strings.TrimSpace(entry.ArchivePath)) + if entry.ArchivePath == "" { + return + } + + // 已有目录覆盖时,直接跳过,避免重复导出同一文件。 + if b.isCoveredByExisting(entry.SourcePath) { + return + } + + for i, existing := range b.entries { + if samePath(existing.SourcePath, entry.SourcePath) { + if entry.Required && !existing.Required { + b.entries[i].Required = true + } + return + } + } + + entry.Exists = pathExists(entry.SourcePath) + b.entries = append(b.entries, entry) + sort.SliceStable(b.entries, func(i, j int) bool { + return b.entries[i].ID < b.entries[j].ID + }) +} + +func (b *scopeBuilder) isCoveredByExisting(candidate string) bool { + for _, existing := range b.entries { + switch existing.EntryType { + case EntryTypeDir: + if isPathWithin(candidate, existing.SourcePath) { + return true + } + case EntryTypeFile: + if samePath(candidate, existing.SourcePath) { + return true + } + } + } + return false +} + +func resolvePath(appRoot, p string) string { + p = strings.TrimSpace(p) + if p == "" { + return filepath.Clean(appRoot) + } + if filepath.IsAbs(p) { + return filepath.Clean(p) + } + return filepath.Clean(filepath.Join(appRoot, p)) +} + +func pathExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func samePath(a, b string) bool { + return normalizeForCompare(a) == normalizeForCompare(b) +} + +func isPathWithin(path, dir string) bool { + p := normalizeForCompare(path) + d := normalizeForCompare(dir) + if p == d { + return true + } + if d == "" || p == "" { + return false + } + if !strings.HasSuffix(d, string(filepath.Separator)) { + d += string(filepath.Separator) + } + return strings.HasPrefix(p, d) +} + +func normalizeForCompare(p string) string { + normalized := filepath.Clean(strings.TrimSpace(p)) + if runtime.GOOS == "windows" { + normalized = strings.ToLower(normalized) + } + return normalized +} diff --git a/backend/internal/backup/spec_test.go b/backend/internal/backup/spec_test.go new file mode 100644 index 00000000..0e4ebe2d --- /dev/null +++ b/backend/internal/backup/spec_test.go @@ -0,0 +1,124 @@ +package backup + +import ( + "ant-chrome/backend/internal/config" + "path/filepath" + "testing" + "time" +) + +func TestBuildScope_DefaultConfigKeepsCoreEntries(t *testing.T) { + tempDir := t.TempDir() + cfg := config.DefaultConfig() + + scope, err := BuildScope(BuildOptions{ + AppRoot: tempDir, + Config: cfg, + }) + if err != nil { + t.Fatalf("BuildScope 返回错误: %v", err) + } + + if scope.Format != PackageFormat { + t.Fatalf("format 不正确: %s", scope.Format) + } + if scope.ManifestVersion != ManifestVersion { + t.Fatalf("manifestVersion 不正确: %d", scope.ManifestVersion) + } + + ids := make(map[string]ScopeEntry) + for _, e := range scope.Entries { + ids[e.ID] = e + } + + assertEntry(t, ids, "system_config_main") + assertEntry(t, ids, "system_config_proxies") + assertEntry(t, ids, "app_data_root") + assertEntry(t, ids, "browser_core_root") + + if _, ok := ids["database_sqlite_main"]; ok { + t.Fatalf("默认配置下 database_sqlite_main 应被 app_data_root 覆盖,不应单独出现") + } + if _, ok := ids["browser_user_data_root"]; ok { + t.Fatalf("默认配置下 browser_user_data_root 与 app_data_root 重合,不应重复出现") + } +} + +func TestBuildScope_CustomPathsIncludeNonOverlappingEntries(t *testing.T) { + tempDir := t.TempDir() + cfg := config.DefaultConfig() + + cfg.Browser.UserDataRoot = "profiles" + cfg.Database.SQLite.Path = "db/main.db" + cfg.Logging.FilePath = "runtime/logs/app.log" + cfg.Browser.Cores = []config.BrowserCore{ + { + CoreId: "core-external-a", + CoreName: "External Core A", + CorePath: "external-core-a", + }, + } + + scope, err := BuildScope(BuildOptions{ + AppRoot: tempDir, + Config: cfg, + }) + if err != nil { + t.Fatalf("BuildScope 返回错误: %v", err) + } + + ids := make(map[string]ScopeEntry) + for _, e := range scope.Entries { + ids[e.ID] = e + } + + assertEntry(t, ids, "browser_user_data_root") + assertEntry(t, ids, "database_sqlite_main") + assertEntry(t, ids, "database_sqlite_wal") + assertEntry(t, ids, "database_sqlite_shm") + assertEntry(t, ids, "logs_root") + assertEntry(t, ids, "browser_core_external_external-01") + + dbEntry := ids["database_sqlite_main"] + expectedDB := filepath.Join(tempDir, "db", "main.db") + if dbEntry.SourcePath != expectedDB { + t.Fatalf("database source path 不匹配: got=%s want=%s", dbEntry.SourcePath, expectedDB) + } +} + +func TestBuildManifest_StripsSourcePath(t *testing.T) { + tempDir := t.TempDir() + scope, err := BuildScope(BuildOptions{ + AppRoot: tempDir, + Config: config.DefaultConfig(), + }) + if err != nil { + t.Fatalf("BuildScope 返回错误: %v", err) + } + + at := time.Date(2026, 3, 2, 12, 0, 0, 0, time.UTC) + manifest := BuildManifest(scope, "Ant Browser", "1.0.0", at) + + if manifest.CreatedAt != "2026-03-02T12:00:00Z" { + t.Fatalf("CreatedAt 不匹配: %s", manifest.CreatedAt) + } + if manifest.App.Name != "Ant Browser" { + t.Fatalf("manifest app name 不正确: %s", manifest.App.Name) + } + if manifest.App.Version != "1.0.0" { + t.Fatalf("manifest app version 不正确: %s", manifest.App.Version) + } + + for _, item := range manifest.Entries { + if item.ArchivePath == "" { + t.Fatalf("manifest entry 缺少 archivePath: %+v", item) + } + } +} + +func assertEntry(t *testing.T, entries map[string]ScopeEntry, id string) { + t.Helper() + if _, ok := entries[id]; !ok { + t.Fatalf("缺少 scope entry: %s", id) + } +} diff --git a/backend/internal/browser/bookmark_dao.go b/backend/internal/browser/bookmark_dao.go new file mode 100644 index 00000000..25d6531c --- /dev/null +++ b/backend/internal/browser/bookmark_dao.go @@ -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() +} diff --git a/backend/internal/browser/bookmarks.go b/backend/internal/browser/bookmarks.go new file mode 100644 index 00000000..25711619 --- /dev/null +++ b/backend/internal/browser/bookmarks.go @@ -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, + ) +} diff --git a/backend/internal/browser/connector.go b/backend/internal/browser/connector.go new file mode 100644 index 00000000..8d29a389 --- /dev/null +++ b/backend/internal/browser/connector.go @@ -0,0 +1,6 @@ +package browser + +// BuildLaunchArgs 构建启动参数 +func BuildLaunchArgs(args []string, profile *Profile) []string { + return args +} diff --git a/backend/internal/browser/core.go b/backend/internal/browser/core.go new file mode 100644 index 00000000..ee8d83c5 --- /dev/null +++ b/backend/internal/browser/core.go @@ -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 +} diff --git a/backend/internal/browser/core_dao.go b/backend/internal/browser/core_dao.go new file mode 100644 index 00000000..038fc486 --- /dev/null +++ b/backend/internal/browser/core_dao.go @@ -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() +} diff --git a/backend/internal/browser/download_core.go b/backend/internal/browser/download_core.go new file mode 100644 index 00000000..02bd9304 --- /dev/null +++ b/backend/internal/browser/download_core.go @@ -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 +} diff --git a/backend/internal/browser/environment.go b/backend/internal/browser/environment.go new file mode 100644 index 00000000..af49414f --- /dev/null +++ b/backend/internal/browser/environment.go @@ -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 +} diff --git a/backend/internal/browser/environment_test.go b/backend/internal/browser/environment_test.go new file mode 100644 index 00000000..53c140ad --- /dev/null +++ b/backend/internal/browser/environment_test.go @@ -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) + } +} diff --git a/backend/internal/browser/group_dao.go b/backend/internal/browser/group_dao.go new file mode 100644 index 00000000..c9f29e0e --- /dev/null +++ b/backend/internal/browser/group_dao.go @@ -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 +} diff --git a/backend/internal/browser/profile.go b/backend/internal/browser/profile.go new file mode 100644 index 00000000..69173f20 --- /dev/null +++ b/backend/internal/browser/profile.go @@ -0,0 +1,471 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +// InitData 初始化浏览器数据 +func (m *Manager) InitData() { + m.Mutex.Lock() + defer m.Mutex.Unlock() + if m.Profiles == nil { + m.Profiles = make(map[string]*Profile) + } + if m.BrowserProcesses == nil { + m.BrowserProcesses = make(map[string]*exec.Cmd) + } + if m.XrayBridges == nil { + m.XrayBridges = make(map[string]*XrayBridge) + } + // 执行配置迁移 + m.MigrateConfig() + if len(m.Profiles) > 0 { + return + } + m.loadProfiles() +} + +func (m *Manager) loadProfiles() { + log := logger.New("Browser") + + // 优先从 DAO(SQLite)加载 + if m.ProfileDAO != nil { + profiles, err := m.ProfileDAO.List() + if err != nil { + log.Error("从数据库加载实例配置失败", logger.F("error", err)) + } else { + // SQLite 模式:无论是否为空都直接使用,不自动创建默认实例 + for _, p := range profiles { + m.Profiles[p.ProfileId] = p + } + if len(profiles) > 0 { + log.Info("实例配置从数据库加载完成", logger.F("count", len(profiles))) + } else { + log.Info("实例表为空,用户可手动创建新实例") + } + return + } + } + + // 降级:从 config.yaml 加载(仅在无 SQLite 时使用) + if len(m.Config.Browser.Profiles) == 0 { + // 不自动创建默认实例,保持空列表 + log.Info("实例配置为空,用户可手动创建新实例") + return + } + now := time.Now().Format(time.RFC3339) + for _, item := range m.Config.Browser.Profiles { + profileId := strings.TrimSpace(item.ProfileId) + if profileId == "" { + continue + } + createdAt := strings.TrimSpace(item.CreatedAt) + if createdAt == "" { + createdAt = now + } + updatedAt := strings.TrimSpace(item.UpdatedAt) + if updatedAt == "" { + updatedAt = createdAt + } + m.Profiles[profileId] = &Profile{ + ProfileId: profileId, + ProfileName: item.ProfileName, + UserDataDir: item.UserDataDir, + CoreId: item.CoreId, + FingerprintArgs: append([]string{}, item.FingerprintArgs...), + ProxyId: item.ProxyId, + ProxyConfig: item.ProxyConfig, + LaunchArgs: append([]string{}, item.LaunchArgs...), + Tags: append([]string{}, item.Tags...), + Keywords: append([]string{}, item.Keywords...), + Running: false, + DebugPort: 0, + Pid: 0, + LastError: "", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + } + log.Info("浏览器配置从文件加载完成", logger.F("count", len(m.Profiles))) +} + +// SaveProfiles 保存所有实例配置(DAO 模式:逐条 upsert) +func (m *Manager) SaveProfiles() error { + log := logger.New("Browser") + if m.ProfileDAO != nil { + for _, profile := range m.Profiles { + if err := m.ProfileDAO.Upsert(profile); err != nil { + log.Error("实例配置持久化失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err)) + return err + } + } + log.Info("实例配置持久化成功", logger.F("count", len(m.Profiles))) + return nil + } + + // 降级:写回 config.yaml + profiles := make([]ProfileConfig, 0, len(m.Profiles)) + for _, profile := range m.Profiles { + profiles = append(profiles, ProfileConfig{ + ProfileId: profile.ProfileId, + ProfileName: profile.ProfileName, + UserDataDir: profile.UserDataDir, + CoreId: profile.CoreId, + FingerprintArgs: append([]string{}, profile.FingerprintArgs...), + ProxyId: profile.ProxyId, + ProxyConfig: profile.ProxyConfig, + LaunchArgs: append([]string{}, profile.LaunchArgs...), + Tags: append([]string{}, profile.Tags...), + Keywords: append([]string{}, profile.Keywords...), + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, + }) + } + m.Config.Browser.Profiles = profiles + if err := m.Config.Save(m.ResolveRelativePath("config.yaml")); err != nil { + log.Error("浏览器配置持久化失败", logger.F("error", err)) + return err + } + log.Info("浏览器配置持久化成功(文件)", logger.F("count", len(profiles))) + return nil +} + +// List 获取配置列表 +func (m *Manager) List() []Profile { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + list := make([]Profile, 0, len(m.Profiles)) + for _, profile := range m.Profiles { + p := *profile + if m.CodeProvider != nil { + if code, err := m.CodeProvider.EnsureCode(p.ProfileId); err == nil { + p.LaunchCode = code + } + } + list = append(list, p) + } + // 按 ProfileId 排序,保持稳定顺序 + sort.Slice(list, func(i, j int) bool { + return list[i].ProfileId < list[j].ProfileId + }) + log.Info("浏览器配置列表查询", logger.F("count", len(list))) + return list +} + +// ListByTag 按标签筛选配置列表 +func (m *Manager) ListByTag(tag string) []Profile { + tag = strings.TrimSpace(tag) + all := m.List() + if tag == "" { + return all + } + result := make([]Profile, 0) + for _, p := range all { + for _, t := range p.Tags { + if strings.EqualFold(t, tag) { + result = append(result, p) + break + } + } + } + return result +} + +// GetAllTags 获取所有已使用的标签(去重排序) +func (m *Manager) GetAllTags() []string { + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + seen := make(map[string]struct{}) + for _, p := range m.Profiles { + for _, t := range p.Tags { + t = strings.TrimSpace(t) + if t != "" { + seen[t] = struct{}{} + } + } + } + tags := make([]string, 0, len(seen)) + for t := range seen { + tags = append(tags, t) + } + sort.Strings(tags) + return tags +} + +// Create 创建配置 +func (m *Manager) Create(input ProfileInput) (*Profile, error) { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + + // Check Profile Limit + if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit { + return nil, fmt.Errorf("实例数量已达上限 (%d个),无法创建新的实例。请兑换额度后重试!", m.Config.App.MaxProfileLimit) + } + + now := time.Now().Format(time.RFC3339) + profileId := uuid.NewString() + userDataDir := strings.TrimSpace(input.UserDataDir) + if userDataDir == "" { + userDataDir = profileId + } + proxyConfig := strings.TrimSpace(input.ProxyConfig) + proxyId := strings.TrimSpace(input.ProxyId) + if proxyId != "" { + if resolved, ok := m.GetProxyConfigById(proxyId); ok { + proxyConfig = resolved + } else { + log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", proxyId)) + } + } + coreId := strings.TrimSpace(input.CoreId) + if coreId == "" { + if defaultCore, ok := m.GetDefaultCore(); ok { + coreId = defaultCore.CoreId + } + } + if proxyConfig == "" && m.Config.Browser.DefaultProxy != "" { + proxyConfig = m.Config.Browser.DefaultProxy + } + profile := &Profile{ + ProfileId: profileId, + ProfileName: input.ProfileName, + UserDataDir: userDataDir, + CoreId: coreId, + FingerprintArgs: input.FingerprintArgs, + ProxyId: proxyId, + ProxyConfig: proxyConfig, + LaunchArgs: input.LaunchArgs, + Tags: input.Tags, + Keywords: append([]string{}, input.Keywords...), + GroupId: strings.TrimSpace(input.GroupId), + Running: false, + DebugPort: 0, + Pid: 0, + LastError: "", + CreatedAt: now, + UpdatedAt: now, + } + m.Profiles[profileId] = profile + log.Info("浏览器配置创建", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName)) + if err := m.SaveProfiles(); err != nil { + return nil, err + } + if m.CodeProvider != nil { + if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil { + profile.LaunchCode = code + } + } + return profile, nil +} + +// Update 更新配置 +func (m *Manager) Update(profileId string, input ProfileInput) (*Profile, error) { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + profile, exists := m.Profiles[profileId] + if !exists { + log.Error("浏览器配置不存在", logger.F("profile_id", profileId)) + return nil, fmt.Errorf("profile not found") + } + profile.ProfileName = input.ProfileName + profile.UserDataDir = input.UserDataDir + profile.CoreId = input.CoreId + profile.FingerprintArgs = input.FingerprintArgs + profile.ProxyId = strings.TrimSpace(input.ProxyId) + if profile.ProxyId != "" { + if resolved, ok := m.GetProxyConfigById(profile.ProxyId); ok { + profile.ProxyConfig = resolved + } else { + profile.ProxyConfig = "" + log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId)) + } + } else { + profile.ProxyConfig = input.ProxyConfig + } + profile.LaunchArgs = input.LaunchArgs + profile.Tags = input.Tags + profile.Keywords = append([]string{}, input.Keywords...) + profile.GroupId = strings.TrimSpace(input.GroupId) + profile.UpdatedAt = time.Now().Format(time.RFC3339) + log.Info("浏览器配置更新", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName)) + if err := m.SaveProfiles(); err != nil { + return nil, err + } + return profile, nil +} + +// Delete 删除配置 +func (m *Manager) Delete(profileId string) error { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + if _, exists := m.Profiles[profileId]; !exists { + log.Error("浏览器配置不存在", logger.F("profile_id", profileId)) + return fmt.Errorf("profile not found") + } + delete(m.Profiles, profileId) + log.Info("浏览器配置删除", logger.F("profile_id", profileId)) + + // DAO 删除 + if m.ProfileDAO != nil { + if err := m.ProfileDAO.Delete(profileId); err != nil { + log.Error("数据库删除实例失败", logger.F("profile_id", profileId), logger.F("error", err)) + return err + } + } else { + if err := m.SaveProfiles(); err != nil { + return err + } + } + + if m.CodeProvider != nil { + _ = m.CodeProvider.Remove(profileId) + } + return nil +} + +// ApplyDefaults 应用默认配置 +func (m *Manager) ApplyDefaults(profile *Profile) bool { + log := logger.New("Browser") + if profile.FingerprintArgs == nil || len(profile.FingerprintArgs) == 0 { + profile.FingerprintArgs = append([]string{}, m.Config.Browser.DefaultFingerprintArgs...) + } + if profile.LaunchArgs == nil || len(profile.LaunchArgs) == 0 { + profile.LaunchArgs = append([]string{}, m.Config.Browser.DefaultLaunchArgs...) + } + if strings.TrimSpace(profile.UserDataDir) == "" { + profile.UserDataDir = profile.ProfileId + } + if strings.TrimSpace(profile.CoreId) == "" { + if defaultCore, ok := m.GetDefaultCore(); ok { + profile.CoreId = defaultCore.CoreId + } + } + proxyChanged := false + if profile.ProxyId != "" { + if proxyConfig, ok := m.GetProxyConfigById(profile.ProxyId); ok { + if proxyConfig != "" && profile.ProxyConfig != proxyConfig { + profile.ProxyConfig = proxyConfig + proxyChanged = true + } + } else { + log.Error("实例代理未找到", logger.F("profile_id", profile.ProfileId), logger.F("proxy_id", profile.ProxyId)) + } + } + if profile.ProxyConfig == "" && m.Config.Browser.DefaultProxy != "" { + profile.ProxyConfig = m.Config.Browser.DefaultProxy + proxyChanged = true + } + return proxyChanged +} + +// Copy 复制实例配置(除指纹参数外全部复制,指纹使用默认值生成新种子) +func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + + // Check Profile Limit + if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit { + log.Error("复制实例失败: 达到数量上限", logger.F("limit", m.Config.App.MaxProfileLimit)) + return nil, fmt.Errorf("实例数量已达上限 (%d个),无法复制实例。请兑换额度后重试!", m.Config.App.MaxProfileLimit) + } + + src, exists := m.Profiles[profileId] + if !exists { + log.Error("源实例不存在", logger.F("profile_id", profileId)) + return nil, fmt.Errorf("profile not found") + } + + now := time.Now().Format(time.RFC3339) + newId := uuid.NewString() + + // 处理名称 + profileName := strings.TrimSpace(newName) + if profileName == "" { + profileName = src.ProfileName + " (副本)" + } + + // 复制配置,指纹参数使用默认值(新种子) + profile := &Profile{ + ProfileId: newId, + ProfileName: profileName, + UserDataDir: newId, // 新的用户数据目录 + CoreId: src.CoreId, + FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), // 使用默认指纹(新种子) + ProxyId: src.ProxyId, + ProxyConfig: src.ProxyConfig, + LaunchArgs: append([]string{}, src.LaunchArgs...), + Tags: append([]string{}, src.Tags...), + Keywords: append([]string{}, src.Keywords...), + GroupId: src.GroupId, // 复制分组 + Running: false, + DebugPort: 0, + Pid: 0, + LastError: "", + CreatedAt: now, + UpdatedAt: now, + } + + m.Profiles[newId] = profile + log.Info("实例复制成功", logger.F("src_id", profileId), logger.F("new_id", newId), logger.F("new_name", profileName)) + + if err := m.SaveProfiles(); err != nil { + return nil, err + } + + if m.CodeProvider != nil { + if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil { + profile.LaunchCode = code + } + } + + return profile, nil +} + +// SetKeywords 设置实例关键字(独立接口,不影响其他字段) +func (m *Manager) SetKeywords(profileId string, keywords []string) (*Profile, error) { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + profile, exists := m.Profiles[profileId] + if !exists { + return nil, fmt.Errorf("profile not found") + } + profile.Keywords = append([]string{}, keywords...) + profile.UpdatedAt = time.Now().Format(time.RFC3339) + log.Info("关键字更新", logger.F("profile_id", profileId)) + if err := m.SaveProfiles(); err != nil { + return nil, err + } + return profile, nil +} + +// copyKeywords 深拷贝 keywords map +func copyKeywords(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/backend/internal/browser/profile_dao.go b/backend/internal/browser/profile_dao.go new file mode 100644 index 00000000..e6f4cc50 --- /dev/null +++ b/backend/internal/browser/profile_dao.go @@ -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 +} diff --git a/backend/internal/browser/proxy_dao.go b/backend/internal/browser/proxy_dao.go new file mode 100644 index 00000000..4fb46bce --- /dev/null +++ b/backend/internal/browser/proxy_dao.go @@ -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() +} diff --git a/backend/internal/browser/proxy_speed.go b/backend/internal/browser/proxy_speed.go new file mode 100644 index 00000000..0a8978cd --- /dev/null +++ b/backend/internal/browser/proxy_speed.go @@ -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() +} diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go new file mode 100644 index 00000000..6a39f1f1 --- /dev/null +++ b/backend/internal/browser/types.go @@ -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 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 00000000..35078858 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,430 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// LaunchServerConfig Launch HTTP 服务配置 +type LaunchServerConfig struct { + // Port <= 0 时自动分配随机可用端口(推荐)。 + Port int `yaml:"port"` +} + +// Config 应用配置 +type Config struct { + Database DatabaseConfig `yaml:"database"` + App AppConfig `yaml:"app"` + Runtime RuntimeConfig `yaml:"runtime"` + Logging LoggingConfig `yaml:"logging"` + Browser BrowserConfig `yaml:"browser"` + LaunchServer LaunchServerConfig `yaml:"launch_server"` +} + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + Type string `yaml:"type"` + SQLite SQLiteConfig `yaml:"sqlite"` +} + +// SQLiteConfig SQLite 配置 +type SQLiteConfig struct { + Path string `yaml:"path"` +} + +// AppConfig 应用配置 +type AppConfig struct { + Name string `yaml:"name"` + Window WindowConfig `yaml:"window"` + MaxProfileLimit int `yaml:"max_profile_limit"` + UsedCDKeys []string `yaml:"used_cd_keys"` +} + +// WindowConfig 窗口配置 +type WindowConfig struct { + Width int `yaml:"width"` + Height int `yaml:"height"` + MinWidth int `yaml:"min_width"` + MinHeight int `yaml:"min_height"` +} + +// RuntimeConfig 运行时配置 +type RuntimeConfig struct { + MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存限制(MB) + GCPercent int `yaml:"gc_percent"` // GC 触发百分比 +} + +type BrowserBookmark struct { + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` +} + +type BrowserConfig struct { + UserDataRoot string `yaml:"user_data_root"` + DefaultFingerprintArgs []string `yaml:"default_fingerprint_args"` + DefaultLaunchArgs []string `yaml:"default_launch_args"` + DefaultProxy string `yaml:"default_proxy"` + DefaultBookmarks []BrowserBookmark `yaml:"default_bookmarks,omitempty"` + Cores []BrowserCore `yaml:"cores,omitempty"` + Proxies []BrowserProxy `yaml:"proxies,omitempty"` + Profiles []BrowserProfileConfig `yaml:"profiles,omitempty"` + // 废弃字段,保留用于迁移 + ChromeBinaryPath string `yaml:"chrome_binary_path,omitempty"` + ClashBinaryPath string `yaml:"clash_binary_path,omitempty"` + XrayBinaryPath string `yaml:"xray_binary_path,omitempty"` + SingBoxBinaryPath string `yaml:"singbox_binary_path,omitempty"` + CoreRoot string `yaml:"core_root,omitempty"` + DefaultCoreId string `yaml:"default_core_id,omitempty"` + DefaultConnectorType string `yaml:"default_connector_type,omitempty"` + Environments []BrowserEnvironment `yaml:"environments,omitempty"` +} + +type BrowserCore struct { + CoreId string `yaml:"core_id" json:"coreId"` + CoreName string `yaml:"core_name" json:"coreName"` + CorePath string `yaml:"core_path" json:"corePath"` + IsDefault bool `yaml:"is_default" json:"isDefault"` +} + +type BrowserProxy struct { + ProxyId string `yaml:"proxy_id" json:"proxyId"` + ProxyName string `yaml:"proxy_name" json:"proxyName"` + ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"` + DnsServers string `yaml:"dns_servers,omitempty" json:"dnsServers,omitempty"` + GroupName string `yaml:"group_name,omitempty" json:"groupName,omitempty"` + SortOrder int `yaml:"sort_order,omitempty" json:"sortOrder,omitempty"` + SourceID string `yaml:"source_id,omitempty" json:"sourceId,omitempty"` + SourceURL string `yaml:"source_url,omitempty" json:"sourceUrl,omitempty"` + // URL 导入时的名称前缀,用于后续自动刷新时重建同名策略 + SourceNamePrefix string `yaml:"source_name_prefix,omitempty" json:"sourceNamePrefix,omitempty"` + // URL 导入自动刷新开关与间隔(分钟) + SourceAutoRefresh bool `yaml:"source_auto_refresh,omitempty" json:"sourceAutoRefresh,omitempty"` + SourceRefreshIntervalM int `yaml:"source_refresh_interval_m,omitempty" json:"sourceRefreshIntervalM,omitempty"` + SourceLastRefreshAt string `yaml:"source_last_refresh_at,omitempty" json:"sourceLastRefreshAt,omitempty"` + // 测速结果(运行时字段,不写入 yaml) + LastLatencyMs int64 `yaml:"-" json:"lastLatencyMs"` + LastTestOk bool `yaml:"-" json:"lastTestOk"` + LastTestedAt string `yaml:"-" json:"lastTestedAt"` + // IP 健康检测原始结果(运行时字段,不写入 yaml) + LastIPHealthJSON string `yaml:"-" json:"lastIPHealthJson,omitempty"` +} + +type BrowserEnvironment struct { + CoreId string `yaml:"core_id" json:"coreId"` + CoreName string `yaml:"core_name" json:"coreName"` + CorePath string `yaml:"core_path" json:"corePath"` + ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"` + ConnectorType string `yaml:"connector_type" json:"connectorType"` + IsDefault bool `yaml:"is_default" json:"isDefault"` +} + +type BrowserProfileConfig struct { + ProfileId string `yaml:"profile_id" json:"profileId"` + ProfileName string `yaml:"profile_name" json:"profileName"` + UserDataDir string `yaml:"user_data_dir" json:"userDataDir"` + CoreId string `yaml:"core_id" json:"coreId"` + FingerprintArgs []string `yaml:"fingerprint_args" json:"fingerprintArgs"` + ProxyId string `yaml:"proxy_id" json:"proxyId"` + ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"` + LaunchArgs []string `yaml:"launch_args" json:"launchArgs"` + Tags []string `yaml:"tags" json:"tags"` + Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"` + CreatedAt string `yaml:"created_at" json:"createdAt"` + UpdatedAt string `yaml:"updated_at" json:"updatedAt"` +} + +// LoggingConfig 日志配置 +type LoggingConfig struct { + Level string `yaml:"level"` + FileEnabled bool `yaml:"file_enabled"` + FilePath string `yaml:"file_path"` + Format string `yaml:"format"` // "text" or "json" + + // 性能配置 + BufferSize int `yaml:"buffer_size"` // 缓冲区大小(KB) + AsyncQueueSize int `yaml:"async_queue_size"` // 异步队列大小 + FlushIntervalMs int `yaml:"flush_interval_ms"` // 刷新间隔(毫秒) + + // 分片配置 + Rotation RotationConfig `yaml:"rotation"` + + // 方法拦截配置 + Interceptor InterceptorConfig `yaml:"interceptor"` +} + +// RotationConfig 日志分片配置 +type RotationConfig struct { + Enabled bool `yaml:"enabled"` + MaxSizeMB int `yaml:"max_size_mb"` // 单文件最大大小(MB) + MaxAge int `yaml:"max_age"` // 保留天数 + MaxBackups int `yaml:"max_backups"` // 保留文件数 + TimeInterval string `yaml:"time_interval"` // 时间间隔: "daily", "hourly" +} + +// InterceptorConfig 方法拦截器配置 +type InterceptorConfig struct { + Enabled bool `yaml:"enabled"` + LogParameters bool `yaml:"log_parameters"` // 是否记录参数 + LogResults bool `yaml:"log_results"` // 是否记录返回值 + SensitiveFields []string `yaml:"sensitive_fields"` // 敏感字段(脱敏) +} + +// Load 加载配置文件 +func Load(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + normalizeConfig(&config) + + return &config, nil +} + +// normalizeConfig 对历史配置进行字段补齐,不覆盖用户已配置值。 +func normalizeConfig(config *Config) { + defaultConfig := DefaultConfig() + + if strings.TrimSpace(config.Database.Type) == "" { + config.Database.Type = defaultConfig.Database.Type + } + if strings.TrimSpace(config.Database.SQLite.Path) == "" { + config.Database.SQLite.Path = defaultConfig.Database.SQLite.Path + } + + if strings.TrimSpace(config.App.Name) == "" { + config.App.Name = defaultConfig.App.Name + } + if config.App.Window.Width <= 0 { + config.App.Window.Width = defaultConfig.App.Window.Width + } + if config.App.Window.Height <= 0 { + config.App.Window.Height = defaultConfig.App.Window.Height + } + if config.App.Window.MinWidth <= 0 { + config.App.Window.MinWidth = defaultConfig.App.Window.MinWidth + } + if config.App.Window.MinHeight <= 0 { + config.App.Window.MinHeight = defaultConfig.App.Window.MinHeight + } + if config.App.UsedCDKeys == nil { + config.App.UsedCDKeys = []string{} + } + + // 兼容老版本配置: 如果之前没有 max_profile_limit,它会被解析成 0, + // 若用户在 0 状态下兑换了额度(比如 0+3=3),基础的 3 额度会被覆盖。 + // 这里通过统计兑换记录重新保底验证它的额度即可修复。 + expectedLimit := defaultConfig.App.MaxProfileLimit + for _, k := range config.App.UsedCDKeys { + if k == "GITHUB_STAR_REWARD" { + expectedLimit += 3 + } else { + expectedLimit += 3 + } + } + if config.App.MaxProfileLimit < expectedLimit { + config.App.MaxProfileLimit = expectedLimit + } + + if config.Runtime.MaxMemoryMB <= 0 { + config.Runtime.MaxMemoryMB = defaultConfig.Runtime.MaxMemoryMB + } + if config.Runtime.GCPercent <= 0 { + config.Runtime.GCPercent = defaultConfig.Runtime.GCPercent + } + + if strings.TrimSpace(config.Logging.Level) == "" { + config.Logging.Level = defaultConfig.Logging.Level + } + if isLegacyDefaultLogPath(config.Logging.FilePath) || strings.TrimSpace(config.Logging.FilePath) == "" { + config.Logging.FilePath = defaultConfig.Logging.FilePath + } + if strings.TrimSpace(config.Logging.Format) == "" { + config.Logging.Format = defaultConfig.Logging.Format + } + if config.Logging.BufferSize <= 0 { + config.Logging.BufferSize = defaultConfig.Logging.BufferSize + } + if config.Logging.AsyncQueueSize <= 0 { + config.Logging.AsyncQueueSize = defaultConfig.Logging.AsyncQueueSize + } + if config.Logging.FlushIntervalMs <= 0 { + config.Logging.FlushIntervalMs = defaultConfig.Logging.FlushIntervalMs + } + if config.Logging.Rotation.MaxSizeMB <= 0 { + config.Logging.Rotation.MaxSizeMB = defaultConfig.Logging.Rotation.MaxSizeMB + } + if config.Logging.Rotation.MaxAge <= 0 { + config.Logging.Rotation.MaxAge = defaultConfig.Logging.Rotation.MaxAge + } + if config.Logging.Rotation.MaxBackups <= 0 { + config.Logging.Rotation.MaxBackups = defaultConfig.Logging.Rotation.MaxBackups + } + if strings.TrimSpace(config.Logging.Rotation.TimeInterval) == "" { + config.Logging.Rotation.TimeInterval = defaultConfig.Logging.Rotation.TimeInterval + } + + interceptorAllZero := !config.Logging.Interceptor.Enabled && + !config.Logging.Interceptor.LogParameters && + !config.Logging.Interceptor.LogResults && + config.Logging.Interceptor.SensitiveFields == nil + if interceptorAllZero { + config.Logging.Interceptor = cloneInterceptorConfig(defaultConfig.Logging.Interceptor) + } else if config.Logging.Interceptor.SensitiveFields == nil { + config.Logging.Interceptor.SensitiveFields = append([]string{}, defaultConfig.Logging.Interceptor.SensitiveFields...) + } + + if strings.TrimSpace(config.Browser.UserDataRoot) == "" { + config.Browser.UserDataRoot = defaultConfig.Browser.UserDataRoot + } + if len(config.Browser.DefaultFingerprintArgs) == 0 { + config.Browser.DefaultFingerprintArgs = append([]string{}, defaultConfig.Browser.DefaultFingerprintArgs...) + } + if len(config.Browser.DefaultLaunchArgs) == 0 { + config.Browser.DefaultLaunchArgs = append([]string{}, defaultConfig.Browser.DefaultLaunchArgs...) + } + if config.Browser.DefaultBookmarks == nil { + config.Browser.DefaultBookmarks = []BrowserBookmark{} + } + if config.Browser.Cores == nil { + config.Browser.Cores = []BrowserCore{} + } + if config.Browser.Proxies == nil { + config.Browser.Proxies = []BrowserProxy{} + } + if config.Browser.Profiles == nil { + config.Browser.Profiles = []BrowserProfileConfig{} + } + + if config.LaunchServer.Port < 0 { + config.LaunchServer.Port = defaultConfig.LaunchServer.Port + } +} + +func cloneInterceptorConfig(src InterceptorConfig) InterceptorConfig { + dst := src + dst.SensitiveFields = append([]string{}, src.SensitiveFields...) + return dst +} + +func isLegacyDefaultLogPath(path string) bool { + return strings.EqualFold(filepath.ToSlash(strings.TrimSpace(path)), "logs/app.log") +} + +// DefaultConfig 返回默认配置 +func DefaultConfig() *Config { + return &Config{ + Database: DatabaseConfig{ + Type: "sqlite", + SQLite: SQLiteConfig{ + Path: "data/app.db", + }, + }, + App: AppConfig{ + Name: "Ant Browser", + Window: WindowConfig{ + Width: 1750, + Height: 1000, + MinWidth: 1200, + MinHeight: 700, + }, + MaxProfileLimit: 3, + UsedCDKeys: []string{}, + }, + Runtime: RuntimeConfig{ + MaxMemoryMB: 1024, // 默认 1GB + GCPercent: 100, // 默认 100% + }, + Browser: BrowserConfig{ + UserDataRoot: "data", + DefaultFingerprintArgs: []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"}, + DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"}, + DefaultProxy: "", + }, + Logging: LoggingConfig{ + Level: "info", + FileEnabled: false, + FilePath: "data/logs/app.log", + Format: "text", + BufferSize: 4, // 4KB + AsyncQueueSize: 1000, + FlushIntervalMs: 1000, // 1秒 + Rotation: RotationConfig{ + Enabled: false, + MaxSizeMB: 100, + MaxAge: 7, + MaxBackups: 5, + TimeInterval: "daily", + }, + Interceptor: InterceptorConfig{ + Enabled: true, + LogParameters: true, + LogResults: true, + SensitiveFields: []string{"password", "token", "secret"}, + }, + }, + LaunchServer: LaunchServerConfig{ + Port: 0, + }, + } +} + +// Save 保存配置到文件 +func (c *Config) Save(configPath string) error { + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("序列化配置失败: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("写入配置文件失败: %w", err) + } + + return nil +} + +// ProxyStore 代理数据文件结构 +type ProxyStore struct { + Proxies []BrowserProxy `yaml:"proxies"` +} + +// LoadProxies 从独立文件加载代理列表 +func LoadProxies(path string) ([]BrowserProxy, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("读取代理文件失败: %w", err) + } + var store ProxyStore + if err := yaml.Unmarshal(data, &store); err != nil { + return nil, fmt.Errorf("解析代理文件失败: %w", err) + } + return store.Proxies, nil +} + +// SaveProxies 将代理列表保存到独立文件 +func SaveProxies(path string, proxies []BrowserProxy) error { + store := ProxyStore{Proxies: proxies} + data, err := yaml.Marshal(store) + if err != nil { + return fmt.Errorf("序列化代理数据失败: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("写入代理文件失败: %w", err) + } + return nil +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 00000000..344bcb7a --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,179 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadBackfillsLegacyConfig(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + legacyConfig := ` +app: + used_cd_keys: + - GITHUB_STAR_REWARD +logging: {} +browser: {} +` + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + + if cfg.Database.Type != "sqlite" { + t.Fatalf("Database.Type 未补齐: got=%q", cfg.Database.Type) + } + if cfg.Database.SQLite.Path != "data/app.db" { + t.Fatalf("Database.SQLite.Path 未补齐: got=%q", cfg.Database.SQLite.Path) + } + if cfg.App.Name != "Ant Browser" { + t.Fatalf("App.Name 未补齐: got=%q", cfg.App.Name) + } + if cfg.App.MaxProfileLimit != 6 { + t.Fatalf("MaxProfileLimit 计算错误: got=%d want=6", cfg.App.MaxProfileLimit) + } + if cfg.Runtime.MaxMemoryMB != 1024 || cfg.Runtime.GCPercent != 100 { + t.Fatalf("Runtime 未补齐: got=%+v", cfg.Runtime) + } + if cfg.Logging.Level != "info" || cfg.Logging.FilePath != "data/logs/app.log" { + t.Fatalf("Logging 基础字段未补齐: got=%+v", cfg.Logging) + } + if !cfg.Logging.Interceptor.Enabled || !cfg.Logging.Interceptor.LogParameters || !cfg.Logging.Interceptor.LogResults { + t.Fatalf("Interceptor 默认值未补齐: got=%+v", cfg.Logging.Interceptor) + } + if len(cfg.Logging.Interceptor.SensitiveFields) == 0 { + t.Fatalf("Interceptor.SensitiveFields 未补齐") + } + if cfg.Browser.UserDataRoot != "data" { + t.Fatalf("Browser.UserDataRoot 未补齐: got=%q", cfg.Browser.UserDataRoot) + } + if len(cfg.Browser.DefaultFingerprintArgs) == 0 || len(cfg.Browser.DefaultLaunchArgs) == 0 { + t.Fatalf("Browser 默认启动参数未补齐") + } + if cfg.Browser.Cores == nil || cfg.Browser.Proxies == nil || cfg.Browser.Profiles == nil { + t.Fatalf("Browser 列表字段应初始化为空切片") + } + if cfg.LaunchServer.Port != 0 { + t.Fatalf("LaunchServer.Port 未补齐: got=%d", cfg.LaunchServer.Port) + } +} + +func TestLoadPreservesExplicitConfig(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + customConfig := ` +database: + type: sqlite + sqlite: + path: custom/app.db +app: + name: Custom App + window: + width: 1400 + height: 800 + min_width: 900 + min_height: 600 + max_profile_limit: 20 + used_cd_keys: [] +runtime: + max_memory_mb: 2048 + gc_percent: 80 +logging: + level: debug + file_enabled: true + file_path: custom.log + format: json + buffer_size: 8 + async_queue_size: 2000 + flush_interval_ms: 500 + rotation: + enabled: true + max_size_mb: 10 + max_age: 3 + max_backups: 2 + time_interval: hourly + interceptor: + enabled: false + log_parameters: false + log_results: false + sensitive_fields: [] +browser: + user_data_root: custom_data + default_fingerprint_args: + - --fingerprint-brand=Edge + default_launch_args: + - --start-maximized + default_proxy: direct:// + default_bookmarks: [] + cores: [] + proxies: [] + profiles: [] +launch_server: + port: 30000 +` + if err := os.WriteFile(configPath, []byte(customConfig), 0o644); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + + if cfg.App.Name != "Custom App" || cfg.App.MaxProfileLimit != 20 { + t.Fatalf("App 显式配置被覆盖: got=%+v", cfg.App) + } + if cfg.Database.SQLite.Path != "custom/app.db" { + t.Fatalf("Database.SQLite.Path 显式配置被覆盖: got=%q", cfg.Database.SQLite.Path) + } + if cfg.Runtime.MaxMemoryMB != 2048 || cfg.Runtime.GCPercent != 80 { + t.Fatalf("Runtime 显式配置被覆盖: got=%+v", cfg.Runtime) + } + if cfg.Logging.Level != "debug" || cfg.Logging.Format != "json" || !cfg.Logging.FileEnabled { + t.Fatalf("Logging 显式配置被覆盖: got=%+v", cfg.Logging) + } + if cfg.Logging.Interceptor.Enabled { + t.Fatalf("Interceptor.Enabled 显式 false 被覆盖") + } + if len(cfg.Browser.DefaultFingerprintArgs) != 1 || cfg.Browser.DefaultFingerprintArgs[0] != "--fingerprint-brand=Edge" { + t.Fatalf("Browser.DefaultFingerprintArgs 显式配置被覆盖: got=%v", cfg.Browser.DefaultFingerprintArgs) + } + if cfg.Browser.UserDataRoot != "custom_data" || cfg.Browser.DefaultProxy != "direct://" { + t.Fatalf("Browser 显式配置被覆盖: got=%+v", cfg.Browser) + } + if cfg.LaunchServer.Port != 30000 { + t.Fatalf("LaunchServer.Port 显式配置被覆盖: got=%d", cfg.LaunchServer.Port) + } +} + +func TestLoadMigratesLegacyRootLogPath(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + legacyConfig := ` +logging: + file_path: logs/app.log +` + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + + if cfg.Logging.FilePath != "data/logs/app.log" { + t.Fatalf("legacy 根目录日志路径未迁移: got=%q", cfg.Logging.FilePath) + } +} diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go new file mode 100644 index 00000000..7c5bbc32 --- /dev/null +++ b/backend/internal/database/sqlite.go @@ -0,0 +1,257 @@ +package database + +import ( + "database/sql" + "fmt" + "strings" + + _ "modernc.org/sqlite" +) + +// DB 数据库连接 +type DB struct { + conn *sql.DB +} + +// migration 单个版本迁移 +type migration struct { + version int // 版本号,单调递增,永不修改 + desc string // 描述,便于日志追踪 + stmts []string +} + +// migrations 所有版本迁移,按 version 升序排列 +// 规则: +// - 只能追加新版本,绝对不能修改已有版本 +// - version 从 1 开始,每次发布新版本时递增 +// - 每个 version 对应一批幂等的 DDL 语句 +var migrations = []migration{ + { + version: 1, + desc: "初始化核心表结构", + stmts: []string{ + `CREATE TABLE IF NOT EXISTS launch_codes ( + profile_id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`, + + `CREATE TABLE IF NOT EXISTS browser_profiles ( + profile_id TEXT PRIMARY KEY, + profile_name TEXT NOT NULL, + user_data_dir TEXT NOT NULL DEFAULT '', + core_id TEXT NOT NULL DEFAULT '', + fingerprint_args TEXT NOT NULL DEFAULT '[]', + proxy_id TEXT NOT NULL DEFAULT '', + proxy_config TEXT NOT NULL DEFAULT '', + launch_args TEXT NOT NULL DEFAULT '[]', + tags TEXT NOT NULL DEFAULT '[]', + keywords TEXT NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_browser_profiles_created_at ON browser_profiles(created_at)`, + + `CREATE TABLE IF NOT EXISTS browser_proxies ( + proxy_id TEXT PRIMARY KEY, + proxy_name TEXT NOT NULL, + proxy_config TEXT NOT NULL, + dns_servers TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS browser_cores ( + core_id TEXT PRIMARY KEY, + core_name TEXT NOT NULL, + core_path TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS browser_bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0 + )`, + }, + }, + { + version: 2, + desc: "添加实例分组支持", + stmts: []string{ + `CREATE TABLE IF NOT EXISTS browser_groups ( + group_id TEXT PRIMARY KEY, + group_name TEXT NOT NULL, + parent_id TEXT DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_browser_groups_parent_id ON browser_groups(parent_id)`, + `ALTER TABLE browser_profiles ADD COLUMN group_id TEXT DEFAULT ''`, + }, + }, + { + version: 3, + desc: "代理表添加分组和测速字段", + stmts: []string{ + `ALTER TABLE browser_proxies ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_proxies ADD COLUMN last_latency_ms INTEGER NOT NULL DEFAULT -1`, + `ALTER TABLE browser_proxies ADD COLUMN last_test_ok INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE browser_proxies ADD COLUMN last_tested_at TEXT NOT NULL DEFAULT ''`, + }, + }, + { + version: 4, + desc: "代理表添加 IP 健康结果字段", + stmts: []string{ + `ALTER TABLE browser_proxies ADD COLUMN last_ip_health_json TEXT NOT NULL DEFAULT ''`, + }, + }, + { + version: 5, + desc: "代理表添加 URL 来源与自动刷新字段", + stmts: []string{ + `ALTER TABLE browser_proxies ADD COLUMN source_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_proxies ADD COLUMN source_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_proxies ADD COLUMN source_name_prefix TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_proxies ADD COLUMN source_auto_refresh INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE browser_proxies ADD COLUMN source_refresh_interval_m INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE browser_proxies ADD COLUMN source_last_refresh_at TEXT NOT NULL DEFAULT ''`, + }, + }, + // ── 新版本在此追加,格式: + // { + // version: 4, + // desc: "描述本次变更", + // stmts: []string{ + // `ALTER TABLE xxx ADD COLUMN yyy TEXT NOT NULL DEFAULT ''`, + // }, + // }, +} + +// NewDB 创建新的数据库连接 +func NewDB(dbPath string) (*DB, error) { + conn, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + + conn.SetMaxOpenConns(1) + conn.SetMaxIdleConns(1) + + if err := conn.Ping(); err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + // WAL 模式:写不阻塞读 + if _, err := conn.Exec(`PRAGMA journal_mode=WAL`); err != nil { + return nil, fmt.Errorf("设置 WAL 模式失败: %w", err) + } + // 开启外键约束 + if _, err := conn.Exec(`PRAGMA foreign_keys=ON`); err != nil { + return nil, fmt.Errorf("开启外键约束失败: %w", err) + } + + return &DB{conn: conn}, nil +} + +// GetConn 获取数据库连接 +func (db *DB) GetConn() *sql.DB { + return db.conn +} + +// Close 关闭数据库连接 +func (db *DB) Close() error { + if db.conn != nil { + return db.conn.Close() + } + return nil +} + +// Migrate 执行版本化迁移 +// 原理:维护 schema_migrations 表记录已执行版本,每次启动只执行未执行的版本 +func (db *DB) Migrate() error { + // 确保版本记录表存在 + if _, err := db.conn.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + desc TEXT NOT NULL DEFAULT '', + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + return fmt.Errorf("创建 schema_migrations 表失败: %w", err) + } + + // 查询已执行的最大版本号 + var currentVersion int + row := db.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`) + if err := row.Scan(¤tVersion); err != nil { + return fmt.Errorf("查询当前 schema 版本失败: %w", err) + } + + // 按版本顺序执行未执行的迁移 + for _, m := range migrations { + if m.version <= currentVersion { + continue // 已执行,跳过 + } + + // 每个版本在事务内执行,保证原子性 + if err := db.applyMigration(m); err != nil { + return fmt.Errorf("迁移版本 %d (%s) 失败: %w", m.version, m.desc, err) + } + } + + return nil +} + +// applyMigration 在事务内执行单个版本的所有语句,并记录版本号 +func (db *DB) applyMigration(m migration) error { + tx, err := db.conn.Begin() + if err != nil { + return fmt.Errorf("开启事务失败: %w", err) + } + defer tx.Rollback() + + for _, stmt := range m.stmts { + if _, err := tx.Exec(stmt); err != nil { + // ALTER TABLE 添加已存在列时忽略(兼容从旧版本直接升级的情况) + if isColumnExistsError(err) { + continue + } + return fmt.Errorf("执行语句失败 [%s]: %w", truncate(stmt, 60), err) + } + } + + // 记录版本号 + if _, err := tx.Exec( + `INSERT INTO schema_migrations (version, desc) VALUES (?, ?)`, + m.version, m.desc, + ); err != nil { + return fmt.Errorf("记录迁移版本失败: %w", err) + } + + return tx.Commit() +} + +// isColumnExistsError 检查是否是列已存在的错误(SQLite 错误信息) +func isColumnExistsError(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "duplicate column") || strings.Contains(s, "already exists") +} + +// truncate 截断字符串用于日志展示 +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/backend/internal/launchcode/dao.go b/backend/internal/launchcode/dao.go new file mode 100644 index 00000000..15a8af74 --- /dev/null +++ b/backend/internal/launchcode/dao.go @@ -0,0 +1,112 @@ +package launchcode + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// LaunchCodeDAO Launch Code 持久化接口 +type LaunchCodeDAO interface { + // FindProfileId 根据 code 查询 profileId + FindProfileId(code string) (string, error) + // FindCode 根据 profileId 查询 code + FindCode(profileId string) (string, error) + // Upsert 保存或更新映射 + Upsert(profileId, code string) error + // Delete 删除映射(实例删除时调用) + Delete(profileId string) error + // LoadAll 加载所有映射(启动时用),返回 profileId -> code 的 map + LoadAll() (map[string]string, error) +} + +// SQLiteLaunchCodeDAO 基于 SQLite 的 LaunchCodeDAO 实现 +type SQLiteLaunchCodeDAO struct { + db *sql.DB +} + +// NewSQLiteLaunchCodeDAO 创建 SQLiteLaunchCodeDAO +func NewSQLiteLaunchCodeDAO(db *sql.DB) *SQLiteLaunchCodeDAO { + return &SQLiteLaunchCodeDAO{db: db} +} + +// FindProfileId 根据 code 查询 profileId +func (d *SQLiteLaunchCodeDAO) FindProfileId(code string) (string, error) { + var profileId string + err := d.db.QueryRow( + `SELECT profile_id FROM launch_codes WHERE code = ?`, code, + ).Scan(&profileId) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("launch code not found: %s", code) + } + if err != nil { + return "", fmt.Errorf("查询 launch code 失败: %w", err) + } + return profileId, nil +} + +// FindCode 根据 profileId 查询 code +func (d *SQLiteLaunchCodeDAO) FindCode(profileId string) (string, error) { + var code string + err := d.db.QueryRow( + `SELECT code FROM launch_codes WHERE profile_id = ?`, profileId, + ).Scan(&code) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("profile not found: %s", profileId) + } + if err != nil { + return "", fmt.Errorf("查询 profile code 失败: %w", err) + } + return code, nil +} + +// Upsert 保存或更新 profileId <-> code 映射 +func (d *SQLiteLaunchCodeDAO) Upsert(profileId, code string) error { + now := time.Now().UTC().Format("2006-01-02 15:04:05") + _, err := d.db.Exec( + `INSERT INTO launch_codes (profile_id, code, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(profile_id) DO UPDATE SET + code = excluded.code, + updated_at = excluded.updated_at`, + profileId, code, now, now, + ) + if err != nil { + return fmt.Errorf("保存 launch code 失败: %w", err) + } + return nil +} + +// Delete 删除 profileId 对应的映射 +func (d *SQLiteLaunchCodeDAO) Delete(profileId string) error { + _, err := d.db.Exec( + `DELETE FROM launch_codes WHERE profile_id = ?`, profileId, + ) + if err != nil { + return fmt.Errorf("删除 launch code 失败: %w", err) + } + return nil +} + +// LoadAll 加载所有映射,返回 profileId -> code 的 map +func (d *SQLiteLaunchCodeDAO) LoadAll() (map[string]string, error) { + rows, err := d.db.Query(`SELECT profile_id, code FROM launch_codes`) + if err != nil { + return nil, fmt.Errorf("加载 launch codes 失败: %w", err) + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var profileId, code string + if err := rows.Scan(&profileId, &code); err != nil { + return nil, fmt.Errorf("读取 launch code 行失败: %w", err) + } + result[profileId] = code + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历 launch codes 失败: %w", err) + } + return result, nil +} diff --git a/backend/internal/launchcode/dao_memory.go b/backend/internal/launchcode/dao_memory.go new file mode 100644 index 00000000..7de32d4b --- /dev/null +++ b/backend/internal/launchcode/dao_memory.go @@ -0,0 +1,73 @@ +package launchcode + +import ( + "fmt" + "sync" +) + +// MemoryLaunchCodeDAO 基于内存的 LaunchCodeDAO 实现,仅用于测试 +type MemoryLaunchCodeDAO struct { + mu sync.RWMutex + profileToCode map[string]string + codeToProfile map[string]string +} + +// NewMemoryLaunchCodeDAO 创建内存 DAO +func NewMemoryLaunchCodeDAO() *MemoryLaunchCodeDAO { + return &MemoryLaunchCodeDAO{ + profileToCode: make(map[string]string), + codeToProfile: make(map[string]string), + } +} + +func (d *MemoryLaunchCodeDAO) FindProfileId(code string) (string, error) { + d.mu.RLock() + defer d.mu.RUnlock() + profileId, ok := d.codeToProfile[code] + if !ok { + return "", fmt.Errorf("launch code not found: %s", code) + } + return profileId, nil +} + +func (d *MemoryLaunchCodeDAO) FindCode(profileId string) (string, error) { + d.mu.RLock() + defer d.mu.RUnlock() + code, ok := d.profileToCode[profileId] + if !ok { + return "", fmt.Errorf("profile not found: %s", profileId) + } + return code, nil +} + +func (d *MemoryLaunchCodeDAO) Upsert(profileId, code string) error { + d.mu.Lock() + defer d.mu.Unlock() + // 清理旧 code 的反向映射 + if oldCode, ok := d.profileToCode[profileId]; ok { + delete(d.codeToProfile, oldCode) + } + d.profileToCode[profileId] = code + d.codeToProfile[code] = profileId + return nil +} + +func (d *MemoryLaunchCodeDAO) Delete(profileId string) error { + d.mu.Lock() + defer d.mu.Unlock() + if code, ok := d.profileToCode[profileId]; ok { + delete(d.codeToProfile, code) + delete(d.profileToCode, profileId) + } + return nil +} + +func (d *MemoryLaunchCodeDAO) LoadAll() (map[string]string, error) { + d.mu.RLock() + defer d.mu.RUnlock() + result := make(map[string]string, len(d.profileToCode)) + for k, v := range d.profileToCode { + result[k] = v + } + return result, nil +} diff --git a/backend/internal/launchcode/server.go b/backend/internal/launchcode/server.go new file mode 100644 index 00000000..22d37b19 --- /dev/null +++ b/backend/internal/launchcode/server.go @@ -0,0 +1,461 @@ +package launchcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/logger" +) + +// BrowserStarter 浏览器启动接口(由 App 层实现并注入) +type BrowserStarter interface { + StartInstance(profileId string) (*browser.Profile, error) +} + +// LaunchRequestParams 支持外部自动化透传的一次性启动参数 +type LaunchRequestParams struct { + LaunchArgs []string `json:"launchArgs"` + StartURLs []string `json:"startUrls"` + SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` +} + +// LaunchRequest POST /api/launch 的请求体 +type LaunchRequest struct { + Code string `json:"code"` + LaunchRequestParams +} + +// BrowserStarterWithParams 可选接口:支持带参数启动实例 +type BrowserStarterWithParams interface { + StartInstanceWithParams(profileId string, params LaunchRequestParams) (*browser.Profile, error) +} + +// LaunchCallRecord 接口调用记录 +type LaunchCallRecord struct { + Timestamp string `json:"timestamp"` + Method string `json:"method"` + Path string `json:"path"` + ClientIP string `json:"clientIp"` + Code string `json:"code"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Params LaunchRequestParams `json:"params"` + OK bool `json:"ok"` + Status int `json:"status"` + Error string `json:"error"` + DurationMs int64 `json:"durationMs"` +} + +// LaunchServer 本地 HTTP 唤起服务 +type LaunchServer struct { + service *LaunchCodeService + starter BrowserStarter + browserMgr *browser.Manager + port int + server *http.Server + mu sync.Mutex + logMu sync.Mutex + callLogs []LaunchCallRecord +} + +// NewLaunchServer 创建 LaunchServer +func NewLaunchServer(service *LaunchCodeService, starter BrowserStarter, mgr *browser.Manager, port int) *LaunchServer { + return &LaunchServer{ + service: service, + starter: starter, + browserMgr: mgr, + port: port, + } +} + +// Start 非阻塞启动 HTTP 服务。 +// 规则: +// - port <= 0:自动分配随机可用端口 +// - port > 0:优先使用指定端口;若被占用则回退到随机可用端口 +func (s *LaunchServer) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/launch", s.handleLaunchWithBody) + mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs) + mux.HandleFunc("/api/launch/", s.handleLaunch) + + handler := s.localhostMiddleware(mux) + + preferredPort := s.port + ln, port, usedFallbackRandom, err := bindLaunchListener(preferredPort) + if err != nil { + return err + } + + s.mu.Lock() + s.port = port + s.server = &http.Server{Handler: handler} + s.mu.Unlock() + + log := logger.New("LaunchServer") + if preferredPort <= 0 { + log.Info("LaunchServer 使用随机端口", logger.F("port", port)) + } else if usedFallbackRandom { + log.Warn("LaunchServer 首选端口不可用,已切换随机端口", + logger.F("preferred_port", preferredPort), + logger.F("port", port), + ) + } + log.Info("LaunchServer 已启动", logger.F("port", port)) + + go func() { + if serveErr := s.server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed { + log.Error("LaunchServer 异常退出", logger.F("error", serveErr.Error())) + } + }() + + return nil +} + +func bindLaunchListener(preferredPort int) (net.Listener, int, bool, error) { + if preferredPort <= 0 { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, 0, false, fmt.Errorf("自动分配端口失败: %w", err) + } + port, err := listenerPort(ln) + if err != nil { + _ = ln.Close() + return nil, 0, false, err + } + return ln, port, false, nil + } + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(preferredPort)) + ln, err := net.Listen("tcp", addr) + if err == nil { + return ln, preferredPort, false, nil + } + + fallbackLn, fallbackErr := net.Listen("tcp", "127.0.0.1:0") + if fallbackErr != nil { + return nil, 0, false, fmt.Errorf("端口 %d 不可用且自动分配失败: %w", preferredPort, err) + } + port, portErr := listenerPort(fallbackLn) + if portErr != nil { + _ = fallbackLn.Close() + return nil, 0, false, portErr + } + return fallbackLn, port, true, nil +} + +func listenerPort(ln net.Listener) (int, error) { + if ln == nil { + return 0, fmt.Errorf("listener is nil") + } + if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok { + return tcpAddr.Port, nil + } + + _, rawPort, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + return 0, fmt.Errorf("解析监听地址失败: %w", err) + } + port, err := strconv.Atoi(rawPort) + if err != nil { + return 0, fmt.Errorf("解析端口失败: %w", err) + } + return port, nil +} + +// Stop 优雅关闭(5 秒超时) +func (s *LaunchServer) Stop() error { + s.mu.Lock() + srv := s.server + s.mu.Unlock() + + if srv == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(ctx) +} + +// Port 返回实际绑定的端口 +func (s *LaunchServer) Port() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.port +} + +// localhostMiddleware 只允许 127.0.0.1 访问 +func (s *LaunchServer) localhostMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil || host != "127.0.0.1" { + writeJSON(w, http.StatusForbidden, map[string]interface{}{ + "ok": false, + "error": "forbidden: only localhost is allowed", + }) + return + } + next.ServeHTTP(w, r) + }) +} + +// handleHealth GET /api/health +func (s *LaunchServer) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleLaunch GET /api/launch/{code} +func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) { + startAt := time.Now() + clientIP := remoteIP(r.RemoteAddr) + if r.Method != http.MethodGet { + msg := "method not allowed" + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": msg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt) + return + } + + code := strings.TrimPrefix(r.URL.Path, "/api/launch/") + if strings.TrimSpace(code) == "" { + msg := "launch code not found" + writeJSON(w, http.StatusNotFound, map[string]interface{}{ + "ok": false, + "error": msg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt) + return + } + + profile, status, errMsg := s.launchByCode(code, LaunchRequestParams{}) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) +} + +// handleLaunchWithBody POST /api/launch +func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Request) { + startAt := time.Now() + clientIP := remoteIP(r.RemoteAddr) + if r.Method != http.MethodPost { + msg := "method not allowed" + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": msg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt) + return + } + + var req LaunchRequest + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + msg := "invalid request body" + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": msg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt) + return + } + if strings.TrimSpace(req.Code) == "" { + msg := "code is required" + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": msg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt) + return + } + + req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) + req.StartURLs = normalizeStringSlice(req.StartURLs) + profile, status, errMsg := s.launchByCode(req.Code, req.LaunchRequestParams) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) +} + +// handleLaunchLogs GET /api/launch/logs?limit=50 +func (s *LaunchServer) handleLaunchLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": "method not allowed", + }) + return + } + + limit := 50 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil { + if n < 1 { + n = 1 + } + if n > 200 { + n = 200 + } + limit = n + } + } + + items := s.listLaunchLogs(limit) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "items": items, + }) +} + +func (s *LaunchServer) launchByCode(code string, params LaunchRequestParams) (*browser.Profile, int, string) { + profileId, err := s.service.Resolve(strings.TrimSpace(code)) + if err != nil { + return nil, http.StatusNotFound, "launch code not found" + } + + var profile *browser.Profile + if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok { + profile, err = starterWithParams.StartInstanceWithParams(profileId, params) + } else { + profile, err = s.starter.StartInstance(profileId) + } + if err != nil { + return nil, http.StatusInternalServerError, err.Error() + } + + return profile, http.StatusOK, "" +} + +// writeJSON 写入 JSON 响应 +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// NewTestHandler 返回不含 localhost 限制的 handler,仅供测试使用 +func NewTestHandler(s *LaunchServer) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/launch", s.handleLaunchWithBody) + mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs) + mux.HandleFunc("/api/launch/", s.handleLaunch) + return mux +} + +func normalizeStringSlice(items []string) []string { + if len(items) == 0 { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + v := strings.TrimSpace(item) + if v != "" { + out = append(out, v) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func (s *LaunchServer) appendLaunchLog(method, path, clientIP, code string, params LaunchRequestParams, ok bool, status int, errMsg, profileID, profileName string, startAt time.Time) { + entry := LaunchCallRecord{ + Timestamp: time.Now().Format(time.RFC3339), + Method: method, + Path: path, + ClientIP: clientIP, + Code: strings.TrimSpace(code), + ProfileID: profileID, + ProfileName: profileName, + Params: params, + OK: ok, + Status: status, + Error: errMsg, + DurationMs: time.Since(startAt).Milliseconds(), + } + + s.logMu.Lock() + s.callLogs = append(s.callLogs, entry) + if len(s.callLogs) > 500 { + s.callLogs = append([]LaunchCallRecord(nil), s.callLogs[len(s.callLogs)-500:]...) + } + s.logMu.Unlock() + + log := logger.New("LaunchServer") + if ok { + log.Info("Launch API 调用", logger.F("method", method), logger.F("path", path), logger.F("code", entry.Code), logger.F("profile_id", profileID), logger.F("status", status), logger.F("duration_ms", entry.DurationMs)) + return + } + log.Warn("Launch API 调用失败", logger.F("method", method), logger.F("path", path), logger.F("code", entry.Code), logger.F("status", status), logger.F("error", errMsg), logger.F("duration_ms", entry.DurationMs)) +} + +func (s *LaunchServer) listLaunchLogs(limit int) []LaunchCallRecord { + s.logMu.Lock() + defer s.logMu.Unlock() + + if limit <= 0 { + limit = 50 + } + if limit > len(s.callLogs) { + limit = len(s.callLogs) + } + if limit == 0 { + return []LaunchCallRecord{} + } + + out := make([]LaunchCallRecord, 0, limit) + for i := len(s.callLogs) - 1; i >= 0 && len(out) < limit; i-- { + out = append(out, s.callLogs[i]) + } + return out +} + +func remoteIP(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr + } + return host +} diff --git a/backend/internal/launchcode/server_start_test.go b/backend/internal/launchcode/server_start_test.go new file mode 100644 index 00000000..e6151df3 --- /dev/null +++ b/backend/internal/launchcode/server_start_test.go @@ -0,0 +1,64 @@ +package launchcode_test + +import ( + "fmt" + "net" + "net/http" + "testing" + + "ant-chrome/backend/internal/launchcode" +) + +func TestLaunchServerStartWithAutoPort(t *testing.T) { + svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + srv := launchcode.NewLaunchServer(svc, nil, nil, 0) + + if err := srv.Start(); err != nil { + t.Fatalf("Start 失败: %v", err) + } + defer func() { + _ = srv.Stop() + }() + + port := srv.Port() + if port <= 0 { + t.Fatalf("自动端口分配失败: got=%d", port) + } + + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port)) + if err != nil { + t.Fatalf("健康检查请求失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("健康检查状态码错误: got=%d", resp.StatusCode) + } +} + +func TestLaunchServerFallbackToRandomPortWhenPreferredIsBusy(t *testing.T) { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("占用端口失败: %v", err) + } + defer occupied.Close() + + busyPort := occupied.Addr().(*net.TCPAddr).Port + svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + srv := launchcode.NewLaunchServer(svc, nil, nil, busyPort) + + if err := srv.Start(); err != nil { + t.Fatalf("Start 失败: %v", err) + } + defer func() { + _ = srv.Stop() + }() + + actualPort := srv.Port() + if actualPort <= 0 { + t.Fatalf("随机回退端口无效: got=%d", actualPort) + } + if actualPort == busyPort { + t.Fatalf("期望回退到随机端口,但仍使用了被占用端口: %d", actualPort) + } +} diff --git a/backend/internal/launchcode/service.go b/backend/internal/launchcode/service.go new file mode 100644 index 00000000..64b60688 --- /dev/null +++ b/backend/internal/launchcode/service.go @@ -0,0 +1,208 @@ +package launchcode + +import ( + "crypto/rand" + "fmt" + "regexp" + "strings" + "sync" +) + +const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +const codeLen = 6 +const maxRetries = 10 +const customCodeMinLen = 4 +const customCodeMaxLen = 32 + +var customCodePattern = regexp.MustCompile(`^[A-Z0-9_-]+$`) + +// LaunchCodeService 负责 Launch Code 的生成、缓存与管理 +type LaunchCodeService struct { + dao LaunchCodeDAO + codeToProfile map[string]string + profileToCode map[string]string + mu sync.RWMutex +} + +// NewLaunchCodeService 创建 LaunchCodeService +func NewLaunchCodeService(dao LaunchCodeDAO) *LaunchCodeService { + return &LaunchCodeService{ + dao: dao, + codeToProfile: make(map[string]string), + profileToCode: make(map[string]string), + } +} + +// EnsureCode 为 profile 生成并持久化 code(幂等:已有则直接返回) +func (s *LaunchCodeService) EnsureCode(profileId string) (string, error) { + s.mu.RLock() + if code, ok := s.profileToCode[profileId]; ok { + s.mu.RUnlock() + return code, nil + } + s.mu.RUnlock() + + code, err := s.generateUniqueCode() + if err != nil { + return "", err + } + + if err := s.dao.Upsert(profileId, code); err != nil { + return "", err + } + + s.mu.Lock() + s.profileToCode[profileId] = code + s.codeToProfile[code] = profileId + s.mu.Unlock() + + return code, nil +} + +// SetCode 为指定 profile 设置自定义 launch code。 +// code 会自动 trim 并转为大写;格式限制为 4-32 位,字符集 [A-Z0-9_-]。 +func (s *LaunchCodeService) SetCode(profileId, code string) (string, error) { + code = normalizeCode(code) + if err := validateCustomCode(code); err != nil { + return "", err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if old, ok := s.profileToCode[profileId]; ok && old == code { + return code, nil + } + + if ownerProfile, exists := s.codeToProfile[code]; exists && ownerProfile != profileId { + return "", fmt.Errorf("launch code already exists") + } + + if err := s.dao.Upsert(profileId, code); err != nil { + return "", err + } + + if old, ok := s.profileToCode[profileId]; ok { + delete(s.codeToProfile, old) + } + s.profileToCode[profileId] = code + s.codeToProfile[code] = profileId + return code, nil +} + +// RegenerateCode 重新生成 code(废弃旧 code) +func (s *LaunchCodeService) RegenerateCode(profileId string) (string, error) { + s.mu.Lock() + if oldCode, ok := s.profileToCode[profileId]; ok { + delete(s.codeToProfile, oldCode) + delete(s.profileToCode, profileId) + } + s.mu.Unlock() + + code, err := s.generateUniqueCode() + if err != nil { + return "", err + } + + if err := s.dao.Upsert(profileId, code); err != nil { + return "", err + } + + s.mu.Lock() + s.profileToCode[profileId] = code + s.codeToProfile[code] = profileId + s.mu.Unlock() + + return code, nil +} + +// Resolve 根据 code 查找 profileId(仅查内存缓存) +func (s *LaunchCodeService) Resolve(code string) (string, error) { + code = normalizeCode(code) + s.mu.RLock() + defer s.mu.RUnlock() + + profileId, ok := s.codeToProfile[code] + if !ok { + return "", fmt.Errorf("launch code not found: %s", code) + } + return profileId, nil +} + +// Remove 删除 profile 对应的 code(同时清理内存缓存和数据库) +func (s *LaunchCodeService) Remove(profileId string) error { + s.mu.Lock() + if code, ok := s.profileToCode[profileId]; ok { + delete(s.codeToProfile, code) + delete(s.profileToCode, profileId) + } + s.mu.Unlock() + + return s.dao.Delete(profileId) +} + +// LoadAll 启动时从数据库加载所有映射到内存 +func (s *LaunchCodeService) LoadAll() error { + profileToCode, err := s.dao.LoadAll() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.profileToCode = make(map[string]string, len(profileToCode)) + s.codeToProfile = make(map[string]string, len(profileToCode)) + + for profileId, code := range profileToCode { + s.profileToCode[profileId] = code + s.codeToProfile[code] = profileId + } + return nil +} + +// generateUniqueCode 生成一个在内存缓存中唯一的 code +func (s *LaunchCodeService) generateUniqueCode() (string, error) { + for i := 0; i < maxRetries; i++ { + code, err := randomCode() + if err != nil { + return "", fmt.Errorf("生成 launch code 失败: %w", err) + } + + s.mu.RLock() + _, exists := s.codeToProfile[code] + s.mu.RUnlock() + + if !exists { + return code, nil + } + } + return "", fmt.Errorf("无法在 %d 次重试内生成唯一 launch code", maxRetries) +} + +// randomCode 使用 crypto/rand 生成一个随机 6 位字符串 +func randomCode() (string, error) { + buf := make([]byte, codeLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + result := make([]byte, codeLen) + for i, b := range buf { + result[i] = charset[int(b)%len(charset)] + } + return string(result), nil +} + +func normalizeCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +func validateCustomCode(code string) error { + if len(code) < customCodeMinLen || len(code) > customCodeMaxLen { + return fmt.Errorf("launch code must be %d-%d characters", customCodeMinLen, customCodeMaxLen) + } + if !customCodePattern.MatchString(code) { + return fmt.Errorf("launch code format invalid: only A-Z, 0-9, _ and - are allowed") + } + return nil +} diff --git a/backend/internal/logger/formatter.go b/backend/internal/logger/formatter.go new file mode 100644 index 00000000..1977cc3b --- /dev/null +++ b/backend/internal/logger/formatter.go @@ -0,0 +1,192 @@ +package logger + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// TextFormatter 文本格式化器 +// 将日志条目格式化为结构化文本格式 +type TextFormatter struct { + // TimestampFormat 时间戳格式,默认为 "2006-01-02 15:04:05.000" + TimestampFormat string +} + +// NewTextFormatter 创建新的文本格式化器 +func NewTextFormatter() *TextFormatter { + return &TextFormatter{ + TimestampFormat: "2006-01-02 15:04:05.000", + } +} + +// Format 格式化日志条目为文本格式 +// 输出格式: [timestamp] [level] [component] message | field1=value1 field2=value2 +func (f *TextFormatter) Format(entry *LogEntry) ([]byte, error) { + if entry == nil { + return nil, fmt.Errorf("log entry is nil") + } + + var sb strings.Builder + + // 时间戳 + timestampFormat := f.TimestampFormat + if timestampFormat == "" { + timestampFormat = "2006-01-02 15:04:05.000" + } + sb.WriteString("[") + sb.WriteString(entry.Timestamp.Format(timestampFormat)) + sb.WriteString("] ") + + // 级别 + sb.WriteString("[") + sb.WriteString(entry.Level.String()) + sb.WriteString("] ") + + // 组件 + sb.WriteString("[") + if entry.Component != "" { + sb.WriteString(entry.Component) + } else { + sb.WriteString("-") + } + sb.WriteString("] ") + + // 消息 + sb.WriteString(entry.Message) + + // 收集所有额外字段 + extraFields := make([]string, 0) + + // 请求ID + if entry.RequestID != "" { + extraFields = append(extraFields, fmt.Sprintf("request_id=%s", entry.RequestID)) + } + + // 方法名 + if entry.Method != "" { + extraFields = append(extraFields, fmt.Sprintf("method=%s", entry.Method)) + } + + // 执行耗时 + if entry.Duration > 0 { + extraFields = append(extraFields, fmt.Sprintf("duration_ms=%d", entry.Duration)) + } + + // 调用位置 + if entry.CallerFile != "" { + caller := entry.CallerFile + if entry.CallerLine > 0 { + caller = fmt.Sprintf("%s:%d", entry.CallerFile, entry.CallerLine) + } + extraFields = append(extraFields, fmt.Sprintf("caller=%s", caller)) + } + + // 错误信息 + if entry.Error != "" { + extraFields = append(extraFields, fmt.Sprintf("error=%s", entry.Error)) + } + + // 扩展字段(按key排序以保证输出稳定) + if len(entry.Fields) > 0 { + keys := make([]string, 0, len(entry.Fields)) + for k := range entry.Fields { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + extraFields = append(extraFields, fmt.Sprintf("%s=%v", k, entry.Fields[k])) + } + } + + // 如果有额外字段,添加分隔符和字段 + if len(extraFields) > 0 { + sb.WriteString(" | ") + sb.WriteString(strings.Join(extraFields, " ")) + } + + // 添加换行符 + sb.WriteString("\n") + + return []byte(sb.String()), nil +} + +// JSONFormatter JSON格式化器 +// 将日志条目格式化为JSON格式 +type JSONFormatter struct { + // PrettyPrint 是否美化输出(带缩进) + PrettyPrint bool +} + +// NewJSONFormatter 创建新的JSON格式化器 +func NewJSONFormatter() *JSONFormatter { + return &JSONFormatter{ + PrettyPrint: false, + } +} + +// jsonLogEntry 用于JSON序列化的内部结构 +// 确保字段顺序和格式符合要求 +type jsonLogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Component string `json:"component"` + Message string `json:"message"` + RequestID string `json:"request_id,omitempty"` + Method string `json:"method,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Caller string `json:"caller,omitempty"` + Error string `json:"error,omitempty"` + Fields map[string]interface{} `json:"fields,omitempty"` +} + +// Format 格式化日志条目为JSON格式 +func (f *JSONFormatter) Format(entry *LogEntry) ([]byte, error) { + if entry == nil { + return nil, fmt.Errorf("log entry is nil") + } + + // 构建调用位置字符串 + var caller string + if entry.CallerFile != "" { + if entry.CallerLine > 0 { + caller = fmt.Sprintf("%s:%d", entry.CallerFile, entry.CallerLine) + } else { + caller = entry.CallerFile + } + } + + // 创建JSON结构 + jsonEntry := jsonLogEntry{ + Timestamp: entry.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"), + Level: entry.Level.String(), + Component: entry.Component, + Message: entry.Message, + RequestID: entry.RequestID, + Method: entry.Method, + DurationMs: entry.Duration, + Caller: caller, + Error: entry.Error, + Fields: entry.Fields, + } + + var data []byte + var err error + + if f.PrettyPrint { + data, err = json.MarshalIndent(jsonEntry, "", " ") + } else { + data, err = json.Marshal(jsonEntry) + } + + if err != nil { + return nil, fmt.Errorf("failed to marshal log entry to JSON: %w", err) + } + + // 添加换行符 + data = append(data, '\n') + + return data, nil +} diff --git a/backend/internal/logger/formatter_test.go b/backend/internal/logger/formatter_test.go new file mode 100644 index 00000000..25487205 --- /dev/null +++ b/backend/internal/logger/formatter_test.go @@ -0,0 +1,280 @@ +package logger + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" +) + +// genLevel 生成随机日志级别 +func genLevel() gopter.Gen { + return gen.IntRange(0, 3).Map(func(i int) Level { + return Level(i) + }) +} + +// genLogEntry 生成随机 LogEntry +func genLogEntry() gopter.Gen { + return gopter.CombineGens( + genLevel(), + gen.AlphaString(), + gen.AlphaString(), + gen.AlphaString(), + gen.AlphaString(), + gen.Int64Range(0, 10000), + ).Map(func(values []interface{}) *LogEntry { + level := values[0].(Level) + component := values[1].(string) + message := values[2].(string) + requestID := values[3].(string) + method := values[4].(string) + duration := values[5].(int64) + + entry := &LogEntry{ + Timestamp: time.Now(), + Level: level, + Component: component, + Message: message, + RequestID: requestID, + Method: method, + Duration: duration, + } + return entry + }) +} + +// TestProperty13_JSONFormatValidity 属性测试:JSON 格式有效性 +// **Property 13: JSON Format Validity** +// **Validates: Requirements 6.2** +// *For any* log entry when JSON format is configured, the output SHALL be valid JSON +// that can be parsed without error. +func TestProperty13_JSONFormatValidity(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + formatter := NewJSONFormatter() + + properties.Property("JSON output is always valid JSON", prop.ForAll( + func(entry *LogEntry) bool { + // Format the entry + data, err := formatter.Format(entry) + if err != nil { + return false + } + + // Verify it's valid JSON by attempting to unmarshal + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return false + } + + return true + }, + genLogEntry(), + )) + + properties.TestingRun(t) +} + +// TestProperty12_StructuredLogFieldCompleteness 属性测试:结构化日志字段完整性 +// **Property 12: Structured Log Field Completeness** +// **Validates: Requirements 6.1** +// *For any* log entry, the output SHALL contain: timestamp (ISO 8601), level +// (DEBUG/INFO/WARN/ERROR), component name, and message. +func TestProperty12_StructuredLogFieldCompleteness(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + textFormatter := NewTextFormatter() + jsonFormatter := NewJSONFormatter() + + // Test TextFormatter field completeness + properties.Property("TextFormatter output contains all required fields", prop.ForAll( + func(entry *LogEntry) bool { + data, err := textFormatter.Format(entry) + if err != nil { + return false + } + + output := string(data) + + // Check timestamp format (YYYY-MM-DD HH:MM:SS.mmm) + if !strings.Contains(output, "[") || !strings.Contains(output, "]") { + return false + } + + // Check level is present (DEBUG/INFO/WARN/ERROR) + levelStr := entry.Level.String() + if !strings.Contains(output, "["+levelStr+"]") { + return false + } + + // Check component is present (or "-" if empty) + if entry.Component != "" { + if !strings.Contains(output, "["+entry.Component+"]") { + return false + } + } else { + if !strings.Contains(output, "[-]") { + return false + } + } + + // Check message is present + if !strings.Contains(output, entry.Message) { + return false + } + + return true + }, + genLogEntry(), + )) + + // Test JSONFormatter field completeness + properties.Property("JSONFormatter output contains all required fields", prop.ForAll( + func(entry *LogEntry) bool { + data, err := jsonFormatter.Format(entry) + if err != nil { + return false + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return false + } + + // Check timestamp exists and is in ISO 8601 format + timestamp, ok := result["timestamp"].(string) + if !ok || timestamp == "" { + return false + } + // Verify timestamp can be parsed as ISO 8601 + _, err = time.Parse("2006-01-02T15:04:05.000Z07:00", timestamp) + if err != nil { + return false + } + + // Check level exists and is valid + level, ok := result["level"].(string) + if !ok { + return false + } + validLevels := map[string]bool{"DEBUG": true, "INFO": true, "WARN": true, "ERROR": true} + if !validLevels[level] { + return false + } + + // Check component exists + if _, ok := result["component"]; !ok { + return false + } + + // Check message exists + if _, ok := result["message"]; !ok { + return false + } + + return true + }, + genLogEntry(), + )) + + properties.TestingRun(t) +} + +// TestTextFormatterBasic 基础单元测试:TextFormatter +func TestTextFormatterBasic(t *testing.T) { + formatter := NewTextFormatter() + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + entry := &LogEntry{ + Timestamp: testTime, + Level: INFO, + Component: "TestComponent", + Message: "Test message", + } + + data, err := formatter.Format(entry) + if err != nil { + t.Fatalf("Format failed: %v", err) + } + + output := string(data) + + // Verify basic structure + if !strings.Contains(output, "[2024-01-15 10:30:00.000]") { + t.Errorf("Timestamp not found in output: %s", output) + } + if !strings.Contains(output, "[INFO]") { + t.Errorf("Level not found in output: %s", output) + } + if !strings.Contains(output, "[TestComponent]") { + t.Errorf("Component not found in output: %s", output) + } + if !strings.Contains(output, "Test message") { + t.Errorf("Message not found in output: %s", output) + } +} + +// TestJSONFormatterBasic 基础单元测试:JSONFormatter +func TestJSONFormatterBasic(t *testing.T) { + formatter := NewJSONFormatter() + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + entry := &LogEntry{ + Timestamp: testTime, + Level: INFO, + Component: "TestComponent", + Message: "Test message", + RequestID: "req-123", + Method: "TestMethod", + Duration: 150, + } + + data, err := formatter.Format(entry) + if err != nil { + t.Fatalf("Format failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + // Verify fields + if result["level"] != "INFO" { + t.Errorf("Level should be 'INFO', got %v", result["level"]) + } + if result["component"] != "TestComponent" { + t.Errorf("Component should be 'TestComponent', got %v", result["component"]) + } + if result["message"] != "Test message" { + t.Errorf("Message should be 'Test message', got %v", result["message"]) + } + if result["request_id"] != "req-123" { + t.Errorf("RequestID should be 'req-123', got %v", result["request_id"]) + } +} + +// TestFormatterNilEntry 测试 nil entry 处理 +func TestFormatterNilEntry(t *testing.T) { + textFormatter := NewTextFormatter() + jsonFormatter := NewJSONFormatter() + + _, err := textFormatter.Format(nil) + if err == nil { + t.Error("TextFormatter should return error for nil entry") + } + + _, err = jsonFormatter.Format(nil) + if err == nil { + t.Error("JSONFormatter should return error for nil entry") + } +} diff --git a/backend/internal/logger/interceptor.go b/backend/internal/logger/interceptor.go new file mode 100644 index 00000000..075dd0a0 --- /dev/null +++ b/backend/internal/logger/interceptor.go @@ -0,0 +1,488 @@ +package logger + +import ( + "fmt" + "reflect" + "runtime" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// InterceptorConfig 拦截器配置 +type InterceptorConfig struct { + Enabled bool + LogParameters bool + LogResults bool + SensitiveFields []string +} + +// MethodInterceptor 方法拦截器 +// 用于自动记录方法调用的 AOP 组件 +type MethodInterceptor struct { + logger *Logger + config InterceptorConfig + sensitiveFields map[string]bool + mu sync.RWMutex +} + +// CallContext 调用上下文 +type CallContext struct { + RequestID string + MethodName string + StartTime time.Time + Parameters []interface{} +} + +// NewMethodInterceptor 创建新的方法拦截器 +func NewMethodInterceptor(logger *Logger, config InterceptorConfig) *MethodInterceptor { + sensitiveFields := make(map[string]bool) + for _, field := range config.SensitiveFields { + sensitiveFields[strings.ToLower(field)] = true + } + + return &MethodInterceptor{ + logger: logger, + config: config, + sensitiveFields: sensitiveFields, + } +} + +// GenerateRequestID 生成唯一的请求 ID +func GenerateRequestID() string { + return uuid.New().String() +} + +// WrapFunc 包装无参数无返回值的函数 +func (m *MethodInterceptor) WrapFunc(name string, fn func()) func() { + if !m.config.Enabled { + return fn + } + + return func() { + ctx := m.beforeCall(name, nil) + defer m.afterCallRecover(ctx, nil, nil) + + fn() + } +} + +// WrapFuncWithError 包装返回 error 的函数 +func (m *MethodInterceptor) WrapFuncWithError(name string, fn func() error) func() error { + if !m.config.Enabled { + return fn + } + + return func() error { + ctx := m.beforeCall(name, nil) + var err error + + defer func() { + m.afterCallRecover(ctx, nil, err) + }() + + err = fn() + return err + } +} + +// WrapFuncResult 包装有返回值的函数(使用 interface{}) +func (m *MethodInterceptor) WrapFuncResult(name string, fn func() interface{}) func() interface{} { + if !m.config.Enabled { + return fn + } + + return func() interface{} { + ctx := m.beforeCall(name, nil) + var result interface{} + + defer func() { + m.afterCallRecover(ctx, result, nil) + }() + + result = fn() + return result + } +} + +// WrapFuncResultError 包装有返回值和 error 的函数 +func (m *MethodInterceptor) WrapFuncResultError(name string, fn func() (interface{}, error)) func() (interface{}, error) { + if !m.config.Enabled { + return fn + } + + return func() (interface{}, error) { + ctx := m.beforeCall(name, nil) + var result interface{} + var err error + + defer func() { + m.afterCallRecover(ctx, result, err) + }() + + result, err = fn() + return result, err + } +} + +// WrapMethod1Arg 包装单参数方法 +func (m *MethodInterceptor) WrapMethod1Arg(name string, fn func(interface{}) interface{}) func(interface{}) interface{} { + if !m.config.Enabled { + return fn + } + + return func(p interface{}) interface{} { + ctx := m.beforeCall(name, []interface{}{p}) + var result interface{} + + defer func() { + m.afterCallRecover(ctx, result, nil) + }() + + result = fn(p) + return result + } +} + +// WrapMethod1ArgError 包装单参数返回 error 的方法 +func (m *MethodInterceptor) WrapMethod1ArgError(name string, fn func(interface{}) (interface{}, error)) func(interface{}) (interface{}, error) { + if !m.config.Enabled { + return fn + } + + return func(p interface{}) (interface{}, error) { + ctx := m.beforeCall(name, []interface{}{p}) + var result interface{} + var err error + + defer func() { + m.afterCallRecover(ctx, result, err) + }() + + result, err = fn(p) + return result, err + } +} + +// beforeCall 方法调用前的处理 +func (m *MethodInterceptor) beforeCall(methodName string, params []interface{}) *CallContext { + ctx := &CallContext{ + RequestID: GenerateRequestID(), + MethodName: methodName, + StartTime: time.Now(), + Parameters: params, + } + + // 记录方法入口日志 + entry := NewLogEntry(INFO, "interceptor", fmt.Sprintf("Method call started: %s", methodName)) + entry.WithRequestID(ctx.RequestID) + entry.WithMethod(methodName) + + // 添加参数信息 + if m.config.LogParameters && len(params) > 0 { + maskedParams := m.maskSensitiveParams(params) + entry.WithFields(map[string]interface{}{ + "parameters": maskedParams, + }) + } + + // 添加调用位置 + if file, line := m.getCaller(); file != "" { + entry.WithCaller(file, line) + } + + m.safeLog(entry) + + return ctx +} + +// afterCallRecover 方法调用后的处理(带 panic 恢复) +func (m *MethodInterceptor) afterCallRecover(ctx *CallContext, result interface{}, err error) { + // 捕获 panic,确保日志错误不影响业务 + if r := recover(); r != nil { + m.handlePanic(ctx, r) + // 重新抛出 panic,让业务代码处理 + panic(r) + } + + m.afterCall(ctx, result, err) +} + +// afterCall 方法调用后的处理 +func (m *MethodInterceptor) afterCall(ctx *CallContext, result interface{}, err error) { + duration := time.Since(ctx.StartTime).Milliseconds() + + var entry *LogEntry + if err != nil { + // 错误情况 + entry = NewLogEntry(ERROR, "interceptor", fmt.Sprintf("Method call failed: %s", ctx.MethodName)) + entry.WithError(err.Error()) + + // 获取堆栈信息 + stack := m.getStackTrace() + if stack != "" { + if entry.Fields == nil { + entry.Fields = make(map[string]interface{}) + } + entry.Fields["stack_trace"] = stack + } + } else { + // 成功情况 + entry = NewLogEntry(INFO, "interceptor", fmt.Sprintf("Method call completed: %s", ctx.MethodName)) + + // 记录返回结果 + if m.config.LogResults && result != nil { + maskedResult := m.maskSensitiveValue("result", result) + if entry.Fields == nil { + entry.Fields = make(map[string]interface{}) + } + entry.Fields["result"] = maskedResult + } + } + + entry.WithRequestID(ctx.RequestID) + entry.WithMethod(ctx.MethodName) + entry.WithDuration(duration) + + m.safeLog(entry) +} + +// handlePanic 处理 panic +func (m *MethodInterceptor) handlePanic(ctx *CallContext, panicValue interface{}) { + duration := time.Since(ctx.StartTime).Milliseconds() + + entry := NewLogEntry(ERROR, "interceptor", fmt.Sprintf("Method call panicked: %s", ctx.MethodName)) + entry.WithRequestID(ctx.RequestID) + entry.WithMethod(ctx.MethodName) + entry.WithDuration(duration) + entry.WithError(fmt.Sprintf("panic: %v", panicValue)) + + // 获取堆栈信息 + stack := m.getStackTrace() + if stack != "" { + if entry.Fields == nil { + entry.Fields = make(map[string]interface{}) + } + entry.Fields["stack_trace"] = stack + } + + m.safeLog(entry) +} + +// safeLog 安全地记录日志(捕获所有错误) +func (m *MethodInterceptor) safeLog(entry *LogEntry) { + defer func() { + if r := recover(); r != nil { + // 日志系统出错,静默处理,不影响业务 + fmt.Printf("[INTERCEPTOR ERROR] Failed to log: %v\n", r) + } + }() + + if m.logger != nil { + m.logger.LogEntry(entry) + } +} + +// maskSensitiveParams 对敏感参数进行脱敏 +func (m *MethodInterceptor) maskSensitiveParams(params []interface{}) []interface{} { + if len(m.sensitiveFields) == 0 { + return params + } + + masked := make([]interface{}, len(params)) + for i, param := range params { + masked[i] = m.maskValue(param) + } + return masked +} + +// maskValue 对值进行脱敏处理 +func (m *MethodInterceptor) maskValue(value interface{}) interface{} { + if value == nil { + return nil + } + + v := reflect.ValueOf(value) + + switch v.Kind() { + case reflect.Map: + return m.maskMap(v) + case reflect.Struct: + return m.maskStruct(v) + case reflect.Ptr: + if v.IsNil() { + return nil + } + return m.maskValue(v.Elem().Interface()) + default: + return value + } +} + +// maskMap 对 map 进行脱敏 +func (m *MethodInterceptor) maskMap(v reflect.Value) interface{} { + result := make(map[string]interface{}) + + iter := v.MapRange() + for iter.Next() { + key := fmt.Sprintf("%v", iter.Key().Interface()) + val := iter.Value().Interface() + + if m.isSensitiveField(key) { + result[key] = "***" + } else { + result[key] = m.maskValue(val) + } + } + + return result +} + +// maskStruct 对结构体进行脱敏 +func (m *MethodInterceptor) maskStruct(v reflect.Value) interface{} { + result := make(map[string]interface{}) + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + fieldName := field.Name + fieldValue := v.Field(i).Interface() + + if m.isSensitiveField(fieldName) { + result[fieldName] = "***" + } else { + result[fieldName] = m.maskValue(fieldValue) + } + } + + return result +} + +// maskSensitiveValue 对单个值进行脱敏(用于返回值) +func (m *MethodInterceptor) maskSensitiveValue(fieldName string, value interface{}) interface{} { + if m.isSensitiveField(fieldName) { + return "***" + } + return m.maskValue(value) +} + +// isSensitiveField 检查字段是否为敏感字段 +func (m *MethodInterceptor) isSensitiveField(fieldName string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.sensitiveFields[strings.ToLower(fieldName)] +} + +// AddSensitiveField 添加敏感字段 +func (m *MethodInterceptor) AddSensitiveField(fieldName string) { + m.mu.Lock() + defer m.mu.Unlock() + m.sensitiveFields[strings.ToLower(fieldName)] = true +} + +// RemoveSensitiveField 移除敏感字段 +func (m *MethodInterceptor) RemoveSensitiveField(fieldName string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sensitiveFields, strings.ToLower(fieldName)) +} + +// getCaller 获取调用位置 +func (m *MethodInterceptor) getCaller() (string, int) { + // 跳过拦截器内部的调用栈 + for i := 3; i < 10; i++ { + _, file, line, ok := runtime.Caller(i) + if !ok { + break + } + // 跳过拦截器自身的文件 + if !strings.Contains(file, "interceptor.go") { + // 只保留文件名 + parts := strings.Split(file, "/") + if len(parts) > 0 { + return parts[len(parts)-1], line + } + return file, line + } + } + return "", 0 +} + +// getStackTrace 获取堆栈信息 +func (m *MethodInterceptor) getStackTrace() string { + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + return string(buf[:n]) +} + +// SetEnabled 设置拦截器启用状态 +func (m *MethodInterceptor) SetEnabled(enabled bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.config.Enabled = enabled +} + +// IsEnabled 检查拦截器是否启用 +func (m *MethodInterceptor) IsEnabled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.config.Enabled +} + +// GetConfig 获取拦截器配置 +func (m *MethodInterceptor) GetConfig() InterceptorConfig { + m.mu.RLock() + defer m.mu.RUnlock() + return m.config +} + +// Intercept 通用拦截方法,用于手动记录方法调用 +// 返回 CallContext 用于后续调用 Complete 或 Fail +func (m *MethodInterceptor) Intercept(methodName string, params ...interface{}) *CallContext { + if !m.config.Enabled { + return &CallContext{ + RequestID: GenerateRequestID(), + MethodName: methodName, + StartTime: time.Now(), + Parameters: params, + } + } + return m.beforeCall(methodName, params) +} + +// Complete 标记方法调用成功完成 +func (m *MethodInterceptor) Complete(ctx *CallContext, result interface{}) { + if !m.config.Enabled { + return + } + m.afterCall(ctx, result, nil) +} + +// Fail 标记方法调用失败 +func (m *MethodInterceptor) Fail(ctx *CallContext, err error) { + if !m.config.Enabled { + return + } + m.afterCall(ctx, nil, err) +} + +// GetRequestID 获取调用上下文的请求 ID +func (ctx *CallContext) GetRequestID() string { + return ctx.RequestID +} + +// GetMethodName 获取调用上下文的方法名 +func (ctx *CallContext) GetMethodName() string { + return ctx.MethodName +} + +// GetDuration 获取调用耗时(毫秒) +func (ctx *CallContext) GetDuration() int64 { + return time.Since(ctx.StartTime).Milliseconds() +} diff --git a/backend/internal/logger/interceptor_test.go b/backend/internal/logger/interceptor_test.go new file mode 100644 index 00000000..050d4a7d --- /dev/null +++ b/backend/internal/logger/interceptor_test.go @@ -0,0 +1,466 @@ +package logger + +import ( + "errors" + "sync" + "testing" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" +) + +// TestProperty3_RequestIDUniqueness 属性测试:请求 ID 唯一性 +// **Property 3: Request ID Uniqueness** +// **Validates: Requirements 2.4** +// *For any* sequence of N method calls through the interceptor, all N generated +// request IDs SHALL be unique (no duplicates). +func TestProperty3_RequestIDUniqueness(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("All generated request IDs are unique", prop.ForAll( + func(n int) bool { + if n <= 0 { + return true + } + + ids := make(map[string]bool) + for i := 0; i < n; i++ { + id := GenerateRequestID() + if ids[id] { + // Duplicate found + return false + } + ids[id] = true + } + return true + }, + gen.IntRange(1, 1000), + )) + + properties.TestingRun(t) +} + +// TestProperty3_RequestIDUniqueness_Concurrent 并发场景下的请求 ID 唯一性 +func TestProperty3_RequestIDUniqueness_Concurrent(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Concurrent request ID generation produces unique IDs", prop.ForAll( + func(goroutines int, idsPerGoroutine int) bool { + if goroutines <= 0 || idsPerGoroutine <= 0 { + return true + } + + var mu sync.Mutex + ids := make(map[string]bool) + var wg sync.WaitGroup + + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < idsPerGoroutine; i++ { + id := GenerateRequestID() + mu.Lock() + if ids[id] { + mu.Unlock() + return + } + ids[id] = true + mu.Unlock() + } + }() + } + + wg.Wait() + + // Verify total count matches expected + expectedCount := goroutines * idsPerGoroutine + return len(ids) == expectedCount + }, + gen.IntRange(1, 10), + gen.IntRange(1, 100), + )) + + properties.TestingRun(t) +} + +// TestProperty4_SensitiveFieldMasking 属性测试:敏感字段脱敏 +// **Property 4: Sensitive Field Masking** +// **Validates: Requirements 2.5** +// *For any* log entry containing fields configured as sensitive, the logged value +// SHALL be masked (e.g., "***") and SHALL NOT contain the original value. +func TestProperty4_SensitiveFieldMasking(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + // Test map masking + properties.Property("Sensitive fields in maps are masked", prop.ForAll( + func(sensitiveField string, sensitiveValue string, normalField string, normalValue string) bool { + // Skip empty field names + if sensitiveField == "" || normalField == "" { + return true + } + // Ensure fields are different + if sensitiveField == normalField { + return true + } + + interceptor := NewMethodInterceptor(nil, InterceptorConfig{ + Enabled: true, + LogParameters: true, + SensitiveFields: []string{sensitiveField}, + }) + + input := map[string]interface{}{ + sensitiveField: sensitiveValue, + normalField: normalValue, + } + + masked := interceptor.maskValue(input) + maskedMap, ok := masked.(map[string]interface{}) + if !ok { + return false + } + + // Sensitive field should be masked + if maskedMap[sensitiveField] != "***" { + return false + } + + // Normal field should not be masked + if maskedMap[normalField] != normalValue { + return false + } + + return true + }, + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }), + gen.AlphaString(), + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }), + gen.AlphaString(), + )) + + properties.TestingRun(t) +} + +// TestProperty4_SensitiveFieldMasking_CaseInsensitive 测试大小写不敏感 +func TestProperty4_SensitiveFieldMasking_CaseInsensitive(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Sensitive field matching is case-insensitive", prop.ForAll( + func(fieldName string, value string) bool { + if fieldName == "" { + return true + } + + // Configure with lowercase + interceptor := NewMethodInterceptor(nil, InterceptorConfig{ + Enabled: true, + LogParameters: true, + SensitiveFields: []string{fieldName}, + }) + + // Test with various case variations + variations := []string{ + fieldName, + toUpperCase(fieldName), + toLowerCase(fieldName), + mixedCase(fieldName), + } + + for _, variant := range variations { + input := map[string]interface{}{ + variant: value, + } + + masked := interceptor.maskValue(input) + maskedMap, ok := masked.(map[string]interface{}) + if !ok { + return false + } + + // All variations should be masked + if maskedMap[variant] != "***" { + return false + } + } + + return true + }, + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }), + gen.AlphaString(), + )) + + properties.TestingRun(t) +} + +// Helper functions for case conversion +func toUpperCase(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' { + result[i] = c - 32 + } else { + result[i] = c + } + } + return string(result) +} + +func toLowerCase(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + result[i] = c + 32 + } else { + result[i] = c + } + } + return string(result) +} + +func mixedCase(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if i%2 == 0 { + if c >= 'a' && c <= 'z' { + result[i] = c - 32 + } else { + result[i] = c + } + } else { + if c >= 'A' && c <= 'Z' { + result[i] = c + 32 + } else { + result[i] = c + } + } + } + return string(result) +} + +// TestProperty4_SensitiveFieldMasking_NestedStructures 测试嵌套结构脱敏 +func TestProperty4_SensitiveFieldMasking_NestedStructures(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Sensitive fields in nested maps are masked", prop.ForAll( + func(sensitiveValue string) bool { + interceptor := NewMethodInterceptor(nil, InterceptorConfig{ + Enabled: true, + LogParameters: true, + SensitiveFields: []string{"password", "token", "secret"}, + }) + + // Create nested structure + input := map[string]interface{}{ + "user": map[string]interface{}{ + "name": "testuser", + "password": sensitiveValue, + }, + "auth": map[string]interface{}{ + "token": sensitiveValue, + }, + } + + masked := interceptor.maskValue(input) + maskedMap, ok := masked.(map[string]interface{}) + if !ok { + return false + } + + // Check nested password is masked + userMap, ok := maskedMap["user"].(map[string]interface{}) + if !ok { + return false + } + if userMap["password"] != "***" { + return false + } + if userMap["name"] != "testuser" { + return false + } + + // Check nested token is masked + authMap, ok := maskedMap["auth"].(map[string]interface{}) + if !ok { + return false + } + if authMap["token"] != "***" { + return false + } + + return true + }, + gen.AlphaString(), + )) + + properties.TestingRun(t) +} + +// TestProperty11_LoggerFaultIsolation 属性测试:日志系统错误隔离 +// **Property 11: Logger Fault Isolation** +// **Validates: Requirements 5.3** +// *For any* error occurring in the logging system (file write failure, formatter +// error, etc.), the wrapped business method SHALL still execute and return normally. +func TestProperty11_LoggerFaultIsolation(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Business method executes normally even when logger is nil", prop.ForAll( + func(input int) bool { + // Create interceptor with nil logger (simulates logger failure) + interceptor := NewMethodInterceptor(nil, InterceptorConfig{ + Enabled: true, + LogParameters: true, + LogResults: true, + }) + + // Wrap a simple function + expectedResult := input * 2 + wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} { + return input * 2 + }) + + // Execute wrapped function + result := wrappedFn() + + // Verify business logic executed correctly + return result == expectedResult + }, + gen.Int(), + )) + + properties.TestingRun(t) +} + +// TestProperty11_LoggerFaultIsolation_WithError 测试返回错误的方法 +func TestProperty11_LoggerFaultIsolation_WithError(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Error-returning method works with nil logger", prop.ForAll( + func(shouldError bool) bool { + interceptor := NewMethodInterceptor(nil, InterceptorConfig{ + Enabled: true, + LogParameters: true, + LogResults: true, + }) + + var expectedErr error + if shouldError { + expectedErr = errors.New("test error") + } + + wrappedFn := interceptor.WrapFuncWithError("TestMethod", func() error { + return expectedErr + }) + + // Execute wrapped function + resultErr := wrappedFn() + + // Verify error is returned correctly + if shouldError { + return resultErr != nil && resultErr.Error() == "test error" + } + return resultErr == nil + }, + gen.Bool(), + )) + + properties.TestingRun(t) +} + +// faultyWriter 模拟故障的写入器 +type faultyWriter struct { + shouldPanic bool +} + +func (w *faultyWriter) Write(entry *LogEntry) error { + if w.shouldPanic { + panic("simulated writer panic") + } + return errors.New("simulated write error") +} + +func (w *faultyWriter) Close() error { + return nil +} + +// TestProperty11_LoggerFaultIsolation_FaultyWriter 测试故障写入器 +func TestProperty11_LoggerFaultIsolation_FaultyWriter(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Business method executes normally with faulty writer", prop.ForAll( + func(input string) bool { + // Create a logger with faulty writer + logger := &Logger{ + level: INFO, + writers: []Writer{&faultyWriter{shouldPanic: false}}, + } + + interceptor := NewMethodInterceptor(logger, InterceptorConfig{ + Enabled: true, + LogParameters: true, + LogResults: true, + }) + + // Wrap a simple function + expectedResult := "processed: " + input + wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} { + return "processed: " + input + }) + + // Execute wrapped function + result := wrappedFn() + + // Verify business logic executed correctly + return result == expectedResult + }, + gen.AlphaString(), + )) + + properties.TestingRun(t) +} + +// TestProperty11_LoggerFaultIsolation_DisabledInterceptor 测试禁用的拦截器 +func TestProperty11_LoggerFaultIsolation_DisabledInterceptor(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + properties.Property("Disabled interceptor passes through without modification", prop.ForAll( + func(input int) bool { + interceptor := NewMethodInterceptor(nil, InterceptorConfig{ + Enabled: false, + }) + + expectedResult := input * 3 + wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} { + return input * 3 + }) + + result := wrappedFn() + return result == expectedResult + }, + gen.Int(), + )) + + properties.TestingRun(t) +} diff --git a/backend/internal/logger/interfaces.go b/backend/internal/logger/interfaces.go new file mode 100644 index 00000000..ba81f3f5 --- /dev/null +++ b/backend/internal/logger/interfaces.go @@ -0,0 +1,134 @@ +package logger + +import ( + "encoding/json" + "os" + "time" +) + +// Writer 日志写入器接口 +// 负责将日志写入不同目标(控制台、文件等) +type Writer interface { + // Write 写入日志条目 + Write(entry *LogEntry) error + // Close 关闭写入器,释放资源 + Close() error +} + +// Formatter 日志格式化器接口 +// 负责将日志条目格式化为字节数组 +type Formatter interface { + // Format 格式化日志条目 + Format(entry *LogEntry) ([]byte, error) +} + +// RotationPolicy 日志分片策略接口 +// 定义何时触发日志文件分片 +type RotationPolicy interface { + // ShouldRotate 判断是否应该触发分片 + ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool + // GetRotatedFileName 获取分片后的文件名 + GetRotatedFileName(baseName string, timestamp time.Time) string +} + +// LogEntry 日志条目 +// 包含日志记录的所有必要信息 +type LogEntry struct { + // Timestamp 日志时间戳 + Timestamp time.Time `json:"timestamp"` + // Level 日志级别 + Level Level `json:"level"` + // Component 组件名称 + Component string `json:"component"` + // Message 日志消息 + Message string `json:"message"` + // Fields 扩展字段 + Fields map[string]interface{} `json:"fields,omitempty"` + // RequestID 请求ID,用于链路追踪 + RequestID string `json:"request_id,omitempty"` + // Method 方法名(方法调用日志) + Method string `json:"method,omitempty"` + // Duration 执行耗时(毫秒) + Duration int64 `json:"duration_ms,omitempty"` + // CallerFile 调用者文件 + CallerFile string `json:"caller_file,omitempty"` + // CallerLine 调用者行号 + CallerLine int `json:"caller_line,omitempty"` + // Error 错误信息 + Error string `json:"error,omitempty"` +} + +// Caller 返回格式化的调用位置字符串 +func (e *LogEntry) Caller() string { + if e.CallerFile == "" { + return "" + } + if e.CallerLine > 0 { + return e.CallerFile + ":" + string(rune(e.CallerLine+'0')) + } + return e.CallerFile +} + +// ToJSON 将日志条目序列化为JSON字节数组 +func (e *LogEntry) ToJSON() ([]byte, error) { + return json.Marshal(e) +} + +// MarshalJSON 自定义JSON序列化,确保Level以字符串形式输出 +func (e *LogEntry) MarshalJSON() ([]byte, error) { + type Alias LogEntry + return json.Marshal(&struct { + Level string `json:"level"` + *Alias + }{ + Level: e.Level.String(), + Alias: (*Alias)(e), + }) +} + +// NewLogEntry 创建新的日志条目 +func NewLogEntry(level Level, component, message string) *LogEntry { + return &LogEntry{ + Timestamp: time.Now(), + Level: level, + Component: component, + Message: message, + } +} + +// WithFields 添加扩展字段 +func (e *LogEntry) WithFields(fields map[string]interface{}) *LogEntry { + e.Fields = fields + return e +} + +// WithRequestID 添加请求ID +func (e *LogEntry) WithRequestID(requestID string) *LogEntry { + e.RequestID = requestID + return e +} + +// WithMethod 添加方法名 +func (e *LogEntry) WithMethod(method string) *LogEntry { + e.Method = method + return e +} + +// WithDuration 添加执行耗时 +func (e *LogEntry) WithDuration(duration int64) *LogEntry { + e.Duration = duration + return e +} + +// WithCaller 添加调用位置 +func (e *LogEntry) WithCaller(file string, line int) *LogEntry { + e.CallerFile = file + e.CallerLine = line + return e +} + +// WithError 添加错误信息 +func (e *LogEntry) WithError(err string) *LogEntry { + e.Error = err + return e +} diff --git a/backend/internal/logger/interfaces_test.go b/backend/internal/logger/interfaces_test.go new file mode 100644 index 00000000..9c50138b --- /dev/null +++ b/backend/internal/logger/interfaces_test.go @@ -0,0 +1,163 @@ +package logger + +import ( + "encoding/json" + "testing" + "time" +) + +// TestLogEntryJSONSerialization 测试 LogEntry JSON 序列化 +func TestLogEntryJSONSerialization(t *testing.T) { + // 创建测试时间 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + // 创建完整的 LogEntry + entry := &LogEntry{ + Timestamp: testTime, + Level: INFO, + Component: "TestComponent", + Message: "Test message", + Fields: map[string]interface{}{"key1": "value1", "key2": 123}, + RequestID: "req-12345", + Method: "TestMethod", + Duration: 150, + CallerFile: "test.go", + CallerLine: 42, + Error: "", + } + + // 序列化 + data, err := entry.ToJSON() + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + // 验证是有效的 JSON + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + // 验证必需字段存在 + requiredFields := []string{"timestamp", "level", "component", "message"} + for _, field := range requiredFields { + if _, ok := result[field]; !ok { + t.Errorf("Required field %q missing from JSON output", field) + } + } + + // 验证 Level 以字符串形式输出 + if level, ok := result["level"].(string); !ok || level != "INFO" { + t.Errorf("Level should be string 'INFO', got %v", result["level"]) + } + + // 验证 Component + if component, ok := result["component"].(string); !ok || component != "TestComponent" { + t.Errorf("Component should be 'TestComponent', got %v", result["component"]) + } + + // 验证 Message + if message, ok := result["message"].(string); !ok || message != "Test message" { + t.Errorf("Message should be 'Test message', got %v", result["message"]) + } + + // 验证 RequestID + if requestID, ok := result["request_id"].(string); !ok || requestID != "req-12345" { + t.Errorf("RequestID should be 'req-12345', got %v", result["request_id"]) + } + + // 验证 Method + if method, ok := result["method"].(string); !ok || method != "TestMethod" { + t.Errorf("Method should be 'TestMethod', got %v", result["method"]) + } + + // 验证 Duration + if duration, ok := result["duration_ms"].(float64); !ok || duration != 150 { + t.Errorf("Duration should be 150, got %v", result["duration_ms"]) + } +} + +// TestLogEntryJSONSerializationAllLevels 测试所有日志级别的序列化 +func TestLogEntryJSONSerializationAllLevels(t *testing.T) { + levels := []struct { + level Level + expected string + }{ + {DEBUG, "DEBUG"}, + {INFO, "INFO"}, + {WARN, "WARN"}, + {ERROR, "ERROR"}, + } + + for _, tc := range levels { + t.Run(tc.expected, func(t *testing.T) { + entry := NewLogEntry(tc.level, "TestComponent", "Test message") + data, err := entry.ToJSON() + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + if level, ok := result["level"].(string); !ok || level != tc.expected { + t.Errorf("Level should be %q, got %v", tc.expected, result["level"]) + } + }) + } +} + +// TestLogEntryOmitEmptyFields 测试空字段不输出 +func TestLogEntryOmitEmptyFields(t *testing.T) { + // 创建只有必需字段的 LogEntry + entry := NewLogEntry(INFO, "TestComponent", "Test message") + + data, err := entry.ToJSON() + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + // 验证可选字段不存在(omitempty) + optionalFields := []string{"fields", "request_id", "method", "error"} + for _, field := range optionalFields { + if val, ok := result[field]; ok && val != "" { + t.Errorf("Optional field %q should be omitted when empty, got %v", field, val) + } + } +} + +// TestLogEntryWithMethods 测试链式方法 +func TestLogEntryWithMethods(t *testing.T) { + entry := NewLogEntry(INFO, "TestComponent", "Test message"). + WithRequestID("req-123"). + WithMethod("TestMethod"). + WithDuration(100). + WithCaller("test.go", 10). + WithFields(map[string]interface{}{"key": "value"}) + + if entry.RequestID != "req-123" { + t.Errorf("RequestID should be 'req-123', got %q", entry.RequestID) + } + if entry.Method != "TestMethod" { + t.Errorf("Method should be 'TestMethod', got %q", entry.Method) + } + if entry.Duration != 100 { + t.Errorf("Duration should be 100, got %d", entry.Duration) + } + if entry.CallerFile != "test.go" { + t.Errorf("CallerFile should be 'test.go', got %q", entry.CallerFile) + } + if entry.CallerLine != 10 { + t.Errorf("CallerLine should be 10, got %d", entry.CallerLine) + } + if entry.Fields["key"] != "value" { + t.Errorf("Fields['key'] should be 'value', got %v", entry.Fields["key"]) + } +} diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 00000000..d7a51593 --- /dev/null +++ b/backend/internal/logger/logger.go @@ -0,0 +1,546 @@ +package logger + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Level 日志级别 +type Level int + +const ( + DEBUG Level = iota + INFO + WARN + ERROR +) + +// String 返回日志级别的字符串表示 +func (l Level) String() string { + switch l { + case DEBUG: + return "DEBUG" + case INFO: + return "INFO" + case WARN: + return "WARN" + case ERROR: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// ParseLevel 解析日志级别字符串 +func ParseLevel(levelStr string) Level { + switch strings.ToLower(levelStr) { + case "debug": + return DEBUG + case "info": + return INFO + case "warn", "warning": + return WARN + case "error": + return ERROR + default: + return INFO + } +} + +// Field 结构化日志字段 +type Field struct { + Key string + Value interface{} +} + +// LoggerConfig 日志配置 +type LoggerConfig struct { + Level string + FileEnabled bool + FilePath string + Format string // "text" or "json" + BufferSize int // 缓冲区大小(KB) + AsyncQueueSize int // 异步队列大小 + FlushIntervalMs int // 刷新间隔(毫秒) + + // 分片配置 + Rotation RotationConfig +} + +// RotationConfig 日志分片配置 +type RotationConfig struct { + Enabled bool + MaxSizeMB int // 单文件最大大小(MB) + MaxAge int // 保留天数 + MaxBackups int // 保留文件数 + TimeInterval string // 时间间隔: "daily", "hourly" +} + +// Logger 日志记录器 +type Logger struct { + level Level + component string + ctx context.Context + + // 写入器 + writers []Writer + consoleWriter Writer + fileWriter *FileWriter + + // 分片管理器 + rotationManager *RotationManager + + // 并发安全 + mu sync.RWMutex + + // 文件写入失败标志 + fileWriteFailed bool +} + +// 全局日志实例 +var ( + globalLogger *Logger + globalMu sync.RWMutex +) + +// DefaultLoggerConfig 返回默认日志配置 +func DefaultLoggerConfig() LoggerConfig { + return LoggerConfig{ + Level: "info", + FileEnabled: false, + FilePath: "data/logs/app.log", + Format: "text", + BufferSize: 4, // 4KB + AsyncQueueSize: 1000, + FlushIntervalMs: 1000, // 1秒 + Rotation: RotationConfig{ + Enabled: false, + MaxSizeMB: 100, + MaxAge: 7, + MaxBackups: 5, + TimeInterval: "daily", + }, + } +} + +// Init 初始化全局日志(简单版本,仅控制台输出) +func Init(ctx context.Context, levelStr string) { + InitWithConfig(ctx, LoggerConfig{ + Level: levelStr, + FileEnabled: false, + Format: "text", + }) +} + +// InitWithConfig 使用配置初始化全局日志 +func InitWithConfig(ctx context.Context, config LoggerConfig) { + globalMu.Lock() + defer globalMu.Unlock() + + // 解析日志级别,无效级别使用默认 INFO + level := ParseLevel(config.Level) + if config.Level != "" && level == INFO && strings.ToLower(config.Level) != "info" { + // 无效级别,记录警告(使用 fmt 因为 logger 还未初始化) + fmt.Printf("[WARN] Invalid log level '%s', using default 'INFO'\n", config.Level) + } + + // 创建格式化器 + var formatter Formatter + switch strings.ToLower(config.Format) { + case "json": + formatter = NewJSONFormatter() + default: + formatter = NewTextFormatter() + } + + // 创建控制台写入器 + consoleWriter := NewConsoleWriter(formatter) + + logger := &Logger{ + level: level, + ctx: ctx, + writers: []Writer{consoleWriter, globalMemoryWriter}, + consoleWriter: consoleWriter, + } + + // 如果启用文件日志,创建文件写入器 + if config.FileEnabled && config.FilePath != "" { + fileWriter, rotationManager, err := createFileWriterWithRotation(config, formatter) + if err != nil { + // 文件写入器创建失败,回退到仅控制台输出 + fmt.Printf("[WARN] Failed to create file writer: %v, falling back to console only\n", err) + logger.fileWriteFailed = true + } else { + logger.fileWriter = fileWriter + logger.rotationManager = rotationManager + logger.writers = append(logger.writers, fileWriter) + } + } + + globalLogger = logger +} + +// createFileWriterWithRotation 创建带分片功能的文件写入器 +func createFileWriterWithRotation(config LoggerConfig, formatter Formatter) (*FileWriter, *RotationManager, error) { + // 确保目录存在 + dir := filepath.Dir(config.FilePath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, nil, fmt.Errorf("failed to create log directory: %w", err) + } + } + + // 计算缓冲区大小(KB -> 字节) + bufferSize := config.BufferSize * 1024 + if bufferSize <= 0 { + bufferSize = 4 * 1024 // 默认 4KB + } + + // 计算刷新间隔 + flushInterval := time.Duration(config.FlushIntervalMs) * time.Millisecond + if flushInterval <= 0 { + flushInterval = time.Second + } + + // 异步队列大小 + asyncQueueSize := config.AsyncQueueSize + if asyncQueueSize <= 0 { + asyncQueueSize = 1000 + } + + fileConfig := FileWriterConfig{ + FilePath: config.FilePath, + BufferSize: bufferSize, + FlushInterval: flushInterval, + AsyncQueueSize: asyncQueueSize, + } + + // 使用异步文件写入器 + fileWriter, err := NewAsyncFileWriter(fileConfig, formatter) + if err != nil { + return nil, nil, err + } + + // 创建分片管理器(如果启用) + var rotationManager *RotationManager + if config.Rotation.Enabled { + rotationPolicy := createRotationPolicy(config.Rotation) + rotationManager = NewRotationManager(RotationManagerConfig{ + BasePath: config.FilePath, + MaxBackups: config.Rotation.MaxBackups, + MaxAge: config.Rotation.MaxAge, + Policy: rotationPolicy, + }) + } + + return fileWriter, rotationManager, nil +} + +// createRotationPolicy 根据配置创建分片策略 +func createRotationPolicy(config RotationConfig) RotationPolicy { + var policies []RotationPolicy + + // 时间分片策略 + if config.TimeInterval != "" { + var interval TimeInterval + switch strings.ToLower(config.TimeInterval) { + case "hourly": + interval = Hourly + default: + interval = Daily + } + policies = append(policies, NewTimeRotationPolicy(interval)) + } + + // 大小分片策略 + if config.MaxSizeMB > 0 { + policies = append(policies, NewSizeRotationPolicyMB(config.MaxSizeMB)) + } + + // 如果有多个策略,使用组合策略 + if len(policies) > 1 { + return NewCompositeRotationPolicy(policies...) + } else if len(policies) == 1 { + return policies[0] + } + + // 默认按天分片 + return NewTimeRotationPolicy(Daily) +} + +// Close 关闭全局日志 +func Close() error { + globalMu.Lock() + defer globalMu.Unlock() + + if globalLogger == nil { + return nil + } + + var lastErr error + for _, writer := range globalLogger.writers { + if err := writer.Close(); err != nil { + lastErr = err + } + } + + globalLogger = nil + return lastErr +} + +// New 创建新的日志记录器 +func New(component string) *Logger { + globalMu.RLock() + defer globalMu.RUnlock() + + if globalLogger == nil { + // 如果全局日志未初始化,创建一个默认的 + consoleWriter := NewConsoleWriter(NewTextFormatter()) + return &Logger{ + level: INFO, + component: component, + writers: []Writer{consoleWriter}, + consoleWriter: consoleWriter, + } + } + + return &Logger{ + level: globalLogger.level, + component: component, + ctx: globalLogger.ctx, + writers: globalLogger.writers, + consoleWriter: globalLogger.consoleWriter, + fileWriter: globalLogger.fileWriter, + rotationManager: globalLogger.rotationManager, + fileWriteFailed: globalLogger.fileWriteFailed, + } +} + +// SetLevel 动态设置日志级别(并发安全) +func (l *Logger) SetLevel(level Level) { + l.mu.Lock() + defer l.mu.Unlock() + l.level = level +} + +// SetLevelString 通过字符串动态设置日志级别 +func (l *Logger) SetLevelString(levelStr string) { + l.SetLevel(ParseLevel(levelStr)) +} + +// GetLevel 获取当前日志级别 +func (l *Logger) GetLevel() Level { + l.mu.RLock() + defer l.mu.RUnlock() + return l.level +} + +// SetGlobalLevel 设置全局日志级别 +func SetGlobalLevel(level Level) { + globalMu.Lock() + defer globalMu.Unlock() + + if globalLogger != nil { + globalLogger.mu.Lock() + globalLogger.level = level + globalLogger.mu.Unlock() + } +} + +// SetGlobalLevelString 通过字符串设置全局日志级别 +func SetGlobalLevelString(levelStr string) { + SetGlobalLevel(ParseLevel(levelStr)) +} + +// Debug 记录调试日志 +func (l *Logger) Debug(msg string, fields ...Field) { + l.mu.RLock() + level := l.level + l.mu.RUnlock() + + if level <= DEBUG { + l.log(DEBUG, msg, fields...) + } +} + +// Info 记录信息日志 +func (l *Logger) Info(msg string, fields ...Field) { + l.mu.RLock() + level := l.level + l.mu.RUnlock() + + if level <= INFO { + l.log(INFO, msg, fields...) + } +} + +// Warn 记录警告日志 +func (l *Logger) Warn(msg string, fields ...Field) { + l.mu.RLock() + level := l.level + l.mu.RUnlock() + + if level <= WARN { + l.log(WARN, msg, fields...) + } +} + +// Error 记录错误日志 +func (l *Logger) Error(msg string, fields ...Field) { + l.mu.RLock() + level := l.level + l.mu.RUnlock() + + if level <= ERROR { + l.log(ERROR, msg, fields...) + } +} + +// log 内部日志记录方法 +func (l *Logger) log(level Level, msg string, fields ...Field) { + // 创建日志条目 + entry := NewLogEntry(level, l.component, msg) + + // 添加字段 + if len(fields) > 0 { + fieldMap := make(map[string]interface{}, len(fields)) + for _, field := range fields { + fieldMap[field.Key] = field.Value + } + entry.WithFields(fieldMap) + } + + // 写入所有写入器 + l.writeEntry(entry) +} + +// writeEntry 写入日志条目到所有写入器 +func (l *Logger) writeEntry(entry *LogEntry) { + l.mu.RLock() + writers := l.writers + fileWriter := l.fileWriter + consoleWriter := l.consoleWriter + fileWriteFailed := l.fileWriteFailed + l.mu.RUnlock() + + // 如果文件写入已失败,只写入控制台 + if fileWriteFailed { + if consoleWriter != nil { + _ = consoleWriter.Write(entry) + } + return + } + + // 写入所有写入器 + for _, writer := range writers { + if err := writer.Write(entry); err != nil { + // 如果是文件写入器失败,标记并回退到控制台 + if writer == fileWriter { + l.handleFileWriteError(entry, err) + } + } + } +} + +// handleFileWriteError 处理文件写入错误 +func (l *Logger) handleFileWriteError(entry *LogEntry, err error) { + l.mu.Lock() + if !l.fileWriteFailed { + l.fileWriteFailed = true + // 记录错误到控制台 + fmt.Printf("[ERROR] File write failed: %v, falling back to console only\n", err) + } + l.mu.Unlock() +} + +// LogEntry 直接写入日志条目(用于拦截器等高级用法) +func (l *Logger) LogEntry(entry *LogEntry) { + l.mu.RLock() + level := l.level + l.mu.RUnlock() + + // 检查日志级别 + if entry.Level < level { + return + } + + l.writeEntry(entry) +} + +// WithComponent 创建带有组件名的新日志记录器 +func (l *Logger) WithComponent(component string) *Logger { + l.mu.RLock() + defer l.mu.RUnlock() + + return &Logger{ + level: l.level, + component: component, + ctx: l.ctx, + writers: l.writers, + consoleWriter: l.consoleWriter, + fileWriter: l.fileWriter, + rotationManager: l.rotationManager, + fileWriteFailed: l.fileWriteFailed, + } +} + +// Flush 刷新所有写入器的缓冲区 +func (l *Logger) Flush() error { + l.mu.RLock() + fileWriter := l.fileWriter + l.mu.RUnlock() + + if fileWriter != nil { + return fileWriter.Flush() + } + return nil +} + +// GetRotationManager 获取分片管理器 +func (l *Logger) GetRotationManager() *RotationManager { + l.mu.RLock() + defer l.mu.RUnlock() + return l.rotationManager +} + +// F 创建字段的便捷函数 +func F(key string, value interface{}) Field { + return Field{Key: key, Value: value} +} + +// Fs 创建多个字段的便捷函数 +func Fs(keyValues ...interface{}) []Field { + fields := make([]Field, 0, len(keyValues)/2) + for i := 0; i < len(keyValues)-1; i += 2 { + if key, ok := keyValues[i].(string); ok { + fields = append(fields, Field{Key: key, Value: keyValues[i+1]}) + } + } + return fields +} + +// IsFileEnabled 检查文件日志是否启用 +func (l *Logger) IsFileEnabled() bool { + l.mu.RLock() + defer l.mu.RUnlock() + return l.fileWriter != nil && !l.fileWriteFailed +} + +// GetWriters 获取所有写入器(用于测试) +func (l *Logger) GetWriters() []Writer { + l.mu.RLock() + defer l.mu.RUnlock() + return l.writers +} + +// ShouldLog 检查指定级别是否应该被记录 +func (l *Logger) ShouldLog(level Level) bool { + l.mu.RLock() + defer l.mu.RUnlock() + return level >= l.level +} diff --git a/backend/internal/logger/logger_test.go b/backend/internal/logger/logger_test.go new file mode 100644 index 00000000..81413a50 --- /dev/null +++ b/backend/internal/logger/logger_test.go @@ -0,0 +1,439 @@ +package logger + +import ( + "bytes" + "context" + "sync" + "testing" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" +) + +// MockWriter 用于测试的模拟写入器 +type MockWriter struct { + entries []*LogEntry + mu sync.Mutex +} + +func NewMockWriter() *MockWriter { + return &MockWriter{ + entries: make([]*LogEntry, 0), + } +} + +func (w *MockWriter) Write(entry *LogEntry) error { + w.mu.Lock() + defer w.mu.Unlock() + w.entries = append(w.entries, entry) + return nil +} + +func (w *MockWriter) Close() error { + return nil +} + +func (w *MockWriter) GetEntries() []*LogEntry { + w.mu.Lock() + defer w.mu.Unlock() + result := make([]*LogEntry, len(w.entries)) + copy(result, w.entries) + return result +} + +func (w *MockWriter) Clear() { + w.mu.Lock() + defer w.mu.Unlock() + w.entries = make([]*LogEntry, 0) +} + +// createTestLogger 创建用于测试的 Logger +func createTestLogger(level Level, writer Writer) *Logger { + return &Logger{ + level: level, + component: "test", + writers: []Writer{writer}, + consoleWriter: writer, + } +} + +// TestProperty5_LogLevelFiltering 属性测试:日志级别过滤 +// Property 5: Log Level Filtering +// *For any* configured log level L, all log entries with level below L SHALL NOT be written +// to any output, and all entries with level >= L SHALL be written. +// **Validates: Requirements 3.2** +func TestProperty5_LogLevelFiltering(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + // 生成日志级别 (0-3: DEBUG, INFO, WARN, ERROR) + levelGen := gen.IntRange(0, 3).Map(func(i int) Level { + return Level(i) + }) + + // Property: 低于配置级别的日志不应被写入 + properties.Property("logs below configured level are not written", prop.ForAll( + func(configuredLevel Level, entryLevel Level) bool { + mockWriter := NewMockWriter() + logger := createTestLogger(configuredLevel, mockWriter) + + // 根据 entryLevel 调用相应的日志方法 + switch entryLevel { + case DEBUG: + logger.Debug("test message") + case INFO: + logger.Info("test message") + case WARN: + logger.Warn("test message") + case ERROR: + logger.Error("test message") + } + + entries := mockWriter.GetEntries() + + // 如果 entryLevel < configuredLevel,不应该有日志写入 + if entryLevel < configuredLevel { + return len(entries) == 0 + } + // 如果 entryLevel >= configuredLevel,应该有日志写入 + return len(entries) == 1 && entries[0].Level == entryLevel + }, + levelGen, + levelGen, + )) + + // Property: 等于或高于配置级别的日志应被写入 + properties.Property("logs at or above configured level are written", prop.ForAll( + func(configuredLevel Level) bool { + mockWriter := NewMockWriter() + logger := createTestLogger(configuredLevel, mockWriter) + + // 写入所有级别的日志 + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + + entries := mockWriter.GetEntries() + + // 计算应该写入的日志数量 + expectedCount := 0 + for level := DEBUG; level <= ERROR; level++ { + if level >= configuredLevel { + expectedCount++ + } + } + + if len(entries) != expectedCount { + return false + } + + // 验证所有写入的日志级别都 >= configuredLevel + for _, entry := range entries { + if entry.Level < configuredLevel { + return false + } + } + + return true + }, + levelGen, + )) + + // Property: 动态修改级别后过滤行为正确 + properties.Property("dynamic level change affects filtering correctly", prop.ForAll( + func(initialLevel Level, newLevel Level) bool { + mockWriter := NewMockWriter() + logger := createTestLogger(initialLevel, mockWriter) + + // 使用初始级别写入日志 + logger.Info("initial info") + initialEntries := mockWriter.GetEntries() + + // 验证初始级别过滤 + initialExpected := INFO >= initialLevel + if initialExpected && len(initialEntries) != 1 { + return false + } + if !initialExpected && len(initialEntries) != 0 { + return false + } + + // 动态修改级别 + mockWriter.Clear() + logger.SetLevel(newLevel) + + // 使用新级别写入日志 + logger.Info("new info") + newEntries := mockWriter.GetEntries() + + // 验证新级别过滤 + newExpected := INFO >= newLevel + if newExpected && len(newEntries) != 1 { + return false + } + if !newExpected && len(newEntries) != 0 { + return false + } + + return true + }, + levelGen, + levelGen, + )) + + properties.TestingRun(t) +} + +// TestLoggerBasic 基础功能测试 +func TestLoggerBasic(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(DEBUG, mockWriter) + + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + + entries := mockWriter.GetEntries() + if len(entries) != 4 { + t.Errorf("expected 4 entries, got %d", len(entries)) + } +} + +// TestLoggerLevelFiltering 级别过滤测试 +func TestLoggerLevelFiltering(t *testing.T) { + tests := []struct { + name string + configLevel Level + expectedCount int + }{ + {"DEBUG level logs all", DEBUG, 4}, + {"INFO level filters DEBUG", INFO, 3}, + {"WARN level filters DEBUG and INFO", WARN, 2}, + {"ERROR level filters all except ERROR", ERROR, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(tt.configLevel, mockWriter) + + logger.Debug("debug") + logger.Info("info") + logger.Warn("warn") + logger.Error("error") + + entries := mockWriter.GetEntries() + if len(entries) != tt.expectedCount { + t.Errorf("expected %d entries, got %d", tt.expectedCount, len(entries)) + } + }) + } +} + +// TestLoggerSetLevel 动态级别修改测试 +func TestLoggerSetLevel(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(DEBUG, mockWriter) + + // 初始级别为 DEBUG,所有日志都应该写入 + logger.Debug("debug1") + if len(mockWriter.GetEntries()) != 1 { + t.Error("DEBUG log should be written at DEBUG level") + } + + // 修改级别为 ERROR + mockWriter.Clear() + logger.SetLevel(ERROR) + + logger.Debug("debug2") + logger.Info("info2") + logger.Warn("warn2") + logger.Error("error2") + + entries := mockWriter.GetEntries() + if len(entries) != 1 { + t.Errorf("expected 1 entry at ERROR level, got %d", len(entries)) + } + if entries[0].Level != ERROR { + t.Errorf("expected ERROR level, got %s", entries[0].Level.String()) + } +} + +// TestLoggerGetLevel 获取级别测试 +func TestLoggerGetLevel(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(WARN, mockWriter) + + if logger.GetLevel() != WARN { + t.Errorf("expected WARN level, got %s", logger.GetLevel().String()) + } + + logger.SetLevel(DEBUG) + if logger.GetLevel() != DEBUG { + t.Errorf("expected DEBUG level after SetLevel, got %s", logger.GetLevel().String()) + } +} + +// TestLoggerShouldLog 检查是否应该记录测试 +func TestLoggerShouldLog(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(INFO, mockWriter) + + if logger.ShouldLog(DEBUG) { + t.Error("DEBUG should not be logged at INFO level") + } + if !logger.ShouldLog(INFO) { + t.Error("INFO should be logged at INFO level") + } + if !logger.ShouldLog(WARN) { + t.Error("WARN should be logged at INFO level") + } + if !logger.ShouldLog(ERROR) { + t.Error("ERROR should be logged at INFO level") + } +} + +// TestLoggerWithFields 带字段的日志测试 +func TestLoggerWithFields(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(DEBUG, mockWriter) + + logger.Info("test message", F("key1", "value1"), F("key2", 123)) + + entries := mockWriter.GetEntries() + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + + entry := entries[0] + if entry.Fields == nil { + t.Fatal("expected fields to be set") + } + if entry.Fields["key1"] != "value1" { + t.Errorf("expected key1=value1, got %v", entry.Fields["key1"]) + } + if entry.Fields["key2"] != 123 { + t.Errorf("expected key2=123, got %v", entry.Fields["key2"]) + } +} + +// TestParseLevel 级别解析测试 +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + expected Level + }{ + {"debug", DEBUG}, + {"DEBUG", DEBUG}, + {"info", INFO}, + {"INFO", INFO}, + {"warn", WARN}, + {"WARN", WARN}, + {"warning", WARN}, + {"error", ERROR}, + {"ERROR", ERROR}, + {"invalid", INFO}, // 默认为 INFO + {"", INFO}, // 空字符串默认为 INFO + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ParseLevel(tt.input) + if result != tt.expected { + t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +// TestLoggerInit 初始化测试 +func TestLoggerInit(t *testing.T) { + // 保存原始全局 logger + originalLogger := globalLogger + defer func() { + globalLogger = originalLogger + }() + + ctx := context.Background() + Init(ctx, "debug") + + logger := New("test-component") + if logger.GetLevel() != DEBUG { + t.Errorf("expected DEBUG level, got %s", logger.GetLevel().String()) + } + if logger.component != "test-component" { + t.Errorf("expected component 'test-component', got %s", logger.component) + } +} + +// TestLoggerConcurrency 并发安全测试 +func TestLoggerConcurrency(t *testing.T) { + mockWriter := NewMockWriter() + logger := createTestLogger(DEBUG, mockWriter) + + var wg sync.WaitGroup + iterations := 100 + + // 并发写入日志 + for i := 0; i < iterations; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + logger.Info("concurrent message", F("iteration", n)) + }(i) + } + + // 并发修改级别 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + level := Level(n % 4) + logger.SetLevel(level) + }(i) + } + + wg.Wait() + + // 验证没有 panic 发生,日志数量可能因级别变化而不同 + entries := mockWriter.GetEntries() + t.Logf("Concurrent test wrote %d entries", len(entries)) +} + +// BufferWriter 用于捕获输出的写入器 +type BufferWriter struct { + buffer *bytes.Buffer + mu sync.Mutex +} + +func NewBufferWriter() *BufferWriter { + return &BufferWriter{ + buffer: new(bytes.Buffer), + } +} + +func (w *BufferWriter) Write(entry *LogEntry) error { + w.mu.Lock() + defer w.mu.Unlock() + formatter := NewTextFormatter() + data, err := formatter.Format(entry) + if err != nil { + return err + } + w.buffer.Write(data) + return nil +} + +func (w *BufferWriter) Close() error { + return nil +} + +func (w *BufferWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.buffer.String() +} diff --git a/backend/internal/logger/memory_writer.go b/backend/internal/logger/memory_writer.go new file mode 100644 index 00000000..4081c47a --- /dev/null +++ b/backend/internal/logger/memory_writer.go @@ -0,0 +1,76 @@ +package logger + +import ( + "sync" +) + +const defaultMemoryBufferSize = 500 + +// MemoryLogEntry 内存日志条目(供前端消费) +type MemoryLogEntry struct { + Time string `json:"time"` + Level string `json:"level"` + Component string `json:"component"` + Message string `json:"message"` + Fields map[string]interface{} `json:"fields,omitempty"` +} + +// MemoryWriter 内存环形缓冲写入器,线程安全 +type MemoryWriter struct { + mu sync.RWMutex + entries []MemoryLogEntry + maxSize int +} + +var globalMemoryWriter *MemoryWriter + +func init() { + globalMemoryWriter = &MemoryWriter{ + entries: make([]MemoryLogEntry, 0, defaultMemoryBufferSize), + maxSize: defaultMemoryBufferSize, + } +} + +// GetMemoryWriter 获取全局内存写入器 +func GetMemoryWriter() *MemoryWriter { + return globalMemoryWriter +} + +func (w *MemoryWriter) Write(entry *LogEntry) error { + if entry == nil { + return nil + } + w.mu.Lock() + defer w.mu.Unlock() + + item := MemoryLogEntry{ + Time: entry.Timestamp.Format("2006-01-02 15:04:05"), + Level: entry.Level.String(), + Component: entry.Component, + Message: entry.Message, + Fields: entry.Fields, + } + if len(w.entries) >= w.maxSize { + w.entries = w.entries[1:] + } + w.entries = append(w.entries, item) + return nil +} + +func (w *MemoryWriter) Close() error { return nil } + +// GetEntries 返回所有缓冲日志(最新在后) +func (w *MemoryWriter) GetEntries() []MemoryLogEntry { + w.mu.RLock() + defer w.mu.RUnlock() + result := make([]MemoryLogEntry, len(w.entries)) + copy(result, w.entries) + return result +} + +// Clear 清空缓冲 +func (w *MemoryWriter) Clear() { + w.mu.Lock() + defer w.mu.Unlock() + w.entries = w.entries[:0] +} diff --git a/backend/internal/logger/rotation.go b/backend/internal/logger/rotation.go new file mode 100644 index 00000000..d0d37522 --- /dev/null +++ b/backend/internal/logger/rotation.go @@ -0,0 +1,524 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// TimeInterval 时间分片间隔类型 +type TimeInterval string + +const ( + // Daily 每天分片 + Daily TimeInterval = "daily" + // Hourly 每小时分片 + Hourly TimeInterval = "hourly" +) + +// TimeRotationPolicy 按时间分片策略 +// 支持按天或按小时分片 +type TimeRotationPolicy struct { + interval TimeInterval + lastRotate time.Time + mu sync.RWMutex +} + +// NewTimeRotationPolicy 创建时间分片策略 +func NewTimeRotationPolicy(interval TimeInterval) *TimeRotationPolicy { + return &TimeRotationPolicy{ + interval: interval, + lastRotate: time.Time{}, // 零值,首次检查时会初始化 + } +} + +// ShouldRotate 判断是否应该触发时间分片 +func (p *TimeRotationPolicy) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool { + if fileInfo == nil || entry == nil { + return false + } + + p.mu.RLock() + lastRotate := p.lastRotate + p.mu.RUnlock() + + entryTime := entry.Timestamp + if entryTime.IsZero() { + entryTime = time.Now() + } + + // 首次检查,使用文件修改时间作为基准 + if lastRotate.IsZero() { + p.mu.Lock() + p.lastRotate = fileInfo.ModTime() + p.mu.Unlock() + lastRotate = fileInfo.ModTime() + } + + switch p.interval { + case Daily: + // 检查是否跨天 + return !sameDay(lastRotate, entryTime) + case Hourly: + // 检查是否跨小时 + return !sameHour(lastRotate, entryTime) + default: + // 默认按天 + return !sameDay(lastRotate, entryTime) + } +} + +// GetRotatedFileName 获取分片后的文件名 +func (p *TimeRotationPolicy) GetRotatedFileName(baseName string, timestamp time.Time) string { + ext := filepath.Ext(baseName) + nameWithoutExt := strings.TrimSuffix(baseName, ext) + if ext == "" { + ext = ".log" + } + + switch p.interval { + case Hourly: + // 格式: app.2024-01-15-14.log + return fmt.Sprintf("%s.%s%s", nameWithoutExt, timestamp.Format("2006-01-02-15"), ext) + default: + // 格式: app.2024-01-15.log + return fmt.Sprintf("%s.%s%s", nameWithoutExt, timestamp.Format("2006-01-02"), ext) + } +} + +// UpdateLastRotate 更新最后分片时间 +func (p *TimeRotationPolicy) UpdateLastRotate(t time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + p.lastRotate = t +} + +// sameDay 判断两个时间是否在同一天 +func sameDay(t1, t2 time.Time) bool { + y1, m1, d1 := t1.Date() + y2, m2, d2 := t2.Date() + return y1 == y2 && m1 == m2 && d1 == d2 +} + +// sameHour 判断两个时间是否在同一小时 +func sameHour(t1, t2 time.Time) bool { + return sameDay(t1, t2) && t1.Hour() == t2.Hour() +} + +// SizeRotationPolicy 按大小分片策略 +// 当文件大小超过指定阈值时触发分片 +type SizeRotationPolicy struct { + maxSize int64 // 最大文件大小(字节) + sequence int // 当前序号(同一天内多次分片) + mu sync.RWMutex +} + +// NewSizeRotationPolicy 创建大小分片策略 +// maxSizeBytes: 最大文件大小(字节) +func NewSizeRotationPolicy(maxSizeBytes int64) *SizeRotationPolicy { + return &SizeRotationPolicy{ + maxSize: maxSizeBytes, + sequence: 0, + } +} + +// NewSizeRotationPolicyMB 创建大小分片策略(MB为单位) +// maxSizeMB: 最大文件大小(MB) +func NewSizeRotationPolicyMB(maxSizeMB int) *SizeRotationPolicy { + return NewSizeRotationPolicy(int64(maxSizeMB) * 1024 * 1024) +} + +// ShouldRotate 判断是否应该触发大小分片 +func (p *SizeRotationPolicy) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool { + if fileInfo == nil { + return false + } + return fileInfo.Size() >= p.maxSize +} + +// GetRotatedFileName 获取分片后的文件名 +func (p *SizeRotationPolicy) GetRotatedFileName(baseName string, timestamp time.Time) string { + ext := filepath.Ext(baseName) + nameWithoutExt := strings.TrimSuffix(baseName, ext) + if ext == "" { + ext = ".log" + } + + p.mu.Lock() + p.sequence++ + seq := p.sequence + p.mu.Unlock() + + // 格式: app.2024-01-15.1.log + return fmt.Sprintf("%s.%s.%d%s", nameWithoutExt, timestamp.Format("2006-01-02"), seq, ext) +} + +// ResetSequence 重置序号(通常在日期变化时调用) +func (p *SizeRotationPolicy) ResetSequence() { + p.mu.Lock() + defer p.mu.Unlock() + p.sequence = 0 +} + +// GetMaxSize 获取最大文件大小 +func (p *SizeRotationPolicy) GetMaxSize() int64 { + return p.maxSize +} + +// CompositeRotationPolicy 组合分片策略 +// 任一子策略满足条件即触发分片 +type CompositeRotationPolicy struct { + policies []RotationPolicy + mu sync.RWMutex +} + +// NewCompositeRotationPolicy 创建组合分片策略 +func NewCompositeRotationPolicy(policies ...RotationPolicy) *CompositeRotationPolicy { + return &CompositeRotationPolicy{ + policies: policies, + } +} + +// ShouldRotate 判断是否应该触发分片 +// 任一子策略返回 true 即触发 +func (p *CompositeRotationPolicy) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool { + p.mu.RLock() + defer p.mu.RUnlock() + + for _, policy := range p.policies { + if policy.ShouldRotate(fileInfo, entry) { + return true + } + } + return false +} + +// GetRotatedFileName 获取分片后的文件名 +// 使用第一个策略的命名规则 +func (p *CompositeRotationPolicy) GetRotatedFileName(baseName string, timestamp time.Time) string { + p.mu.RLock() + defer p.mu.RUnlock() + + if len(p.policies) > 0 { + return p.policies[0].GetRotatedFileName(baseName, timestamp) + } + + // 默认命名 + ext := filepath.Ext(baseName) + nameWithoutExt := strings.TrimSuffix(baseName, ext) + if ext == "" { + ext = ".log" + } + return fmt.Sprintf("%s.%s%s", nameWithoutExt, timestamp.Format("2006-01-02"), ext) +} + +// AddPolicy 添加子策略 +func (p *CompositeRotationPolicy) AddPolicy(policy RotationPolicy) { + p.mu.Lock() + defer p.mu.Unlock() + p.policies = append(p.policies, policy) +} + +// GetPolicies 获取所有子策略 +func (p *CompositeRotationPolicy) GetPolicies() []RotationPolicy { + p.mu.RLock() + defer p.mu.RUnlock() + result := make([]RotationPolicy, len(p.policies)) + copy(result, p.policies) + return result +} + +// RotationManagerConfig 分片管理器配置 +type RotationManagerConfig struct { + BasePath string // 基础日志文件路径 + MaxBackups int // 最大保留文件数 + MaxAge int // 最大保留天数 + Policy RotationPolicy // 分片策略 +} + +// RotationManager 日志分片管理器 +// 负责执行分片操作和清理历史文件 +type RotationManager struct { + config RotationManagerConfig + mu sync.Mutex + currentSeq int // 当前序号 +} + +// NewRotationManager 创建分片管理器 +func NewRotationManager(config RotationManagerConfig) *RotationManager { + if config.MaxBackups <= 0 { + config.MaxBackups = 5 + } + return &RotationManager{ + config: config, + currentSeq: 0, + } +} + +// ShouldRotate 检查是否需要分片 +func (m *RotationManager) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool { + if m.config.Policy == nil { + return false + } + return m.config.Policy.ShouldRotate(fileInfo, entry) +} + +// Rotate 执行分片操作 +// 返回新的日志文件路径 +func (m *RotationManager) Rotate(currentFile *os.File) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if currentFile == nil { + return "", fmt.Errorf("current file is nil") + } + + // 获取当前文件信息 + basePath := m.config.BasePath + timestamp := time.Now() + + // 生成分片文件名 + rotatedName := m.generateRotatedFileName(basePath, timestamp) + + // 关闭当前文件 + if err := currentFile.Close(); err != nil { + return "", fmt.Errorf("failed to close current file: %w", err) + } + + // 重命名当前文件为分片文件 + if err := os.Rename(basePath, rotatedName); err != nil { + return "", fmt.Errorf("failed to rename file: %w", err) + } + + // 清理历史文件 + if err := m.cleanupOldFiles(); err != nil { + // 清理失败不影响主流程,只记录错误 + fmt.Fprintf(os.Stderr, "failed to cleanup old files: %v\n", err) + } + + return rotatedName, nil +} + +// generateRotatedFileName 生成分片文件名 +// 格式: {basename}.{timestamp}[.{sequence}].log +func (m *RotationManager) generateRotatedFileName(basePath string, timestamp time.Time) string { + ext := filepath.Ext(basePath) + nameWithoutExt := strings.TrimSuffix(basePath, ext) + if ext == "" { + ext = ".log" + } + + dateStr := timestamp.Format("2006-01-02") + + // 检查是否已存在同日期的文件,确定序号 + seq := m.findNextSequence(nameWithoutExt, dateStr, ext) + + if seq > 0 { + // 格式: app.2024-01-15.1.log + return fmt.Sprintf("%s.%s.%d%s", nameWithoutExt, dateStr, seq, ext) + } + // 格式: app.2024-01-15.log + return fmt.Sprintf("%s.%s%s", nameWithoutExt, dateStr, ext) +} + +// findNextSequence 查找下一个可用序号 +func (m *RotationManager) findNextSequence(nameWithoutExt, dateStr, ext string) int { + dir := filepath.Dir(nameWithoutExt) + if dir == "" { + dir = "." + } + baseName := filepath.Base(nameWithoutExt) + + // 查找已存在的同日期文件 + pattern := fmt.Sprintf("%s.%s*%s", baseName, dateStr, ext) + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil || len(matches) == 0 { + return 0 + } + + // 找到最大序号 + maxSeq := 0 + seqPattern := regexp.MustCompile(fmt.Sprintf(`%s\.%s(?:\.(\d+))?%s$`, + regexp.QuoteMeta(baseName), + regexp.QuoteMeta(dateStr), + regexp.QuoteMeta(ext))) + + for _, match := range matches { + fileName := filepath.Base(match) + if submatches := seqPattern.FindStringSubmatch(fileName); submatches != nil { + if len(submatches) > 1 && submatches[1] != "" { + var seq int + fmt.Sscanf(submatches[1], "%d", &seq) + if seq > maxSeq { + maxSeq = seq + } + } else { + // 无序号的文件存在,下一个从1开始 + if maxSeq == 0 { + maxSeq = 0 + } + } + } + } + + return maxSeq + 1 +} + +// cleanupOldFiles 清理历史文件 +func (m *RotationManager) cleanupOldFiles() error { + files, err := m.listRotatedFiles() + if err != nil { + return err + } + + // 按修改时间排序(最新的在前) + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime.After(files[j].ModTime) + }) + + // 删除超出数量限制的文件 + if len(files) > m.config.MaxBackups { + for _, f := range files[m.config.MaxBackups:] { + if err := os.Remove(f.Path); err != nil { + return fmt.Errorf("failed to remove old file %s: %w", f.Path, err) + } + } + } + + // 删除超出时间限制的文件 + if m.config.MaxAge > 0 { + cutoff := time.Now().AddDate(0, 0, -m.config.MaxAge) + for _, f := range files { + if f.ModTime.Before(cutoff) { + if err := os.Remove(f.Path); err != nil { + return fmt.Errorf("failed to remove old file %s: %w", f.Path, err) + } + } + } + } + + return nil +} + +// rotatedFileInfo 分片文件信息 +type rotatedFileInfo struct { + Path string + ModTime time.Time +} + +// listRotatedFiles 列出所有分片文件 +func (m *RotationManager) listRotatedFiles() ([]rotatedFileInfo, error) { + basePath := m.config.BasePath + dir := filepath.Dir(basePath) + if dir == "" { + dir = "." + } + + ext := filepath.Ext(basePath) + nameWithoutExt := filepath.Base(strings.TrimSuffix(basePath, ext)) + if ext == "" { + ext = ".log" + } + + // 匹配模式: app.YYYY-MM-DD*.log + pattern := fmt.Sprintf("%s.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*%s", nameWithoutExt, ext) + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + return nil, fmt.Errorf("failed to glob files: %w", err) + } + + var files []rotatedFileInfo + for _, match := range matches { + info, err := os.Stat(match) + if err != nil { + continue + } + files = append(files, rotatedFileInfo{ + Path: match, + ModTime: info.ModTime(), + }) + } + + return files, nil +} + +// GetRotatedFileCount 获取当前分片文件数量 +func (m *RotationManager) GetRotatedFileCount() (int, error) { + files, err := m.listRotatedFiles() + if err != nil { + return 0, err + } + return len(files), nil +} + +// GetConfig 获取配置 +func (m *RotationManager) GetConfig() RotationManagerConfig { + return m.config +} + +// ValidateRotatedFileName 验证文件名是否符合分片命名格式 +// 格式: {basename}.{timestamp}[.{sequence}].log +func ValidateRotatedFileName(fileName string) bool { + // 匹配模式: name.YYYY-MM-DD.log 或 name.YYYY-MM-DD.N.log 或 name.YYYY-MM-DD-HH.log + patterns := []string{ + `^.+\.\d{4}-\d{2}-\d{2}\.log$`, // app.2024-01-15.log + `^.+\.\d{4}-\d{2}-\d{2}\.\d+\.log$`, // app.2024-01-15.1.log + `^.+\.\d{4}-\d{2}-\d{2}-\d{2}\.log$`, // app.2024-01-15-14.log (hourly) + `^.+\.\d{4}-\d{2}-\d{2}-\d{2}\.\d+\.log$`, // app.2024-01-15-14.1.log + } + + for _, p := range patterns { + matched, _ := regexp.MatchString(p, fileName) + if matched { + return true + } + } + return false +} + +// ParseRotatedFileName 解析分片文件名 +// 返回基础名、时间戳、序号 +func ParseRotatedFileName(fileName string) (baseName string, timestamp time.Time, sequence int, err error) { + ext := filepath.Ext(fileName) + nameWithoutExt := strings.TrimSuffix(fileName, ext) + + // 尝试匹配带序号的格式: app.2024-01-15.1 + seqPattern := regexp.MustCompile(`^(.+)\.(\d{4}-\d{2}-\d{2}(?:-\d{2})?)\.(\d+)$`) + if matches := seqPattern.FindStringSubmatch(nameWithoutExt); matches != nil { + baseName = matches[1] + timestamp, err = parseTimestamp(matches[2]) + if err != nil { + return "", time.Time{}, 0, err + } + fmt.Sscanf(matches[3], "%d", &sequence) + return baseName, timestamp, sequence, nil + } + + // 尝试匹配不带序号的格式: app.2024-01-15 + noSeqPattern := regexp.MustCompile(`^(.+)\.(\d{4}-\d{2}-\d{2}(?:-\d{2})?)$`) + if matches := noSeqPattern.FindStringSubmatch(nameWithoutExt); matches != nil { + baseName = matches[1] + timestamp, err = parseTimestamp(matches[2]) + if err != nil { + return "", time.Time{}, 0, err + } + return baseName, timestamp, 0, nil + } + + return "", time.Time{}, 0, fmt.Errorf("invalid rotated file name format: %s", fileName) +} + +// parseTimestamp 解析时间戳字符串 +func parseTimestamp(s string) (time.Time, error) { + // 尝试小时格式 + if t, err := time.Parse("2006-01-02-15", s); err == nil { + return t, nil + } + // 尝试日期格式 + return time.Parse("2006-01-02", s) +} diff --git a/backend/internal/logger/rotation_test.go b/backend/internal/logger/rotation_test.go new file mode 100644 index 00000000..3237e986 --- /dev/null +++ b/backend/internal/logger/rotation_test.go @@ -0,0 +1,197 @@ +package logger + +import ( + "os" + "testing" + "time" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" +) + +// mockFileInfo 模拟文件信息用于测试 +type mockFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time + isDir bool +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return m.size } +func (m *mockFileInfo) Mode() os.FileMode { return m.mode } +func (m *mockFileInfo) ModTime() time.Time { return m.modTime } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() interface{} { return nil } + +// TestProperty6_SizeBasedRotationTrigger 属性测试:大小分片触发 +// **Property 6: Size-Based Rotation Trigger** +// **Validates: Requirements 4.2** +// *For any* configured max file size S, when the current log file size exceeds S, +// a new log file SHALL be created before writing the next entry. +func TestProperty6_SizeBasedRotationTrigger(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + // 生成随机的最大文件大小 (1KB - 100MB) + maxSizeGen := gen.Int64Range(1024, 100*1024*1024) + // 生成随机的当前文件大小 (0 - 200MB) + currentSizeGen := gen.Int64Range(0, 200*1024*1024) + + properties.Property("size rotation triggers when file size >= maxSize", prop.ForAll( + func(maxSize, currentSize int64) bool { + policy := NewSizeRotationPolicy(maxSize) + + fileInfo := &mockFileInfo{ + name: "test.log", + size: currentSize, + modTime: time.Now(), + } + + entry := NewLogEntry(INFO, "test", "test message") + shouldRotate := policy.ShouldRotate(fileInfo, entry) + + // 当文件大小 >= 最大大小时,应该触发分片 + expected := currentSize >= maxSize + return shouldRotate == expected + }, + maxSizeGen, + currentSizeGen, + )) + + properties.TestingRun(t) +} + +// TestProperty8_HistoryFileLimit 属性测试:历史文件数量限制 +// **Property 8: History File Limit** +// **Validates: Requirements 4.5** +// *For any* configured max backup count N, the number of rotated log files +// SHALL never exceed N, with oldest files deleted first. +func TestProperty8_HistoryFileLimit(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + // 生成随机的最大备份数 (1-20) + maxBackupsGen := gen.IntRange(1, 20) + // 生成随机的初始文件数 (0-30) + initialFilesGen := gen.IntRange(0, 30) + + properties.Property("history files never exceed maxBackups after cleanup", prop.ForAll( + func(maxBackups, initialFiles int) bool { + // 创建临时目录 + tempDir, err := os.MkdirTemp("", "rotation_test_*") + if err != nil { + t.Logf("Failed to create temp dir: %v", err) + return false + } + defer os.RemoveAll(tempDir) + + basePath := tempDir + "/app.log" + + // 创建初始的分片文件 + baseTime := time.Now().AddDate(0, 0, -initialFiles) + for i := 0; i < initialFiles; i++ { + fileTime := baseTime.AddDate(0, 0, i) + fileName := tempDir + "/app." + fileTime.Format("2006-01-02") + ".log" + f, err := os.Create(fileName) + if err != nil { + t.Logf("Failed to create file: %v", err) + return false + } + f.Close() + // 设置文件修改时间以便排序 + os.Chtimes(fileName, fileTime, fileTime) + } + + // 创建 RotationManager 并执行清理 + manager := NewRotationManager(RotationManagerConfig{ + BasePath: basePath, + MaxBackups: maxBackups, + }) + + // 执行清理 + err = manager.cleanupOldFiles() + if err != nil { + t.Logf("Cleanup failed: %v", err) + return false + } + + // 检查剩余文件数 + count, err := manager.GetRotatedFileCount() + if err != nil { + t.Logf("Failed to get file count: %v", err) + return false + } + + // 文件数应该不超过 maxBackups + return count <= maxBackups + }, + maxBackupsGen, + initialFilesGen, + )) + + properties.TestingRun(t) +} + +// TestProperty9_RotatedFileNamingFormat 属性测试:分片文件命名格式 +// **Property 9: Rotated File Naming Format** +// **Validates: Requirements 4.6** +// *For any* rotated log file, the filename SHALL match the pattern +// `{basename}.{timestamp}[.{sequence}].log` where timestamp is in ISO date format. +func TestProperty9_RotatedFileNamingFormat(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + properties := gopter.NewProperties(parameters) + + // 生成随机的基础文件名(使用字母数字字符) + baseNameGen := gen.AlphaString().Map(func(s string) string { + if s == "" || len(s) == 0 { + return "app" + } + if len(s) > 20 { + return s[:20] + } + return s + }) + + // 生成随机时间戳 (过去一年内) + timestampGen := gen.Int64Range(0, 365*24).Map(func(hours int64) time.Time { + return time.Now().Add(-time.Duration(hours) * time.Hour) + }) + + // 生成随机的时间间隔类型 + intervalGen := gen.OneConstOf(Daily, Hourly) + + properties.Property("time rotation generates valid file names", prop.ForAll( + func(baseName string, timestamp time.Time, interval TimeInterval) bool { + policy := NewTimeRotationPolicy(interval) + fileName := policy.GetRotatedFileName(baseName+".log", timestamp) + + // 验证文件名格式 + return ValidateRotatedFileName(fileName) + }, + baseNameGen, + timestampGen, + intervalGen, + )) + + // 测试大小分片的文件命名 + properties.Property("size rotation generates valid file names", prop.ForAll( + func(baseName string, timestamp time.Time, maxSize int64) bool { + policy := NewSizeRotationPolicy(maxSize) + fileName := policy.GetRotatedFileName(baseName+".log", timestamp) + + // 验证文件名格式 + return ValidateRotatedFileName(fileName) + }, + baseNameGen, + timestampGen, + gen.Int64Range(1024, 100*1024*1024), + )) + + properties.TestingRun(t) +} diff --git a/backend/internal/logger/writer.go b/backend/internal/logger/writer.go new file mode 100644 index 00000000..9f244cce --- /dev/null +++ b/backend/internal/logger/writer.go @@ -0,0 +1,375 @@ +package logger + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// ConsoleWriter 控制台写入器 +// 将日志输出到标准输出 +type ConsoleWriter struct { + formatter Formatter + mu sync.Mutex +} + +// NewConsoleWriter 创建新的控制台写入器 +func NewConsoleWriter(formatter Formatter) *ConsoleWriter { + if formatter == nil { + formatter = NewTextFormatter() + } + return &ConsoleWriter{ + formatter: formatter, + } +} + +// Write 写入日志条目到控制台 +func (w *ConsoleWriter) Write(entry *LogEntry) error { + if entry == nil { + return nil + } + + data, err := w.formatter.Format(entry) + if err != nil { + return fmt.Errorf("failed to format log entry: %w", err) + } + + w.mu.Lock() + defer w.mu.Unlock() + + _, err = os.Stdout.Write(data) + if err != nil { + return fmt.Errorf("failed to write to console: %w", err) + } + + return nil +} + +// Close 关闭控制台写入器(无操作) +func (w *ConsoleWriter) Close() error { + return nil +} + +// FileWriterConfig 文件写入器配置 +type FileWriterConfig struct { + FilePath string // 日志文件路径 + BufferSize int // 缓冲区大小(字节),默认 4KB + FlushInterval time.Duration // 刷新间隔,默认 1s + AsyncQueueSize int // 异步队列大小,默认 1000 +} + +// DefaultFileWriterConfig 返回默认的文件写入器配置 +func DefaultFileWriterConfig(filePath string) FileWriterConfig { + return FileWriterConfig{ + FilePath: filePath, + BufferSize: 4 * 1024, // 4KB + FlushInterval: time.Second, + AsyncQueueSize: 1000, + } +} + +// FileWriter 文件写入器 +// 支持缓冲写入和异步写入 +type FileWriter struct { + config FileWriterConfig + formatter Formatter + file *os.File + buffer *bufio.Writer + mu sync.Mutex + + // 异步写入相关 + asyncChan chan *LogEntry + done chan struct{} + wg sync.WaitGroup + asyncMode bool + flushTicker *time.Ticker +} + +// NewFileWriter 创建新的文件写入器(同步模式) +func NewFileWriter(config FileWriterConfig, formatter Formatter) (*FileWriter, error) { + if formatter == nil { + formatter = NewTextFormatter() + } + + // 确保目录存在 + dir := filepath.Dir(config.FilePath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + } + + // 打开文件(追加模式) + file, err := os.OpenFile(config.FilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + // 设置默认缓冲区大小 + bufferSize := config.BufferSize + if bufferSize <= 0 { + bufferSize = 4 * 1024 // 4KB + } + + w := &FileWriter{ + config: config, + formatter: formatter, + file: file, + buffer: bufio.NewWriterSize(file, bufferSize), + asyncMode: false, + } + + return w, nil +} + +// Write 写入日志条目到文件(同步模式) +func (w *FileWriter) Write(entry *LogEntry) error { + if entry == nil { + return nil + } + + // 如果是异步模式,发送到队列 + if w.asyncMode { + return w.writeAsync(entry) + } + + return w.writeSync(entry) +} + +// writeSync 同步写入 +func (w *FileWriter) writeSync(entry *LogEntry) error { + data, err := w.formatter.Format(entry) + if err != nil { + return fmt.Errorf("failed to format log entry: %w", err) + } + + w.mu.Lock() + defer w.mu.Unlock() + + _, err = w.buffer.Write(data) + if err != nil { + return fmt.Errorf("failed to write to buffer: %w", err) + } + + return nil +} + +// Flush 刷新缓冲区到文件 +func (w *FileWriter) Flush() error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.buffer != nil { + return w.buffer.Flush() + } + return nil +} + +// Close 关闭文件写入器 +func (w *FileWriter) Close() error { + // 如果是异步模式,先停止异步写入 + if w.asyncMode { + w.stopAsync() + } + + w.mu.Lock() + defer w.mu.Unlock() + + var errs []error + + // 刷新缓冲区 + if w.buffer != nil { + if err := w.buffer.Flush(); err != nil { + errs = append(errs, fmt.Errorf("failed to flush buffer: %w", err)) + } + } + + // 关闭文件 + if w.file != nil { + if err := w.file.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close file: %w", err)) + } + w.file = nil + } + + if len(errs) > 0 { + return errs[0] + } + return nil +} + +// GetFilePath 获取当前日志文件路径 +func (w *FileWriter) GetFilePath() string { + return w.config.FilePath +} + +// NewAsyncFileWriter 创建新的异步文件写入器 +func NewAsyncFileWriter(config FileWriterConfig, formatter Formatter) (*FileWriter, error) { + w, err := NewFileWriter(config, formatter) + if err != nil { + return nil, err + } + + // 启用异步模式 + w.enableAsync() + + return w, nil +} + +// enableAsync 启用异步写入模式 +func (w *FileWriter) enableAsync() { + if w.asyncMode { + return + } + + queueSize := w.config.AsyncQueueSize + if queueSize <= 0 { + queueSize = 1000 + } + + flushInterval := w.config.FlushInterval + if flushInterval <= 0 { + flushInterval = time.Second + } + + w.asyncChan = make(chan *LogEntry, queueSize) + w.done = make(chan struct{}) + w.flushTicker = time.NewTicker(flushInterval) + w.asyncMode = true + + // 启动后台写入 goroutine + w.wg.Add(1) + go w.asyncWriteLoop() +} + +// asyncWriteLoop 异步写入循环 +func (w *FileWriter) asyncWriteLoop() { + defer w.wg.Done() + + for { + select { + case entry, ok := <-w.asyncChan: + if !ok { + // 通道已关闭,处理剩余日志 + return + } + // 写入日志(忽略错误,避免阻塞) + _ = w.writeSync(entry) + + case <-w.flushTicker.C: + // 定期刷新缓冲区 + _ = w.Flush() + + case <-w.done: + // 收到停止信号,处理剩余日志 + w.drainQueue() + return + } + } +} + +// drainQueue 清空队列中的剩余日志 +func (w *FileWriter) drainQueue() { + for { + select { + case entry, ok := <-w.asyncChan: + if !ok { + return + } + _ = w.writeSync(entry) + default: + // 队列已空 + return + } + } +} + +// writeAsync 异步写入(非阻塞) +func (w *FileWriter) writeAsync(entry *LogEntry) error { + select { + case w.asyncChan <- entry: + return nil + default: + // 队列满,丢弃日志(非阻塞) + return fmt.Errorf("async queue full, log entry dropped") + } +} + +// stopAsync 停止异步写入 +func (w *FileWriter) stopAsync() { + if !w.asyncMode { + return + } + + // 停止定时器 + if w.flushTicker != nil { + w.flushTicker.Stop() + } + + // 发送停止信号 + close(w.done) + + // 等待后台 goroutine 完成 + w.wg.Wait() + + // 关闭通道 + close(w.asyncChan) + + w.asyncMode = false +} + +// IsAsync 返回是否为异步模式 +func (w *FileWriter) IsAsync() bool { + return w.asyncMode +} + +// QueueLength 返回当前异步队列长度(用于监控) +func (w *FileWriter) QueueLength() int { + if !w.asyncMode { + return 0 + } + return len(w.asyncChan) +} + +// MultiWriter 多写入器 +// 同时写入多个目标 +type MultiWriter struct { + writers []Writer +} + +// NewMultiWriter 创建多写入器 +func NewMultiWriter(writers ...Writer) *MultiWriter { + return &MultiWriter{ + writers: writers, + } +} + +// Write 写入日志到所有写入器 +func (w *MultiWriter) Write(entry *LogEntry) error { + var lastErr error + for _, writer := range w.writers { + if err := writer.Write(entry); err != nil { + lastErr = err + } + } + return lastErr +} + +// Close 关闭所有写入器 +func (w *MultiWriter) Close() error { + var lastErr error + for _, writer := range w.writers { + if err := writer.Close(); err != nil { + lastErr = err + } + } + return lastErr +} + +// AddWriter 添加写入器 +func (w *MultiWriter) AddWriter(writer Writer) { + w.writers = append(w.writers, writer) +} diff --git a/backend/internal/logger/writer_test.go b/backend/internal/logger/writer_test.go new file mode 100644 index 00000000..6ad3e127 --- /dev/null +++ b/backend/internal/logger/writer_test.go @@ -0,0 +1,194 @@ +package logger + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" +) + +// TestConsoleWriter_Write tests basic console writer functionality +func TestConsoleWriter_Write(t *testing.T) { + formatter := NewTextFormatter() + writer := NewConsoleWriter(formatter) + defer writer.Close() + + entry := NewLogEntry(INFO, "test", "test message") + err := writer.Write(entry) + if err != nil { + t.Errorf("ConsoleWriter.Write() error = %v", err) + } +} + +// TestFileWriter_Write tests basic file writer functionality +func TestFileWriter_Write(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + config := DefaultFileWriterConfig(logPath) + formatter := NewTextFormatter() + + writer, err := NewFileWriter(config, formatter) + if err != nil { + t.Fatalf("NewFileWriter() error = %v", err) + } + defer writer.Close() + + entry := NewLogEntry(INFO, "test", "test message") + err = writer.Write(entry) + if err != nil { + t.Errorf("FileWriter.Write() error = %v", err) + } + + // Flush and verify file exists + writer.Flush() + + if _, err := os.Stat(logPath); os.IsNotExist(err) { + t.Error("Log file was not created") + } +} + +// TestFileWriter_CreateDirectory tests automatic directory creation +func TestFileWriter_CreateDirectory(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log") + + config := DefaultFileWriterConfig(logPath) + formatter := NewTextFormatter() + + writer, err := NewFileWriter(config, formatter) + if err != nil { + t.Fatalf("NewFileWriter() error = %v", err) + } + defer writer.Close() + + // Verify directory was created + dir := filepath.Dir(logPath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Error("Directory was not created automatically") + } +} + +// TestAsyncFileWriter_NonBlocking tests that async writes are non-blocking +func TestAsyncFileWriter_NonBlocking(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "async_test.log") + + config := FileWriterConfig{ + FilePath: logPath, + BufferSize: 4 * 1024, + FlushInterval: 100 * time.Millisecond, + AsyncQueueSize: 100, + } + formatter := NewTextFormatter() + + writer, err := NewAsyncFileWriter(config, formatter) + if err != nil { + t.Fatalf("NewAsyncFileWriter() error = %v", err) + } + defer writer.Close() + + // Write should return quickly + entry := NewLogEntry(INFO, "test", "test message") + start := time.Now() + err = writer.Write(entry) + elapsed := time.Since(start) + + if err != nil { + t.Errorf("AsyncFileWriter.Write() error = %v", err) + } + + // Should complete in less than 1ms (non-blocking) + if elapsed > time.Millisecond { + t.Errorf("Async write took too long: %v", elapsed) + } +} + +// Property 10: Async Write Non-Blocking +// *For any* log write operation, the call SHALL return within a bounded time (< 1ms typical) +// regardless of file I/O latency. +// **Validates: Requirements 5.2** +func TestProperty10_AsyncWriteNonBlocking(t *testing.T) { + parameters := gopter.DefaultTestParameters() + parameters.MinSuccessfulTests = 100 + parameters.MaxSize = 50 + + properties := gopter.NewProperties(parameters) + + properties.Property("async write returns within bounded time", prop.ForAll( + func(level int, component string, message string) bool { + // Create temp file for each test + tmpDir := os.TempDir() + logPath := filepath.Join(tmpDir, "pbt_async_test.log") + defer os.Remove(logPath) + + config := FileWriterConfig{ + FilePath: logPath, + BufferSize: 4 * 1024, + FlushInterval: time.Second, + AsyncQueueSize: 1000, + } + formatter := NewTextFormatter() + + writer, err := NewAsyncFileWriter(config, formatter) + if err != nil { + return false + } + defer writer.Close() + + // Create log entry from generated data + logLevel := Level(level % 4) // Ensure valid level 0-3 + entry := NewLogEntry(logLevel, component, message) + + // Measure write time + start := time.Now() + _ = writer.Write(entry) + elapsed := time.Since(start) + + // Property: write should complete within 1ms (non-blocking) + // Using 5ms as upper bound to account for system variance + return elapsed < 5*time.Millisecond + }, + gen.IntRange(0, 3), + gen.AlphaString(), + gen.AlphaString(), + )) + + properties.TestingRun(t) +} + +// TestMultiWriter tests writing to multiple destinations +func TestMultiWriter(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "multi_test.log") + + // Create console and file writers + consoleWriter := NewConsoleWriter(NewTextFormatter()) + fileConfig := DefaultFileWriterConfig(logPath) + fileWriter, err := NewFileWriter(fileConfig, NewTextFormatter()) + if err != nil { + t.Fatalf("NewFileWriter() error = %v", err) + } + + multiWriter := NewMultiWriter(consoleWriter, fileWriter) + defer multiWriter.Close() + + entry := NewLogEntry(INFO, "test", "multi writer test") + err = multiWriter.Write(entry) + if err != nil { + t.Errorf("MultiWriter.Write() error = %v", err) + } + + // Flush file writer + fileWriter.Flush() + + // Verify file was written + if _, err := os.Stat(logPath); os.IsNotExist(err) { + t.Error("Log file was not created by MultiWriter") + } +} diff --git a/backend/internal/proxy/clash.go b/backend/internal/proxy/clash.go new file mode 100644 index 00000000..c9df6beb --- /dev/null +++ b/backend/internal/proxy/clash.go @@ -0,0 +1,127 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "os/exec" + "strings" +) + +// ClashManager Clash 进程管理器 +type ClashManager struct { + Config *config.Config + AppRoot string // 应用根目录,所有相对路径基于此解析 + Processes map[string]*exec.Cmd +} + +// NewClashManager 创建 Clash 管理器 +func NewClashManager(cfg *config.Config, appRoot string) *ClashManager { + return &ClashManager{ + Config: cfg, + AppRoot: appRoot, + Processes: make(map[string]*exec.Cmd), + } +} + +// ClashProfile Clash 配置接口 +type ClashProfile interface { + GetProfileId() string + GetClashEnabled() bool + GetClashRunning() bool + GetClashConfigPath() string + GetClashProxyPort() int + SetClashRunning(bool) + SetClashPid(int) + SetClashProxyPort(int) + SetClashLastError(string) +} + +// StartForProfile 为配置启动 Clash 进程 +func (m *ClashManager) StartForProfile(profile ClashProfile, userDataDir string) error { + log := logger.New("Clash") + if !profile.GetClashEnabled() { + return nil + } + if profile.GetClashRunning() { + return nil + } + clashBinaryPath := strings.TrimSpace(m.Config.Browser.ClashBinaryPath) + if clashBinaryPath == "" { + err := fmt.Errorf("clash binary path not configured") + profile.SetClashLastError(err.Error()) + log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + return err + } + if _, err := os.Stat(clashBinaryPath); err != nil { + profile.SetClashLastError(err.Error()) + log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + return err + } + templatePath := strings.TrimSpace(profile.GetClashConfigPath()) + if templatePath == "" { + err := fmt.Errorf("clash config path not configured") + profile.SetClashLastError(err.Error()) + log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + return err + } + if _, err := os.Stat(templatePath); err != nil { + profile.SetClashLastError(err.Error()) + log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + return err + } + port := profile.GetClashProxyPort() + if port == 0 { + p, err := nextAvailablePort() + if err != nil { + profile.SetClashLastError(err.Error()) + log.Error("Clash 端口分配失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + return err + } + port = p + profile.SetClashProxyPort(port) + } + args := []string{ + "-f", templatePath, + "-d", userDataDir, + } + cmd := exec.Command(clashBinaryPath, args...) + hideWindow(cmd) + if err := cmd.Start(); err != nil { + profile.SetClashLastError(err.Error()) + log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + return err + } + m.Processes[profile.GetProfileId()] = cmd + profile.SetClashRunning(true) + profile.SetClashPid(cmd.Process.Pid) + profile.SetClashLastError("") + log.Info("Clash 启动成功", logger.F("profile_id", profile.GetProfileId()), logger.F("pid", cmd.Process.Pid), logger.F("port", port)) + return nil +} + +// StopForProfile 停止配置的 Clash 进程 +func (m *ClashManager) StopForProfile(profile ClashProfile) { + log := logger.New("Clash") + cmd := m.Processes[profile.GetProfileId()] + if cmd != nil && cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil { + log.Error("Clash 停止失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err)) + } + } + delete(m.Processes, profile.GetProfileId()) + profile.SetClashRunning(false) + profile.SetClashPid(0) + log.Info("Clash 已停止", logger.F("profile_id", profile.GetProfileId())) +} + +// StopAll 停止所有 Clash 进程 +func (m *ClashManager) StopAll() { + for profileID, cmd := range m.Processes { + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + delete(m.Processes, profileID) + } +} diff --git a/backend/internal/proxy/http_client.go b/backend/internal/proxy/http_client.go new file mode 100644 index 00000000..423ccb52 --- /dev/null +++ b/backend/internal/proxy/http_client.go @@ -0,0 +1,95 @@ +package proxy + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "ant-chrome/backend/internal/config" + + xproxy "golang.org/x/net/proxy" +) + +// buildProxyHTTPClient 根据代理配置构建 HTTP 客户端,统一用于测速/健康检测场景。 +func buildProxyHTTPClient( + src string, + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, + singboxMgr *SingBoxManager, + timeout time.Duration, +) (*http.Client, error) { + l := strings.ToLower(strings.TrimSpace(src)) + if l == "" || l == "direct://" { + return &http.Client{Timeout: timeout}, nil + } + + if IsSingBoxProtocol(src) { + if singboxMgr == nil { + return nil, fmt.Errorf("sing-box 管理器未初始化") + } + socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId) + if err != nil { + return nil, fmt.Errorf("sing-box 桥接启动失败: %w", err) + } + return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout) + } + + if RequiresBridge(src, proxies, proxyId) { + if xrayMgr == nil { + return nil, fmt.Errorf("xray 管理器未初始化") + } + socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId) + if err != nil { + return nil, fmt.Errorf("xray 桥接启动失败: %w", err) + } + return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout) + } + + if strings.HasPrefix(l, "socks5://") { + u, err := url.Parse(src) + if err != nil { + return nil, fmt.Errorf("SOCKS5 地址解析失败: %w", err) + } + var auth *xproxy.Auth + if u.User != nil { + pass, _ := u.User.Password() + auth = &xproxy.Auth{ + User: u.User.Username(), + Password: pass, + } + } + dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct) + if err != nil { + return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err) + } + contextDialer, ok := dialer.(xproxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer") + } + transport := &http.Transport{DialContext: contextDialer.DialContext} + return &http.Client{Transport: transport, Timeout: timeout}, nil + } + + proxyURL, err := url.Parse(src) + if err != nil { + return nil, fmt.Errorf("代理地址解析失败: %w", err) + } + transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + return &http.Client{Transport: transport, Timeout: timeout}, nil +} + +func buildSocks5HTTPClient(socks5Host string, timeout time.Duration) (*http.Client, error) { + dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct) + if err != nil { + return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err) + } + contextDialer, ok := dialer.(xproxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer") + } + transport := &http.Transport{DialContext: contextDialer.DialContext} + return &http.Client{Transport: transport, Timeout: timeout}, nil +} diff --git a/backend/internal/proxy/iphealth.go b/backend/internal/proxy/iphealth.go new file mode 100644 index 00000000..225fd75b --- /dev/null +++ b/backend/internal/proxy/iphealth.go @@ -0,0 +1,82 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "ant-chrome/backend/internal/config" +) + +const defaultIPPureInfoURL = "https://my.ippure.com/v1/info" + +// FetchIPPureInfo 通过指定代理链路查询 IPPure 的出口 IP 健康信息。 +// 返回值为第三方接口原始 JSON(map 形式),不做本地评分计算。 +func FetchIPPureInfo( + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, + singboxMgr *SingBoxManager, +) (map[string]interface{}, error) { + src := "" + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + break + } + } + if src == "" { + return nil, fmt.Errorf("未找到代理配置") + } + + client, err := buildIPPureHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, 20*time.Second) + if err != nil { + return nil, err + } + + req, _ := http.NewRequest(http.MethodGet, defaultIPPureInfoURL, nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "AntChrome/1.0") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("调用 IPPure 接口失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 IPPure 响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("IPPure HTTP %d: %s", resp.StatusCode, bodySnippet(body, 180)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("IPPure JSON 解析失败: %w", err) + } + return result, nil +} + +func buildIPPureHTTPClient( + src string, + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, + singboxMgr *SingBoxManager, + timeout time.Duration, +) (*http.Client, error) { + return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout) +} + +func bodySnippet(body []byte, max int) string { + s := strings.TrimSpace(string(body)) + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/backend/internal/proxy/parser.go b/backend/internal/proxy/parser.go new file mode 100644 index 00000000..8950e60f --- /dev/null +++ b/backend/internal/proxy/parser.go @@ -0,0 +1,685 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// ParseProxyNode 解析代理节点 +func ParseProxyNode(node string) (string, map[string]interface{}, error) { + src := strings.TrimSpace(node) + if src == "" { + return "", nil, fmt.Errorf("代理节点为空") + } + l := strings.ToLower(src) + if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { + return src, nil, nil + } + if strings.HasPrefix(l, "clash://") || strings.Contains(l, "type:") || strings.Contains(l, "proxies:") { + outbound, standard, err := parseClashNode(src) + if err != nil { + return "", nil, err + } + if standard != "" { + return standard, nil, nil + } + if outbound != nil { + return "", outbound, nil + } + } + outbound, err := buildXrayOutbound(src) + if err != nil { + return "", nil, err + } + return "", outbound, nil +} + +func parseClashNode(src string) (map[string]interface{}, string, error) { + data := strings.TrimSpace(src) + if strings.HasPrefix(strings.ToLower(data), "clash://") { + raw := strings.TrimPrefix(data, "clash://") + raw, _ = url.QueryUnescape(raw) + decoded, err := decodeBase64String(raw) + if err != nil { + return nil, "", err + } + data = string(decoded) + } + var payload interface{} + if err := yaml.Unmarshal([]byte(data), &payload); err != nil { + return nil, "", err + } + nodeMap := pickClashNode(payload) + if nodeMap == nil { + return nil, "", fmt.Errorf("clash 节点解析失败") + } + nodeType := strings.ToLower(getMapString(nodeMap, "type")) + switch nodeType { + case "socks5", "http", "https": + return nil, buildStandardProxyFromClash(nodeMap, nodeType), nil + case "vmess": + return buildOutboundFromClashVmess(nodeMap) + case "vless": + return buildOutboundFromClashVless(nodeMap) + case "trojan": + return buildOutboundFromClashTrojan(nodeMap) + case "ss", "shadowsocks": + return buildOutboundFromClashSS(nodeMap) + case "ssr": + return nil, "", fmt.Errorf("不支持 ShadowsocksR 协议,Xray 不支持 SSR,请使用 SS/vmess/vless/trojan") + case "hysteria2", "hysteria": + return buildOutboundFromClashHysteria2(nodeMap) + } + return nil, "", fmt.Errorf("不支持的节点类型") +} + +func pickClashNode(payload interface{}) map[string]interface{} { + if m := toStringMap(payload); m != nil { + if proxies, ok := m["proxies"]; ok { + if arr, ok := proxies.([]interface{}); ok && len(arr) > 0 { + return toStringMap(arr[0]) + } + } + if proxyItem, ok := m["proxy"]; ok { + if node := toStringMap(proxyItem); node != nil { + return node + } + } + return m + } + if arr, ok := payload.([]interface{}); ok && len(arr) > 0 { + return toStringMap(arr[0]) + } + return nil +} + +func buildStandardProxyFromClash(node map[string]interface{}, scheme string) string { + host := getMapString(node, "server") + port := getMapInt(node, "port") + username := getMapString(node, "username") + password := getMapString(node, "password") + if host == "" || port == 0 { + return "" + } + address := fmt.Sprintf("%s:%d", host, port) + if username != "" { + user := url.UserPassword(username, password) + return fmt.Sprintf("%s://%s@%s", scheme, user.String(), address) + } + return fmt.Sprintf("%s://%s", scheme, address) +} + +func buildOutboundFromClashVless(node map[string]interface{}) (map[string]interface{}, string, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + id := getMapString(node, "uuid") + flow := getMapString(node, "flow") + // sni 和 servername 都要读 + sni := getMapString(node, "sni") + if sni == "" { + sni = getMapString(node, "servername") + } + network := getMapString(node, "network") + out := map[string]interface{}{ + "protocol": "vless", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "vnext": []interface{}{ + map[string]interface{}{ + "address": host, + "port": port, + "users": []interface{}{ + map[string]interface{}{ + "id": id, + "flow": flow, + "encryption": "none", + }, + }, + }, + }, + }, + } + stream := map[string]interface{}{} + tlsVal := strings.ToLower(getMapString(node, "tls")) + _, hasRealityOpts := node["reality-opts"] + + if hasRealityOpts { + // Reality 模式:network 必须显式为 tcp,否则 xray 校验失败 + stream["network"] = "tcp" + realityOpts := map[string]interface{}{ + "spiderX": "", + } + if sni != "" { + realityOpts["serverName"] = sni + } + fingerprint := getMapString(node, "client-fingerprint") + if fingerprint == "" { + fingerprint = "chrome" + } + realityOpts["fingerprint"] = fingerprint + if rm := toStringMap(node["reality-opts"]); rm != nil { + if pbk := getMapString(rm, "public-key"); pbk != "" { + realityOpts["publicKey"] = pbk + } + if sid := getMapString(rm, "short-id"); sid != "" { + realityOpts["shortId"] = sid + } + } + stream["security"] = "reality" + stream["realitySettings"] = realityOpts + } else if getMapBool(node, "tls") || tlsVal == "true" || tlsVal == "tls" { + // 普通 TLS 模式 + tlsSettings := map[string]interface{}{} + if sni != "" { + tlsSettings["serverName"] = sni + } + tlsSettings["allowInsecure"] = getMapBool(node, "skip-cert-verify") + stream["security"] = "tls" + stream["tlsSettings"] = tlsSettings + } + if network == "ws" { + stream["network"] = "ws" + ws := map[string]interface{}{} + if wsOpts, ok := node["ws-opts"]; ok { + if wsMap := toStringMap(wsOpts); wsMap != nil { + path := getMapString(wsMap, "path") + // path 为 "/" 也要设置 + if path != "" { + ws["path"] = path + } + if headers, ok := wsMap["headers"]; ok { + if headerMap := toStringMap(headers); headerMap != nil { + if hostH := getMapString(headerMap, "Host"); hostH != "" { + ws["headers"] = map[string]interface{}{"Host": hostH} + } + } + } + } + } + stream["wsSettings"] = ws + } + if network == "grpc" { + stream["network"] = "grpc" + if grpcOpts, ok := node["grpc-opts"]; ok { + if grpcMap := toStringMap(grpcOpts); grpcMap != nil { + serviceName := getMapString(grpcMap, "grpc-service-name") + if serviceName != "" { + stream["grpcSettings"] = map[string]interface{}{"serviceName": serviceName} + } + } + } + } + if len(stream) > 0 { + out["streamSettings"] = stream + } + return out, "", nil +} + +func buildOutboundFromClashVmess(node map[string]interface{}) (map[string]interface{}, string, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + id := getMapString(node, "uuid") + cipher := getMapString(node, "cipher") + if cipher == "" { + cipher = "auto" + } + network := getMapString(node, "network") + // sni 和 servername 都要读 + sni := getMapString(node, "sni") + if sni == "" { + sni = getMapString(node, "servername") + } + out := map[string]interface{}{ + "protocol": "vmess", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "vnext": []interface{}{ + map[string]interface{}{ + "address": host, + "port": port, + "users": []interface{}{ + map[string]interface{}{ + "id": id, + "security": cipher, + }, + }, + }, + }, + }, + } + stream := map[string]interface{}{} + if getMapBool(node, "tls") || strings.ToLower(getMapString(node, "tls")) == "true" { + tlsSettings := map[string]interface{}{} + if sni != "" { + tlsSettings["serverName"] = sni + } + skipVerify := getMapBool(node, "skip-cert-verify") + tlsSettings["allowInsecure"] = skipVerify + stream["security"] = "tls" + stream["tlsSettings"] = tlsSettings + } + if network == "ws" { + stream["network"] = "ws" + ws := map[string]interface{}{} + if wsOpts, ok := node["ws-opts"]; ok { + if wsMap := toStringMap(wsOpts); wsMap != nil { + path := getMapString(wsMap, "path") + // path 为 "/" 也要设置 + if path != "" { + ws["path"] = path + } + if headers, ok := wsMap["headers"]; ok { + if headerMap := toStringMap(headers); headerMap != nil { + if hostH := getMapString(headerMap, "Host"); hostH != "" { + ws["headers"] = map[string]interface{}{"Host": hostH} + } + } + } + } + } + stream["wsSettings"] = ws + } + if network == "grpc" { + stream["network"] = "grpc" + if grpcOpts, ok := node["grpc-opts"]; ok { + if grpcMap := toStringMap(grpcOpts); grpcMap != nil { + serviceName := getMapString(grpcMap, "grpc-service-name") + if serviceName != "" { + stream["grpcSettings"] = map[string]interface{}{"serviceName": serviceName} + } + } + } + } + if len(stream) > 0 { + out["streamSettings"] = stream + } + return out, "", nil +} + +func buildOutboundFromClashTrojan(node map[string]interface{}) (map[string]interface{}, string, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + password := getMapString(node, "password") + sni := getMapString(node, "sni") + if sni == "" { + sni = getMapString(node, "servername") + } + network := getMapString(node, "network") + skipVerify := getMapBool(node, "skip-cert-verify") + + out := map[string]interface{}{ + "protocol": "trojan", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "address": host, + "port": port, + "password": password, + }, + } + stream := map[string]interface{}{ + "security": "tls", + "tlsSettings": map[string]interface{}{ + "serverName": sni, + "allowInsecure": skipVerify, + }, + } + if network == "ws" { + stream["network"] = "ws" + ws := map[string]interface{}{} + if wsOpts, ok := node["ws-opts"]; ok { + if wsMap := toStringMap(wsOpts); wsMap != nil { + if path := getMapString(wsMap, "path"); path != "" { + ws["path"] = path + } + if headers := toStringMap(wsMap["headers"]); headers != nil { + if h := getMapString(headers, "Host"); h != "" { + ws["headers"] = map[string]interface{}{"Host": h} + } + } + } + } + stream["wsSettings"] = ws + } else if network == "grpc" { + stream["network"] = "grpc" + if grpcOpts, ok := node["grpc-opts"]; ok { + if grpcMap := toStringMap(grpcOpts); grpcMap != nil { + if svcName := getMapString(grpcMap, "grpc-service-name"); svcName != "" { + stream["grpcSettings"] = map[string]interface{}{"serviceName": svcName} + } + } + } + } + out["streamSettings"] = stream + return out, "", nil +} + +func buildOutboundFromClashHysteria2(node map[string]interface{}) (map[string]interface{}, string, error) { + // 支持的协议: vless, vmess, trojan, shadowsocks, socks, http, wireguard + // hysteria2 需要使用 Hysteria 客户端或 sing-box + return nil, "", fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理") +} + +func buildXrayOutbound(node string) (map[string]interface{}, error) { + l := strings.ToLower(node) + if strings.HasPrefix(l, "vmess://") { + return buildOutboundVmess(node) + } + if strings.HasPrefix(l, "vless://") { + return buildOutboundVless(node) + } + if strings.HasPrefix(l, "trojan://") { + return buildOutboundTrojan(node) + } + if strings.HasPrefix(l, "ss://") { + return buildOutboundSS(node) + } + if strings.HasPrefix(l, "ssr://") { + return nil, fmt.Errorf("不支持 ShadowsocksR 协议,Xray 不支持 SSR,请使用 SS/vmess/vless/trojan") + } + if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") { + return buildOutboundHysteria2(node) + } + return nil, fmt.Errorf("不支持的节点协议") +} + +func buildOutboundVmess(node string) (map[string]interface{}, error) { + raw := strings.TrimPrefix(node, "vmess://") + decoded, err := decodeBase64String(strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("vmess 解析失败: %v", err) + } + var v struct { + Add string `json:"add"` + Port string `json:"port"` + ID string `json:"id"` + Net string `json:"net"` + Type string `json:"type"` + Host string `json:"host"` + Path string `json:"path"` + TLS string `json:"tls"` + Sni string `json:"sni"` + Alpn string `json:"alpn"` + } + if err := json.Unmarshal(decoded, &v); err != nil { + return nil, fmt.Errorf("vmess 配置解析失败: %v", err) + } + p, _ := strconv.Atoi(v.Port) + out := map[string]interface{}{ + "protocol": "vmess", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "vnext": []interface{}{ + map[string]interface{}{ + "address": v.Add, + "port": p, + "users": []interface{}{ + map[string]interface{}{ + "id": v.ID, + "security": "auto", + }, + }, + }, + }, + }, + } + stream := map[string]interface{}{} + if v.TLS == "tls" { + stream["security"] = "tls" + if v.Sni != "" { + stream["tlsSettings"] = map[string]interface{}{"serverName": v.Sni} + } + } + if v.Net == "ws" { + stream["network"] = "ws" + ws := map[string]interface{}{} + if v.Path != "" { + ws["path"] = v.Path + } + if v.Host != "" { + ws["headers"] = map[string]interface{}{"Host": v.Host} + } + if len(ws) > 0 { + stream["wsSettings"] = ws + } + } + if len(stream) > 0 { + out["streamSettings"] = stream + } + return out, nil +} + +func buildOutboundVless(node string) (map[string]interface{}, error) { + u, err := url.Parse(node) + if err != nil { + return nil, fmt.Errorf("vless 解析失败: %v", err) + } + host := u.Hostname() + portStr := u.Port() + p, _ := strconv.Atoi(portStr) + id := u.User.Username() + q := u.Query() + flow := q.Get("flow") + sec := strings.ToLower(q.Get("security")) + sni := q.Get("sni") + out := map[string]interface{}{ + "protocol": "vless", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "vnext": []interface{}{ + map[string]interface{}{ + "address": host, + "port": p, + "users": []interface{}{ + map[string]interface{}{ + "id": id, + "flow": flow, + "encryption": "none", + }, + }, + }, + }, + }, + } + stream := map[string]interface{}{} + if sec == "tls" || sec == "reality" { + stream["security"] = "tls" + if sni != "" { + stream["tlsSettings"] = map[string]interface{}{"serverName": sni} + } + } + network := q.Get("type") + if network == "" { + network = q.Get("network") + } + if network == "ws" { + stream["network"] = "ws" + ws := map[string]interface{}{} + if pth := q.Get("path"); pth != "" { + ws["path"] = pth + } + hostH := q.Get("host") + if hostH == "" { + hostH = u.Hostname() + } + if hostH != "" { + ws["headers"] = map[string]interface{}{"Host": hostH} + } + stream["wsSettings"] = ws + } + if len(stream) > 0 { + out["streamSettings"] = stream + } + return out, nil +} + +func buildOutboundHysteria2(node string) (map[string]interface{}, error) { + // Xray 不支持 hysteria2 作为 outbound 协议 + // 支持的协议: vless, vmess, trojan, shadowsocks, socks, http, wireguard + // hysteria2 需要使用 Hysteria 客户端或 sing-box + return nil, fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理") +} + +// buildOutboundTrojan 解析 trojan:// URI 格式 +func buildOutboundTrojan(node string) (map[string]interface{}, error) { + u, err := url.Parse(node) + if err != nil { + return nil, fmt.Errorf("trojan 解析失败: %v", err) + } + host := u.Hostname() + portStr := u.Port() + p, _ := strconv.Atoi(portStr) + password := u.User.Username() + q := u.Query() + sni := q.Get("sni") + if sni == "" { + sni = q.Get("peer") + } + skipVerify := q.Get("allowInsecure") == "1" || strings.ToLower(q.Get("allowInsecure")) == "true" + network := q.Get("type") + + out := map[string]interface{}{ + "protocol": "trojan", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "address": host, + "port": p, + "password": password, + }, + } + stream := map[string]interface{}{ + "security": "tls", + "tlsSettings": map[string]interface{}{ + "serverName": sni, + "allowInsecure": skipVerify, + }, + } + if network == "ws" { + stream["network"] = "ws" + ws := map[string]interface{}{} + if pth := q.Get("path"); pth != "" { + ws["path"] = pth + } + if h := q.Get("host"); h != "" { + ws["headers"] = map[string]interface{}{"Host": h} + } + stream["wsSettings"] = ws + } + out["streamSettings"] = stream + return out, nil +} + +// buildOutboundFromClashSS 从 Clash YAML 格式解析 Shadowsocks outbound +func buildOutboundFromClashSS(node map[string]interface{}) (map[string]interface{}, string, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + password := getMapString(node, "password") + cipher := getMapString(node, "cipher") + if cipher == "" { + cipher = getMapString(node, "method") + } + if cipher == "" { + cipher = "aes-256-gcm" + } + out := map[string]interface{}{ + "protocol": "shadowsocks", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "address": host, + "port": port, + "method": cipher, + "password": password, + }, + } + // plugin 支持(obfs/v2ray-plugin) + if plugin := getMapString(node, "plugin"); plugin != "" { + pluginOpts := getMapString(node, "plugin-opts") + _ = pluginOpts // xray 原生不支持 plugin,忽略 + } + return out, "", nil +} + +// buildOutboundSS 解析 ss:// URI 格式 +// 支持两种格式: +// 1. ss://BASE64(method:password)@host:port +// 2. ss://BASE64(method:password@host:port) +func buildOutboundSS(node string) (map[string]interface{}, error) { + raw := strings.TrimPrefix(node, "ss://") + // 去掉 fragment(#备注) + if idx := strings.Index(raw, "#"); idx >= 0 { + raw = raw[:idx] + } + raw = strings.TrimSpace(raw) + + var host, method, password string + var port int + + // 格式1:method:password@host:port(SIP002) + if strings.Contains(raw, "@") { + u, err := url.Parse("ss://" + raw) + if err != nil { + return nil, fmt.Errorf("ss 解析失败: %v", err) + } + host = u.Hostname() + port, _ = strconv.Atoi(u.Port()) + userInfo := u.User.String() + // userInfo 可能是 base64 编码的 method:password + if decoded, err := decodeBase64String(userInfo); err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + } else { + // 明文 method:password + parts := strings.SplitN(userInfo, ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + } + } else { + // 格式2:整体 base64 + decoded, err := decodeBase64String(raw) + if err != nil { + return nil, fmt.Errorf("ss base64 解析失败: %v", err) + } + // method:password@host:port + s := string(decoded) + atIdx := strings.LastIndex(s, "@") + if atIdx < 0 { + return nil, fmt.Errorf("ss 格式错误") + } + userPart := s[:atIdx] + hostPart := s[atIdx+1:] + parts := strings.SplitN(userPart, ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + hostPort := strings.Split(hostPart, ":") + if len(hostPort) == 2 { + host = hostPort[0] + port, _ = strconv.Atoi(hostPort[1]) + } + } + + if host == "" || port == 0 || method == "" { + return nil, fmt.Errorf("ss 节点信息不完整") + } + + return map[string]interface{}{ + "protocol": "shadowsocks", + "tag": "proxy-out", + "settings": map[string]interface{}{ + "address": host, + "port": port, + "method": method, + "password": password, + }, + }, nil +} diff --git a/backend/internal/proxy/singbox.go b/backend/internal/proxy/singbox.go new file mode 100644 index 00000000..3e742497 --- /dev/null +++ b/backend/internal/proxy/singbox.go @@ -0,0 +1,281 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "time" +) + +// SingBoxBridge sing-box 桥接进程 +type SingBoxBridge struct { + NodeKey string + Port int + Cmd *exec.Cmd + Pid int + Running bool + LastError string +} + +// SingBoxManager sing-box 桥接管理器 +type SingBoxManager struct { + Config *config.Config + AppRoot string // 应用根目录,所有相对路径基于此解析 + Bridges map[string]*SingBoxBridge + OnBridgeDied func(key string, err error) +} + +// NewSingBoxManager 创建 sing-box 管理器 +func NewSingBoxManager(cfg *config.Config, appRoot string) *SingBoxManager { + return &SingBoxManager{ + Config: cfg, + AppRoot: appRoot, + Bridges: make(map[string]*SingBoxBridge), + } +} + +// EnsureBridge 确保 sing-box 桥接进程运行,返回 socks5://127.0.0.1:port +func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) { + log := logger.New("SingBox") + src := strings.TrimSpace(proxyConfig) + if proxyId != "" { + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + break + } + } + } + if src == "" { + return "", fmt.Errorf("未找到代理节点") + } + + src = normalizeNodeScheme(src) + outbound, err := BuildSingBoxOutbound(src) + if err != nil { + log.Error("节点解析失败", logger.F("error", err)) + return "", err + } + + key := computeNodeKey(src) + + // 复用已有桥接 + if bridge, ok := m.Bridges[key]; ok && bridge != nil && bridge.Running { + alive := bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil + if alive { + if err := waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond); err == nil { + log.Info("复用 sing-box 桥接", logger.F("key", key[:8]), logger.F("port", bridge.Port)) + return fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port), nil + } + } + log.Info("sing-box 桥接已失效,重新启动", logger.F("key", key[:8])) + if bridge.Cmd != nil && bridge.Cmd.Process != nil { + _ = bridge.Cmd.Process.Kill() + } + bridge.Running = false + delete(m.Bridges, key) + } + + binaryPath, err := m.resolveBinary() + if err != nil { + log.Error("sing-box 不可用", logger.F("error", err), logger.F("appRoot", m.AppRoot)) + return "", err + } + log.Debug("sing-box binary", logger.F("path", binaryPath)) + + const maxRetries = 3 + var lastErr error + for attempt := 1; attempt <= maxRetries; attempt++ { + port, err := nextAvailablePort() + if err != nil { + lastErr = err + continue + } + + cfgPath, err := m.buildConfig(key, outbound, port) + if err != nil { + return "", fmt.Errorf("sing-box 配置生成失败: %w", err) + } + + cmd := exec.Command(binaryPath, "run", "-c", cfgPath) + hideWindow(cmd) + cmd.Dir = filepath.Dir(cfgPath) + stderrPath := filepath.Join(filepath.Dir(cfgPath), "singbox-stderr.log") + stderrFile, _ := os.Create(stderrPath) + if stderrFile != nil { + cmd.Stderr = stderrFile + } + + if err := cmd.Start(); err != nil { + if stderrFile != nil { + stderrFile.Close() + } + log.Error("sing-box 启动失败", logger.F("error", err), logger.F("attempt", attempt)) + lastErr = err + continue + } + + bridge := &SingBoxBridge{ + NodeKey: key, + Port: port, + Cmd: cmd, + Pid: cmd.Process.Pid, + Running: true, + } + m.Bridges[key] = bridge + log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port)) + + if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil { + if stderrFile != nil { + stderrFile.Close() + } + if content, readErr := os.ReadFile(stderrPath); readErr == nil && len(content) > 0 { + log.Error("sing-box stderr", logger.F("output", string(content))) + } + _ = cmd.Process.Kill() + bridge.Running = false + bridge.LastError = err.Error() + delete(m.Bridges, key) + log.Error("sing-box 端口不可用,重试", logger.F("error", err), logger.F("attempt", attempt)) + lastErr = err + time.Sleep(200 * time.Millisecond) + continue + } + + if stderrFile != nil { + stderrFile.Close() + } + + go func(b *SingBoxBridge, nodeKey string) { + _ = b.Cmd.Wait() + b.Running = false + if m.OnBridgeDied != nil { + m.OnBridgeDied(nodeKey, fmt.Errorf("sing-box 桥接进程意外退出")) + } + }(bridge, key) + + return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil + } + + return "", fmt.Errorf("sing-box 启动失败(已重试 %d 次): %w", maxRetries, lastErr) +} + +// StopAll 关闭所有 sing-box 桥接进程 +func (m *SingBoxManager) StopAll() { + for key, bridge := range m.Bridges { + if bridge != nil && bridge.Cmd != nil && bridge.Cmd.Process != nil { + _ = bridge.Cmd.Process.Kill() + } + delete(m.Bridges, key) + } +} + +func (m *SingBoxManager) resolveBinary() (string, error) { + configPath := strings.TrimSpace(m.Config.Browser.SingBoxBinaryPath) + if configPath != "" { + resolved := resolveEnvPath(configPath, m.AppRoot) + if resolved != "" { + if _, err := os.Stat(resolved); err == nil { + return resolved, nil + } + } + } + if env := strings.TrimSpace(os.Getenv("SINGBOX_BINARY_PATH")); env != "" { + if _, err := os.Stat(env); err == nil { + return env, nil + } + } + // 优先基于 appRoot 查找 bin/sing-box.exe + if m.AppRoot != "" { + candidate := filepath.Join(m.AppRoot, "bin", "sing-box.exe") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + // 兜底:exe 目录 + if exePath, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exePath), "bin", "sing-box.exe") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + if path, err := exec.LookPath("sing-box"); err == nil { + return path, nil + } + if goruntime.GOOS == "windows" { + if path, err := exec.LookPath("sing-box.exe"); err == nil { + return path, nil + } + } + return "", fmt.Errorf("未找到 sing-box.exe。请将 sing-box.exe 放到 bin/ 目录,或在配置中设置 SingBoxBinaryPath") +} + +func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}, port int) (string, error) { + baseDir := m.resolveWorkdir(key) + if err := os.MkdirAll(baseDir, 0755); err != nil { + return "", err + } + + cfg := map[string]interface{}{ + "log": map[string]interface{}{ + "level": "info", + "output": filepath.Join(baseDir, "singbox.log"), + "timestamp": true, + }, + "inbounds": []interface{}{ + map[string]interface{}{ + "type": "socks", + "tag": "socks-in", + "listen": "127.0.0.1", + "listen_port": port, + }, + }, + "outbounds": []interface{}{ + outbound, + map[string]interface{}{ + "type": "direct", + "tag": "direct", + }, + }, + "route": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "inbound": []string{"socks-in"}, + "outbound": "proxy-out", + }, + }, + }, + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + + cfgPath := filepath.Join(baseDir, "singbox-config.json") + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + return "", err + } + return cfgPath, nil +} + +func (m *SingBoxManager) resolveWorkdir(key string) string { + root := strings.TrimSpace(m.Config.Browser.UserDataRoot) + if root == "" { + root = "data" + } + if !filepath.IsAbs(root) { + if m.AppRoot != "" { + root = filepath.Join(m.AppRoot, root) + } else if exePath, err := os.Executable(); err == nil { + root = filepath.Join(filepath.Dir(exePath), root) + } + } + return filepath.Join(root, "_singbox", key) +} diff --git a/backend/internal/proxy/singbox_parser.go b/backend/internal/proxy/singbox_parser.go new file mode 100644 index 00000000..471edac7 --- /dev/null +++ b/backend/internal/proxy/singbox_parser.go @@ -0,0 +1,248 @@ +package proxy + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// IsSingBoxProtocol 判断是否为 sing-box 支持的协议(hysteria2/tuic) +func IsSingBoxProtocol(proxyConfig string) bool { + l := strings.ToLower(strings.TrimSpace(proxyConfig)) + if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") { + return true + } + // Clash YAML 格式 + if strings.Contains(l, "type: hysteria2") || strings.Contains(l, "type:hysteria2") || + strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") || + strings.Contains(l, "type: tuic") || strings.Contains(l, "type:tuic") { + return true + } + return false +} + +// BuildSingBoxOutbound 解析节点配置,返回 sing-box outbound map +func BuildSingBoxOutbound(node string) (map[string]interface{}, error) { + src := strings.TrimSpace(node) + l := strings.ToLower(src) + + if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") { + return parseHysteria2URI(src) + } + + // Clash YAML 格式 + if strings.Contains(l, "type:") || strings.Contains(l, "proxies:") { + return parseClashSingBoxNode(src) + } + + return nil, fmt.Errorf("不支持的 sing-box 节点格式") +} + +// parseHysteria2URI 解析 hysteria2:// URI +// 格式: hysteria2://password@host:port?sni=xxx&insecure=1 +func parseHysteria2URI(node string) (map[string]interface{}, error) { + // 统一为 hysteria2:// + if strings.HasPrefix(strings.ToLower(node), "hysteria://") { + node = "hysteria2://" + node[len("hysteria://"):] + } + + u, err := url.Parse(node) + if err != nil { + return nil, fmt.Errorf("hysteria2 URI 解析失败: %v", err) + } + + host := u.Hostname() + portStr := u.Port() + port, _ := strconv.Atoi(portStr) + password := u.User.Username() + if password == "" { + // 有些格式把密码放在 userinfo 里不带 @ + password = strings.TrimPrefix(u.Host, "@") + } + + q := u.Query() + sni := q.Get("sni") + if sni == "" { + sni = q.Get("peer") + } + insecure := q.Get("insecure") == "1" || strings.ToLower(q.Get("insecure")) == "true" + obfsPassword := q.Get("obfs-password") + + if host == "" || port == 0 { + return nil, fmt.Errorf("hysteria2 节点信息不完整: host=%s port=%d", host, port) + } + + out := map[string]interface{}{ + "type": "hysteria2", + "tag": "proxy-out", + "server": host, + "server_port": port, + "password": password, + "tls": map[string]interface{}{ + "enabled": true, + "insecure": insecure, + }, + } + + if sni != "" { + out["tls"].(map[string]interface{})["server_name"] = sni + } + + if obfsPassword != "" { + out["obfs"] = map[string]interface{}{ + "type": "salamander", + "password": obfsPassword, + } + } + + return out, nil +} + +// parseClashSingBoxNode 解析 Clash YAML 格式的 sing-box 节点 +func parseClashSingBoxNode(src string) (map[string]interface{}, error) { + // 复用已有的 YAML 解析基础设施 + var payload interface{} + if err := yaml.Unmarshal([]byte(src), &payload); err != nil { + return nil, fmt.Errorf("YAML 解析失败: %v", err) + } + + nodeMap := pickClashNode(payload) + if nodeMap == nil { + return nil, fmt.Errorf("节点解析失败") + } + + nodeType := strings.ToLower(getMapString(nodeMap, "type")) + switch nodeType { + case "hysteria2", "hysteria": + return buildSingBoxHysteria2FromClash(nodeMap) + case "tuic": + return buildSingBoxTUICFromClash(nodeMap) + default: + return nil, fmt.Errorf("不支持的 sing-box 节点类型: %s", nodeType) + } +} + +func buildSingBoxHysteria2FromClash(node map[string]interface{}) (map[string]interface{}, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + password := getMapString(node, "password") + sni := getMapString(node, "sni") + if sni == "" { + sni = getMapString(node, "servername") + } + skipVerify := getMapBool(node, "skip-cert-verify") + + if host == "" || port == 0 { + return nil, fmt.Errorf("hysteria2 节点信息不完整") + } + + tls := map[string]interface{}{ + "enabled": true, + "insecure": skipVerify, + } + if sni != "" { + tls["server_name"] = sni + } + + out := map[string]interface{}{ + "type": "hysteria2", + "tag": "proxy-out", + "server": host, + "server_port": port, + "password": password, + "tls": tls, + } + + // 带宽限制(可选) + if up := getMapString(node, "up"); up != "" { + out["up_mbps"] = parseBandwidthMbps(up) + } + if down := getMapString(node, "down"); down != "" { + out["down_mbps"] = parseBandwidthMbps(down) + } + + // obfs + if obfsPassword := getMapString(node, "obfs-password"); obfsPassword != "" { + out["obfs"] = map[string]interface{}{ + "type": "salamander", + "password": obfsPassword, + } + } + + return out, nil +} + +func buildSingBoxTUICFromClash(node map[string]interface{}) (map[string]interface{}, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + uuid := getMapString(node, "uuid") + password := getMapString(node, "password") + sni := getMapString(node, "sni") + skipVerify := getMapBool(node, "skip-cert-verify") + + if host == "" || port == 0 { + return nil, fmt.Errorf("tuic 节点信息不完整") + } + + tls := map[string]interface{}{ + "enabled": true, + "insecure": skipVerify, + } + if sni != "" { + tls["server_name"] = sni + } + + // alpn + if alpnRaw, ok := node["alpn"]; ok { + if alpnList := toStringSlice(alpnRaw); len(alpnList) > 0 { + tls["alpn"] = alpnList + } + } + + return map[string]interface{}{ + "type": "tuic", + "tag": "proxy-out", + "server": host, + "server_port": port, + "uuid": uuid, + "password": password, + "congestion_control": "bbr", + "tls": tls, + }, nil +} + +// parseBandwidthMbps 解析带宽字符串,返回 Mbps 整数 +// 支持: "100 Mbps", "100", "100M" +func parseBandwidthMbps(s string) int { + s = strings.TrimSpace(s) + s = strings.ToUpper(s) + s = strings.ReplaceAll(s, " ", "") + s = strings.TrimSuffix(s, "BPS") + s = strings.TrimSuffix(s, "B") + s = strings.TrimSuffix(s, "M") + n, _ := strconv.Atoi(s) + return n +} + +// toStringSlice 将 interface{} 转为 []string +func toStringSlice(v interface{}) []string { + if v == nil { + return nil + } + if arr, ok := v.([]interface{}); ok { + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + } + if s, ok := v.(string); ok && s != "" { + return []string{s} + } + return nil +} diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go new file mode 100644 index 00000000..d63951f2 --- /dev/null +++ b/backend/internal/proxy/speedtest.go @@ -0,0 +1,321 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/metacubex/mihomo/adapter" + C "github.com/metacubex/mihomo/constant" + "gopkg.in/yaml.v3" + + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" +) + +// ─── Clash 标准测速 URL ─── +// 使用 HTTP 与 Clash 客户端保持一致 + +const defaultTestURL = "http://www.gstatic.com/generate_204" + +// SpeedTestConfig 测速参数 +type SpeedTestConfig struct { + Timeout time.Duration + TCPTimeout time.Duration + URLs []string +} + +var DefaultSpeedTestConfig = SpeedTestConfig{ + Timeout: 10 * time.Second, + TCPTimeout: 5 * time.Second, +} + +// ─── 对外入口 ─── + +// SpeedTest 使用 mihomo 代理适配器进行测速。 +// 采用 unified-delay 策略:先建立连接(预热),再单独计时 HTTP 往返, +// 与 Clash 客户端 unified-delay: true 的延迟结果一致。 +func SpeedTest( + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, + singboxMgr *SingBoxManager, + cfg *SpeedTestConfig, +) TestResult { + log := logger.New("SpeedTest") + + if cfg == nil { + c := DefaultSpeedTestConfig + cfg = &c + } + + // 查找代理配置 + src := "" + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + break + } + } + if src == "" { + return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"} + } + + if strings.ToLower(src) == "direct://" { + return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0} + } + + testURL := defaultTestURL + if len(cfg.URLs) > 0 { + testURL = cfg.URLs[0] + } + + // 将代理配置转换为 mihomo mapping + mapping, err := proxyConfigToMapping(src) + if err != nil { + log.Warn("代理配置解析失败,降级到 TCP ping", + logger.F("proxy_id", proxyId), + logger.F("error", err.Error()), + ) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + } + + // 使用 mihomo adapter.ParseProxy 创建代理实例 + proxyInstance, err := adapter.ParseProxy(mapping) + if err != nil { + log.Warn("mihomo 代理创建失败,降级到 TCP ping", + logger.F("proxy_id", proxyId), + logger.F("error", err.Error()), + logger.F("type", mapping["type"]), + ) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + } + + // unified-delay 测速:分离连接建立和 HTTP 往返计时 + return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout) +} + +// unifiedDelayTest 模拟 Clash unified-delay 模式: +// 1. 通过代理建立到目标的 TCP 连接(预热,不计入延迟) +// 2. 发送第一次 HTTP 请求预热连接(不计入延迟) +// 3. 在已建立的连接上发送第二次 HTTP 请求,只计这次的 RTT +// 这样测出的延迟 = 纯 HTTP 往返时间,和 Clash unified-delay: true 一致。 +func unifiedDelayTest(proxyId string, px C.Proxy, testURL string, timeout time.Duration) TestResult { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // 解析目标地址 + addr, err := urlToMeta(testURL) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("URL 解析失败: %v", err)} + } + + // 步骤 1:通过代理 DialContext 建立连接(预热) + conn, err := px.DialContext(ctx, &addr) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理连接失败: %v", err)} + } + defer conn.Close() + + // 构造复用此连接的 HTTP client + transport := &http.Transport{ + DialContext: func(context.Context, string, string) (net.Conn, error) { + return conn, nil + }, + DisableKeepAlives: false, + } + client := &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + defer client.CloseIdleConnections() + + // 步骤 2:第一次请求预热(不计时) + req1, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil) + resp1, err := client.Do(req1) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()} + } + resp1.Body.Close() + + // 步骤 3:第二次请求计时(纯 HTTP RTT) + start := time.Now() + req2, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil) + resp2, err := client.Do(req2) + latency := time.Since(start).Milliseconds() + + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()} + } + resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusNoContent { + return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, + Error: fmt.Sprintf("HTTP %d", resp2.StatusCode)} + } + + return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency} +} + +// urlToMeta 将 URL 转换为 mihomo Metadata +func urlToMeta(rawURL string) (C.Metadata, error) { + var host string + var portNum uint16 + if strings.HasPrefix(rawURL, "https://") { + host = rawURL[len("https://"):] + portNum = 443 + } else if strings.HasPrefix(rawURL, "http://") { + host = rawURL[len("http://"):] + portNum = 80 + } else { + return C.Metadata{}, fmt.Errorf("不支持的 URL scheme") + } + // 去掉 path + if idx := strings.Index(host, "/"); idx >= 0 { + host = host[:idx] + } + // 检查是否有自定义端口 + if h, p, err := net.SplitHostPort(host); err == nil { + host = h + fmt.Sscanf(p, "%d", &portNum) + } + + meta := C.Metadata{ + Host: host, + DstPort: portNum, + } + if addr, err := netip.ParseAddr(host); err == nil { + meta.DstIP = addr + } + return meta, nil +} + +// ─── 代理配置转换为 mihomo mapping ─── + +func proxyConfigToMapping(src string) (map[string]any, error) { + src = strings.TrimSpace(src) + l := strings.ToLower(src) + + // http/https 直连代理 + if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") { + return parseStandardProxy(src, "http") + } + // socks5 直连代理 + if strings.HasPrefix(l, "socks5://") { + return parseStandardProxy(src, "socks5") + } + + // URI 格式(vmess:// vless:// 等)暂不支持直接转 mapping,降级 + if strings.Contains(l, "://") && !strings.Contains(l, "type:") { + return nil, fmt.Errorf("URI 格式暂不支持: %s", l[:min(30, len(l))]) + } + + // Clash YAML 格式 → 直接解析 + return parseClashYAMLToMapping(src) +} + +func parseStandardProxy(src string, proxyType string) (map[string]any, error) { + rest := src[strings.Index(src, "://")+3:] + + var username, password, hostport string + if atIdx := strings.LastIndex(rest, "@"); atIdx >= 0 { + userInfo := rest[:atIdx] + hostport = rest[atIdx+1:] + parts := strings.SplitN(userInfo, ":", 2) + username = parts[0] + if len(parts) > 1 { + password = parts[1] + } + } else { + hostport = rest + } + hostport = strings.SplitN(hostport, "/", 2)[0] + + host, port := splitHostPort(hostport) + if host == "" || port == 0 { + return nil, fmt.Errorf("无法解析地址: %s", src) + } + + mapping := map[string]any{ + "name": "speedtest-proxy", + "type": proxyType, + "server": host, + "port": port, + } + if username != "" { + mapping["username"] = username + mapping["password"] = password + } + return mapping, nil +} + +func parseClashYAMLToMapping(src string) (map[string]any, error) { + var payload interface{} + if err := yaml.Unmarshal([]byte(src), &payload); err != nil { + return nil, fmt.Errorf("YAML 解析失败: %v", err) + } + + node := pickClashNode(payload) + if node == nil { + return nil, fmt.Errorf("无法提取 Clash 节点") + } + + if _, ok := node["name"]; !ok { + node["name"] = "speedtest-proxy" + } + + return node, nil +} + +func splitHostPort(hostport string) (string, int) { + if strings.HasPrefix(hostport, "[") { + if idx := strings.LastIndex(hostport, "]:"); idx >= 0 { + host := hostport[1:idx] + port := 0 + fmt.Sscanf(hostport[idx+2:], "%d", &port) + return host, port + } + return strings.Trim(hostport, "[]"), 0 + } + idx := strings.LastIndex(hostport, ":") + if idx < 0 { + return hostport, 0 + } + host := hostport[:idx] + port := 0 + fmt.Sscanf(hostport[idx+1:], "%d", &port) + return host, port +} + +// ─── TCP Ping 降级 ─── + +func tcpPingFallback(proxyId, src string, timeout time.Duration, log *logger.Logger) TestResult { + endpoint, err := proxyEndpoint(src) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("无法解析代理地址: %v", err)} + } + + start := time.Now() + conn, err := net.DialTimeout("tcp", endpoint, timeout) + latency := time.Since(start).Milliseconds() + + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("TCP 连接失败: %v", err)} + } + conn.Close() + return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency} +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/proxy/sysproc_others.go b/backend/internal/proxy/sysproc_others.go new file mode 100644 index 00000000..38c27cff --- /dev/null +++ b/backend/internal/proxy/sysproc_others.go @@ -0,0 +1,10 @@ +//go:build !windows +// +build !windows + +package proxy + +import "os/exec" + +func hideWindow(cmd *exec.Cmd) { + // do nothing on non-windows platforms +} diff --git a/backend/internal/proxy/sysproc_windows.go b/backend/internal/proxy/sysproc_windows.go new file mode 100644 index 00000000..ee43b343 --- /dev/null +++ b/backend/internal/proxy/sysproc_windows.go @@ -0,0 +1,13 @@ +//go:build windows +// +build windows + +package proxy + +import ( + "os/exec" + "syscall" +) + +func hideWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/backend/internal/proxy/types.go b/backend/internal/proxy/types.go new file mode 100644 index 00000000..34d0a38e --- /dev/null +++ b/backend/internal/proxy/types.go @@ -0,0 +1,25 @@ +package proxy + +import ( + "os/exec" + "time" +) + +// XrayBridge Xray 桥接进程 +type XrayBridge struct { + NodeKey string + Port int + Cmd *exec.Cmd + Pid int + Running bool + LastError string + RefCount int + LastUsedAt time.Time + Stopping bool +} + +// ProxyResult 代理解析结果 +type ProxyResult struct { + StandardProxy string // 标准代理 URL (http/socks5) + Outbound map[string]interface{} // Xray outbound 配置 +} diff --git a/backend/internal/proxy/utils.go b/backend/internal/proxy/utils.go new file mode 100644 index 00000000..67b36987 --- /dev/null +++ b/backend/internal/proxy/utils.go @@ -0,0 +1,309 @@ +package proxy + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "ant-chrome/backend/internal/config" + xproxy "golang.org/x/net/proxy" + "gopkg.in/yaml.v3" +) + +// TestResult 代理测试结果 +type TestResult struct { + ProxyId string + Ok bool + LatencyMs int64 + Error string +} + +// proxyEndpoint 从代理配置中提取 server:port,用于 TCP ping +func proxyEndpoint(src string) (string, error) { + src = strings.TrimSpace(src) + l := strings.ToLower(src) + + // 标准 URL 格式: socks5://host:port, http://host:port + if strings.HasPrefix(l, "socks5://") || strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") { + hostport := src[strings.Index(src, "//")+2:] + hostport = strings.SplitN(hostport, "/", 2)[0] + return hostport, nil + } + + // vmess:// URL (base64 encoded JSON) + if strings.HasPrefix(l, "vmess://") { + raw := strings.TrimPrefix(src, "vmess://") + decoded, err := decodeBase64String(strings.TrimSpace(raw)) + if err == nil { + var v struct { + Add string `json:"add"` + Port interface{} `json:"port"` + } + if jsonErr := json.Unmarshal(decoded, &v); jsonErr == nil && v.Add != "" { + return fmt.Sprintf("%s:%v", v.Add, v.Port), nil + } + } + } + + // vless:// URL: vless://uuid@host:port?... + if strings.HasPrefix(l, "vless://") { + rest := src[len("vless://"):] + if at := strings.LastIndex(rest, "@"); at >= 0 { + hostport := strings.SplitN(rest[at+1:], "?", 2)[0] + hostport = strings.SplitN(hostport, "#", 2)[0] + return hostport, nil + } + } + + // Clash YAML 格式 + var payload interface{} + if err := yaml.Unmarshal([]byte(src), &payload); err == nil { + node := pickClashNode(payload) + if node != nil { + server := getMapString(node, "server") + port := getMapInt(node, "port") + if server != "" && port > 0 { + return fmt.Sprintf("%s:%d", server, port), nil + } + } + } + + return "", fmt.Errorf("无法解析代理地址") +} + +// TestConnectivity 通过 TCP 握手测试代理服务器的可达性和延迟 +// 直接对 server:port 建立 TCP 连接测量 RTT,无需启动外部进程 +func TestConnectivity(proxyId string, proxyConfig string, proxies []config.BrowserProxy, _ interface{}) TestResult { + src := strings.TrimSpace(proxyConfig) + if proxyId != "" { + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + break + } + } + } + if src == "" { + return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"} + } + + endpoint, err := proxyEndpoint(src) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("地址解析失败: %v", err)} + } + + start := time.Now() + conn, err := net.DialTimeout("tcp", endpoint, 10*time.Second) + latency := time.Since(start).Milliseconds() + + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()} + } + conn.Close() + return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency} +} + +func toStringMap(input interface{}) map[string]interface{} { + switch v := input.(type) { + case map[string]interface{}: + return v + case map[interface{}]interface{}: + out := map[string]interface{}{} + for k, val := range v { + out[fmt.Sprint(k)] = val + } + return out + } + return nil +} + +func getMapString(m map[string]interface{}, key string) string { + v, ok := m[key] + if !ok { + return "" + } + switch s := v.(type) { + case string: + return strings.TrimSpace(s) + case int: + return strconv.Itoa(s) + case int64: + return strconv.FormatInt(s, 10) + case float64: + return strconv.Itoa(int(s)) + case bool: + if s { + return "true" + } + return "false" + } + return strings.TrimSpace(fmt.Sprint(v)) +} + +func getMapInt(m map[string]interface{}, key string) int { + v, ok := m[key] + if !ok { + return 0 + } + switch s := v.(type) { + case int: + return s + case int64: + return int(s) + case float64: + return int(s) + case string: + value, _ := strconv.Atoi(s) + return value + } + return 0 +} + +func getMapBool(m map[string]interface{}, key string) bool { + v, ok := m[key] + if !ok { + return false + } + switch s := v.(type) { + case bool: + return s + case string: + return strings.ToLower(s) == "true" + case int: + return s != 0 + case float64: + return int(s) != 0 + } + return false +} + +func decodeBase64String(raw string) ([]byte, error) { + if raw == "" { + return nil, fmt.Errorf("base64 内容为空") + } + if data, err := base64.StdEncoding.DecodeString(raw); err == nil { + return data, nil + } + if data, err := base64.RawStdEncoding.DecodeString(raw); err == nil { + return data, nil + } + if data, err := base64.URLEncoding.DecodeString(raw); err == nil { + return data, nil + } + if data, err := base64.RawURLEncoding.DecodeString(raw); err == nil { + return data, nil + } + return nil, fmt.Errorf("base64 解析失败") +} + +// isUnsupportedProtocol 判断是否为不支持的协议(hysteria/hysteria2) +func isUnsupportedProtocol(src string) bool { + l := strings.ToLower(strings.TrimSpace(src)) + return strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") +} + +// TestRealConnectivity 通过代理链路发起真实 HTTP 请求测量端到端延迟。 +// - DirectProxy (http/https/socks5):直接通过该代理发送请求 +// - BridgeProxy (vmess/vless/Clash):调用 EnsureBridge 获取 socks5 地址后发送请求 +// - SingBoxProxy (hysteria2/tuic):调用 SingBoxManager.EnsureBridge 后发送请求 +func TestRealConnectivity( + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, +) TestResult { + return TestRealConnectivityWithSingBox(proxyId, proxies, xrayMgr, nil) +} + +// TestRealConnectivityWithSingBox 支持 sing-box 的真实连通性测试 +func TestRealConnectivityWithSingBox( + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, + singboxMgr *SingBoxManager, +) TestResult { + src := "" + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + break + } + } + if src == "" { + return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"} + } + + const targetURL = "http://www.gstatic.com/generate_204" + const timeout = 15 * time.Second + + var client *http.Client + + if IsSingBoxProtocol(src) { + // hysteria2/tuic → sing-box 桥接 + if singboxMgr == nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"} + } + socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("sing-box 桥接启动失败: %v", err)} + } + socks5Host := strings.TrimPrefix(socks5Addr, "socks5://") + dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)} + } + contextDialer, ok := dialer.(xproxy.ContextDialer) + if !ok { + return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"} + } + transport := &http.Transport{DialContext: contextDialer.DialContext} + client = &http.Client{Transport: transport, Timeout: timeout} + } else if RequiresBridge(src, proxies, proxyId) { + // BridgeProxy:通过 xray socks5 桥接 + if xrayMgr == nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: "xray 管理器未初始化"} + } + socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("桥接启动失败: %v", err)} + } + // 解析 socks5://127.0.0.1:port + socks5Host := strings.TrimPrefix(socks5Addr, "socks5://") + dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)} + } + contextDialer, ok := dialer.(xproxy.ContextDialer) + if !ok { + return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"} + } + transport := &http.Transport{DialContext: contextDialer.DialContext} + client = &http.Client{Transport: transport, Timeout: timeout} + } else { + // DirectProxy:http/https/socks5 直接代理 + proxyURL, err := url.Parse(src) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理地址解析失败: %v", err)} + } + transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + client = &http.Client{Transport: transport, Timeout: timeout} + } + + start := time.Now() + resp, err := client.Get(targetURL) + latency := time.Since(start).Milliseconds() + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)} + } + return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency} +} diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go new file mode 100644 index 00000000..17c55631 --- /dev/null +++ b/backend/internal/proxy/xray.go @@ -0,0 +1,725 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strconv" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + xrayBridgeIdleTTL = 45 * time.Second + xrayBridgeCleanupInterval = 15 * time.Second +) + +// XrayManager Xray 桥接管理器 +type XrayManager struct { + Config *config.Config + AppRoot string // 应用根目录,所有相对路径基于此解析 + Bridges map[string]*XrayBridge + OnBridgeDied func(key string, err error) // 桥接进程意外退出回调 + mu sync.Mutex + stopCh chan struct{} + stopOnce sync.Once +} + +// NewXrayManager 创建 Xray 管理器 +func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager { + manager := &XrayManager{ + Config: cfg, + AppRoot: appRoot, + Bridges: make(map[string]*XrayBridge), + stopCh: make(chan struct{}), + } + go manager.cleanupLoop() + return manager +} + +// ValidateProxyConfig 验证代理配置是否支持 +// 返回: supported bool, errorMsg string +func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (bool, string) { + src := strings.TrimSpace(proxyConfig) + found := false + if proxyId != "" { + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + found = true + break + } + } + if !found { + return false, fmt.Sprintf("代理链路不可用:代理池节点已不存在(proxyId=%s)。可能因订阅刷新后节点下线或被删除,请重新选择代理后再启动。", proxyId) + } + } + if src == "" { + return true, "" // 无代理配置,允许启动 + } + if strings.EqualFold(src, "direct://") { + return true, "" + } + l := strings.ToLower(src) + // 标准代理格式,支持 + if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { + return true, "" + } + // hysteria2/tuic 通过 sing-box 支持,先做可解析性校验 + if IsSingBoxProtocol(src) { + if _, err := BuildSingBoxOutbound(src); err != nil { + return false, fmt.Sprintf("代理配置解析失败: %v", err) + } + return true, "" + } + + // 其余协议交给统一解析器校验,防止无效字符串被当成代理参数透传给 Chrome + standardProxy, outbound, err := ParseProxyNode(src) + if err != nil { + return false, fmt.Sprintf("代理配置解析失败: %v", err) + } + if strings.TrimSpace(standardProxy) == "" && outbound == nil { + return false, "代理配置无效" + } + return true, "" +} + +// RequiresBridge 判断是否需要 Xray 桥接 +// 注意: Xray 仅支持 vless/vmess/trojan/shadowsocks 等协议 +// hysteria2 不支持,需要使用 Hysteria 客户端或 sing-box +func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) bool { + src := strings.TrimSpace(proxyConfig) + if proxyId != "" { + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + break + } + } + } + if src == "" { + return false + } + l := strings.ToLower(src) + // 标准代理格式,不需要桥接 + if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { + return false + } + // hysteria2 Xray 不支持,不触发桥接 + if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") { + return false + } + // Xray 支持的协议 + if strings.HasPrefix(l, "vmess://") || strings.HasPrefix(l, "vless://") || strings.HasPrefix(l, "trojan://") || strings.HasPrefix(l, "ss://") { + return true + } + // Clash 格式需要进一步检查类型 + if strings.HasPrefix(l, "clash://") || strings.Contains(l, "type:") || strings.Contains(l, "proxies:") { + // 排除 hysteria 类型 + if strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") { + return false + } + return true + } + return false +} + +// EnsureBridge 确保 Xray 桥接进程运行,用于临时请求场景。 +func (m *XrayManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) { + socksURL, _, err := m.ensureBridge(proxyConfig, proxies, proxyId, false) + return socksURL, err +} + +// AcquireBridge 获取一个带引用计数的 Xray 桥接,用于浏览器实例等长生命周期场景。 +func (m *XrayManager) AcquireBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, string, error) { + return m.ensureBridge(proxyConfig, proxies, proxyId, true) +} + +// ReleaseBridge 释放一个已占用的桥接引用;空闲桥接会由后台回收协程延迟清理。 +func (m *XrayManager) ReleaseBridge(key string) { + key = strings.TrimSpace(key) + if key == "" { + return + } + + m.mu.Lock() + defer m.mu.Unlock() + + bridge, ok := m.Bridges[key] + if !ok || bridge == nil { + return + } + if bridge.RefCount > 0 { + bridge.RefCount-- + } + bridge.LastUsedAt = time.Now() +} + +// StopAll 关闭所有 xray 桥接进程。 +func (m *XrayManager) StopAll() { + m.stopOnce.Do(func() { + close(m.stopCh) + }) + + m.mu.Lock() + bridges := make([]*XrayBridge, 0, len(m.Bridges)) + for key, bridge := range m.Bridges { + if bridge != nil { + bridge.Stopping = true + bridges = append(bridges, bridge) + } + delete(m.Bridges, key) + } + m.mu.Unlock() + + for _, bridge := range bridges { + m.stopBridgeProcess(bridge) + } +} + +func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) { + log := logger.New("Xray") + src := strings.TrimSpace(proxyConfig) + dnsServers := "" + if proxyId != "" { + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + src = strings.TrimSpace(item.ProxyConfig) + dnsServers = item.DnsServers + break + } + } + } + if src == "" { + return "", "", fmt.Errorf("未找到代理节点") + } + src = normalizeNodeScheme(src) + standardProxy, outbound, err := ParseProxyNode(src) + if err != nil { + log.Error("节点解析失败", logger.F("error", err)) + return "", "", err + } + if standardProxy != "" { + return standardProxy, "", nil + } + if outbound == nil { + return "", "", fmt.Errorf("节点解析失败") + } + key := computeNodeKey(src + "\x00" + dnsServers) + + if socksURL, reused := m.tryReuseBridge(key, pin); reused { + log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL)) + return socksURL, key, nil + } + + binaryPath, err := m.resolveBinary() + if err != nil { + log.Error("xray 不可用", logger.F("error", err)) + return "", "", err + } + // 最多重试 3 次,解决端口分配后被抢占的 TOCTOU 竞争问题 + const maxLaunchRetries = 3 + var lastErr error + for attempt := 1; attempt <= maxLaunchRetries; attempt++ { + port, err := nextAvailablePort() + if err != nil { + log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) + lastErr = err + continue + } + cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers) + if err != nil { + log.Error("xray 配置生成失败", logger.F("error", err)) + return "", "", err + } + cmd := exec.Command(binaryPath, "run", "-c", cfgPath) + hideWindow(cmd) + cmd.Dir = filepath.Dir(cfgPath) + stderrPath := filepath.Join(filepath.Dir(cfgPath), "xray-stderr.log") + stderrFile, _ := os.Create(stderrPath) + if stderrFile != nil { + cmd.Stderr = stderrFile + } + if err := cmd.Start(); err != nil { + if stderrFile != nil { + stderrFile.Close() + } + log.Error("xray 启动失败", logger.F("error", err), logger.F("attempt", attempt)) + lastErr = err + continue + } + bridge := &XrayBridge{ + NodeKey: key, + Port: port, + Cmd: cmd, + Pid: cmd.Process.Pid, + Running: true, + RefCount: 0, + LastUsedAt: time.Now(), + } + log.Info("xray 启动", logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt)) + if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil { + if stderrFile != nil { + stderrFile.Close() + } + // 优先读 stderr,再读 xray-error.log + if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 { + log.Error("xray stderr", logger.F("output", string(stderrContent))) + } else { + errLogPath := filepath.Join(filepath.Dir(cfgPath), "xray-error.log") + if errContent, readErr := os.ReadFile(errLogPath); readErr == nil && len(errContent) > 0 { + log.Error("xray error.log", logger.F("output", string(errContent))) + } + } + bridge.Stopping = true + m.stopBridgeProcess(bridge) + bridge.Running = false + bridge.Pid = 0 + bridge.LastError = err.Error() + log.Error("xray 端口不可用,重试", logger.F("key", key), logger.F("error", err), logger.F("port", port), logger.F("attempt", attempt)) + lastErr = err + // 等待一下再重试,给 OS 时间回收端口 + time.Sleep(200 * time.Millisecond) + continue + } + if stderrFile != nil { + stderrFile.Close() + } + + if socksURL, reused := m.registerBridge(key, bridge, pin); reused { + log.Info("复用已就绪桥接进程", logger.F("key", key), logger.F("socks_url", socksURL)) + bridge.Stopping = true + m.stopBridgeProcess(bridge) + return socksURL, key, nil + } + + go m.watchBridge(bridge, key) + return fmt.Sprintf("socks5://127.0.0.1:%d", port), key, nil + } + return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr) +} + +func (m *XrayManager) tryReuseBridge(key string, pin bool) (string, bool) { + var stale *XrayBridge + + m.mu.Lock() + if bridge, ok := m.Bridges[key]; ok && bridge != nil { + alive := bridge.Running && bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil + if alive && waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil { + if pin { + bridge.RefCount++ + } + bridge.LastUsedAt = time.Now() + socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port) + m.mu.Unlock() + return socksURL, true + } + + bridge.Stopping = true + stale = bridge + delete(m.Bridges, key) + } + m.mu.Unlock() + + if stale != nil { + m.stopBridgeProcess(stale) + } + return "", false +} + +func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) (string, bool) { + var duplicate *XrayBridge + + m.mu.Lock() + if existing, ok := m.Bridges[key]; ok && existing != nil { + alive := existing.Running && existing.Cmd != nil && existing.Cmd.Process != nil && existing.Cmd.ProcessState == nil + if alive && waitPortReady("127.0.0.1", existing.Port, 800*time.Millisecond) == nil { + if pin { + existing.RefCount++ + } + existing.LastUsedAt = time.Now() + duplicate = bridge + socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port) + m.mu.Unlock() + if duplicate != nil { + duplicate.Stopping = true + m.stopBridgeProcess(duplicate) + } + return socksURL, true + } + + existing.Stopping = true + delete(m.Bridges, key) + duplicate = existing + } + + if pin { + bridge.RefCount = 1 + } + bridge.LastUsedAt = time.Now() + m.Bridges[key] = bridge + m.mu.Unlock() + + if duplicate != nil { + m.stopBridgeProcess(duplicate) + } + return "", false +} + +func (m *XrayManager) watchBridge(bridge *XrayBridge, key string) { + if bridge == nil || bridge.Cmd == nil { + return + } + _ = bridge.Cmd.Wait() + + m.mu.Lock() + if current, ok := m.Bridges[key]; ok && current == bridge { + delete(m.Bridges, key) + } + bridge.Running = false + stopping := bridge.Stopping + m.mu.Unlock() + + if !stopping && m.OnBridgeDied != nil { + m.OnBridgeDied(key, fmt.Errorf("xray 桥接进程意外退出")) + } +} + +func (m *XrayManager) cleanupLoop() { + ticker := time.NewTicker(xrayBridgeCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + m.recycleIdleBridges() + case <-m.stopCh: + return + } + } +} + +func (m *XrayManager) recycleIdleBridges() { + now := time.Now() + var stale []*XrayBridge + + m.mu.Lock() + for key, bridge := range m.Bridges { + if bridge == nil { + delete(m.Bridges, key) + continue + } + if bridge.RefCount > 0 { + continue + } + if now.Sub(bridge.LastUsedAt) < xrayBridgeIdleTTL { + continue + } + + bridge.Stopping = true + stale = append(stale, bridge) + delete(m.Bridges, key) + } + m.mu.Unlock() + + if len(stale) == 0 { + return + } + + log := logger.New("Xray") + for _, bridge := range stale { + log.Info("回收空闲桥接进程", logger.F("key", bridge.NodeKey), logger.F("pid", bridge.Pid)) + m.stopBridgeProcess(bridge) + } +} + +func (m *XrayManager) stopBridgeProcess(bridge *XrayBridge) { + if bridge == nil || bridge.Cmd == nil || bridge.Cmd.Process == nil { + return + } + _ = bridge.Cmd.Process.Kill() +} + +func (m *XrayManager) resolveBinary() (string, error) { + configPath := strings.TrimSpace(m.Config.Browser.XrayBinaryPath) + if configPath != "" { + resolved := resolveEnvPath(configPath, m.AppRoot) + if resolved != "" { + if _, err := os.Stat(resolved); err == nil { + return resolved, nil + } + } + } + env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH")) + if env != "" { + if _, err := os.Stat(env); err == nil { + return env, nil + } + } + // 优先基于 appRoot 查找 bin/xray.exe + if m.AppRoot != "" { + candidate := filepath.Join(m.AppRoot, "bin", "xray.exe") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + // 兜底:exe 目录 + if exePath, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exePath), "bin", "xray.exe") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + if path, err := exec.LookPath("xray"); err == nil { + return path, nil + } + if goruntime.GOOS == "windows" { + if path, err := exec.LookPath("xray.exe"); err == nil { + return path, nil + } + } + return "", fmt.Errorf("未找到 xray.exe。请将 xray.exe 放到 bin/ 目录,或在配置中设置 XrayBinaryPath") +} + +// parseDnsConfig 解析 DNS 配置,支持两种格式: +// 1. Clash dns: YAML 块(含 nameserver/fallback 等字段) +// 2. 逗号分隔的 IP 列表(兼容旧格式) +// 返回 xray dns 配置 map,若无有效配置则返回 nil +// +// 注意:xray dns.servers 只支持纯 IP 或 DoH(https://)地址, +// 不支持 Clash 的 tls:// 格式(DoT),会被自动过滤。 +func parseDnsConfig(raw string) map[string]interface{} { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + // 尝试解析 Clash dns: YAML 块 + type clashDns struct { + Enable bool `yaml:"enable"` + Nameserver []string `yaml:"nameserver"` + Fallback []string `yaml:"fallback"` + } + type clashDnsWrapper struct { + Dns clashDns `yaml:"dns"` + } + + var wrapper clashDnsWrapper + if err := yaml.Unmarshal([]byte(raw), &wrapper); err == nil && len(wrapper.Dns.Nameserver) > 0 { + servers := make([]interface{}, 0) + for _, s := range wrapper.Dns.Nameserver { + if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) { + servers = append(servers, s) + } + } + for _, s := range wrapper.Dns.Fallback { + if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) { + servers = append(servers, s) + } + } + if len(servers) > 0 { + return map[string]interface{}{"servers": servers} + } + } + + // 兼容旧格式:逗号分隔的 IP 列表 + var result []string + for _, s := range strings.Split(raw, ",") { + if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) { + result = append(result, s) + } + } + if len(result) > 0 { + servers := make([]interface{}, len(result)) + for i, s := range result { + servers[i] = s + } + return map[string]interface{}{"servers": servers} + } + return nil +} + +// isXrayDnsAddr 判断 DNS 地址是否为 xray 支持的格式。 +// xray 支持:纯 IP(如 8.8.8.8)、IP:port(如 8.8.8.8:53)、 +// DoH(https://...)、localhost。 +// 不支持:Clash 的 tls:// 格式(DoT)。 +func isXrayDnsAddr(s string) bool { + l := strings.ToLower(s) + if strings.HasPrefix(l, "tls://") { + return false + } + return true +} + +func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) { + baseDir := m.resolveWorkdir(key) + if err := os.MkdirAll(baseDir, 0755); err != nil { + return "", err + } + cfgPath := filepath.Join(baseDir, "xray-config.json") + cfg := map[string]interface{}{ + "log": map[string]interface{}{ + "loglevel": "info", + "error": filepath.Join(baseDir, "xray-error.log"), + }, + "inbounds": []interface{}{ + map[string]interface{}{ + "tag": "socks-in", + "port": port, + "listen": "127.0.0.1", + "protocol": "socks", + "settings": map[string]interface{}{ + "udp": true, + }, + "sniffing": map[string]interface{}{ + "enabled": false, + }, + }, + }, + "outbounds": []interface{}{ + outbound, + map[string]interface{}{ + "protocol": "direct", + "tag": "direct", + }, + map[string]interface{}{ + "protocol": "blackhole", + "tag": "block", + }, + }, + "routing": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + }, + }, + } + if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil { + cfg["dns"] = dnsCfg + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + return "", err + } + return cfgPath, nil +} + +func (m *XrayManager) resolveWorkdir(key string) string { + root := strings.TrimSpace(m.Config.Browser.UserDataRoot) + if root == "" { + root = "data" + } + if !filepath.IsAbs(root) { + if m.AppRoot != "" { + root = filepath.Join(m.AppRoot, root) + } else if exePath, err := os.Executable(); err == nil { + root = filepath.Join(filepath.Dir(exePath), root) + } + } + return filepath.Join(root, "_xray", key) +} + +func computeNodeKey(src string) string { + h := sha256.Sum256([]byte(strings.TrimSpace(src))) + return hex.EncodeToString(h[:]) +} + +func normalizeNodeScheme(src string) string { + s := strings.TrimSpace(src) + if strings.HasPrefix(strings.ToLower(s), "hysteria://") { + return "hysteria2://" + strings.TrimPrefix(s, "hysteria://") + } + return s +} + +func resolveEnvPath(path string, appRoot string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return path + } + // 优先基于 appRoot 解析 + if appRoot != "" { + candidate := filepath.Join(appRoot, path) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + // 兜底:exe 目录 + if exePath, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exePath), path) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + // 兜底:CWD + if cwd, err := os.Getwd(); err == nil { + candidate := filepath.Join(cwd, path) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return path +} + +func waitPortReady(host string, port int, timeout time.Duration) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + conn.Close() + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("端口 %d 不可用", port) +} + +// nextAvailablePort 分配一个可用端口。 +// 采用二次验证策略:分配后立即再次绑定确认未被其他进程抢占, +// 并在 EnsureBridge 层面加重试,彻底消除 TOCTOU 竞争窗口。 +func nextAvailablePort() (int, error) { + return nextAvailablePortWithRetry(10) +} + +func nextAvailablePortWithRetry(maxRetries int) (int, error) { + for i := 0; i < maxRetries; i++ { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + continue + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + // 短暂等待确保 OS 释放端口 + time.Sleep(10 * time.Millisecond) + // 二次验证端口确实可用(没有被其他进程抢占) + verifyListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + // 端口被抢占,重试 + continue + } + verifyListener.Close() + return port, nil + } + return 0, fmt.Errorf("无法分配可用端口,已重试 %d 次", maxRetries) +} diff --git a/backend/internal/proxy/xray_validate_test.go b/backend/internal/proxy/xray_validate_test.go new file mode 100644 index 00000000..5a9f2786 --- /dev/null +++ b/backend/internal/proxy/xray_validate_test.go @@ -0,0 +1,36 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "strings" + "testing" +) + +func TestValidateProxyConfigInvalidRawString(t *testing.T) { + ok, msg := ValidateProxyConfig("not-a-proxy-config", nil, "") + if ok { + t.Fatalf("expected invalid raw string to fail validation") + } + if !strings.Contains(msg, "解析失败") { + t.Fatalf("unexpected message: %s", msg) + } +} + +func TestValidateProxyConfigMissingProxyId(t *testing.T) { + ok, msg := ValidateProxyConfig("", []config.BrowserProxy{ + {ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"}, + }, "missing-proxy") + if ok { + t.Fatalf("expected missing proxyId to fail validation") + } + if !strings.Contains(msg, "不存在") { + t.Fatalf("unexpected message: %s", msg) + } +} + +func TestValidateProxyConfigStandardProxy(t *testing.T) { + ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", nil, "") + if !ok { + t.Fatalf("expected standard proxy to pass: %s", msg) + } +} diff --git a/backend/internal/tray/icon.ico b/backend/internal/tray/icon.ico new file mode 100644 index 00000000..986fcfd6 Binary files /dev/null and b/backend/internal/tray/icon.ico differ diff --git a/backend/internal/tray/tray.go b/backend/internal/tray/tray.go new file mode 100644 index 00000000..f5ca873c --- /dev/null +++ b/backend/internal/tray/tray.go @@ -0,0 +1,63 @@ +//go:build windows + +package tray + +import ( + _ "embed" + + "github.com/energye/systray" +) + +//go:embed icon.ico +var iconData []byte + +// Callbacks 托盘回调 +type Callbacks struct { + OnShow func() + OnQuit func() +} + +// Run 启动系统托盘(阻塞,需在独立 goroutine 中调用) +func Run(cb Callbacks) { + systray.Run(func() { + systray.SetIcon(iconData) + systray.SetTitle("Ant Chrome") + systray.SetTooltip("Ant Chrome") + + mShow := systray.AddMenuItem("显示窗口", "显示主窗口") + systray.AddSeparator() + mQuit := systray.AddMenuItem("退出", "退出应用") + + systray.SetOnClick(func(menu systray.IMenu) { + if cb.OnShow != nil { + cb.OnShow() + } + }) + + systray.SetOnDClick(func(menu systray.IMenu) { + if cb.OnShow != nil { + cb.OnShow() + } + }) + + mShow.Click(func() { + if cb.OnShow != nil { + cb.OnShow() + } + }) + + mQuit.Click(func() { + systray.Quit() + if cb.OnQuit != nil { + cb.OnQuit() + } + }) + }, func() { + // onExit: 托盘退出时什么都不做,由 OnQuit 回调处理 + }) +} + +// Quit 主动退出托盘循环 +func Quit() { + systray.Quit() +} diff --git a/backend/license_state.go b/backend/license_state.go new file mode 100644 index 00000000..2a79dc04 --- /dev/null +++ b/backend/license_state.go @@ -0,0 +1,178 @@ +package backend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const localLicenseStateFilename = ".ant-license.json" + +type localLicenseState struct { + MaxProfileLimit int `json:"maxProfileLimit"` + UsedCDKeys []string `json:"usedCdKeys,omitempty"` +} + +func localLicenseStatePath(configPath string) string { + configPath = strings.TrimSpace(configPath) + if configPath == "" { + return localLicenseStateFilename + } + dir := filepath.Dir(configPath) + if dir == "." || dir == "" { + if cwd, err := os.Getwd(); err == nil { + dir = cwd + } + } + return filepath.Join(dir, localLicenseStateFilename) +} + +func loadLocalLicenseState(configPath string) (*localLicenseState, bool, error) { + statePath := localLicenseStatePath(configPath) + data, err := os.ReadFile(statePath) + if err != nil { + if os.IsNotExist(err) { + return &localLicenseState{}, false, nil + } + return nil, false, fmt.Errorf("读取本机额度状态失败: %w", err) + } + + var state localLicenseState + if err := json.Unmarshal(data, &state); err != nil { + // 状态文件损坏时回退到当前配置并在后续自动重建,避免阻断启动。 + return &localLicenseState{}, false, nil + } + normalizeLocalLicenseState(&state) + return &state, true, nil +} + +func saveLocalLicenseState(configPath string, state *localLicenseState) error { + if state == nil { + state = &localLicenseState{} + } + cloned := *state + normalizeLocalLicenseState(&cloned) + + data, err := json.MarshalIndent(cloned, "", " ") + if err != nil { + return fmt.Errorf("序列化本机额度状态失败: %w", err) + } + if err := os.WriteFile(localLicenseStatePath(configPath), data, 0644); err != nil { + return fmt.Errorf("写入本机额度状态失败: %w", err) + } + return nil +} + +func reconcileConfigWithLocalLicense(configPath string, cfg *Config) (bool, bool, error) { + if cfg == nil { + return false, false, nil + } + + state, stateExists, err := loadLocalLicenseState(configPath) + if err != nil { + return false, false, err + } + + originalKeys := normalizeUsedCDKeys(cfg.App.UsedCDKeys) + originalMax := cfg.App.MaxProfileLimit + + mergedKeys := unionUsedCDKeys(originalKeys, state.UsedCDKeys) + effectiveMax := maxInt(originalMax, state.MaxProfileLimit) + minLimit := minimumProfileLimitForKeys(mergedKeys) + if effectiveMax < minLimit { + effectiveMax = minLimit + } + + cfg.App.UsedCDKeys = mergedKeys + cfg.App.MaxProfileLimit = effectiveMax + + configChanged := originalMax != effectiveMax || !sameStringSlice(originalKeys, mergedKeys) + + desiredState := &localLicenseState{ + MaxProfileLimit: effectiveMax, + UsedCDKeys: mergedKeys, + } + normalizeLocalLicenseState(desiredState) + + stateChanged := state.MaxProfileLimit != desiredState.MaxProfileLimit || !sameStringSlice(state.UsedCDKeys, desiredState.UsedCDKeys) + shouldPersist := stateExists || desiredState.MaxProfileLimit > DefaultConfig().App.MaxProfileLimit || len(desiredState.UsedCDKeys) > 0 + if shouldPersist && stateChanged { + if err := saveLocalLicenseState(configPath, desiredState); err != nil { + return configChanged, false, err + } + return configChanged, true, nil + } + + return configChanged, false, nil +} + +func normalizeLocalLicenseState(state *localLicenseState) { + if state == nil { + return + } + state.UsedCDKeys = normalizeUsedCDKeys(state.UsedCDKeys) + minLimit := minimumProfileLimitForKeys(state.UsedCDKeys) + if state.MaxProfileLimit < minLimit { + state.MaxProfileLimit = minLimit + } +} + +func normalizeUsedCDKeys(keys []string) []string { + result := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + normalized := strings.ToUpper(strings.TrimSpace(key)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result +} + +func unionUsedCDKeys(primary, secondary []string) []string { + result := make([]string, 0, len(primary)+len(secondary)) + seen := make(map[string]struct{}, len(primary)+len(secondary)) + appendKeys := func(list []string) { + for _, key := range normalizeUsedCDKeys(list) { + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, key) + } + } + appendKeys(primary) + appendKeys(secondary) + return result +} + +func minimumProfileLimitForKeys(keys []string) int { + baseLimit := DefaultConfig().App.MaxProfileLimit + return baseLimit + len(normalizeUsedCDKeys(keys))*3 +} + +func sameStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/backend/license_state_test.go b/backend/license_state_test.go new file mode 100644 index 00000000..47bb9052 --- /dev/null +++ b/backend/license_state_test.go @@ -0,0 +1,100 @@ +package backend + +import ( + appconfig "ant-chrome/backend/internal/config" + "path/filepath" + "testing" +) + +func TestLoadConfigRestoresLocalLicenseState(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "config.yaml") + + cfg := appconfig.DefaultConfig() + if err := cfg.Save(configPath); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + if err := saveLocalLicenseState(configPath, &localLicenseState{ + MaxProfileLimit: 12, + UsedCDKeys: []string{"GITHUB_STAR_REWARD", "ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE"}, + }); err != nil { + t.Fatalf("写入本机额度状态失败: %v", err) + } + + loaded, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig 失败: %v", err) + } + + if loaded.App.MaxProfileLimit != 12 { + t.Fatalf("本机额度状态未恢复: got=%d", loaded.App.MaxProfileLimit) + } + if len(loaded.App.UsedCDKeys) != 2 { + t.Fatalf("兑换记录未恢复: %+v", loaded.App.UsedCDKeys) + } +} + +func TestLoadConfigSeedsLocalLicenseStateFromConfig(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "config.yaml") + + cfg := appconfig.DefaultConfig() + cfg.App.MaxProfileLimit = 15 + cfg.App.UsedCDKeys = []string{"GITHUB_STAR_REWARD"} + if err := cfg.Save(configPath); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + loaded, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig 失败: %v", err) + } + if loaded.App.MaxProfileLimit != 15 { + t.Fatalf("LoadConfig 读取额度失败: got=%d", loaded.App.MaxProfileLimit) + } + + state, exists, err := loadLocalLicenseState(configPath) + if err != nil { + t.Fatalf("读取本机额度状态失败: %v", err) + } + if !exists { + t.Fatalf("应当从现有配置补建本机额度状态") + } + if state.MaxProfileLimit != 15 { + t.Fatalf("本机额度状态未补建: got=%d", state.MaxProfileLimit) + } + if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" { + t.Fatalf("本机兑换记录未补建: %+v", state.UsedCDKeys) + } +} + +func TestRedeemGithubStarPersistsLocalLicenseState(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "config.yaml") + + cfg := appconfig.DefaultConfig() + if err := cfg.Save(configPath); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + app := NewApp(root) + app.config = cfg + + if err := app.RedeemGithubStar(); err != nil { + t.Fatalf("RedeemGithubStar 失败: %v", err) + } + + state, exists, err := loadLocalLicenseState(configPath) + if err != nil { + t.Fatalf("读取本机额度状态失败: %v", err) + } + if !exists { + t.Fatalf("兑换后应写入本机额度状态") + } + if state.MaxProfileLimit != 6 { + t.Fatalf("兑换后本机额度状态错误: got=%d", state.MaxProfileLimit) + } + if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" { + t.Fatalf("兑换后本机兑换记录错误: %+v", state.UsedCDKeys) + } +} diff --git a/backend/residual_processes_others.go b/backend/residual_processes_others.go new file mode 100644 index 00000000..db23a1c3 --- /dev/null +++ b/backend/residual_processes_others.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package backend + +func killResidualRuntimeProcesses(appRoot string) error { + return nil +} diff --git a/backend/residual_processes_windows.go b/backend/residual_processes_windows.go new file mode 100644 index 00000000..2182d1a0 --- /dev/null +++ b/backend/residual_processes_windows.go @@ -0,0 +1,104 @@ +//go:build windows +// +build windows + +package backend + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func killResidualRuntimeProcesses(appRoot string) error { + appRoot = strings.TrimSpace(appRoot) + if appRoot == "" { + return nil + } + if _, err := os.Stat(appRoot); err != nil { + return nil + } + + exePath, _ := os.Executable() + psScript := `param([string]$Root, [string]$ExcludePath) +$ErrorActionPreference = 'SilentlyContinue' +if ([string]::IsNullOrWhiteSpace($Root) -or -not (Test-Path -LiteralPath $Root)) { exit 0 } +$root = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + '\' +$exclude = '' +if (-not [string]::IsNullOrWhiteSpace($ExcludePath)) { + $exclude = [System.IO.Path]::GetFullPath($ExcludePath) +} +function Get-AntChromeResidualProcesses { + @( + Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and ( + $_.ExecutablePath.StartsWith(($root + 'bin\'), [System.StringComparison]::OrdinalIgnoreCase) -or + $_.ExecutablePath.StartsWith(($root + 'chrome\'), [System.StringComparison]::OrdinalIgnoreCase) + ) -and ( + $exclude -eq '' -or + -not $_.ExecutablePath.Equals($exclude, [System.StringComparison]::OrdinalIgnoreCase) + ) + } + ) +} +$targets = @(Get-AntChromeResidualProcesses | Sort-Object ProcessId -Descending) +foreach ($p in $targets) { + try { Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop } catch {} +} +Start-Sleep -Milliseconds 400 +$left = @(Get-AntChromeResidualProcesses) +if ($left.Count -gt 0) { + $names = ($left | ForEach-Object { $_.Name + '#' + $_.ProcessId }) -join ', ' + Write-Host ('still running: ' + $names) + exit 1 +} +exit 0 +` + + tempFile, err := os.CreateTemp("", "ant-chrome-cleanup-*.ps1") + if err != nil { + return fmt.Errorf("创建清理脚本失败: %w", err) + } + scriptPath := tempFile.Name() + if _, err := tempFile.WriteString(psScript); err != nil { + tempFile.Close() + _ = os.Remove(scriptPath) + return fmt.Errorf("写入清理脚本失败: %w", err) + } + if err := tempFile.Close(); err != nil { + _ = os.Remove(scriptPath) + return fmt.Errorf("关闭清理脚本失败: %w", err) + } + defer os.Remove(scriptPath) + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + powershellPath := `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` + if _, err := os.Stat(powershellPath); err != nil { + if fallbackPath, lookErr := exec.LookPath("powershell.exe"); lookErr == nil { + powershellPath = fallbackPath + } else { + return fmt.Errorf("未找到 powershell.exe") + } + } + + cmd := exec.CommandContext(ctx, powershellPath, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", filepath.Clean(scriptPath), "-Root", appRoot, "-ExcludePath", exePath) + hideWindow(cmd) + + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("清理残留进程超时") + } + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("清理残留进程失败: %s", message) + } + return nil +} diff --git a/backend/sysproc_others.go b/backend/sysproc_others.go new file mode 100644 index 00000000..f56d1413 --- /dev/null +++ b/backend/sysproc_others.go @@ -0,0 +1,10 @@ +//go:build !windows +// +build !windows + +package backend + +import "os/exec" + +func hideWindow(cmd *exec.Cmd) { + // do nothing on non-windows platforms +} diff --git a/backend/sysproc_windows.go b/backend/sysproc_windows.go new file mode 100644 index 00000000..748fb9e1 --- /dev/null +++ b/backend/sysproc_windows.go @@ -0,0 +1,13 @@ +//go:build windows +// +build windows + +package backend + +import ( + "os/exec" + "syscall" +) + +func hideWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/backend/test/launchcode/dao_prop_test.go b/backend/test/launchcode/dao_prop_test.go new file mode 100644 index 00000000..ceee10cc --- /dev/null +++ b/backend/test/launchcode/dao_prop_test.go @@ -0,0 +1,199 @@ +package launchcode_test + +// Feature: instance-launch-code, Property 2: persistence round-trip +// Validates: Requirements 1.3, 5.1, 5.3 + +import ( + "database/sql" + "fmt" + "os" + "testing" + + "ant-chrome/backend/internal/launchcode" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" + _ "modernc.org/sqlite" +) + +// newTestDB 创建内存 SQLite 数据库并执行建表迁移 +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", "file::memory:?cache=shared&_journal_mode=WAL") + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes ( + profile_id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + t.Fatalf("建表失败: %v", err) + } + _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`) + if err != nil { + t.Fatalf("建索引失败: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +// newFileTestDB 创建基于文件的 SQLite 数据库(用于需要独立隔离的测试) +func newFileTestDB(t *testing.T) *sql.DB { + t.Helper() + f, err := os.CreateTemp("", "launchcode_test_*.db") + if err != nil { + t.Fatalf("创建临时数据库文件失败: %v", err) + } + f.Close() + dbPath := f.Name() + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes ( + profile_id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + t.Fatalf("建表失败: %v", err) + } + _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`) + if err != nil { + t.Fatalf("建索引失败: %v", err) + } + t.Cleanup(func() { + db.Close() + os.Remove(dbPath) + }) + return db +} + +// TestProperty2_PersistenceRoundTrip +// Property 2: 持久化 Round-Trip +// 对于任意 ProfileId 和 LaunchCode,Upsert 后: +// - FindProfileId(code) 返回相同的 profileId +// - FindCode(profileId) 返回相同的 code +func TestProperty2_PersistenceRoundTrip(t *testing.T) { + properties := gopter.NewProperties(gopter.DefaultTestParameters()) + + properties.Property("Upsert 后 FindProfileId 返回正确 profileId", prop.ForAll( + func(profileId, code string) bool { + db := newFileTestDB(t) + dao := launchcode.NewSQLiteLaunchCodeDAO(db) + + if err := dao.Upsert(profileId, code); err != nil { + return false + } + got, err := dao.FindProfileId(code) + return err == nil && got == profileId + }, + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), + gen.RegexMatch(`[A-Z0-9]{6}`), + )) + + properties.Property("Upsert 后 FindCode 返回正确 code", prop.ForAll( + func(profileId, code string) bool { + db := newFileTestDB(t) + dao := launchcode.NewSQLiteLaunchCodeDAO(db) + + if err := dao.Upsert(profileId, code); err != nil { + return false + } + got, err := dao.FindCode(profileId) + return err == nil && got == code + }, + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), + gen.RegexMatch(`[A-Z0-9]{6}`), + )) + + properties.Property("Upsert 幂等:相同 profileId 更新 code 后查询返回新 code", prop.ForAll( + func(profileId, code1, code2 string) bool { + if code1 == code2 { + return true // 跳过相同 code 的情况 + } + db := newFileTestDB(t) + dao := launchcode.NewSQLiteLaunchCodeDAO(db) + + if err := dao.Upsert(profileId, code1); err != nil { + return false + } + if err := dao.Upsert(profileId, code2); err != nil { + return false + } + got, err := dao.FindCode(profileId) + return err == nil && got == code2 + }, + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), + gen.RegexMatch(`[A-Z0-9]{6}`), + gen.RegexMatch(`[A-Z0-9]{6}`), + )) + + properties.TestingRun(t) +} + +// TestProperty2_DeleteRemovesMapping +// Property 2 补充:Delete 后查询应返回 not found +func TestProperty2_DeleteRemovesMapping(t *testing.T) { + properties := gopter.NewProperties(gopter.DefaultTestParameters()) + + properties.Property("Delete 后 FindCode 返回错误", prop.ForAll( + func(profileId, code string) bool { + db := newFileTestDB(t) + dao := launchcode.NewSQLiteLaunchCodeDAO(db) + + if err := dao.Upsert(profileId, code); err != nil { + return false + } + if err := dao.Delete(profileId); err != nil { + return false + } + _, err := dao.FindCode(profileId) + return err != nil + }, + gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), + gen.RegexMatch(`[A-Z0-9]{6}`), + )) + + properties.TestingRun(t) +} + +// TestProperty2_LoadAllRoundTrip +// Property 2 补充:LoadAll 返回所有已写入的映射 +func TestProperty2_LoadAllRoundTrip(t *testing.T) { + db := newTestDB(t) + dao := launchcode.NewSQLiteLaunchCodeDAO(db) + + // 写入一批映射 + entries := map[string]string{} + for i := 0; i < 10; i++ { + profileId := fmt.Sprintf("profile-%02d", i) + code := fmt.Sprintf("CODE%02d", i) + entries[profileId] = code + if err := dao.Upsert(profileId, code); err != nil { + t.Fatalf("Upsert 失败: %v", err) + } + } + + loaded, err := dao.LoadAll() + if err != nil { + t.Fatalf("LoadAll 失败: %v", err) + } + + for profileId, code := range entries { + got, ok := loaded[profileId] + if !ok { + t.Errorf("LoadAll 缺少 profileId=%s", profileId) + continue + } + if got != code { + t.Errorf("LoadAll profileId=%s: 期望 code=%s,实际=%s", profileId, code, got) + } + } +} diff --git a/backend/test/launchcode/server_params_test.go b/backend/test/launchcode/server_params_test.go new file mode 100644 index 00000000..cbdebdb3 --- /dev/null +++ b/backend/test/launchcode/server_params_test.go @@ -0,0 +1,168 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/launchcode" +) + +type mockStarterWithParams struct { + profiles map[string]*browser.Profile + lastProfile string + lastParams launchcode.LaunchRequestParams +} + +func newMockStarterWithParams() *mockStarterWithParams { + return &mockStarterWithParams{profiles: make(map[string]*browser.Profile)} +} + +func (m *mockStarterWithParams) addProfile(p *browser.Profile) { + m.profiles[p.ProfileId] = p +} + +func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profile, error) { + m.lastProfile = profileId + p, ok := m.profiles[profileId] + if !ok { + return nil, http.ErrMissingFile + } + return p, nil +} + +func (m *mockStarterWithParams) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { + m.lastProfile = profileId + m.lastParams = params + p, ok := m.profiles[profileId] + if !ok { + return nil, http.ErrMissingFile + } + return p, nil +} + +func TestLaunchWithParams(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + starter.addProfile(&browser.Profile{ + ProfileId: "profile-automation", + ProfileName: "automation", + Pid: 321, + DebugPort: 9555, + }) + + code, err := svc.EnsureCode("profile-automation") + if err != nil { + t.Fatalf("EnsureCode 失败: %v", err) + } + + handler := buildTestHandler(svc, starter) + body := map[string]interface{}{ + "code": code, + "launchArgs": []string{"--window-size=1280,800", "--lang=en-US"}, + "startUrls": []string{"https://example.com"}, + "skipDefaultStartUrls": true, + } + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != "profile-automation" { + t.Fatalf("profileId 传递错误: %s", starter.lastProfile) + } + if len(starter.lastParams.LaunchArgs) != 2 { + t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs) + } + if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { + t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs) + } + if !starter.lastParams.SkipDefaultStartURLs { + t.Fatal("skipDefaultStartUrls 传递错误") + } +} + +func TestLaunchWithParamsBadRequest(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + handler := buildTestHandler(svc, starter) + + t.Run("invalid-json", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString("{bad json}")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d", w.Code) + } + }) + + t.Run("missing-code", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"launchArgs":["--incognito"]}`)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d", w.Code) + } + }) +} + +func TestLaunchLogsEndpoint(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + starter.addProfile(&browser.Profile{ + ProfileId: "profile-log-test", + ProfileName: "log-test", + Pid: 456, + DebugPort: 9666, + }) + + code, err := svc.EnsureCode("profile-log-test") + if err != nil { + t.Fatalf("EnsureCode 失败: %v", err) + } + + handler := buildTestHandler(svc, starter) + payload := bytes.NewBufferString(`{"code":"` + code + `","launchArgs":["--incognito"]}`) + + reqLaunch := httptest.NewRequest(http.MethodPost, "/api/launch", payload) + reqLaunch.Header.Set("Content-Type", "application/json") + wLaunch := httptest.NewRecorder() + handler.ServeHTTP(wLaunch, reqLaunch) + if wLaunch.Code != http.StatusOK { + t.Fatalf("调用 launch 失败: %d", wLaunch.Code) + } + + reqLogs := httptest.NewRequest(http.MethodGet, "/api/launch/logs?limit=10", nil) + wLogs := httptest.NewRecorder() + handler.ServeHTTP(wLogs, reqLogs) + if wLogs.Code != http.StatusOK { + t.Fatalf("查询 logs 失败: %d", wLogs.Code) + } + + var resp struct { + OK bool `json:"ok"` + Items []launchcode.LaunchCallRecord `json:"items"` + } + if err := json.NewDecoder(wLogs.Body).Decode(&resp); err != nil { + t.Fatalf("解析 logs 响应失败: %v", err) + } + if !resp.OK { + t.Fatal("logs 响应 ok=false") + } + if len(resp.Items) == 0 { + t.Fatal("logs 为空,期望至少一条记录") + } + if resp.Items[0].Path != "/api/launch" { + t.Fatalf("最新记录 path 不正确: %s", resp.Items[0].Path) + } +} diff --git a/backend/test/launchcode/server_prop_test.go b/backend/test/launchcode/server_prop_test.go new file mode 100644 index 00000000..80cb2e0d --- /dev/null +++ b/backend/test/launchcode/server_prop_test.go @@ -0,0 +1,274 @@ +package launchcode_test + +// Feature: instance-launch-code, Property 6: valid code response structure +// Feature: instance-launch-code, Property 7: invalid code returns 404 +// Feature: instance-launch-code, Property 8: idempotent launch +// Validates: Requirements 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.4 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/launchcode" + + "github.com/leanovate/gopter" + "github.com/leanovate/gopter/gen" + "github.com/leanovate/gopter/prop" +) + +// --- 测试辅助类型 --- + +// mockStarter 模拟 BrowserStarter,记录调用次数 +type mockStarter struct { + profiles map[string]*browser.Profile + callCounts map[string]int +} + +func newMockStarter() *mockStarter { + return &mockStarter{ + profiles: make(map[string]*browser.Profile), + callCounts: make(map[string]int), + } +} + +func (m *mockStarter) addProfile(p *browser.Profile) { + m.profiles[p.ProfileId] = p +} + +func (m *mockStarter) StartInstance(profileId string) (*browser.Profile, error) { + m.callCounts[profileId]++ + p, ok := m.profiles[profileId] + if !ok { + return nil, fmt.Errorf("profile not found: %s", profileId) + } + return p, nil +} + +// buildTestHandler 构建一个可直接用于 httptest 的 handler(绕过 localhost 中间件) +// 通过直接调用 server 内部 handler 的方式,使用 httptest.NewRecorder 测试路由逻辑 +func buildTestHandler(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter) http.Handler { + srv := launchcode.NewLaunchServer(svc, starter, nil, 0) + return launchcode.NewTestHandler(srv) +} + +// newInMemoryService 创建一个使用内存 DAO 的 LaunchCodeService +func newInMemoryService() *launchcode.LaunchCodeService { + dao := launchcode.NewMemoryLaunchCodeDAO() + return launchcode.NewLaunchCodeService(dao) +} + +// --- Property 6: 有效 Code 返回正确响应结构 --- + +// genNonEmptyAlpha 生成长度 1-32 的字母字符串(不使用 SuchThat 过滤) +func genNonEmptyAlpha() gopter.Gen { + return gen.SliceOfN(8, gen.RuneRange('a', 'z')).Map(func(runes []rune) string { + return string(runes) + }) +} + +// TestProperty6_ValidCodeResponseStructure +// 对于任意存在的 LaunchCode,GET /api/launch/{code} 应返回: +// - HTTP 200 +// - Content-Type: application/json +// - 响应体含 ok:true, profileId, profileName, pid, debugPort +func TestProperty6_ValidCodeResponseStructure(t *testing.T) { + properties := gopter.NewProperties(gopter.DefaultTestParameters()) + + properties.Property("有效 code 返回 200 及正确响应结构", prop.ForAll( + func(profileId, profileName string, pid, debugPort int) bool { + svc := newInMemoryService() + starter := newMockStarter() + + profile := &browser.Profile{ + ProfileId: profileId, + ProfileName: profileName, + Pid: pid, + DebugPort: debugPort, + } + starter.addProfile(profile) + + code, err := svc.EnsureCode(profileId) + if err != nil { + return false + } + + handler := buildTestHandler(svc, starter) + req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + return false + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/json") { + return false + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + return false + } + + ok, _ := resp["ok"].(bool) + gotProfileId, _ := resp["profileId"].(string) + gotProfileName, _ := resp["profileName"].(string) + _, hasPid := resp["pid"] + _, hasDebugPort := resp["debugPort"] + + return ok && + gotProfileId == profileId && + gotProfileName == profileName && + hasPid && hasDebugPort + }, + genNonEmptyAlpha(), + genNonEmptyAlpha(), + gen.IntRange(1000, 99999), + gen.IntRange(9000, 9999), + )) + + properties.TestingRun(t) +} + +// --- Property 7: 无效 Code 返回 404 --- + +// genInvalidCode 生成一定不存在于空 service 中的 code(小写字母,不符合 A-Z0-9 格式) +func genInvalidCode() gopter.Gen { + // 生成 4 位小写字母字符串,永远不会匹配 [A-Z0-9]{6} 格式的有效 code + return gen.SliceOfN(4, gen.RuneRange('a', 'z')).Map(func(runes []rune) string { + return string(runes) + }) +} + +// TestProperty7_InvalidCodeReturns404 +// 对于任意不存在的 code,GET /api/launch/{code} 应返回: +// - HTTP 404 +// - Content-Type: application/json +// - 响应体含 ok:false 和 error 字段 +func TestProperty7_InvalidCodeReturns404(t *testing.T) { + properties := gopter.NewProperties(gopter.DefaultTestParameters()) + + properties.Property("不存在的 code 返回 404", prop.ForAll( + func(code string) bool { + svc := newInMemoryService() + starter := newMockStarter() + + handler := buildTestHandler(svc, starter) + req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + return false + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/json") { + return false + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + return false + } + + ok, _ := resp["ok"].(bool) + _, hasError := resp["error"] + return !ok && hasError + }, + genInvalidCode(), + )) + + properties.TestingRun(t) +} + +// --- Property 8: 重复唤起的幂等性 --- + +// TestProperty8_IdempotentLaunch +// 对于已运行的实例,连续两次 GET /api/launch/{code}: +// - 两次均返回 HTTP 200 +// - 两次返回的 pid 相同(不重新启动) +func TestProperty8_IdempotentLaunch(t *testing.T) { + properties := gopter.NewProperties(gopter.DefaultTestParameters()) + + properties.Property("重复唤起返回相同 pid,不重新启动", prop.ForAll( + func(profileId string, pid int) bool { + svc := newInMemoryService() + starter := newMockStarter() + + profile := &browser.Profile{ + ProfileId: profileId, + ProfileName: "test-profile", + Pid: pid, + DebugPort: 9222, + Running: true, + } + starter.addProfile(profile) + + code, err := svc.EnsureCode(profileId) + if err != nil { + return false + } + + handler := buildTestHandler(svc, starter) + + // 第一次请求 + req1 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + w1 := httptest.NewRecorder() + handler.ServeHTTP(w1, req1) + + // 第二次请求 + req2 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + w2 := httptest.NewRecorder() + handler.ServeHTTP(w2, req2) + + if w1.Code != http.StatusOK || w2.Code != http.StatusOK { + return false + } + + var resp1, resp2 map[string]interface{} + if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil { + return false + } + if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil { + return false + } + + pid1, _ := resp1["pid"].(float64) + pid2, _ := resp2["pid"].(float64) + + // 两次 pid 相同,且 StartInstance 被调用了 2 次(幂等由 starter 保证返回同一 profile) + return pid1 == pid2 && pid1 == float64(pid) + }, + genNonEmptyAlpha(), + gen.IntRange(1000, 99999), + )) + + properties.TestingRun(t) +} + +// --- 健康检查单元测试 --- + +func TestHealthEndpoint(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarter() + handler := buildTestHandler(svc, starter) + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d", w.Code) + } + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + ok, _ := resp["ok"].(bool) + if !ok { + t.Error("期望 ok=true") + } +} diff --git a/backend/test/launchcode/service_setcode_test.go b/backend/test/launchcode/service_setcode_test.go new file mode 100644 index 00000000..cdd803cf --- /dev/null +++ b/backend/test/launchcode/service_setcode_test.go @@ -0,0 +1,47 @@ +package launchcode_test + +import ( + "strings" + "testing" + + "ant-chrome/backend/internal/launchcode" +) + +func TestSetCodeAndResolveCaseInsensitive(t *testing.T) { + svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + code, err := svc.SetCode("p1", "demo_code") + if err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + if code != "DEMO_CODE" { + t.Fatalf("期望 DEMO_CODE,实际 %s", code) + } + + profileID, err := svc.Resolve("demo_code") + if err != nil { + t.Fatalf("Resolve 失败: %v", err) + } + if profileID != "p1" { + t.Fatalf("期望 p1,实际 %s", profileID) + } +} + +func TestSetCodeConflict(t *testing.T) { + svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + if _, err := svc.SetCode("p1", "AAA111"); err != nil { + t.Fatalf("SetCode p1 失败: %v", err) + } + if _, err := svc.SetCode("p2", "AAA111"); err == nil { + t.Fatal("期望 code 冲突时报错") + } +} + +func TestSetCodeValidation(t *testing.T) { + svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + cases := []string{"", "a", "ab", "中文123", "abc!123", strings.Repeat("A", 40)} + for _, c := range cases { + if _, err := svc.SetCode("p1", c); err == nil { + t.Fatalf("期望非法 code 报错: %q", c) + } + } +} diff --git a/backend/test/proxy/speedtest_debug_test.go b/backend/test/proxy/speedtest_debug_test.go new file mode 100644 index 00000000..0f1301f4 --- /dev/null +++ b/backend/test/proxy/speedtest_debug_test.go @@ -0,0 +1,119 @@ +package proxy_test + +import ( + "fmt" + "strings" + "testing" + + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/proxy" +) + +// 模拟数据库中实际存储的 Clash YAML 格式代理配置 +var testTrojanConfig = `- name: HK01|香港|x1.0 + type: trojan + server: trojan.example.com + port: 443 + password: example-password + udp: true + skip-cert-verify: true + network: tcp` + +var testVmessConfig = `- name: DE-Vmess(NL1) 1x + type: vmess + server: 203.0.113.55 + port: 443 + uuid: 11111111-1111-4111-8111-111111111111 + alterId: 0 + cipher: auto + udp: true + tls: true + skip-cert-verify: false + servername: vmess.example.com + network: ws + ws-opts: + path: / + headers: + Host: vmess.example.com` + +var testHysteria2Config = `- name: Hysteria Japan Pluse | 0.1x + server: hy2.example.com + port: 443 + sni: hy2.example.com + up: 102400 + down: 102400 + skip-cert-verify: true + ports: 10800-10888 + type: hysteria2 + password: example-password` + +func TestProtocolDetection(t *testing.T) { + tests := []struct { + name string + config string + }{ + {"trojan-clash", testTrojanConfig}, + {"vmess-clash", testVmessConfig}, + {"hysteria2-clash", testHysteria2Config}, + {"socks5-direct", "socks5://127.0.0.1:1080"}, + {"http-direct", "http://127.0.0.1:7890"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := strings.TrimSpace(tt.config) + l := strings.ToLower(src) + + isSingBox := proxy.IsSingBoxProtocol(src) + requiresBridge := proxy.RequiresBridge(src, nil, "") + isDirectHTTP := strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") + isDirectSocks := strings.HasPrefix(l, "socks5://") + + fmt.Printf("\n=== %s ===\n", tt.name) + fmt.Printf(" config前30字符: %q\n", src[:minInt(30, len(src))]) + fmt.Printf(" IsSingBoxProtocol: %v\n", isSingBox) + fmt.Printf(" RequiresBridge: %v\n", requiresBridge) + fmt.Printf(" isDirectHTTP: %v\n", isDirectHTTP) + fmt.Printf(" isDirectSocks: %v\n", isDirectSocks) + + // 测试 ParseProxyNode + standardProxy, outbound, err := proxy.ParseProxyNode(src) + fmt.Printf(" ParseProxyNode:\n") + fmt.Printf(" standardProxy: %q\n", standardProxy) + fmt.Printf(" outbound nil?: %v\n", outbound == nil) + fmt.Printf(" error: %v\n", err) + + if !isSingBox && !requiresBridge && !isDirectHTTP && !isDirectSocks { + t.Errorf("代理配置未被任何分支识别!会走到兜底逻辑") + } + }) + } +} + +func TestSpeedTestWithMockProxies(t *testing.T) { + // 模拟 a.config.Browser.Proxies 的内容 + proxies := []config.BrowserProxy{ + {ProxyId: "test-trojan", ProxyName: "测试trojan", ProxyConfig: testTrojanConfig}, + {ProxyId: "test-vmess", ProxyName: "测试vmess", ProxyConfig: testVmessConfig}, + {ProxyId: "test-hysteria2", ProxyName: "测试hysteria2", ProxyConfig: testHysteria2Config}, + {ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://127.0.0.1:7890"}, + } + + for _, p := range proxies { + t.Run(p.ProxyName, func(t *testing.T) { + // 不传 xrayMgr/singboxMgr,看会走到哪个分支 + result := proxy.SpeedTest(p.ProxyId, proxies, nil, nil, nil) + fmt.Printf("\n=== SpeedTest %s ===\n", p.ProxyName) + fmt.Printf(" Ok: %v\n", result.Ok) + fmt.Printf(" LatencyMs: %d\n", result.LatencyMs) + fmt.Printf(" Error: %q\n", result.Error) + }) + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/test/proxy/trojan_test.go b/backend/test/proxy/trojan_test.go new file mode 100644 index 00000000..75a1e814 --- /dev/null +++ b/backend/test/proxy/trojan_test.go @@ -0,0 +1,228 @@ +package proxy_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "ant-chrome/backend/internal/proxy" +) + +func TestTrojanClashYAML(t *testing.T) { + node := `- name: JP02|日本|x1.0 + type: trojan + server: trojan.example.com + port: 443 + password: example-password + udp: true + skip-cert-verify: true + network: tcp` + + standard, outbound, err := proxy.ParseProxyNode(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if standard != "" { + t.Fatalf("期望 outbound,得到 standard: %s", standard) + } + data, _ := json.MarshalIndent(outbound, "", " ") + t.Logf("trojan clash outbound:\n%s", string(data)) + + if outbound["protocol"] != "trojan" { + t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"]) + } + settings := outbound["settings"].(map[string]interface{}) + if settings["address"] != "trojan.example.com" { + t.Errorf("address 不匹配: %v", settings["address"]) + } + if settings["password"] != "example-password" { + t.Errorf("password 不匹配: %v", settings["password"]) + } + stream := outbound["streamSettings"].(map[string]interface{}) + if stream["security"] != "tls" { + t.Errorf("security 期望 tls,得到 %v", stream["security"]) + } + tls := stream["tlsSettings"].(map[string]interface{}) + if tls["allowInsecure"] != true { + t.Errorf("allowInsecure 期望 true,得到 %v", tls["allowInsecure"]) + } +} + +func TestTrojanURI(t *testing.T) { + node := "trojan://mypassword@example.com:443?sni=example.com&allowInsecure=1" + _, outbound, err := proxy.ParseProxyNode(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if outbound["protocol"] != "trojan" { + t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"]) + } + settings := outbound["settings"].(map[string]interface{}) + if settings["address"] != "example.com" { + t.Errorf("address 不匹配: %v", settings["address"]) + } + if settings["password"] != "mypassword" { + t.Errorf("password 不匹配: %v", settings["password"]) + } +} + +func TestSSClashYAML(t *testing.T) { + node := `- name: SS节点 + type: ss + server: 1.2.3.4 + port: 8388 + cipher: aes-256-gcm + password: testpassword` + + _, outbound, err := proxy.ParseProxyNode(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + data, _ := json.MarshalIndent(outbound, "", " ") + t.Logf("SS clash outbound:\n%s", string(data)) + + if outbound["protocol"] != "shadowsocks" { + t.Errorf("protocol 期望 shadowsocks,得到 %v", outbound["protocol"]) + } + settings := outbound["settings"].(map[string]interface{}) + if settings["address"] != "1.2.3.4" { + t.Errorf("address 不匹配: %v", settings["address"]) + } + if settings["method"] != "aes-256-gcm" { + t.Errorf("method 不匹配: %v", settings["method"]) + } + if settings["password"] != "testpassword" { + t.Errorf("password 不匹配: %v", settings["password"]) + } +} + +func TestSSURI_SIP002(t *testing.T) { + // SIP002: ss://BASE64(method:password)@host:port#name + userInfo := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:mypassword")) + node := fmt.Sprintf("ss://%s@1.2.3.4:8388#测试节点", userInfo) + _, outbound, err := proxy.ParseProxyNode(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + data, _ := json.MarshalIndent(outbound, "", " ") + t.Logf("SS SIP002 outbound:\n%s", string(data)) + + settings := outbound["settings"].(map[string]interface{}) + if settings["method"] != "aes-256-gcm" { + t.Errorf("method 不匹配: %v", settings["method"]) + } + if settings["password"] != "mypassword" { + t.Errorf("password 不匹配: %v", settings["password"]) + } + if settings["address"] != "1.2.3.4" { + t.Errorf("address 不匹配: %v", settings["address"]) + } +} + +func TestSSURI_Legacy(t *testing.T) { + // 旧格式: ss://BASE64(method:password@host:port) + raw := base64.StdEncoding.EncodeToString([]byte("chacha20-ietf-poly1305:pass123@2.3.4.5:443")) + node := "ss://" + raw + _, outbound, err := proxy.ParseProxyNode(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + settings := outbound["settings"].(map[string]interface{}) + if settings["method"] != "chacha20-ietf-poly1305" { + t.Errorf("method 不匹配: %v", settings["method"]) + } + if settings["address"] != "2.3.4.5" { + t.Errorf("address 不匹配: %v", settings["address"]) + } + t.Logf("SS legacy outbound OK: %v", settings) +} + +func TestSSR_Unsupported(t *testing.T) { + node := "ssr://somebase64data" + _, _, err := proxy.ParseProxyNode(node) + if err == nil { + t.Fatal("期望 SSR 返回错误,但没有") + } + t.Logf("SSR 正确返回错误: %v", err) +} + +func TestHysteria2URI(t *testing.T) { + node := "hysteria2://mypassword@example.com:443?sni=example.com&insecure=1" + outbound, err := proxy.BuildSingBoxOutbound(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + data, _ := json.MarshalIndent(outbound, "", " ") + t.Logf("hysteria2 URI outbound:\n%s", string(data)) + + if outbound["type"] != "hysteria2" { + t.Errorf("type 期望 hysteria2,得到 %v", outbound["type"]) + } + if outbound["server"] != "example.com" { + t.Errorf("server 不匹配: %v", outbound["server"]) + } + if outbound["password"] != "mypassword" { + t.Errorf("password 不匹配: %v", outbound["password"]) + } + tls := outbound["tls"].(map[string]interface{}) + if tls["insecure"] != true { + t.Errorf("insecure 期望 true,得到 %v", tls["insecure"]) + } +} + +func TestHysteria2ClashYAML(t *testing.T) { + node := `- name: HY2节点 + type: hysteria2 + server: example.com + port: 443 + password: testpass + sni: example.com + skip-cert-verify: false` + + outbound, err := proxy.BuildSingBoxOutbound(node) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + data, _ := json.MarshalIndent(outbound, "", " ") + t.Logf("hysteria2 clash outbound:\n%s", string(data)) + + if outbound["type"] != "hysteria2" { + t.Errorf("type 期望 hysteria2,得到 %v", outbound["type"]) + } + if outbound["server"] != "example.com" { + t.Errorf("server 不匹配: %v", outbound["server"]) + } + tls := outbound["tls"].(map[string]interface{}) + if tls["server_name"] != "example.com" { + t.Errorf("server_name 不匹配: %v", tls["server_name"]) + } +} + +func TestIsSingBoxProtocol(t *testing.T) { + cases := []struct { + input string + expected bool + }{ + {"hysteria2://pass@host:443", true}, + {"hysteria://pass@host:443", true}, + {"- name: n\n type: hysteria2\n server: h\n port: 443", true}, + {"- name: n\n type: tuic\n server: h\n port: 443", true}, + {"vmess://xxx", false}, + {"trojan://pass@host:443", false}, + {"socks5://127.0.0.1:1080", false}, + } + for _, c := range cases { + got := proxy.IsSingBoxProtocol(c.input) + if got != c.expected { + t.Errorf("IsSingBoxProtocol(%q) = %v, 期望 %v", c.input[:min(30, len(c.input))], got, c.expected) + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/bat/README.md b/bat/README.md new file mode 100644 index 00000000..c1fefe0d --- /dev/null +++ b/bat/README.md @@ -0,0 +1,43 @@ +# bat + +## 用途 + +- `dev.bat`:本地开发启动 +- `build.bat`:本地构建可执行文件 +- `publish.bat`:打包发布安装包 + +## 用法 + +### `dev.bat` + +适合日常开发。 + +```bat +bat\dev.bat +``` + +### `build.bat` + +构建 `build\bin\ant-chrome.exe`。 + +```bat +bat\build.bat +``` + +### `publish.bat` + +生成安装包,需要先安装 NSIS。 + +```bat +bat\publish.bat +``` + +产物: + +```text +publish\output\AntBrowser-Setup-.exe +``` + +## 备注 + +- `generate-bindings.bat` 是辅助脚本,通常由 `build.bat` 调用。 diff --git a/bat/build.bat b/bat/build.bat new file mode 100644 index 00000000..b286be9c --- /dev/null +++ b/bat/build.bat @@ -0,0 +1,148 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +REM 切换到项目根目录(脚本所在目录的上一级) +cd /d "%~dp0.." + +echo ======================================== +echo Ant Browser - Wails 构建脚本 +echo ======================================== +echo. +echo 当前工作目录: %CD% +echo. + +REM ======== 代理配置 ======== +REM 本地代理地址(例如 Clash、V2Ray 等) +set PROXY_HOST=127.0.0.1 +set PROXY_PORT=7890 +set USE_PROXY=1 + +REM 如果不需要使用代理,将 USE_PROXY 设置为 0 +REM set USE_PROXY=0 + +REM 设置代理环境变量 +if "%USE_PROXY%"=="1" ( + echo [0/7] 正在配置代理... + set HTTP_PROXY=http://%PROXY_HOST%:%PROXY_PORT% + set HTTPS_PROXY=http://%PROXY_HOST%:%PROXY_PORT% + set http_proxy=http://%PROXY_HOST%:%PROXY_PORT% + set https_proxy=http://%PROXY_HOST%:%PROXY_PORT% + + REM 配置 npm 代理 + call npm config set proxy http://%PROXY_HOST%:%PROXY_PORT% 2>nul + call npm config set https-proxy http://%PROXY_HOST%:%PROXY_PORT% 2>nul + + REM 配置 Go 代理环境变量 + set GOPROXY=https://goproxy.cn,direct + + echo ✓ 代理已配置: %PROXY_HOST%:%PROXY_PORT% + echo. +) + +REM 定义清理函数(用于恢复代理设置) +goto :skip_cleanup_function +:cleanup +if "%USE_PROXY%"=="1" ( + echo. + echo [清理代理配置...] + call npm config delete proxy 2>nul + call npm config delete https-proxy 2>nul + echo ✓ 代理配置已清理 +) +exit /b +:skip_cleanup_function + + +echo [1/7] 安装前端依赖... +cd frontend +call npm install +if %errorlevel% neq 0 ( + echo ✗ 安装前端依赖失败 + cd .. + call :cleanup + pause + exit /b 1 +) +cd .. + +echo. +echo [2/7] 安装 Go 依赖... +go mod download +go mod tidy +if %errorlevel% neq 0 ( + echo ✗ 安装 Go 依赖失败 + call :cleanup + pause + exit /b 1 +) + +echo. +echo [3/7] 创建临时 dist 目录... +if not exist "frontend\dist" ( + mkdir "frontend\dist" + echo. > "frontend\dist\index.html" + echo ✓ 临时 dist 目录已创建 +) else ( + echo ✓ dist 目录已存在 +) + +echo. +echo [4/7] 生成 Wails 绑定文件... +call bat\generate-bindings.bat --no-pause +if %errorlevel% neq 0 ( + echo ✗ 生成绑定文件失败 + call :cleanup + pause + exit /b 1 +) + +echo. +echo [5/7] 构建前端项目... +REM 清理临时 dist 目录 +if exist "frontend\dist" ( + rmdir /S /Q "frontend\dist" 2>nul + echo ✓ 临时 dist 目录已清理 +) +cd frontend +call npm run build +if %errorlevel% neq 0 ( + echo ✗ 构建前端失败 + cd .. + call :cleanup + pause + exit /b 1 +) +cd .. + +echo. +echo [6/7] 构建应用... +wails build +if %errorlevel% neq 0 ( + echo ✗ 构建失败 + call :cleanup + pause + exit /b 1 +) + +echo. +echo [7/7] 复制运行时依赖... +if exist "bin" ( + xcopy /E /I /Y bin build\bin\bin >nul + echo ✓ bin 目录已复制到 build\bin\bin\ +) else ( + echo [Warn] bin 目录不存在,跳过复制 +) + +echo. +echo ======================================== +echo ✓ 构建成功! +echo ======================================== +echo. +echo 可执行文件位置: build\bin\ant-chrome.exe +echo. + +REM 清理代理配置 +call :cleanup + +pause diff --git a/bat/dev.bat b/bat/dev.bat new file mode 100644 index 00000000..ee266f20 --- /dev/null +++ b/bat/dev.bat @@ -0,0 +1,167 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +cd /d "%~dp0.." + +echo ======================================== +echo Ant Chrome - Dev Launcher +echo ======================================== +echo. +echo Current workdir: %CD% +echo. + +call :cleanup_dev_logs + +echo Cleaning stale processes... +taskkill /F /IM ant-chrome-dev.exe >nul 2>&1 +taskkill /F /IM ant-chrome.exe >nul 2>&1 +echo. + +set FRONTEND_PORT=5218 +set PORT_ERROR=0 +set TEMP_DEV_DIST_CREATED=0 +set TEMP_DEV_PLACEHOLDER_CREATED=0 + +call :cleanup_local_vite_port %FRONTEND_PORT% + +echo Checking port status... +call :check_port %FRONTEND_PORT% + +if "!PORT_ERROR!"=="1" ( + echo. + echo Please close the process using the occupied port and retry. + pause + exit /b 1 +) +echo. + +set GOPROXY=https://goproxy.cn,direct + +echo Checking dependencies... +if not exist "go.sum" ( + echo Installing Go dependencies... + go mod download + go mod tidy +) + +if not exist "frontend\node_modules" ( + echo Installing frontend dependencies... + pushd frontend + call npm install + popd +) +echo. + +echo Regenerating Wails bindings... +if not exist "frontend\dist" ( + mkdir "frontend\dist" + set TEMP_DEV_DIST_CREATED=1 +) +if not exist "frontend\dist\__wails_placeholder__.txt" ( + echo placeholder> "frontend\dist\__wails_placeholder__.txt" + set TEMP_DEV_PLACEHOLDER_CREATED=1 +) +wails generate module +if errorlevel 1 ( + call :cleanup_temp_dist + echo [ERROR] Failed to generate Wails bindings. + pause + exit /b 1 +) + +if exist "frontend\wailsjs" ( + xcopy /E /I /Y "frontend\wailsjs" "frontend\src\wailsjs" >nul +) +if not exist "frontend\src\wailsjs" ( + call :cleanup_temp_dist + echo [ERROR] Wails bindings output folder not found. + pause + exit /b 1 +) +call :cleanup_temp_dist +echo. + +echo Starting dev server... +echo Frontend URL: http://127.0.0.1:%FRONTEND_PORT% +echo Wails dev endpoint: auto-select +echo. + +wails dev -viteservertimeout 60 +set EXIT_CODE=%errorlevel% + +if not "%EXIT_CODE%"=="0" ( + echo. + echo [ERROR] wails dev exited with code %EXIT_CODE%. +) + +pause +exit /b %EXIT_CODE% + +:cleanup_temp_dist +if "%TEMP_DEV_PLACEHOLDER_CREATED%"=="1" ( + del /F /Q "frontend\dist\__wails_placeholder__.txt" >nul 2>&1 +) +if "%TEMP_DEV_DIST_CREATED%"=="1" ( + rmdir /S /Q "frontend\dist" >nul 2>&1 +) +exit /b 0 + +:cleanup_dev_logs +for %%f in ( + "tmp-npm-dev.err.log" + "tmp-npm-dev.log" + "tmp-wails-err.log" + "tmp-wails-out.log" + "tmp-wails2-err.log" + "tmp-wails2-out.log" + "tmp-wails3-err.log" + "tmp-wails3-out.log" + "tmp-wails.err" + "wails-dev-capture.log" + "wails-dev-run.log" + "wails-dev-stderr.log" + "wails-dev-stdout.log" +) do ( + if exist %%~f del /F /Q %%~f >nul 2>&1 +) +exit /b 0 + +:cleanup_local_vite_port +set "CHECK_PORT=%~1" +set "CHECK_PID=" +set "CHECK_CMDLINE=" +for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$port=%CHECK_PORT%; $procId=(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess); if($procId){Write-Output $procId}"`) do ( + set "CHECK_PID=%%a" +) +if not defined CHECK_PID exit /b 0 + +for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$line=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq %CHECK_PID% } | Select-Object -First 1 -ExpandProperty CommandLine; if($line){Write-Output $line}"`) do ( + set "CHECK_CMDLINE=%%a" +) + +echo !CHECK_CMDLINE! | findstr /I /C:"%CD%\frontend" >nul +set "MATCH_PROJECT=!errorlevel!" +echo !CHECK_CMDLINE! | findstr /I /C:"vite" >nul +set "MATCH_VITE=!errorlevel!" + +if "!MATCH_PROJECT!"=="0" if "!MATCH_VITE!"=="0" ( + echo Cleaning stale local Vite process on port %CHECK_PORT% ^(PID !CHECK_PID!^)... + taskkill /F /PID !CHECK_PID! /T >nul 2>&1 + timeout /t 1 /nobreak >nul +) +exit /b 0 + +:check_port +set "CHECK_PORT=%~1" +set "CHECK_PID=" +for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$port=%CHECK_PORT%; $procId=(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess); if($procId){Write-Output $procId}"`) do ( + set "CHECK_PID=%%a" +) + +if defined CHECK_PID ( + set PORT_ERROR=1 + echo [ERROR] Port %CHECK_PORT% is occupied. PID: !CHECK_PID! +) else ( + echo [OK] Port %CHECK_PORT% is available. +) +exit /b 0 diff --git a/bat/generate-bindings.bat b/bat/generate-bindings.bat new file mode 100644 index 00000000..836621e6 --- /dev/null +++ b/bat/generate-bindings.bat @@ -0,0 +1,80 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +REM Change to repository root (parent directory of this script). +cd /d "%~dp0.." + +set "NO_PAUSE=0" +if /I "%~1"=="--no-pause" set "NO_PAUSE=1" + +set "TEMP_DIST_CREATED=0" +set "TEMP_PLACEHOLDER_CREATED=0" + +echo ======================================== +echo Generate Wails Bindings +echo ======================================== +echo. +echo Working directory: %CD% +echo. + +echo [1/3] Ensure frontend\dist exists... +if not exist "frontend\dist" ( + mkdir "frontend\dist" + set "TEMP_DIST_CREATED=1" + echo Created temporary dist directory. +) else ( + echo Dist directory already exists. +) + +if not exist "frontend\dist\__wails_placeholder__.txt" ( + echo placeholder> "frontend\dist\__wails_placeholder__.txt" + set "TEMP_PLACEHOLDER_CREATED=1" + echo Created temporary placeholder file. +) + +echo. +echo [2/3] Regenerating Wails bindings... +wails generate module +if errorlevel 1 ( + echo Failed to regenerate Wails bindings. + goto :cleanup_fail +) + +echo. +echo [3/3] Verify bindings output... +if exist "frontend\wailsjs" ( + xcopy /E /I /Y "frontend\wailsjs" "frontend\src\wailsjs" >nul + echo Bindings copied from frontend\wailsjs to frontend\src\wailsjs. +) else if exist "frontend\src\wailsjs" ( + echo Bindings already generated in frontend\src\wailsjs. +) else ( + echo Cannot find generated bindings in frontend\wailsjs or frontend\src\wailsjs. + goto :cleanup_fail +) + +call :cleanup + +echo. +echo ======================================== +echo Done +echo ======================================== +echo. + +if not "%NO_PAUSE%"=="1" pause +exit /b 0 + +:cleanup +if "!TEMP_PLACEHOLDER_CREATED!"=="1" ( + del /Q "frontend\dist\__wails_placeholder__.txt" >nul 2>&1 +) +if "!TEMP_DIST_CREATED!"=="1" ( + rmdir /S /Q "frontend\dist" >nul 2>&1 +) +exit /b 0 + +:cleanup_fail +call :cleanup +if not "%NO_PAUSE%"=="1" pause +exit /b 1 + diff --git a/bat/publish.bat b/bat/publish.bat new file mode 100644 index 00000000..5b7b954d --- /dev/null +++ b/bat/publish.bat @@ -0,0 +1,248 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +REM 切换到项目根目录(脚本所在目录的上一级) +cd /d "%~dp0.." + +echo ======================================== +echo Ant Browser - 发布打包脚本 +echo ======================================== +echo. +echo 当前工作目录: %CD% +echo. + +REM ======== [1/6] 检测 NSIS ======== +echo [1/6] 检测 NSIS 安装... +echo 支持环境变量:MAKENSIS_PATH / NSIS_PATH / NSIS_HOME +echo. + +set "MAKENSIS=" + +REM 优先级 1:MAKENSIS_PATH 直接指向 makensis.exe +if defined MAKENSIS_PATH ( + if exist "!MAKENSIS_PATH!" ( + set "MAKENSIS=!MAKENSIS_PATH!" + goto :nsis_found + ) +) + +REM 优先级 2:NSIS_PATH 可以是 makensis.exe 或 NSIS 目录 +if defined NSIS_PATH ( + if exist "!NSIS_PATH!\makensis.exe" ( + set "MAKENSIS=!NSIS_PATH!\makensis.exe" + goto :nsis_found + ) + if exist "!NSIS_PATH!" ( + set "MAKENSIS=!NSIS_PATH!" + goto :nsis_found + ) +) + +REM 优先级 3:NSIS_HOME 为 NSIS 安装根目录 +if defined NSIS_HOME ( + if exist "!NSIS_HOME!\makensis.exe" ( + set "MAKENSIS=!NSIS_HOME!\makensis.exe" + goto :nsis_found + ) +) + +REM 优先级 4:系统 PATH +for /f "delims=" %%i in ('where makensis.exe 2^>nul') do ( + set "MAKENSIS=%%i" + goto :nsis_found +) + +REM 优先级 5:常见安装目录 +if exist "C:\Program Files (x86)\NSIS\makensis.exe" ( + set "MAKENSIS=C:\Program Files (x86)\NSIS\makensis.exe" + goto :nsis_found +) +if exist "C:\Program Files\NSIS\makensis.exe" ( + set "MAKENSIS=C:\Program Files\NSIS\makensis.exe" + goto :nsis_found +) + +echo ✗ 未找到 NSIS(makensis.exe) +echo. +echo 请安装 NSIS 后,通过以下任一方式配置(PowerShell): +echo setx MAKENSIS_PATH "D:\tools\NSIS\makensis.exe" +echo setx NSIS_PATH "D:\tools\NSIS" +echo setx NSIS_HOME "D:\tools\NSIS" +echo. +echo 或下载安装:https://nsis.sourceforge.io/Download +echo. +pause +exit /b 1 + +:nsis_found +echo ✓ NSIS 已就绪: !MAKENSIS! +echo. + +REM ======== [2/6] 读取版本号 ======== +echo [2/6] 读取版本号... + +set "VERSION=" +for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content wails.json | ConvertFrom-Json).info.productVersion"`) do ( + set "VERSION=%%v" +) + +if "!VERSION!"=="" ( + echo ✗ 无法从 wails.json 读取版本号 + pause + exit /b 1 +) +echo ✓ 版本号: !VERSION! +echo. + +REM ======== [3/6] Wails 构建 ======== +echo [3/6] 执行 Wails 构建... + +set GOPROXY=https://goproxy.cn,direct +wails build +if %errorlevel% neq 0 ( + echo ✗ Wails 构建失败 + pause + exit /b 1 +) + +if not exist "build\bin\ant-chrome.exe" ( + echo ✗ 构建产物不存在: build\bin\ant-chrome.exe + pause + exit /b 1 +) +echo ✓ 构建成功: build\bin\ant-chrome.exe +echo. + +REM ======== [4/6] 组装 staging 目录 ======== +echo [4/6] 组装 staging 目录... + +set "STAGING=publish\staging" +set "RELEASE_CONFIG=publish\config.init.yaml" + +if exist "!STAGING!" rmdir /S /Q "!STAGING!" +mkdir "!STAGING!" + +copy /Y "build\bin\ant-chrome.exe" "!STAGING!\ant-chrome.exe" >nul +if errorlevel 1 ( + echo ✗ 复制 ant-chrome.exe 失败 + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +if not exist "!STAGING!\ant-chrome.exe" ( + echo ✗ staging 中缺少 ant-chrome.exe + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +echo ✓ 复制 ant-chrome.exe + +if not exist "!RELEASE_CONFIG!" ( + echo ✗ 未找到发布配置模板: !RELEASE_CONFIG! + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +copy /Y "!RELEASE_CONFIG!" "!STAGING!\config.yaml" >nul +if errorlevel 1 ( + echo ✗ 复制发布配置模板失败: !RELEASE_CONFIG! + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +if not exist "!STAGING!\config.yaml" ( + echo ✗ staging 中缺少 config.yaml(来源: !RELEASE_CONFIG!) + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +echo ✓ 复制发布配置模板 !RELEASE_CONFIG! -> config.yaml + +if not exist "bin" ( + echo ✗ bin\ 目录不存在,缺少代理运行时文件 + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +if not exist "bin\xray.exe" ( + echo ✗ 缺少运行时文件: bin\xray.exe + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +if not exist "bin\sing-box.exe" ( + echo ✗ 缺少运行时文件: bin\sing-box.exe + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) + +xcopy /E /I /Y bin "!STAGING!\bin" >nul +if not exist "!STAGING!\bin\xray.exe" ( + echo ✗ 复制后仍缺少 !STAGING!\bin\xray.exe + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +if not exist "!STAGING!\bin\sing-box.exe" ( + echo ✗ 复制后仍缺少 !STAGING!\bin\sing-box.exe + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +echo ✓ 复制 bin\(xray.exe, sing-box.exe) + +if not exist "chrome" ( + echo ✗ chrome\ 目录不存在,Chrome 内核为必需文件 + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +xcopy /E /I /Y chrome "!STAGING!\chrome" >nul +echo ✓ 复制 chrome\ + +mkdir "!STAGING!\data" +echo ✓ 创建空 data 目录(不打包 app.db,首次启动自动初始化) + +echo. +echo ✓ staging 目录组装完成 +echo. + +REM ======== [5/6] NSIS 打包 ======== +echo [5/6] 调用 NSIS 打包... + +if not exist "publish\output" mkdir "publish\output" + +REM 确保 installer.nsi 是 UTF-8 with BOM(NSIS Unicode True 要求) +powershell -NoProfile -Command "$f=(Resolve-Path 'publish\installer.nsi').Path; $c=[System.IO.File]::ReadAllText($f,[System.Text.Encoding]::UTF8); [System.IO.File]::WriteAllText($f,$c,[System.Text.UTF8Encoding]::new($true))" >nul + +for /f "usebackq delims=" %%p in (`powershell -NoProfile -Command "(Resolve-Path '!STAGING!').Path"`) do ( + set "STAGING_ABS=%%p" +) + +"!MAKENSIS!" /DVERSION=!VERSION! "/DSTAGINGDIR=!STAGING_ABS!" publish\installer.nsi +if %errorlevel% neq 0 ( + echo ✗ NSIS 打包失败 + rmdir /S /Q "!STAGING!" + pause + exit /b 1 +) +echo ✓ 安装包生成成功 +echo. + +REM ======== [6/6] 清理 staging ======== +echo [6/6] 清理临时文件... +rmdir /S /Q "!STAGING!" +echo ✓ staging 目录已清理 +echo. + +echo ======================================== +echo ✓ 发布完成! +echo ======================================== +echo. +echo 安装包位置: publish\output\AntBrowser-Setup-!VERSION!.exe +echo. +echo 提示:用户安装后可将旧的 data\ 目录粘贴到安装目录覆盖初始数据 +echo. +pause diff --git a/bin/sing-box.exe b/bin/sing-box.exe new file mode 100644 index 00000000..7f029930 Binary files /dev/null and b/bin/sing-box.exe differ diff --git a/bin/xray.exe b/bin/xray.exe new file mode 100644 index 00000000..c9bdd4e8 Binary files /dev/null and b/bin/xray.exe differ diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 00000000..2630a042 Binary files /dev/null and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 00000000..7e2f5046 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 00000000..9727946b --- /dev/null +++ b/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 00000000..17e1a238 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 00000000..bf63a311 --- /dev/null +++ b/config.yaml @@ -0,0 +1,53 @@ +database: + type: sqlite + sqlite: + path: data/app.db +app: + name: Ant Browser + window: + width: 1750 + height: 1000 + min_width: 1200 + min_height: 700 + max_profile_limit: 3 + used_cd_keys: [] +runtime: + max_memory_mb: 1024 + gc_percent: 100 +logging: + level: info + file_enabled: false + file_path: data/logs/app.log + format: text + buffer_size: 4 + async_queue_size: 1000 + flush_interval_ms: 1000 + rotation: + enabled: false + max_size_mb: 100 + max_age: 7 + max_backups: 5 + time_interval: daily + interceptor: + enabled: true + log_parameters: true + log_results: true + sensitive_fields: + - password + - token + - secret +browser: + user_data_root: data + default_fingerprint_args: + - --fingerprint-brand=Chrome + - --fingerprint-platform=windows + default_launch_args: + - --disable-sync + - --no-first-run + default_proxy: "" + default_bookmarks: [] + cores: [] + proxies: [] + profiles: [] +launch_server: + port: 0 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..3bfb5b10 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,84 @@ + + + + + + + Ant Browser + + +
+ + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..b5347044 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5155 @@ +{ + "name": "ant-browser-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ant-browser-frontend", + "version": "1.0.0", + "dependencies": { + "@types/js-yaml": "^4.0.9", + "clsx": "^2.0.0", + "js-yaml": "^4.1.1", + "lucide-react": "^0.292.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.1", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "cross-env": "^10.1.0", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", + "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", + "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.254", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", + "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5/node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.292.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", + "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..90c5c188 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "ant-browser-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "node ./scripts/dev-watcher.mjs", + "dev:raw": "cross-env NODE_OPTIONS=--max-old-space-size=500 vite", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=1024 tsc && cross-env NODE_OPTIONS=--max-old-space-size=1024 vite build", + "preview": "cross-env NODE_OPTIONS=--max-old-space-size=500 vite preview" + }, + "dependencies": { + "@types/js-yaml": "^4.0.9", + "clsx": "^2.0.0", + "js-yaml": "^4.1.1", + "lucide-react": "^0.292.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.1", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "cross-env": "^10.1.0", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 00000000..862e9531 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +0813f8076d07158b8ddcd1f7e61caed1 \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 00000000..b4a6220e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 00000000..6f33dcc8 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/scripts/dev-watcher.mjs b/frontend/scripts/dev-watcher.mjs new file mode 100644 index 00000000..7a0473ff --- /dev/null +++ b/frontend/scripts/dev-watcher.mjs @@ -0,0 +1,106 @@ +import { spawn, spawnSync } from 'node:child_process' + +const frontendDir = process.cwd() +const vitePort = 5218 +const frontendDirLower = frontendDir.toLowerCase() + +function runPowerShell(command) { + return spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { + cwd: frontendDir, + encoding: 'utf8', + }) +} + +function listListeners(port) { + const frontend = frontendDir.replace(/'/g, "''") + const result = runPowerShell(` +$port = ${port} +$frontend = '${frontend}' +$items = @() +$conns = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue +foreach ($conn in $conns) { + $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $($conn.OwningProcess)" | Select-Object -First 1 + if ($proc) { + $items += [PSCustomObject]@{ + pid = [int]$proc.ProcessId + name = [string]$proc.Name + commandLine = [string]$proc.CommandLine + } + } +} +$items | ConvertTo-Json -Compress +`) + + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || `failed to inspect port ${port}`) + } + + const text = result.stdout.trim() + if (!text) return [] + + const parsed = JSON.parse(text) + return Array.isArray(parsed) ? parsed : [parsed] +} + +function isProjectVite(proc) { + const cmd = String(proc.commandLine || '').toLowerCase() + return cmd.includes(frontendDirLower) && cmd.includes('vite') +} + +function ensureVitePortAvailable(port) { + if (process.platform !== 'win32') return + + const listeners = listListeners(port) + for (const proc of listeners) { + if (isProjectVite(proc)) { + console.log(`[dev] cleaning stale Vite on ${port} (PID ${proc.pid})`) + const killed = spawnSync('taskkill.exe', ['/F', '/T', '/PID', String(proc.pid)], { + cwd: frontendDir, + stdio: 'inherit', + }) + if (killed.status !== 0) { + throw new Error(`failed to kill stale Vite process ${proc.pid}`) + } + continue + } + + const name = proc.name || 'unknown' + const cmd = proc.commandLine || '' + throw new Error(`port ${port} is already occupied by ${name} (PID ${proc.pid})\n${cmd}`) + } +} + +function main() { + ensureVitePortAvailable(vitePort) + + const command = process.platform === 'win32' ? 'cmd.exe' : 'npm' + const args = process.platform === 'win32' + ? ['/d', '/s', '/c', 'npm run dev:raw'] + : ['run', 'dev:raw'] + + const child = spawn(command, args, { + cwd: frontendDir, + stdio: 'inherit', + env: process.env, + }) + + child.on('error', (error) => { + console.error(`[dev] failed to start Vite: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + }) + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 0) + }) +} + +try { + main() +} catch (error) { + console.error(`[dev] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..027d4951 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from 'react' +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import { ThemeProvider } from './shared/theme' +import { Layout } from './shared/layout' +import { ToastContainer, Modal, Button } from './shared/components' +import { AlertCircle } from 'lucide-react' +import { DashboardPage } from './modules/dashboard' +import { SettingsPage } from './modules/settings' +import { ProfilePage } from './modules/profile' +import { AdminKeygenPage } from './modules/profile/AdminKeygenPage' +import { ChartsPage } from './modules/charts' +import { + BrowserListPage, + BrowserDetailPage, + BrowserEditPage, + BrowserCopyPage, + BrowserLogsPage, + ProxyPoolPage, + CoreManagementPage, + BookmarkSettingsPage, + LaunchApiDocsPage, + TagManagementPage, + AutomationPage, + UsageTutorialPage, +} from './modules/browser' +import { QuickLaunchModal } from './modules/browser/components/QuickLaunchModal' +import { useNotificationStore } from './store/notificationStore' +import { useBackupStore } from './store/backupStore' + +function useWailsNotifications() { + const addNotification = useNotificationStore((s) => s.addNotification) + + useEffect(() => { + const runtime = (window as any).runtime + if (!runtime?.EventsOn) return + + const offCrashed = runtime.EventsOn( + 'browser:instance:crashed', + (data: { profileId: string; profileName: string; error: string }) => { + addNotification({ + type: 'error', + title: '实例异常退出', + message: `「${data.profileName || data.profileId}」意外崩溃:${data.error}`, + }) + } + ) + + const offBridgeFailed = runtime.EventsOn( + 'proxy:bridge:failed', + (data: { profileId: string; profileName: string; error: string }) => { + addNotification({ + type: 'error', + title: '代理连接失败', + message: `「${data.profileName || data.profileId}」代理桥接启动失败:${data.error}`, + }) + } + ) + + const offBridgeDied = runtime.EventsOn( + 'proxy:bridge:died', + (data: { key: string; error: string }) => { + addNotification({ + type: 'warning', + title: '连接池节点失效', + message: `代理节点 ${data.key} 连接中断,相关实例可能无法访问网络`, + }) + } + ) + + return () => { + offCrashed?.() + offBridgeFailed?.() + offBridgeDied?.() + } + }, [addNotification]) +} + +function CloseConfirmModal() { + const [open, setOpen] = useState(false) + const importInProgress = useBackupStore((s) => s.importInProgress) + const importProgress = useBackupStore((s) => s.importProgress) + const importMessage = useBackupStore((s) => s.importMessage) + + useEffect(() => { + const runtime = (window as any).runtime + if (!runtime?.EventsOn) return + + const off = runtime.EventsOn('app:request-close', () => { + setOpen(true) + }) + return () => { + if (typeof off === 'function') off() + } + }, []) + + const handleMinimize = () => { + setOpen(false) + const runtime = (window as any).runtime + runtime?.WindowHide?.() + } + + const handleQuit = async () => { + setOpen(false) + const goApp = (window as any).go?.main?.App + if (goApp?.ForceQuit) { + await goApp.ForceQuit() + } else { + const runtime = (window as any).runtime + runtime?.Quit?.() + } + } + + return ( + setOpen(false)} + title={importInProgress ? '关闭应用确认' : '退出确认'} + width="360px" + > +
+
+ +
+

+ {importInProgress ? '正在加载中,是否关闭?' : '是否退出应用程序?'} +

+ {importInProgress ? ( +

+ 当前正在加载配置 + {importProgress > 0 ? `(${importProgress}%)` : ''}。 +
+ {importMessage || '强制关闭会中断本次加载,是否仍要关闭应用?'} +

+ ) : ( +

+ 退出后将停止所有在此客户端运行的服务,
+ 如果您需要保持服务运行,请选择「最小化到托盘」。 +

+ )} + +
+ {importInProgress ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ ) +} + +function App() { + useWailsNotifications() + const [quickLaunchOpen, setQuickLaunchOpen] = useState(false) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return + if (!(event.ctrlKey || event.metaKey)) return + if (event.key.toLowerCase() !== 'k') return + event.preventDefault() + setQuickLaunchOpen((prev) => !prev) + } + + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, []) + + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + setQuickLaunchOpen(false)} /> + + + ) +} + +export default App diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts new file mode 100644 index 00000000..f5732c09 --- /dev/null +++ b/frontend/src/config/index.ts @@ -0,0 +1,20 @@ +// 配置模块入口 +export { default as config } from './project.config' +export { + projectConfig, + navigationConfig, + featuresConfig, + uiConfig, +} from './project.config' +export { profilePageConfig } from './profile.config' + +export type { NavItem, NavSection } from './project.config' +export type { + AuthorProfileConfig, + ProfileChannelConfig, + ProfileIconKey, + ProjectProfileActionConfig, + ProjectProfileConfig, + RemoteAuthorSourceConfig, + ProfilePageLocalConfig, +} from './profile.config' diff --git a/frontend/src/config/links.ts b/frontend/src/config/links.ts new file mode 100644 index 00000000..83830dec --- /dev/null +++ b/frontend/src/config/links.ts @@ -0,0 +1 @@ +export const PROJECT_GITHUB_URL = 'https://github.com/black-ant/Ant-Browser' diff --git a/frontend/src/config/profile.config.ts b/frontend/src/config/profile.config.ts new file mode 100644 index 00000000..526d9fe9 --- /dev/null +++ b/frontend/src/config/profile.config.ts @@ -0,0 +1,124 @@ +import { projectConfig } from './project.config' +import { PROJECT_GITHUB_URL } from './links' + +export type ProfileIconKey = + | 'book-open' + | 'globe' + | 'message-square' + | 'github' + | 'mail' + | 'external-link' + +export interface ProfileChannelConfig { + name: string + description: string + detail: string + href?: string + icon?: ProfileIconKey +} + +export interface AuthorProfileConfig { + name: string + initial: string + title: string + bio: string + location: string + joinDate: string + email: string + website: string + github: string + skills: string[] + channels: ProfileChannelConfig[] +} + +export interface ProjectProfileActionConfig { + label: string + href: string + icon: ProfileIconKey +} + +export interface ProjectProfileConfig { + name: string + introBadge: string + introText: string + techStack: string[] + description: string + actions: ProjectProfileActionConfig[] +} + +export interface RemoteAuthorSourceConfig { + authorURL: string + timeoutMs: number +} + +export interface ProfilePageLocalConfig { + remoteAuthor: RemoteAuthorSourceConfig + defaultAuthor: AuthorProfileConfig + project: ProjectProfileConfig +} + +export const profilePageConfig: ProfilePageLocalConfig = { + remoteAuthor: { + // 当前先使用一个测试用不可达地址,验证超时/失败后回退默认资料的逻辑。 + // 后续你补真实地址时,直接替换这里即可。 + // https://static.antblack.de/profile/author.json + // https://raw.githubusercontent.com///main/author.json + authorURL: 'http://10.255.255.1/profile-author.json', + timeoutMs: 1000, + }, + defaultAuthor: { + name: '志字辈小蚂蚁', + initial: '志', + title: '全栈开发工程师', + bio: '热爱开源,专注于 Web 和桌面应用开发。致力于打造优雅、高效的开发工具和框架。', + location: '中国', + joinDate: '2020', + email: 'contact@antblack.dev', + website: 'http://blog.antblack.de', + github: 'https://github.com/black-ant', + skills: ['Go', 'React', 'TypeScript', 'Wails', 'Node.js', 'Docker'], + channels: [ + { + name: '掘金', + description: '技术文章与开发记录', + detail: 'juejin.cn', + href: 'https://juejin.cn/user/3790771822007822', + icon: 'book-open', + }, + { + name: '个人博客', + description: '独立站文章与项目归档', + detail: 'blog.antblack.de', + href: 'http://blog.antblack.de', + icon: 'globe', + }, + { + name: '公众号', + description: '微信搜索后即可关注', + detail: 'xxx', + icon: 'message-square', + }, + ], + }, + project: { + name: projectConfig.name, + introBadge: projectConfig.name, + introText: '是一个面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。', + techStack: ['Wails', 'React', 'TypeScript'], + description: '项目当前聚焦浏览器实例隔离、代理池配置、浏览器内核管理、标签检索和快捷启动等核心能力,适合跨境电商、社媒运营、本地测试以及需要统一管理浏览器环境的团队场景。', + actions: [ + { + label: '查看源码', + href: PROJECT_GITHUB_URL, + icon: 'github', + }, + { + label: '下载发布版', + href: `${PROJECT_GITHUB_URL}/releases`, + icon: 'globe', + }, + ], + }, +} + +export default profilePageConfig diff --git a/frontend/src/config/project.config.ts b/frontend/src/config/project.config.ts new file mode 100644 index 00000000..9648fac1 --- /dev/null +++ b/frontend/src/config/project.config.ts @@ -0,0 +1,79 @@ +/** + * 项目配置文件 + * + * 基于此脚手架创建新项目时,修改此文件即可完成定制 + */ + +// 项目基础信息 +export const projectConfig = { + name: 'Ant Browser', + shortName: '架', + description: '基于 Wails + React 的桌面应用脚手架', + version: '1.0.0', + primaryColor: 'primary', +} + +// 导航菜单配置 +export interface NavItem { + name: string + path: string + icon: string +} + +export interface NavSection { + title: string + items: NavItem[] +} + +export const navigationConfig: NavSection[] = [ + { + title: '主菜单', + items: [ + { name: '控制台', path: '/', icon: 'LayoutDashboard' }, + ] + }, + { + title: '指纹浏览器', + items: [ + { name: '实例列表', path: '/browser/list', icon: 'Monitor' }, + { name: '自动化(未完成)', path: '/browser/automation', icon: 'Bot' }, + { name: '内核管理', path: '/browser/cores', icon: 'Cpu' }, + { name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' }, + { name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' }, + { name: '标签管理', path: '/browser/tags', icon: 'Tag' }, + ] + }, + { + title: '系统维护', + items: [ + { name: '系统设置', path: '/settings', icon: 'Settings' }, + { name: '使用教程', path: '/system/tutorial', icon: 'BookOpen' }, + { name: '日志查看', path: '/browser/logs', icon: 'FileText' }, + { name: '接口文档', path: '/browser/launch-api', icon: 'BookOpen' }, + ] + }, +] + +// 功能开关 +export const featuresConfig = { + dashboard: true, + data: true, + settings: true, +} + +// UI 配置 +export const uiConfig = { + pagination: { + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + }, + dateFormat: 'YYYY-MM-DD HH:mm:ss', + locale: 'zh-CN', +} + +export default { + project: projectConfig, + navigation: navigationConfig, + features: featuresConfig, + ui: uiConfig, +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..76a22449 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,206 @@ +/* 引入主题样式 - 必须在最前面 */ +@import './shared/theme/themes/index.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', sans-serif; + background: var(--color-bg-base); + color: var(--color-text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; + transition: background-color 0.2s ease, color 0.2s ease; +} + +/* 选中文本样式 */ +::selection { + background: var(--color-accent); + color: var(--color-text-inverse); +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* 聚焦样式 */ +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* 链接样式 */ +a { + color: inherit; + text-decoration: none; +} + +/* 按钮重置 */ +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +/* 输入框重置 */ +input, select, textarea { + font: inherit; +} + +/* 动画 */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.animate-fade-in { + animation: fadeIn 0.2s ease-out; +} + +.animate-slide-up { + animation: slideUp 0.3s ease-out; +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 文本截断 */ +.line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* 图标样式 */ +.fallback-logo .fallback-content { + display: block; +} + +/* 新增动画 */ +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-scale-in { + animation: scaleIn 0.2s ease-out; +} + +.animate-slide-in-right { + animation: slideInRight 0.3s ease-out; +} + +/* 抽屉动画 */ +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideInTop { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } +} + +@keyframes slideInBottom { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.animate-slide-in-left { + animation: slideInLeft 0.3s ease-out; +} + +.animate-slide-in-top { + animation: slideInTop 0.3s ease-out; +} + +.animate-slide-in-bottom { + animation: slideInBottom 0.3s ease-out; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..1fae3081 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +;(window as Window & { __ANT_APP_BOOTED__?: boolean }).__ANT_APP_BOOTED__ = true + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) + diff --git a/frontend/src/modules/browser/api.ts b/frontend/src/modules/browser/api.ts new file mode 100644 index 00000000..b4f67110 --- /dev/null +++ b/frontend/src/modules/browser/api.ts @@ -0,0 +1,767 @@ +import type { BrowserProfile, BrowserProfileInput, BrowserTab, BrowserSettings, BrowserCore, BrowserCoreInput, BrowserCoreValidateResult, BrowserProxy, BrowserCoreExtended, CookieInfo, SnapshotInfo, BrowserBookmark, BrowserGroup, BrowserGroupInput, BrowserGroupWithCount, ProxyIPHealthResult } from './types' + +const getBindings = async () => { + try { + return await import('../../wailsjs/go/main/App') + } catch { + return null + } +} + +let mockProfiles: BrowserProfile[] = [ + { + profileId: 'mock-1', + profileName: '默认指纹配置', + userDataDir: 'data/default', + coreId: 'default', + fingerprintArgs: ['--fingerprint-brand=Chrome', '--fingerprint-platform=windows'], + proxyId: '', + proxyConfig: '', + launchArgs: ['--disable-features=Translate'], + tags: ['默认'], + keywords: [], + running: false, + debugPort: 0, + pid: 0, + lastError: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, +] + +let mockCores: BrowserCore[] = [] + +let mockProxies: BrowserProxy[] = [] + +// ============================================================================ +// Profile API +// ============================================================================ + +export async function fetchBrowserProfiles(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileList) { + return (await bindings.BrowserProfileList()) || [] + } + return mockProfiles +} + +export async function fetchBrowserProfilesByTag(tag: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileListByTag) { + return (await bindings.BrowserProfileListByTag(tag)) || [] + } + return mockProfiles.filter(p => p.tags?.includes(tag)) +} + +export async function fetchAllTags(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserGetAllTags) { + return (await bindings.BrowserGetAllTags()) || [] + } + const set = new Set() + mockProfiles.forEach(p => p.tags?.forEach(t => set.add(t))) + return Array.from(set).sort() +} + +export async function createBrowserProfile(input: BrowserProfileInput): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileCreate) { + return (await bindings.BrowserProfileCreate(input)) || null + } + const profile: BrowserProfile = { + profileId: `mock-${Date.now()}`, + ...input, + keywords: input.keywords || {}, + running: false, + debugPort: 0, + pid: 0, + lastError: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + mockProfiles = [profile, ...mockProfiles] + return profile +} + +export async function updateBrowserProfile(profileId: string, input: BrowserProfileInput): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileUpdate) { + return (await bindings.BrowserProfileUpdate(profileId, input)) || null + } + const index = mockProfiles.findIndex(item => item.profileId === profileId) + if (index === -1) return null + mockProfiles[index] = { ...mockProfiles[index], ...input, updatedAt: new Date().toISOString() } + return mockProfiles[index] +} + +export async function deleteBrowserProfile(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileDelete) { + await bindings.BrowserProfileDelete(profileId) + return true + } + mockProfiles = mockProfiles.filter(item => item.profileId !== profileId) + return true +} + +export async function copyBrowserProfile(profileId: string, newName: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileCopy) { + return (await bindings.BrowserProfileCopy(profileId, newName)) || null + } + // mock + const src = mockProfiles.find(p => p.profileId === profileId) + if (!src) return null + const copy: BrowserProfile = { + ...src, + profileId: `mock-${Date.now()}`, + profileName: newName || src.profileName + ' (副本)', + userDataDir: `mock-${Date.now()}`, + running: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + mockProfiles = [copy, ...mockProfiles] + return copy +} + +// ============================================================================ +// Instance API +// ============================================================================ + +export async function startBrowserInstance(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceStart) { + return (await bindings.BrowserInstanceStart(profileId)) || null + } + mockProfiles = mockProfiles.map(item => + item.profileId === profileId ? { ...item, running: true, debugPort: 9222, pid: Math.floor(Math.random() * 100000), lastStartAt: new Date().toISOString() } : item + ) + return mockProfiles.find(item => item.profileId === profileId) || null +} + +export async function startBrowserInstanceByCode(code: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceStartByCode) { + return (await bindings.BrowserInstanceStartByCode(code)) || null + } + const normalized = code.trim().toUpperCase() + const profile = mockProfiles.find(item => (item.launchCode || '').toUpperCase() === normalized) + if (!profile) { + throw new Error('launch code not found') + } + return await startBrowserInstance(profile.profileId) +} + +export async function stopBrowserInstance(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceStop) { + return (await bindings.BrowserInstanceStop(profileId)) || null + } + mockProfiles = mockProfiles.map(item => + item.profileId === profileId ? { ...item, running: false, pid: 0, lastStopAt: new Date().toISOString() } : item + ) + return mockProfiles.find(item => item.profileId === profileId) || null +} + +export async function restartBrowserInstance(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceRestart) { + return (await bindings.BrowserInstanceRestart(profileId)) || null + } + await stopBrowserInstance(profileId) + return await startBrowserInstance(profileId) +} + +export async function openBrowserUrl(profileId: string, targetUrl: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceOpenUrl) { + return (await bindings.BrowserInstanceOpenUrl(profileId, targetUrl)) === true + } + return true +} + +export async function fetchBrowserTabs(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceGetTabs) { + return (await bindings.BrowserInstanceGetTabs(profileId)) || [] + } + return [ + { tabId: 'tab-1', title: '新标签页', url: 'about:blank', active: true }, + { tabId: 'tab-2', title: '示例站点', url: 'https://example.com', active: false }, + ] +} + +// ============================================================================ +// Settings API +// ============================================================================ + +export async function fetchBrowserSettings(): Promise { + const bindings: any = await getBindings() + if (bindings?.GetBrowserSettings) { + return (await bindings.GetBrowserSettings()) || { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' } + } + return { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' } +} + +export async function saveBrowserSettings(settings: BrowserSettings): Promise { + const bindings: any = await getBindings() + if (bindings?.SaveBrowserSettings) { + await bindings.SaveBrowserSettings(settings) + return true + } + return true +} + +// ============================================================================ +// Core API +// ============================================================================ + +export async function fetchBrowserCores(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreList) { + return (await bindings.BrowserCoreList()) || [] + } + return mockCores +} + +export async function saveBrowserCore(input: BrowserCoreInput): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreSave) { + await bindings.BrowserCoreSave(input) + return true + } + const index = mockCores.findIndex(c => c.coreId === input.coreId) + if (index >= 0) { + mockCores[index] = input + } else { + mockCores.push({ ...input, coreId: input.coreId || `core-${Date.now()}` }) + } + return true +} + +export async function deleteBrowserCore(coreId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreDelete) { + await bindings.BrowserCoreDelete(coreId) + return true + } + mockCores = mockCores.filter(c => c.coreId !== coreId) + return true +} + +export async function setDefaultBrowserCore(coreId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreSetDefault) { + await bindings.BrowserCoreSetDefault(coreId) + return true + } + mockCores = mockCores.map(c => ({ ...c, isDefault: c.coreId === coreId })) + return true +} + +export async function validateBrowserCorePath(corePath: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreValidate) { + return (await bindings.BrowserCoreValidate(corePath)) || { valid: false, message: '验证失败' } + } + return { valid: true, message: '路径有效(模拟)' } +} + +export async function fetchCoreExtendedInfo(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreExtendedInfo) { + return (await bindings.BrowserCoreExtendedInfo()) || [] + } + return [] +} + +export async function scanBrowserCores(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreScan) { + return (await bindings.BrowserCoreScan()) || [] + } + return mockCores +} + +export async function BrowserCoreDownload(coreName: string, url: string, proxyConfig?: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreDownload) { + await bindings.BrowserCoreDownload(coreName, url, proxyConfig || '') + return true + } + return true +} + +// ============================================================================ +// Proxy API +// ============================================================================ + +export async function fetchBrowserProxies(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyList) { + return (await bindings.BrowserProxyList()) || [] + } + return mockProxies +} + +export async function fetchBrowserProxyGroups(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyListGroups) { + return (await bindings.BrowserProxyListGroups()) || [] + } + return [] +} + +export async function fetchBrowserProxiesByGroup(groupName: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyListByGroup) { + return (await bindings.BrowserProxyListByGroup(groupName)) || [] + } + return mockProxies.filter(p => p.groupName === groupName) +} + +export interface ClashImportURLResult { + url: string + content: string + proxyCount: number + dnsServers?: string + suggestedGroup?: string +} + +export async function fetchClashImportFromURL(targetURL: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyFetchClashByURL) { + return (await bindings.BrowserProxyFetchClashByURL(targetURL)) || { + url: targetURL, + content: '', + proxyCount: 0, + } + } + + // 兜底:wailsjs 尚未刷新时,直接通过 window.go 调用后端绑定 + const goApp = (window as any).go?.main?.App + if (goApp?.BrowserProxyFetchClashByURL) { + return (await goApp.BrowserProxyFetchClashByURL(targetURL)) || { + url: targetURL, + content: '', + proxyCount: 0, + } + } + + throw new Error('当前环境不支持 URL 导入 Clash 配置') +} + +export async function saveBrowserProxies(proxies: BrowserProxy[]): Promise { + const bindings: any = await getBindings() + if (bindings?.SaveBrowserProxies) { + await bindings.SaveBrowserProxies(proxies) + return true + } + mockProxies = proxies + return true +} + +export async function validateProxyConfig(proxyConfig: string, proxyId: string): Promise<{ supported: boolean; errorMsg: string }> { + const bindings: any = await getBindings() + if (bindings?.ValidateProxyConfig) { + return (await bindings.ValidateProxyConfig(proxyConfig, proxyId)) || { supported: true, errorMsg: '' } + } + return { supported: true, errorMsg: '' } +} + +export async function testProxyConnectivity(proxyId: string, proxyConfig: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> { + const bindings: any = await getBindings() + if (bindings?.TestProxyConnectivity) { + return (await bindings.TestProxyConnectivity(proxyId, proxyConfig)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' } + } + // mock: simulate latency + await new Promise(r => setTimeout(r, 300 + Math.random() * 500)) + return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 200), error: '' } +} + +export async function testProxyRealConnectivity(proxyId: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> { + const bindings: any = await getBindings() + if (bindings?.TestProxyRealConnectivity) { + return (await bindings.TestProxyRealConnectivity(proxyId)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' } + } + // mock: simulate latency 300-800ms + await new Promise(r => setTimeout(r, 300 + Math.random() * 500)) + return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' } +} + +export async function browserProxyTestSpeed(proxyId: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyTestSpeed) { + return (await bindings.BrowserProxyTestSpeed(proxyId)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' } + } + await new Promise(r => setTimeout(r, 300 + Math.random() * 500)) + return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' } +} + +export async function browserProxyBatchTestSpeed(proxyIds: string[], concurrency: number = 20): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }[]> { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyBatchTestSpeed) { + return (await bindings.BrowserProxyBatchTestSpeed(proxyIds, concurrency)) || [] + } + // mock + await new Promise(r => setTimeout(r, 1000)) + return proxyIds.map(id => ({ proxyId: id, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' })) +} + +export async function browserProxyCheckIPHealth(proxyId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyCheckIPHealth) { + return (await bindings.BrowserProxyCheckIPHealth(proxyId)) || { + proxyId, + ok: false, + source: 'ippure', + error: '调用失败', + ip: '', + fraudScore: 0, + isResidential: false, + isBroadcast: false, + country: '', + region: '', + city: '', + asOrganization: '', + rawData: {}, + updatedAt: new Date().toISOString(), + } + } + await new Promise(r => setTimeout(r, 600)) + return { + proxyId, + ok: true, + source: 'ippure', + error: '', + ip: '127.0.0.1', + fraudScore: Math.floor(Math.random() * 100), + isResidential: Math.random() > 0.5, + isBroadcast: false, + country: 'Mock', + region: 'Mock', + city: 'Mock', + asOrganization: 'Mock ISP', + rawData: {}, + updatedAt: new Date().toISOString(), + } +} + +export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurrency: number = 10): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyBatchCheckIPHealth) { + return (await bindings.BrowserProxyBatchCheckIPHealth(proxyIds, concurrency)) || [] + } + await new Promise(r => setTimeout(r, 1200)) + return proxyIds.map(proxyId => ({ + proxyId, + ok: true, + source: 'ippure', + error: '', + ip: '127.0.0.1', + fraudScore: Math.floor(Math.random() * 100), + isResidential: Math.random() > 0.5, + isBroadcast: false, + country: 'Mock', + region: 'Mock', + city: 'Mock', + asOrganization: 'Mock ISP', + rawData: {}, + updatedAt: new Date().toISOString(), + })) +} + +export async function openUserDataDir(userDataDir: string): Promise { + const bindings: any = await getBindings() + if (bindings?.OpenUserDataDir) { + await bindings.OpenUserDataDir(userDataDir) + return true + } + return false +} + +export async function openCorePath(corePath: string): Promise { + const bindings: any = await getBindings() + if (bindings?.OpenCorePath) { + await bindings.OpenCorePath(corePath) + return true + } + return false +} + +// ============================================================================ +// Cookie API +// ============================================================================ + +export async function fetchBrowserCookies(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserGetCookies) { + return (await bindings.BrowserGetCookies(profileId)) || [] + } + // mock data + return [ + { name: 'session', value: 'abc123', domain: '.example.com', path: '/', expires: Date.now() / 1000 + 3600, httpOnly: true, secure: true, sameSite: 'Lax' }, + { name: 'pref', value: 'dark', domain: 'example.com', path: '/', expires: -1, httpOnly: false, secure: false, sameSite: 'None' }, + ] +} + +export async function clearBrowserCookies(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserClearCookies) { + await bindings.BrowserClearCookies(profileId) + return true + } + return true +} + +export async function exportBrowserCookies(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserExportCookies) { + return (await bindings.BrowserExportCookies(profileId)) || '' + } + return '# Netscape HTTP Cookie File\n# Generated by BrowserManager\n\n.example.com\tTRUE\t/\tTRUE\t0\tsession\tabc123\n' +} + +// ============================================================================ +// Snapshot API +// ============================================================================ + +export async function listSnapshots(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserSnapshotList) { + return (await bindings.BrowserSnapshotList(profileId)) || [] + } + return [] +} + +export async function createSnapshot(profileId: string, name: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserSnapshotCreate) { + return (await bindings.BrowserSnapshotCreate(profileId, name)) || null + } + // mock + return { + snapshotId: `snap-${Date.now()}`, + profileId, + name, + sizeMB: 12.5, + createdAt: new Date().toISOString(), + } +} + +export async function restoreSnapshot(profileId: string, snapshotId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserSnapshotRestore) { + await bindings.BrowserSnapshotRestore(profileId, snapshotId) + return true + } + return true +} + +export async function deleteSnapshot(profileId: string, snapshotId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserSnapshotDelete) { + await bindings.BrowserSnapshotDelete(profileId, snapshotId) + return true + } + return true +} + +// ============================================================================ +// Bookmark API +// ============================================================================ + +export async function fetchBookmarks(): Promise { + const bindings: any = await getBindings() + if (bindings?.BookmarkList) { + return (await bindings.BookmarkList()) || [] + } + return [ + { 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/' }, + ] +} + +export async function saveBookmarks(items: BrowserBookmark[]): Promise { + const bindings: any = await getBindings() + if (bindings?.BookmarkSave) { + await bindings.BookmarkSave(items) + return true + } + return true +} + +export async function resetBookmarks(): Promise { + const bindings: any = await getBindings() + if (bindings?.BookmarkReset) { + await bindings.BookmarkReset() + return true + } + return true +} + +// ============================================================================ +// Keywords API +// ============================================================================ + +export async function setProfileKeywords(profileId: string, keywords: string[]): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileSetKeywords) { + return (await bindings.BrowserProfileSetKeywords(profileId, keywords)) || null + } + mockProfiles = mockProfiles.map(p => + p.profileId === profileId ? { ...p, keywords, updatedAt: new Date().toISOString() } : p + ) + return mockProfiles.find(p => p.profileId === profileId) || null +} + +// ============================================================================ +// LaunchCode API +// ============================================================================ + +export interface LaunchServerInfo { + host: string + port: number + preferredPort: number + baseUrl: string + ready: boolean +} + +function normalizeLaunchServerInfo(payload: any): LaunchServerInfo { + const host = String(payload?.host || '127.0.0.1') + const port = Number(payload?.port) || 0 + const preferredPort = Number(payload?.preferredPort) || 0 + const fallbackPort = preferredPort > 0 ? preferredPort : 19876 + const effectivePort = port > 0 ? port : fallbackPort + const baseUrl = String(payload?.baseUrl || (effectivePort > 0 ? `http://${host}:${effectivePort}` : '')) + + return { + host, + port: effectivePort, + preferredPort, + baseUrl, + ready: !!payload?.ready && port > 0, + } +} + +export async function fetchLaunchServerInfo(): Promise { + const bindings: any = await getBindings() + if (bindings?.GetLaunchServerInfo) { + return normalizeLaunchServerInfo(await bindings.GetLaunchServerInfo()) + } + + const goApp = (window as any).go?.main?.App + if (goApp?.GetLaunchServerInfo) { + return normalizeLaunchServerInfo(await goApp.GetLaunchServerInfo()) + } + + return { + host: '127.0.0.1', + port: 19876, + preferredPort: 19876, + baseUrl: 'http://127.0.0.1:19876', + ready: false, + } +} + +export async function getBrowserProfileCode(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileGetCode) { + return (await bindings.BrowserProfileGetCode(profileId)) || '' + } + return '' +} + +export async function regenerateBrowserProfileCode(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileRegenerateCode) { + return (await bindings.BrowserProfileRegenerateCode(profileId)) || '' + } + return '' +} + +export async function setBrowserProfileCode(profileId: string, code: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileSetCode) { + return (await bindings.BrowserProfileSetCode(profileId, code)) || '' + } + return code.trim().toUpperCase() +} + + +export async function batchSetProfileTags(profileIds: string[], tags: string[], replace: boolean): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileBatchSetTags) { + await bindings.BrowserProfileBatchSetTags(profileIds, tags, replace) + return true + } + return true +} + +export async function batchRemoveProfileTags(profileIds: string[], tags: string[]): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileBatchRemoveTags) { + await bindings.BrowserProfileBatchRemoveTags(profileIds, tags) + return true + } + return true +} + +export async function renameBrowserTag(oldName: string, newName: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserRenameTag) { + await bindings.BrowserRenameTag(oldName, newName) + return true + } + return true +} + +// ============================================================================ +// Group API +// ============================================================================ + +export async function fetchGroups(): Promise { + const bindings: any = await getBindings() + if (bindings?.ListGroups) { + return (await bindings.ListGroups()) || [] + } + return [] +} + +export async function createGroup(input: BrowserGroupInput): Promise { + const bindings: any = await getBindings() + if (bindings?.CreateGroup) { + return (await bindings.CreateGroup(input)) || null + } + return null +} + +export async function updateGroup(groupId: string, input: BrowserGroupInput): Promise { + const bindings: any = await getBindings() + if (bindings?.UpdateGroup) { + return (await bindings.UpdateGroup(groupId, input)) || null + } + return null +} + +export async function deleteGroup(groupId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.DeleteGroup) { + await bindings.DeleteGroup(groupId) + return true + } + return false +} + +export async function moveInstancesToGroup(profileIds: string[], groupId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.MoveInstancesToGroup) { + await bindings.MoveInstancesToGroup(profileIds, groupId) + return true + } + return false +} diff --git a/frontend/src/modules/browser/components/BrowserSettingsModal.tsx b/frontend/src/modules/browser/components/BrowserSettingsModal.tsx new file mode 100644 index 00000000..ef0d6b46 --- /dev/null +++ b/frontend/src/modules/browser/components/BrowserSettingsModal.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react' +import { CheckCircle, Edit2, Plus, Star, Trash2, XCircle } from 'lucide-react' +import { Button, Card, FormItem, Input, Modal, Table, Textarea, toast } from '../../../shared/components' +import type { TableColumn } from '../../../shared/components/Table' +import type { BrowserCore, BrowserCoreInput, BrowserSettings } from '../types' +import { + deleteBrowserCore, + saveBrowserCore, + saveBrowserSettings, + setDefaultBrowserCore, + validateBrowserCorePath, +} from '../api' + +interface BrowserSettingsModalProps { + open: boolean + onClose: () => void + settings: BrowserSettings + cores: BrowserCore[] + onCoresChange: (cores: BrowserCore[]) => void +} + +export function BrowserSettingsModal({ open, onClose, settings: initSettings, cores, onCoresChange }: BrowserSettingsModalProps) { + const [settings, setSettings] = useState(initSettings) + const [fingerprintText, setFingerprintText] = useState((initSettings.defaultFingerprintArgs || []).join('\n')) + const [launchText, setLaunchText] = useState((initSettings.defaultLaunchArgs || []).join('\n')) + const [saving, setSaving] = useState(false) + + // 内核编辑弹窗 + const [coreModalOpen, setCoreModalOpen] = useState(false) + const [coreForm, setCoreForm] = useState({ coreId: '', coreName: '', corePath: '', isDefault: false }) + const [coreValidation, setCoreValidation] = useState<{ valid: boolean; message: string } | null>(null) + const [savingCore, setSavingCore] = useState(false) + + const handleSave = async () => { + setSaving(true) + try { + await saveBrowserSettings({ + ...settings, + defaultFingerprintArgs: fingerprintText.split('\n').map(s => s.trim()).filter(Boolean), + defaultLaunchArgs: launchText.split('\n').map(s => s.trim()).filter(Boolean), + }) + toast.success('配置已保存') + onClose() + } catch (error: any) { + toast.error(error?.message || '保存失败') + } finally { + setSaving(false) + } + } + + const handleOpenCoreModal = (core?: BrowserCore) => { + setCoreForm(core ? { ...core } : { coreId: '', coreName: '', corePath: '', isDefault: false }) + setCoreValidation(null) + setCoreModalOpen(true) + } + + const handleValidateCorePath = async () => { + if (!coreForm.corePath.trim()) { setCoreValidation({ valid: false, message: '请输入路径' }); return } + setCoreValidation(await validateBrowserCorePath(coreForm.corePath)) + } + + const handleSaveCore = async () => { + if (!coreForm.coreName.trim()) { toast.error('请输入内核名称'); return } + if (!coreForm.corePath.trim()) { toast.error('请输入内核路径'); return } + setSavingCore(true) + try { + await saveBrowserCore(coreForm) + toast.success('内核已保存') + setCoreModalOpen(false) + // 刷新 cores 列表 + const { fetchBrowserCores } = await import('../api') + onCoresChange(await fetchBrowserCores()) + } catch (error: any) { + toast.error(error?.message || '保存失败') + } finally { + setSavingCore(false) + } + } + + const handleDeleteCore = async (coreId: string) => { + if (cores.length <= 1) { toast.error('至少保留一个内核'); return } + await deleteBrowserCore(coreId) + toast.success('内核已删除') + const { fetchBrowserCores } = await import('../api') + onCoresChange(await fetchBrowserCores()) + } + + const handleSetDefaultCore = async (coreId: string) => { + await setDefaultBrowserCore(coreId) + toast.success('已设为默认') + const { fetchBrowserCores } = await import('../api') + onCoresChange(await fetchBrowserCores()) + } + + const coreColumns: TableColumn[] = [ + { key: 'coreName', title: '名称' }, + { key: 'corePath', title: '路径' }, + { key: 'isDefault', title: '默认', render: (v) => v ? : null }, + { + key: 'actions', title: '操作', align: 'right', + render: (_, record) => ( +
+ {!record.isDefault && } + + +
+ ), + }, + ] + + return ( + <> + }> +
+
+
+ 内核管理 + +
+ + + + setSettings(p => ({ ...p, userDataRoot: e.target.value }))} placeholder="data" /> + + +