diff --git a/.github/workflows/publish-macos.yml b/.github/workflows/publish-macos.yml index bd56677e..b5819147 100644 --- a/.github/workflows/publish-macos.yml +++ b/.github/workflows/publish-macos.yml @@ -2,6 +2,14 @@ name: Publish macOS Packages on: workflow_dispatch: + inputs: + version: + description: Optional package version override, for example 1.2.0 + required: false + type: string + +permissions: + contents: read jobs: build: @@ -33,13 +41,21 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 - name: Add Go bin to PATH run: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Build unsigned macOS package - run: bash publish/mac/publish-mac.sh --arch ${{ matrix.arch }} + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + args=(--arch "${{ matrix.arch }}") + if [[ -n "$VERSION" ]]; then + args+=(--version "$VERSION") + fi + bash publish/mac/publish-mac.sh "${args[@]}" - name: Validate app bundle run: | @@ -61,3 +77,4 @@ jobs: path: | publish/output/AntBrowser-*-macos-${{ matrix.arch }}.app publish/output/AntBrowser-*-macos-${{ matrix.arch }}.zip + if-no-files-found: error diff --git a/automation-target-id-vs-code-report.zh-CN.md b/automation-target-id-vs-code-report.zh-CN.md new file mode 100644 index 00000000..16301347 --- /dev/null +++ b/automation-target-id-vs-code-report.zh-CN.md @@ -0,0 +1,359 @@ +# 自动化脚本目标标识方案报告 + +## 主题 + +在自动化脚本、Launch API 和实例管理场景中,是否应当用 `code` 替代 `profileId` 作为主要标识。 + +## 结论 + +`code 更适合做人看到、记住、手动输入和外部调用的主标识;profileId 更适合做系统内部稳定绑定。` + +如果只允许保留一个,我不建议直接把内部绑定从 `profileId` 全量切到 `code`。 + +更合理的方案是: + +- `对外`:`code-first` +- `对内`:`profileId-first` +- `展示层`:优先显示 `code`,把 `profileId` 下沉到高级信息 +- `脚本持久化`:同时保存 `profileId` 和 `code` 快照,但解析以 `profileId` 为准 + +一句话判断: + +`ID 不适合当用户主视角标识,但仍然适合当系统主键。code 适合成为产品层主标识,不适合单独承担全部内部绑定责任。` + +## 当前实现事实 + +### 1. `profileId` 是实例主键 + +浏览器实例结构里,`profileId` 是实例记录的唯一标识,`launchCode` 是附加的人类友好字段。 + +相关位置: + +- `backend/internal/browser/types.go` +- `backend/internal/launchcode/dao.go` + +### 2. `code` 本质上是 `profileId -> code` 的映射层 + +当前 `LaunchCodeService` 维护的是: + +- `profileToCode` +- `codeToProfile` + +也就是说,`code` 不是主键本体,而是主键的可读别名。 + +相关位置: + +- `backend/internal/launchcode/service.go` + +### 3. `code` 是唯一的,但不是不可变的 + +当前实现支持: + +- 自动生成 code +- 手动设置 code +- 重新生成 code + +这意味着 `code` 虽然唯一,但它是可变的;一旦改码,原码就会释放。 + +相关位置: + +- `backend/internal/launchcode/service.go` + +### 4. `/api/launch` 已支持 `code` 和 `profileId` + +Launch Selector 同时支持: + +- `code` +- `profileId` +- `profileName` +- `groupId` +- `tags` +- `keywords` + +而且当前匹配逻辑里,`code` 会先被解析成 `profileId`,再继续走后续筛选。 + +相关位置: + +- `backend/internal/launchcode/selector_types.go` +- `backend/internal/launchcode/selector_match.go` + +### 5. 自动化脚本的“使用已有实例”当前偏向 `profileId` + +在脚本详情页里: + +- `existing` 模式直接用 `profileId` 作为 select value +- `rotate` 模式同时支持 `code` 和 `profileId` +- 手动 JSON selector 例子也偏向 `code` + +说明当前产品层语义没有完全统一。 + +相关位置: + +- `frontend/src/modules/browser/pages/AutomationScriptDetailPage.tsx` + +## 为什么你会觉得 `profileId` 不合理 + +这个判断在产品视角上是成立的。 + +`profileId` 的问题不是“技术上错”,而是“人机交互上错位”: + +- 太长,记不住 +- 没有业务语义 +- 不适合手输 +- 不适合口头沟通 +- 不适合写文档和教程 +- 不适合作为外部 API 的主要示例字段 + +如果用户看到的是: + +- `05caae0a-58b8-4707-b8a9-2d81dc9df42c` + +这对操作没有帮助。 + +如果用户看到的是: + +- `BUYER_001` +- `SHOP_US_A` +- `WARM_TIKTOK_03` + +这才是可操作、可沟通、可排障的标识。 + +所以: + +`你说 code 更合适,这个在产品层是对的。` + +## 为什么我不建议直接用 `code` 完全替代 `profileId` + +因为 `code` 在当前系统里是“好用的别名”,不是“稳定的主键”。 + +### 1. `code` 可变 + +当前支持 `SetCode` 和 `RegenerateCode`。 + +如果脚本只存 `code`: + +- 今天绑的是 `BUYER_001` +- 明天用户把它改成 `BUYER_A` +- 原脚本就失效 + +更糟的是,旧 code 之后还可能被别的实例占用。 + +### 2. `code` 可能漂移到另一实例 + +因为旧 code 被释放后,可以重新分配给别的 profile。 + +这会导致“脚本没有报错,但命中了错误实例”,这是比“直接失败”更危险的结果。 + +### 3. 随机生成的 code 不一定有业务语义 + +当前自动生成 code 是随机 6 位大写字母数字。 + +这比 UUID 好很多,但不一定天然有业务可读性。 + +只有当用户主动维护 code 命名规范时,`code` 才真正成为稳定业务标识。 + +### 4. 内部数据关联仍然更适合不可变 ID + +数据库、缓存、脚本绑定、运行记录、导入导出、恢复数据,这些都更适合基于稳定主键工作。 + +如果内部主关联层改成可变 code,后续会出现更多迁移、冲突、历史兼容问题。 + +## 产品层判断 + +### 适合给用户看的主标识 + +应该是: + +- `code` + +不应该是: + +- `profileId` + +### 适合系统内部绑定的主标识 + +应该是: + +- `profileId` + +不应该是: + +- 仅 `code` + +### 适合外部 API 文档默认示例的字段 + +应该优先: + +- `selector.code` + +只在高级场景里提: + +- `selector.profileId` + +## 推荐方案 + +## 方案 A:纯 `code` 替代 `profileId` + +优点: + +- 用户理解成本最低 +- 文档和外部调用更直观 +- UI 展示更统一 + +缺点: + +- code 可变,绑定不稳定 +- 改码后脚本可能失效或漂移 +- 数据恢复和内部关联会更脆弱 + +结论: + +`不推荐直接采用。` + +## 方案 B:继续维持 `profileId-first` + +优点: + +- 内部逻辑最稳定 +- 历史兼容成本最低 +- 不怕改名、改 code + +缺点: + +- 用户感知很差 +- 外部接入不友好 +- UI 和文档会持续让人困惑 + +结论: + +`只适合内部实现,不适合作为产品层最终形态。` + +## 方案 C:对外 code-first,对内 profileId-first + +建议设计: + +- UI 主展示:`code` +- UI 次展示:实例名 +- 高级信息:`profileId` +- 脚本持久化:保存 `profileId + code` +- 运行解析:优先 `profileId` +- 若 `profileId` 失效,再尝试 `code` +- 若两者不一致,提示“目标实例已变更,请确认” + +优点: + +- 用户视角清晰 +- 外部 API 友好 +- 内部绑定稳定 +- 能兼容 code 变更场景 + +缺点: + +- 实现比单字段方案复杂一点 +- 需要一层“绑定校验/修复”逻辑 + +结论: + +`这是当前项目最合理的方向。` + +## 对当前页面和脚本管理的具体建议 + +### 1. 列表页不要只写“使用已有实例” + +应该直接显示: + +- 目标实例:`实例名称` +- 目标 Code:`BUYER_001` +- 高级 ID:折叠显示 `profileId` + +### 2. 脚本详情页 Existing 模式不要只存“看不见语义的 profileId” + +建议改成: + +- 下拉项主文案:`实例名 · Code` +- 持久化时同时保存: + - `profileId` + - `code` + +### 3. 文档与 API 示例优先用 `code` + +例如脚本执行示例应优先写: + +```json +{ + "scriptId": "news-query-txt", + "useScriptSelector": false, + "selector": { + "code": "BUYER_001" + } +} +``` + +而不是默认展示 `profileId`。 + +### 4. 把 `profileId` 下沉到“高级/调试信息” + +适合出现 `profileId` 的地方: + +- 调试信息 +- 导入导出原始数据 +- 错误排查 +- 高级编辑器 + +不适合出现 `profileId` 的地方: + +- 普通列表主卡片 +- 新手文档 +- 操作按钮附近 +- 外部调用示例首页 + +## 迁移建议 + +如果要往 `code-first` 方向收敛,建议分三步: + +### 第 1 步:先改展示,不改底层绑定 + +- 列表页、详情页、运行弹窗都优先显示 `code` +- `profileId` 只留在高级信息 + +这是最低风险改法。 + +### 第 2 步:脚本配置持久化改为同时保存 `profileId + code` + +- 兼容历史脚本 +- 新保存脚本带上 code 快照 +- 页面上明确提示当前绑定实例 + +### 第 3 步:增加绑定修复机制 + +当出现以下情况时提示用户: + +- `profileId` 不存在 +- `code` 已指向别的实例 +- `profileId` 与 `code` 对不上 + +这样可以把“静默跑错实例”的风险降下来。 + +## 最终判断 + +如果问题是: + +`profileId 适不适合继续当用户主视角字段?` + +答案是: + +`不适合。` + +如果问题是: + +`内部实现要不要彻底放弃 profileId,全部换成 code?` + +答案是: + +`也不建议。` + +最合适的落地结论是: + +`产品层改成 code-first,系统层继续保留 profileId-first。` + +这既符合你的直觉,也符合当前代码库的稳定性要求。 diff --git a/backend/app.go b/backend/app.go index 9e7c4dde..cd83d0fa 100644 --- a/backend/app.go +++ b/backend/app.go @@ -1,7 +1,7 @@ package backend import ( - "ant-chrome/backend/internal/apppath" + "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/database" @@ -9,19 +9,8 @@ import ( "ant-chrome/backend/internal/logger" "ant-chrome/backend/internal/proxy" "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - goruntime "runtime" - "runtime/debug" - "strconv" "strings" "sync" - "time" - - "github.com/wailsapp/wails/v2/pkg/runtime" ) type quitMode uint8 @@ -43,17 +32,20 @@ type App struct { singboxMgr *proxy.SingBoxManager launchCodeSvc *launchcode.LaunchCodeService launchServer *launchcode.LaunchServer + automationMgr *automation.Manager speedScheduler *browser.ProxySpeedScheduler appRoot string version string - forceQuit bool // 强制退出标志,用于跳过 OnBeforeClose 的拦截 - quitMode quitMode // 退出模式:全量退出 / 仅退出应用 - maintenanceMu sync.Mutex // 维护类操作(初始化/导入/导出)互斥锁 - bridgeMu sync.Mutex - xrayBridgeRefs map[string]string - stopServicesOnce sync.Once - finalizeOnce sync.Once + forceQuit bool + quitMode quitMode + maintenanceMu sync.Mutex + bridgeMu sync.Mutex + xrayBridgeRefs map[string]string + automationTargetMu sync.Mutex + automationTargetCursor map[string]string + stopServicesOnce sync.Once + finalizeOnce sync.Once } // NewApp 创建新的应用实例 @@ -63,9 +55,10 @@ func NewApp(appRoot string, appVersion ...string) *App { version = strings.TrimSpace(appVersion[0]) } return &App{ - appRoot: strings.TrimSpace(appRoot), - version: version, - xrayBridgeRefs: make(map[string]string), + appRoot: strings.TrimSpace(appRoot), + version: version, + xrayBridgeRefs: make(map[string]string), + automationTargetCursor: make(map[string]string), } } @@ -85,1212 +78,3 @@ func (a *App) appVersion() string { } return version } - -// startup 应用启动时调用 -func (a *App) startup(ctx context.Context) { - a.ctx = ctx - if err := apppath.EnsureWritableLayout(a.appRoot); err != nil { - runtime.LogFatal(ctx, fmt.Sprintf("初始化 Linux 用户数据目录失败: %v", err)) - return - } - cfg, err := LoadConfig(a.resolveAppPath("config.yaml")) - if err != nil { - cfg = config.DefaultConfig() - } - a.config = cfg - a.applyRuntimeConfig(cfg.Runtime) - - logConfig := logger.LoggerConfig{ - Level: cfg.Logging.Level, - FileEnabled: cfg.Logging.FileEnabled, - FilePath: a.resolveAppPath(cfg.Logging.FilePath), - Format: cfg.Logging.Format, - BufferSize: cfg.Logging.BufferSize, - AsyncQueueSize: cfg.Logging.AsyncQueueSize, - FlushIntervalMs: cfg.Logging.FlushIntervalMs, - Rotation: logger.RotationConfig{ - Enabled: cfg.Logging.Rotation.Enabled, - MaxSizeMB: cfg.Logging.Rotation.MaxSizeMB, - MaxAge: cfg.Logging.Rotation.MaxAge, - MaxBackups: cfg.Logging.Rotation.MaxBackups, - TimeInterval: cfg.Logging.Rotation.TimeInterval, - }, - } - logger.InitWithConfig(ctx, logConfig) - - log := logger.New("App") - log.Info("应用启动中...", - logger.F("version", a.appVersion()), - logger.F("max_memory_mb", cfg.Runtime.MaxMemoryMB), - logger.F("gc_percent", cfg.Runtime.GCPercent), - ) - if apppath.IsDetached(a.appRoot) { - log.Info("检测到安装目录需要只读运行,已切换到用户数据目录", - logger.F("install_root", apppath.InstallRoot(a.appRoot)), - logger.F("state_root", apppath.StateRoot(a.appRoot)), - ) - } - - // 确保 data 目录存在(存放数据库、用户数据、快照等) - if err := os.MkdirAll(a.resolveAppPath("data"), 0755); err != nil { - log.Error("创建 data 目录失败", logger.F("error", err)) - } - - a.ensureDefaultCores() - - if cfg.Logging.Interceptor.Enabled { - interceptorConfig := logger.InterceptorConfig{ - Enabled: cfg.Logging.Interceptor.Enabled, - LogParameters: cfg.Logging.Interceptor.LogParameters, - LogResults: cfg.Logging.Interceptor.LogResults, - SensitiveFields: cfg.Logging.Interceptor.SensitiveFields, - } - a.interceptor = logger.NewMethodInterceptor(log, interceptorConfig) - } - - db, err := database.NewDB(a.resolveAppPath(cfg.Database.SQLite.Path)) - if err != nil { - log.Error("初始化数据库失败", logger.F("error", err)) - runtime.LogFatal(ctx, fmt.Sprintf("初始化数据库失败: %v", err)) - return - } - a.db = db - if err := db.Migrate(); err != nil { - log.Error("数据库迁移失败", logger.F("error", err)) - } - - a.browserMgr = browser.NewManager(cfg, a.appRoot) - a.xrayMgr = proxy.NewXrayManager(cfg, a.appRoot) - a.clashMgr = proxy.NewClashManager(cfg, a.appRoot) - a.singboxMgr = proxy.NewSingBoxManager(cfg, a.appRoot) - - // 注入 DAO(必须在 InitData 之前) - conn := db.GetConn() - a.browserMgr.ProfileDAO = browser.NewSQLiteProfileDAO(conn) - a.browserMgr.ProxyDAO = browser.NewSQLiteProxyDAO(conn) - a.browserMgr.CoreDAO = browser.NewSQLiteCoreDAO(conn) - a.browserMgr.BookmarkDAO = browser.NewSQLiteBookmarkDAO(conn) - a.browserMgr.GroupDAO = browser.NewSQLiteGroupDAO(conn) - - // 一次性迁移:若 SQLite 表为空则从旧文件导入 - a.migrateToSQLite() - - a.browserMgr.InitData() - a.autoDetectCores() - a.loadProxies() - a.reconcileProfileProxyBindings() - - // 初始化 LaunchCode 服务 - launchCodeDAO := launchcode.NewSQLiteLaunchCodeDAO(a.db.GetConn()) - a.launchCodeSvc = launchcode.NewLaunchCodeService(launchCodeDAO) - if err := a.launchCodeSvc.LoadAll(); err != nil { - log.Error("LaunchCode 加载失败", logger.F("error", err)) - } - a.browserMgr.CodeProvider = a.launchCodeSvc - - // 启动 LaunchServer - port := a.config.LaunchServer.Port - a.launchServer = launchcode.NewLaunchServer(a.launchCodeSvc, a, a.browserMgr, port) - a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ - Enabled: a.config.LaunchServer.Auth.Enabled, - APIKey: a.config.LaunchServer.Auth.APIKey, - Header: a.config.LaunchServer.Auth.Header, - }) - if err := a.launchServer.Start(); err != nil { - log.Error("LaunchServer 启动失败", logger.F("error", err)) - } else { - log.Info("LaunchServer 监听地址", - logger.F("url", fmt.Sprintf("http://127.0.0.1:%d", a.launchServer.Port())), - logger.F("preferred_port", port), - ) - } - - // 连接池失效通知 - a.xrayMgr.OnBridgeDied = func(key string, err error) { - if a.ctx != nil { - runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{ - "engine": "xray", - "key": key[:8], - "error": err.Error(), - }) - } - } - a.singboxMgr.OnBridgeDied = func(key string, err error) { - if a.ctx != nil { - runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{ - "engine": "singbox", - "key": key[:8], - "error": err.Error(), - }) - } - } - - // 启动代理测速定时调度器(每5分钟一轮,并发5) - a.speedScheduler = browser.NewProxySpeedScheduler( - a.browserMgr.ProxyDAO, - func(proxyId string) (bool, int64, string) { - r := proxy.SpeedTest(proxyId, a.config.Browser.Proxies, a.xrayMgr, a.singboxMgr, nil) - return r.Ok, r.LatencyMs, r.Error - }, - 5*time.Minute, - 5, - ) - a.speedScheduler.Start() - - log.Info("应用启动成功") -} - -// ReloadConfig 开放给前端重新读取配置,用于应对手动修补后的配置重载 -func (a *App) ReloadConfig() error { - log := logger.New("App") - cfg, err := LoadConfig(a.resolveAppPath("config.yaml")) - if err != nil { - log.Error("重载配置文件失败", logger.F("error", err)) - return fmt.Errorf("重载配置文件失败: %w", err) - } - - a.config = cfg - a.applyRuntimeConfig(cfg.Runtime) - // Update browser manager config reference - if a.browserMgr != nil { - a.browserMgr.Config = cfg - a.browserMgr.ListCores() - a.loadProxies() - a.reconcileProfileProxyBindings() - } - if a.xrayMgr != nil { - a.xrayMgr.Config = cfg - } - if a.clashMgr != nil { - a.clashMgr.Config = cfg - } - if a.singboxMgr != nil { - a.singboxMgr.Config = cfg - } - if a.launchServer != nil { - a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ - Enabled: cfg.LaunchServer.Auth.Enabled, - APIKey: cfg.LaunchServer.Auth.APIKey, - Header: cfg.LaunchServer.Auth.Header, - }) - } - - log.Info("前端触发配置重载成功") - return nil -} - -func (a *App) applyRuntimeConfig(cfg config.RuntimeConfig) { - if cfg.GCPercent > 0 { - debug.SetGCPercent(cfg.GCPercent) - } - if cfg.MaxMemoryMB > 0 { - maxMemoryBytes := int64(cfg.MaxMemoryMB) * 1024 * 1024 - debug.SetMemoryLimit(maxMemoryBytes) - return - } - // 0 表示禁用自定义软限制,避免 ReloadConfig 后残留旧的 GOMEMLIMIT。 - debug.SetMemoryLimit(1 << 60) -} - -func (a *App) shutdown(ctx context.Context) { - log := logger.New("App") - if a.shouldStopRuntimeServicesOnShutdown() { - log.Info("应用正在关闭...") - a.stopRuntimeServices() - } else { - log.Info("应用正在关闭(保留当前已打开的浏览器实例)...") - } - a.finalizeShutdown() -} - -func (a *App) GetInterceptor() *logger.MethodInterceptor { - return a.interceptor -} - -// ForceQuit 设置强制退出标志并调用 runtime.Quit -func (a *App) ForceQuit() { - a.setQuitMode(quitModeFull) - a.stopRuntimeServices() - if a.ctx != nil { - runtime.Quit(a.ctx) - } -} - -// QuitAppOnly 仅退出应用本身,保留当前已打开的浏览器实例。 -func (a *App) QuitAppOnly() { - a.setQuitMode(quitModeAppOnly) - if a.ctx != nil { - runtime.Quit(a.ctx) - } -} - -func Start(a *App, ctx context.Context) { - a.startup(ctx) -} - -func Stop(a *App, ctx context.Context) { - a.shutdown(ctx) -} - -func platformSupportsTrayCloseFlow() bool { - return platformSupportsTrayCloseFlowForOS(goruntime.GOOS) -} - -func platformSupportsTrayCloseFlowForOS(goos string) bool { - return strings.EqualFold(strings.TrimSpace(goos), "windows") -} - -func (a *App) setQuitMode(mode quitMode) { - a.forceQuit = true - a.quitMode = mode -} - -func (a *App) shouldStopRuntimeServicesOnShutdown() bool { - return a.quitMode != quitModeAppOnly -} - -func ShouldBlockClose(a *App, ctx context.Context) bool { - if a.forceQuit { - return false - } - if !platformSupportsTrayCloseFlow() { - return false - } - runtime.EventsEmit(ctx, "app:request-close") - return true -} - -func (a *App) bindProfileXrayBridge(profileId string, bridgeKey string) { - profileId = strings.TrimSpace(profileId) - bridgeKey = strings.TrimSpace(bridgeKey) - if profileId == "" || bridgeKey == "" { - return - } - - a.bridgeMu.Lock() - a.xrayBridgeRefs[profileId] = bridgeKey - a.bridgeMu.Unlock() -} - -func (a *App) releaseProfileXrayBridge(profileId string) { - profileId = strings.TrimSpace(profileId) - if profileId == "" { - return - } - - a.bridgeMu.Lock() - bridgeKey := a.xrayBridgeRefs[profileId] - delete(a.xrayBridgeRefs, profileId) - a.bridgeMu.Unlock() - - if bridgeKey != "" && a.xrayMgr != nil { - a.xrayMgr.ReleaseBridge(bridgeKey) - } -} - -func (a *App) clearProfileXrayBridges() { - a.bridgeMu.Lock() - a.xrayBridgeRefs = make(map[string]string) - a.bridgeMu.Unlock() -} - -// ============================================================================ -// 仪表盘 API -// ============================================================================ - -func (a *App) GetDashboardStats() map[string]interface{} { - profiles := a.browserMgr.List() - totalInstances := len(profiles) - runningInstances := 0 - for _, p := range profiles { - if p.Running { - runningInstances++ - } - } - proxyCount := len(a.config.Browser.Proxies) - coreCount := len(a.config.Browser.Cores) - - var mem goruntime.MemStats - goruntime.ReadMemStats(&mem) - memUsedMB := float64(mem.Alloc) / 1024 / 1024 - - return map[string]interface{}{ - "totalInstances": totalInstances, - "runningInstances": runningInstances, - "proxyCount": proxyCount, - "coreCount": coreCount, - "memUsedMB": int(memUsedMB), - "appVersion": a.appVersion(), - } -} - -func (a *App) GetAppConfig() map[string]interface{} { - return map[string]interface{}{ - "name": a.appName(), - "version": a.appVersion(), - } -} - -func (a *App) GetMemoryStats() map[string]interface{} { - var m goruntime.MemStats - goruntime.ReadMemStats(&m) - return map[string]interface{}{ - "alloc_mb": float64(m.Alloc) / 1024 / 1024, - "total_alloc_mb": float64(m.TotalAlloc) / 1024 / 1024, - "sys_mb": float64(m.Sys) / 1024 / 1024, - "num_gc": m.NumGC, - "limit_mb": a.config.Runtime.MaxMemoryMB, - "gc_percent": a.config.Runtime.GCPercent, - } -} - -func (a *App) TriggerGC() { goruntime.GC() } -func (a *App) SetLogLevel(level string) { logger.SetGlobalLevelString(level) } -func (a *App) GetLogLevel() string { return logger.New("App").GetLevel().String() } - -// GetAppLogs 获取内存缓冲日志 -func (a *App) GetAppLogs() []logger.MemoryLogEntry { - return logger.GetMemoryWriter().GetEntries() -} - -// ClearAppLogs 清空内存缓冲日志 -func (a *App) ClearAppLogs() { - logger.GetMemoryWriter().Clear() -} - -// GetRunningInstances 获取运行中实例的详细信息 -func (a *App) GetRunningInstances() []BrowserProfile { - all := a.browserMgr.List() - result := make([]BrowserProfile, 0) - for _, p := range all { - if p.Running { - result = append(result, p) - } - } - return result -} - -// ============================================================================ -// 浏览器类型别名 (保持 Wails 绑定兼容) -// ============================================================================ - -type BrowserProfile = browser.Profile -type BrowserProfileInput = browser.ProfileInput -type BrowserTab = browser.Tab -type BrowserSettings = browser.Settings -type BrowserProxy = browser.Proxy -type BrowserCore = browser.Core -type BrowserCoreInput = browser.CoreInput -type BrowserCoreValidateResult = browser.CoreValidateResult -type BrowserCoreExtendedInfo = browser.CoreExtendedInfo - -// ============================================================================ -// 浏览器配置 API -// ============================================================================ - -// BrowserProfileList 获取所有实例列表 -func (a *App) BrowserProfileList() []BrowserProfile { return a.browserMgr.List() } - -// BrowserProfileListByTag 按标签筛选实例列表 -func (a *App) BrowserProfileListByTag(tag string) []BrowserProfile { - return a.browserMgr.ListByTag(tag) -} - -// BrowserGetAllTags 获取所有已使用的标签 -func (a *App) BrowserGetAllTags() []string { - return a.browserMgr.GetAllTags() -} - -// BrowserProfileSetKeywords 设置实例关键字 -func (a *App) BrowserProfileSetKeywords(profileId string, keywords []string) (*BrowserProfile, error) { - return a.browserMgr.SetKeywords(profileId, keywords) -} - -func (a *App) BrowserProfileCreate(input BrowserProfileInput) (*BrowserProfile, error) { - return a.browserMgr.Create(input) -} - -func (a *App) BrowserProfileUpdate(profileId string, input BrowserProfileInput) (*BrowserProfile, error) { - return a.browserMgr.Update(profileId, input) -} - -func (a *App) BrowserProfileDelete(profileId string) error { return a.browserMgr.Delete(profileId) } - -// BrowserProfileCopy 复制实例配置(除指纹参数外全部复制) -func (a *App) BrowserProfileCopy(profileId string, newName string) (*BrowserProfile, error) { - return a.browserMgr.Copy(profileId, newName) -} - -// ============================================================================ -// 浏览器设置 API -// ============================================================================ - -func (a *App) GetBrowserSettings() BrowserSettings { - return BrowserSettings{ - UserDataRoot: a.config.Browser.UserDataRoot, - DefaultFingerprintArgs: append([]string{}, a.config.Browser.DefaultFingerprintArgs...), - DefaultLaunchArgs: append([]string{}, a.config.Browser.DefaultLaunchArgs...), - DefaultProxy: a.config.Browser.DefaultProxy, - StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config), - StartStableWindowMs: browserStartStableWindowMillis(a.config), - } -} - -func (a *App) SaveBrowserSettings(settings BrowserSettings) error { - log := logger.New("Browser") - a.config.Browser.UserDataRoot = strings.TrimSpace(settings.UserDataRoot) - a.config.Browser.DefaultFingerprintArgs = append([]string{}, settings.DefaultFingerprintArgs...) - a.config.Browser.DefaultLaunchArgs = append([]string{}, settings.DefaultLaunchArgs...) - a.config.Browser.DefaultProxy = strings.TrimSpace(settings.DefaultProxy) - if settings.StartReadyTimeoutMs > 0 { - a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs - } else if a.config.Browser.StartReadyTimeoutMs <= 0 { - a.config.Browser.StartReadyTimeoutMs = browserStartReadyTimeoutMillis(nil) - } - if settings.StartStableWindowMs > 0 { - a.config.Browser.StartStableWindowMs = settings.StartStableWindowMs - } else if a.config.Browser.StartStableWindowMs <= 0 { - a.config.Browser.StartStableWindowMs = browserStartStableWindowMillis(nil) - } - if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { - log.Error("浏览器配置保存失败", logger.F("error", err)) - return err - } - return nil -} - -// ============================================================================ -// 内核管理 API -// ============================================================================ - -func (a *App) BrowserCoreList() []BrowserCore { - return a.browserMgr.ListCores() -} - -func (a *App) BrowserCoreSave(input BrowserCoreInput) error { - return a.browserMgr.SaveCore(input) -} - -func (a *App) BrowserCoreDelete(coreId string) error { - return a.browserMgr.DeleteCore(coreId) -} - -func (a *App) BrowserCoreSetDefault(coreId string) error { - return a.browserMgr.SetDefaultCore(coreId) -} - -func (a *App) BrowserCoreValidate(corePath string) BrowserCoreValidateResult { - return a.browserMgr.ValidateCorePath(corePath) -} - -func (a *App) BrowserCoreExtendedInfo() []BrowserCoreExtendedInfo { - return a.browserMgr.GetCoresExtendedInfo() -} - -// BrowserCoreScan 重新扫描 chrome 目录,自动注册新内核 -func (a *App) BrowserCoreScan() []BrowserCore { - a.autoDetectCores() - return a.browserMgr.ListCores() -} - -// BrowserCoreDownload 在线下载并自动解压配置内核 -func (a *App) BrowserCoreDownload(coreName, url, proxyConfig string) error { - if a.ctx == nil { - return fmt.Errorf("app context is nil") - } - // 异步启动下载流程,以防阻塞前端请求,通过 Wails events 发送进度 - go a.browserMgr.DownloadAndExtractCore(a.ctx, coreName, url, proxyConfig) - return nil -} - -// ============================================================================ -// 代理池 API -// ============================================================================ - -// ProxyValidationResult 代理验证结果 -type ProxyValidationResult struct { - Supported bool `json:"supported"` - ErrorMsg string `json:"errorMsg"` -} - -func (a *App) BrowserProxyList() []BrowserProxy { - if a.browserMgr.ProxyDAO != nil { - if list, err := a.browserMgr.ProxyDAO.List(); err == nil { - return list - } - } - return append([]BrowserProxy{}, a.config.Browser.Proxies...) -} - -// BrowserProxyListGroups 获取所有代理分组名称 -func (a *App) BrowserProxyListGroups() []string { - if a.browserMgr.ProxyDAO != nil { - if groups, err := a.browserMgr.ProxyDAO.ListGroups(); err == nil { - return groups - } - } - return nil -} - -// BrowserProxyListByGroup 按分组名称查询代理 -func (a *App) BrowserProxyListByGroup(groupName string) []BrowserProxy { - if a.browserMgr.ProxyDAO != nil { - if list, err := a.browserMgr.ProxyDAO.ListByGroup(groupName); err == nil { - return list - } - } - // 降级:内存过滤 - var result []BrowserProxy - for _, p := range a.config.Browser.Proxies { - if p.GroupName == groupName { - result = append(result, p) - } - } - return result -} - -// ValidateProxyConfig 验证代理配置是否支持 -func (a *App) ValidateProxyConfig(proxyConfig string, proxyId string) ProxyValidationResult { - proxies := a.getLatestProxies() - supported, errorMsg := proxy.ValidateProxyConfig(proxyConfig, proxies, proxyId) - return ProxyValidationResult{ - Supported: supported, - ErrorMsg: errorMsg, - } -} - -// ProxyTestResult 代理测试结果 -type ProxyTestResult struct { - ProxyId string `json:"proxyId"` - Ok bool `json:"ok"` - LatencyMs int64 `json:"latencyMs"` - Error string `json:"error"` -} - -// ProxyIPHealthResult 代理出口 IP 健康信息(透传第三方接口结果) -type ProxyIPHealthResult struct { - ProxyId string `json:"proxyId"` - Ok bool `json:"ok"` - Source string `json:"source"` - Error string `json:"error"` - IP string `json:"ip"` - FraudScore int64 `json:"fraudScore"` - IsResidential bool `json:"isResidential"` - IsBroadcast bool `json:"isBroadcast"` - Country string `json:"country"` - Region string `json:"region"` - City string `json:"city"` - AsOrganization string `json:"asOrganization"` - RawData map[string]interface{} `json:"rawData"` - UpdatedAt string `json:"updatedAt"` -} - -// TestProxyConnectivity 测试代理连通性 -func (a *App) TestProxyConnectivity(proxyId string, proxyConfig string) ProxyTestResult { - proxies := a.getLatestProxies() - r := proxy.TestConnectivity(proxyId, proxyConfig, proxies, nil) - return ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error} -} - -// TestProxyRealConnectivity 通过真实 HTTP 请求测试代理连通性(Wails 绑定) -// 参考 Clash URLTest 策略:多 URL fallback + 复用桥接 + TCP ping 降级 -func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult { - proxies := a.getLatestProxies() - r := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil) - return ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error} -} - -// BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果 -func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult { - proxies := a.getLatestProxies() - r := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil) - if a.browserMgr.ProxyDAO != nil { - testedAt := time.Now().Format(time.RFC3339) - _ = a.browserMgr.ProxyDAO.UpdateSpeedResult(proxyId, r.Ok, r.LatencyMs, testedAt) - } - return ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error} -} - -// BrowserProxyBatchTestSpeed 批量并发测速,concurrency 控制并发数(默认 20) -func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []ProxyTestResult { - if len(proxyIds) == 0 { - return []ProxyTestResult{} - } - if concurrency <= 0 { - concurrency = 20 - } - if concurrency > len(proxyIds) { - concurrency = len(proxyIds) - } - proxies := a.getLatestProxies() - results := make([]ProxyTestResult, len(proxyIds)) - type speedJob struct { - Idx int - ProxyId string - } - jobs := make(chan speedJob, len(proxyIds)) - var wg sync.WaitGroup - - // 固定大小 worker 池,避免大量代理时创建过多 goroutine - for worker := 0; worker < concurrency; worker++ { - wg.Add(1) - go func() { - defer wg.Done() - for job := range jobs { - r := proxy.SpeedTest(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, nil) - if a.browserMgr.ProxyDAO != nil { - testedAt := time.Now().Format(time.RFC3339) - _ = a.browserMgr.ProxyDAO.UpdateSpeedResult(job.ProxyId, r.Ok, r.LatencyMs, testedAt) - } - result := ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error} - results[job.Idx] = result - - // 实时推送单个结果到前端 - if a.ctx != nil { - runtime.EventsEmit(a.ctx, "proxy:speed:result", result) - } - } - }() - } - - for i, pid := range proxyIds { - jobs <- speedJob{Idx: i, ProxyId: pid} - } - close(jobs) - - wg.Wait() - return results -} - -// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息(通过 IPPure 接口) -func (a *App) BrowserProxyCheckIPHealth(proxyId string) ProxyIPHealthResult { - proxies := a.getLatestProxies() - data, err := proxy.FetchIPPureInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr) - result := buildProxyIPHealthResult(proxyId, data, err) - a.persistProxyIPHealthResult(result) - if a.ctx != nil { - runtime.EventsEmit(a.ctx, "proxy:iphealth:result", result) - } - return result -} - -// BrowserProxyBatchCheckIPHealth 批量并发检测代理出口 IP 健康信息 -func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int) []ProxyIPHealthResult { - if len(proxyIds) == 0 { - return []ProxyIPHealthResult{} - } - if concurrency <= 0 { - concurrency = 10 - } - if concurrency > len(proxyIds) { - concurrency = len(proxyIds) - } - - proxies := a.getLatestProxies() - results := make([]ProxyIPHealthResult, len(proxyIds)) - type healthJob struct { - Idx int - ProxyId string - } - jobs := make(chan healthJob, len(proxyIds)) - var wg sync.WaitGroup - - for worker := 0; worker < concurrency; worker++ { - wg.Add(1) - go func() { - defer wg.Done() - for job := range jobs { - data, err := proxy.FetchIPPureInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr) - result := buildProxyIPHealthResult(job.ProxyId, data, err) - a.persistProxyIPHealthResult(result) - results[job.Idx] = result - if a.ctx != nil { - runtime.EventsEmit(a.ctx, "proxy:iphealth:result", result) - } - } - }() - } - - for i, pid := range proxyIds { - jobs <- healthJob{Idx: i, ProxyId: pid} - } - close(jobs) - - wg.Wait() - return results -} - -func buildProxyIPHealthResult(proxyId string, data map[string]interface{}, err error) ProxyIPHealthResult { - if err != nil { - return ProxyIPHealthResult{ - ProxyId: proxyId, - Ok: false, - Source: "ippure", - Error: err.Error(), - RawData: map[string]interface{}{}, - UpdatedAt: time.Now().Format(time.RFC3339), - } - } - - if data == nil { - data = map[string]interface{}{} - } - - return ProxyIPHealthResult{ - ProxyId: proxyId, - Ok: true, - Source: "ippure", - Error: "", - IP: mapString(data, "ip"), - FraudScore: mapInt64(data, "fraudScore"), - IsResidential: mapBool(data, "isResidential"), - IsBroadcast: mapBool(data, "isBroadcast"), - Country: mapString(data, "country"), - Region: mapString(data, "region"), - City: mapString(data, "city"), - AsOrganization: mapString(data, "asOrganization"), - RawData: data, - UpdatedAt: time.Now().Format(time.RFC3339), - } -} - -func (a *App) persistProxyIPHealthResult(result ProxyIPHealthResult) { - if a.browserMgr.ProxyDAO == nil { - return - } - payload, err := json.Marshal(result) - if err != nil { - return - } - _ = a.browserMgr.ProxyDAO.UpdateIPHealthResult(result.ProxyId, string(payload)) -} - -func mapString(m map[string]interface{}, key string) string { - v, ok := m[key] - if !ok || v == nil { - return "" - } - switch s := v.(type) { - case string: - return s - default: - return fmt.Sprint(v) - } -} - -func mapInt64(m map[string]interface{}, key string) int64 { - v, ok := m[key] - if !ok || v == nil { - return 0 - } - switch n := v.(type) { - case int: - return int64(n) - case int8: - return int64(n) - case int16: - return int64(n) - case int32: - return int64(n) - case int64: - return n - case uint: - return int64(n) - case uint8: - return int64(n) - case uint16: - return int64(n) - case uint32: - return int64(n) - case uint64: - return int64(n) - case float32: - return int64(n) - case float64: - return int64(n) - case json.Number: - if iv, err := n.Int64(); err == nil { - return iv - } - if fv, err := n.Float64(); err == nil { - return int64(fv) - } - case string: - if iv, err := strconv.ParseInt(n, 10, 64); err == nil { - return iv - } - if fv, err := strconv.ParseFloat(n, 64); err == nil { - return int64(fv) - } - } - return 0 -} - -func mapBool(m map[string]interface{}, key string) bool { - v, ok := m[key] - if !ok || v == nil { - return false - } - switch b := v.(type) { - case bool: - return b - case string: - return strings.EqualFold(b, "true") || b == "1" - case int: - return b != 0 - case int64: - return b != 0 - case float64: - return b != 0 - } - return false -} - -// getLatestProxies 获取最新的代理列表,优先从数据库读取 -func (a *App) getLatestProxies() []BrowserProxy { - if a.browserMgr.ProxyDAO != nil { - if list, err := a.browserMgr.ProxyDAO.List(); err == nil && len(list) > 0 { - return list - } - } - return a.config.Browser.Proxies -} - -func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error { - log := logger.New("Browser") - normalized := make([]BrowserProxy, 0, len(proxies)) - for i, item := range proxies { - proxyName := strings.TrimSpace(item.ProxyName) - proxyConfig := strings.TrimSpace(item.ProxyConfig) - if proxyName == "" || proxyConfig == "" { - continue - } - proxyId := strings.TrimSpace(item.ProxyId) - if proxyId == "" { - proxyId = generateUUID() - } - sourceURL := strings.TrimSpace(item.SourceURL) - sourceID := strings.TrimSpace(item.SourceID) - sourceNamePrefix := strings.TrimSpace(item.SourceNamePrefix) - sourceLastRefreshAt := strings.TrimSpace(item.SourceLastRefreshAt) - sourceRefreshIntervalM := item.SourceRefreshIntervalM - if sourceRefreshIntervalM < 0 { - sourceRefreshIntervalM = 0 - } - if sourceRefreshIntervalM > 24*60 { - sourceRefreshIntervalM = 24 * 60 - } - sourceAutoRefresh := item.SourceAutoRefresh && sourceURL != "" - if sourceAutoRefresh && sourceRefreshIntervalM <= 0 { - sourceRefreshIntervalM = 60 - } - if !sourceAutoRefresh { - sourceRefreshIntervalM = 0 - } - if sourceURL == "" { - sourceID = "" - sourceNamePrefix = "" - sourceLastRefreshAt = "" - sourceAutoRefresh = false - sourceRefreshIntervalM = 0 - } - normalized = append(normalized, BrowserProxy{ - ProxyId: proxyId, - ProxyName: proxyName, - ProxyConfig: proxyConfig, - DnsServers: strings.TrimSpace(item.DnsServers), - GroupName: strings.TrimSpace(item.GroupName), - SourceID: sourceID, - SourceURL: sourceURL, - SourceNamePrefix: sourceNamePrefix, - SourceAutoRefresh: sourceAutoRefresh, - SourceRefreshIntervalM: sourceRefreshIntervalM, - SourceLastRefreshAt: sourceLastRefreshAt, - SortOrder: i, - }) - } - - // 确保内置代理始终存在(直连 + 本地代理) - builtins := []BrowserProxy{ - {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, - } - for _, b := range builtins { - found := false - for _, p := range normalized { - if p.ProxyId == b.ProxyId { - found = true - break - } - } - if !found { - normalized = append([]BrowserProxy{b}, normalized...) - } - } - - a.config.Browser.Proxies = normalized - - // 优先写入 SQLite - if a.browserMgr.ProxyDAO != nil { - if err := a.browserMgr.ProxyDAO.DeleteAll(); err != nil { - log.Error("清空代理表失败", logger.F("error", err)) - return err - } - for _, p := range normalized { - if err := a.browserMgr.ProxyDAO.Upsert(p); err != nil { - log.Error("代理保存失败", logger.F("proxy_id", p.ProxyId), logger.F("error", err)) - return err - } - } - log.Info("代理列表已保存到数据库", logger.F("count", len(normalized))) - a.reconcileProfileProxyBindings() - return nil - } - - // 降级:写入 proxies.yaml - if err := config.SaveProxies(a.resolveAppPath("proxies.yaml"), normalized); err != nil { - log.Error("代理列表保存失败", logger.F("error", err)) - return err - } - a.reconcileProfileProxyBindings() - return nil -} - -// ============================================================================ -// 文件系统 API -// ============================================================================ - -// OpenUserDataDir 在资源管理器中打开用户数据目录 -func (a *App) OpenUserDataDir(userDataDir string) error { - log := logger.New("Browser") - - // 解析完整路径 - userDataDir = strings.TrimSpace(userDataDir) - if userDataDir == "" { - return fmt.Errorf("用户数据目录不能为空") - } - - var fullPath string - if filepath.IsAbs(userDataDir) { - fullPath = userDataDir - } else { - root := strings.TrimSpace(a.config.Browser.UserDataRoot) - if root == "" { - root = "data" - } - root = a.resolveAppPath(root) - fullPath = filepath.Join(root, userDataDir) - } - - // 检查目录是否存在 - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - // 目录不存在,尝试创建 - if err := os.MkdirAll(fullPath, 0755); err != nil { - log.Error("创建用户数据目录失败", logger.F("path", fullPath), logger.F("error", err)) - return fmt.Errorf("创建目录失败: %v", err) - } - } - - // 获取绝对路径 - absPath, err := filepath.Abs(fullPath) - if err != nil { - log.Error("获取绝对路径失败", logger.F("path", fullPath), logger.F("error", err)) - return err - } - - if err := openPathInFileManager(absPath); err != nil { - log.Error("打开资源管理器失败", logger.F("path", absPath), logger.F("error", err)) - return err - } - - log.Info("已打开用户数据目录", logger.F("path", absPath)) - return nil -} - -// OpenCorePath 在资源管理器中打开内核路径 -func (a *App) OpenCorePath(corePath string) error { - log := logger.New("Browser") - - corePath = strings.TrimSpace(corePath) - if corePath == "" { - return fmt.Errorf("内核路径不能为空") - } - - var fullPath string - if filepath.IsAbs(corePath) { - fullPath = corePath - } else { - fullPath = a.resolveAppPath(corePath) - } - - // 检查目录是否存在 - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - return fmt.Errorf("路径不存在: %s", fullPath) - } - - // 获取绝对路径 - absPath, err := filepath.Abs(fullPath) - if err != nil { - log.Error("获取绝对路径失败", logger.F("path", fullPath), logger.F("error", err)) - return err - } - - if err := openPathInFileManager(absPath); err != nil { - log.Error("打开资源管理器失败", logger.F("path", absPath), logger.F("error", err)) - return err - } - - log.Info("已打开内核路径", logger.F("path", absPath)) - return nil -} - -// openPathInFileManager 调用系统文件管理器打开路径。 -// Windows 下不能复用 hideWindow,否则可能导致资源管理器窗口被隐藏。 -func openPathInFileManager(absPath string) error { - info, err := os.Stat(absPath) - if err != nil { - return err - } - - switch goruntime.GOOS { - case "windows": - if info.IsDir() { - return exec.Command("explorer.exe", absPath).Start() - } - return exec.Command("explorer.exe", "/select,", absPath).Start() - case "darwin": - if info.IsDir() { - return exec.Command("open", absPath).Start() - } - return exec.Command("open", "-R", absPath).Start() - default: - target := absPath - if !info.IsDir() { - target = filepath.Dir(absPath) - } - return exec.Command("xdg-open", target).Start() - } -} - -// ============================================================================ -// 数据迁移 -// ============================================================================ - -// migrateToSQLite 一次性迁移:若 SQLite 表为空则从旧文件导入数据,或初始化默认数据 -// 迁移顺序:cores → proxies → profiles → bookmarks -func (a *App) migrateToSQLite() { - log := logger.New("Migration") - - // 迁移/初始化内核 - if cores, err := a.browserMgr.CoreDAO.List(); err == nil && len(cores) == 0 { - // 优先从 config.yaml 迁移 - if len(a.config.Browser.Cores) > 0 { - for _, c := range a.config.Browser.Cores { - if err := a.browserMgr.CoreDAO.Upsert(c); err != nil { - log.Error("内核迁移失败", logger.F("core_id", c.CoreId), logger.F("error", err)) - } - } - log.Info("内核数据已迁移", logger.F("count", len(a.config.Browser.Cores))) - } else { - // 初始化默认内核(自动检测会补充) - log.Info("内核表为空,将通过自动检测初始化") - } - } - - // 迁移/初始化代理 - if proxies, err := a.browserMgr.ProxyDAO.List(); err == nil && len(proxies) == 0 { - var srcProxies []browser.Proxy - // 优先 proxies.yaml,其次 config.yaml - if loaded, err := config.LoadProxies(a.resolveAppPath("proxies.yaml")); err == nil && len(loaded) > 0 { - srcProxies = loaded - } else if len(a.config.Browser.Proxies) > 0 { - srcProxies = a.config.Browser.Proxies - } else { - // 初始化默认代理 - srcProxies = []browser.Proxy{ - {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, - } - log.Info("代理表为空,初始化默认代理") - } - for _, p := range srcProxies { - if err := a.browserMgr.ProxyDAO.Upsert(p); err != nil { - log.Error("代理迁移失败", logger.F("proxy_id", p.ProxyId), logger.F("error", err)) - } - } - if len(srcProxies) > 0 { - log.Info("代理数据已初始化", logger.F("count", len(srcProxies))) - } - } - - // 迁移实例配置(如果为空则自动创建一个默认实例) - if profiles, err := a.browserMgr.ProfileDAO.List(); err == nil && len(profiles) == 0 { - if len(a.config.Browser.Profiles) > 0 { - for _, pc := range a.config.Browser.Profiles { - coreId := strings.TrimSpace(pc.CoreId) - if strings.EqualFold(coreId, "default") { - coreId = "" - } - p := &browser.Profile{ - ProfileId: pc.ProfileId, - ProfileName: pc.ProfileName, - UserDataDir: pc.UserDataDir, - CoreId: coreId, - FingerprintArgs: pc.FingerprintArgs, - ProxyId: pc.ProxyId, - ProxyConfig: pc.ProxyConfig, - ProxyBindSourceID: pc.ProxyBindSourceID, - ProxyBindSourceURL: pc.ProxyBindSourceURL, - ProxyBindName: pc.ProxyBindName, - ProxyBindUpdatedAt: pc.ProxyBindUpdatedAt, - LaunchArgs: pc.LaunchArgs, - Tags: pc.Tags, - Keywords: pc.Keywords, - CreatedAt: pc.CreatedAt, - UpdatedAt: pc.UpdatedAt, - } - if err := a.browserMgr.ProfileDAO.Upsert(p); err != nil { - log.Error("实例迁移失败", logger.F("profile_id", pc.ProfileId), logger.F("error", err)) - } - } - log.Info("实例数据已迁移", logger.F("count", len(a.config.Browser.Profiles))) - } else { - log.Info("实例表为空,自动创建默认实例") - defaultProfile := &browser.Profile{ - ProfileId: generateUUID(), - ProfileName: "默认实例", - UserDataDir: "default", - CoreId: "", - FingerprintArgs: a.config.Browser.DefaultFingerprintArgs, - LaunchArgs: a.config.Browser.DefaultLaunchArgs, - Tags: []string{"默认"}, - ProxyId: a.config.Browser.DefaultProxy, - CreatedAt: time.Now().Format(time.RFC3339), - UpdatedAt: time.Now().Format(time.RFC3339), - } - if err := a.browserMgr.ProfileDAO.Upsert(defaultProfile); err != nil { - log.Error("自动创建默认实例失败", logger.F("error", err)) - } - } - } - - // 迁移/初始化书签 - if bookmarks, err := a.browserMgr.BookmarkDAO.List(); err == nil && len(bookmarks) == 0 { - src := a.config.Browser.DefaultBookmarks - if len(src) == 0 { - // 初始化默认书签 - src = []config.BrowserBookmark{ - {Name: "Google", URL: "https://www.google.com/"}, - {Name: "Gmail", URL: "https://mail.google.com/"}, - {Name: "Claude", URL: "https://claude.ai/"}, - {Name: "ChatGPT", URL: "https://chatgpt.com/"}, - {Name: "YouTube", URL: "https://www.youtube.com/"}, - } - } - if err := a.browserMgr.BookmarkDAO.ReplaceAll(src); err != nil { - log.Error("书签迁移失败", logger.F("error", err)) - } else { - log.Info("书签数据已迁移", logger.F("count", len(src))) - } - } -} diff --git a/backend/app_backup_archive_helpers.go b/backend/app_backup_archive_helpers.go new file mode 100644 index 00000000..424cafe6 --- /dev/null +++ b/backend/app_backup_archive_helpers.go @@ -0,0 +1,47 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "path/filepath" + "strings" +) + +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 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 backupEnsureZipSuffix(path string) string { + if strings.EqualFold(filepath.Ext(path), ".zip") { + return path + } + return path + ".zip" +} diff --git a/backend/app_backup_archive_import.go b/backend/app_backup_archive_import.go new file mode 100644 index 00000000..dee66083 --- /dev/null +++ b/backend/app_backup_archive_import.go @@ -0,0 +1,81 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "ant-chrome/backend/internal/config" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +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 +} diff --git a/backend/app_backup_archive_write.go b/backend/app_backup_archive_write.go new file mode 100644 index 00000000..f3ed7bc5 --- /dev/null +++ b/backend/app_backup_archive_write.go @@ -0,0 +1,196 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "archive/zip" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +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 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 +} diff --git a/backend/app_backup_config.go b/backend/app_backup_config.go new file mode 100644 index 00000000..bfd69e76 --- /dev/null +++ b/backend/app_backup_config.go @@ -0,0 +1,268 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "database/sql" + "fmt" + "strings" +) + +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 (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 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_data_merge.go b/backend/app_backup_data_merge.go new file mode 100644 index 00000000..30ee8733 --- /dev/null +++ b/backend/app_backup_data_merge.go @@ -0,0 +1,214 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "fmt" + "os" + "path/filepath" + "strings" +) + +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() +} diff --git a/backend/app_backup_entry.go b/backend/app_backup_entry.go new file mode 100644 index 00000000..c320ff6d --- /dev/null +++ b/backend/app_backup_entry.go @@ -0,0 +1,112 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "fmt" + "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 + } + manifest := backup.BuildManifest(scope, a.appName(), a.appVersion(), 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 +} diff --git a/backend/app_backup_file_import.go b/backend/app_backup_file_import.go new file mode 100644 index 00000000..5e1196ec --- /dev/null +++ b/backend/app_backup_file_import.go @@ -0,0 +1,155 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +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 +} diff --git a/backend/app_backup_file_sync.go b/backend/app_backup_file_sync.go new file mode 100644 index 00000000..2f02ae2a --- /dev/null +++ b/backend/app_backup_file_sync.go @@ -0,0 +1,181 @@ +package backend + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +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 +} diff --git a/backend/app_backup_import_flow.go b/backend/app_backup_import_flow.go new file mode 100644 index 00000000..cc2fb36d --- /dev/null +++ b/backend/app_backup_import_flow.go @@ -0,0 +1,95 @@ +package backend + +import ( + "fmt" + "os" + "path/filepath" +) + +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) + issueTracker := newBackupImportTracker(componentEntries) + + 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 { + issueTracker.RecordIssue("system_config_main", "主配置文件", fmt.Errorf("解析配置失败: %w", err)) + incomingCfg = nil + hasIncomingCfg = false + } + if resetFirst && !hasIncomingCfg { + issueTracker.RecordIssue("system_config_main", "主配置文件", fmt.Errorf("备份包缺少 payload/system/config.yaml,已保留默认配置继续加载其余模块")) + } + + if hasIncomingCfg { + a.backupEmitImportProgress("importing", 58, "正在应用系统配置...") + if err := a.backupApplyIncomingConfig(incomingCfg, resetFirst); err != nil { + issueTracker.RecordIssue("system_config_main", "主配置文件", err) + } + } + + a.backupEmitImportProgress("importing", 66, "正在合并代理配置...") + if err := a.backupMergeProxiesFile(payloadRoot, resetFirst, stats); err != nil { + issueTracker.RecordIssue("system_config_proxies", "代理配置文件", err) + } + + if dbSrc := backupFindDatabaseFile(payloadRoot); dbSrc != "" { + a.backupEmitImportProgress("importing", 76, "正在合并数据库数据...") + if err := a.backupMergeDatabaseFromSource(dbSrc, resetFirst, stats); err != nil { + issueTracker.RecordIssue("database_sqlite_main", "SQLite 主数据库", err) + } + } else if _, ok := componentEntries["database_sqlite_main"]; ok { + issueTracker.RecordIssue("database_sqlite_main", "SQLite 主数据库", fmt.Errorf("备份包缺少数据库文件")) + } + + a.backupEmitImportProgress("importing", 86, "正在同步文件数据...") + a.backupImportFileTrees(payloadRoot, incomingCfg, resetFirst, stats, issueTracker.RecordIssue) + + a.backupEmitImportProgress("importing", 94, "正在刷新运行时配置...") + if err := a.backupReloadAfterMutation(); err != nil { + return nil, err + } + + totalComponents, successCount, failedCount, partial := issueTracker.Summary() + message := "加载完成" + if partial { + message = fmt.Sprintf("加载完成(部分成功):成功 %d 个模块,异常 %d 个模块", successCount, failedCount) + } + a.backupEmitImportProgress("done", 100, message) + + 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": issueTracker.FailedComponents(), + "message": message, + }, nil +} diff --git a/backend/app_backup_import_tracker.go b/backend/app_backup_import_tracker.go new file mode 100644 index 00000000..1b0127fc --- /dev/null +++ b/backend/app_backup_import_tracker.go @@ -0,0 +1,97 @@ +package backend + +import ( + "ant-chrome/backend/internal/backup" + "strings" +) + +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 backupImportTracker struct { + componentEntries map[string]backup.ManifestEntry + componentUniverse map[string]struct{} + failedComponentIDs map[string]struct{} + issues []backupImportIssue +} + +func newBackupImportTracker(componentEntries map[string]backup.ManifestEntry) *backupImportTracker { + universe := make(map[string]struct{}, len(componentEntries)) + for id := range componentEntries { + universe[id] = struct{}{} + } + + return &backupImportTracker{ + componentEntries: componentEntries, + componentUniverse: universe, + failedComponentIDs: make(map[string]struct{}), + issues: make([]backupImportIssue, 0), + } +} + +func (t *backupImportTracker) RecordIssue(componentID, componentName string, err error) { + if t == nil || err == nil { + return + } + + componentID = strings.TrimSpace(componentID) + componentName = strings.TrimSpace(componentName) + if componentID != "" { + t.componentUniverse[componentID] = struct{}{} + t.failedComponentIDs[componentID] = struct{}{} + if componentName == "" { + if entry, ok := t.componentEntries[componentID]; ok { + componentName = backupResolveManifestComponentName(entry) + } + } + } + if componentName == "" { + componentName = "未知模块" + } + + t.issues = append(t.issues, backupImportIssue{ + ComponentID: componentID, + ComponentName: componentName, + Error: err.Error(), + }) +} + +func (t *backupImportTracker) Summary() (totalComponents, successCount, failedCount int, partial bool) { + if t == nil { + return 0, 0, 0, false + } + + totalComponents = len(t.componentUniverse) + failedCount = len(t.failedComponentIDs) + successCount = totalComponents - failedCount + if successCount < 0 { + successCount = 0 + } + partial = failedCount > 0 + return totalComponents, successCount, failedCount, partial +} + +func (t *backupImportTracker) FailedComponents() []map[string]string { + if t == nil { + return nil + } + + failedComponents := make([]map[string]string, 0, len(t.issues)) + for _, item := range t.issues { + failedComponents = append(failedComponents, map[string]string{ + "componentId": item.ComponentID, + "componentName": item.ComponentName, + "error": item.Error, + }) + } + return failedComponents +} diff --git a/backend/app_backup_initialize.go b/backend/app_backup_initialize.go new file mode 100644 index 00000000..a81c9ecd --- /dev/null +++ b/backend/app_backup_initialize.go @@ -0,0 +1,67 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "strings" +) + +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 +} diff --git a/backend/app_backup_ops.go b/backend/app_backup_ops.go deleted file mode 100644 index f69f4201..00000000 --- a/backend/app_backup_ops.go +++ /dev/null @@ -1,1602 +0,0 @@ -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 - } - manifest := backup.BuildManifest(scope, a.appName(), a.appVersion(), 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_path_utils.go b/backend/app_backup_path_utils.go new file mode 100644 index 00000000..421981d0 --- /dev/null +++ b/backend/app_backup_path_utils.go @@ -0,0 +1,65 @@ +package backend + +import ( + "os" + "path/filepath" + "strings" +) + +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 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 +} diff --git a/backend/app_backup_progress.go b/backend/app_backup_progress.go new file mode 100644 index 00000000..e7d831c0 --- /dev/null +++ b/backend/app_backup_progress.go @@ -0,0 +1,78 @@ +package backend + +import ( + "strings" + "time" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +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, + }) +} diff --git a/backend/app_backup_runtime.go b/backend/app_backup_runtime.go new file mode 100644 index 00000000..be14ae45 --- /dev/null +++ b/backend/app_backup_runtime.go @@ -0,0 +1,85 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/proxy" + "os/exec" + "time" +) + +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 +} diff --git a/backend/app_bridge_refs.go b/backend/app_bridge_refs.go new file mode 100644 index 00000000..db70e609 --- /dev/null +++ b/backend/app_bridge_refs.go @@ -0,0 +1,37 @@ +package backend + +import "strings" + +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() +} diff --git a/backend/app_browser_config_api.go b/backend/app_browser_config_api.go new file mode 100644 index 00000000..b7fa933c --- /dev/null +++ b/backend/app_browser_config_api.go @@ -0,0 +1,87 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "fmt" + "strings" +) + +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...), + DefaultStartURLs: append([]string{}, a.config.Browser.DefaultStartURLs...), + RestoreLastSession: a.config.Browser.RestoreLastSession, + StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config), + StartStableWindowMs: browserStartStableWindowMillis(a.config), + } +} + +func (a *App) SaveBrowserSettings(settings BrowserSettings) error { + log := logger.New("Browser") + a.config.Browser.UserDataRoot = strings.TrimSpace(settings.UserDataRoot) + a.config.Browser.DefaultFingerprintArgs = append([]string{}, settings.DefaultFingerprintArgs...) + a.config.Browser.DefaultLaunchArgs = append([]string{}, settings.DefaultLaunchArgs...) + if settings.DefaultStartURLs != nil { + a.config.Browser.DefaultStartURLs = normalizeNonEmptyStrings(settings.DefaultStartURLs) + } else if a.config.Browser.DefaultStartURLs == nil { + a.config.Browser.DefaultStartURLs = config.DefaultBrowserStartURLs() + } + a.config.Browser.RestoreLastSession = settings.RestoreLastSession + if settings.StartReadyTimeoutMs > 0 { + a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs + } else if a.config.Browser.StartReadyTimeoutMs <= 0 { + a.config.Browser.StartReadyTimeoutMs = browserStartReadyTimeoutMillis(nil) + } + if settings.StartStableWindowMs > 0 { + a.config.Browser.StartStableWindowMs = settings.StartStableWindowMs + } else if a.config.Browser.StartStableWindowMs <= 0 { + a.config.Browser.StartStableWindowMs = browserStartStableWindowMillis(nil) + } + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + log.Error("浏览器配置保存失败", logger.F("error", err)) + return err + } + return nil +} + +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") + } + go a.browserMgr.DownloadAndExtractCore(a.ctx, coreName, url, proxyConfig) + return nil +} diff --git a/backend/app_browser_profile_api.go b/backend/app_browser_profile_api.go new file mode 100644 index 00000000..8e838efd --- /dev/null +++ b/backend/app_browser_profile_api.go @@ -0,0 +1,163 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "strings" + "time" +) + +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 + +// 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) +} + +// 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 { + 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 + if loaded, err := config.LoadProxies(a.resolveAppPath("proxies.yaml")); err == nil && len(loaded) > 0 { + srcProxies = loaded + } else if len(a.config.Browser.Proxies) > 0 { + srcProxies = a.config.Browser.Proxies + } else { + srcProxies = []browser.Proxy{ + {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, + } + log.Info("代理表为空,初始化默认代理") + } + for _, p := range srcProxies { + if err := a.browserMgr.ProxyDAO.Upsert(p); err != nil { + log.Error("代理迁移失败", logger.F("proxy_id", p.ProxyId), logger.F("error", err)) + } + } + if len(srcProxies) > 0 { + log.Info("代理数据已初始化", logger.F("count", len(srcProxies))) + } + } + + if profiles, err := a.browserMgr.ProfileDAO.List(); err == nil && len(profiles) == 0 { + if len(a.config.Browser.Profiles) > 0 { + for _, pc := range a.config.Browser.Profiles { + coreId := strings.TrimSpace(pc.CoreId) + if strings.EqualFold(coreId, "default") { + coreId = "" + } + p := &browser.Profile{ + ProfileId: pc.ProfileId, + ProfileName: pc.ProfileName, + UserDataDir: pc.UserDataDir, + CoreId: coreId, + FingerprintArgs: pc.FingerprintArgs, + ProxyId: pc.ProxyId, + ProxyConfig: pc.ProxyConfig, + ProxyBindSourceID: pc.ProxyBindSourceID, + ProxyBindSourceURL: pc.ProxyBindSourceURL, + ProxyBindName: pc.ProxyBindName, + ProxyBindUpdatedAt: pc.ProxyBindUpdatedAt, + LaunchArgs: pc.LaunchArgs, + Tags: pc.Tags, + Keywords: pc.Keywords, + CreatedAt: pc.CreatedAt, + UpdatedAt: pc.UpdatedAt, + } + if err := a.browserMgr.ProfileDAO.Upsert(p); err != nil { + log.Error("实例迁移失败", logger.F("profile_id", pc.ProfileId), logger.F("error", err)) + } + } + log.Info("实例数据已迁移", logger.F("count", len(a.config.Browser.Profiles))) + } else { + log.Info("实例表为空,自动创建默认实例") + defaultProfile := &browser.Profile{ + ProfileId: generateUUID(), + ProfileName: "默认实例", + UserDataDir: "default", + CoreId: "", + FingerprintArgs: a.config.Browser.DefaultFingerprintArgs, + LaunchArgs: a.config.Browser.DefaultLaunchArgs, + Tags: []string{"默认"}, + ProxyId: "__direct__", + ProxyConfig: "direct://", + 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_browser_settings_test.go b/backend/app_browser_settings_test.go index 4705f709..bf8a49eb 100644 --- a/backend/app_browser_settings_test.go +++ b/backend/app_browser_settings_test.go @@ -31,7 +31,6 @@ func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T UserDataRoot: app.config.Browser.UserDataRoot, DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), - DefaultProxy: app.config.Browser.DefaultProxy, }); err != nil { t.Fatalf("SaveBrowserSettings returned error: %v", err) } @@ -42,6 +41,9 @@ func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T if app.config.Browser.StartStableWindowMs != 2400 { t.Fatalf("expected stable window to be preserved, got %d", app.config.Browser.StartStableWindowMs) } + if len(app.config.Browser.DefaultStartURLs) != len(config.DefaultBrowserStartURLs()) { + t.Fatalf("expected default start urls to be preserved, got %v", app.config.Browser.DefaultStartURLs) + } } func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { @@ -52,7 +54,8 @@ func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { UserDataRoot: app.config.Browser.UserDataRoot, DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), - DefaultProxy: app.config.Browser.DefaultProxy, + DefaultStartURLs: []string{}, + RestoreLastSession: true, StartReadyTimeoutMs: 18000, StartStableWindowMs: 3000, }); err != nil { @@ -65,4 +68,10 @@ func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { if app.config.Browser.StartStableWindowMs != 3000 { t.Fatalf("expected stable window 3000ms, got %d", app.config.Browser.StartStableWindowMs) } + if len(app.config.Browser.DefaultStartURLs) != 0 { + t.Fatalf("expected default start urls to be cleared, got %v", app.config.Browser.DefaultStartURLs) + } + if !app.config.Browser.RestoreLastSession { + t.Fatal("expected restore last session to be enabled") + } } diff --git a/backend/app_dashboard_api.go b/backend/app_dashboard_api.go new file mode 100644 index 00000000..7c53d3fa --- /dev/null +++ b/backend/app_dashboard_api.go @@ -0,0 +1,79 @@ +package backend + +import ( + goruntime "runtime" + + "ant-chrome/backend/internal/logger" +) + +func (a *App) GetDashboardStats() map[string]interface{} { + profiles := a.browserMgr.List() + totalInstances := len(profiles) + runningInstances := 0 + for _, profile := range profiles { + if profile.Running { + runningInstances++ + } + } + proxyCount := len(a.config.Browser.Proxies) + coreCount := len(a.config.Browser.Cores) + + var mem goruntime.MemStats + goruntime.ReadMemStats(&mem) + memUsedMB := float64(mem.Alloc) / 1024 / 1024 + + return map[string]interface{}{ + "totalInstances": totalInstances, + "runningInstances": runningInstances, + "proxyCount": proxyCount, + "coreCount": coreCount, + "memUsedMB": int(memUsedMB), + "appVersion": a.appVersion(), + } +} + +func (a *App) GetAppConfig() map[string]interface{} { + return map[string]interface{}{ + "name": a.appName(), + "version": a.appVersion(), + } +} + +func (a *App) GetMemoryStats() map[string]interface{} { + var mem goruntime.MemStats + goruntime.ReadMemStats(&mem) + return map[string]interface{}{ + "alloc_mb": float64(mem.Alloc) / 1024 / 1024, + "total_alloc_mb": float64(mem.TotalAlloc) / 1024 / 1024, + "sys_mb": float64(mem.Sys) / 1024 / 1024, + "num_gc": mem.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 _, profile := range all { + if profile.Running { + result = append(result, profile) + } + } + return result +} diff --git a/backend/app_filesystem_api.go b/backend/app_filesystem_api.go new file mode 100644 index 00000000..64f6e924 --- /dev/null +++ b/backend/app_filesystem_api.go @@ -0,0 +1,145 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" +) + +// 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 +} + +// OpenProjectRoot 在资源管理器中打开项目根目录 +func (a *App) OpenProjectRoot() error { + log := logger.New("Browser") + + rootPath := strings.TrimSpace(a.appRootAbs()) + if rootPath == "" { + return fmt.Errorf("项目根目录不能为空") + } + + if _, err := os.Stat(rootPath); os.IsNotExist(err) { + return fmt.Errorf("项目根目录不存在: %s", rootPath) + } + + absPath, err := filepath.Abs(rootPath) + if err != nil { + log.Error("获取项目根目录绝对路径失败", logger.F("path", rootPath), 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() + } +} diff --git a/backend/app_instance.go b/backend/app_instance.go deleted file mode 100644 index 2025c5cd..00000000 --- a/backend/app_instance.go +++ /dev/null @@ -1,849 +0,0 @@ -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, false) -} - -// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库) -func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { - return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, true) -} - -func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) (*BrowserProfile, error) { - log := logger.New("Browser") - a.browserMgr.Mutex.Lock() - defer a.browserMgr.Mutex.Unlock() - - normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs) - normalizedStartURLs := normalizeNonEmptyStrings(startURLs) - if preferVisibleWindow { - normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs) - } - - 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 { - if !isBrowserProfileLive(profile, a.browserMgr.BrowserProcesses[profileId]) { - log.Info("检测到实例运行状态已失效,准备重新启动", - logger.F("profile_id", profileId), - logger.F("pid", profile.Pid), - logger.F("debug_port", profile.DebugPort), - ) - a.markProfileStoppedLocked(profileId, profile) - } else { - if preferVisibleWindow { - if err := a.openBrowserWindowForRunningProfile(profile, normalizedExtraLaunchArgs, normalizedStartURLs); err != nil { - startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err) - log.Error("运行中实例窗口唤起失败", - logger.F("profile_id", profileId), - logger.F("debug_port", profile.DebugPort), - logger.F("error", err.Error()), - logger.F("reason", startErr.Error()), - ) - profile.LastError = startErr.Error() - return profile, startErr - } - } - if a.launchServer != nil && profile.DebugReady { - a.launchServer.SetActiveProfile(profile) - } - a.emitBrowserInstanceStarted(profile, true) - return profile, nil - } - } - sanitizedProfileLaunchArgs, managedProfileArgs := sanitizeManagedLaunchArgs(profile.LaunchArgs) - sanitizedExtraLaunchArgs, managedExtraArgs := sanitizeManagedLaunchArgs(normalizedExtraLaunchArgs) - logManagedLaunchArgOverrides(log, profileId, "profile.launchArgs", managedProfileArgs) - logManagedLaunchArgOverrides(log, profileId, "start.extraLaunchArgs", managedExtraArgs) - - 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.Error()), 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.Error()), 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 可执行文件是否存在,以及本地端口是否被占用。", 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 可执行文件是否存在,以及本地端口是否被占用。", 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)) - } - - startReadyTimeout, startStableWindow := a.browserStartTimingSettings() - maxStartAttempts := browserStartAttemptCount() - totalReadyTimeout := time.Duration(maxStartAttempts) * startReadyTimeout - var lastStartErr error - assignedDebugPort, err := nextAvailablePort() - if err != nil { - startErr := fmt.Errorf("实例启动失败:本地调试端口分配失败。原因:%v。请关闭占用端口的程序后重试。", err) - log.Error("调试端口分配失败", logger.F("profile_id", profileId), logger.F("error", err.Error()), 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", assignedDebugPort), - "--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, sanitizedProfileLaunchArgs...) - args = append(args, sanitizedExtraLaunchArgs...) - args = appendLaunchTargets(args, profile, normalizedStartURLs, skipDefaultStartURLs) - - cmd := exec.Command(chromeBinaryPath, args...) - cmd.Dir = filepath.Dir(chromeBinaryPath) - monitor, err := newBrowserProcessMonitor(cmd) - if err != nil { - startErr := fmt.Errorf("实例启动失败:无法建立浏览器错误输出捕获。可执行文件:%s。原因:%v。", chromeBinaryPath, err) - log.Error("浏览器错误输出捕获初始化失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err.Error()), logger.F("reason", startErr.Error())) - profile.LastError = startErr.Error() - return profile, startErr - } - 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.Error()), logger.F("reason", startErr.Error())) - profile.LastError = startErr.Error() - return profile, startErr - } - monitor.Start() - - for attempt := 1; attempt <= maxStartAttempts; attempt++ { - stableDebugPort, readyErr := waitBrowserDebugPortStable(assignedDebugPort, userDataDir, startReadyTimeout, startStableWindow, monitor) - if readyErr == nil { - a.markProfileRunningLocked(profileId, profile, cmd, cmd.Process.Pid, stableDebugPort, true, "") - if acquiredXrayBridgeKey != "" { - a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) - releaseXrayBridge = false - } - - log.Info("实例启动", - logger.F("profile_id", profileId), - logger.F("debug_port", stableDebugPort), - logger.F("pid", profile.Pid), - logger.F("proxy", effectiveProxy), - logger.F("attempt", attempt), - logger.F("max_attempts", maxStartAttempts), - logger.F("args", strings.Join(args, " ")), - ) - a.emitBrowserInstanceStarted(profile, false) - - go a.waitBrowserProcess(profileId, monitor) - return profile, nil - } - - startErr := fmt.Errorf("%s", describeBrowserReadyFailure(chromeBinaryPath, assignedDebugPort, totalReadyTimeout, readyErr)) - lastStartErr = startErr - log.Error("浏览器启动未就绪", - logger.F("profile_id", profileId), - logger.F("chrome", chromeBinaryPath), - logger.F("debug_port", assignedDebugPort), - logger.F("attempt", attempt), - logger.F("max_attempts", maxStartAttempts), - logger.F("error", readyErr.Error()), - logger.F("reason", startErr.Error()), - ) - - if attempt < maxStartAttempts && shouldRetryBrowserReadyFailure(readyErr) { - log.Warn("浏览器启动未就绪,继续检测", - logger.F("profile_id", profileId), - logger.F("debug_port", assignedDebugPort), - logger.F("attempt", attempt), - logger.F("next_attempt", attempt+1), - logger.F("max_attempts", maxStartAttempts), - logger.F("timeout_ms", startReadyTimeout.Milliseconds()), - ) - continue - } - - break - } - - pendingStartNotice := "" - if shouldKeepBrowserRunningPendingDebugReady(assignedDebugPort, monitor) { - runtimeWarning := browserDebugPendingWarning(totalReadyTimeout) - pendingStartNotice = browserDebugPendingStartNotice(totalReadyTimeout) - a.markProfileRunningLocked(profileId, profile, cmd, cmd.Process.Pid, assignedDebugPort, false, runtimeWarning) - if acquiredXrayBridgeKey != "" { - a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) - releaseXrayBridge = false - } - - log.Warn("浏览器窗口已启动,但调试接口在等待窗口内未就绪,转入后台附着", - logger.F("profile_id", profileId), - logger.F("debug_port", assignedDebugPort), - logger.F("pid", profile.Pid), - logger.F("max_attempts", maxStartAttempts), - logger.F("warning", runtimeWarning), - ) - a.emitBrowserInstanceStarted(profile, false) - go a.waitBrowserProcess(profileId, monitor) - go a.waitBrowserDebugReadyAsync(profileId, assignedDebugPort, browserAsyncDebugAttachTimeout) - } - - if pendingStartNotice != "" { - profile.LastError = pendingStartNotice - return profile, fmt.Errorf("%s", pendingStartNotice) - } - - if lastStartErr != nil { - profile.LastError = lastStartErr.Error() - return profile, lastStartErr - } - return profile, fmt.Errorf("实例启动失败:浏览器在等待窗口内仍未就绪") -} - -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] - debugPort := profile.DebugPort - if tryCloseBrowserViaCDP(debugPort, 5*time.Second) { - a.markProfileStoppedLocked(profileId, profile) - log.Info("实例停止", logger.F("profile_id", profileId), logger.F("method", "cdp"), logger.F("debug_port", debugPort)) - return profile, nil - } - - 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 - } - } - - if debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) { - err := fmt.Errorf("实例停止失败:浏览器仍在运行(调试端口 %d 仍可访问)", debugPort) - log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("reason", err.Error())) - profile.LastError = err.Error() - return profile, err - } - - a.markProfileStoppedLocked(profileId, profile) - 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, monitor *browserProcessMonitor) { - err := monitor.Wait() - - log := logger.New("Browser") - debugPort := 0 - profileName := profileId - shouldMonitorDetached := false - - a.browserMgr.Mutex.Lock() - profile, exists := a.browserMgr.Profiles[profileId] - wasRunning := exists && profile.Running - if exists { - profileName = profile.ProfileName - debugPort = profile.DebugPort - } - a.browserMgr.Mutex.Unlock() - - if wasRunning && debugPort > 0 { - snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, browserLauncherDetachGraceWindow) - if snapshot != nil { - if changed { - log.Info("浏览器启动器进程退出后,调试接口延迟就绪", - logger.F("profile_id", profileId), - logger.F("debug_port", debugPort), - ) - a.emitBrowserInstanceUpdated(snapshot) - } - } - - a.browserMgr.Mutex.Lock() - profile, exists = a.browserMgr.Profiles[profileId] - if exists && profile.Running && profile.DebugPort == debugPort && profile.DebugReady && canConnectDebugPort(debugPort, 250*time.Millisecond) { - delete(a.browserMgr.BrowserProcesses, profileId) - profile.Pid = 0 - shouldMonitorDetached = true - } - a.browserMgr.Mutex.Unlock() - if shouldMonitorDetached { - log.Info("浏览器启动器进程已退出,切换为调试端口存活监控", - logger.F("profile_id", profileId), - logger.F("profile_name", profileName), - logger.F("debug_port", debugPort), - ) - a.waitDetachedBrowser(profileId, debugPort) - return - } - } - - a.browserMgr.Mutex.Lock() - profile, exists = a.browserMgr.Profiles[profileId] - wasRunning = exists && profile.Running - if exists { - profileName = profile.ProfileName - a.markProfileStoppedLocked(profileId, profile) - } - a.browserMgr.Mutex.Unlock() - - if a.ctx == nil { - return - } - - // 进程是正常退出(用户手动关闭)还是异常崩溃 - if wasRunning && err != nil { - // 异常退出,推送崩溃通知 - if exists && profile != nil { - 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 (a *App) waitDetachedBrowser(profileId string, debugPort int) { - const ( - pollInterval = 500 * time.Millisecond - maxMisses = 3 - ) - - log := logger.New("Browser") - misses := 0 - for { - if canConnectDebugPort(debugPort, 250*time.Millisecond) { - misses = 0 - time.Sleep(pollInterval) - continue - } - - misses++ - if misses < maxMisses { - time.Sleep(pollInterval) - continue - } - - profileName := profileId - a.browserMgr.Mutex.Lock() - profile, exists := a.browserMgr.Profiles[profileId] - if !exists || !profile.Running || profile.DebugPort != debugPort { - a.browserMgr.Mutex.Unlock() - return - } - profileName = profile.ProfileName - a.markProfileStoppedLocked(profileId, profile) - a.browserMgr.Mutex.Unlock() - - log.Info("检测到浏览器调试端口关闭,实例已停止", - logger.F("profile_id", profileId), - logger.F("profile_name", profileName), - logger.F("debug_port", debugPort), - ) - if a.ctx != nil { - runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId) - } - return - } -} - -func tryCloseBrowserViaCDP(debugPort int, timeout time.Duration) bool { - if debugPort <= 0 || !canConnectDebugPort(debugPort, 250*time.Millisecond) { - return false - } - - _ = cdpBrowserCall(debugPort, "Browser.close", nil) - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if !canConnectDebugPort(debugPort, 250*time.Millisecond) { - return true - } - time.Sleep(150 * time.Millisecond) - } - return false -} - -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 ensureNewWindowLaunchArg(args []string) []string { - for _, arg := range args { - if strings.EqualFold(strings.TrimSpace(arg), "--new-window") { - return args - } - } - return append(args, "--new-window") -} - -func appendLaunchTargets(args []string, profile *BrowserProfile, startURLs []string, skipDefaultStartURLs bool) []string { - if len(startURLs) > 0 { - return append(args, startURLs...) - } - if !skipDefaultStartURLs { - return browser.BuildLaunchArgs(args, profile) - } - return args -} - -func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile) { - if profile == nil { - return - } - profile.Running = false - profile.DebugReady = false - profile.Pid = 0 - profile.DebugPort = 0 - profile.RuntimeWarning = "" - profile.LastStopAt = time.Now().Format(time.RFC3339) - delete(a.browserMgr.BrowserProcesses, profileId) - a.releaseProfileXrayBridge(profileId) - if a.launchServer != nil { - a.launchServer.ClearActiveProfile(profileId) - } -} - -func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraLaunchArgs []string, startURLs []string) error { - chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile) - if err != nil { - return err - } - - userDataDir := a.browserMgr.ResolveUserDataDir(profile) - if err := os.MkdirAll(userDataDir, 0755); err != nil { - return fmt.Errorf("无法创建用户数据目录 %s:%w", userDataDir, err) - } - - args := []string{ - fmt.Sprintf("--user-data-dir=%s", userDataDir), - } - sanitizedExtraLaunchArgs, managedExtraArgs := sanitizeManagedLaunchArgs(extraLaunchArgs) - logManagedLaunchArgOverrides(logger.New("Browser"), profile.ProfileId, "running-window.extraLaunchArgs", managedExtraArgs) - args = append(args, sanitizedExtraLaunchArgs...) - if len(startURLs) > 0 { - args = append(args, startURLs...) - } else { - args = append(args, "about:blank") - } - - cmd := exec.Command(chromeBinaryPath, args...) - cmd.Dir = filepath.Dir(chromeBinaryPath) - if err := cmd.Start(); err != nil { - return fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) - } - - go func() { - _ = cmd.Wait() - }() - return nil -} - -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_debug_probe.go b/backend/app_instance_debug_probe.go new file mode 100644 index 00000000..6acc6050 --- /dev/null +++ b/backend/app_instance_debug_probe.go @@ -0,0 +1,83 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +const browserDebugProbeTimeout = 250 * time.Millisecond + +func probeBrowserDebugPort(debugPort int, requestTimeout time.Duration) error { + if debugPort <= 0 { + return fmt.Errorf("invalid debug port %d", debugPort) + } + + client := &http.Client{Timeout: requestTimeout} + versionErr := probeBrowserJSONVersion(client, debugPort) + if versionErr == nil { + return nil + } + + listErr := probeBrowserJSONList(client, debugPort) + if listErr == nil { + return nil + } + + return fmt.Errorf("%v; %v", versionErr, listErr) +} + +func probeBrowserJSONVersion(client *http.Client, debugPort int) error { + var payload struct { + Browser string `json:"Browser"` + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := fetchBrowserDebugJSON(client, debugPort, "/json/version", &payload); err != nil { + return err + } + if strings.TrimSpace(payload.Browser) == "" && strings.TrimSpace(payload.WebSocketDebuggerURL) == "" { + return fmt.Errorf("/json/version missing Browser and webSocketDebuggerUrl") + } + return nil +} + +func probeBrowserJSONList(client *http.Client, debugPort int) error { + var payload []map[string]interface{} + return fetchBrowserDebugJSON(client, debugPort, "/json/list", &payload) +} + +func fetchBrowserDebugJSON(client *http.Client, debugPort int, path string, dest interface{}) error { + url := fmt.Sprintf("http://127.0.0.1:%d%s", debugPort, path) + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("%s request failed: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", path, resp.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(resp.Body, 256*1024)) + if err := decoder.Decode(dest); err != nil { + return fmt.Errorf("%s returned invalid JSON: %w", path, err) + } + return nil +} + +func canConnectDebugPort(debugPort int, dialTimeout time.Duration) bool { + if debugPort <= 0 { + return false + } + + address := fmt.Sprintf("127.0.0.1:%d", debugPort) + conn, err := net.DialTimeout("tcp", address, dialTimeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/backend/app_instance_debug_ready.go b/backend/app_instance_debug_ready.go new file mode 100644 index 00000000..e8c41af8 --- /dev/null +++ b/backend/app_instance_debug_ready.go @@ -0,0 +1,163 @@ +package backend + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const browserStartReadyTimeout = 10 * time.Second +const browserStartStableWindow = 1200 * time.Millisecond + +var errBrowserDebugPortPending = errors.New("browser debug port pending") + +func waitBrowserDebugPortReady(initialDebugPort int, userDataDir string, timeout time.Duration, monitor *browserProcessMonitor) (int, error) { + deadline := time.Now().Add(timeout) + allowDetachedGrace := initialDebugPort > 0 + var lastErr error + var exitResult browserProcessExitResult + exitObserved := false + + for time.Now().Before(deadline) { + debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor) + if resolveErr == nil { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return debugPort, nil + } else { + lastErr = err + } + } else if !errors.Is(resolveErr, errBrowserDebugPortPending) { + lastErr = resolveErr + } + if monitor != nil && monitor.HasExited() { + if !exitObserved { + exitResult = monitor.Result() + exitObserved = true + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(exitResult) + } + exitDeadline := time.Now().Add(browserLauncherDetachGraceWindow) + if exitDeadline.After(deadline) { + deadline = exitDeadline + } + } + } + time.Sleep(150 * time.Millisecond) + } + if !exitObserved && monitor != nil && monitor.HasExited() { + exitResult = monitor.Result() + exitObserved = true + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(exitResult) + } + postExitDeadline := time.Now().Add(browserLauncherDetachGraceWindow) + for time.Now().Before(postExitDeadline) { + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return debugPort, nil + } + } + time.Sleep(150 * time.Millisecond) + } + } + if exitObserved { + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return debugPort, nil + } + } + return 0, newBrowserStartupExitError(exitResult) + } + if lastErr != nil { + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪:%w", timeout.Round(time.Second), debugPort, lastErr) + } + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,尚未获取调试端口:%w", timeout.Round(time.Second), lastErr) + } + + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort) + } + + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,尚未获取调试端口", timeout.Round(time.Second)) +} + +func waitBrowserDebugPortStable(initialDebugPort int, userDataDir string, timeout time.Duration, stableFor time.Duration, monitor *browserProcessMonitor) (int, error) { + debugPort, err := waitBrowserDebugPortReady(initialDebugPort, userDataDir, timeout, monitor) + if err != nil { + return 0, err + } + if stableFor <= 0 { + return debugPort, nil + } + allowDetachedGrace := initialDebugPort > 0 + + deadline := time.Now().Add(stableFor) + for time.Now().Before(deadline) { + if monitor != nil && monitor.HasExited() { + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(monitor.Result()) + } + } + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err != nil { + if monitor != nil && monitor.HasExited() { + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(monitor.Result()) + } + } + return 0, fmt.Errorf("浏览器调试端口 %d 短暂就绪后又失效:%w", debugPort, err) + } + time.Sleep(150 * time.Millisecond) + } + return debugPort, nil +} + +func resolveBrowserDebugPort(initialDebugPort int, userDataDir string, monitor *browserProcessMonitor) (int, error) { + if initialDebugPort > 0 { + return initialDebugPort, nil + } + if monitor != nil { + if debugPort, ok := monitor.DebugPort(); ok { + return debugPort, nil + } + } + if debugPort, err := readBrowserDebugPortFile(userDataDir); err == nil { + if monitor != nil { + monitor.SetDebugPort(debugPort) + } + return debugPort, nil + } else if !errors.Is(err, errBrowserDebugPortPending) { + return 0, err + } + return 0, errBrowserDebugPortPending +} + +func readBrowserDebugPortFile(userDataDir string) (int, error) { + userDataDir = strings.TrimSpace(userDataDir) + if userDataDir == "" { + return 0, errBrowserDebugPortPending + } + + data, err := os.ReadFile(filepath.Join(userDataDir, "DevToolsActivePort")) + if err != nil { + if os.IsNotExist(err) { + return 0, errBrowserDebugPortPending + } + return 0, fmt.Errorf("读取 DevToolsActivePort 失败: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + return 0, errBrowserDebugPortPending + } + + port, err := strconv.Atoi(strings.TrimSpace(lines[0])) + if err != nil || port <= 0 { + return 0, fmt.Errorf("DevToolsActivePort 内容无效: %q", lines[0]) + } + return port, nil +} diff --git a/backend/app_instance_errors.go b/backend/app_instance_errors.go deleted file mode 100644 index 9215a3fc..00000000 --- a/backend/app_instance_errors.go +++ /dev/null @@ -1,323 +0,0 @@ -package backend - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" -) - -const browserStartReadyTimeout = 10 * time.Second -const browserStartStableWindow = 1200 * time.Millisecond -const browserDebugProbeTimeout = 250 * time.Millisecond - -var errBrowserDebugPortPending = errors.New("browser debug port pending") - -type browserStartupExitError struct { - exitErr error - stderrTail string -} - -func (e *browserStartupExitError) Error() string { - detail := e.Detail() - if detail == "" && e.exitErr != nil { - detail = strings.TrimSpace(e.exitErr.Error()) - } - if detail == "" { - return "browser process exited before ready" - } - return fmt.Sprintf("browser process exited before ready: %s", detail) -} - -func (e *browserStartupExitError) Detail() string { - lines := strings.Split(strings.TrimSpace(e.stderrTail), "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line != "" { - return line - } - } - return "" -} - -func newBrowserStartupExitError(result browserProcessExitResult) error { - return &browserStartupExitError{ - exitErr: result.Err, - stderrTail: result.StderrTail, - } -} - -func waitBrowserDebugPortReady(initialDebugPort int, userDataDir string, timeout time.Duration, monitor *browserProcessMonitor) (int, error) { - deadline := time.Now().Add(timeout) - allowDetachedGrace := initialDebugPort > 0 - var lastErr error - var exitResult browserProcessExitResult - exitObserved := false - - for time.Now().Before(deadline) { - debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor) - if resolveErr == nil { - if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { - return debugPort, nil - } else { - lastErr = err - } - } else if !errors.Is(resolveErr, errBrowserDebugPortPending) { - lastErr = resolveErr - } - if monitor != nil && monitor.HasExited() { - if !exitObserved { - exitResult = monitor.Result() - exitObserved = true - if !allowDetachedGrace { - return 0, newBrowserStartupExitError(exitResult) - } - exitDeadline := time.Now().Add(browserLauncherDetachGraceWindow) - if exitDeadline.After(deadline) { - deadline = exitDeadline - } - } - } - time.Sleep(150 * time.Millisecond) - } - if !exitObserved && monitor != nil && monitor.HasExited() { - exitResult = monitor.Result() - exitObserved = true - if !allowDetachedGrace { - return 0, newBrowserStartupExitError(exitResult) - } - postExitDeadline := time.Now().Add(browserLauncherDetachGraceWindow) - for time.Now().Before(postExitDeadline) { - if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { - if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { - return debugPort, nil - } - } - time.Sleep(150 * time.Millisecond) - } - } - if exitObserved { - if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { - if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { - return debugPort, nil - } - } - return 0, newBrowserStartupExitError(exitResult) - } - if lastErr != nil { - if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { - return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪:%w", timeout.Round(time.Second), debugPort, lastErr) - } - return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,尚未获取调试端口:%w", timeout.Round(time.Second), lastErr) - } - - if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { - return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort) - } - - return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,尚未获取调试端口", timeout.Round(time.Second)) -} - -func waitBrowserDebugPortStable(initialDebugPort int, userDataDir string, timeout time.Duration, stableFor time.Duration, monitor *browserProcessMonitor) (int, error) { - debugPort, err := waitBrowserDebugPortReady(initialDebugPort, userDataDir, timeout, monitor) - if err != nil { - return 0, err - } - if stableFor <= 0 { - return debugPort, nil - } - allowDetachedGrace := initialDebugPort > 0 - - deadline := time.Now().Add(stableFor) - for time.Now().Before(deadline) { - if monitor != nil && monitor.HasExited() { - if !allowDetachedGrace { - return 0, newBrowserStartupExitError(monitor.Result()) - } - } - if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err != nil { - if monitor != nil && monitor.HasExited() { - if !allowDetachedGrace { - return 0, newBrowserStartupExitError(monitor.Result()) - } - } - return 0, fmt.Errorf("浏览器调试端口 %d 短暂就绪后又失效:%w", debugPort, err) - } - time.Sleep(150 * time.Millisecond) - } - return debugPort, nil -} - -func resolveBrowserDebugPort(initialDebugPort int, userDataDir string, monitor *browserProcessMonitor) (int, error) { - if initialDebugPort > 0 { - return initialDebugPort, nil - } - if monitor != nil { - if debugPort, ok := monitor.DebugPort(); ok { - return debugPort, nil - } - } - if debugPort, err := readBrowserDebugPortFile(userDataDir); err == nil { - if monitor != nil { - monitor.SetDebugPort(debugPort) - } - return debugPort, nil - } else if !errors.Is(err, errBrowserDebugPortPending) { - return 0, err - } - return 0, errBrowserDebugPortPending -} - -func readBrowserDebugPortFile(userDataDir string) (int, error) { - userDataDir = strings.TrimSpace(userDataDir) - if userDataDir == "" { - return 0, errBrowserDebugPortPending - } - - data, err := os.ReadFile(filepath.Join(userDataDir, "DevToolsActivePort")) - if err != nil { - if os.IsNotExist(err) { - return 0, errBrowserDebugPortPending - } - return 0, fmt.Errorf("读取 DevToolsActivePort 失败: %w", err) - } - - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { - return 0, errBrowserDebugPortPending - } - - port, err := strconv.Atoi(strings.TrimSpace(lines[0])) - if err != nil || port <= 0 { - return 0, fmt.Errorf("DevToolsActivePort 内容无效: %q", lines[0]) - } - return port, nil -} - -func probeBrowserDebugPort(debugPort int, requestTimeout time.Duration) error { - if debugPort <= 0 { - return fmt.Errorf("invalid debug port %d", debugPort) - } - - client := &http.Client{Timeout: requestTimeout} - versionErr := probeBrowserJSONVersion(client, debugPort) - if versionErr == nil { - return nil - } - - listErr := probeBrowserJSONList(client, debugPort) - if listErr == nil { - return nil - } - - return fmt.Errorf("%v; %v", versionErr, listErr) -} - -func probeBrowserJSONVersion(client *http.Client, debugPort int) error { - var payload struct { - Browser string `json:"Browser"` - WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` - } - if err := fetchBrowserDebugJSON(client, debugPort, "/json/version", &payload); err != nil { - return err - } - if strings.TrimSpace(payload.Browser) == "" && strings.TrimSpace(payload.WebSocketDebuggerURL) == "" { - return fmt.Errorf("/json/version missing Browser and webSocketDebuggerUrl") - } - return nil -} - -func probeBrowserJSONList(client *http.Client, debugPort int) error { - var payload []map[string]interface{} - return fetchBrowserDebugJSON(client, debugPort, "/json/list", &payload) -} - -func fetchBrowserDebugJSON(client *http.Client, debugPort int, path string, dest interface{}) error { - url := fmt.Sprintf("http://127.0.0.1:%d%s", debugPort, path) - resp, err := client.Get(url) - if err != nil { - return fmt.Errorf("%s request failed: %w", path, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%s returned HTTP %d", path, resp.StatusCode) - } - decoder := json.NewDecoder(io.LimitReader(resp.Body, 256*1024)) - if err := decoder.Decode(dest); err != nil { - return fmt.Errorf("%s returned invalid JSON: %w", path, err) - } - return nil -} - -func canConnectDebugPort(debugPort int, dialTimeout time.Duration) bool { - if debugPort <= 0 { - return false - } - - address := fmt.Sprintf("127.0.0.1:%d", debugPort) - conn, err := net.DialTimeout("tcp", address, dialTimeout) - if err != nil { - return false - } - _ = conn.Close() - return true -} - -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"), - strings.Contains(lower, "exec format error"), - strings.Contains(lower, "cannot execute binary file"): - return fmt.Sprintf("实例启动失败:当前浏览器内核与系统/架构不兼容。可执行文件:%s。请确认 Linux 环境使用的是对应架构的 Chrome 内核,而不是 Windows 可执行文件。", 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 { - if debugPort <= 0 { - return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,且未获取到调试端口。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second)) - } - return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,调试端口 %d 未就绪。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second), debugPort) -} - -func describeBrowserReadyFailure(chromeBinaryPath string, debugPort int, timeout time.Duration, err error) string { - var exitErr *browserStartupExitError - if errors.As(err, &exitErr) { - detail := exitErr.Detail() - if detail == "" && exitErr.exitErr != nil { - detail = strings.TrimSpace(exitErr.exitErr.Error()) - } - if detail != "" { - return fmt.Sprintf("实例启动失败:浏览器进程在完成就绪前退出。可执行文件:%s。原因:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath, detail) - } - return fmt.Sprintf("实例启动失败:浏览器进程在完成就绪前退出。可执行文件:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath) - } - return describeBrowserReadyTimeout(debugPort, timeout) -} diff --git a/backend/app_instance_launch_args.go b/backend/app_instance_launch_args.go new file mode 100644 index 00000000..e37bf7a5 --- /dev/null +++ b/backend/app_instance_launch_args.go @@ -0,0 +1,138 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func tryCloseBrowserViaCDP(debugPort int, timeout time.Duration) bool { + if debugPort <= 0 || !canConnectDebugPort(debugPort, 250*time.Millisecond) { + return false + } + + _ = cdpBrowserCall(debugPort, "Browser.close", nil) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !canConnectDebugPort(debugPort, 250*time.Millisecond) { + return true + } + time.Sleep(150 * time.Millisecond) + } + return false +} + +func normalizeNonEmptyStrings(items []string) []string { + if len(items) == 0 { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + value := strings.TrimSpace(item) + if value != "" { + out = append(out, value) + } + } + return out +} + +func ensureNewWindowLaunchArg(args []string) []string { + for _, arg := range args { + if strings.EqualFold(strings.TrimSpace(arg), "--new-window") { + return args + } + } + return append(args, "--new-window") +} + +func browserDefaultStartURLs(cfg *config.Config) []string { + if cfg != nil && cfg.Browser.DefaultStartURLs != nil { + return normalizeNonEmptyStrings(cfg.Browser.DefaultStartURLs) + } + return config.DefaultBrowserStartURLs() +} + +func browserRestoreLastSession(cfg *config.Config) bool { + if cfg == nil { + return false + } + return cfg.Browser.RestoreLastSession +} + +func appendLaunchTargets(args []string, startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool) []string { + normalizedStartURLs := normalizeNonEmptyStrings(startURLs) + if len(normalizedStartURLs) > 0 { + return browser.BuildLaunchArgs(args, normalizedStartURLs) + } + + if !skipDefaultStartURLs { + normalizedDefaultStartURLs := normalizeNonEmptyStrings(defaultStartURLs) + if len(normalizedDefaultStartURLs) > 0 { + return browser.BuildLaunchArgs(args, normalizedDefaultStartURLs) + } + } + + if !restoreLastSession { + return browser.BuildLaunchArgs(args, []string{"about:blank"}) + } + + return args +} + +func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile) { + if profile == nil { + return + } + profile.Running = false + profile.DebugReady = false + profile.Pid = 0 + profile.DebugPort = 0 + profile.RuntimeWarning = "" + profile.LastStopAt = time.Now().Format(time.RFC3339) + delete(a.browserMgr.BrowserProcesses, profileId) + a.releaseProfileXrayBridge(profileId) + if a.launchServer != nil { + a.launchServer.ClearActiveProfile(profileId) + } +} + +func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraLaunchArgs []string, startURLs []string) error { + chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile) + if err != nil { + return err + } + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if err := os.MkdirAll(userDataDir, 0755); err != nil { + return fmt.Errorf("无法创建用户数据目录 %s:%w", userDataDir, err) + } + + args := []string{ + fmt.Sprintf("--user-data-dir=%s", userDataDir), + } + sanitizedExtraLaunchArgs, managedExtraArgs := sanitizeManagedLaunchArgs(extraLaunchArgs) + logManagedLaunchArgOverrides(logger.New("Browser"), profile.ProfileId, "running-window.extraLaunchArgs", managedExtraArgs) + args = append(args, sanitizedExtraLaunchArgs...) + if len(startURLs) > 0 { + args = append(args, startURLs...) + } else { + args = append(args, "about:blank") + } + + cmd := exec.Command(chromeBinaryPath, args...) + cmd.Dir = filepath.Dir(chromeBinaryPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) + } + + go func() { + _ = cmd.Wait() + }() + return nil +} diff --git a/backend/app_instance_monitor.go b/backend/app_instance_monitor.go new file mode 100644 index 00000000..1f46c143 --- /dev/null +++ b/backend/app_instance_monitor.go @@ -0,0 +1,127 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func (a *App) waitBrowserProcess(profileId string, monitor *browserProcessMonitor) { + err := monitor.Wait() + + log := logger.New("Browser") + debugPort := 0 + profileName := profileId + shouldMonitorDetached := false + + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + wasRunning := exists && profile.Running + if exists { + profileName = profile.ProfileName + debugPort = profile.DebugPort + } + a.browserMgr.Mutex.Unlock() + + if wasRunning && debugPort > 0 { + snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, browserLauncherDetachGraceWindow) + if snapshot != nil && changed { + log.Info("浏览器启动器进程退出后,调试接口延迟就绪", + logger.F("profile_id", profileId), + logger.F("debug_port", debugPort), + ) + a.emitBrowserInstanceUpdated(snapshot) + } + + a.browserMgr.Mutex.Lock() + profile, exists = a.browserMgr.Profiles[profileId] + if exists && profile.Running && profile.DebugPort == debugPort && profile.DebugReady && canConnectDebugPort(debugPort, 250*time.Millisecond) { + delete(a.browserMgr.BrowserProcesses, profileId) + profile.Pid = 0 + shouldMonitorDetached = true + } + a.browserMgr.Mutex.Unlock() + if shouldMonitorDetached { + log.Info("浏览器启动器进程已退出,切换为调试端口存活监控", + logger.F("profile_id", profileId), + logger.F("profile_name", profileName), + logger.F("debug_port", debugPort), + ) + a.waitDetachedBrowser(profileId, debugPort) + return + } + } + + a.browserMgr.Mutex.Lock() + profile, exists = a.browserMgr.Profiles[profileId] + wasRunning = exists && profile.Running + if exists { + profileName = profile.ProfileName + a.markProfileStoppedLocked(profileId, profile) + } + a.browserMgr.Mutex.Unlock() + + if a.ctx == nil { + return + } + + if wasRunning && err != nil { + if exists && profile != nil { + 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 (a *App) waitDetachedBrowser(profileId string, debugPort int) { + const ( + pollInterval = 500 * time.Millisecond + maxMisses = 3 + ) + + log := logger.New("Browser") + misses := 0 + for { + if canConnectDebugPort(debugPort, 250*time.Millisecond) { + misses = 0 + time.Sleep(pollInterval) + continue + } + + misses++ + if misses < maxMisses { + time.Sleep(pollInterval) + continue + } + + profileName := profileId + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists || !profile.Running || profile.DebugPort != debugPort { + a.browserMgr.Mutex.Unlock() + return + } + profileName = profile.ProfileName + a.markProfileStoppedLocked(profileId, profile) + a.browserMgr.Mutex.Unlock() + + log.Info("检测到浏览器调试端口关闭,实例已停止", + logger.F("profile_id", profileId), + logger.F("profile_name", profileName), + logger.F("debug_port", debugPort), + ) + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId) + } + return + } +} diff --git a/backend/app_instance_process_control.go b/backend/app_instance_process_control.go new file mode 100644 index 00000000..c6b4fed2 --- /dev/null +++ b/backend/app_instance_process_control.go @@ -0,0 +1,104 @@ +package backend + +import ( + "fmt" + "os/exec" + stdruntime "runtime" + "strings" + "time" +) + +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 + } + + 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 + } + message := strings.ToLower(strings.TrimSpace(err.Error())) + if message == "" { + return false + } + if strings.Contains(message, "process already finished") { + return true + } + if strings.Contains(message, "not found") { + return true + } + if strings.Contains(message, "no process") { + return true + } + if strings.Contains(message, "不存在") { + 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_start.go b/backend/app_instance_start.go new file mode 100644 index 00000000..7e800b70 --- /dev/null +++ b/backend/app_instance_start.go @@ -0,0 +1,34 @@ +package backend + +func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) { + return a.browserInstanceStartInternal(profileId, nil, nil, false, false) +} + +func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool { + return len(normalizeNonEmptyStrings(startURLs)) > 0 +} + +// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库) +func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { + preferVisibleWindow := shouldPreferVisibleWindowForStartWithParams(startURLs) + return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow) +} + +func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) (*BrowserProfile, error) { + input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow) + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + profile, handled, err := a.resolveBrowserStartProfile(input) + if err != nil || handled { + return profile, err + } + + plan, err := a.prepareBrowserStartPlan(input, profile) + if err != nil { + return profile, err + } + defer plan.releaseBridgeIfNeeded(a) + + return a.startBrowserProfileWithPlan(input, plan) +} diff --git a/backend/app_instance_start_error.go b/backend/app_instance_start_error.go new file mode 100644 index 00000000..61e836d2 --- /dev/null +++ b/backend/app_instance_start_error.go @@ -0,0 +1,93 @@ +package backend + +import ( + "errors" + "fmt" + "strings" + "time" +) + +type browserStartupExitError struct { + exitErr error + stderrTail string +} + +func (e *browserStartupExitError) Error() string { + detail := e.Detail() + if detail == "" && e.exitErr != nil { + detail = strings.TrimSpace(e.exitErr.Error()) + } + if detail == "" { + return "browser process exited before ready" + } + return fmt.Sprintf("browser process exited before ready: %s", detail) +} + +func (e *browserStartupExitError) Detail() string { + lines := strings.Split(strings.TrimSpace(e.stderrTail), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line != "" { + return line + } + } + return "" +} + +func newBrowserStartupExitError(result browserProcessExitResult) error { + return &browserStartupExitError{ + exitErr: result.Err, + stderrTail: result.StderrTail, + } +} + +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"), + strings.Contains(lower, "exec format error"), + strings.Contains(lower, "cannot execute binary file"): + return fmt.Sprintf("实例启动失败:当前浏览器内核与系统/架构不兼容。可执行文件:%s。请确认 Linux 环境使用的是对应架构的 Chrome 内核,而不是 Windows 可执行文件。", 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 { + if debugPort <= 0 { + return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,且未获取到调试端口。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second)) + } + return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,调试端口 %d 未就绪。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second), debugPort) +} + +func describeBrowserReadyFailure(chromeBinaryPath string, debugPort int, timeout time.Duration, err error) string { + var exitErr *browserStartupExitError + if errors.As(err, &exitErr) { + detail := exitErr.Detail() + if detail == "" && exitErr.exitErr != nil { + detail = strings.TrimSpace(exitErr.exitErr.Error()) + } + if detail != "" { + return fmt.Sprintf("实例启动失败:浏览器进程在完成就绪前退出。可执行文件:%s。原因:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath, detail) + } + return fmt.Sprintf("实例启动失败:浏览器进程在完成就绪前退出。可执行文件:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath) + } + return describeBrowserReadyTimeout(debugPort, timeout) +} diff --git a/backend/app_instance_start_execute.go b/backend/app_instance_start_execute.go new file mode 100644 index 00000000..a94b0291 --- /dev/null +++ b/backend/app_instance_start_execute.go @@ -0,0 +1,130 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browserStartPlan) (*BrowserProfile, error) { + log := logger.New("Browser") + profile := plan.profile + + cmd := exec.Command(plan.chromeBinaryPath, plan.args...) + cmd.Dir = filepath.Dir(plan.chromeBinaryPath) + + monitor, err := newBrowserProcessMonitor(cmd) + if err != nil { + startErr := fmt.Errorf("实例启动失败:无法建立浏览器错误输出捕获。可执行文件:%s。原因:%v。", plan.chromeBinaryPath, err) + log.Error("浏览器错误输出捕获初始化失败", + logger.F("profile_id", input.ProfileID), + logger.F("chrome", plan.chromeBinaryPath), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return profile, startErr + } + if err := cmd.Start(); err != nil { + startErr := fmt.Errorf("%s", describeChromeProcessStartError(plan.chromeBinaryPath, err)) + log.Error("浏览器进程启动失败", + logger.F("profile_id", input.ProfileID), + logger.F("chrome", plan.chromeBinaryPath), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return profile, startErr + } + monitor.Start() + + var lastStartErr error + for attempt := 1; attempt <= plan.maxStartAttempts; attempt++ { + stableDebugPort, readyErr := waitBrowserDebugPortStable(plan.assignedDebugPort, plan.userDataDir, plan.startReadyTimeout, plan.startStableWindow, monitor) + if readyErr == nil { + a.markProfileRunningLocked(input.ProfileID, profile, cmd, cmd.Process.Pid, stableDebugPort, true, "") + if plan.acquiredXrayBridgeKey != "" { + a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey) + plan.releaseXrayBridge = false + } + + log.Info("实例启动", + logger.F("profile_id", input.ProfileID), + logger.F("debug_port", stableDebugPort), + logger.F("pid", profile.Pid), + logger.F("proxy", plan.effectiveProxy), + logger.F("attempt", attempt), + logger.F("max_attempts", plan.maxStartAttempts), + logger.F("args", strings.Join(plan.args, " ")), + ) + a.emitBrowserInstanceStarted(profile, false) + + go a.waitBrowserProcess(input.ProfileID, monitor) + return profile, nil + } + + startErr := fmt.Errorf("%s", describeBrowserReadyFailure(plan.chromeBinaryPath, plan.assignedDebugPort, plan.totalReadyTimeout, readyErr)) + lastStartErr = startErr + log.Error("浏览器启动未就绪", + logger.F("profile_id", input.ProfileID), + logger.F("chrome", plan.chromeBinaryPath), + logger.F("debug_port", plan.assignedDebugPort), + logger.F("attempt", attempt), + logger.F("max_attempts", plan.maxStartAttempts), + logger.F("error", readyErr.Error()), + logger.F("reason", startErr.Error()), + ) + + if attempt < plan.maxStartAttempts && shouldRetryBrowserReadyFailure(readyErr) { + log.Warn("浏览器启动未就绪,继续检测", + logger.F("profile_id", input.ProfileID), + logger.F("debug_port", plan.assignedDebugPort), + logger.F("attempt", attempt), + logger.F("next_attempt", attempt+1), + logger.F("max_attempts", plan.maxStartAttempts), + logger.F("timeout_ms", plan.startReadyTimeout.Milliseconds()), + ) + continue + } + + break + } + + pendingStartNotice := "" + if shouldKeepBrowserRunningPendingDebugReady(plan.assignedDebugPort, monitor) { + runtimeWarning := browserDebugPendingWarning(plan.totalReadyTimeout) + pendingStartNotice = browserDebugPendingStartNotice(plan.totalReadyTimeout) + a.markProfileRunningLocked(input.ProfileID, profile, cmd, cmd.Process.Pid, plan.assignedDebugPort, false, runtimeWarning) + if plan.acquiredXrayBridgeKey != "" { + a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey) + plan.releaseXrayBridge = false + } + + log.Warn("浏览器窗口已启动,但调试接口在等待窗口内未就绪,转入后台附着", + logger.F("profile_id", input.ProfileID), + logger.F("debug_port", plan.assignedDebugPort), + logger.F("pid", profile.Pid), + logger.F("max_attempts", plan.maxStartAttempts), + logger.F("warning", runtimeWarning), + ) + a.emitBrowserInstanceStarted(profile, false) + go a.waitBrowserProcess(input.ProfileID, monitor) + go a.waitBrowserDebugReadyAsync(input.ProfileID, plan.assignedDebugPort, browserAsyncDebugAttachTimeout) + } + + if pendingStartNotice != "" { + profile.LastError = pendingStartNotice + return profile, fmt.Errorf("%s", pendingStartNotice) + } + + if lastStartErr != nil { + profile.LastError = lastStartErr.Error() + return profile, lastStartErr + } + + startErr := fmt.Errorf("实例启动失败:浏览器在等待窗口内仍未就绪") + profile.LastError = startErr.Error() + return profile, startErr +} diff --git a/backend/app_instance_start_prepare.go b/backend/app_instance_start_prepare.go new file mode 100644 index 00000000..e1df1962 --- /dev/null +++ b/backend/app_instance_start_prepare.go @@ -0,0 +1,243 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type browserStartInput struct { + ProfileID string + ExtraLaunchArgs []string + StartURLs []string + SkipDefaultStartURLs bool + PreferVisibleWindow bool +} + +type browserStartPlan struct { + profile *BrowserProfile + chromeBinaryPath string + userDataDir string + args []string + effectiveProxy string + acquiredXrayBridgeKey string + releaseXrayBridge bool + assignedDebugPort int + startReadyTimeout time.Duration + startStableWindow time.Duration + maxStartAttempts int + totalReadyTimeout time.Duration +} + +func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) browserStartInput { + normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs) + if preferVisibleWindow { + normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs) + } + + return browserStartInput{ + ProfileID: profileID, + ExtraLaunchArgs: normalizedExtraLaunchArgs, + StartURLs: normalizeNonEmptyStrings(startURLs), + SkipDefaultStartURLs: skipDefaultStartURLs, + PreferVisibleWindow: preferVisibleWindow, + } +} + +func (plan *browserStartPlan) releaseBridgeIfNeeded(a *App) { + if plan == nil || a == nil { + return + } + if plan.releaseXrayBridge && plan.acquiredXrayBridgeKey != "" && a.xrayMgr != nil { + a.xrayMgr.ReleaseBridge(plan.acquiredXrayBridgeKey) + } +} + +func (a *App) resolveBrowserStartProfile(input browserStartInput) (*BrowserProfile, bool, error) { + log := logger.New("Browser") + + profile, exists := a.browserMgr.Profiles[input.ProfileID] + if !exists { + err := fmt.Errorf("实例启动失败:未找到实例配置(ID=%s)。请刷新列表后重试。", input.ProfileID) + log.Error("实例不存在", logger.F("profile_id", input.ProfileID), logger.F("reason", err.Error())) + return nil, false, err + } + + if !profile.Running { + return profile, false, nil + } + + if !isBrowserProfileLive(profile, a.browserMgr.BrowserProcesses[input.ProfileID]) { + log.Info("检测到实例运行状态已失效,准备重新启动", + logger.F("profile_id", input.ProfileID), + logger.F("pid", profile.Pid), + logger.F("debug_port", profile.DebugPort), + ) + a.markProfileStoppedLocked(input.ProfileID, profile) + return profile, false, nil + } + + if input.PreferVisibleWindow { + if err := a.openBrowserWindowForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil { + startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err) + log.Error("运行中实例窗口唤起失败", + logger.F("profile_id", input.ProfileID), + logger.F("debug_port", profile.DebugPort), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return profile, true, startErr + } + } + + if a.launchServer != nil && profile.DebugReady { + a.launchServer.SetActiveProfile(profile) + } + a.emitBrowserInstanceStarted(profile, true) + return profile, true, nil +} + +func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserProfile) (*browserStartPlan, error) { + sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, err := a.prepareBrowserLaunchContext(input, profile) + if err != nil { + return nil, err + } + + effectiveProxy, acquiredXrayBridgeKey, releaseXrayBridge, err := a.resolveBrowserStartProxy(input.ProfileID, profile) + if err != nil { + return nil, err + } + + startReadyTimeout, startStableWindow := a.browserStartTimingSettings() + maxStartAttempts := browserStartAttemptCount() + totalReadyTimeout := time.Duration(maxStartAttempts) * startReadyTimeout + + assignedDebugPort, err := nextAvailablePort() + if err != nil { + startErr := fmt.Errorf("实例启动失败:本地调试端口分配失败。原因:%v。请关闭占用端口的程序后重试。", err) + logger.New("Browser").Error("调试端口分配失败", + logger.F("profile_id", input.ProfileID), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return nil, startErr + } + + return &browserStartPlan{ + profile: profile, + chromeBinaryPath: chromeBinaryPath, + userDataDir: userDataDir, + args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, browserDefaultStartURLs(a.config), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)), + effectiveProxy: effectiveProxy, + acquiredXrayBridgeKey: acquiredXrayBridgeKey, + releaseXrayBridge: releaseXrayBridge, + assignedDebugPort: assignedDebugPort, + startReadyTimeout: startReadyTimeout, + startStableWindow: startStableWindow, + maxStartAttempts: maxStartAttempts, + totalReadyTimeout: totalReadyTimeout, + }, nil +} + +func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *BrowserProfile) ([]string, []string, string, string, error) { + log := logger.New("Browser") + + sanitizedProfileLaunchArgs, managedProfileArgs := sanitizeManagedLaunchArgs(profile.LaunchArgs) + sanitizedExtraLaunchArgs, managedExtraArgs := sanitizeManagedLaunchArgs(input.ExtraLaunchArgs) + logManagedLaunchArgOverrides(log, input.ProfileID, "profile.launchArgs", managedProfileArgs) + logManagedLaunchArgOverrides(log, input.ProfileID, "start.extraLaunchArgs", managedExtraArgs) + + 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", input.ProfileID), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return nil, nil, "", "", startErr + } + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if err := os.MkdirAll(userDataDir, 0o755); err != nil { + startErr := fmt.Errorf("实例启动失败:无法创建用户数据目录 %s。原因:%w。请检查目录权限或路径配置。", userDataDir, err) + log.Error("用户数据目录创建失败", + logger.F("profile_id", input.ProfileID), + logger.F("dir", userDataDir), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return nil, nil, "", "", startErr + } + + if err := browser.EnsureDefaultBookmarks(userDataDir, a.BookmarkList()); err != nil { + log.Error("默认书签写入失败", logger.F("error", err.Error())) + } + + if !browserRestoreLastSession(a.config) { + if err := browser.ClearSessionRestoreData(userDataDir); err != nil { + sessionDir := filepath.Join(userDataDir, "Default", "Sessions") + startErr := fmt.Errorf("实例启动失败:无法清理上次会话缓存 %s。原因:%w。请关闭占用该目录的浏览器进程后重试。", sessionDir, err) + log.Error("会话恢复缓存清理失败", + logger.F("profile_id", input.ProfileID), + logger.F("dir", sessionDir), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return nil, nil, "", "", startErr + } + } + + return sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, nil +} + +func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool) []string { + 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, sanitizedProfileLaunchArgs...) + args = append(args, sanitizedExtraLaunchArgs...) + return appendLaunchTargets(args, startURLs, defaultStartURLs, skipDefaultStartURLs, restoreLastSession) +} diff --git a/backend/app_instance_start_proxy.go b/backend/app_instance_start_proxy.go new file mode 100644 index 00000000..e558d943 --- /dev/null +++ b/backend/app_instance_start_proxy.go @@ -0,0 +1,88 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "ant-chrome/backend/internal/proxy" + "fmt" + "strings" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile) (string, string, bool, error) { + log := logger.New("Browser") + proxies := a.getLatestProxies() + + 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 + } + } + } + + 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 "", "", false, startErr + } + + if proxy.IsSingBoxProtocol(resolvedProxyConfig) { + socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId) + if bridgeErr != nil { + startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) + log.Error("代理桥接失败(sing-box)", + logger.F("error", bridgeErr.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + a.emitBrowserStartBridgeFailure(profileID, profile.ProfileName, startErr.Error()) + return "", "", false, startErr + } + log.Info("sing-box 桥接成功", logger.F("socks_url", socksURL)) + return socksURL, "", false, nil + } + + if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) { + socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId) + if bridgeErr != nil { + startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) + log.Error("代理桥接失败(xray)", + logger.F("error", bridgeErr.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + a.emitBrowserStartBridgeFailure(profileID, profile.ProfileName, startErr.Error()) + return "", "", false, startErr + } + log.Info("xray 桥接成功", logger.F("socks_url", socksURL)) + return socksURL, bridgeKey, bridgeKey != "", nil + } + + return resolvedProxyConfig, "", false, nil +} + +func (a *App) emitBrowserStartBridgeFailure(profileID string, profileName string, errorText string) { + if a.ctx == nil { + return + } + runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{ + "profileId": profileID, + "profileName": profileName, + "error": errorText, + }) +} diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go index c4d8c3e5..f634d305 100644 --- a/backend/app_instance_start_test.go +++ b/backend/app_instance_start_test.go @@ -33,6 +33,47 @@ func TestEnsureNewWindowLaunchArgAddsFlagOnce(t *testing.T) { } } +func TestShouldPreferVisibleWindowForStartWithParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + startURLs []string + want bool + }{ + { + name: "nil start URLs", + startURLs: nil, + want: false, + }, + { + name: "empty start URLs", + startURLs: []string{}, + want: false, + }, + { + name: "blank start URLs", + startURLs: []string{" ", "\t"}, + want: false, + }, + { + name: "valid start URL", + startURLs: []string{"https://finance.sina.com.cn"}, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := shouldPreferVisibleWindowForStartWithParams(tt.startURLs); got != tt.want { + t.Fatalf("shouldPreferVisibleWindowForStartWithParams() = %v, want %v", got, tt.want) + } + }) + } +} + func TestIsBrowserProfileLive(t *testing.T) { t.Parallel() @@ -420,6 +461,36 @@ func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) { } } +func TestAppendLaunchTargetsUsesConfiguredDefaultStartURLs(t *testing.T) { + t.Parallel() + + got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{"https://one.example/", "https://two.example/"}, false, false) + want := []string{"--disable-sync", "https://one.example/", "https://two.example/"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("appendLaunchTargets mismatch: got=%v want=%v", got, want) + } +} + +func TestAppendLaunchTargetsUsesBlankPageWhenSessionRestoreDisabled(t *testing.T) { + t.Parallel() + + got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, false) + want := []string{"--disable-sync", "about:blank"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("appendLaunchTargets should fall back to about:blank: got=%v want=%v", got, want) + } +} + +func TestAppendLaunchTargetsPreservesSessionRestoreWhenEnabled(t *testing.T) { + t.Parallel() + + got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, true) + want := []string{"--disable-sync"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("appendLaunchTargets should preserve session restore behavior: got=%v want=%v", got, want) + } +} + func mustListenLoopback(t *testing.T) net.Listener { t.Helper() diff --git a/backend/app_instance_status.go b/backend/app_instance_status.go new file mode 100644 index 00000000..2f197580 --- /dev/null +++ b/backend/app_instance_status.go @@ -0,0 +1,30 @@ +package backend + +import "fmt" + +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}, + } +} diff --git a/backend/app_instance_stop.go b/backend/app_instance_stop.go new file mode 100644 index 00000000..953ff566 --- /dev/null +++ b/backend/app_instance_stop.go @@ -0,0 +1,52 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "time" +) + +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] + debugPort := profile.DebugPort + if tryCloseBrowserViaCDP(debugPort, 5*time.Second) { + a.markProfileStoppedLocked(profileId, profile) + log.Info("实例停止", logger.F("profile_id", profileId), logger.F("method", "cdp"), logger.F("debug_port", debugPort)) + return profile, nil + } + + 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 + } + } + + if debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) { + err := fmt.Errorf("实例停止失败:浏览器仍在运行(调试端口 %d 仍可访问)", debugPort) + log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("reason", err.Error())) + profile.LastError = err.Error() + return profile, err + } + + a.markProfileStoppedLocked(profileId, profile) + 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) +} diff --git a/backend/app_launchcode.go b/backend/app_launchcode.go index 57d148a0..751d5851 100644 --- a/backend/app_launchcode.go +++ b/backend/app_launchcode.go @@ -4,6 +4,7 @@ import ( "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/launchcode" "fmt" + "time" ) // StartInstance 实现 launchcode.BrowserStarter 接口 @@ -16,6 +17,38 @@ func (a *App) StartInstanceWithParams(profileId string, params launchcode.Launch return a.BrowserInstanceStartWithParams(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs) } +// StatusInstance 实现 launchcode.BrowserStatusProvider 接口 +func (a *App) StatusInstance(profileId string) (*browser.Profile, error) { + return a.BrowserInstanceStatus(profileId) +} + +// StopInstance 实现 launchcode.BrowserStopper 接口 +func (a *App) StopInstance(profileId string) (*browser.Profile, error) { + return a.BrowserInstanceStop(profileId) +} + +// WaitInstanceDebugReady 实现 launchcode.BrowserDebugWaiter 接口 +func (a *App) WaitInstanceDebugReady(profileId string, debugPort int, timeout time.Duration) (*browser.Profile, bool, error) { + if timeout <= 0 { + profile, err := a.BrowserInstanceStatus(profileId) + if err != nil { + return nil, false, err + } + return profile, profile != nil && profile.DebugReady, nil + } + + snapshot, _ := a.waitForBrowserDebugReady(profileId, debugPort, timeout) + if snapshot != nil { + return snapshot, snapshot.DebugReady, nil + } + + profile, err := a.BrowserInstanceStatus(profileId) + if err != nil { + return nil, false, err + } + return profile, profile != nil && profile.DebugReady, nil +} + // BrowserProfileGetCode 获取实例的 LaunchCode(Wails 绑定) func (a *App) BrowserProfileGetCode(profileId string) (string, error) { if a.launchCodeSvc == nil { @@ -94,11 +127,16 @@ func (a *App) GetLaunchServerInfo() map[string]interface{} { info["cdpUrl"] = fmt.Sprintf("http://127.0.0.1:%d", actualPort) if a.launchServer != nil { info["activeDebugPort"] = a.launchServer.ActiveDebugPort() + activeProfileID, activeProfileName, _ := a.launchServer.ActiveProfile() + info["activeProfileId"] = activeProfileID + info["activeProfileName"] = activeProfileName } } else { info["baseUrl"] = "" info["cdpUrl"] = "" info["activeDebugPort"] = 0 + info["activeProfileId"] = "" + info["activeProfileName"] = "" } return info } @@ -106,3 +144,10 @@ func (a *App) GetLaunchServerInfo() map[string]interface{} { // 确保编译器检查 App 实现了 BrowserStarter 接口 var _ launchcode.BrowserStarter = (*App)(nil) var _ launchcode.BrowserStarterWithParams = (*App)(nil) +var _ launchcode.BrowserStatusProvider = (*App)(nil) +var _ launchcode.BrowserStopper = (*App)(nil) +var _ launchcode.BrowserDebugWaiter = (*App)(nil) +var _ launchcode.AutomationScriptLister = (*App)(nil) +var _ launchcode.AutomationScriptGetter = (*App)(nil) +var _ launchcode.AutomationScriptRunner = (*App)(nil) +var _ launchcode.AutomationScriptRunLister = (*App)(nil) diff --git a/backend/app_profile_tags.go b/backend/app_profile_tags.go new file mode 100644 index 00000000..dff02793 --- /dev/null +++ b/backend/app_profile_tags.go @@ -0,0 +1,137 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "strings" + "time" +) + +// 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 _, tag := range profile.Tags { + existing[tag] = struct{}{} + } + for _, tag := range tags { + if _, ok := existing[tag]; !ok { + profile.Tags = append(profile.Tags, tag) + existing[tag] = 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 _, tag := range tags { + removeSet[tag] = struct{}{} + } + + for _, profileID := range profileIds { + profile, exists := a.browserMgr.Profiles[profileID] + if !exists { + continue + } + filtered := profile.Tags[:0] + for _, tag := range profile.Tags { + if _, ok := removeSet[tag]; !ok { + filtered = append(filtered, tag) + } + } + 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 _, tag := range profile.Tags { + if strings.EqualFold(tag, oldName) { + newTags = append(newTags, newName) + tagChanged = true + } else { + newTags = append(newTags, tag) + } + } + + if tagChanged { + uniqueTags := make([]string, 0) + seen := make(map[string]struct{}) + for _, tag := range newTags { + if _, ok := seen[tag]; !ok { + uniqueTags = append(uniqueTags, tag) + seen[tag] = 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 +} diff --git a/backend/app_proxy_health.go b/backend/app_proxy_health.go new file mode 100644 index 00000000..2053c151 --- /dev/null +++ b/backend/app_proxy_health.go @@ -0,0 +1,258 @@ +package backend + +import ( + "ant-chrome/backend/internal/proxy" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果 +func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult { + proxies := a.getLatestProxies() + result := 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, result.Ok, result.LatencyMs, testedAt) + } + return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.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 + + for worker := 0; worker < concurrency; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + result := 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, result.Ok, result.LatencyMs, testedAt) + } + item := ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error} + results[job.Idx] = item + + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "proxy:speed:result", item) + } + } + }() + } + + for i, proxyID := range proxyIds { + jobs <- speedJob{Idx: i, ProxyId: proxyID} + } + 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, proxyID := range proxyIds { + jobs <- healthJob{Idx: i, ProxyId: proxyID} + } + 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(data map[string]interface{}, key string) string { + value, ok := data[key] + if !ok || value == nil { + return "" + } + switch item := value.(type) { + case string: + return item + default: + return fmt.Sprint(value) + } +} + +func mapInt64(data map[string]interface{}, key string) int64 { + value, ok := data[key] + if !ok || value == nil { + return 0 + } + switch item := value.(type) { + case int: + return int64(item) + case int8: + return int64(item) + case int16: + return int64(item) + case int32: + return int64(item) + case int64: + return item + case uint: + return int64(item) + case uint8: + return int64(item) + case uint16: + return int64(item) + case uint32: + return int64(item) + case uint64: + return int64(item) + case float32: + return int64(item) + case float64: + return int64(item) + case json.Number: + if integer, err := item.Int64(); err == nil { + return integer + } + if decimal, err := item.Float64(); err == nil { + return int64(decimal) + } + case string: + if integer, err := strconv.ParseInt(item, 10, 64); err == nil { + return integer + } + if decimal, err := strconv.ParseFloat(item, 64); err == nil { + return int64(decimal) + } + } + return 0 +} + +func mapBool(data map[string]interface{}, key string) bool { + value, ok := data[key] + if !ok || value == nil { + return false + } + switch item := value.(type) { + case bool: + return item + case string: + return strings.EqualFold(item, "true") || item == "1" + case int: + return item != 0 + case int64: + return item != 0 + case float64: + return item != 0 + } + return false +} diff --git a/backend/app_proxy_query.go b/backend/app_proxy_query.go new file mode 100644 index 00000000..2103b029 --- /dev/null +++ b/backend/app_proxy_query.go @@ -0,0 +1,76 @@ +package backend + +import ( + "ant-chrome/backend/internal/proxy" +) + +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 _, item := range a.config.Browser.Proxies { + if item.GroupName == groupName { + result = append(result, item) + } + } + 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, + } +} + +// TestProxyConnectivity 测试代理连通性 +func (a *App) TestProxyConnectivity(proxyId string, proxyConfig string) ProxyTestResult { + proxies := a.getLatestProxies() + result := proxy.TestConnectivity(proxyId, proxyConfig, proxies, nil) + return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error} +} + +// TestProxyRealConnectivity 通过真实 HTTP 请求测试代理连通性(Wails 绑定) +// 参考 Clash URLTest 策略:多 URL fallback + 复用桥接 + TCP ping 降级 +func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult { + proxies := a.getLatestProxies() + result := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil) + return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error} +} + +// 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 +} diff --git a/backend/app_proxy_save.go b/backend/app_proxy_save.go new file mode 100644 index 00000000..45b84ff8 --- /dev/null +++ b/backend/app_proxy_save.go @@ -0,0 +1,104 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "strings" +) + +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 _, builtin := range builtins { + found := false + for _, item := range normalized { + if item.ProxyId == builtin.ProxyId { + found = true + break + } + } + if !found { + normalized = append([]BrowserProxy{builtin}, normalized...) + } + } + + a.config.Browser.Proxies = normalized + + if a.browserMgr.ProxyDAO != nil { + if err := a.browserMgr.ProxyDAO.DeleteAll(); err != nil { + log.Error("清空代理表失败", logger.F("error", err)) + return err + } + for _, item := range normalized { + if err := a.browserMgr.ProxyDAO.Upsert(item); err != nil { + log.Error("代理保存失败", logger.F("proxy_id", item.ProxyId), logger.F("error", err)) + return err + } + } + log.Info("代理列表已保存到数据库", logger.F("count", len(normalized))) + a.reconcileProfileProxyBindings() + return nil + } + + if err := config.SaveProxies(a.resolveAppPath("proxies.yaml"), normalized); err != nil { + log.Error("代理列表保存失败", logger.F("error", err)) + return err + } + a.reconcileProfileProxyBindings() + return nil +} diff --git a/backend/app_proxy_types.go b/backend/app_proxy_types.go new file mode 100644 index 00000000..c6c4fdc0 --- /dev/null +++ b/backend/app_proxy_types.go @@ -0,0 +1,33 @@ +package backend + +// ProxyValidationResult 代理验证结果 +type ProxyValidationResult struct { + Supported bool `json:"supported"` + ErrorMsg string `json:"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"` +} diff --git a/backend/app_runtime_reload.go b/backend/app_runtime_reload.go new file mode 100644 index 00000000..ec3d2a1f --- /dev/null +++ b/backend/app_runtime_reload.go @@ -0,0 +1,62 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" + "ant-chrome/backend/internal/logger" + "fmt" + "runtime/debug" +) + +// 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) + if a.browserMgr != nil { + a.browserMgr.Config = cfg + a.browserMgr.ListCores() + a.loadProxies() + a.reconcileProfileProxyBindings() + } + if a.xrayMgr != nil { + a.xrayMgr.Config = cfg + } + if a.clashMgr != nil { + a.clashMgr.Config = cfg + } + if a.singboxMgr != nil { + a.singboxMgr.Config = cfg + } + if a.launchServer != nil { + a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ + Enabled: cfg.LaunchServer.Auth.Enabled, + APIKey: cfg.LaunchServer.Auth.APIKey, + Header: cfg.LaunchServer.Auth.Header, + }) + } + if a.automationMgr != nil { + a.automationMgr.SetConfig(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) + return + } + debug.SetMemoryLimit(1 << 60) +} diff --git a/backend/app_shutdown.go b/backend/app_shutdown.go index c1616e0f..a6a11203 100644 --- a/backend/app_shutdown.go +++ b/backend/app_shutdown.go @@ -2,150 +2,143 @@ package backend import ( "ant-chrome/backend/internal/logger" - "fmt" + "context" "os/exec" - stdruntime "runtime" - "sync" - "time" + goruntime "runtime" + "strings" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) -type browserProcessSnapshot struct { - profileID string - cmd *exec.Cmd +func (a *App) shutdown(ctx context.Context) { + log := logger.New("App") + if a.shouldStopRuntimeServicesOnShutdown() { + log.Info("应用正在关闭...") + a.stopRuntimeServices() + } else { + log.Info("应用正在关闭(保留当前已打开的浏览器实例)...") + } + a.finalizeShutdown() +} + +func (a *App) GetInterceptor() *logger.MethodInterceptor { + return a.interceptor +} + +// ForceQuit 设置强制退出标志并调用 runtime.Quit +func (a *App) ForceQuit() { + a.setQuitMode(quitModeFull) + a.stopRuntimeServices() + if a.ctx != nil { + runtime.Quit(a.ctx) + } +} + +// QuitAppOnly 仅退出应用本身,保留当前已打开的浏览器实例。 +func (a *App) QuitAppOnly() { + a.setQuitMode(quitModeAppOnly) + if a.ctx != nil { + runtime.Quit(a.ctx) + } +} + +func Start(a *App, ctx context.Context) { + a.startup(ctx) +} + +func Stop(a *App, ctx context.Context) { + a.shutdown(ctx) +} + +func platformSupportsTrayCloseFlow() bool { + return platformSupportsTrayCloseFlowForOS(goruntime.GOOS) +} + +func platformSupportsTrayCloseFlowForOS(goos string) bool { + return strings.EqualFold(strings.TrimSpace(goos), "windows") +} + +func (a *App) setQuitMode(mode quitMode) { + a.forceQuit = true + a.quitMode = mode +} + +func (a *App) shouldStopRuntimeServicesOnShutdown() bool { + return a.quitMode != quitModeAppOnly +} + +func ShouldBlockClose(a *App, ctx context.Context) bool { + if a.forceQuit { + return false + } + if !platformSupportsTrayCloseFlow() { + return false + } + runtime.EventsEmit(ctx, "app:request-close") + return true } func (a *App) stopRuntimeServices() { a.stopServicesOnce.Do(func() { - log := logger.New("App") - - a.stopAllBrowserProcessesForExit(log) - - if a.xrayMgr != nil { - a.xrayMgr.StopAll() + if a.automationMgr != nil { + a.automationMgr.StopAllTasks() } - 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 + a.stopTrackedBrowserProcesses() + if a.xrayMgr != nil { + a.xrayMgr.StopAll() } - - if err := killResidualRuntimeProcesses(a.appRoot); err != nil { - log.Error("退出前清理残留进程失败", logger.F("error", err.Error())) + a.clearProfileXrayBridges() + if a.clashMgr != nil { + a.clashMgr.StopAll() + } + if a.singboxMgr != nil { + a.singboxMgr.StopAll() } }) } -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) { +func (a *App) stopTrackedBrowserProcesses() { if a.browserMgr == nil { return } - stoppedAt := time.Now().Format(time.RFC3339) + a.browserMgr.Mutex.Lock() + cmds := make([]*exec.Cmd, 0, len(a.browserMgr.BrowserProcesses)) + for _, cmd := range a.browserMgr.BrowserProcesses { + cmds = append(cmds, cmd) + } + a.browserMgr.Mutex.Unlock() + + for _, cmd := range cmds { + _ = a.stopProcessCmd(cmd) + } 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 + defer a.browserMgr.Mutex.Unlock() + + for profileID, profile := range a.browserMgr.Profiles { + if profile == nil { + continue } - if cmd != nil && cmd.Process != nil { - processes = append(processes, browserProcessSnapshot{ - profileID: profileID, - cmd: cmd, - }) + if profile.Running || a.browserMgr.BrowserProcesses[profileID] != nil { + a.markProfileStoppedLocked(profileID, profile) } } 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 +func (a *App) finalizeShutdown() { + a.finalizeOnce.Do(func() { + if a.launchServer != nil { + _ = a.launchServer.Stop() } - } - - 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 + if a.db != nil { + _ = a.db.Close() + } + _ = logger.Close() + }) } diff --git a/backend/app_snapshot.go b/backend/app_snapshot.go index 78d807d7..e3c30e98 100644 --- a/backend/app_snapshot.go +++ b/backend/app_snapshot.go @@ -1,24 +1,5 @@ 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"` @@ -28,266 +9,3 @@ type SnapshotInfo struct { 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_snapshot_api.go b/backend/app_snapshot_api.go new file mode 100644 index 00000000..15130bec --- /dev/null +++ b/backend/app_snapshot_api.go @@ -0,0 +1,161 @@ +package backend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +// 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, 0o644); err != nil { + return SnapshotInfo{}, err + } + + 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 + } + + 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, 0o755); 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 +} diff --git a/backend/app_snapshot_archive.go b/backend/app_snapshot_archive.go new file mode 100644 index 00000000..ac31712b --- /dev/null +++ b/backend/app_snapshot_archive.go @@ -0,0 +1,94 @@ +package backend + +import ( + "archive/zip" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// 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)) + 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, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); 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 +} diff --git a/backend/app_snapshot_paths.go b/backend/app_snapshot_paths.go new file mode 100644 index 00000000..11745837 --- /dev/null +++ b/backend/app_snapshot_paths.go @@ -0,0 +1,36 @@ +package backend + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// snapshotDir 返回指定实例的快照目录路径(存放在 data/snapshots 下) +func (a *App) snapshotDir(profileId string) (string, error) { + dir := filepath.Join(a.resolveAppPath("data"), "snapshots", profileId) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, 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_snapshot_test.go b/backend/app_snapshot_test.go new file mode 100644 index 00000000..6c21a76c --- /dev/null +++ b/backend/app_snapshot_test.go @@ -0,0 +1,64 @@ +package backend + +import ( + "os" + "path/filepath" + "testing" +) + +func TestZipDirAndUnzipTo(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := filepath.Join(root, "src") + dstZip := filepath.Join(root, "archive.zip") + dstDir := filepath.Join(root, "dst") + + if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil { + t.Fatalf("mkdir src: %v", err) + } + if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("write source file: %v", err) + } + + if err := zipDir(src, dstZip); err != nil { + t.Fatalf("zipDir failed: %v", err) + } + if err := unzipTo(dstZip, dstDir); err != nil { + t.Fatalf("unzipTo failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dstDir, "nested", "file.txt")) + if err != nil { + t.Fatalf("read extracted file: %v", err) + } + if string(data) != "hello" { + t.Fatalf("extracted content = %q, want hello", string(data)) + } +} + +func TestFindSnapshotFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + metaPath := filepath.Join(dir, "snap-1_demo.meta.json") + zipPath := filepath.Join(dir, "snap-1_demo.zip") + + if err := os.WriteFile(metaPath, []byte("{}"), 0o644); err != nil { + t.Fatalf("write meta: %v", err) + } + if err := os.WriteFile(zipPath, []byte("zip"), 0o644); err != nil { + t.Fatalf("write zip: %v", err) + } + + gotMeta, gotZip, err := findSnapshotFiles(dir, "snap-1") + if err != nil { + t.Fatalf("findSnapshotFiles failed: %v", err) + } + if gotMeta != metaPath { + t.Fatalf("meta path = %q, want %q", gotMeta, metaPath) + } + if gotZip != zipPath { + t.Fatalf("zip path = %q, want %q", gotZip, zipPath) + } +} diff --git a/backend/app_startup.go b/backend/app_startup.go new file mode 100644 index 00000000..d1be3b97 --- /dev/null +++ b/backend/app_startup.go @@ -0,0 +1,212 @@ +package backend + +import ( + "ant-chrome/backend/internal/apppath" + "ant-chrome/backend/internal/automation" + "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" + "fmt" + "os" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// startup 应用启动时调用 +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + if err := apppath.EnsureWritableLayout(a.appRoot); err != nil { + runtime.LogFatal(ctx, fmt.Sprintf("初始化 Linux 用户数据目录失败: %v", err)) + return + } + + cfg := a.startupLoadConfig() + a.config = cfg + a.applyRuntimeConfig(cfg.Runtime) + + log := a.startupInitLogger(ctx, cfg) + a.startupLogEnvironment(log, cfg) + + if err := os.MkdirAll(a.resolveAppPath("data"), 0o755); err != nil { + log.Error("创建 data 目录失败", logger.F("error", err)) + } + + a.ensureDefaultCores() + a.startupInitInterceptor(log, cfg) + + db, err := a.startupInitDatabase(cfg) + 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.startupInitManagers(cfg, db) + a.startupInitLaunchCode(log) + a.startupInitLaunchServer(log) + a.startupInitAutomation() + a.startupInitBridgeHooks() + a.startupInitSpeedScheduler() + + log.Info("应用启动成功") +} + +func (a *App) startupLoadConfig() *config.Config { + cfg, err := LoadConfig(a.resolveAppPath("config.yaml")) + if err != nil { + return config.DefaultConfig() + } + return cfg +} + +func (a *App) startupInitLogger(ctx context.Context, cfg *config.Config) *logger.Logger { + 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) + return logger.New("App") +} + +func (a *App) startupLogEnvironment(log *logger.Logger, cfg *config.Config) { + log.Info("应用启动中...", + logger.F("version", a.appVersion()), + logger.F("max_memory_mb", cfg.Runtime.MaxMemoryMB), + logger.F("gc_percent", cfg.Runtime.GCPercent), + ) + if apppath.IsDetached(a.appRoot) { + log.Info("检测到安装目录需要只读运行,已切换到用户数据目录", + logger.F("install_root", apppath.InstallRoot(a.appRoot)), + logger.F("state_root", apppath.StateRoot(a.appRoot)), + ) + } +} + +func (a *App) startupInitInterceptor(log *logger.Logger, cfg *config.Config) { + if !cfg.Logging.Interceptor.Enabled { + return + } + 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) +} + +func (a *App) startupInitDatabase(cfg *config.Config) (*database.DB, error) { + return database.NewDB(a.resolveAppPath(cfg.Database.SQLite.Path)) +} + +func (a *App) startupInitManagers(cfg *config.Config, db *database.DB) { + 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) + + 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) + + a.migrateToSQLite() + + a.browserMgr.InitData() + a.autoDetectCores() + a.loadProxies() + a.reconcileProfileProxyBindings() +} + +func (a *App) startupInitLaunchCode(log *logger.Logger) { + 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 +} + +func (a *App) startupInitLaunchServer(log *logger.Logger) { + port := a.config.LaunchServer.Port + a.launchServer = launchcode.NewLaunchServer(a.launchCodeSvc, a, a.browserMgr, port) + a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ + Enabled: a.config.LaunchServer.Auth.Enabled, + APIKey: a.config.LaunchServer.Auth.APIKey, + Header: a.config.LaunchServer.Auth.Header, + }) + if err := a.launchServer.Start(); err != nil { + log.Error("LaunchServer 启动失败", logger.F("error", err)) + return + } + log.Info("LaunchServer 监听地址", + logger.F("url", fmt.Sprintf("http://127.0.0.1:%d", a.launchServer.Port())), + logger.F("preferred_port", port), + ) +} + +func (a *App) startupInitAutomation() { + a.automationMgr = automation.NewManager(a.appRoot, a.config, func(event string, payload any) { + if a.ctx == nil { + return + } + runtime.EventsEmit(a.ctx, event, payload) + }, automation.Options{}) +} + +func (a *App) startupInitBridgeHooks() { + a.xrayMgr.OnBridgeDied = func(key string, err error) { + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{ + "engine": "xray", + "key": key[:8], + "error": err.Error(), + }) + } + } + a.singboxMgr.OnBridgeDied = func(key string, err error) { + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{ + "engine": "singbox", + "key": key[:8], + "error": err.Error(), + }) + } + } +} + +func (a *App) startupInitSpeedScheduler() { + 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() +} diff --git a/backend/automation_api_test.go b/backend/automation_api_test.go new file mode 100644 index 00000000..e8e08672 --- /dev/null +++ b/backend/automation_api_test.go @@ -0,0 +1,108 @@ +package backend + +import ( + "path/filepath" + "testing" + + "ant-chrome/backend/internal/config" +) + +func TestSaveAutomationRuntimeSettingsNormalizesAndPersists(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + + state, err := app.SaveAutomationRuntimeSettings(" SYSTEM ", " C:/tools/node/node.exe ") + if err != nil { + t.Fatalf("SaveAutomationRuntimeSettings returned error: %v", err) + } + + if app.config.Automation.NodeSource != config.AutomationNodeSourceSystem { + t.Fatalf("expected node source %q, got %q", config.AutomationNodeSourceSystem, app.config.Automation.NodeSource) + } + if app.config.Automation.SystemNodePath != "C:/tools/node/node.exe" { + t.Fatalf("expected trimmed system node path, got %q", app.config.Automation.SystemNodePath) + } + + settings, ok := state["settings"].(map[string]interface{}) + if !ok { + t.Fatalf("state.settings should be a map, got %T", state["settings"]) + } + if settings["nodeSource"] != config.AutomationNodeSourceSystem { + t.Fatalf("expected settings.nodeSource %q, got %#v", config.AutomationNodeSourceSystem, settings["nodeSource"]) + } + if settings["systemNodePath"] != "C:/tools/node/node.exe" { + t.Fatalf("expected settings.systemNodePath to be trimmed, got %#v", settings["systemNodePath"]) + } + + loaded, err := LoadConfig(filepath.Join(app.appRoot, "config.yaml")) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + if loaded.Automation.NodeSource != config.AutomationNodeSourceSystem { + t.Fatalf("expected persisted node source %q, got %q", config.AutomationNodeSourceSystem, loaded.Automation.NodeSource) + } + if loaded.Automation.SystemNodePath != "C:/tools/node/node.exe" { + t.Fatalf("expected persisted system node path, got %q", loaded.Automation.SystemNodePath) + } +} + +func TestSaveAutomationRuntimeSettingsFallsBackToAutoForUnknownSource(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + + if _, err := app.SaveAutomationRuntimeSettings("custom-source", ""); err != nil { + t.Fatalf("SaveAutomationRuntimeSettings returned error: %v", err) + } + + if app.config.Automation.NodeSource != config.AutomationNodeSourceAuto { + t.Fatalf("expected unknown source to normalize to %q, got %q", config.AutomationNodeSourceAuto, app.config.Automation.NodeSource) + } +} + +func TestSaveAutomationSettingsPreservesRuntimeStrategy(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.NodeSource = config.AutomationNodeSourceSystem + app.config.Automation.SystemNodePath = "C:/tools/node/node.exe" + + if _, err := app.SaveAutomationSettings(true, true); err != nil { + t.Fatalf("SaveAutomationSettings returned error: %v", err) + } + + if app.config.Automation.NodeSource != config.AutomationNodeSourceSystem { + t.Fatalf("expected node source to be preserved, got %q", app.config.Automation.NodeSource) + } + if app.config.Automation.SystemNodePath != "C:/tools/node/node.exe" { + t.Fatalf("expected system node path to be preserved, got %q", app.config.Automation.SystemNodePath) + } +} + +func TestSaveAutomationScriptPackageSettingsPersists(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + + state, err := app.SaveAutomationScriptPackageSettings(true) + if err != nil { + t.Fatalf("SaveAutomationScriptPackageSettings returned error: %v", err) + } + + if !app.config.Automation.AllowTypeScriptBuild { + t.Fatalf("expected allowTypeScriptBuild to be enabled in memory") + } + + settings, ok := state["settings"].(map[string]interface{}) + if !ok { + t.Fatalf("state.settings should be a map, got %T", state["settings"]) + } + if settings["allowTypeScriptBuild"] != true { + t.Fatalf("expected settings.allowTypeScriptBuild true, got %#v", settings["allowTypeScriptBuild"]) + } + + loaded, err := LoadConfig(filepath.Join(app.appRoot, "config.yaml")) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + if !loaded.Automation.AllowTypeScriptBuild { + t.Fatalf("expected persisted allowTypeScriptBuild to be true") + } +} diff --git a/backend/automation_demo_api.go b/backend/automation_demo_api.go new file mode 100644 index 00000000..d898f2bb --- /dev/null +++ b/backend/automation_demo_api.go @@ -0,0 +1,377 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + automationDemoHealthPath = "/api/health" + automationDemoProfilesPath = "/api/profiles" + automationDemoLaunchPath = "/api/launch" + automationDemoRuntimeSessionPath = "/api/runtime/session" + automationDemoTimeout = 10 * time.Second +) + +type automationDemoResultOptions struct { + RequestedCode string + StoppedBeforeDelete bool + StopError string +} + +type automationDemoCreateOptions struct { + ProfileName string `json:"profileName"` + LaunchCode string `json:"launchCode"` + StartURL string `json:"startUrl"` + LaunchArgs []string `json:"launchArgs"` + SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` + AutoLaunch bool `json:"autoLaunch"` +} + +func (a *App) AutomationDemoHealthCheck() (map[string]interface{}, error) { + status, payload, err := a.automationDemoRequest(http.MethodGet, automationDemoHealthPath, nil) + if err != nil { + return nil, err + } + return a.newAutomationDemoPayload(http.MethodGet, automationDemoHealthPath, status, payload, automationDemoResultOptions{}), nil +} + +func (a *App) AutomationDemoCreateProfile() (map[string]interface{}, error) { + return a.automationDemoCreateProfile(automationDemoCreateOptions{}) +} + +func (a *App) AutomationDemoCreateProfileWithOptions(optionsJSON string) (map[string]interface{}, error) { + options, err := decodeAutomationDemoCreateOptions(optionsJSON) + if err != nil { + return nil, err + } + return a.automationDemoCreateProfile(options) +} + +func (a *App) automationDemoCreateProfile(options automationDemoCreateOptions) (map[string]interface{}, error) { + requestedCode, requestBody := buildAutomationDemoCreateRequest(options) + + status, payload, err := a.automationDemoRequest(http.MethodPost, automationDemoProfilesPath, requestBody) + if err != nil { + return nil, err + } + return a.newAutomationDemoPayload(http.MethodPost, automationDemoProfilesPath, status, payload, automationDemoResultOptions{ + RequestedCode: requestedCode, + }), nil +} + +func (a *App) AutomationDemoLaunchProfile(code string) (map[string]interface{}, error) { + requestedCode := strings.ToUpper(strings.TrimSpace(code)) + if requestedCode == "" { + return nil, fmt.Errorf("launch code is required") + } + + status, payload, err := a.automationDemoRequest(http.MethodPost, automationDemoLaunchPath, map[string]interface{}{ + "code": requestedCode, + "startUrls": []string{"about:blank"}, + "skipDefaultStartUrls": true, + }) + if err != nil { + return nil, err + } + return a.newAutomationDemoPayload(http.MethodPost, automationDemoLaunchPath, status, payload, automationDemoResultOptions{ + RequestedCode: requestedCode, + }), nil +} + +func (a *App) AutomationDemoDeleteProfile(profileId string) (map[string]interface{}, error) { + normalizedProfileID := strings.TrimSpace(profileId) + if normalizedProfileID == "" { + return nil, fmt.Errorf("profileId is required") + } + + apiPath := automationDemoProfilesPath + "/" + url.PathEscape(normalizedProfileID) + status, payload, err := a.automationDemoRequest(http.MethodDelete, apiPath, nil) + if err != nil { + return nil, err + } + + options := automationDemoResultOptions{} + if status == http.StatusConflict { + if _, stopErr := a.BrowserInstanceStop(normalizedProfileID); stopErr != nil { + options.StopError = stopErr.Error() + return a.newAutomationDemoPayload(http.MethodDelete, apiPath, status, payload, options), nil + } + options.StoppedBeforeDelete = true + + status, payload, err = a.automationDemoRequest(http.MethodDelete, apiPath, nil) + if err != nil { + return nil, err + } + } + + return a.newAutomationDemoPayload(http.MethodDelete, apiPath, status, payload, options), nil +} + +func (a *App) automationDemoRequest(method string, apiPath string, body any) (int, map[string]interface{}, error) { + baseURL, authHeader, authValue, err := a.automationDemoEndpoint() + if err != nil { + return 0, nil, err + } + + requestURL := strings.TrimRight(baseURL, "/") + apiPath + ctx, cancel := context.WithTimeout(context.Background(), automationDemoTimeout) + defer cancel() + + var reader io.Reader + if body != nil { + raw, marshalErr := json.Marshal(body) + if marshalErr != nil { + return 0, nil, fmt.Errorf("marshal demo request failed: %w", marshalErr) + } + reader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, requestURL, reader) + if err != nil { + return 0, nil, fmt.Errorf("create demo request failed: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if authHeader != "" && authValue != "" { + req.Header.Set(authHeader, authValue) + } + + resp, err := (&http.Client{Timeout: automationDemoTimeout}).Do(req) + if err != nil { + return 0, nil, fmt.Errorf("call launch api failed: %w", err) + } + defer resp.Body.Close() + + payload, err := decodeAutomationDemoBody(resp.Body) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, payload, nil +} + +func (a *App) automationDemoEndpoint() (string, string, string, error) { + if a.launchServer == nil { + return "", "", "", fmt.Errorf("launch server is not initialized") + } + + port := a.launchServer.Port() + if port <= 0 { + return "", "", "", fmt.Errorf("launch server is not ready") + } + + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + if !a.launchServer.APIAuthEnabled() { + return baseURL, "", "", nil + } + if a.config == nil { + return "", "", "", fmt.Errorf("launch server auth config is not initialized") + } + + apiKey := strings.TrimSpace(a.config.LaunchServer.Auth.APIKey) + if apiKey == "" { + return "", "", "", fmt.Errorf("launch server api key is empty") + } + + return baseURL, a.launchServer.APIAuthHeader(), apiKey, nil +} + +func (a *App) newAutomationDemoPayload(method string, apiPath string, status int, response map[string]interface{}, options automationDemoResultOptions) map[string]interface{} { + if response == nil { + response = map[string]interface{}{} + } + + baseURL := "" + if a.launchServer != nil && a.launchServer.Port() > 0 { + baseURL = fmt.Sprintf("http://127.0.0.1:%d", a.launchServer.Port()) + } + + ok := status >= http.StatusOK && status < http.StatusMultipleChoices + if rawOK, exists := response["ok"]; exists { + if value, valid := rawOK.(bool); valid { + ok = ok && value + } + } + + payload := map[string]interface{}{ + "ok": ok, + "status": status, + "method": method, + "path": apiPath, + "baseUrl": baseURL, + "requestedAt": time.Now().Format(time.RFC3339), + "response": response, + } + + if errMsg := mapStringValue(response, "error"); errMsg != "" { + payload["error"] = errMsg + } + + for _, key := range []string{ + "profileId", + "profileName", + "launchCode", + "cdpUrl", + "cdpPort", + "debugPort", + "debugReady", + "pid", + "created", + "updated", + "launched", + "deleted", + "runtimeWarning", + "authHeader", + } { + if value, exists := response[key]; exists { + payload[key] = value + } + } + + if options.RequestedCode != "" { + payload["requestedCode"] = options.RequestedCode + if _, exists := payload["launchCode"]; !exists && status >= http.StatusOK && status < http.StatusMultipleChoices { + payload["launchCode"] = options.RequestedCode + } + } + if options.StoppedBeforeDelete { + payload["stoppedBeforeDelete"] = true + } + if options.StopError != "" { + payload["stopError"] = options.StopError + } + + return payload +} + +func decodeAutomationDemoBody(body io.Reader) (map[string]interface{}, error) { + raw, err := io.ReadAll(io.LimitReader(body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read demo response failed: %w", err) + } + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return map[string]interface{}{}, nil + } + + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return map[string]interface{}{"rawBody": string(raw)}, nil + } + + if payload, ok := decoded.(map[string]interface{}); ok { + return payload, nil + } + return map[string]interface{}{"data": decoded}, nil +} + +func decodeAutomationDemoCreateOptions(optionsJSON string) (automationDemoCreateOptions, error) { + normalizedJSON := strings.TrimSpace(optionsJSON) + if normalizedJSON == "" { + return automationDemoCreateOptions{}, nil + } + + var options automationDemoCreateOptions + if err := json.Unmarshal([]byte(normalizedJSON), &options); err != nil { + return automationDemoCreateOptions{}, fmt.Errorf("decode demo create options failed: %w", err) + } + return options, nil +} + +func buildAutomationDemoCreateRequest(options automationDemoCreateOptions) (string, map[string]interface{}) { + requestedCode := strings.ToUpper(strings.TrimSpace(options.LaunchCode)) + if requestedCode == "" { + requestedCode = automationDemoLaunchCode() + } + + profileName := strings.TrimSpace(options.ProfileName) + if profileName == "" { + profileName = fmt.Sprintf("自动化 Demo %s", requestedCode) + } + + launchArgs := normalizeAutomationDemoLaunchArgs(options.LaunchArgs) + requestBody := map[string]interface{}{ + "profile": map[string]interface{}{ + "profileName": profileName, + "userDataDir": fmt.Sprintf("automation-demo-%s", strings.ToLower(strings.ReplaceAll(requestedCode, "_", "-"))), + "launchArgs": launchArgs, + "tags": []string{"自动化", "Demo"}, + "keywords": []string{"automation-demo", "launch-api-demo"}, + }, + "launchCode": requestedCode, + "autoLaunch": options.AutoLaunch, + } + + if options.AutoLaunch { + requestBody["start"] = buildAutomationDemoStartPayload(options.StartURL, launchArgs, options.SkipDefaultStartURLs) + } + + return requestedCode, requestBody +} + +func normalizeAutomationDemoLaunchArgs(values []string) []string { + if len(values) == 0 { + return []string{} + } + + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized == "" { + continue + } + result = append(result, normalized) + } + return result +} + +func buildAutomationDemoStartPayload(startURL string, launchArgs []string, skipDefaultStartURLs bool) map[string]interface{} { + payload := map[string]interface{}{} + if len(launchArgs) > 0 { + payload["launchArgs"] = launchArgs + } + + normalizedStartURL := strings.TrimSpace(startURL) + if normalizedStartURL != "" { + payload["startUrls"] = []string{normalizedStartURL} + } + + if skipDefaultStartURLs { + payload["skipDefaultStartUrls"] = true + } + + if len(payload) == 0 { + payload["startUrls"] = []string{"about:blank"} + payload["skipDefaultStartUrls"] = true + } + + return payload +} + +func automationDemoLaunchCode() string { + token := strings.ToUpper(strings.ReplaceAll(generateUUID(), "-", "")) + if len(token) > 6 { + token = token[:6] + } + return "DEMO_" + token +} + +func mapStringValue(payload map[string]interface{}, key string) string { + value, exists := payload[key] + if !exists || value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" { + return "" + } + return text +} diff --git a/backend/automation_demo_api_test.go b/backend/automation_demo_api_test.go new file mode 100644 index 00000000..e79fbc7d --- /dev/null +++ b/backend/automation_demo_api_test.go @@ -0,0 +1,87 @@ +package backend + +import ( + "bytes" + "net/http" + "regexp" + "testing" +) + +func TestAutomationDemoLaunchCodeFormat(t *testing.T) { + code := automationDemoLaunchCode() + if matched := regexp.MustCompile(`^DEMO_[A-Z0-9]{6}$`).MatchString(code); !matched { + t.Fatalf("expected demo launch code to match DEMO_[A-Z0-9]{6}, got %q", code) + } +} + +func TestNewAutomationDemoPayloadUsesRequestedCode(t *testing.T) { + app := &App{} + payload := app.newAutomationDemoPayload(http.MethodPost, automationDemoProfilesPath, http.StatusCreated, map[string]interface{}{ + "ok": true, + "profileId": "profile-1", + }, automationDemoResultOptions{ + RequestedCode: "DEMO_ABC123", + }) + + if payload["ok"] != true { + t.Fatalf("expected ok=true, got %#v", payload["ok"]) + } + if payload["launchCode"] != "DEMO_ABC123" { + t.Fatalf("expected launchCode to fall back to requested code, got %#v", payload["launchCode"]) + } + if payload["profileId"] != "profile-1" { + t.Fatalf("expected profileId to be propagated, got %#v", payload["profileId"]) + } +} + +func TestBuildAutomationDemoCreateRequestUsesOptions(t *testing.T) { + requestedCode, payload := buildAutomationDemoCreateRequest(automationDemoCreateOptions{ + ProfileName: "我的演示实例", + LaunchCode: " demo_custom ", + StartURL: " https://example.com/order ", + LaunchArgs: []string{" --lang=en-US ", "", "--window-size=1280,800"}, + SkipDefaultStartURLs: true, + AutoLaunch: true, + }) + + if requestedCode != "DEMO_CUSTOM" { + t.Fatalf("expected requested code to be normalized, got %q", requestedCode) + } + + profile, ok := payload["profile"].(map[string]interface{}) + if !ok { + t.Fatalf("expected profile payload, got %#v", payload["profile"]) + } + if profile["profileName"] != "我的演示实例" { + t.Fatalf("expected custom profile name, got %#v", profile["profileName"]) + } + launchArgs, ok := profile["launchArgs"].([]string) + if !ok { + t.Fatalf("expected launchArgs to be []string, got %#v", profile["launchArgs"]) + } + if len(launchArgs) != 2 || launchArgs[0] != "--lang=en-US" || launchArgs[1] != "--window-size=1280,800" { + t.Fatalf("expected launchArgs to be normalized, got %#v", launchArgs) + } + + start, ok := payload["start"].(map[string]interface{}) + if !ok { + t.Fatalf("expected start payload, got %#v", payload["start"]) + } + startURLs, ok := start["startUrls"].([]string) + if !ok || len(startURLs) != 1 || startURLs[0] != "https://example.com/order" { + t.Fatalf("expected startUrls to be normalized, got %#v", start["startUrls"]) + } + if start["skipDefaultStartUrls"] != true { + t.Fatalf("expected skipDefaultStartUrls=true, got %#v", start["skipDefaultStartUrls"]) + } +} + +func TestDecodeAutomationDemoBodyFallsBackToRawText(t *testing.T) { + payload, err := decodeAutomationDemoBody(bytes.NewBufferString("plain-text-response")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if payload["rawBody"] != "plain-text-response" { + t.Fatalf("expected rawBody fallback, got %#v", payload["rawBody"]) + } +} diff --git a/backend/automation_script_api.go b/backend/automation_script_api.go new file mode 100644 index 00000000..42a8e9dc --- /dev/null +++ b/backend/automation_script_api.go @@ -0,0 +1,73 @@ +package backend + +import ( + "path/filepath" + "strings" + + "ant-chrome/backend/internal/automation" +) + +func (a *App) automationScriptStore() *automation.ScriptStore { + return automation.NewScriptStore(a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "scripts")))) +} + +func (a *App) AutomationScriptList() ([]automation.ScriptRecord, error) { + store := a.automationScriptStore() + if err := a.ensureAutomationScriptDefaults(store); err != nil { + return nil, err + } + + items, err := store.List() + if err != nil { + return nil, err + } + return a.enrichAutomationScriptRecords(items), nil +} + +func (a *App) AutomationScriptGet(scriptID string) (*automation.ScriptRecord, error) { + store := a.automationScriptStore() + if err := a.ensureAutomationScriptDefaults(store); err != nil { + return nil, err + } + + record, err := store.Get(scriptID) + if err != nil { + return nil, err + } + enriched := a.enrichAutomationScriptRecord(record) + return &enriched, nil +} + +func (a *App) AutomationScriptSave(input automation.ScriptRecord) (*automation.ScriptRecord, error) { + record, err := a.automationScriptStore().Save(a.enrichAutomationScriptRecord(input)) + if err != nil { + return nil, err + } + return &record, nil +} + +func (a *App) AutomationScriptDelete(scriptID string) error { + return a.automationScriptStore().Delete(scriptID) +} + +func (a *App) enrichAutomationScriptRecords(items []automation.ScriptRecord) []automation.ScriptRecord { + if len(items) == 0 { + return []automation.ScriptRecord{} + } + + result := make([]automation.ScriptRecord, 0, len(items)) + for _, item := range items { + result = append(result, a.enrichAutomationScriptRecord(item)) + } + return result +} + +func (a *App) enrichAutomationScriptRecord(record automation.ScriptRecord) automation.ScriptRecord { + switch strings.ToLower(strings.TrimSpace(record.TargetConfig.Mode)) { + case "existing": + record.TargetConfig.Selector = a.enrichAutomationExactTargetSelector(record.TargetConfig.Selector) + case "create": + record.TargetConfig.TemplateSelector = a.enrichAutomationExactTargetSelector(record.TargetConfig.TemplateSelector) + } + return record +} diff --git a/backend/automation_script_api_test.go b/backend/automation_script_api_test.go new file mode 100644 index 00000000..05f18ce1 --- /dev/null +++ b/backend/automation_script_api_test.go @@ -0,0 +1,735 @@ +package backend + +import ( + "archive/zip" + "bytes" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" +) + +func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { + app := NewApp(t.TempDir()) + + items, err := app.AutomationScriptList() + if err != nil { + t.Fatalf("AutomationScriptList returned error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected two default scripts, got %d", len(items)) + } + + byID := make(map[string]automation.ScriptRecord, len(items)) + for _, script := range items { + byID[script.ID] = script + } + + expectedNames := map[string]string{ + "dual-instance-runtime-switch": "双实例启动与 Runtime 切换", + "news-query-txt": "查询新闻并写 TXT", + } + + for scriptID, expectedName := range expectedNames { + script, ok := byID[scriptID] + if !ok { + t.Fatalf("missing default script %q", scriptID) + } + if script.Name != expectedName { + t.Fatalf("unexpected default script name for %q: %q", scriptID, script.Name) + } + if script.EntryFile != "index.cjs" { + t.Fatalf("unexpected default entry file for %q: %q", scriptID, script.EntryFile) + } + + scriptDir := filepath.Join(app.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "scripts"))), script.ID) + if _, err := os.Stat(filepath.Join(scriptDir, "config")); err != nil { + t.Fatalf("expected default config to exist for %q: %v", scriptID, err) + } + if _, err := os.Stat(filepath.Join(scriptDir, script.EntryFile)); err != nil { + t.Fatalf("expected default entry file to exist for %q: %v", scriptID, err) + } + } + + dualScript := byID[automation.DualInstanceRuntimeScriptID] + if !strings.Contains(dualScript.ParamsText, `"browsers"`) { + t.Fatalf("expected dual-instance default params to use browsers array, got %s", dualScript.ParamsText) + } + if strings.Contains(dualScript.ParamsText, `"primaryCode"`) { + t.Fatalf("expected dual-instance default params to drop legacy primaryCode fields, got %s", dualScript.ParamsText) + } + + for scriptID := range expectedNames { + if err := app.AutomationScriptDelete(scriptID); err != nil { + t.Fatalf("AutomationScriptDelete returned error for %q: %v", scriptID, err) + } + } + + items, err = app.AutomationScriptList() + if err != nil { + t.Fatalf("AutomationScriptList returned error after delete: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected deleted default script not to be re-seeded, got %d items", len(items)) + } +} + +func TestAutomationScriptSaveListAndDelete(t *testing.T) { + app := NewApp(t.TempDir()) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "app-script", + Name: "App 脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + if saved == nil { + t.Fatalf("AutomationScriptSave returned nil result") + } + if saved.ID != "app-script" { + t.Fatalf("expected saved id app-script, got %q", saved.ID) + } + + items, err := app.AutomationScriptList() + if err != nil { + t.Fatalf("AutomationScriptList returned error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one script, got %d", len(items)) + } + + if err := app.AutomationScriptDelete(saved.ID); err != nil { + t.Fatalf("AutomationScriptDelete returned error: %v", err) + } + + items, err = app.AutomationScriptList() + if err != nil { + t.Fatalf("AutomationScriptList returned error after delete: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected zero scripts after delete, got %d", len(items)) + } +} + +func TestAutomationScriptSaveHydratesExactTargetSelectorWithCode(t *testing.T) { + app := newAutomationTargetTestApp(t) + profile := createAutomationTargetProfile(t, app, browser.ProfileInput{ + ProfileName: "buyer-001", + }) + code, err := app.launchCodeSvc.SetCode(profile.ProfileId, "BUYER_001") + if err != nil { + t.Fatalf("set code failed: %v", err) + } + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "app-script", + Name: "App 脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + TargetConfig: automation.ScriptTargetConfig{ + Mode: "existing", + Selector: automation.ScriptTargetSelector{ + ProfileID: profile.ProfileId, + }, + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + if saved == nil { + t.Fatalf("AutomationScriptSave returned nil result") + } + if saved.TargetConfig.Selector.ProfileID != profile.ProfileId { + t.Fatalf("expected profileId to be preserved, got %+v", saved.TargetConfig.Selector) + } + if saved.TargetConfig.Selector.Code != code { + t.Fatalf("expected code snapshot %q, got %+v", code, saved.TargetConfig.Selector) + } +} + +func TestAutomationScriptRunRecordsUnsupportedType(t *testing.T) { + app := NewApp(t.TempDir()) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "playwright-script", + Name: "Playwright 脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + run, err := app.AutomationScriptRun(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRun returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRun returned nil result") + } + if run.Status != "failed" { + t.Fatalf("expected unsupported script to fail, got %q", run.Status) + } + if run.Error == "" { + t.Fatalf("expected unsupported script run to contain error") + } + + runs, err := app.AutomationScriptRunList(10) + if err != nil { + t.Fatalf("AutomationScriptRunList returned error: %v", err) + } + if len(runs) != 1 { + t.Fatalf("expected one run record, got %d", len(runs)) + } +} + +func TestAutomationScriptRunWithOptionsInvalidSelector(t *testing.T) { + app := NewApp(t.TempDir()) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "launch-script", + Name: "Launch 脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + SelectorText: `{"code":"BUYER_001"}`, + ParamsText: `{"startUrls":["https://example.com"]}`, + ScriptText: "export async function run() {}", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: saved.ID, + SelectorText: "{invalid", + UseScriptSelector: false, + UseScriptParams: true, + }) + if err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRunWithOptions returned nil result") + } + if run.Status != "failed" { + t.Fatalf("expected invalid selector run to fail, got %q", run.Status) + } + if run.Error == "" { + t.Fatalf("expected invalid selector run to contain error") + } + if run.Summary != "脚本执行失败" { + t.Fatalf("expected invalid selector summary, got %q", run.Summary) + } +} + +func TestAutomationScriptRunWithOptionsAllowsEmptySelectorForDualInstanceRuntimeScript(t *testing.T) { + app := NewApp(t.TempDir()) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: automation.DualInstanceRuntimeScriptID, + Name: "双实例启动与 Runtime 切换", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ParamsText: `{"browsers":[{"code":"BUYER_001"},{"code":"BUYER_002"}],"timeoutMs":45000}`, + ScriptText: "export async function run() {}", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: saved.ID, + SelectorText: "", + UseScriptSelector: false, + UseScriptParams: true, + }) + if err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRunWithOptions returned nil result") + } + if run.Summary != "双实例流程执行失败" { + t.Fatalf("expected dual-instance flow to bypass selector validation, got %+v", run) + } + if strings.Contains(run.Error, "selector is required") { + t.Fatalf("expected dual-instance script to allow empty selector, got %+v", run) + } +} + +func TestAutomationScriptRefreshFromLocalFile(t *testing.T) { + app := NewApp(t.TempDir()) + + sourcePath := filepath.Join(t.TempDir(), "demo-script.cjs") + if err := os.WriteFile(sourcePath, []byte("module.exports.run = async () => ({ ok: true, source: 'local-file' })"), 0o644); err != nil { + t.Fatalf("write source file failed: %v", err) + } + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-local-file", + Name: "本地文件脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "local-file", + URI: sourcePath, + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if refreshed.Type != "playwright-cdp" { + t.Fatalf("expected type to follow imported source, got %q", refreshed.Type) + } + if refreshed.EntryFile != "demo-script.cjs" { + t.Fatalf("expected entry file from source bundle, got %q", refreshed.EntryFile) + } + if !strings.Contains(refreshed.ScriptText, "source: 'local-file'") { + t.Fatalf("expected refreshed script text from local file, got %q", refreshed.ScriptText) + } + if refreshed.Source.Type != "local-file" || refreshed.Source.URI != sourcePath { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } + if refreshed.Source.ImportedAt == "" { + t.Fatalf("expected refreshed source importedAt to be populated") + } +} + +func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) { + app := NewApp(t.TempDir()) + + sourceDir := filepath.Join(t.TempDir(), "local-dir-script") + if err := os.MkdirAll(filepath.Join(sourceDir, "scripts", "helpers"), 0o755); err != nil { + t.Fatalf("create local dir source failed: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "automation.script.json"), []byte(`{ + "name": "本地目录脚本", + "type": "playwright-cdp", + "entryFile": "scripts/index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write local dir manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "index.cjs"), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), 0o644); err != nil { + t.Fatalf("write local dir entry failed: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil { + t.Fatalf("write local dir helper failed: %v", err) + } + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-local-dir", + Name: "旧本地目录脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "local-dir", + URI: sourceDir, + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if refreshed.EntryFile != "scripts/index.cjs" { + t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile) + } + if !strings.Contains(refreshed.ScriptText, "helper.run()") { + t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText) + } +} + +func TestAutomationScriptRefreshFromRemote(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "manifest": { + "name": "远程刷新脚本", + "description": "来自远程", + "type": "playwright-cdp", + "entryFile": "index.cjs" + }, + "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })" +}`)) + })) + defer server.Close() + + app := NewApp(t.TempDir()) + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-remote", + Name: "旧远程脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "remote-url", + URI: server.URL + "/script.json", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Name != "远程刷新脚本" { + t.Fatalf("expected remote manifest name, got %q", refreshed.Name) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if !strings.Contains(refreshed.ScriptText, "source: 'remote'") { + t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText) + } + if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } +} + +func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) { + app := NewApp(t.TempDir()) + + zipData := buildAutomationZipBytesForTest(t, map[string]string{ + "automation.script.json": `{ + "name": "远程 ZIP", + "type": "playwright-cdp", + "entryFile": "scripts/index.cjs" +}`, + "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipData) + })) + defer server.Close() + + bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip") + if err != nil { + t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) + } + + if bundle.Record.Name != "远程 ZIP" { + t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) + } + if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" { + t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) + } + if !strings.Contains(bundle.Record.ScriptText, "remote-zip") { + t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) + } +} + +func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.AllowTypeScriptBuild = true + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(`export async function run() { + return { ok: true, source: 'remote-ts' } +}`)) + })) + defer server.Close() + + bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts") + if err != nil { + t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) + } + + if bundle.Record.EntryFile != "demo-script.cjs" { + t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) + } + if !strings.Contains(bundle.Record.ScriptText, "remote-ts") { + t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) + } + if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" { + t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) + } +} + +func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`export async function run() { + return { ok: true, source: 'remote-ts-refresh' } +}`)) + })) + defer server.Close() + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.AllowTypeScriptBuild = true + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-remote-ts", + Name: "旧远程 TS 脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "remote-url", + URI: server.URL + "/refresh-script.ts", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed.EntryFile != "refresh-script.cjs" { + t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile) + } + if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") { + t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText) + } + if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } +} + +func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + + repoDir := filepath.Join(t.TempDir(), "automation-ts-repo") + if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil { + t.Fatalf("create repo dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ + "name": "Git TS 导入", + "type": "playwright-cdp", + "entryFile": "index.ts" +}`), 0o644); err != nil { + t.Fatalf("write git manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag' + +export async function run() { + return { ok: flag, source: 'git-ts' } +}`), 0o644); err != nil { + t.Fatalf("write git entry file failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil { + t.Fatalf("write git helper file failed: %v", err) + } + + runGitForTest(t, repoDir, "init") + runGitForTest(t, repoDir, "config", "user.email", "test@example.com") + runGitForTest(t, repoDir, "config", "user.name", "Test User") + runGitForTest(t, repoDir, "add", ".") + runGitForTest(t, repoDir, "commit", "-m", "init") + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.AllowTypeScriptBuild = true + + bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo") + if err != nil { + t.Fatalf("loadAutomationGitBundle returned error: %v", err) + } + + if bundle.Record.Name != "Git TS 导入" { + t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) + } + if bundle.Record.EntryFile != "index.cjs" { + t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) + } + if !strings.Contains(bundle.Record.ScriptText, "git-ts") { + t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) + } + if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" { + t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) + } +} + +func TestAutomationScriptRefreshFromGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + + repoDir := filepath.Join(t.TempDir(), "automation-repo") + if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil { + t.Fatalf("create repo dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ + "name": "Git 刷新脚本", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write git manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil { + t.Fatalf("write git entry file failed: %v", err) + } + + runGitForTest(t, repoDir, "init") + runGitForTest(t, repoDir, "config", "user.email", "test@example.com") + runGitForTest(t, repoDir, "config", "user.name", "Test User") + runGitForTest(t, repoDir, "add", ".") + runGitForTest(t, repoDir, "commit", "-m", "init") + + app := NewApp(t.TempDir()) + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-git", + Name: "旧 Git 脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "git", + URI: repoDir, + Path: "scripts/demo", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Name != "Git 刷新脚本" { + t.Fatalf("expected git manifest name, got %q", refreshed.Name) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if !strings.Contains(refreshed.ScriptText, "source: 'git'") { + t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText) + } + if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } +} + +func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) { + app := NewApp(t.TempDir()) + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-manual", + Name: "手动脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + Source: automation.ScriptSource{ + Type: "manual", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + if _, err := app.AutomationScriptRefresh(saved.ID); err == nil { + t.Fatalf("expected unsupported source refresh to fail") + } +} + +func runGitForTest(t *testing.T, workdir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = workdir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) + } +} + +func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + + paths := make([]string, 0, len(files)) + for relativePath := range files { + paths = append(paths, relativePath) + } + sort.Strings(paths) + + for _, relativePath := range paths { + entry, err := writer.Create(relativePath) + if err != nil { + t.Fatalf("create zip entry failed: %v", err) + } + if _, err := entry.Write([]byte(files[relativePath])); err != nil { + t.Fatalf("write zip entry failed: %v", err) + } + } + + if err := writer.Close(); err != nil { + t.Fatalf("close zip writer failed: %v", err) + } + return buf.Bytes() +} diff --git a/backend/automation_script_defaults.go b/backend/automation_script_defaults.go new file mode 100644 index 00000000..2993285d --- /dev/null +++ b/backend/automation_script_defaults.go @@ -0,0 +1,82 @@ +package backend + +import ( + "os" + "path/filepath" + + "ant-chrome/backend/internal/automation" +) + +const ( + automationScriptDefaultsMarkerName = "defaults-seeded-v2" + automationScriptDefaultsLegacyMarkerName = "defaults-seeded-v1" +) + +func (a *App) automationScriptDefaultsMarkerPath(name string) string { + return a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", name))) +} + +func (a *App) automationScriptDefaultsInitializedByName(name string) bool { + info, err := os.Stat(a.automationScriptDefaultsMarkerPath(name)) + return err == nil && !info.IsDir() +} + +func (a *App) automationScriptDefaultsInitialized() bool { + return a.automationScriptDefaultsInitializedByName(automationScriptDefaultsMarkerName) +} + +func (a *App) automationScriptDefaultsInitializedLegacy() bool { + return a.automationScriptDefaultsInitializedByName(automationScriptDefaultsLegacyMarkerName) +} + +func (a *App) markAutomationScriptDefaultsInitialized() error { + markerPath := a.automationScriptDefaultsMarkerPath(automationScriptDefaultsMarkerName) + if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil { + return err + } + return os.WriteFile(markerPath, []byte("ok\n"), 0o644) +} + +func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) error { + defaults := automation.DefaultScripts() + items, err := store.List() + if err != nil { + return err + } + + // v2 marker exists: defaults were already initialized or user intentionally removed them. + if a.automationScriptDefaultsInitialized() { + return nil + } + + if len(items) == 0 { + // Keep legacy behavior for users that had deleted all defaults under v1. + if a.automationScriptDefaultsInitializedLegacy() { + return a.markAutomationScriptDefaultsInitialized() + } + + for _, record := range defaults { + if _, err := store.Save(record); err != nil { + return err + } + } + return a.markAutomationScriptDefaultsInitialized() + } + + // Migration from v1: existing scripts are present, add any missing built-in baselines once. + if a.automationScriptDefaultsInitializedLegacy() { + existingIDs := make(map[string]struct{}, len(items)) + for _, item := range items { + existingIDs[item.ID] = struct{}{} + } + for _, record := range defaults { + if _, exists := existingIDs[record.ID]; exists { + continue + } + if _, err := store.Save(record); err != nil { + return err + } + } + } + return a.markAutomationScriptDefaultsInitialized() +} diff --git a/backend/automation_script_export_api.go b/backend/automation_script_export_api.go new file mode 100644 index 00000000..47b7ee92 --- /dev/null +++ b/backend/automation_script_export_api.go @@ -0,0 +1,215 @@ +package backend + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/automation" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func (a *App) AutomationScriptExport(scriptID string) (map[string]any, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + + bundle, err := a.automationScriptStore().ExportBundle(strings.TrimSpace(scriptID)) + if err != nil { + return nil, err + } + + payload, err := automation.MarshalScriptTemplate(bundle) + if err != nil { + return nil, err + } + + savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{ + Title: "导出脚本模板", + DefaultFilename: buildAutomationScriptTemplateFilename(bundle.Record.Name), + Filters: []wailsruntime.FileFilter{ + {DisplayName: "JSON 模板 (*.json)", Pattern: "*.json"}, + }, + }) + if err != nil { + return nil, fmt.Errorf("打开保存对话框失败: %w", err) + } + if strings.TrimSpace(savePath) == "" { + return map[string]any{ + "cancelled": true, + "message": "已取消导出", + }, nil + } + + savePath = ensureAutomationScriptTemplateJSONSuffix(savePath) + if err := os.WriteFile(savePath, payload, 0o644); err != nil { + return nil, fmt.Errorf("写入脚本模板失败: %w", err) + } + + return map[string]any{ + "cancelled": false, + "format": "json", + "path": savePath, + "fileCount": len(bundle.Files), + "message": "模板已导出", + }, nil +} + +func (a *App) AutomationScriptExportZip(scriptID string) (map[string]any, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + + bundle, err := a.automationScriptStore().ExportBundle(strings.TrimSpace(scriptID)) + if err != nil { + return nil, err + } + + savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{ + Title: "导出脚本 ZIP", + DefaultFilename: buildAutomationScriptPackageZipFilename(bundle.Record.Name), + Filters: []wailsruntime.FileFilter{ + {DisplayName: "ZIP 脚本包 (*.zip)", Pattern: "*.zip"}, + }, + }) + if err != nil { + return nil, fmt.Errorf("打开保存对话框失败: %w", err) + } + if strings.TrimSpace(savePath) == "" { + return map[string]any{ + "cancelled": true, + "message": "已取消导出", + }, nil + } + + savePath = ensureAutomationScriptZipSuffix(savePath) + if err := automation.WriteScriptPackageZip(savePath, bundle); err != nil { + return nil, err + } + + return map[string]any{ + "cancelled": false, + "format": "zip", + "path": savePath, + "fileCount": len(bundle.Files), + "message": "脚本包已导出", + }, nil +} + +func (a *App) AutomationScriptExportDirectory(scriptID string) (map[string]any, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + + bundle, err := a.automationScriptStore().ExportBundle(strings.TrimSpace(scriptID)) + if err != nil { + return nil, err + } + + baseDir, err := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "选择导出目录", + }) + if err != nil { + return nil, fmt.Errorf("打开目录对话框失败: %w", err) + } + if strings.TrimSpace(baseDir) == "" { + return map[string]any{ + "cancelled": true, + "message": "已取消导出", + }, nil + } + + targetDir := filepath.Join( + strings.TrimSpace(baseDir), + buildAutomationScriptPackageDirectoryName(bundle.Record.Name), + ) + if err := automation.WriteScriptPackageDirectory(targetDir, bundle); err != nil { + return nil, err + } + + return map[string]any{ + "cancelled": false, + "format": "directory", + "path": targetDir, + "fileCount": len(bundle.Files), + "message": "脚本目录已导出", + }, nil +} + +func buildAutomationScriptTemplateFilename(scriptName string) string { + name := sanitizeAutomationScriptTemplateFilename(strings.TrimSpace(scriptName)) + if name == "" { + name = "automation-script" + } + return fmt.Sprintf("%s-template-%s.json", name, time.Now().Format("20060102-150405")) +} + +func ensureAutomationScriptTemplateJSONSuffix(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return trimmed + } + if strings.HasSuffix(strings.ToLower(trimmed), ".json") { + return trimmed + } + return trimmed + ".json" +} + +func buildAutomationScriptPackageZipFilename(scriptName string) string { + name := sanitizeAutomationScriptTemplateFilename(strings.TrimSpace(scriptName)) + if name == "" { + name = "automation-script" + } + return fmt.Sprintf("%s-package-%s.zip", name, time.Now().Format("20060102-150405")) +} + +func ensureAutomationScriptZipSuffix(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return trimmed + } + if strings.HasSuffix(strings.ToLower(trimmed), ".zip") { + return trimmed + } + return trimmed + ".zip" +} + +func buildAutomationScriptPackageDirectoryName(scriptName string) string { + name := sanitizeAutomationScriptTemplateFilename(strings.TrimSpace(scriptName)) + if name == "" { + name = "automation-script" + } + return fmt.Sprintf("%s-package-%s", name, time.Now().Format("20060102-150405")) +} + +func sanitizeAutomationScriptTemplateFilename(value string) string { + replacer := strings.NewReplacer( + "\\", "-", + "/", "-", + ":", "-", + "*", "-", + "?", "-", + "\"", "-", + "<", "-", + ">", "-", + "|", "-", + ) + cleaned := strings.TrimSpace(replacer.Replace(value)) + cleaned = strings.Trim(cleaned, ". ") + if cleaned == "" { + return "" + } + return cleaned +} diff --git a/backend/automation_script_import_entry.go b/backend/automation_script_import_entry.go new file mode 100644 index 00000000..3de166ce --- /dev/null +++ b/backend/automation_script_import_entry.go @@ -0,0 +1,126 @@ +package backend + +import ( + "fmt" + "strings" + + "ant-chrome/backend/internal/automation" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func (a *App) AutomationScriptImportText(text string) (*automation.ScriptRecord, error) { + bundle, err := automation.ImportBundleFromBytesWithOptions("automation-template.json", []byte(strings.TrimSpace(text)), "文本导入", a.automationScriptImportOptions()) + if err != nil { + return nil, err + } + return a.saveImportedAutomationBundle(bundle) +} + +func (a *App) AutomationScriptImportLocalFile() (*automation.ScriptRecord, error) { + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + + path, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "选择脚本文件", + Filters: []wailsruntime.FileFilter{ + {DisplayName: "脚本文件 (*.zip;*.json;*.js;*.cjs;*.mjs;*.ts;*.cts;*.mts)", Pattern: "*.zip;*.json;*.js;*.cjs;*.mjs;*.ts;*.cts;*.mts"}, + {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, + }, + }) + if err != nil { + return nil, fmt.Errorf("打开文件对话框失败: %w", err) + } + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("未选择脚本文件") + } + + bundle, err := automation.ImportBundleFromFileWithOptions(path, "本地文件 "+path, a.automationScriptImportOptions()) + if err != nil { + return nil, err + } + return a.saveImportedAutomationBundle(bundle) +} + +func (a *App) AutomationScriptImportLocalDirectory() (*automation.ScriptRecord, error) { + if a.ctx == nil { + return nil, fmt.Errorf("应用上下文未初始化") + } + + path, err := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "选择脚本目录", + }) + if err != nil { + return nil, fmt.Errorf("打开目录对话框失败: %w", err) + } + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("未选择脚本目录") + } + + bundle, err := automation.ImportBundleFromDirectoryWithOptions(path, "", "本地目录 "+path, a.automationScriptImportOptions()) + if err != nil { + return nil, err + } + return a.saveImportedAutomationBundle(bundle) +} + +func (a *App) AutomationScriptImportRemote(rawURL string) (*automation.ScriptRecord, error) { + bundle, err := a.loadAutomationRemoteBundle(strings.TrimSpace(rawURL)) + if err != nil { + return nil, err + } + return a.saveImportedAutomationBundle(bundle) +} + +func (a *App) AutomationScriptImportGit(repoURL string, ref string, scriptPath string) (*automation.ScriptRecord, error) { + bundle, err := a.loadAutomationGitBundle(strings.TrimSpace(repoURL), strings.TrimSpace(ref), strings.TrimSpace(scriptPath)) + if err != nil { + return nil, err + } + return a.saveImportedAutomationBundle(bundle) +} + +func (a *App) AutomationScriptRefresh(scriptID string) (*automation.ScriptRecord, error) { + normalizedID := strings.TrimSpace(scriptID) + if normalizedID == "" { + return nil, fmt.Errorf("脚本 ID 不能为空") + } + + existing, err := a.automationScriptStore().Get(normalizedID) + if err != nil { + return nil, fmt.Errorf("读取脚本失败: %w", err) + } + + bundle, err := a.loadAutomationBundleFromSource(existing.Source) + if err != nil { + return nil, err + } + + bundle.Record.ID = existing.ID + bundle.Record.CreatedAt = existing.CreatedAt + bundle.Record.Status = existing.Status + + record, err := a.automationScriptStore().ImportBundle(bundle) + if err != nil { + return nil, err + } + return &record, nil +} + +func (a *App) saveImportedAutomationBundle(bundle automation.ImportedBundle) (*automation.ScriptRecord, error) { + record, err := a.automationScriptStore().ImportBundle(bundle) + if err != nil { + return nil, err + } + return &record, nil +} + +func (a *App) automationScriptImportOptions() automation.ImportOptions { + if a.config == nil { + return automation.ImportOptions{} + } + return automation.ImportOptions{ + AllowTypeScriptBuild: a.config.Automation.AllowTypeScriptBuild, + } +} diff --git a/backend/automation_script_import_git.go b/backend/automation_script_import_git.go new file mode 100644 index 00000000..21a4fd54 --- /dev/null +++ b/backend/automation_script_import_git.go @@ -0,0 +1,95 @@ +package backend + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "ant-chrome/backend/internal/automation" +) + +func cloneAutomationGitRepository(repoURL string, ref string) (string, func(), error) { + if _, err := exec.LookPath("git"); err != nil { + return "", nil, fmt.Errorf("未找到 git,可先安装 git 后再导入仓库脚本") + } + + tempDir, err := os.MkdirTemp("", "ant-automation-git-*") + if err != nil { + return "", nil, fmt.Errorf("创建 Git 临时目录失败: %w", err) + } + + cleanup := func() { + _ = os.RemoveAll(tempDir) + } + + if strings.TrimSpace(ref) == "" { + if err := runGitCommand("", "clone", "--depth", "1", repoURL, tempDir); err != nil { + cleanup() + return "", nil, err + } + return tempDir, cleanup, nil + } + + if err := runGitCommand("", "clone", "--depth", "1", "--branch", ref, "--single-branch", repoURL, tempDir); err == nil { + return tempDir, cleanup, nil + } + + _ = os.RemoveAll(tempDir) + tempDir, err = os.MkdirTemp("", "ant-automation-git-*") + if err != nil { + return "", nil, fmt.Errorf("创建 Git 临时目录失败: %w", err) + } + cleanup = func() { + _ = os.RemoveAll(tempDir) + } + + if err := runGitCommand("", "clone", repoURL, tempDir); err != nil { + cleanup() + return "", nil, err + } + if err := runGitCommand(tempDir, "checkout", ref); err != nil { + cleanup() + return "", nil, fmt.Errorf("切换 Git 引用失败: %w", err) + } + return tempDir, cleanup, nil +} + +func runGitCommand(workdir string, args ...string) error { + cmd := exec.Command("git", args...) + if strings.TrimSpace(workdir) != "" { + cmd.Dir = workdir + } + + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("git %s 失败: %s", strings.Join(args, " "), message) + } + return nil +} + +func (a *App) loadAutomationGitBundle(repoURL string, ref string, scriptPath string) (automation.ImportedBundle, error) { + normalizedRepoURL := strings.TrimSpace(repoURL) + if normalizedRepoURL == "" { + return automation.ImportedBundle{}, fmt.Errorf("Git 仓库地址不能为空") + } + + normalizedRef := strings.TrimSpace(ref) + normalizedScriptPath := strings.TrimSpace(scriptPath) + + repoDir, cleanup, err := cloneAutomationGitRepository(normalizedRepoURL, normalizedRef) + if err != nil { + return automation.ImportedBundle{}, err + } + defer cleanup() + + bundle, err := automation.ImportBundleFromDirectoryWithOptions(repoDir, normalizedScriptPath, buildAutomationGitImportLabel(normalizedRepoURL, normalizedRef, normalizedScriptPath), a.automationScriptImportOptions()) + if err != nil { + return automation.ImportedBundle{}, err + } + return bundle, nil +} diff --git a/backend/automation_script_import_labels.go b/backend/automation_script_import_labels.go new file mode 100644 index 00000000..46bd2c69 --- /dev/null +++ b/backend/automation_script_import_labels.go @@ -0,0 +1,42 @@ +package backend + +import ( + "strings" + + "ant-chrome/backend/internal/automation" +) + +func buildAutomationImportSourceLabel(source automation.ScriptSource) string { + switch strings.TrimSpace(source.Type) { + case "local-file": + return "本地文件 " + firstNonBlank(source.URI, source.Path) + case "local-dir": + return "本地目录 " + firstNonBlank(source.URI, source.Path) + case "remote-url": + return "远程地址 " + strings.TrimSpace(source.URI) + case "git": + return buildAutomationGitImportLabel(source.URI, source.Ref, source.Path) + default: + return firstNonBlank(source.URI, source.Path) + } +} + +func buildAutomationGitImportLabel(repoURL string, ref string, scriptPath string) string { + label := "Git " + strings.TrimSpace(repoURL) + if strings.TrimSpace(ref) != "" { + label += " @ " + strings.TrimSpace(ref) + } + if strings.TrimSpace(scriptPath) != "" { + label += " : " + strings.TrimSpace(scriptPath) + } + return label +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/backend/automation_script_import_source.go b/backend/automation_script_import_source.go new file mode 100644 index 00000000..e66d9710 --- /dev/null +++ b/backend/automation_script_import_source.go @@ -0,0 +1,90 @@ +package backend + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/automation" +) + +const ( + automationRemoteImportTimeout = 20 * time.Second + maxAutomationRemoteScriptSize = 16 << 20 +) + +func (a *App) loadAutomationBundleFromSource(source automation.ScriptSource) (automation.ImportedBundle, error) { + sourceType := strings.TrimSpace(source.Type) + switch sourceType { + case "local-file": + path := firstNonBlank(source.URI, source.Path) + if path == "" { + return automation.ImportedBundle{}, fmt.Errorf("本地脚本文件路径缺失") + } + return automation.ImportBundleFromFileWithOptions(path, buildAutomationImportSourceLabel(source), a.automationScriptImportOptions()) + case "local-dir": + path := firstNonBlank(source.URI, source.Path) + if path == "" { + return automation.ImportedBundle{}, fmt.Errorf("本地脚本目录路径缺失") + } + return automation.ImportBundleFromDirectoryWithOptions(path, "", buildAutomationImportSourceLabel(source), a.automationScriptImportOptions()) + case "remote-url": + return a.loadAutomationRemoteBundle(source.URI) + case "git": + return a.loadAutomationGitBundle(source.URI, source.Ref, source.Path) + case "manual", "text", "": + return automation.ImportedBundle{}, fmt.Errorf("当前脚本来源不支持重新导入") + default: + return automation.ImportedBundle{}, fmt.Errorf("当前脚本来源 %q 不支持重新导入", sourceType) + } +} + +func (a *App) loadAutomationRemoteBundle(rawURL string) (automation.ImportedBundle, error) { + normalizedURL := strings.TrimSpace(rawURL) + if normalizedURL == "" { + return automation.ImportedBundle{}, fmt.Errorf("远程脚本地址不能为空") + } + + parsedURL, err := url.Parse(normalizedURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return automation.ImportedBundle{}, fmt.Errorf("远程脚本地址不合法") + } + + ctx, cancel := context.WithTimeout(context.Background(), automationRemoteImportTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizedURL, nil) + if err != nil { + return automation.ImportedBundle{}, fmt.Errorf("创建远程脚本请求失败: %w", err) + } + + resp, err := (&http.Client{Timeout: automationRemoteImportTimeout}).Do(req) + if err != nil { + return automation.ImportedBundle{}, fmt.Errorf("下载远程脚本失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return automation.ImportedBundle{}, fmt.Errorf("下载远程脚本失败: HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxAutomationRemoteScriptSize+1)) + if err != nil { + return automation.ImportedBundle{}, fmt.Errorf("读取远程脚本失败: %w", err) + } + if len(data) > maxAutomationRemoteScriptSize { + return automation.ImportedBundle{}, fmt.Errorf("远程脚本文件过大") + } + + nameHint := filepath.Base(parsedURL.Path) + if nameHint == "" || nameHint == "." || nameHint == "/" { + nameHint = "remote-script.cjs" + } + + return automation.ImportBundleFromBytesWithOptions(nameHint, data, "远程地址 "+normalizedURL, a.automationScriptImportOptions()) +} diff --git a/backend/automation_script_run_entry.go b/backend/automation_script_run_entry.go new file mode 100644 index 00000000..0345c402 --- /dev/null +++ b/backend/automation_script_run_entry.go @@ -0,0 +1,79 @@ +package backend + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/automation" +) + +func (a *App) automationScriptRunStore() *automation.ScriptRunStore { + return automation.NewScriptRunStore(a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "runs")))) +} + +func (a *App) AutomationScriptRunList(limit int) ([]automation.ScriptRunRecord, error) { + return a.automationScriptRunStore().List(limit) +} + +func (a *App) AutomationScriptRun(scriptID string) (*automation.ScriptRunRecord, error) { + return a.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: scriptID, + UseScriptSelector: true, + UseScriptParams: true, + }) +} + +func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) (*automation.ScriptRunRecord, error) { + startedAt := time.Now() + run := automation.ScriptRunRecord{ + ScriptID: strings.TrimSpace(input.ScriptID), + Status: "failed", + StartedAt: startedAt.Format(time.RFC3339), + } + + script, err := a.automationScriptStore().Get(run.ScriptID) + if err != nil { + run.Summary = "脚本读取失败" + run.Error = err.Error() + return a.finalizeAutomationScriptRun(run, startedAt) + } + + run.ScriptName = script.Name + run.ScriptType = script.Type + + switch script.Type { + case "launch-api": + resultText, summary, errText := a.runLaunchAPIScript(script, input) + run.ResultText = resultText + run.Summary = summary + run.Error = errText + if errText == "" { + run.Status = "success" + } + case "playwright-cdp": + resultText, summary, errText := a.runPlaywrightScript(script, input) + run.ResultText = resultText + run.Summary = summary + run.Error = errText + if errText == "" { + run.Status = "success" + } + default: + run.Summary = "当前脚本类型暂不支持直接执行" + run.Error = fmt.Sprintf("script type %q is not supported yet", script.Type) + } + + return a.finalizeAutomationScriptRun(run, startedAt) +} + +func (a *App) finalizeAutomationScriptRun(run automation.ScriptRunRecord, startedAt time.Time) (*automation.ScriptRunRecord, error) { + run.FinishedAt = time.Now().Format(time.RFC3339) + run.DurationMs = time.Since(startedAt).Milliseconds() + saved, err := a.automationScriptRunStore().Save(run) + if err != nil { + return nil, err + } + return &saved, nil +} diff --git a/backend/automation_script_run_helpers.go b/backend/automation_script_run_helpers.go new file mode 100644 index 00000000..e00e2334 --- /dev/null +++ b/backend/automation_script_run_helpers.go @@ -0,0 +1,41 @@ +package backend + +import ( + "encoding/json" + "fmt" + "strings" +) + +func resolveAutomationRunJSONText(value string, fallback string, useFallback bool) string { + if useFallback { + return strings.TrimSpace(fallback) + } + return strings.TrimSpace(value) +} + +func parseAutomationJSONObject(text string, required bool) (map[string]any, error) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + if required { + return nil, fmt.Errorf("selector is required") + } + return map[string]any{}, nil + } + + var decoded map[string]any + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return nil, fmt.Errorf("invalid json object: %w", err) + } + return decoded, nil +} + +func marshalAutomationResultText(payload map[string]any) string { + if payload == nil { + return "" + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "" + } + return string(data) +} diff --git a/backend/automation_script_run_integration_test.go b/backend/automation_script_run_integration_test.go new file mode 100644 index 00000000..451125c0 --- /dev/null +++ b/backend/automation_script_run_integration_test.go @@ -0,0 +1,347 @@ +package backend + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" +) + +func TestAutomationScriptRunWithOptionsExecutesPlaywrightScript(t *testing.T) { + nodeExecPath := lookupAutomationTestNode(t) + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.Enabled = true + app.config.Automation.NodeSource = config.AutomationNodeSourceSystem + app.config.Automation.SystemNodePath = nodeExecPath + app.config.Automation.NodeVersion = "test-node" + app.config.Automation.PlaywrightCoreVersion = "1.59.0" + app.config.Automation.RuntimeVersion = "test-runtime" + app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) + + prepareAutomationTestRuntime(t, app.automationMgr, app.config.Automation.PlaywrightCoreVersion) + + app.launchServer = launchcode.NewLaunchServer( + launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()), + nil, + nil, + 0, + ) + if err := app.launchServer.Start(); err != nil { + t.Fatalf("start launch server failed: %v", err) + } + defer func() { + _ = app.launchServer.Stop() + }() + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "playwright-success", + Name: "Playwright 成功脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "const fs = require('fs')\nmodule.exports.run = async ({ params, artifact }) => {\n const outputPath = artifact('result.txt')\n fs.writeFileSync(outputPath, String(params.message || 'default'), 'utf8')\n return { ok: true, summary: 'artifact ready', outputPath }\n}\n", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: saved.ID, + SelectorText: `{}`, + ParamsText: `{"message":"hello integration"}`, + UseScriptSelector: false, + UseScriptParams: false, + }) + if err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRunWithOptions returned nil result") + } + if run.Status != "success" { + t.Fatalf("expected success status, got %+v", run) + } + if run.Summary != "artifact ready" { + t.Fatalf("unexpected run summary: %q", run.Summary) + } + + var payload struct { + OK bool `json:"ok"` + Summary string `json:"summary"` + Artifacts []string `json:"artifacts"` + Result struct { + OutputPath string `json:"outputPath"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(run.ResultText), &payload); err != nil { + t.Fatalf("unmarshal run result failed: %v; result=%s", err, run.ResultText) + } + if !payload.OK { + t.Fatalf("expected payload ok=true, got %+v", payload) + } + if payload.Result.OutputPath == "" { + t.Fatalf("expected outputPath in payload, got %+v result=%s", payload, run.ResultText) + } + if len(payload.Artifacts) != 1 || payload.Artifacts[0] != payload.Result.OutputPath { + t.Fatalf("expected artifacts to contain output path, got %+v", payload) + } + + data, err := os.ReadFile(payload.Result.OutputPath) + if err != nil { + t.Fatalf("read output artifact failed: %v", err) + } + if string(data) != "hello integration" { + t.Fatalf("unexpected artifact content: %q", string(data)) + } +} + +func TestAutomationScriptRunWithOptionsPrestartsStoredTargetForConnectOnlyScript(t *testing.T) { + nodeExecPath := lookupAutomationTestNode(t) + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.Enabled = true + app.config.Automation.NodeSource = config.AutomationNodeSourceSystem + app.config.Automation.SystemNodePath = nodeExecPath + app.config.Automation.NodeVersion = "test-node" + app.config.Automation.PlaywrightCoreVersion = "1.59.0" + app.config.Automation.RuntimeVersion = "test-runtime" + app.browserMgr = browser.NewManager(app.config, app.appRoot) + app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + app.browserMgr.CodeProvider = app.launchCodeSvc + app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) + + prepareAutomationTestRuntimeWithPlaywrightModule( + t, + app.automationMgr, + app.config.Automation.PlaywrightCoreVersion, + automationTestConnectProbePlaywrightModule, + ) + + var debugHits atomic.Int32 + debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + debugHits.Add(1) + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "Browser": "Chrome/123.0.0.0", + }) + })) + defer debugServer.Close() + + debugURL, err := url.Parse(debugServer.URL) + if err != nil { + t.Fatalf("parse debug server url failed: %v", err) + } + debugPort, err := strconv.Atoi(debugURL.Port()) + if err != nil { + t.Fatalf("parse debug server port failed: %v", err) + } + + profile, err := app.browserMgr.Create(browser.ProfileInput{ + ProfileName: "buyer-connect-only", + }) + if err != nil { + t.Fatalf("create profile failed: %v", err) + } + if profile == nil { + t.Fatal("create profile returned nil") + } + app.browserMgr.Profiles[profile.ProfileId].Running = true + app.browserMgr.Profiles[profile.ProfileId].DebugReady = true + app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort + app.browserMgr.Profiles[profile.ProfileId].Pid = 12345 + + app.launchServer = launchcode.NewLaunchServer( + app.launchCodeSvc, + app, + app.browserMgr, + 0, + ) + if err := app.launchServer.Start(); err != nil { + t.Fatalf("start launch server failed: %v", err) + } + defer func() { + _ = app.launchServer.Stop() + }() + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "playwright-connect-stored-target", + Name: "Playwright Connect Stored Target", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "module.exports.run = async ({ connect }) => {\n" + + " const { browser } = await connect()\n" + + " return { ok: true, summary: 'connected through stored target', contextCount: browser.contexts().length }\n" + + "}\n", + TargetConfig: automation.ScriptTargetConfig{ + Mode: "existing", + Selector: automation.ScriptTargetSelector{ + ProfileID: profile.ProfileId, + }, + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: saved.ID, + UseScriptSelector: true, + UseScriptParams: true, + }) + if err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRunWithOptions returned nil result") + } + if run.Status != "success" { + t.Fatalf("expected success status, got %+v", run) + } + if !strings.Contains(run.Summary, "connected through stored target") { + t.Fatalf("unexpected run summary: %q", run.Summary) + } + if !strings.Contains(run.ResultText, `"contextCount":1`) { + t.Fatalf("expected connect result payload, got %s", run.ResultText) + } + if debugHits.Load() == 0 { + t.Fatalf("expected connect() to hit active debug endpoint through launch server") + } +} + +func lookupAutomationTestNode(t *testing.T) string { + t.Helper() + + nodeExecPath, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + return nodeExecPath +} + +func prepareAutomationTestRuntime(t *testing.T, manager *automation.Manager, playwrightVersion string) { + t.Helper() + + prepareAutomationTestRuntimeWithPlaywrightModule( + t, + manager, + playwrightVersion, + "module.exports = { chromium: {} }\n", + ) +} + +func prepareAutomationTestRuntimeWithPlaywrightModule(t *testing.T, manager *automation.Manager, playwrightVersion string, playwrightModuleSource string) { + t.Helper() + + state := manager.CurrentState() + + playwrightCoreDir := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(playwrightCoreDir, 0o755); err != nil { + t.Fatalf("create playwright-core dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(playwrightCoreDir, "package.json"), []byte("{\"name\":\"playwright-core\",\"version\":\""+playwrightVersion+"\"}\n"), 0o644); err != nil { + t.Fatalf("write playwright-core package failed: %v", err) + } + if err := os.WriteFile(filepath.Join(playwrightCoreDir, "index.js"), []byte(playwrightModuleSource), 0o644); err != nil { + t.Fatalf("write playwright-core stub failed: %v", err) + } + if err := os.WriteFile(state.RunnerPath, []byte(automationTestRunnerScript), 0o755); err != nil { + t.Fatalf("write runner script failed: %v", err) + } +} + +const automationTestConnectProbePlaywrightModule = `const http = require('http') + +module.exports = { + chromium: { + connectOverCDP: async (endpoint) => { + const target = new URL('/json/version', endpoint) + await new Promise((resolve, reject) => { + const req = http.get(target, (res) => { + res.resume() + res.on('end', () => { + const status = res.statusCode || 0 + if (status >= 200 && status < 300) { + resolve() + return + } + reject(new Error('cdp connect probe failed with http ' + String(status))) + }) + }) + req.on('error', reject) + }) + + return { + contexts: () => [{ + pages: () => [], + newPage: async () => ({}) + }], + close: async () => {} + } + } + } +} +` + +const automationTestRunnerScript = `const fs = require('fs') +const path = require('path') + +async function main() { + const payloadPath = process.argv[2] + const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8')) + const script = require(payload.ScriptPath) + const startedAt = new Date().toISOString() + const result = await script.run({ + selector: payload.Selector || {}, + params: payload.Params || {}, + artifact: (name) => { + const dir = payload.ArtifactDir || path.dirname(payload.ScriptPath) + fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, name) + }, + log: () => {}, + launch: async () => ({ ok: true }), + connect: async () => ({ + browser: { contexts: () => [] }, + context: { + pages: () => [], + newPage: async () => ({}) + }, + page: null + }) + }) + + console.log(JSON.stringify({ + ok: result && result.ok !== false, + summary: result && result.summary ? String(result.summary) : '', + error: result && result.error ? String(result.error) : '', + startedAt, + finishedAt: new Date().toISOString(), + ...result + })) +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) +}) +` diff --git a/backend/automation_script_run_launch_api.go b/backend/automation_script_run_launch_api.go new file mode 100644 index 00000000..f55b90f5 --- /dev/null +++ b/backend/automation_script_run_launch_api.go @@ -0,0 +1,307 @@ +package backend + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "ant-chrome/backend/internal/automation" +) + +const dualInstanceRuntimeDefaultTimeoutMs = 45000 + +type dualInstanceRuntimeParams struct { + Browsers []dualInstanceRuntimeBrowserInput `json:"browsers"` + TimeoutMs int `json:"timeoutMs"` + SkipDefaultStartURLs *bool `json:"skipDefaultStartUrls"` + PrimaryCode string `json:"primaryCode"` + SecondaryCode string `json:"secondaryCode"` +} + +type dualInstanceRuntimeBrowserInput struct { + Code string `json:"code"` + LaunchCode string `json:"launchCode"` + SkipDefaultStartURLs *bool `json:"skipDefaultStartUrls"` + StartURLs []string `json:"startUrls"` + LaunchArgs []string `json:"launchArgs"` +} + +type dualInstanceRuntimeBrowser struct { + Code string + SkipDefaultStartURLs bool + StartURLs []string + LaunchArgs []string +} + +func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { + paramsText := resolveAutomationRunJSONText(input.ParamsText, script.ParamsText, input.UseScriptParams) + if script.ID == automation.DualInstanceRuntimeScriptID { + return a.runDualInstanceRuntimeLaunchAPIScript(paramsText) + } + + selector, targetSummary, err := a.resolveAutomationEffectiveSelector(script, input, true) + if err != nil { + return "", "脚本执行失败", err.Error() + } + params, err := parseAutomationJSONObject(paramsText, false) + if err != nil { + return "", "脚本执行失败", err.Error() + } + + body := make(map[string]any, len(params)+1) + body["selector"] = selector + for key, value := range params { + body[key] = value + } + + status, payload, reqErr := a.automationDemoRequest(http.MethodPost, automationDemoLaunchPath, body) + if reqErr != nil { + return "", "Launch API 请求失败", reqErr.Error() + } + + responseText := marshalAutomationResultText(payload) + ok := status >= http.StatusOK && status < http.StatusMultipleChoices + if rawOK, exists := payload["ok"]; exists { + if payloadOK, valid := rawOK.(bool); valid { + ok = ok && payloadOK + } + } + + summary := appendAutomationRunSummary(fmt.Sprintf("Launch API 响应 HTTP %d", status), targetSummary) + if ok { + return responseText, summary, "" + } + + errorText := mapStringValue(payload, "error") + if errorText == "" { + errorText = fmt.Sprintf("launch api returned http %d", status) + } + return responseText, summary, errorText +} + +func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, string, string) { + browsers, timeoutMs, err := parseDualInstanceRuntimeParams(paramsText) + if err != nil { + return "", "脚本执行失败", err.Error() + } + + sessions := make([]map[string]interface{}, 0, len(browsers)) + browserCodes := make([]string, 0, len(browsers)) + + for _, browser := range browsers { + sessionStatus, sessionPayload, reqErr := a.automationDemoRequest( + http.MethodPost, + automationDemoRuntimeSessionPath, + map[string]any{ + "selector": map[string]any{ + "code": browser.Code, + "matchMode": "unique", + }, + "skipDefaultStartUrls": browser.SkipDefaultStartURLs, + "startUrls": browser.StartURLs, + "launchArgs": browser.LaunchArgs, + "timeoutMs": timeoutMs, + }, + ) + sessionPayload = ensureAutomationPayload(sessionPayload, browser.Code) + sessions = append(sessions, sessionPayload) + if reqErr != nil { + return buildDualInstanceRuntimeFailureResult( + sessions, + browserCodes, + fmt.Sprintf("准备 %s Runtime 失败", browser.Code), + reqErr.Error(), + ) + } + if !isAutomationDemoRequestOK(sessionStatus, sessionPayload) { + errText := mapStringValue(sessionPayload, "error") + if errText == "" { + errText = fmt.Sprintf("runtime session api returned http %d", sessionStatus) + } + return buildDualInstanceRuntimeFailureResult( + sessions, + browserCodes, + fmt.Sprintf("准备 %s Runtime 失败", browser.Code), + errText, + ) + } + + browserCodes = append(browserCodes, browser.Code) + } + + summary := fmt.Sprintf( + "%d 个浏览器已就绪:%s", + len(browserCodes), + strings.Join(browserCodes, " / "), + ) + result := map[string]any{ + "ok": true, + "summary": summary, + "browserCodes": browserCodes, + "sessions": sessions, + } + return marshalAutomationResultText(result), summary, "" +} + +func parseDualInstanceRuntimeParams(paramsText string) ([]dualInstanceRuntimeBrowser, int, error) { + normalizedText := strings.TrimSpace(paramsText) + if normalizedText == "" { + normalizedText = "{}" + } + + var payload dualInstanceRuntimeParams + if err := json.Unmarshal([]byte(normalizedText), &payload); err != nil { + return nil, 0, fmt.Errorf("invalid json object: %w", err) + } + + defaultSkipDefaultStartURLs := true + if payload.SkipDefaultStartURLs != nil { + defaultSkipDefaultStartURLs = *payload.SkipDefaultStartURLs + } + + browsers := make([]dualInstanceRuntimeBrowser, 0, len(payload.Browsers)) + for index, item := range payload.Browsers { + code := normalizeDualInstanceRuntimeCode(item.Code) + if code == "" { + code = normalizeDualInstanceRuntimeCode(item.LaunchCode) + } + if code == "" && index < 2 { + code = dualInstanceRuntimeDefaultCode(index) + } + if code == "" { + continue + } + + skipDefaultStartURLs := defaultSkipDefaultStartURLs + if item.SkipDefaultStartURLs != nil { + skipDefaultStartURLs = *item.SkipDefaultStartURLs + } + startURLs := normalizeDualInstanceRuntimeStrings(item.StartURLs) + if len(startURLs) == 0 { + startURLs = dualInstanceRuntimeDefaultStartURLs(index) + } + + browsers = append(browsers, dualInstanceRuntimeBrowser{ + Code: code, + SkipDefaultStartURLs: skipDefaultStartURLs, + StartURLs: startURLs, + LaunchArgs: normalizeDualInstanceRuntimeStrings(item.LaunchArgs), + }) + } + + if len(browsers) == 0 { + for index, code := range []string{ + normalizeDualInstanceRuntimeCode(payload.PrimaryCode), + normalizeDualInstanceRuntimeCode(payload.SecondaryCode), + } { + if code == "" { + continue + } + browsers = append(browsers, dualInstanceRuntimeBrowser{ + Code: code, + SkipDefaultStartURLs: defaultSkipDefaultStartURLs, + StartURLs: dualInstanceRuntimeDefaultStartURLs(index), + }) + } + } + + if len(browsers) == 0 { + browsers = append(browsers, + dualInstanceRuntimeBrowser{ + Code: dualInstanceRuntimeDefaultCode(0), + SkipDefaultStartURLs: defaultSkipDefaultStartURLs, + StartURLs: dualInstanceRuntimeDefaultStartURLs(0), + }, + dualInstanceRuntimeBrowser{ + Code: dualInstanceRuntimeDefaultCode(1), + SkipDefaultStartURLs: defaultSkipDefaultStartURLs, + StartURLs: dualInstanceRuntimeDefaultStartURLs(1), + }, + ) + } + + timeoutMs := dualInstanceRuntimeDefaultTimeoutMs + if payload.TimeoutMs > 0 { + timeoutMs = payload.TimeoutMs + if timeoutMs < 1000 { + timeoutMs = 1000 + } + } + + return browsers, timeoutMs, nil +} + +func normalizeDualInstanceRuntimeCode(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizeDualInstanceRuntimeStrings(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized != "" { + result = append(result, normalized) + } + } + return result +} + +func dualInstanceRuntimeDefaultCode(index int) string { + switch index { + case 0: + return "BUYER_001" + case 1: + return "BUYER_002" + default: + return "" + } +} + +func dualInstanceRuntimeDefaultStartURLs(index int) []string { + switch index { + case 0: + return []string{"https://finance.sina.com.cn/"} + case 1: + return []string{"https://map.baidu.com/"} + default: + return nil + } +} + +func ensureAutomationPayload(payload map[string]interface{}, requestedCode string) map[string]interface{} { + if payload == nil { + payload = map[string]interface{}{} + } + if strings.TrimSpace(requestedCode) != "" { + payload["requestedCode"] = strings.ToUpper(strings.TrimSpace(requestedCode)) + } + return payload +} + +func isAutomationDemoRequestOK(status int, payload map[string]interface{}) bool { + ok := status >= http.StatusOK && status < http.StatusMultipleChoices + if rawOK, exists := payload["ok"]; exists { + if payloadOK, valid := rawOK.(bool); valid { + ok = ok && payloadOK + } + } + return ok +} + +func buildDualInstanceRuntimeFailureResult( + sessions []map[string]interface{}, + browserCodes []string, + step string, + errorText string, +) (string, string, string) { + result := map[string]any{ + "ok": false, + "summary": "双实例流程执行失败", + "error": errorText, + "step": step, + "browserCodes": browserCodes, + "sessions": sessions, + } + return marshalAutomationResultText(result), "双实例流程执行失败", errorText +} diff --git a/backend/automation_script_run_launch_api_test.go b/backend/automation_script_run_launch_api_test.go new file mode 100644 index 00000000..28d8c741 --- /dev/null +++ b/backend/automation_script_run_launch_api_test.go @@ -0,0 +1,59 @@ +package backend + +import ( + "reflect" + "testing" +) + +func TestParseDualInstanceRuntimeParamsBackfillsDefaultStartURLs(t *testing.T) { + browsers, timeoutMs, err := parseDualInstanceRuntimeParams(`{"browsers":[{"code":"buyer_001"},{"code":"buyer_002"}]}`) + if err != nil { + t.Fatalf("parseDualInstanceRuntimeParams returned error: %v", err) + } + if timeoutMs != dualInstanceRuntimeDefaultTimeoutMs { + t.Fatalf("unexpected timeoutMs: got %d want %d", timeoutMs, dualInstanceRuntimeDefaultTimeoutMs) + } + if len(browsers) != 2 { + t.Fatalf("unexpected browser count: got %d want 2", len(browsers)) + } + + if !reflect.DeepEqual(browsers[0].StartURLs, []string{"https://finance.sina.com.cn/"}) { + t.Fatalf("unexpected browser[0] startUrls: %+v", browsers[0].StartURLs) + } + if !reflect.DeepEqual(browsers[1].StartURLs, []string{"https://map.baidu.com/"}) { + t.Fatalf("unexpected browser[1] startUrls: %+v", browsers[1].StartURLs) + } +} + +func TestParseDualInstanceRuntimeParamsKeepsProvidedStartURLs(t *testing.T) { + browsers, _, err := parseDualInstanceRuntimeParams(`{"browsers":[{"code":"buyer_001","startUrls":["https://example.com"]}]}`) + if err != nil { + t.Fatalf("parseDualInstanceRuntimeParams returned error: %v", err) + } + if len(browsers) != 1 { + t.Fatalf("unexpected browser count: got %d want 1", len(browsers)) + } + if !reflect.DeepEqual(browsers[0].StartURLs, []string{"https://example.com"}) { + t.Fatalf("unexpected startUrls: %+v", browsers[0].StartURLs) + } +} + +func TestParseDualInstanceRuntimeParamsUsesDefaultStartURLsForFallbackCodes(t *testing.T) { + browsers, _, err := parseDualInstanceRuntimeParams(`{}`) + if err != nil { + t.Fatalf("parseDualInstanceRuntimeParams returned error: %v", err) + } + if len(browsers) != 2 { + t.Fatalf("unexpected browser count: got %d want 2", len(browsers)) + } + + if browsers[0].Code != "BUYER_001" || browsers[1].Code != "BUYER_002" { + t.Fatalf("unexpected fallback codes: %+v", browsers) + } + if !reflect.DeepEqual(browsers[0].StartURLs, []string{"https://finance.sina.com.cn/"}) { + t.Fatalf("unexpected browser[0] startUrls: %+v", browsers[0].StartURLs) + } + if !reflect.DeepEqual(browsers[1].StartURLs, []string{"https://map.baidu.com/"}) { + t.Fatalf("unexpected browser[1] startUrls: %+v", browsers[1].StartURLs) + } +} diff --git a/backend/automation_script_run_playwright.go b/backend/automation_script_run_playwright.go new file mode 100644 index 00000000..591b4d28 --- /dev/null +++ b/backend/automation_script_run_playwright.go @@ -0,0 +1,93 @@ +package backend + +import ( + "fmt" + "strings" + + "ant-chrome/backend/internal/automation" +) + +func automationSelectorProfileID(selector map[string]any) string { + if selector == nil { + return "" + } + + profileID, _ := selector["profileId"].(string) + return strings.TrimSpace(profileID) +} + +func (a *App) ensurePlaywrightTargetReady(selector map[string]any) error { + profileID := automationSelectorProfileID(selector) + if profileID == "" { + return nil + } + + if _, err := a.BrowserInstanceStart(profileID); err != nil { + return fmt.Errorf("预启动脚本目标实例失败: %w", err) + } + return nil +} + +func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { + if a.automationMgr == nil { + return "", "脚本执行失败", "automation runtime manager is not initialized" + } + if a.config == nil || !a.config.Automation.Enabled { + return "", "脚本执行失败", "自动化支持尚未启用" + } + if err := a.automationMgr.EnsureInstalled(a.ctx); err != nil { + return "", "脚本执行失败", err.Error() + } + + state := a.automationMgr.CurrentState() + if !state.Ready { + return "", "脚本执行失败", "自动化运行时尚未就绪" + } + + paramsText := resolveAutomationRunJSONText(input.ParamsText, script.ParamsText, input.UseScriptParams) + + selector, targetSummary, err := a.resolveAutomationEffectiveSelector(script, input, false) + if err != nil { + return "", "脚本执行失败", err.Error() + } + if err := a.ensurePlaywrightTargetReady(selector); err != nil { + return "", "脚本执行失败", err.Error() + } + params, err := parseAutomationJSONObject(paramsText, false) + if err != nil { + return "", "脚本执行失败", err.Error() + } + + baseURL, authHeader, authValue, err := a.automationDemoEndpoint() + if err != nil { + return "", "脚本执行失败", err.Error() + } + + scriptPath, artifactDir, cleanup, err := a.preparePlaywrightScriptWorkspace(state.RuntimeDir, script) + if err != nil { + return "", "脚本执行失败", err.Error() + } + defer cleanup() + + taskResult, err := a.automationMgr.RunScriptTask(a.ctx, automation.ScriptTaskRequest{ + TaskKey: "script:" + script.ID, + ScriptPath: scriptPath, + Selector: selector, + Params: params, + LaunchBaseURL: baseURL, + LaunchAuthHeader: authHeader, + LaunchAuthValue: authValue, + ArtifactDir: artifactDir, + }) + if err != nil { + return "", "脚本执行失败", err.Error() + } + if !taskResult.OK { + errorText := strings.TrimSpace(taskResult.Error) + if errorText == "" { + errorText = "playwright script returned ok=false" + } + return taskResult.ResultText, appendAutomationRunSummary(taskResult.Summary, targetSummary), errorText + } + return taskResult.ResultText, appendAutomationRunSummary(taskResult.Summary, targetSummary), "" +} diff --git a/backend/automation_script_run_test.go b/backend/automation_script_run_test.go new file mode 100644 index 00000000..9147f78d --- /dev/null +++ b/backend/automation_script_run_test.go @@ -0,0 +1,119 @@ +package backend + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "ant-chrome/backend/internal/automation" +) + +func TestPreparePlaywrightScriptWorkspaceCopiesScriptDirectory(t *testing.T) { + app := NewApp(t.TempDir()) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "workspace-script", + Name: "工作区脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true, source: 'workspace' })", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + scriptDir, err := app.automationScriptStore().Dir(saved.ID) + if err != nil { + t.Fatalf("Dir returned error: %v", err) + } + + extraHelperPath := filepath.Join(scriptDir, "scripts", "helpers", "format.cjs") + if err := os.MkdirAll(filepath.Dir(extraHelperPath), 0o755); err != nil { + t.Fatalf("create helper dir failed: %v", err) + } + if err := os.WriteFile(extraHelperPath, []byte("module.exports.format = () => 'helper-ready'"), 0o644); err != nil { + t.Fatalf("write helper file failed: %v", err) + } + + assetPath := filepath.Join(scriptDir, "assets", "seed.txt") + if err := os.MkdirAll(filepath.Dir(assetPath), 0o755); err != nil { + t.Fatalf("create asset dir failed: %v", err) + } + if err := os.WriteFile(assetPath, []byte("seed-ready"), 0o644); err != nil { + t.Fatalf("write asset file failed: %v", err) + } + + runtimeDir := filepath.Join(t.TempDir(), "runtime") + scriptPath, artifactDir, cleanup, err := app.preparePlaywrightScriptWorkspace(runtimeDir, *saved) + if err != nil { + t.Fatalf("preparePlaywrightScriptWorkspace returned error: %v", err) + } + defer cleanup() + + execRoot := workspaceRootFromScriptPath(t, scriptPath, saved.EntryFile) + + assertFileContent(t, scriptPath, saved.ScriptText) + assertFileContent(t, filepath.Join(execRoot, "config"), `"id": "workspace-script"`) + assertFileContent(t, filepath.Join(execRoot, "scripts", "helpers", "format.cjs"), "helper-ready") + assertFileContent(t, filepath.Join(execRoot, "assets", "seed.txt"), "seed-ready") + assertFileContent(t, filepath.Join(execRoot, "node_modules", "playwright", "index.js"), "playwright-core") + assertFileContent(t, filepath.Join(execRoot, "node_modules", "playwright-core", "package.json"), `"name":"playwright-core"`) + + if info, err := os.Stat(artifactDir); err != nil || !info.IsDir() { + t.Fatalf("expected artifact dir to exist, got err=%v info=%v", err, info) + } +} + +func TestPreparePlaywrightScriptWorkspaceFallsBackWhenScriptDirMissing(t *testing.T) { + app := NewApp(t.TempDir()) + + script := automation.ScriptRecord{ + ID: "orphan-script", + Name: "孤立脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "nested/index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true, source: 'orphan' })", + } + + runtimeDir := filepath.Join(t.TempDir(), "runtime") + scriptPath, _, cleanup, err := app.preparePlaywrightScriptWorkspace(runtimeDir, script) + if err != nil { + t.Fatalf("preparePlaywrightScriptWorkspace returned error: %v", err) + } + + execRoot := workspaceRootFromScriptPath(t, scriptPath, script.EntryFile) + assertFileContent(t, scriptPath, script.ScriptText) + assertFileContent(t, filepath.Join(execRoot, "node_modules", "playwright", "index.js"), "playwright-core") + + cleanup() + if _, err := os.Stat(execRoot); !os.IsNotExist(err) { + t.Fatalf("expected cleanup to remove execRoot, got %v", err) + } +} + +func workspaceRootFromScriptPath(t *testing.T, scriptPath string, entryFile string) string { + t.Helper() + + entryPath := filepath.FromSlash(entryFile) + if !strings.HasSuffix(scriptPath, entryPath) { + t.Fatalf("script path %q does not end with entry file %q", scriptPath, entryPath) + } + + execRoot := strings.TrimSuffix(scriptPath, entryPath) + return strings.TrimRight(execRoot, `\/`) +} + +func assertFileContent(t *testing.T, path string, expectedSubstring string) { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read file %s failed: %v", path, err) + } + if !strings.Contains(string(data), expectedSubstring) { + t.Fatalf("file %s does not contain %q; got %q", path, expectedSubstring, string(data)) + } +} diff --git a/backend/automation_script_target_resolver.go b/backend/automation_script_target_resolver.go new file mode 100644 index 00000000..d971f745 --- /dev/null +++ b/backend/automation_script_target_resolver.go @@ -0,0 +1,403 @@ +package backend + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/browser" +) + +const defaultAutomationCreateNameTemplate = "${templateName}-${timestamp}" + +func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord, input automation.ScriptRunRequest, required bool) (map[string]any, string, error) { + overrideSelectorText := strings.TrimSpace(input.SelectorText) + if !input.UseScriptSelector && overrideSelectorText != "" { + selector, err := parseAutomationJSONObject(overrideSelectorText, required) + return selector, "", err + } + + if strings.TrimSpace(script.TargetConfig.Mode) != "" && !strings.EqualFold(script.TargetConfig.Mode, "manual") { + return a.resolveAutomationScriptTarget(script) + } + + selectorText := resolveAutomationRunJSONText(input.SelectorText, script.SelectorText, input.UseScriptSelector) + selector, err := parseAutomationJSONObject(selectorText, required) + return selector, "", err +} + +func (a *App) resolveAutomationScriptTarget(script automation.ScriptRecord) (map[string]any, string, error) { + switch strings.ToLower(strings.TrimSpace(script.TargetConfig.Mode)) { + case "existing": + profile, err := a.resolveAutomationExactTargetProfile(script.TargetConfig.Selector, "使用已有实例") + if err != nil { + return nil, "", err + } + return automationProfileSelector(profile.ProfileId), automationProfileLabel(profile), nil + case "rotate": + profiles, err := a.resolveAutomationTargetProfiles(script.TargetConfig.Selector, "按条件轮询实例") + if err != nil { + return nil, "", err + } + profile := a.pickAutomationRoundRobinTarget(script.ID, script.TargetConfig.Selector, profiles) + return automationProfileSelector(profile.ProfileId), fmt.Sprintf("轮询实例 %s", automationProfileLabel(profile)), nil + case "create": + templateProfile, err := a.resolveAutomationExactTargetProfile(script.TargetConfig.TemplateSelector, "按模板新建实例") + if err != nil { + return nil, "", err + } + createdName := buildAutomationCreatedProfileName(script.TargetConfig.CreateNameTemplate, script, templateProfile) + createdProfile, err := a.browserMgr.Copy(templateProfile.ProfileId, createdName) + if err != nil { + return nil, "", fmt.Errorf("按模板新建实例失败: %w", err) + } + if createdProfile == nil { + return nil, "", fmt.Errorf("按模板新建实例失败:未返回新实例") + } + return automationProfileSelector(createdProfile.ProfileId), fmt.Sprintf("新建实例 %s", automationProfileLabel(*createdProfile)), nil + default: + return map[string]any{}, "", nil + } +} + +func (a *App) resolveAutomationExactTargetProfile(selector automation.ScriptTargetSelector, actionLabel string) (browser.Profile, error) { + if profile, ok := a.findAutomationTargetProfileByIDOrCode(selector); ok { + return profile, nil + } + return a.resolveAutomationTargetProfile(selector, actionLabel) +} + +func (a *App) resolveAutomationTargetProfile(selector automation.ScriptTargetSelector, actionLabel string) (browser.Profile, error) { + profiles, err := a.resolveAutomationTargetProfiles(selector, actionLabel) + if err != nil { + return browser.Profile{}, err + } + if len(profiles) > 1 { + return browser.Profile{}, fmt.Errorf("%s失败:%s", actionLabel, buildAutomationTargetAmbiguousError(profiles)) + } + return profiles[0], nil +} + +func (a *App) resolveAutomationTargetProfiles(selector automation.ScriptTargetSelector, actionLabel string) ([]browser.Profile, error) { + if a.browserMgr == nil { + return nil, fmt.Errorf("%s失败:实例管理器未初始化", actionLabel) + } + + normalized := normalizeAutomationTargetSelector(selector) + if automationTargetSelectorEmpty(normalized) { + return nil, fmt.Errorf("%s失败:请至少填写一个实例条件", actionLabel) + } + + snapshots := a.browserMgr.List() + if len(snapshots) == 0 { + return nil, fmt.Errorf("%s失败:当前没有可用实例", actionLabel) + } + + if normalized.Code != "" { + snapshots = filterAutomationProfiles(snapshots, func(item browser.Profile) bool { + return strings.EqualFold(strings.TrimSpace(item.LaunchCode), normalized.Code) + }) + } + if normalized.ProfileID != "" { + snapshots = filterAutomationProfiles(snapshots, func(item browser.Profile) bool { + return strings.TrimSpace(item.ProfileId) == normalized.ProfileID + }) + } + if normalized.ProfileName != "" { + snapshots = filterAutomationProfiles(snapshots, func(item browser.Profile) bool { + return strings.EqualFold(strings.TrimSpace(item.ProfileName), normalized.ProfileName) + }) + } + if normalized.GroupID != "" { + snapshots = filterAutomationProfiles(snapshots, func(item browser.Profile) bool { + return strings.TrimSpace(item.GroupId) == normalized.GroupID + }) + } + if len(normalized.Tags) > 0 { + snapshots = filterAutomationProfiles(snapshots, func(item browser.Profile) bool { + return automationProfileHasAllTags(item, normalized.Tags) + }) + } + if len(normalized.Keywords) > 0 { + snapshots = filterAutomationProfiles(snapshots, func(item browser.Profile) bool { + return automationProfileMatchesAllKeywordQueries(item, normalized.Keywords) + }) + } + + if len(snapshots) == 0 { + return nil, fmt.Errorf("%s失败:没有匹配到实例", actionLabel) + } + + sortAutomationProfilesForTarget(snapshots) + return snapshots, nil +} + +func normalizeAutomationTargetSelector(selector automation.ScriptTargetSelector) automation.ScriptTargetSelector { + return automation.ScriptTargetSelector{ + Code: strings.ToUpper(strings.TrimSpace(selector.Code)), + ProfileID: strings.TrimSpace(selector.ProfileID), + ProfileName: strings.TrimSpace(selector.ProfileName), + GroupID: strings.TrimSpace(selector.GroupID), + Keywords: normalizeAutomationTargetTerms(selector.Keywords), + Tags: normalizeAutomationTargetTerms(selector.Tags), + } +} + +func (a *App) findAutomationTargetProfileByIDOrCode(selector automation.ScriptTargetSelector) (browser.Profile, bool) { + if a.browserMgr == nil { + return browser.Profile{}, false + } + + normalizedProfileID := strings.TrimSpace(selector.ProfileID) + normalizedCode := strings.ToUpper(strings.TrimSpace(selector.Code)) + if normalizedProfileID == "" && normalizedCode == "" { + return browser.Profile{}, false + } + + snapshots := a.browserMgr.List() + if normalizedProfileID != "" { + for _, item := range snapshots { + if strings.TrimSpace(item.ProfileId) == normalizedProfileID { + return item, true + } + } + } + + if normalizedCode != "" { + for _, item := range snapshots { + if strings.EqualFold(strings.TrimSpace(item.LaunchCode), normalizedCode) { + return item, true + } + } + } + + return browser.Profile{}, false +} + +func (a *App) enrichAutomationExactTargetSelector(selector automation.ScriptTargetSelector) automation.ScriptTargetSelector { + normalized := normalizeAutomationTargetSelector(selector) + profile, ok := a.findAutomationTargetProfileByIDOrCode(normalized) + if !ok { + return normalized + } + + normalized.ProfileID = strings.TrimSpace(profile.ProfileId) + if code := strings.ToUpper(strings.TrimSpace(profile.LaunchCode)); code != "" { + normalized.Code = code + } + return normalized +} + +func normalizeAutomationTargetTerms(items []string) []string { + if len(items) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +func automationTargetSelectorEmpty(selector automation.ScriptTargetSelector) bool { + return selector.Code == "" && + selector.ProfileID == "" && + selector.ProfileName == "" && + selector.GroupID == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 +} + +func filterAutomationProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile { + filtered := make([]browser.Profile, 0, len(items)) + for _, item := range items { + if keep(item) { + filtered = append(filtered, item) + } + } + return filtered +} + +func automationProfileHasAllTags(profile browser.Profile, required []string) bool { + if len(required) == 0 { + return true + } + if len(profile.Tags) == 0 { + return false + } + + for _, want := range required { + found := false + for _, tag := range profile.Tags { + if strings.EqualFold(strings.TrimSpace(tag), want) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func automationProfileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool { + if len(queries) == 0 { + return true + } + if len(profile.Keywords) == 0 { + return false + } + + for _, query := range queries { + queryLower := strings.ToLower(strings.TrimSpace(query)) + found := false + for _, keyword := range profile.Keywords { + if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func sortAutomationProfilesForTarget(items []browser.Profile) { + sort.Slice(items, func(i, j int) bool { + leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName)) + rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName)) + if leftName != rightName { + return leftName < rightName + } + return items[i].ProfileId < items[j].ProfileId + }) +} + +func buildAutomationTargetAmbiguousError(items []browser.Profile) string { + const maxPreview = 5 + parts := make([]string, 0, minAutomationInt(len(items), maxPreview)) + for i := 0; i < len(items) && i < maxPreview; i++ { + parts = append(parts, automationProfileLabel(items[i])) + } + suffix := "" + if len(items) > maxPreview { + suffix = fmt.Sprintf(" 等 %d 个实例", len(items)) + } + return fmt.Sprintf("命中了多个实例:%s%s。请改用 code/profileId,或继续加分组、标签、关键字缩小范围", strings.Join(parts, ","), suffix) +} + +func automationProfileLabel(profile browser.Profile) string { + label := strings.TrimSpace(profile.ProfileName) + if label == "" { + label = strings.TrimSpace(profile.ProfileId) + } + if code := strings.TrimSpace(profile.LaunchCode); code != "" { + return fmt.Sprintf("%s[id=%s, code=%s]", label, profile.ProfileId, code) + } + return fmt.Sprintf("%s[id=%s]", label, profile.ProfileId) +} + +func automationProfileSelector(profileID string) map[string]any { + return map[string]any{ + "profileId": strings.TrimSpace(profileID), + } +} + +func (a *App) pickAutomationRoundRobinTarget(scriptID string, selector automation.ScriptTargetSelector, profiles []browser.Profile) browser.Profile { + rotationKey := buildAutomationTargetRotationKey(scriptID, selector) + + a.automationTargetMu.Lock() + defer a.automationTargetMu.Unlock() + + lastProfileID := strings.TrimSpace(a.automationTargetCursor[rotationKey]) + nextIndex := 0 + if lastProfileID != "" { + for idx, profile := range profiles { + if profile.ProfileId == lastProfileID { + nextIndex = (idx + 1) % len(profiles) + break + } + } + } + + selected := profiles[nextIndex] + a.automationTargetCursor[rotationKey] = selected.ProfileId + return selected +} + +func buildAutomationTargetRotationKey(scriptID string, selector automation.ScriptTargetSelector) string { + payload := normalizeAutomationTargetSelector(selector) + data, err := json.Marshal(payload) + if err != nil { + return strings.TrimSpace(scriptID) + } + return strings.TrimSpace(scriptID) + ":" + string(data) +} + +func buildAutomationCreatedProfileName(template string, script automation.ScriptRecord, source browser.Profile) string { + now := time.Now() + pattern := strings.TrimSpace(template) + if pattern == "" { + pattern = defaultAutomationCreateNameTemplate + } + + replacements := map[string]string{ + "${timestamp}": now.Format("20060102-150405"), + "${date}": now.Format("20060102"), + "${time}": now.Format("150405"), + "${templateName}": strings.TrimSpace(source.ProfileName), + "${scriptName}": strings.TrimSpace(script.Name), + } + for placeholder, value := range replacements { + pattern = strings.ReplaceAll(pattern, placeholder, value) + } + pattern = strings.TrimSpace(pattern) + if pattern != "" { + return pattern + } + + templateName := strings.TrimSpace(source.ProfileName) + if templateName == "" { + templateName = "自动化实例" + } + return fmt.Sprintf("%s-%s", templateName, now.Format("20060102-150405")) +} + +func appendAutomationRunSummary(summary string, targetSummary string) string { + summary = strings.TrimSpace(summary) + targetSummary = strings.TrimSpace(targetSummary) + if targetSummary == "" { + return summary + } + if summary == "" { + return targetSummary + } + return summary + " · " + targetSummary +} + +func minAutomationInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/automation_script_target_resolver_test.go b/backend/automation_script_target_resolver_test.go new file mode 100644 index 00000000..34a84e1d --- /dev/null +++ b/backend/automation_script_target_resolver_test.go @@ -0,0 +1,215 @@ +package backend + +import ( + "testing" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" +) + +func newAutomationTargetTestApp(t *testing.T) *App { + t.Helper() + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.browserMgr = browser.NewManager(app.config, app.appRoot) + app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + app.browserMgr.CodeProvider = app.launchCodeSvc + return app +} + +func createAutomationTargetProfile(t *testing.T, app *App, input browser.ProfileInput) *browser.Profile { + t.Helper() + + profile, err := app.browserMgr.Create(input) + if err != nil { + t.Fatalf("create profile failed: %v", err) + } + if profile == nil { + t.Fatal("create profile returned nil") + } + return profile +} + +func TestResolveAutomationScriptTargetUsesExistingProfile(t *testing.T) { + app := newAutomationTargetTestApp(t) + first := createAutomationTargetProfile(t, app, browser.ProfileInput{ + ProfileName: "buyer-001", + Keywords: []string{"buyer-001"}, + }) + _, err := app.launchCodeSvc.SetCode(first.ProfileId, "BUYER_001") + if err != nil { + t.Fatalf("set code failed: %v", err) + } + + selector, summary, err := app.resolveAutomationScriptTarget(automation.ScriptRecord{ + ID: "script-existing", + Name: "使用已有实例", + TargetConfig: automation.ScriptTargetConfig{ + Mode: "existing", + Selector: automation.ScriptTargetSelector{ + Code: "buyer_001", + }, + }, + }) + if err != nil { + t.Fatalf("resolveAutomationScriptTarget returned error: %v", err) + } + if selector["profileId"] != first.ProfileId { + t.Fatalf("unexpected selector: %+v want profileId=%s", selector, first.ProfileId) + } + if summary == "" { + t.Fatalf("expected target summary to be populated") + } +} + +func TestResolveAutomationScriptTargetPrefersProfileIDWhenStoredCodeIsStale(t *testing.T) { + app := newAutomationTargetTestApp(t) + first := createAutomationTargetProfile(t, app, browser.ProfileInput{ + ProfileName: "buyer-001", + }) + if _, err := app.launchCodeSvc.SetCode(first.ProfileId, "BUYER_001"); err != nil { + t.Fatalf("set initial code failed: %v", err) + } + if _, err := app.launchCodeSvc.SetCode(first.ProfileId, "BUYER_RENAMED"); err != nil { + t.Fatalf("set updated code failed: %v", err) + } + + selector, summary, err := app.resolveAutomationScriptTarget(automation.ScriptRecord{ + ID: "script-existing", + Name: "使用已有实例", + TargetConfig: automation.ScriptTargetConfig{ + Mode: "existing", + Selector: automation.ScriptTargetSelector{ + ProfileID: first.ProfileId, + Code: "BUYER_001", + }, + }, + }) + if err != nil { + t.Fatalf("resolveAutomationScriptTarget returned error: %v", err) + } + if selector["profileId"] != first.ProfileId { + t.Fatalf("unexpected selector: %+v want profileId=%s", selector, first.ProfileId) + } + updatedProfiles := app.browserMgr.List() + if len(updatedProfiles) == 0 { + t.Fatalf("expected profiles to be available after resolve") + } + expectedSummary := "" + for _, item := range updatedProfiles { + if item.ProfileId == first.ProfileId { + expectedSummary = automationProfileLabel(item) + break + } + } + if expectedSummary == "" { + t.Fatalf("expected updated profile summary to be available") + } + if summary != expectedSummary { + t.Fatalf("expected updated target summary %q, got %q", expectedSummary, summary) + } +} + +func TestResolveAutomationScriptTargetCreatesProfileFromTemplate(t *testing.T) { + app := newAutomationTargetTestApp(t) + template := createAutomationTargetProfile(t, app, browser.ProfileInput{ + ProfileName: "template-buyer", + Tags: []string{"template"}, + }) + _, err := app.launchCodeSvc.SetCode(template.ProfileId, "TPL_001") + if err != nil { + t.Fatalf("set code failed: %v", err) + } + + before := app.browserMgr.List() + selector, summary, err := app.resolveAutomationScriptTarget(automation.ScriptRecord{ + ID: "script-create", + Name: "按模板新建", + TargetConfig: automation.ScriptTargetConfig{ + Mode: "create", + TemplateSelector: automation.ScriptTargetSelector{ + Code: "TPL_001", + }, + CreateNameTemplate: "${templateName}-${scriptName}", + }, + }) + if err != nil { + t.Fatalf("resolveAutomationScriptTarget returned error: %v", err) + } + + after := app.browserMgr.List() + if len(after) != len(before)+1 { + t.Fatalf("expected profile count to grow by one: before=%d after=%d", len(before), len(after)) + } + + newProfileID, _ := selector["profileId"].(string) + if newProfileID == "" || newProfileID == template.ProfileId { + t.Fatalf("unexpected created selector: %+v", selector) + } + + var created *browser.Profile + for i := range after { + if after[i].ProfileId == newProfileID { + created = &after[i] + break + } + } + if created == nil { + t.Fatalf("created profile not found in list") + } + if created.ProfileName != "template-buyer-按模板新建" { + t.Fatalf("unexpected created profile name: %q", created.ProfileName) + } + if summary == "" { + t.Fatalf("expected create summary to be populated") + } +} + +func TestResolveAutomationScriptTargetRotatesProfiles(t *testing.T) { + app := newAutomationTargetTestApp(t) + first := createAutomationTargetProfile(t, app, browser.ProfileInput{ + ProfileName: "buyer-a", + Tags: []string{"pool"}, + }) + second := createAutomationTargetProfile(t, app, browser.ProfileInput{ + ProfileName: "buyer-b", + Tags: []string{"pool"}, + }) + + script := automation.ScriptRecord{ + ID: "script-rotate", + Name: "轮询实例", + TargetConfig: automation.ScriptTargetConfig{ + Mode: "rotate", + Selector: automation.ScriptTargetSelector{ + Tags: []string{"pool"}, + }, + }, + } + + firstSelector, _, err := app.resolveAutomationScriptTarget(script) + if err != nil { + t.Fatalf("first resolve returned error: %v", err) + } + secondSelector, _, err := app.resolveAutomationScriptTarget(script) + if err != nil { + t.Fatalf("second resolve returned error: %v", err) + } + thirdSelector, _, err := app.resolveAutomationScriptTarget(script) + if err != nil { + t.Fatalf("third resolve returned error: %v", err) + } + + if firstSelector["profileId"] != first.ProfileId { + t.Fatalf("expected first rotation profile %s, got %+v", first.ProfileId, firstSelector) + } + if secondSelector["profileId"] != second.ProfileId { + t.Fatalf("expected second rotation profile %s, got %+v", second.ProfileId, secondSelector) + } + if thirdSelector["profileId"] != first.ProfileId { + t.Fatalf("expected third rotation profile %s, got %+v", first.ProfileId, thirdSelector) + } +} diff --git a/backend/automation_script_workspace.go b/backend/automation_script_workspace.go new file mode 100644 index 00000000..dcd51624 --- /dev/null +++ b/backend/automation_script_workspace.go @@ -0,0 +1,99 @@ +package backend + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/automation" +) + +func (a *App) preparePlaywrightScriptWorkspace(runtimeDir string, script automation.ScriptRecord) (string, string, func(), error) { + execRoot := filepath.Join(runtimeDir, "tmp", "script-run", fmt.Sprintf("%s-%d", strings.TrimSpace(script.ID), time.Now().UnixNano())) + scriptPath := filepath.Join(execRoot, filepath.FromSlash(script.EntryFile)) + artifactDir := filepath.Join(a.appDataDir(), "automation", "artifacts", strings.TrimSpace(script.ID), time.Now().Format("20060102-150405")) + + if err := os.MkdirAll(artifactDir, 0o755); err != nil { + return "", "", nil, fmt.Errorf("create script artifact dir failed: %w", err) + } + + scriptDir, err := a.automationScriptStore().Dir(script.ID) + if err == nil { + if _, statErr := os.Stat(scriptDir); statErr == nil { + if copyErr := copyAutomationScriptDir(scriptDir, execRoot); copyErr != nil { + return "", "", nil, copyErr + } + } else if !os.IsNotExist(statErr) { + return "", "", nil, fmt.Errorf("stat script workspace failed: %w", statErr) + } + } + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + return "", "", nil, fmt.Errorf("create script workspace failed: %w", err) + } + if err := os.WriteFile(scriptPath, []byte(script.ScriptText), 0o644); err != nil { + return "", "", nil, fmt.Errorf("write script workspace failed: %w", err) + } + if err := writePlaywrightCompatModule(execRoot, runtimeDir); err != nil { + return "", "", nil, err + } + + cleanup := func() { + _ = os.RemoveAll(execRoot) + } + return scriptPath, artifactDir, cleanup, nil +} + +func copyAutomationScriptDir(srcDir string, dstDir string) error { + if err := filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relativePath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if relativePath == "." { + return os.MkdirAll(dstDir, 0o755) + } + + targetPath := filepath.Join(dstDir, relativePath) + if info.IsDir() { + return os.MkdirAll(targetPath, 0o755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + return os.WriteFile(targetPath, data, 0o644) + }); err != nil { + return fmt.Errorf("copy script workspace failed: %w", err) + } + return nil +} + +func writePlaywrightCompatModule(execRoot string, runtimeDir string) error { + target := filepath.Join(runtimeDir, "node_modules", "playwright-core") + for _, packageName := range []string{"playwright", "playwright-core"} { + compatDir := filepath.Join(execRoot, "node_modules", packageName) + if err := os.MkdirAll(compatDir, 0o755); err != nil { + return fmt.Errorf("create %s compatibility module failed: %w", packageName, err) + } + + content := fmt.Sprintf("module.exports = require(%q)\n", target) + if err := os.WriteFile(filepath.Join(compatDir, "index.js"), []byte(content), 0o644); err != nil { + return fmt.Errorf("write %s compatibility module failed: %w", packageName, err) + } + packageJSON := fmt.Sprintf("{\"name\":%q,\"main\":\"index.js\"}\n", packageName) + if err := os.WriteFile(filepath.Join(compatDir, "package.json"), []byte(packageJSON), 0o644); err != nil { + return fmt.Errorf("write %s compatibility package.json failed: %w", packageName, err) + } + } + return nil +} diff --git a/backend/automation_settings_api.go b/backend/automation_settings_api.go new file mode 100644 index 00000000..0f7b381b --- /dev/null +++ b/backend/automation_settings_api.go @@ -0,0 +1,124 @@ +package backend + +import ( + "fmt" + "strings" + + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" +) + +func (a *App) SaveAutomationSettings(enabled bool, headlessDefault bool) (map[string]interface{}, error) { + if a.config == nil { + return nil, fmt.Errorf("automation config is not initialized") + } + + a.config.Automation.Enabled = enabled + a.config.Automation.HeadlessDefault = headlessDefault + applyAutomationConfigDefaults(&a.config.Automation) + + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + logger.New("Automation").Error("自动化配置保存失败", logger.F("error", err.Error())) + return nil, err + } + + if a.automationMgr != nil { + a.automationMgr.SetConfig(a.config) + state := a.automationMgr.CurrentState() + if enabled && !state.Ready && strings.EqualFold(a.config.Automation.InstallPolicy, config.DefaultAutomationInstallPolicy) { + a.automationMgr.InstallAsync(a.ctx) + } + } + + return a.automationStatePayload(), nil +} + +func (a *App) SaveAutomationRuntimeSettings(nodeSource string, systemNodePath string) (map[string]interface{}, error) { + if a.config == nil { + return nil, fmt.Errorf("automation config is not initialized") + } + + a.config.Automation.NodeSource = normalizeAutomationNodeSourceInput(nodeSource) + a.config.Automation.SystemNodePath = strings.TrimSpace(systemNodePath) + applyAutomationConfigDefaults(&a.config.Automation) + + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + logger.New("Automation").Error("自动化运行时策略保存失败", logger.F("error", err.Error())) + return nil, err + } + + if a.automationMgr != nil { + a.automationMgr.SetConfig(a.config) + if a.config.Automation.Enabled && strings.EqualFold(a.config.Automation.InstallPolicy, config.DefaultAutomationInstallPolicy) { + a.automationMgr.InstallAsync(a.ctx) + } + } + + return a.automationStatePayload(), nil +} + +func (a *App) SaveAutomationScriptPackageSettings(allowTypeScriptBuild bool) (map[string]interface{}, error) { + if a.config == nil { + return nil, fmt.Errorf("automation config is not initialized") + } + + a.config.Automation.AllowTypeScriptBuild = allowTypeScriptBuild + applyAutomationConfigDefaults(&a.config.Automation) + + if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { + logger.New("Automation").Error("自动化脚本包配置保存失败", logger.F("error", err.Error())) + return nil, err + } + + if a.automationMgr != nil { + a.automationMgr.SetConfig(a.config) + } + + return a.automationStatePayload(), nil +} + +func (a *App) InstallAutomationRuntime() (map[string]interface{}, error) { + if a.automationMgr == nil { + return nil, fmt.Errorf("automation runtime manager is not initialized") + } + a.automationMgr.InstallAsync(a.ctx) + return a.automationStatePayload(), nil +} + +func (a *App) AutomationProbeSystemNode(systemNodePath string) (map[string]interface{}, error) { + if a.automationMgr == nil { + return nil, fmt.Errorf("automation runtime manager is not initialized") + } + + explicitPath := strings.TrimSpace(systemNodePath) + if explicitPath == "" && a.config != nil { + explicitPath = strings.TrimSpace(a.config.Automation.SystemNodePath) + } + + result, err := a.automationMgr.ProbeSystemNode(a.ctx, explicitPath) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "ok": result.OK, + "path": result.Path, + "version": result.Version, + }, nil +} + +func (a *App) AutomationRuntimeSelfCheck() (map[string]interface{}, error) { + if a.automationMgr == nil { + return nil, fmt.Errorf("automation runtime manager is not initialized") + } + result, err := a.automationMgr.SelfCheck(a.ctx) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "ok": result.OK, + "nodeSource": result.NodeSource, + "nodeVersion": result.NodeVersion, + "playwrightVersion": result.PlaywrightVersion, + }, nil +} diff --git a/backend/automation_state_api.go b/backend/automation_state_api.go new file mode 100644 index 00000000..17f7e3e4 --- /dev/null +++ b/backend/automation_state_api.go @@ -0,0 +1,116 @@ +package backend + +import ( + "strings" + + "ant-chrome/backend/internal/config" +) + +func (a *App) GetAutomationState() map[string]interface{} { + return a.automationStatePayload() +} + +func (a *App) automationStatePayload() map[string]interface{} { + settings := map[string]interface{}{ + "enabled": false, + "installPolicy": config.DefaultAutomationInstallPolicy, + "runtimeVersion": config.DefaultAutomationRuntimeVersion(config.DefaultAutomationNodeVersion, config.DefaultAutomationPWVersion), + "headlessDefault": false, + "keepRuntimeOnDisable": true, + "allowTypeScriptBuild": false, + "nodeSource": config.DefaultAutomationNodeSource, + "systemNodePath": "", + "nodeVersion": config.DefaultAutomationNodeVersion, + "playwrightVersion": config.DefaultAutomationPWVersion, + } + status := map[string]interface{}{ + "installed": false, + "ready": false, + "installing": false, + "lastError": "", + "runtimeDir": "", + "nodePath": "", + "nodeSource": config.DefaultAutomationNodeSource, + "nodeResolution": "", + "systemNodeDetected": false, + "systemNodePath": "", + "systemNodeError": "", + "nodeVersion": config.DefaultAutomationNodeVersion, + "playwrightVersion": config.DefaultAutomationPWVersion, + } + + if a.config != nil { + settings["enabled"] = a.config.Automation.Enabled + settings["installPolicy"] = a.config.Automation.InstallPolicy + settings["runtimeVersion"] = a.config.Automation.RuntimeVersion + settings["headlessDefault"] = a.config.Automation.HeadlessDefault + settings["keepRuntimeOnDisable"] = a.config.Automation.KeepRuntimeOnDisable + settings["allowTypeScriptBuild"] = a.config.Automation.AllowTypeScriptBuild + settings["nodeSource"] = a.config.Automation.NodeSource + settings["systemNodePath"] = a.config.Automation.SystemNodePath + settings["nodeVersion"] = a.config.Automation.NodeVersion + settings["playwrightVersion"] = a.config.Automation.PlaywrightCoreVersion + } + + if a.automationMgr != nil { + state := a.automationMgr.CurrentState() + status = map[string]interface{}{ + "installed": state.Installed, + "ready": state.Ready, + "installing": state.Installing, + "lastError": state.LastError, + "runtimeDir": state.RuntimeDir, + "nodePath": state.NodePath, + "runnerPath": state.RunnerPath, + "nodeSource": state.NodeSource, + "nodeResolution": state.NodeResolution, + "systemNodeDetected": state.SystemNodeDetected, + "systemNodePath": state.SystemNodePath, + "systemNodeError": state.SystemNodeError, + "nodeVersion": state.NodeVersion, + "playwrightVersion": state.PlaywrightVersion, + } + } + + return map[string]interface{}{ + "settings": settings, + "status": status, + } +} + +func applyAutomationConfigDefaults(auto *config.AutomationConfig) { + if auto == nil { + return + } + if strings.TrimSpace(auto.InstallPolicy) == "" { + auto.InstallPolicy = config.DefaultAutomationInstallPolicy + } + auto.NodeSource = normalizeAutomationNodeSourceInput(auto.NodeSource) + auto.SystemNodePath = strings.TrimSpace(auto.SystemNodePath) + if strings.TrimSpace(auto.NodeVersion) == "" { + auto.NodeVersion = config.DefaultAutomationNodeVersion + } + if strings.TrimSpace(auto.PlaywrightCoreVersion) == "" { + auto.PlaywrightCoreVersion = config.DefaultAutomationPWVersion + } + if strings.TrimSpace(auto.RuntimeVersion) == "" { + auto.RuntimeVersion = config.DefaultAutomationRuntimeVersion( + auto.NodeVersion, + auto.PlaywrightCoreVersion, + ) + } + if !auto.KeepRuntimeOnDisable { + auto.KeepRuntimeOnDisable = true + } +} + +func normalizeAutomationNodeSourceInput(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case config.AutomationNodeSourceSystem: + return config.AutomationNodeSourceSystem + case config.AutomationNodeSourceBundled: + return config.AutomationNodeSourceBundled + default: + return config.AutomationNodeSourceAuto + } +} diff --git a/backend/browser_process_monitor.go b/backend/browser_process_monitor.go index 80dec455..e53f45c3 100644 --- a/backend/browser_process_monitor.go +++ b/backend/browser_process_monitor.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/url" + "os" "os/exec" "strconv" "strings" @@ -25,6 +26,7 @@ type browserProcessMonitor struct { cmd *exec.Cmd stderr io.ReadCloser stderrTail *tailTextBuffer + stderrInit chan struct{} stderrDone chan struct{} waitDone chan struct{} @@ -47,6 +49,7 @@ func newBrowserProcessMonitor(cmd *exec.Cmd) (*browserProcessMonitor, error) { cmd: cmd, stderr: stderr, stderrTail: newTailTextBuffer(browserStderrTailMaxLines, browserStderrTailMaxBytes), + stderrInit: make(chan struct{}), stderrDone: make(chan struct{}), waitDone: make(chan struct{}), }, nil @@ -54,6 +57,7 @@ func newBrowserProcessMonitor(cmd *exec.Cmd) (*browserProcessMonitor, error) { func (m *browserProcessMonitor) Start() { go m.captureStderr() + <-m.stderrInit go m.waitForExit() } @@ -107,12 +111,14 @@ func (m *browserProcessMonitor) captureStderr() { defer close(m.stderrDone) if m.stderr == nil { + close(m.stderrInit) return } defer m.stderr.Close() scanner := bufio.NewScanner(m.stderr) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + close(m.stderrInit) for scanner.Scan() { line := scanner.Text() m.stderrTail.Append(line) @@ -120,11 +126,24 @@ func (m *browserProcessMonitor) captureStderr() { m.SetDebugPort(port) } } - if err := scanner.Err(); err != nil { + if err := scanner.Err(); err != nil && !shouldIgnoreBrowserStderrReadError(err) { m.stderrTail.Append(fmt.Sprintf("[stderr read error] %v", err)) } } +func shouldIgnoreBrowserStderrReadError(err error) bool { + if err == nil { + return false + } + if err == io.EOF || err == os.ErrClosed { + return true + } + + message := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(message, "file already closed") || + strings.Contains(message, "handle is invalid") +} + func (m *browserProcessMonitor) waitForExit() { err := m.cmd.Wait() <-m.stderrDone diff --git a/backend/cmd/profile-recover/core_selection.go b/backend/cmd/profile-recover/core_selection.go new file mode 100644 index 00000000..64784b7b --- /dev/null +++ b/backend/cmd/profile-recover/core_selection.go @@ -0,0 +1,90 @@ +package main + +import ( + "ant-chrome/backend/internal/apppath" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "fmt" + "os" + "path/filepath" + "strings" +) + +func normalizeRepairStrategy(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "none": + return "none" + case "risky": + return "risky" + default: + return "" + } +} + +func selectCore(appRoot string, cfg *config.Config, dbPath string, apply bool) (selectedCore, []string, error) { + var warnings []string + + if info, err := os.Stat(dbPath); err == nil && !info.IsDir() { + db, err := openQueryDB(dbPath) + if err != nil { + return selectedCore{}, warnings, fmt.Errorf("open database for core selection: %w", err) + } + defer db.Close() + + cores, err := browser.NewSQLiteCoreDAO(db).List() + if err == nil && len(cores) > 0 { + picked := pickCoreFromList(appRoot, cores, "database") + return picked, warnings, nil + } + if err != nil { + warnings = append(warnings, fmt.Sprintf("load cores from database failed, fallback to config: %v", err)) + } + } + + if len(cfg.Browser.Cores) > 0 { + picked := pickCoreFromConfig(appRoot, cfg.Browser.Cores, "config") + return picked, warnings, nil + } + + if apply { + warnings = append(warnings, "no browser core was found; restored profiles will be created with empty core_id") + } + + return selectedCore{ + CoreID: "", + CoreName: "", + CorePath: "", + Source: "none", + }, warnings, nil +} + +func pickCoreFromList(appRoot string, cores []browser.Core, source string) selectedCore { + for _, core := range cores { + if core.IsDefault { + return buildSelectedCore(appRoot, core.CoreId, core.CoreName, core.CorePath, source) + } + } + first := cores[0] + return buildSelectedCore(appRoot, first.CoreId, first.CoreName, first.CorePath, source) +} + +func pickCoreFromConfig(appRoot string, cores []config.BrowserCore, source string) selectedCore { + for _, core := range cores { + if core.IsDefault { + return buildSelectedCore(appRoot, core.CoreId, core.CoreName, core.CorePath, source) + } + } + first := cores[0] + return buildSelectedCore(appRoot, first.CoreId, first.CoreName, first.CorePath, source) +} + +func buildSelectedCore(appRoot, coreID, coreName, corePath, source string) selectedCore { + coreAbsPath := apppath.Resolve(appRoot, corePath) + return selectedCore{ + CoreID: strings.TrimSpace(coreID), + CoreName: strings.TrimSpace(coreName), + CorePath: strings.TrimSpace(corePath), + BinaryPath: filepath.Join(coreAbsPath, "chrome.exe"), + Source: source, + } +} diff --git a/backend/cmd/profile-recover/main.go b/backend/cmd/profile-recover/main.go index 908917eb..5b8552f2 100644 --- a/backend/cmd/profile-recover/main.go +++ b/backend/cmd/profile-recover/main.go @@ -4,20 +4,13 @@ import ( "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/database" - "database/sql" - "encoding/binary" - "encoding/json" "flag" "fmt" - "io" - "io/fs" "os" "path/filepath" "sort" "strings" "time" - "unicode/utf16" "github.com/google/uuid" _ "modernc.org/sqlite" @@ -55,44 +48,44 @@ type candidateInspection struct { } type reportEntry struct { - DirName string `json:"dirName"` - ResolvedPath string `json:"resolvedPath"` - Action string `json:"action"` - Reason string `json:"reason,omitempty"` - ExistingProfileID string `json:"existingProfileId,omitempty"` - ExistingProfileName string `json:"existingProfileName,omitempty"` - RestoredProfileID string `json:"restoredProfileId,omitempty"` - RestoredProfileName string `json:"restoredProfileName,omitempty"` - RegisteredUserDataDir string `json:"registeredUserDataDir,omitempty"` - Repair *repairResult `json:"repair,omitempty"` - Inspection candidateInspection `json:"inspection"` + DirName string `json:"dirName"` + ResolvedPath string `json:"resolvedPath"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` + ExistingProfileID string `json:"existingProfileId,omitempty"` + ExistingProfileName string `json:"existingProfileName,omitempty"` + RestoredProfileID string `json:"restoredProfileId,omitempty"` + RestoredProfileName string `json:"restoredProfileName,omitempty"` + RegisteredUserDataDir string `json:"registeredUserDataDir,omitempty"` + Repair *repairResult `json:"repair,omitempty"` + Inspection candidateInspection `json:"inspection"` } type reportSummary struct { - Scanned int `json:"scanned"` - Candidates int `json:"candidates"` - Existing int `json:"existing"` - Restored int `json:"restored"` - RepairCopies int `json:"repairCopies"` - Skipped int `json:"skipped"` - Warnings int `json:"warnings"` + Scanned int `json:"scanned"` + Candidates int `json:"candidates"` + Existing int `json:"existing"` + Restored int `json:"restored"` + RepairCopies int `json:"repairCopies"` + Skipped int `json:"skipped"` + Warnings int `json:"warnings"` } type recoveryReport struct { - Timestamp string `json:"timestamp"` - AppRoot string `json:"appRoot"` - ConfigPath string `json:"configPath"` - DBPath string `json:"dbPath"` - UserDataRoot string `json:"userDataRoot"` - Apply bool `json:"apply"` - RepairStrategy string `json:"repairStrategy"` - NamePrefix string `json:"namePrefix"` - SelectedCore selectedCore `json:"selectedCore"` - BackupDir string `json:"backupDir,omitempty"` - ReportPath string `json:"reportPath,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Summary reportSummary `json:"summary"` - Entries []reportEntry `json:"entries"` + Timestamp string `json:"timestamp"` + AppRoot string `json:"appRoot"` + ConfigPath string `json:"configPath"` + DBPath string `json:"dbPath"` + UserDataRoot string `json:"userDataRoot"` + Apply bool `json:"apply"` + RepairStrategy string `json:"repairStrategy"` + NamePrefix string `json:"namePrefix"` + SelectedCore selectedCore `json:"selectedCore"` + BackupDir string `json:"backupDir,omitempty"` + ReportPath string `json:"reportPath,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Summary reportSummary `json:"summary"` + Entries []reportEntry `json:"entries"` } type existingProfile struct { @@ -102,24 +95,6 @@ type existingProfile struct { ResolvedPath string } -var volatileDirNames = map[string]struct{}{ - "browsermetrics": {}, - "deferredbrowsermetrics": {}, - "graphitedawncache": {}, - "grshadercache": {}, - "shadercache": {}, - "component_crx_cache": {}, - "extensions_crx_cache": {}, - "cache": {}, - "code cache": {}, - "gpucache": {}, -} - -var volatileFileNames = map[string]struct{}{ - "lock": {}, - "local state.bad": {}, -} - func main() { opts := parseFlags() @@ -194,7 +169,7 @@ func run(opts options) (*recoveryReport, error) { return nil, fmt.Errorf("unsupported repair strategy %q", opts.repairStrategy) } - if err := os.MkdirAll(userDataRoot, 0755); err != nil { + if err := os.MkdirAll(userDataRoot, 0o755); err != nil { return nil, fmt.Errorf("ensure user data root: %w", err) } @@ -386,472 +361,3 @@ func run(opts options) (*recoveryReport, error) { return report, nil } - -func normalizeRepairStrategy(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "", "none": - return "none" - case "risky": - return "risky" - default: - return "" - } -} - -func selectCore(appRoot string, cfg *config.Config, dbPath string, apply bool) (selectedCore, []string, error) { - var warnings []string - - if info, err := os.Stat(dbPath); err == nil && !info.IsDir() { - db, err := openQueryDB(dbPath) - if err != nil { - return selectedCore{}, warnings, fmt.Errorf("open database for core selection: %w", err) - } - defer db.Close() - - cores, err := browser.NewSQLiteCoreDAO(db).List() - if err == nil && len(cores) > 0 { - picked := pickCoreFromList(appRoot, cores, "database") - return picked, warnings, nil - } - if err != nil { - warnings = append(warnings, fmt.Sprintf("load cores from database failed, fallback to config: %v", err)) - } - } - - if len(cfg.Browser.Cores) > 0 { - picked := pickCoreFromConfig(appRoot, cfg.Browser.Cores, "config") - return picked, warnings, nil - } - - if apply { - warnings = append(warnings, "no browser core was found; restored profiles will be created with empty core_id") - } - - return selectedCore{ - CoreID: "", - CoreName: "", - CorePath: "", - Source: "none", - }, warnings, nil -} - -func pickCoreFromList(appRoot string, cores []browser.Core, source string) selectedCore { - for _, core := range cores { - if core.IsDefault { - return buildSelectedCore(appRoot, core.CoreId, core.CoreName, core.CorePath, source) - } - } - first := cores[0] - return buildSelectedCore(appRoot, first.CoreId, first.CoreName, first.CorePath, source) -} - -func pickCoreFromConfig(appRoot string, cores []config.BrowserCore, source string) selectedCore { - for _, core := range cores { - if core.IsDefault { - return buildSelectedCore(appRoot, core.CoreId, core.CoreName, core.CorePath, source) - } - } - first := cores[0] - return buildSelectedCore(appRoot, first.CoreId, first.CoreName, first.CorePath, source) -} - -func buildSelectedCore(appRoot, coreID, coreName, corePath, source string) selectedCore { - coreAbsPath := apppath.Resolve(appRoot, corePath) - return selectedCore{ - CoreID: strings.TrimSpace(coreID), - CoreName: strings.TrimSpace(coreName), - CorePath: strings.TrimSpace(corePath), - BinaryPath: filepath.Join(coreAbsPath, "chrome.exe"), - Source: source, - } -} - -func loadExistingProfiles(dbPath string, userDataRoot string, apply bool) ([]existingProfile, *sql.DB, *database.DB, error) { - dbExists := fileExists(dbPath) - if !dbExists && !apply { - return nil, nil, nil, nil - } - - if apply { - handle, err := database.NewDB(dbPath) - if err != nil { - return nil, nil, nil, fmt.Errorf("open database: %w", err) - } - list, err := browser.NewSQLiteProfileDAO(handle.GetConn()).List() - if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "no such table") { - return nil, nil, handle, nil - } - _ = handle.Close() - return nil, nil, nil, fmt.Errorf("load existing profiles: %w", err) - } - return toExistingProfiles(list, userDataRoot), nil, handle, nil - } - - db, err := openQueryDB(dbPath) - if err != nil { - return nil, nil, nil, fmt.Errorf("open database: %w", err) - } - list, err := browser.NewSQLiteProfileDAO(db).List() - if err != nil { - _ = db.Close() - return nil, nil, nil, fmt.Errorf("load existing profiles: %w", err) - } - return toExistingProfiles(list, userDataRoot), db, nil, nil -} - -func toExistingProfiles(items []*browser.Profile, userDataRoot string) []existingProfile { - out := make([]existingProfile, 0, len(items)) - for _, item := range items { - if item == nil { - continue - } - out = append(out, existingProfile{ - ProfileID: item.ProfileId, - ProfileName: item.ProfileName, - UserDataDir: item.UserDataDir, - ResolvedPath: resolveUserDataPath(userDataRoot, item.UserDataDir), - }) - } - return out -} - -func resolveUserDataPath(userDataRoot string, raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if filepath.IsAbs(raw) { - return filepath.Clean(raw) - } - return filepath.Join(userDataRoot, raw) -} - -func openQueryDB(dbPath string) (*sql.DB, error) { - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, err - } - if err := db.Ping(); err != nil { - _ = db.Close() - return nil, err - } - return db, nil -} - -func backupDatabaseFiles(dbPath string, now time.Time) (string, error) { - dataRoot := filepath.Dir(dbPath) - backupDir := filepath.Join(dataRoot, "recovery-backups", now.Format("20060102-150405")) - if err := os.MkdirAll(backupDir, 0755); err != nil { - return "", fmt.Errorf("create backup dir: %w", err) - } - - for _, src := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { - if !fileExists(src) { - continue - } - dst := filepath.Join(backupDir, filepath.Base(src)) - if err := copyFile(src, dst); err != nil { - return "", fmt.Errorf("backup %s: %w", src, err) - } - } - - return backupDir, nil -} - -func inspectUserDataDir(dirPath string, currentCoreBinaryPath string) candidateInspection { - inspection := candidateInspection{} - - markers := make([]string, 0, 4) - for _, marker := range []string{"Local State", "Default", "Last Browser", "Last Version"} { - if fileExists(filepath.Join(dirPath, marker)) { - markers = append(markers, marker) - } - } - inspection.Markers = markers - inspection.LooksLikeBrowserData = len(markers) > 0 - if !inspection.LooksLikeBrowserData { - return inspection - } - - if raw, err := os.ReadFile(filepath.Join(dirPath, "Last Browser")); err == nil { - inspection.LastBrowser = decodePossiblyUTF16(raw) - } - if raw, err := os.ReadFile(filepath.Join(dirPath, "Last Version")); err == nil { - inspection.LastVersion = strings.TrimSpace(string(raw)) - } - - if fileExists(filepath.Join(dirPath, "Local State.bad")) { - inspection.Risky = true - inspection.RiskReasons = append(inspection.RiskReasons, "Local State.bad exists") - } - if inspection.LastBrowser != "" && currentCoreBinaryPath != "" { - if normalizePath(inspection.LastBrowser) != normalizePath(currentCoreBinaryPath) { - inspection.Risky = true - inspection.RiskReasons = append(inspection.RiskReasons, fmt.Sprintf("Last Browser points to %s", inspection.LastBrowser)) - } - } - - return inspection -} - -func createRepairCopy(userDataRoot, dirName, sourcePath string) (string, string, error) { - targetDirName := uniqueRepairDirName(userDataRoot, dirName) - targetPath := filepath.Join(userDataRoot, targetDirName) - if err := copyDirFiltered(sourcePath, targetPath); err != nil { - return "", "", err - } - return targetDirName, targetPath, nil -} - -func predictedRepairDirName(dirName string, now time.Time) string { - return fmt.Sprintf("%s__repair_%s", dirName, now.Format("20060102-150405")) -} - -func uniqueRepairDirName(userDataRoot, dirName string) string { - base := predictedRepairDirName(dirName, time.Now()) - target := filepath.Join(userDataRoot, base) - if !fileExists(target) { - return base - } - for i := 1; ; i++ { - candidate := fmt.Sprintf("%s_%02d", base, i) - if !fileExists(filepath.Join(userDataRoot, candidate)) { - return candidate - } - } -} - -func copyDirFiltered(src, dst string) error { - 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 { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - if rel == "." { - return nil - } - - if shouldSkipRepairPath(rel, d.IsDir()) { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - target := filepath.Join(dst, rel) - if d.IsDir() { - return os.MkdirAll(target, 0755) - } - - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - return copyFile(path, target) - }) -} - -func shouldSkipRepairPath(rel string, isDir bool) bool { - clean := filepath.ToSlash(strings.TrimSpace(rel)) - base := strings.ToLower(filepath.Base(clean)) - - if strings.HasPrefix(base, "singleton") { - return true - } - if strings.HasSuffix(base, ".tmp") { - return true - } - if _, ok := volatileFileNames[base]; ok { - return true - } - - if !isDir { - return false - } - - if _, ok := volatileDirNames[base]; ok { - return true - } - - parent := strings.ToLower(filepath.Base(filepath.Dir(clean))) - if parent == "default" { - if _, ok := volatileDirNames[base]; ok { - return true - } - } - - return false -} - -func buildProfileName(prefix, dirName string) string { - name := strings.TrimSpace(dirName) - if isUUIDLike(name) { - name = name[:8] - } - prefix = strings.TrimSpace(prefix) - if prefix == "" { - return name - } - return fmt.Sprintf("%s-%s", prefix, name) -} - -func isUUIDLike(value string) bool { - _, err := uuid.Parse(strings.TrimSpace(value)) - return err == nil -} - -func writeReport(report *recoveryReport, now time.Time) (string, error) { - if report == nil { - return "", fmt.Errorf("report is nil") - } - - reportDir := filepath.Join(report.UserDataRoot, "recovery-reports") - if err := os.MkdirAll(reportDir, 0755); err != nil { - return "", err - } - reportPath := filepath.Join(reportDir, fmt.Sprintf("profile-recover-%s.json", now.Format("20060102-150405"))) - data, err := json.MarshalIndent(report, "", " ") - if err != nil { - return "", err - } - if err := os.WriteFile(reportPath, data, 0644); err != nil { - return "", err - } - return reportPath, nil -} - -func printSummary(report *recoveryReport) { - fmt.Printf("AppRoot: %s\n", report.AppRoot) - fmt.Printf("DBPath: %s\n", report.DBPath) - fmt.Printf("UserDataRoot: %s\n", report.UserDataRoot) - mode := "preview" - if report.Apply { - mode = "apply" - } - fmt.Printf("Mode: %s\n", mode) - if report.SelectedCore.CoreID != "" || report.SelectedCore.CoreName != "" { - fmt.Printf("SelectedCore: %s (%s)\n", report.SelectedCore.CoreName, report.SelectedCore.CoreID) - } - if report.BackupDir != "" { - fmt.Printf("BackupDir: %s\n", report.BackupDir) - } - if report.ReportPath != "" { - fmt.Printf("Report: %s\n", report.ReportPath) - } - fmt.Printf("Scanned=%d Candidates=%d Existing=%d Restored=%d RepairCopies=%d Skipped=%d Warnings=%d\n", - report.Summary.Scanned, - report.Summary.Candidates, - report.Summary.Existing, - report.Summary.Restored, - report.Summary.RepairCopies, - report.Summary.Skipped, - report.Summary.Warnings, - ) - for _, entry := range report.Entries { - fmt.Printf("- [%s] %s", entry.Action, entry.DirName) - if entry.RestoredProfileName != "" { - fmt.Printf(" -> %s", entry.RestoredProfileName) - } - if entry.ExistingProfileName != "" { - fmt.Printf(" -> %s", entry.ExistingProfileName) - } - if entry.Reason != "" { - fmt.Printf(" (%s)", entry.Reason) - } - fmt.Println() - } - if len(report.Warnings) > 0 { - fmt.Println("Warnings:") - for _, warning := range report.Warnings { - fmt.Printf(" - %s\n", warning) - } - } -} - -func decodePossiblyUTF16(raw []byte) string { - if len(raw) == 0 { - return "" - } - if len(raw) >= 2 && len(raw)%2 == 0 { - zeros := 0 - for i := 1; i < len(raw); i += 2 { - if raw[i] == 0 { - zeros++ - } - } - if zeros >= len(raw)/4 { - u16 := make([]uint16, 0, len(raw)/2) - for i := 0; i+1 < len(raw); i += 2 { - u16 = append(u16, binary.LittleEndian.Uint16(raw[i:i+2])) - } - return strings.TrimSpace(string(utf16.Decode(u16))) - } - } - return strings.TrimSpace(string(raw)) -} - -func normalizeRoot(root string) string { - root = strings.TrimSpace(root) - if root == "" { - root = "." - } - abs, err := filepath.Abs(root) - if err != nil { - return filepath.Clean(root) - } - return filepath.Clean(abs) -} - -func normalizePath(p string) string { - p = strings.TrimSpace(p) - if p == "" { - return "" - } - if abs, err := filepath.Abs(p); err == nil { - p = abs - } - return strings.ToLower(filepath.Clean(p)) -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func copyFile(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 - } - - out, err := os.OpenFile(dst, 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() - return err - } - return out.Close() -} diff --git a/backend/cmd/profile-recover/profile_store.go b/backend/cmd/profile-recover/profile_store.go new file mode 100644 index 00000000..1fdec4b0 --- /dev/null +++ b/backend/cmd/profile-recover/profile_store.go @@ -0,0 +1,105 @@ +package main + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/database" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func loadExistingProfiles(dbPath string, userDataRoot string, apply bool) ([]existingProfile, *sql.DB, *database.DB, error) { + dbExists := fileExists(dbPath) + if !dbExists && !apply { + return nil, nil, nil, nil + } + + if apply { + handle, err := database.NewDB(dbPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("open database: %w", err) + } + list, err := browser.NewSQLiteProfileDAO(handle.GetConn()).List() + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + return nil, nil, handle, nil + } + _ = handle.Close() + return nil, nil, nil, fmt.Errorf("load existing profiles: %w", err) + } + return toExistingProfiles(list, userDataRoot), nil, handle, nil + } + + db, err := openQueryDB(dbPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("open database: %w", err) + } + list, err := browser.NewSQLiteProfileDAO(db).List() + if err != nil { + _ = db.Close() + return nil, nil, nil, fmt.Errorf("load existing profiles: %w", err) + } + return toExistingProfiles(list, userDataRoot), db, nil, nil +} + +func toExistingProfiles(items []*browser.Profile, userDataRoot string) []existingProfile { + out := make([]existingProfile, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + out = append(out, existingProfile{ + ProfileID: item.ProfileId, + ProfileName: item.ProfileName, + UserDataDir: item.UserDataDir, + ResolvedPath: resolveUserDataPath(userDataRoot, item.UserDataDir), + }) + } + return out +} + +func resolveUserDataPath(userDataRoot string, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if filepath.IsAbs(raw) { + return filepath.Clean(raw) + } + return filepath.Join(userDataRoot, raw) +} + +func openQueryDB(dbPath string) (*sql.DB, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +func backupDatabaseFiles(dbPath string, now time.Time) (string, error) { + dataRoot := filepath.Dir(dbPath) + backupDir := filepath.Join(dataRoot, "recovery-backups", now.Format("20060102-150405")) + if err := os.MkdirAll(backupDir, 0o755); err != nil { + return "", fmt.Errorf("create backup dir: %w", err) + } + + for _, src := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { + if !fileExists(src) { + continue + } + dst := filepath.Join(backupDir, filepath.Base(src)) + if err := copyFile(src, dst); err != nil { + return "", fmt.Errorf("backup %s: %w", src, err) + } + } + + return backupDir, nil +} diff --git a/backend/cmd/profile-recover/repair_filesystem.go b/backend/cmd/profile-recover/repair_filesystem.go new file mode 100644 index 00000000..3883bd95 --- /dev/null +++ b/backend/cmd/profile-recover/repair_filesystem.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/binary" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf16" + + "github.com/google/uuid" +) + +var volatileDirNames = map[string]struct{}{ + "browsermetrics": {}, + "deferredbrowsermetrics": {}, + "graphitedawncache": {}, + "grshadercache": {}, + "shadercache": {}, + "component_crx_cache": {}, + "extensions_crx_cache": {}, + "cache": {}, + "code cache": {}, + "gpucache": {}, +} + +var volatileFileNames = map[string]struct{}{ + "lock": {}, + "local state.bad": {}, +} + +func inspectUserDataDir(dirPath string, currentCoreBinaryPath string) candidateInspection { + inspection := candidateInspection{} + + markers := make([]string, 0, 4) + for _, marker := range []string{"Local State", "Default", "Last Browser", "Last Version"} { + if fileExists(filepath.Join(dirPath, marker)) { + markers = append(markers, marker) + } + } + inspection.Markers = markers + inspection.LooksLikeBrowserData = len(markers) > 0 + if !inspection.LooksLikeBrowserData { + return inspection + } + + if raw, err := os.ReadFile(filepath.Join(dirPath, "Last Browser")); err == nil { + inspection.LastBrowser = decodePossiblyUTF16(raw) + } + if raw, err := os.ReadFile(filepath.Join(dirPath, "Last Version")); err == nil { + inspection.LastVersion = strings.TrimSpace(string(raw)) + } + + if fileExists(filepath.Join(dirPath, "Local State.bad")) { + inspection.Risky = true + inspection.RiskReasons = append(inspection.RiskReasons, "Local State.bad exists") + } + if inspection.LastBrowser != "" && currentCoreBinaryPath != "" { + if normalizePath(inspection.LastBrowser) != normalizePath(currentCoreBinaryPath) { + inspection.Risky = true + inspection.RiskReasons = append(inspection.RiskReasons, fmt.Sprintf("Last Browser points to %s", inspection.LastBrowser)) + } + } + + return inspection +} + +func createRepairCopy(userDataRoot, dirName, sourcePath string) (string, string, error) { + targetDirName := uniqueRepairDirName(userDataRoot, dirName) + targetPath := filepath.Join(userDataRoot, targetDirName) + if err := copyDirFiltered(sourcePath, targetPath); err != nil { + return "", "", err + } + return targetDirName, targetPath, nil +} + +func predictedRepairDirName(dirName string, now time.Time) string { + return fmt.Sprintf("%s__repair_%s", dirName, now.Format("20060102-150405")) +} + +func uniqueRepairDirName(userDataRoot, dirName string) string { + base := predictedRepairDirName(dirName, time.Now()) + target := filepath.Join(userDataRoot, base) + if !fileExists(target) { + return base + } + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s_%02d", base, i) + if !fileExists(filepath.Join(userDataRoot, candidate)) { + return candidate + } + } +} + +func copyDirFiltered(src, dst string) error { + if err := os.MkdirAll(dst, 0o755); 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 { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + + if shouldSkipRepairPath(rel, d.IsDir()) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return copyFile(path, target) + }) +} + +func shouldSkipRepairPath(rel string, isDir bool) bool { + clean := filepath.ToSlash(strings.TrimSpace(rel)) + base := strings.ToLower(filepath.Base(clean)) + + if strings.HasPrefix(base, "singleton") { + return true + } + if strings.HasSuffix(base, ".tmp") { + return true + } + if _, ok := volatileFileNames[base]; ok { + return true + } + + if !isDir { + return false + } + + if _, ok := volatileDirNames[base]; ok { + return true + } + + parent := strings.ToLower(filepath.Base(filepath.Dir(clean))) + if parent == "default" { + if _, ok := volatileDirNames[base]; ok { + return true + } + } + + return false +} + +func buildProfileName(prefix, dirName string) string { + name := strings.TrimSpace(dirName) + if isUUIDLike(name) { + name = name[:8] + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return name + } + return fmt.Sprintf("%s-%s", prefix, name) +} + +func isUUIDLike(value string) bool { + _, err := uuid.Parse(strings.TrimSpace(value)) + return err == nil +} + +func decodePossiblyUTF16(raw []byte) string { + if len(raw) == 0 { + return "" + } + if len(raw) >= 2 && len(raw)%2 == 0 { + zeros := 0 + for i := 1; i < len(raw); i += 2 { + if raw[i] == 0 { + zeros++ + } + } + if zeros >= len(raw)/4 { + u16 := make([]uint16, 0, len(raw)/2) + for i := 0; i+1 < len(raw); i += 2 { + u16 = append(u16, binary.LittleEndian.Uint16(raw[i:i+2])) + } + return strings.TrimSpace(string(utf16.Decode(u16))) + } + } + return strings.TrimSpace(string(raw)) +} + +func normalizeRoot(root string) string { + root = strings.TrimSpace(root) + if root == "" { + root = "." + } + abs, err := filepath.Abs(root) + if err != nil { + return filepath.Clean(root) + } + return filepath.Clean(abs) +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + return strings.ToLower(filepath.Clean(p)) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func copyFile(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 + } + + out, err := os.OpenFile(dst, 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() + return err + } + return out.Close() +} diff --git a/backend/cmd/profile-recover/report_output.go b/backend/cmd/profile-recover/report_output.go new file mode 100644 index 00000000..80d4183a --- /dev/null +++ b/backend/cmd/profile-recover/report_output.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +func writeReport(report *recoveryReport, now time.Time) (string, error) { + if report == nil { + return "", fmt.Errorf("report is nil") + } + + reportDir := filepath.Join(report.UserDataRoot, "recovery-reports") + if err := os.MkdirAll(reportDir, 0o755); err != nil { + return "", err + } + reportPath := filepath.Join(reportDir, fmt.Sprintf("profile-recover-%s.json", now.Format("20060102-150405"))) + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", err + } + if err := os.WriteFile(reportPath, data, 0o644); err != nil { + return "", err + } + return reportPath, nil +} + +func printSummary(report *recoveryReport) { + fmt.Printf("AppRoot: %s\n", report.AppRoot) + fmt.Printf("DBPath: %s\n", report.DBPath) + fmt.Printf("UserDataRoot: %s\n", report.UserDataRoot) + mode := "preview" + if report.Apply { + mode = "apply" + } + fmt.Printf("Mode: %s\n", mode) + if report.SelectedCore.CoreID != "" || report.SelectedCore.CoreName != "" { + fmt.Printf("SelectedCore: %s (%s)\n", report.SelectedCore.CoreName, report.SelectedCore.CoreID) + } + if report.BackupDir != "" { + fmt.Printf("BackupDir: %s\n", report.BackupDir) + } + if report.ReportPath != "" { + fmt.Printf("Report: %s\n", report.ReportPath) + } + fmt.Printf("Scanned=%d Candidates=%d Existing=%d Restored=%d RepairCopies=%d Skipped=%d Warnings=%d\n", + report.Summary.Scanned, + report.Summary.Candidates, + report.Summary.Existing, + report.Summary.Restored, + report.Summary.RepairCopies, + report.Summary.Skipped, + report.Summary.Warnings, + ) + for _, entry := range report.Entries { + fmt.Printf("- [%s] %s", entry.Action, entry.DirName) + if entry.RestoredProfileName != "" { + fmt.Printf(" -> %s", entry.RestoredProfileName) + } + if entry.ExistingProfileName != "" { + fmt.Printf(" -> %s", entry.ExistingProfileName) + } + if entry.Reason != "" { + fmt.Printf(" (%s)", entry.Reason) + } + fmt.Println() + } + if len(report.Warnings) > 0 { + fmt.Println("Warnings:") + for _, warning := range report.Warnings { + fmt.Printf(" - %s\n", warning) + } + } +} diff --git a/backend/internal/apppath/apppath.go b/backend/internal/apppath/apppath.go index f4577146..1bc797dd 100644 --- a/backend/internal/apppath/apppath.go +++ b/backend/internal/apppath/apppath.go @@ -164,11 +164,11 @@ func userStateRootForOS(goos, fallback string) string { if base := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); base != "" { return filepath.Join(base, appStateDirName) } - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + if home := configuredHomeDir(); home != "" { return filepath.Join(home, ".local", "share", appStateDirName) } case "darwin": - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + if home := configuredHomeDir(); home != "" { return filepath.Join(home, "Library", "Application Support", appStateDirName) } } @@ -178,6 +178,16 @@ func userStateRootForOS(goos, fallback string) string { return fallback } +func configuredHomeDir() string { + if home := strings.TrimSpace(os.Getenv("HOME")); home != "" { + return home + } + if home, err := os.UserHomeDir(); err == nil { + return strings.TrimSpace(home) + } + return "" +} + func isMacAppBundleRoot(dir string) bool { clean := strings.TrimSuffix(filepath.ToSlash(filepath.Clean(dir)), "/") lower := strings.ToLower(clean) diff --git a/backend/internal/automation/assets/runner.cjs b/backend/internal/automation/assets/runner.cjs new file mode 100644 index 00000000..cbf9cc5e --- /dev/null +++ b/backend/internal/automation/assets/runner.cjs @@ -0,0 +1,504 @@ +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); +const util = require('util'); +const { pathToFileURL } = require('url'); + +const ALLOWED_WAIT_UNTIL = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); + +function normalizeTimeout(value, fallback) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.round(parsed); + } + return fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function writeStream(stream, text) { + return new Promise((resolve, reject) => { + stream.write(text, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function closeBrowserConnection(browser) { + if (!browser || typeof browser.close !== 'function') { + return; + } + await browser.close({ reason: 'automation task finished' }).catch(() => {}); +} + +function normalizeEndpointCandidate(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return ''; + } + + try { + const parsed = new URL(normalized); + if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) { + return ''; + } + if (parsed.port === '0') { + return ''; + } + if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (!parsed.pathname || parsed.pathname === '/') && !parsed.search && !parsed.hash) { + return parsed.origin; + } + return parsed.toString(); + } catch { + return ''; + } +} + +function buildConnectEndpoints(payload, session) { + const candidates = []; + const seen = new Set(); + + const pushCandidate = (value) => { + const endpoint = normalizeEndpointCandidate(value); + if (!endpoint || seen.has(endpoint)) { + return; + } + seen.add(endpoint); + candidates.push(endpoint); + }; + + pushCandidate(session && session.cdpUrl); + + const debugPort = Number(session && session.debugPort); + if (Number.isFinite(debugPort) && debugPort > 0) { + pushCandidate(`http://127.0.0.1:${Math.round(debugPort)}`); + } + + pushCandidate(payload && payload.launchBaseUrl); + return candidates; +} + +function normalizePathUnderRoot(rootDir, targetName) { + const normalizedName = String(targetName || '').trim(); + const resolvedRoot = path.resolve(String(rootDir || '')); + if (!resolvedRoot) { + throw new Error('artifactDir is required'); + } + + const candidate = normalizedName ? path.resolve(resolvedRoot, normalizedName) : resolvedRoot; + if (candidate !== resolvedRoot && !candidate.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error('artifact path escapes root directory'); + } + return candidate; +} + +async function requestJSON(method, requestURL, body, headers = {}) { + const target = new URL(requestURL); + const transport = target.protocol === 'https:' ? https : http; + const payload = body == null ? '' : JSON.stringify(body); + + return await new Promise((resolve, reject) => { + const req = transport.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + method, + headers: { + Accept: 'application/json', + ...(payload + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + } + : {}), + ...headers, + }, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const rawText = Buffer.concat(chunks).toString('utf8').trim(); + let responseBody = {}; + if (rawText) { + try { + responseBody = JSON.parse(rawText); + } catch { + responseBody = { rawBody: rawText }; + } + } + resolve({ + status: res.statusCode || 0, + body: responseBody, + }); + }); + } + ); + + req.on('error', reject); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +function inspectValue(value) { + return util.inspect(value, { + depth: 4, + breakLength: 120, + maxArrayLength: 20, + compact: false, + }); +} + +function toSerializable(value, seen = new WeakSet()) { + if (value == null) { + return value; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + if (Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + if (Array.isArray(value)) { + return value.map((item) => toSerializable(item, seen)); + } + if (typeof value === 'function') { + return `[Function ${value.name || 'anonymous'}]`; + } + if (typeof value !== 'object') { + return inspectValue(value); + } + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + + const prototype = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null) { + const result = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = toSerializable(entry, seen); + } + return result; + } + + return inspectValue(value); +} + +function buildLaunchRequestBody(defaultSelector, options) { + const launchOptions = options && typeof options === 'object' ? options : {}; + const body = {}; + + for (const key of [ + 'code', + 'key', + 'profileId', + 'profileName', + 'keyword', + 'keywords', + 'tag', + 'tags', + 'groupId', + 'matchMode', + 'launchArgs', + 'startUrls', + 'skipDefaultStartUrls', + ]) { + if (Object.prototype.hasOwnProperty.call(launchOptions, key)) { + body[key] = launchOptions[key]; + } + } + + const selector = + launchOptions.selector && + typeof launchOptions.selector === 'object' && + !Array.isArray(launchOptions.selector) + ? launchOptions.selector + : defaultSelector; + if (selector && typeof selector === 'object' && !Array.isArray(selector) && Object.keys(selector).length > 0) { + body.selector = selector; + } + + return body; +} + +async function loadScriptModule(scriptPath) { + const resolvedPath = path.resolve(String(scriptPath || '')); + if (!resolvedPath) { + throw new Error('scriptPath is required'); + } + + let requiredModule = null; + let requireError = null; + try { + requiredModule = require(resolvedPath); + } catch (error) { + requireError = error; + } + + const imported = async () => { + const moduleURL = pathToFileURL(resolvedPath).href; + return await import(`${moduleURL}?t=${Date.now()}`); + }; + + if (requiredModule && typeof requiredModule.run === 'function') { + return requiredModule; + } + if (typeof requiredModule === 'function') { + return { run: requiredModule }; + } + if (requiredModule && requiredModule.default && typeof requiredModule.default.run === 'function') { + return requiredModule.default; + } + + try { + const importedModule = await imported(); + if (importedModule && typeof importedModule.run === 'function') { + return importedModule; + } + if (importedModule && typeof importedModule.default === 'function') { + return { run: importedModule.default }; + } + if ( + importedModule && + importedModule.default && + typeof importedModule.default.run === 'function' + ) { + return importedModule.default; + } + } catch (importError) { + if (requireError) { + throw requireError; + } + throw importError; + } + + if (requireError) { + throw requireError; + } + throw new Error('script must export run()'); +} + +async function runScriptTask(payload, chromium) { + const scriptModule = await loadScriptModule(payload.scriptPath); + if (!scriptModule || typeof scriptModule.run !== 'function') { + throw new Error('script must export run()'); + } + + const logs = []; + const artifacts = []; + const connectedBrowsers = new Set(); + const selector = payload.selector && typeof payload.selector === 'object' ? payload.selector : {}; + const params = payload.params && typeof payload.params === 'object' ? payload.params : {}; + const timeout = normalizeTimeout(params.timeoutMs, 30000); + const startedAt = new Date().toISOString(); + + const log = (...entries) => { + logs.push({ + time: new Date().toISOString(), + values: entries.map((entry) => toSerializable(entry)), + }); + }; + + const artifact = (name) => { + const fileName = String(name || '').trim() || `artifact-${Date.now()}`; + const targetPath = normalizePathUnderRoot(payload.artifactDir, fileName); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + artifacts.push(targetPath); + return targetPath; + }; + + const launchHeaders = {}; + if (payload.launchAuthHeader && payload.launchAuthValue) { + launchHeaders[payload.launchAuthHeader] = payload.launchAuthValue; + } + + const launch = async (options = {}) => { + const body = buildLaunchRequestBody(selector, options); + + const response = await requestJSON( + 'POST', + `${String(payload.launchBaseUrl || '').replace(/\/$/, '')}/api/launch`, + body, + launchHeaders + ); + + if (!(response.status >= 200 && response.status < 300) || response.body.ok === false) { + const errorText = + (response.body && response.body.error && String(response.body.error).trim()) || + `launch api returned http ${response.status}`; + throw new Error(errorText); + } + + return response.body; + }; + + const connect = async (session = {}) => { + const endpoints = buildConnectEndpoints(payload, session); + if (endpoints.length === 0) { + throw new Error( + `launch session does not contain a valid cdp endpoint (cdpUrl=${String( + session && session.cdpUrl ? session.cdpUrl : '' + )}, debugPort=${String(session && session.debugPort ? session.debugPort : '')})` + ); + } + + const deadline = Date.now() + timeout; + let lastError = null; + + while (Date.now() <= deadline) { + for (const endpoint of endpoints) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + + try { + const browser = await chromium.connectOverCDP(endpoint, { + timeout: Math.max(1000, Math.min(remaining, timeout)), + }); + connectedBrowsers.add(browser); + const context = browser.contexts()[0] || null; + const page = context && context.pages().length > 0 ? context.pages()[0] : null; + return { + browser, + context, + page, + session: { + ...session, + cdpUrl: endpoint, + }, + }; + } catch (error) { + lastError = error; + } + } + + if (Date.now() >= deadline) { + break; + } + + await sleep(Math.min(500, Math.max(100, deadline - Date.now()))); + } + + const lastMessage = + lastError && lastError.message ? lastError.message : String(lastError || 'unknown error'); + throw new Error( + `cdp endpoint is not ready after ${timeout} ms (endpoints: ${endpoints.join(', ')}): ${lastMessage}` + ); + }; + + const api = { + chromium, + launch, + connect, + selector, + params, + log, + artifact, + artifactsDir: payload.artifactDir || '', + }; + + try { + const rawResult = await scriptModule.run(api); + const normalizedResult = toSerializable(rawResult); + const ok = !(normalizedResult && typeof normalizedResult === 'object' && normalizedResult.ok === false); + const summary = + normalizedResult && + typeof normalizedResult === 'object' && + typeof normalizedResult.summary === 'string' + ? normalizedResult.summary.trim() + : ok + ? '脚本执行完成' + : '脚本执行失败'; + const error = + normalizedResult && + typeof normalizedResult === 'object' && + typeof normalizedResult.error === 'string' + ? normalizedResult.error.trim() + : ''; + + return { + ok, + summary, + error, + title: + normalizedResult && + typeof normalizedResult === 'object' && + typeof normalizedResult.title === 'string' + ? normalizedResult.title + : '', + url: + normalizedResult && + typeof normalizedResult === 'object' && + typeof normalizedResult.url === 'string' + ? normalizedResult.url + : '', + startedAt, + finishedAt: new Date().toISOString(), + isolatedPage: false, + logs, + artifacts: Array.from(new Set(artifacts)), + result: normalizedResult, + }; + } finally { + await Promise.all(Array.from(connectedBrowsers, (browser) => closeBrowserConnection(browser))); + } +} + +async function main() { + const payloadPath = process.argv[2]; + if (!payloadPath) { + throw new Error('payload path is required'); + } + + const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); + const runtimeDir = path.resolve(String(payload.runtimeDir || '')); + if (!runtimeDir) { + throw new Error('runtimeDir is required'); + } + + const { chromium } = require(path.join(runtimeDir, 'node_modules', 'playwright-core')); + const taskType = String(payload.taskType || 'script').trim() || 'script'; + if (taskType !== 'script') { + throw new Error(`unsupported automation task type: ${taskType}`); + } + + const result = await runScriptTask(payload, chromium); + await writeStream(process.stdout, JSON.stringify(result)); + process.exit(0); +} + +main().catch(async (error) => { + const message = error && error.message ? error.message : String(error); + try { + await writeStream(process.stderr, message); + } finally { + process.exit(1); + } +}); diff --git a/backend/internal/automation/default_scripts.go b/backend/internal/automation/default_scripts.go new file mode 100644 index 00000000..db9a0154 --- /dev/null +++ b/backend/internal/automation/default_scripts.go @@ -0,0 +1,805 @@ +package automation + +const DualInstanceRuntimeScriptID = "dual-instance-runtime-switch" + +func DefaultScripts() []ScriptRecord { + return []ScriptRecord{ + { + ID: DualInstanceRuntimeScriptID, + Name: "双实例启动与 Runtime 切换", + Description: "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + Tags: []string{"Launch API", "OpenClaw", "双实例"}, + ParamsText: `{ + "browsers": [ + { + "code": "BUYER_001", + "skipDefaultStartUrls": true, + "startUrls": ["https://finance.sina.com.cn/"] + }, + { + "code": "BUYER_002", + "skipDefaultStartUrls": true, + "startUrls": ["https://map.baidu.com/"] + } + ], + "timeoutMs": 45000 +}`, + ScriptText: `export async function run({ baseUrl, apiKey, params, log }) { + const normalizeCode = (value, fallback) => + String(value || fallback || '').trim().toUpperCase() + const normalizeStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => String(item || '').trim()) + .filter(Boolean) + : [] + const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { + const raw = value && typeof value === 'object' ? value : {} + const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) + if (!code) { + return null + } + const startUrls = normalizeStringArray(raw.startUrls) + const fallbackUrls = normalizeStringArray(fallbackStartUrls) + const launchArgs = normalizeStringArray(raw.launchArgs) + + return { + code, + skipDefaultStartUrls: + raw.skipDefaultStartUrls !== undefined + ? raw.skipDefaultStartUrls !== false + : defaultSkip, + startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, + launchArgs, + } + } + + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(1000, Math.round(Number(params.timeoutMs))) + : 45000 + const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false + + let browsers = Array.isArray(params.browsers) + ? params.browsers + .map((item, index) => + normalizeBrowserInput( + item, + ['BUYER_001', 'BUYER_002'][index] || '', + ['https://finance.sina.com.cn/', 'https://map.baidu.com/'][index] || [], + defaultSkipDefaultStartUrls, + ), + ) + .filter(Boolean) + : [] + + if (browsers.length === 0) { + browsers = [ + normalizeBrowserInput( + { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + 'BUYER_001', + ['https://finance.sina.com.cn/'], + defaultSkipDefaultStartUrls, + ), + normalizeBrowserInput( + { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + 'BUYER_002', + ['https://map.baidu.com/'], + defaultSkipDefaultStartUrls, + ), + ].filter(Boolean) + } + + if (browsers.length === 0) { + throw new Error('params.browsers 不能为空') + } + + const headers = { + 'Content-Type': 'application/json', + ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), + } + + const post = async (path, payload) => { + const response = await fetch(baseUrl + path, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }) + const text = await response.text() + let body = text + try { + body = text ? JSON.parse(text) : null + } catch { + body = text + } + if (!response.ok) { + throw new Error(path + ' failed: ' + response.status + ' ' + text) + } + return body + } + + const sessions = [] + + for (const browser of browsers) { + const sessionResult = await post('/api/runtime/session', { + selector: { code: browser.code, matchMode: 'unique' }, + skipDefaultStartUrls: browser.skipDefaultStartUrls, + ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), + ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), + timeoutMs, + }) + + sessions.push(sessionResult) + } + + const browserCodes = browsers.map((item) => item.code) + log('browserCodes', browserCodes) + + return { + ok: true, + summary: browserCodes.length + ' 个浏览器已就绪:' + browserCodes.join(' / '), + browserCodes, + sessions, + } +}`, + Notes: "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。", + Source: ScriptSource{ + Type: "builtin", + URI: "repo://backend/internal/automation/default_scripts.go", + Ref: "HEAD", + Path: DualInstanceRuntimeScriptID, + }, + }, + { + ID: "news-query-txt", + Name: "查询新闻并写 TXT", + Description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + Tags: []string{"Playwright", "新闻", "TXT"}, + ParamsText: `{ + "keyword": "OpenAI", + "limit": 10, + "timeRange": "week", + "outputFileName": "openai-news.txt", + "timeoutMs": 30000, + "waitAfterLoadMs": 1500, + "captureScreenshot": false +}`, + ScriptText: `const fs = require('fs') + +const DEFAULT_EXCLUDED_DOMAINS = [ + 'zhihu.com', + 'baidu.com', + 'qq.com', + '36kr.com', + 'apifox.com', + 'chatgpt-chinese.com', + 'openwebui.cn', + 'open-openai.com', + 'xiniushu.com', + 'reddit.com', + 'quora.com', + 'tieba.baidu.com', + 'weibo.com', + 'x.com', + 'twitter.com', + 'youtube.com', + 'bilibili.com', + 'douyin.com', + 'xiaohongshu.com', +] + +function normalizeInt(value, fallback, min, max) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + + const rounded = Math.round(parsed) + if (rounded < min) { + return min + } + if (rounded > max) { + return max + } + return rounded +} + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeDomainList(value) { + if (!Array.isArray(value)) { + return [] + } + + const deduped = new Set() + for (const item of value) { + const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase() + if (normalized) { + deduped.add(normalized) + } + } + return Array.from(deduped) +} + +function buildDefaultQuery(keyword) { + const normalizedKeyword = normalizeText(keyword) || 'OpenAI' + if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { + return normalizedKeyword + ' 新闻' + } + return normalizedKeyword + ' news' +} + +function buildFallbackQueries(keyword, baseQuery) { + const normalizedKeyword = normalizeText(keyword) || 'OpenAI' + const normalizedBaseQuery = normalizeText(baseQuery) + const candidates = [ + normalizedBaseQuery, + ] + + if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { + candidates.push(normalizedKeyword + ' 最新新闻') + } else { + candidates.push(normalizedKeyword + ' latest news') + } + + const deduped = new Set() + for (const item of candidates) { + const normalized = normalizeText(item) + if (normalized) { + deduped.add(normalized) + } + } + return Array.from(deduped) +} + +function buildSearchQuery(baseQuery, excludedDomains) { + const normalizedBaseQuery = normalizeText(baseQuery) + const normalizedDomains = normalizeDomainList(excludedDomains) + const parts = [normalizedBaseQuery] + + for (const domain of normalizedDomains) { + parts.push('-site:' + domain) + } + + return parts.filter(Boolean).join(' ') +} + +function mapTimeRangeToBingFilter(value) { + switch (normalizeText(value).toLowerCase()) { + case 'day': + case '24h': + case 'today': + return 'ex1:"ez1"' + case 'week': + return 'ex1:"ez2"' + case 'month': + return 'ex1:"ez3"' + default: + return '' + } +} + +function buildSearchURL(query, timeRange, firstResultIndex) { + const searchParams = new URLSearchParams({ q: query }) + const filter = mapTimeRangeToBingFilter(timeRange) + if (filter) { + searchParams.set('filters', filter) + } + if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) { + searchParams.set('first', String(firstResultIndex)) + } + return 'https://www.bing.com/search?' + searchParams.toString() +} + +function splitSnippet(snippet) { + const normalized = normalizeText(snippet) + if (!normalized) { + return { publishedAt: '', summary: '' } + } + + const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/) + if ( + match && + /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1]) + ) { + return { + publishedAt: normalizeText(match[1]), + summary: normalizeText(match[2]), + } + } + + return { + publishedAt: '', + summary: normalized, + } +} + +function parseHostname(rawUrl) { + const normalized = normalizeText(rawUrl) + if (!normalized) { + return '' + } + + try { + return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase() + } catch { + return '' + } +} + +function parsePathname(rawUrl) { + const normalized = normalizeText(rawUrl) + if (!normalized) { + return '' + } + + try { + const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase() + if (!pathname) { + return '' + } + return pathname === '/' ? pathname : pathname.replace(/\/$/, '') + } catch { + return '' + } +} + +function looksLikeQuestionTitle(title) { + const normalized = normalizeText(title) + if (!normalized) { + return false + } + + if (/[??]/.test(normalized)) { + return true + } + + return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized) +} + +function looksLikeAggregateText(text) { + const normalized = normalizeText(text).toLowerCase() + if (!normalized) { + return false + } + + return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized) +} + +function looksLikeListingPath(pathname) { + const normalized = normalizeText(pathname).toLowerCase() + if (!normalized || normalized === '/') { + return false + } + + if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) { + return true + } + + if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) { + return true + } + + return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized) +} + +function looksLikeListingText(text) { + const normalized = normalizeText(text).toLowerCase() + if (!normalized) { + return false + } + + return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized) +} + +function isBlockedHostname(hostname) { + const normalized = normalizeText(hostname).toLowerCase() + if (!normalized) { + return false + } + + const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS + const blockedKeywords = [ + 'aitrack', + 'aitoolly', + 'aiflashreport', + 'llm-stats', + 'opentools', + ] + + if (blockedSuffixes.some(function (suffix) { + return normalized === suffix || normalized.endsWith('.' + suffix) + })) { + return true + } + + return blockedKeywords.some(function (keyword) { + return normalized.includes(keyword) + }) +} + +function evaluateNewsItem(item) { + const hostname = parseHostname(item.url) + const pathname = parsePathname(item.url) + const summary = normalizeText(item.summary) + const source = normalizeText(item.source) + const reasons = [] + + if (!normalizeText(item.url)) { + reasons.push('missing-url') + } + if (!hostname) { + reasons.push('invalid-url') + } + if (hostname && isBlockedHostname(hostname)) { + reasons.push('blocked-host') + } + if (!source) { + reasons.push('missing-source') + } + if (summary.length < 20) { + reasons.push('summary-too-short') + } + if (looksLikeQuestionTitle(item.title)) { + reasons.push('question-title') + } + if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) { + reasons.push('aggregate-page') + } + if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) { + reasons.push('listing-page') + } + + return Object.assign({}, item, { + hostname: hostname, + pathname: pathname, + qualityAccepted: reasons.length === 0, + qualityReasons: reasons, + }) +} + +function formatRejectedReason(reason) { + switch (reason) { + case 'missing-url': + return '缺少链接' + case 'invalid-url': + return '链接无效' + case 'blocked-host': + return '来源站点已过滤' + case 'missing-source': + return '缺少来源' + case 'summary-too-short': + return '摘要过短' + case 'question-title': + return '标题更像问答' + case 'aggregate-page': + return '更像聚合页' + case 'listing-page': + return '更像列表页/专题页' + default: + return reason + } +} + +function formatReport(items, metadata) { + const lines = [ + '新闻抓取结果', + '查询词: ' + metadata.query, + '抓取时间: ' + metadata.generatedAt, + '搜索地址: ' + metadata.searchUrl, + '原始结果: ' + metadata.rawCount, + '通过校验: ' + items.length, + '过滤数量: ' + metadata.rejectedItems.length, + '', + ] + + for (const item of items) { + lines.push(item.rank + '. ' + item.title) + if (item.source) { + lines.push('来源: ' + item.source) + } + if (item.publishedAt) { + lines.push('时间: ' + item.publishedAt) + } + lines.push('链接: ' + item.url) + if (item.summary) { + lines.push('摘要: ' + item.summary) + } + lines.push('') + } + + if (metadata.rejectedItems.length > 0) { + lines.push('被过滤结果(最多展示 5 条)') + lines.push('') + for (const item of metadata.rejectedItems.slice(0, 5)) { + lines.push(item.rank + '. ' + item.title) + if (item.hostname) { + lines.push('站点: ' + item.hostname) + } + lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / ')) + lines.push('') + } + } + + return lines.join('\n') +} + +function pickBestAttempt(current, candidate) { + if (!current) { + return candidate + } + + if (candidate.acceptedItems.length !== current.acceptedItems.length) { + return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current + } + + if (candidate.distinctHostCount !== current.distinctHostCount) { + return candidate.distinctHostCount > current.distinctHostCount ? candidate : current + } + + if (candidate.rawItems.length !== current.rawItems.length) { + return candidate.rawItems.length > current.rawItems.length ? candidate : current + } + + return candidate +} + +module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { + const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000) + const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1500, 0, 10000) + const limit = normalizeInt(params.limit, 10, 1, 50) + const maxPages = normalizeInt(params.maxPages, 3, 1, 5) + const baseQuery = normalizeText(params.query) || buildDefaultQuery(params.keyword) + const excludedDomains = normalizeDomainList(params.excludeDomains).length > 0 + ? normalizeDomainList(params.excludeDomains) + : DEFAULT_EXCLUDED_DOMAINS + const outputFileName = normalizeText(params.outputFileName) || 'news-results.txt' + const scanLimit = Math.max(10, Math.min(20, limit * 2)) + const startUrls = Array.isArray(params.startUrls) && params.startUrls.length > 0 + ? params.startUrls + : undefined + + const session = await launch({ + selector, + startUrls, + skipDefaultStartUrls: true, + }) + + const connection = await connect(session) + const browser = connection.browser + const context = connection.context || browser.contexts()[0] + const page = await context.newPage() + const closeRunnerPage = async function () { + if (!page.isClosed()) { + await page.close().catch(function () {}) + } + } + + const searchCandidates = buildFallbackQueries(params.keyword, baseQuery) + const minAcceptedCount = Math.min(limit, Math.max(2, Math.ceil(limit * 0.2))) + const minDistinctHostCount = Math.min(3, minAcceptedCount) + let bestAttempt = null + + try { + for (const candidateQuery of searchCandidates) { + const searchQuery = buildSearchQuery(candidateQuery, excludedDomains) + const normalizedItems = [] + const seenUrls = new Set() + let scannedPageCount = 0 + let firstSearchUrl = '' + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const firstResultIndex = pageIndex * 10 + 1 + const searchUrl = buildSearchURL(searchQuery, params.timeRange, firstResultIndex) + + try { + await page.goto(searchUrl, { + waitUntil: 'domcontentloaded', + timeout, + }) + await page.waitForSelector('li.b_algo', { timeout }) + } catch (error) { + if (pageIndex > 0 && normalizedItems.length > 0) { + break + } + throw error + } + + if (waitAfterLoadMs > 0) { + await page.waitForTimeout(waitAfterLoadMs) + } + + if (!firstSearchUrl) { + firstSearchUrl = page.url() + } + + const pageItems = await page.$$eval('li.b_algo', function (nodes, maxItems) { + const clean = function (value) { + return String(value || '').replace(/\s+/g, ' ').trim() + } + + return nodes + .slice(0, maxItems) + .map(function (node) { + const titleLink = node.querySelector('h2 a') + const title = clean(titleLink && titleLink.textContent) + const url = titleLink ? titleLink.href : '' + const sourceNode = node.querySelector('.tptt') + const source = clean(sourceNode && sourceNode.textContent) + const citeNode = node.querySelector('.b_attribution cite') + const cite = clean(citeNode && citeNode.textContent) + const snippetNode = node.querySelector('.b_caption p') + const snippet = clean(snippetNode && snippetNode.textContent) + + if (!title) { + return null + } + + return { + title, + url, + source: source || cite, + snippet, + } + }) + .filter(Boolean) + }, scanLimit) + + let appendedCount = 0 + for (const item of pageItems) { + const dedupeKey = normalizeText(item.url) + if (!dedupeKey || seenUrls.has(dedupeKey)) { + continue + } + + seenUrls.add(dedupeKey) + normalizedItems.push( + evaluateNewsItem( + Object.assign( + { + rank: normalizedItems.length + 1, + }, + item, + splitSnippet(item.snippet) + ) + ) + ) + appendedCount += 1 + } + + scannedPageCount += 1 + if (appendedCount === 0 || pageItems.length < 8) { + break + } + } + + const acceptedItems = normalizedItems.filter(function (item) { + return item.qualityAccepted + }).slice(0, limit) + const rejectedItems = normalizedItems.filter(function (item) { + return !item.qualityAccepted + }) + const distinctHostCount = new Set( + acceptedItems + .map(function (item) { + return item.hostname + }) + .filter(Boolean) + ).size + + log('searchQuery', searchQuery) + log('rawItemCount', normalizedItems.length) + log('acceptedItemCount', acceptedItems.length) + log('rejectedItemCount', rejectedItems.length) + log('distinctHostCount', distinctHostCount) + log('scannedPageCount', scannedPageCount) + + bestAttempt = pickBestAttempt(bestAttempt, { + baseQuery: candidateQuery, + searchQuery: searchQuery, + searchUrl: firstSearchUrl || page.url(), + rawItems: normalizedItems, + acceptedItems: acceptedItems, + rejectedItems: rejectedItems, + distinctHostCount: distinctHostCount, + scannedPageCount: scannedPageCount, + }) + + if (acceptedItems.length >= minAcceptedCount && distinctHostCount >= minDistinctHostCount) { + break + } + } + } catch (error) { + await closeRunnerPage() + throw error + } + + if (!bestAttempt || bestAttempt.rawItems.length === 0) { + await closeRunnerPage() + throw new Error('未抓到新闻搜索结果,当前页面: ' + page.url()) + } + + const normalizedItems = bestAttempt.rawItems + const acceptedItems = bestAttempt.acceptedItems + const rejectedItems = bestAttempt.rejectedItems + const distinctHostCount = bestAttempt.distinctHostCount + const searchUrl = bestAttempt.searchUrl + const scannedPageCount = bestAttempt.scannedPageCount || 1 + + const outputName = outputFileName.toLowerCase().endsWith('.txt') + ? outputFileName + : outputFileName + '.txt' + const outputPath = artifact(outputName) + const reportText = formatReport(acceptedItems, { + query: bestAttempt.baseQuery, + generatedAt: new Date().toISOString(), + searchUrl: searchUrl, + rawCount: normalizedItems.length, + rejectedItems: rejectedItems, + }) + fs.writeFileSync(outputPath, reportText, 'utf8') + + let screenshotPath = '' + if (params.captureScreenshot === true) { + screenshotPath = artifact('news-search.png') + await page.screenshot({ + path: screenshotPath, + fullPage: true, + }) + } + + log('outputPath', outputPath) + await closeRunnerPage() + + if (acceptedItems.length < minAcceptedCount || distinctHostCount < minDistinctHostCount) { + return { + ok: false, + summary: '新闻结果质量不足,仅 ' + acceptedItems.length + '/' + normalizedItems.length + ' 条通过校验', + error: '搜索结果更像普通搜索、问答页或聚合页,未达到新闻抓取标准', + query: bestAttempt.baseQuery, + searchQuery: bestAttempt.searchQuery, + searchUrl: searchUrl, + outputPath, + screenshotPath, + rawItemCount: normalizedItems.length, + itemCount: acceptedItems.length, + rejectedCount: rejectedItems.length, + distinctHostCount: distinctHostCount, + scannedPageCount: scannedPageCount, + firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', + } + } + + return { + ok: true, + summary: '已筛出 ' + acceptedItems.length + ' 条有效新闻并写入 TXT', + query: bestAttempt.baseQuery, + searchQuery: bestAttempt.searchQuery, + searchUrl: searchUrl, + outputPath, + screenshotPath, + rawItemCount: normalizedItems.length, + itemCount: acceptedItems.length, + rejectedCount: rejectedItems.length, + distinctHostCount: distinctHostCount, + scannedPageCount: scannedPageCount, + firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', + } +}`, + Notes: "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行时可直接点“创建 Demo 并执行”,成功后在结果里的 outputPath 查看文件。", + Source: ScriptSource{ + Type: "builtin", + URI: "repo://backend/internal/automation/default_scripts.go", + Ref: "HEAD", + Path: "news-query-txt", + }, + }, + } +} diff --git a/backend/internal/automation/runner_asset.go b/backend/internal/automation/runner_asset.go new file mode 100644 index 00000000..8499faf2 --- /dev/null +++ b/backend/internal/automation/runner_asset.go @@ -0,0 +1,8 @@ +package automation + +import _ "embed" + +const runnerScriptFileName = "runner.cjs" + +//go:embed assets/runner.cjs +var runnerScriptContent []byte diff --git a/backend/internal/automation/runtime_archive.go b/backend/internal/automation/runtime_archive.go new file mode 100644 index 00000000..012adac4 --- /dev/null +++ b/backend/internal/automation/runtime_archive.go @@ -0,0 +1,273 @@ +package automation + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ulikunitz/xz" +) + +func writeRuntimeManifest(path, nodeVersion, playwrightVersion, runtimeVersion, nodeSource, nodePath string) error { + payload := map[string]string{ + "runtimeVersion": strings.TrimSpace(runtimeVersion), + "nodeVersion": strings.TrimSpace(nodeVersion), + "playwrightVersion": strings.TrimSpace(playwrightVersion), + "nodeSource": strings.TrimSpace(nodeSource), + "nodePath": strings.TrimSpace(nodePath), + "installedAt": time.Now().Format(time.RFC3339), + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func writeRunnerScript(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, runnerScriptContent, 0o755) +} + +func syncRunnerScript(path string) error { + current, err := os.ReadFile(path) + if err == nil && string(current) == string(runnerScriptContent) { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + return writeRunnerScript(path) +} + +func extractArchive(archivePath, destDir, format, stripPrefix string) error { + switch strings.TrimSpace(format) { + case "zip": + return extractZip(archivePath, destDir, stripPrefix) + case "tar.gz": + return extractTarGz(archivePath, destDir, stripPrefix) + case "tar.xz": + return extractTarXz(archivePath, destDir, stripPrefix) + default: + return fmt.Errorf("unsupported archive format: %s", format) + } +} + +func extractZip(archivePath, destDir, stripPrefix string) error { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer reader.Close() + + for _, file := range reader.File { + targetPath, skip, err := sanitizedArchivePath(destDir, file.Name, stripPrefix) + if err != nil { + return err + } + if skip { + continue + } + if file.FileInfo().IsDir() { + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + src, err := file.Open() + if err != nil { + return err + } + dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode()) + if err != nil { + src.Close() + return err + } + _, copyErr := io.Copy(dst, src) + closeErr := dst.Close() + srcCloseErr := src.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if srcCloseErr != nil { + return srcCloseErr + } + } + return nil +} + +func extractTarGz(archivePath, destDir, stripPrefix string) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzReader.Close() + + return extractTarReader(tar.NewReader(gzReader), destDir, stripPrefix) +} + +func extractTarXz(archivePath, destDir, stripPrefix string) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + xzReader, err := xz.NewReader(file) + if err != nil { + return err + } + return extractTarReader(tar.NewReader(xzReader), destDir, stripPrefix) +} + +func extractTarReader(reader *tar.Reader, destDir, stripPrefix string) error { + for { + header, err := reader.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + targetPath, skip, err := sanitizedArchivePath(destDir, header.Name, stripPrefix) + if err != nil { + return err + } + if skip { + continue + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + mode := os.FileMode(header.Mode) + if mode == 0 { + mode = 0o644 + } + file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(file, reader); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + } + } +} + +func sanitizedArchivePath(destDir, rawName, stripPrefix string) (string, bool, error) { + name := filepath.ToSlash(strings.TrimSpace(rawName)) + if name == "" { + return "", true, nil + } + if stripPrefix != "" { + prefix := filepath.ToSlash(strings.TrimSpace(stripPrefix)) + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + if name == strings.TrimSuffix(prefix, "/") { + return "", true, nil + } + if !strings.HasPrefix(name, prefix) { + return "", true, nil + } + name = strings.TrimPrefix(name, prefix) + } + name = strings.TrimPrefix(name, "/") + cleanName := filepath.Clean(filepath.FromSlash(name)) + if cleanName == "." || cleanName == "" { + return "", true, nil + } + if cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) { + return "", false, fmt.Errorf("illegal archive path: %s", rawName) + } + targetPath := filepath.Join(destDir, cleanName) + rel, err := filepath.Rel(destDir, targetPath) + if err != nil { + return "", false, err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", false, fmt.Errorf("illegal archive path: %s", rawName) + } + return targetPath, false, nil +} + +func sha256File(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func sha1File(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha1.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func readPackageVersion(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var payload struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "" + } + return strings.TrimSpace(payload.Version) +} diff --git a/backend/internal/automation/runtime_download.go b/backend/internal/automation/runtime_download.go new file mode 100644 index 00000000..326db799 --- /dev/null +++ b/backend/internal/automation/runtime_download.go @@ -0,0 +1,158 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (m *Manager) fetchNodeSHA256(ctx context.Context, url, fileName string) (string, error) { + body, err := m.fetchText(ctx, url) + if err != nil { + return "", err + } + for _, line := range strings.Split(body, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 { + continue + } + if fields[len(fields)-1] == fileName { + return strings.TrimSpace(fields[0]), nil + } + } + return "", fmt.Errorf("未找到 Node 归档校验信息: %s", fileName) +} + +func (m *Manager) fetchPlaywrightMetadata(ctx context.Context, version string) (playwrightMetadata, error) { + url := fmt.Sprintf("%s/playwright-core/%s", strings.TrimRight(m.options.NPMRegistryBaseURL, "/"), strings.TrimSpace(version)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return playwrightMetadata{}, err + } + resp, err := m.options.HTTPClient.Do(req) + if err != nil { + return playwrightMetadata{}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return playwrightMetadata{}, fmt.Errorf("metadata request failed: %s", resp.Status) + } + + var payload struct { + Dist struct { + Tarball string `json:"tarball"` + Shasum string `json:"shasum"` + } `json:"dist"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return playwrightMetadata{}, err + } + if strings.TrimSpace(payload.Dist.Tarball) == "" { + return playwrightMetadata{}, fmt.Errorf("playwright-core tarball url is empty") + } + return playwrightMetadata{ + TarballURL: strings.TrimSpace(payload.Dist.Tarball), + Shasum: strings.TrimSpace(payload.Dist.Shasum), + }, nil +} + +func (m *Manager) fetchText(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := m.options.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("request failed: %s", resp.Status) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(data), nil +} + +func (m *Manager) downloadFile(ctx context.Context, url, filePath, component string, startProgress, endProgress int, message string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := m.options.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download failed: %s", resp.Status) + } + + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return err + } + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + total := resp.ContentLength + var written int64 + buf := make([]byte, 256*1024) + m.emitProgress("downloading", startProgress, message, component) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, err := file.Write(buf[:n]); err != nil { + return err + } + written += int64(n) + if total > 0 { + progress := startProgress + int(float64(endProgress-startProgress)*(float64(written)/float64(total))) + m.emitProgress("downloading", progress, message, component) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + } + m.emitProgress("downloading", endProgress, message, component) + return nil +} + +func (m *Manager) emitProgress(phase string, progress int, message string, component string) { + if m.emit == nil { + return + } + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + m.emit(ProgressEventName, ProgressEvent{ + Phase: strings.TrimSpace(phase), + Progress: progress, + Message: strings.TrimSpace(message), + Component: strings.TrimSpace(component), + }) +} + +func (m *Manager) installFailed(err error) error { + m.mu.Lock() + m.lastError = err.Error() + m.mu.Unlock() + m.emitProgress("error", 0, err.Error(), "") + return err +} diff --git a/backend/internal/automation/runtime_install.go b/backend/internal/automation/runtime_install.go new file mode 100644 index 00000000..603f8bda --- /dev/null +++ b/backend/internal/automation/runtime_install.go @@ -0,0 +1,57 @@ +package automation + +import ( + "context" + "fmt" + "strings" +) + +type runtimeInstallWorkspace struct { + TempRoot string + StagingDir string +} + +func (w runtimeInstallWorkspace) cleanup() { + _ = removeRuntimeInstallWorkspace(w.StagingDir) +} + +func ensureRuntimeInstallContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func (m *Manager) beginRuntimeInstall(flagAlreadySet bool) bool { + if flagAlreadySet { + return true + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.installing { + return false + } + m.installing = true + m.lastError = "" + return true +} + +func (m *Manager) finishRuntimeInstall() { + m.mu.Lock() + m.installing = false + m.mu.Unlock() +} + +func validateRuntimeVersion(runtimeVersion string) error { + if strings.TrimSpace(runtimeVersion) == "" { + return fmt.Errorf("automation runtime version is empty") + } + return nil +} + +func (m *Manager) clearRuntimeInstallError() { + m.mu.Lock() + m.lastError = "" + m.mu.Unlock() +} diff --git a/backend/internal/automation/runtime_install_node.go b/backend/internal/automation/runtime_install_node.go new file mode 100644 index 00000000..e17b9b94 --- /dev/null +++ b/backend/internal/automation/runtime_install_node.go @@ -0,0 +1,94 @@ +package automation + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "ant-chrome/backend/internal/config" +) + +type runtimeNodePlan struct { + UseBundledNode bool + SystemNode resolvedNodeRuntime +} + +func (m *Manager) prepareRuntimeNodePlan(ctx context.Context, auto config.AutomationConfig, nodeMode string) (runtimeNodePlan, error) { + plan := runtimeNodePlan{ + UseBundledNode: strings.EqualFold(nodeMode, config.AutomationNodeSourceBundled), + } + if plan.UseBundledNode { + return plan, nil + } + + m.emitProgress("checking", 8, "正在检测系统 Node", "node") + resolved, err := m.resolveSystemNode(ctx, auto.SystemNodePath) + if err == nil { + plan.SystemNode = resolved + m.emitProgress("checking", 10, fmt.Sprintf("已检测到系统 Node %s,跳过 Node 下载", resolved.Version), "node") + return plan, nil + } + + if strings.EqualFold(nodeMode, config.AutomationNodeSourceSystem) { + return runtimeNodePlan{}, fmt.Errorf("系统 Node 不可用: %w", err) + } + + plan.UseBundledNode = true + m.emitProgress("checking", 10, "未检测到可用的系统 Node,准备回退内建 Node", "node") + return plan, nil +} + +func (m *Manager) installBundledNodeRuntime(ctx context.Context, tempRoot string, stagingDir string, nodeVersion string, startProgress int, endProgress int, extractProgress int, message string) error { + spec, err := m.nodeArchive(nodeVersion) + if err != nil { + return err + } + + nodeArchiveURL := fmt.Sprintf("%s/v%s/%s", strings.TrimRight(m.options.NodeDistBaseURL, "/"), nodeVersion, spec.FileName) + nodeShasumURL := fmt.Sprintf("%s/v%s/SHASUMS256.txt", strings.TrimRight(m.options.NodeDistBaseURL, "/"), nodeVersion) + nodeArchivePath := filepath.Join(tempRoot, spec.FileName) + + expectedNodeSHA, err := m.fetchNodeSHA256(ctx, nodeShasumURL, spec.FileName) + if err != nil { + return fmt.Errorf("获取 Node 校验信息失败: %w", err) + } + if err := m.downloadFile(ctx, nodeArchiveURL, nodeArchivePath, "node", startProgress, endProgress, message); err != nil { + return fmt.Errorf("下载 Node 运行时失败: %w", err) + } + if actual, err := sha256File(nodeArchivePath); err != nil { + return fmt.Errorf("校验 Node 运行时失败: %w", err) + } else if !strings.EqualFold(actual, expectedNodeSHA) { + return fmt.Errorf("Node 运行时校验失败: expected %s got %s", expectedNodeSHA, actual) + } + + if extractProgress >= 0 { + m.emitProgress("extracting", extractProgress, "正在解压内建 Node 运行时", "node") + } + if err := extractArchive(nodeArchivePath, filepath.Join(stagingDir, "node"), spec.Format, spec.StripPrefix); err != nil { + return fmt.Errorf("解压 Node 运行时失败: %w", err) + } + return nil +} + +func (m *Manager) resolveInstalledNodeRuntime(ctx context.Context, tempRoot string, stagingDir string, auto config.AutomationConfig, nodeMode string, plan runtimeNodePlan) (string, string, string, error) { + if plan.UseBundledNode { + return config.AutomationNodeSourceBundled, strings.TrimSpace(auto.NodeVersion), m.nodeExecutablePath(stagingDir), nil + } + + m.emitProgress("checking", 90, "正在验证系统 Node 与 playwright-core", "node") + check, err := m.verifyNodeWithPlaywright(ctx, plan.SystemNode.Path, stagingDir) + if err == nil { + return config.AutomationNodeSourceSystem, check.NodeVersion, plan.SystemNode.Path, nil + } + + if strings.EqualFold(nodeMode, config.AutomationNodeSourceSystem) { + return "", "", "", fmt.Errorf("系统 Node 与 playwright-core 不兼容: %w", err) + } + + m.emitProgress("checking", 92, "系统 Node 与 playwright-core 不兼容,正在回退内建 Node", "node") + if installErr := m.installBundledNodeRuntime(ctx, tempRoot, stagingDir, auto.NodeVersion, 92, 97, -1, "正在下载内建 Node 运行时"); installErr != nil { + return "", "", "", installErr + } + return config.AutomationNodeSourceBundled, strings.TrimSpace(auto.NodeVersion), m.nodeExecutablePath(stagingDir), nil +} diff --git a/backend/internal/automation/runtime_install_playwright.go b/backend/internal/automation/runtime_install_playwright.go new file mode 100644 index 00000000..2a60ea6c --- /dev/null +++ b/backend/internal/automation/runtime_install_playwright.go @@ -0,0 +1,62 @@ +package automation + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "ant-chrome/backend/internal/config" +) + +func (m *Manager) installPlaywrightRuntime(ctx context.Context, tempRoot string, stagingDir string, version string) error { + playwrightMeta, err := m.fetchPlaywrightMetadata(ctx, version) + if err != nil { + return fmt.Errorf("获取 playwright-core 元数据失败: %w", err) + } + + playwrightArchivePath := filepath.Join(tempRoot, fmt.Sprintf("playwright-core-%s.tgz", version)) + if err := m.downloadFile(ctx, playwrightMeta.TarballURL, playwrightArchivePath, "playwright", 55, 80, "正在下载 playwright-core"); err != nil { + return fmt.Errorf("下载 playwright-core 失败: %w", err) + } + if actual, err := sha1File(playwrightArchivePath); err != nil { + return fmt.Errorf("校验 playwright-core 失败: %w", err) + } else if playwrightMeta.Shasum != "" && !strings.EqualFold(actual, playwrightMeta.Shasum) { + return fmt.Errorf("playwright-core 校验失败: expected %s got %s", playwrightMeta.Shasum, actual) + } + + m.emitProgress("extracting", 85, "正在解压 playwright-core", "playwright") + if err := extractArchive(playwrightArchivePath, filepath.Join(stagingDir, "node_modules", "playwright-core"), "tar.gz", "package/"); err != nil { + return fmt.Errorf("解压 playwright-core 失败: %w", err) + } + return nil +} + +func (m *Manager) activateRuntimeInstall(stagingDir string, auto config.AutomationConfig, nodeSource string, nodeVersion string, nodePath string) error { + if err := writeRuntimeManifest( + filepath.Join(stagingDir, "manifest.json"), + nodeVersion, + auto.PlaywrightCoreVersion, + auto.RuntimeVersion, + nodeSource, + nodePath, + ); err != nil { + return fmt.Errorf("写入自动化运行时清单失败: %w", err) + } + if err := writeRunnerScript(filepath.Join(stagingDir, runnerScriptFileName)); err != nil { + return fmt.Errorf("写入自动化 runner 失败: %w", err) + } + + runtimeDir := m.runtimeDir(auto.RuntimeVersion) + if err := os.MkdirAll(filepath.Dir(runtimeDir), 0o755); err != nil { + return fmt.Errorf("创建自动化运行时目录失败: %w", err) + } + if err := os.RemoveAll(runtimeDir); err != nil { + return fmt.Errorf("替换自动化运行时目录失败: %w", err) + } + if err := os.Rename(stagingDir, runtimeDir); err != nil { + return fmt.Errorf("启用自动化运行时失败: %w", err) + } + return nil +} diff --git a/backend/internal/automation/runtime_install_workspace.go b/backend/internal/automation/runtime_install_workspace.go new file mode 100644 index 00000000..2169edfd --- /dev/null +++ b/backend/internal/automation/runtime_install_workspace.go @@ -0,0 +1,74 @@ +package automation + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/config" +) + +func runtimeInstallNodeMode(auto config.AutomationConfig) string { + nodeMode := config.DefaultAutomationNodeSource + if auto.NodeSource != "" { + nodeMode = auto.NodeSource + } + return nodeMode +} + +func (m *Manager) tryUseReadyRuntime(ctx context.Context, nodeMode string) (bool, error) { + state := m.CurrentState() + if !state.Ready { + return false, nil + } + + if err := syncRunnerScript(state.RunnerPath); err != nil { + return false, fmt.Errorf("更新自动化 runner 失败: %w", err) + } + + if !strings.EqualFold(state.NodeSource, config.AutomationNodeSourceSystem) { + m.emitProgress("done", 100, "自动化运行时已就绪", "") + return true, nil + } + + m.emitProgress("checking", 5, "正在验证系统 Node 与 playwright-core", "node") + if _, err := m.verifyNodeWithPlaywright(ctx, state.NodePath, state.RuntimeDir); err == nil { + m.emitProgress("done", 100, "自动化运行时已就绪", "") + return true, nil + } else if strings.EqualFold(nodeMode, config.AutomationNodeSourceSystem) { + return false, fmt.Errorf("系统 Node 与 playwright-core 不兼容: %w", err) + } + + m.emitProgress("checking", 5, "系统 Node 与 playwright-core 不兼容,正在修复运行时", "node") + return false, nil +} + +func (m *Manager) prepareRuntimeInstallWorkspace(runtimeVersion string) (runtimeInstallWorkspace, error) { + tempRoot := filepath.Join(m.runtimeRoot(), "tmp") + if err := os.MkdirAll(tempRoot, 0o755); err != nil { + return runtimeInstallWorkspace{}, fmt.Errorf("创建自动化运行时临时目录失败: %w", err) + } + + stagingDir := filepath.Join(tempRoot, fmt.Sprintf("%s.staging-%d", runtimeVersion, time.Now().UnixNano())) + if err := removeRuntimeInstallWorkspace(stagingDir); err != nil { + return runtimeInstallWorkspace{}, fmt.Errorf("清理自动化运行时临时目录失败: %w", err) + } + if err := os.MkdirAll(stagingDir, 0o755); err != nil { + return runtimeInstallWorkspace{}, fmt.Errorf("创建自动化运行时临时目录失败: %w", err) + } + + return runtimeInstallWorkspace{ + TempRoot: tempRoot, + StagingDir: stagingDir, + }, nil +} + +func removeRuntimeInstallWorkspace(stagingDir string) error { + if strings.TrimSpace(stagingDir) == "" { + return nil + } + return os.RemoveAll(stagingDir) +} diff --git a/backend/internal/automation/runtime_manager.go b/backend/internal/automation/runtime_manager.go new file mode 100644 index 00000000..f85276d1 --- /dev/null +++ b/backend/internal/automation/runtime_manager.go @@ -0,0 +1,80 @@ +package automation + +import "context" + +func (m *Manager) InstallAsync(ctx context.Context) { + m.mu.Lock() + if m.installing { + m.mu.Unlock() + return + } + m.installing = true + m.lastError = "" + m.mu.Unlock() + + go func() { + _ = m.ensureInstalled(ctx, true) + }() +} + +func (m *Manager) EnsureInstalled(ctx context.Context) error { + return m.ensureInstalled(ctx, false) +} + +func (m *Manager) ensureInstalled(ctx context.Context, flagAlreadySet bool) error { + ctx = ensureRuntimeInstallContext(ctx) + if !m.beginRuntimeInstall(flagAlreadySet) { + return nil + } + defer m.finishRuntimeInstall() + + auto := m.currentAutomationConfig() + nodeMode := runtimeInstallNodeMode(auto) + + reused, err := m.tryUseReadyRuntime(ctx, nodeMode) + if err != nil { + return m.installFailed(err) + } + if reused { + return nil + } + + if err := validateRuntimeVersion(auto.RuntimeVersion); err != nil { + return m.installFailed(err) + } + + workspace, err := m.prepareRuntimeInstallWorkspace(auto.RuntimeVersion) + if err != nil { + return m.installFailed(err) + } + defer workspace.cleanup() + + m.emitProgress("checking", 5, "正在准备自动化运行时", "") + + nodePlan, err := m.prepareRuntimeNodePlan(ctx, auto, nodeMode) + if err != nil { + return m.installFailed(err) + } + if nodePlan.UseBundledNode { + if err := m.installBundledNodeRuntime(ctx, workspace.TempRoot, workspace.StagingDir, auto.NodeVersion, 10, 45, 50, "正在下载内建 Node 运行时"); err != nil { + return m.installFailed(err) + } + } + + if err := m.installPlaywrightRuntime(ctx, workspace.TempRoot, workspace.StagingDir, auto.PlaywrightCoreVersion); err != nil { + return m.installFailed(err) + } + + nodeSource, nodeVersion, nodePath, err := m.resolveInstalledNodeRuntime(ctx, workspace.TempRoot, workspace.StagingDir, auto, nodeMode, nodePlan) + if err != nil { + return m.installFailed(err) + } + + if err := m.activateRuntimeInstall(workspace.StagingDir, auto, nodeSource, nodeVersion, nodePath); err != nil { + return m.installFailed(err) + } + + m.clearRuntimeInstallError() + m.emitProgress("done", 100, "自动化运行时已安装完成", "") + return nil +} diff --git a/backend/internal/automation/runtime_manager_test.go b/backend/internal/automation/runtime_manager_test.go new file mode 100644 index 00000000..229a1430 --- /dev/null +++ b/backend/internal/automation/runtime_manager_test.go @@ -0,0 +1,553 @@ +package automation + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "sync/atomic" + "testing" + + "ant-chrome/backend/internal/config" +) + +func TestEnsureInstalledDownloadsAndExtractsRuntime(t *testing.T) { + t.Parallel() + + nodeArchive, nodeSHA := buildTestNodeZip(t) + playwrightArchive, playwrightSHA := buildTestPlaywrightTGZ(t) + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v22.15.1/SHASUMS256.txt": + _, _ = w.Write([]byte(nodeSHA + " node-v22.15.1-win-x64.zip\n")) + case "/v22.15.1/node-v22.15.1-win-x64.zip": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(nodeArchive) + case "/playwright-core/1.59.0": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dist": map[string]any{ + "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", + "shasum": playwrightSHA, + }, + }) + case "/tarballs/playwright-core-1.59.0.tgz": + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(playwrightArchive) + default: + http.NotFound(w, r) + } + }) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceBundled + cfg.Automation.NodeVersion = "22.15.1" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + NodeDistBaseURL: server.URL, + NPMRegistryBaseURL: server.URL, + TargetOS: "windows", + TargetArch: "amd64", + }) + + if err := manager.EnsureInstalled(context.Background()); err != nil { + t.Fatalf("EnsureInstalled returned error: %v", err) + } + + state := manager.CurrentState() + if !state.Installed || !state.Ready { + t.Fatalf("runtime should be ready after install, got %+v", state) + } + if _, err := os.Stat(filepath.Join(state.RuntimeDir, "node", "node.exe")); err != nil { + t.Fatalf("expected node executable to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(state.RuntimeDir, "node_modules", "playwright-core", "package.json")); err != nil { + t.Fatalf("expected playwright-core package.json to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(state.RuntimeDir, runnerScriptFileName)); err != nil { + t.Fatalf("expected runner script to exist: %v", err) + } +} + +func TestEnsureInstalledUsesSystemNodeAndSkipsBundledDownload(t *testing.T) { + t.Parallel() + + nodeExecPath := lookupNodeExecutable(t) + playwrightArchive, playwrightSHA := buildTestPlayablePlaywrightTGZ(t, "1.59.0") + + var nodeRequests atomic.Int32 + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v22.15.1/") { + nodeRequests.Add(1) + http.NotFound(w, r) + return + } + + switch r.URL.Path { + case "/playwright-core/1.59.0": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dist": map[string]any{ + "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", + "shasum": playwrightSHA, + }, + }) + case "/tarballs/playwright-core-1.59.0.tgz": + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(playwrightArchive) + default: + http.NotFound(w, r) + } + }) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "22.15.1" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + NodeDistBaseURL: server.URL, + NPMRegistryBaseURL: server.URL, + TargetOS: goruntime.GOOS, + TargetArch: goruntime.GOARCH, + }) + + if err := manager.EnsureInstalled(context.Background()); err != nil { + t.Fatalf("EnsureInstalled returned error: %v", err) + } + + state := manager.CurrentState() + if !state.Installed || !state.Ready { + t.Fatalf("runtime should be ready after install, got %+v", state) + } + if state.NodeSource != config.AutomationNodeSourceSystem { + t.Fatalf("expected system node source, got %q", state.NodeSource) + } + if filepath.Clean(state.NodePath) != filepath.Clean(nodeExecPath) { + t.Fatalf("expected system node path %q, got %q", nodeExecPath, state.NodePath) + } + if nodeRequests.Load() != 0 { + t.Fatalf("expected bundled node download to be skipped, got %d node requests", nodeRequests.Load()) + } + if _, err := os.Stat(filepath.Join(state.RuntimeDir, "node_modules", "playwright-core", "package.json")); err != nil { + t.Fatalf("expected playwright-core package.json to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(state.RuntimeDir, runnerScriptFileName)); err != nil { + t.Fatalf("expected runner script to exist: %v", err) + } + if _, err := os.Stat(manager.nodeExecutablePath(state.RuntimeDir)); !os.IsNotExist(err) { + t.Fatalf("expected bundled node to be absent, got err=%v", err) + } +} + +func TestProbeSystemNodeUsesExplicitPath(t *testing.T) { + t.Parallel() + + nodeExecPath := lookupNodeExecutable(t) + manager := NewManager(t.TempDir(), config.DefaultConfig(), nil, Options{}) + + result, err := manager.ProbeSystemNode(context.Background(), nodeExecPath) + if err != nil { + t.Fatalf("ProbeSystemNode returned error: %v", err) + } + if !result.OK { + t.Fatalf("expected probe result to be ok, got %+v", result) + } + if filepath.Clean(result.Path) != filepath.Clean(nodeExecPath) { + t.Fatalf("expected probe path %q, got %q", nodeExecPath, result.Path) + } + if strings.TrimSpace(result.Version) == "" { + t.Fatalf("expected probe version to be set, got %+v", result) + } +} + +func TestProbeSystemNodeMissingReturnsError(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + manager := NewManager(t.TempDir(), config.DefaultConfig(), nil, Options{}) + _, err := manager.ProbeSystemNode(context.Background(), filepath.Join(t.TempDir(), "missing-node.exe")) + if err == nil { + t.Fatalf("expected ProbeSystemNode to fail for missing node path") + } +} + +func TestCurrentStateReportsBundledFallbackReasonWhenSystemNodeMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceAuto + cfg.Automation.SystemNodePath = filepath.Join(t.TempDir(), "missing-node.exe") + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + TargetOS: "windows", + TargetArch: "amd64", + }) + + state := manager.CurrentState() + if state.NodeSource != config.AutomationNodeSourceBundled { + t.Fatalf("expected bundled node source, got %q", state.NodeSource) + } + if !strings.Contains(state.NodeResolution, "回退") { + t.Fatalf("expected fallback resolution message, got %q", state.NodeResolution) + } + if strings.TrimSpace(state.SystemNodeError) == "" { + t.Fatalf("expected system node error to be set, got %+v", state) + } +} + +func TestCurrentStateReportsSystemResolutionWhenExplicitNodeSucceeds(t *testing.T) { + t.Parallel() + + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceAuto + cfg.Automation.SystemNodePath = nodeExecPath + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + TargetOS: goruntime.GOOS, + TargetArch: goruntime.GOARCH, + }) + + state := manager.CurrentState() + if state.NodeSource != config.AutomationNodeSourceSystem { + t.Fatalf("expected system node source, got %q", state.NodeSource) + } + if !strings.Contains(state.NodeResolution, "配置的系统 Node 路径") { + t.Fatalf("expected explicit system node resolution, got %q", state.NodeResolution) + } +} + +func TestEnsureInstalledRepairsBrokenReadyAutoRuntime(t *testing.T) { + t.Parallel() + + nodeExecPath := lookupNodeExecutable(t) + playwrightArchive, playwrightSHA := buildTestPlayablePlaywrightTGZ(t, "1.59.0") + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/playwright-core/1.59.0": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dist": map[string]any{ + "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", + "shasum": playwrightSHA, + }, + }) + case "/tarballs/playwright-core-1.59.0.tgz": + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(playwrightArchive) + default: + http.NotFound(w, r) + } + }) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceAuto + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "22.15.1" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + NPMRegistryBaseURL: server.URL, + TargetOS: goruntime.GOOS, + TargetArch: goruntime.GOARCH, + }) + + initialState := manager.CurrentState() + if err := writeRunnerScript(initialState.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeBrokenPlaywrightModule(initialState.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write broken playwright module failed: %v", err) + } + + readyState := manager.CurrentState() + if !readyState.Ready { + t.Fatalf("expected broken runtime to appear ready before verification, got %+v", readyState) + } + + if err := manager.EnsureInstalled(context.Background()); err != nil { + t.Fatalf("EnsureInstalled returned error: %v", err) + } + + check, err := manager.SelfCheck(context.Background()) + if err != nil { + t.Fatalf("SelfCheck returned error after repair: %v", err) + } + if !check.OK { + t.Fatalf("expected repaired runtime to pass self-check, got %+v", check) + } +} + +func TestEnsureInstalledAutoFallsBackToBundledWhenSystemNodeMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + nodeArchive, nodeSHA := buildTestNodeZip(t) + playwrightArchive, playwrightSHA := buildTestPlaywrightTGZ(t) + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v22.15.1/SHASUMS256.txt": + _, _ = w.Write([]byte(nodeSHA + " node-v22.15.1-win-x64.zip\n")) + case "/v22.15.1/node-v22.15.1-win-x64.zip": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(nodeArchive) + case "/playwright-core/1.59.0": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dist": map[string]any{ + "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", + "shasum": playwrightSHA, + }, + }) + case "/tarballs/playwright-core-1.59.0.tgz": + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(playwrightArchive) + default: + http.NotFound(w, r) + } + }) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceAuto + cfg.Automation.SystemNodePath = filepath.Join(t.TempDir(), "missing-node.exe") + cfg.Automation.NodeVersion = "22.15.1" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + NodeDistBaseURL: server.URL, + NPMRegistryBaseURL: server.URL, + TargetOS: "windows", + TargetArch: "amd64", + }) + + if err := manager.EnsureInstalled(context.Background()); err != nil { + t.Fatalf("EnsureInstalled returned error: %v", err) + } + + state := manager.CurrentState() + if !state.Installed || !state.Ready { + t.Fatalf("runtime should be ready after install, got %+v", state) + } + if state.NodeSource != config.AutomationNodeSourceBundled { + t.Fatalf("expected bundled node source after fallback, got %q", state.NodeSource) + } +} + +func TestEnsureInstalledSystemSourceFailsWhenSystemNodeMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = filepath.Join(t.TempDir(), "missing-node.exe") + cfg.Automation.NodeVersion = "22.15.1" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + TargetOS: "windows", + TargetArch: "amd64", + }) + + err := manager.EnsureInstalled(context.Background()) + if err == nil { + t.Fatalf("expected EnsureInstalled to fail when system node is missing") + } + if !strings.Contains(err.Error(), "系统 Node 不可用") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEnsureInstalledRefreshesExistingRunnerScript(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceBundled + cfg.Automation.NodeVersion = "22.15.1" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) + + manager := NewManager(t.TempDir(), cfg, nil, Options{ + TargetOS: "windows", + TargetArch: "amd64", + }) + + state := manager.CurrentState() + if err := os.MkdirAll(filepath.Dir(state.NodePath), 0o755); err != nil { + t.Fatalf("create node dir failed: %v", err) + } + if err := os.WriteFile(state.NodePath, []byte("fake-node-runtime"), 0o755); err != nil { + t.Fatalf("write fake node failed: %v", err) + } + playwrightPkgPath := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core", "package.json") + if err := os.MkdirAll(filepath.Dir(playwrightPkgPath), 0o755); err != nil { + t.Fatalf("create playwright dir failed: %v", err) + } + if err := os.WriteFile(playwrightPkgPath, []byte(`{"name":"playwright-core","version":"1.59.0"}`), 0o644); err != nil { + t.Fatalf("write fake playwright package.json failed: %v", err) + } + if err := os.WriteFile(state.RunnerPath, []byte("old-runner"), 0o755); err != nil { + t.Fatalf("write stale runner failed: %v", err) + } + + if err := manager.EnsureInstalled(context.Background()); err != nil { + t.Fatalf("EnsureInstalled returned error: %v", err) + } + + runnerData, err := os.ReadFile(state.RunnerPath) + if err != nil { + t.Fatalf("read refreshed runner failed: %v", err) + } + if string(runnerData) != string(runnerScriptContent) { + t.Fatalf("expected runner script to be refreshed") + } +} + +func buildTestNodeZip(t *testing.T) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + + header := &zip.FileHeader{ + Name: "node-v22.15.1-win-x64/node.exe", + Method: zip.Deflate, + } + fileWriter, err := writer.CreateHeader(header) + if err != nil { + t.Fatalf("create node zip header failed: %v", err) + } + if _, err := fileWriter.Write([]byte("fake-node-runtime")); err != nil { + t.Fatalf("write node zip failed: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close node zip failed: %v", err) + } + + hash := sha256.Sum256(buf.Bytes()) + return buf.Bytes(), hex.EncodeToString(hash[:]) +} + +func buildTestPlaywrightTGZ(t *testing.T) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + payload := []byte(`{"name":"playwright-core","version":"1.59.0"}`) + header := &tar.Header{ + Name: "package/package.json", + Mode: 0o644, + Size: int64(len(payload)), + } + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("write playwright header failed: %v", err) + } + if _, err := tarWriter.Write(payload); err != nil { + t.Fatalf("write playwright payload failed: %v", err) + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close playwright tar failed: %v", err) + } + if err := gzWriter.Close(); err != nil { + t.Fatalf("close playwright gzip failed: %v", err) + } + + hash := sha1.Sum(buf.Bytes()) + return buf.Bytes(), hex.EncodeToString(hash[:]) +} + +func buildTestPlayablePlaywrightTGZ(t *testing.T, version string) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + files := map[string][]byte{ + "package/package.json": []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`), + "package/index.js": []byte("exports.chromium = {};"), + } + + for name, payload := range files { + header := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(payload)), + } + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("write playable playwright header failed: %v", err) + } + if _, err := tarWriter.Write(payload); err != nil { + t.Fatalf("write playable playwright payload failed: %v", err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close playable playwright tar failed: %v", err) + } + if err := gzWriter.Close(); err != nil { + t.Fatalf("close playable playwright gzip failed: %v", err) + } + + hash := sha1.Sum(buf.Bytes()) + return buf.Bytes(), hex.EncodeToString(hash[:]) +} + +func writeBrokenPlaywrightModule(runtimeDir, version string) error { + moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + return err + } + + packageJSON := []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`) + if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), packageJSON, 0o644); err != nil { + return err + } + + return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte("module.exports = {};"), 0o644) +} diff --git a/backend/internal/automation/runtime_resolution.go b/backend/internal/automation/runtime_resolution.go new file mode 100644 index 00000000..77edae27 --- /dev/null +++ b/backend/internal/automation/runtime_resolution.go @@ -0,0 +1,233 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/config" +) + +type resolvedNodeRuntime struct { + Source string + Path string + Version string + SystemNodeDetected bool + SystemNodePath string + Resolution string + SystemNodeError string +} + +type nodeProbeResult struct { + Path string `json:"path"` + Version string `json:"version"` +} + +type SystemNodeProbeResult struct { + OK bool `json:"ok"` + Path string `json:"path"` + Version string `json:"version"` +} + +func (m *Manager) resolveNodeRuntime(runtimeDir string, auto config.AutomationConfig) resolvedNodeRuntime { + mode := config.DefaultAutomationNodeSource + if auto.NodeSource != "" { + mode = strings.TrimSpace(auto.NodeSource) + } + + if mode != config.AutomationNodeSourceBundled { + if systemNode, err := m.resolveSystemNode(context.Background(), auto.SystemNodePath); err == nil { + return systemNode + } else if mode == config.AutomationNodeSourceSystem { + return resolvedNodeRuntime{ + Source: config.AutomationNodeSourceSystem, + Version: strings.TrimSpace(auto.NodeVersion), + SystemNodePath: strings.TrimSpace(auto.SystemNodePath), + Resolution: "已配置为 system,必须使用系统 Node", + SystemNodeError: err.Error(), + } + } else { + return resolvedNodeRuntime{ + Source: config.AutomationNodeSourceBundled, + Path: m.nodeExecutablePath(runtimeDir), + Version: strings.TrimSpace(auto.NodeVersion), + SystemNodePath: strings.TrimSpace(auto.SystemNodePath), + Resolution: "系统 Node 不可用,已回退到内建 Node", + SystemNodeError: err.Error(), + } + } + } + + return resolvedNodeRuntime{ + Source: config.AutomationNodeSourceBundled, + Path: m.nodeExecutablePath(runtimeDir), + Version: strings.TrimSpace(auto.NodeVersion), + Resolution: "已配置为 bundled,始终使用内建 Node", + } +} + +func (m *Manager) resolveSystemNode(ctx context.Context, explicitPath string) (resolvedNodeRuntime, error) { + type nodeCandidate struct { + path string + resolution string + } + + candidatePaths := make([]nodeCandidate, 0, 2) + if trimmed := strings.TrimSpace(explicitPath); trimmed != "" { + candidatePaths = append(candidatePaths, nodeCandidate{ + path: trimmed, + resolution: "已使用配置的系统 Node 路径", + }) + } + if lookupPath, err := exec.LookPath("node"); err == nil && strings.TrimSpace(lookupPath) != "" { + lookupPath = strings.TrimSpace(lookupPath) + duplicate := false + for _, existing := range candidatePaths { + if strings.EqualFold(existing.path, lookupPath) { + duplicate = true + break + } + } + if !duplicate { + candidatePaths = append(candidatePaths, nodeCandidate{ + path: lookupPath, + resolution: "已使用 PATH 中的系统 Node", + }) + } + } + + var lastErr error + for _, candidate := range candidatePaths { + probe, err := m.probeNodeExecutable(ctx, candidate.path) + if err != nil { + lastErr = err + continue + } + return resolvedNodeRuntime{ + Source: config.AutomationNodeSourceSystem, + Path: probe.Path, + Version: probe.Version, + SystemNodeDetected: true, + SystemNodePath: probe.Path, + Resolution: candidate.resolution, + }, nil + } + + if lastErr != nil { + return resolvedNodeRuntime{}, lastErr + } + return resolvedNodeRuntime{}, fmt.Errorf("未找到系统 Node") +} + +func (m *Manager) probeNodeExecutable(ctx context.Context, nodePath string) (nodeProbeResult, error) { + nodePath = strings.TrimSpace(nodePath) + if nodePath == "" { + return nodeProbeResult{}, fmt.Errorf("Node 路径为空") + } + if ctx == nil { + ctx = context.Background() + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + script := `process.stdout.write(JSON.stringify({path: process.execPath, version: process.versions.node}));` + cmd := exec.CommandContext(probeCtx, nodePath, "-e", script) + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return nodeProbeResult{}, fmt.Errorf("检测 Node 可执行文件失败(%s): %s", nodePath, message) + } + + var probe nodeProbeResult + if err := json.Unmarshal(output, &probe); err != nil { + return nodeProbeResult{}, fmt.Errorf("解析 Node 探测结果失败: %w", err) + } + probe.Path = strings.TrimSpace(probe.Path) + probe.Version = strings.TrimSpace(probe.Version) + if probe.Path == "" { + probe.Path = nodePath + } + if probe.Version == "" { + return nodeProbeResult{}, fmt.Errorf("Node 版本为空") + } + if absPath, err := filepath.Abs(probe.Path); err == nil { + probe.Path = absPath + } + return probe, nil +} + +func (m *Manager) ProbeSystemNode(ctx context.Context, explicitPath string) (SystemNodeProbeResult, error) { + resolved, err := m.resolveSystemNode(ctx, explicitPath) + if err != nil { + return SystemNodeProbeResult{}, err + } + return SystemNodeProbeResult{ + OK: true, + Path: strings.TrimSpace(resolved.Path), + Version: strings.TrimSpace(resolved.Version), + }, nil +} + +func (m *Manager) verifyNodeWithPlaywright(ctx context.Context, nodePath, runtimeDir string) (RuntimeCheckResult, error) { + nodePath = strings.TrimSpace(nodePath) + runtimeDir = strings.TrimSpace(runtimeDir) + if nodePath == "" { + return RuntimeCheckResult{}, fmt.Errorf("node path is empty") + } + if runtimeDir == "" { + return RuntimeCheckResult{}, fmt.Errorf("runtime dir is empty") + } + if ctx == nil { + ctx = context.Background() + } + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + script := ` +const path = require('path'); +const pkg = require(path.join(process.argv[1], 'node_modules', 'playwright-core', 'package.json')); +const playwright = require(path.join(process.argv[1], 'node_modules', 'playwright-core')); +process.stdout.write(JSON.stringify({ + nodeVersion: process.versions.node, + playwrightVersion: pkg.version, + hasChromium: !!playwright.chromium +})); +` + + cmd := exec.CommandContext(checkCtx, nodePath, "-e", script, runtimeDir) + cmd.Dir = runtimeDir + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return RuntimeCheckResult{}, fmt.Errorf("playwright probe failed: %s", message) + } + + var payload struct { + NodeVersion string `json:"nodeVersion"` + PlaywrightVersion string `json:"playwrightVersion"` + HasChromium bool `json:"hasChromium"` + } + if err := json.Unmarshal(output, &payload); err != nil { + return RuntimeCheckResult{}, fmt.Errorf("parse playwright probe result failed: %w", err) + } + + result := RuntimeCheckResult{ + OK: strings.TrimSpace(payload.NodeVersion) != "" && strings.TrimSpace(payload.PlaywrightVersion) != "" && payload.HasChromium, + NodeVersion: strings.TrimSpace(payload.NodeVersion), + PlaywrightVersion: strings.TrimSpace(payload.PlaywrightVersion), + } + if !result.OK { + return RuntimeCheckResult{}, fmt.Errorf("playwright probe returned incomplete result") + } + return result, nil +} diff --git a/backend/internal/automation/runtime_self_check.go b/backend/internal/automation/runtime_self_check.go new file mode 100644 index 00000000..aef2ddd9 --- /dev/null +++ b/backend/internal/automation/runtime_self_check.go @@ -0,0 +1,23 @@ +package automation + +import ( + "context" + "fmt" +) + +func (m *Manager) SelfCheck(ctx context.Context) (RuntimeCheckResult, error) { + state := m.CurrentState() + if !state.Ready { + return RuntimeCheckResult{}, fmt.Errorf("自动化运行时尚未就绪") + } + if ctx == nil { + ctx = context.Background() + } + + result, err := m.verifyNodeWithPlaywright(ctx, state.NodePath, state.RuntimeDir) + if err != nil { + return RuntimeCheckResult{}, fmt.Errorf("自动化运行时自检失败: %w", err) + } + result.NodeSource = state.NodeSource + return result, nil +} diff --git a/backend/internal/automation/runtime_types.go b/backend/internal/automation/runtime_types.go new file mode 100644 index 00000000..0c75a889 --- /dev/null +++ b/backend/internal/automation/runtime_types.go @@ -0,0 +1,247 @@ +package automation + +import ( + "fmt" + "net/http" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "sync" + + "ant-chrome/backend/internal/apppath" + "ant-chrome/backend/internal/config" +) + +const ProgressEventName = "automation:runtime:progress" + +type ProgressEvent struct { + Phase string `json:"phase"` + Progress int `json:"progress"` + Message string `json:"message"` + Component string `json:"component,omitempty"` +} + +type RuntimeState struct { + Enabled bool `json:"enabled"` + InstallPolicy string `json:"installPolicy"` + RuntimeVersion string `json:"runtimeVersion"` + HeadlessDefault bool `json:"headlessDefault"` + KeepRuntimeOnDisable bool `json:"keepRuntimeOnDisable"` + NodeSource string `json:"nodeSource"` + NodeResolution string `json:"nodeResolution"` + SystemNodeDetected bool `json:"systemNodeDetected"` + SystemNodePath string `json:"systemNodePath"` + SystemNodeError string `json:"systemNodeError"` + Installed bool `json:"installed"` + Ready bool `json:"ready"` + Installing bool `json:"installing"` + LastError string `json:"lastError"` + RuntimeDir string `json:"runtimeDir"` + NodePath string `json:"nodePath"` + RunnerPath string `json:"runnerPath"` + NodeVersion string `json:"nodeVersion"` + PlaywrightVersion string `json:"playwrightVersion"` +} + +type RuntimeCheckResult struct { + OK bool `json:"ok"` + NodeSource string `json:"nodeSource"` + NodeVersion string `json:"nodeVersion"` + PlaywrightVersion string `json:"playwrightVersion"` +} + +type Options struct { + NodeDistBaseURL string + NPMRegistryBaseURL string + TargetOS string + TargetArch string + HTTPClient *http.Client +} + +type Manager struct { + appRoot string + config *config.Config + emit func(string, any) + options Options + + mu sync.RWMutex + installing bool + lastError string + activeTasks map[string]*activeTask + profileTask map[string]string +} + +type activeTask struct { + taskID string + profileID string + cmd *exec.Cmd +} + +type nodeArchiveSpec struct { + FileName string + StripPrefix string + Format string +} + +type playwrightMetadata struct { + TarballURL string + Shasum string +} + +func NewManager(appRoot string, cfg *config.Config, emit func(string, any), opts Options) *Manager { + if strings.TrimSpace(opts.NodeDistBaseURL) == "" { + opts.NodeDistBaseURL = "https://nodejs.org/dist" + } + if strings.TrimSpace(opts.NPMRegistryBaseURL) == "" { + opts.NPMRegistryBaseURL = "https://registry.npmjs.org" + } + if strings.TrimSpace(opts.TargetOS) == "" { + opts.TargetOS = goruntime.GOOS + } + if strings.TrimSpace(opts.TargetArch) == "" { + opts.TargetArch = goruntime.GOARCH + } + if opts.HTTPClient == nil { + opts.HTTPClient = &http.Client{ + Timeout: 0, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + } + } + + return &Manager{ + appRoot: strings.TrimSpace(appRoot), + config: cfg, + emit: emit, + options: opts, + activeTasks: make(map[string]*activeTask), + profileTask: make(map[string]string), + } +} + +func (m *Manager) SetConfig(cfg *config.Config) { + m.mu.Lock() + m.config = cfg + m.mu.Unlock() +} + +func (m *Manager) CurrentState() RuntimeState { + m.mu.RLock() + cfg := m.config + installing := m.installing + lastError := m.lastError + m.mu.RUnlock() + + auto := config.DefaultConfig().Automation + if cfg != nil { + auto = cfg.Automation + } + + runtimeDir := m.runtimeDir(auto.RuntimeVersion) + runnerPath := m.runnerScriptPath(runtimeDir) + playwrightPkgPath := filepath.Join(runtimeDir, "node_modules", "playwright-core", "package.json") + resolvedNode := m.resolveNodeRuntime(runtimeDir, auto) + nodePath := strings.TrimSpace(resolvedNode.Path) + installed := fileExists(nodePath) && fileExists(playwrightPkgPath) && fileExists(runnerPath) + + nodeVersion := strings.TrimSpace(auto.NodeVersion) + if resolvedNode.Version != "" { + nodeVersion = resolvedNode.Version + } + playwrightVersion := strings.TrimSpace(auto.PlaywrightCoreVersion) + if installed { + if detected := readPackageVersion(playwrightPkgPath); detected != "" { + playwrightVersion = detected + } + } + + return RuntimeState{ + Enabled: auto.Enabled, + InstallPolicy: auto.InstallPolicy, + RuntimeVersion: auto.RuntimeVersion, + HeadlessDefault: auto.HeadlessDefault, + KeepRuntimeOnDisable: auto.KeepRuntimeOnDisable, + NodeSource: resolvedNode.Source, + NodeResolution: resolvedNode.Resolution, + SystemNodeDetected: resolvedNode.SystemNodeDetected, + SystemNodePath: resolvedNode.SystemNodePath, + SystemNodeError: resolvedNode.SystemNodeError, + Installed: installed, + Ready: installed, + Installing: installing, + LastError: lastError, + RuntimeDir: runtimeDir, + NodePath: nodePath, + RunnerPath: runnerPath, + NodeVersion: nodeVersion, + PlaywrightVersion: playwrightVersion, + } +} + +func (m *Manager) currentAutomationConfig() config.AutomationConfig { + m.mu.RLock() + cfg := m.config + m.mu.RUnlock() + if cfg == nil { + return config.DefaultConfig().Automation + } + return cfg.Automation +} + +func (m *Manager) runtimeRoot() string { + return apppath.Resolve(m.appRoot, filepath.ToSlash(filepath.Join("data", "runtime", "automation"))) +} + +func (m *Manager) runtimeDir(runtimeVersion string) string { + return filepath.Join(m.runtimeRoot(), strings.TrimSpace(runtimeVersion)) +} + +func (m *Manager) nodeExecutablePath(runtimeDir string) string { + if strings.EqualFold(strings.TrimSpace(m.options.TargetOS), "windows") { + return filepath.Join(runtimeDir, "node", "node.exe") + } + return filepath.Join(runtimeDir, "node", "bin", "node") +} + +func (m *Manager) runnerScriptPath(runtimeDir string) string { + return filepath.Join(runtimeDir, runnerScriptFileName) +} + +func (m *Manager) nodeArchive(version string) (nodeArchiveSpec, error) { + goos := strings.ToLower(strings.TrimSpace(m.options.TargetOS)) + goarch := strings.ToLower(strings.TrimSpace(m.options.TargetArch)) + + switch goos { + case "windows": + switch goarch { + case "amd64": + name := fmt.Sprintf("node-v%s-win-x64.zip", version) + return nodeArchiveSpec{FileName: name, StripPrefix: strings.TrimSuffix(name, ".zip") + "/", Format: "zip"}, nil + case "arm64": + name := fmt.Sprintf("node-v%s-win-arm64.zip", version) + return nodeArchiveSpec{FileName: name, StripPrefix: strings.TrimSuffix(name, ".zip") + "/", Format: "zip"}, nil + } + case "linux": + switch goarch { + case "amd64": + name := fmt.Sprintf("node-v%s-linux-x64.tar.xz", version) + return nodeArchiveSpec{FileName: name, StripPrefix: strings.TrimSuffix(name, ".tar.xz") + "/", Format: "tar.xz"}, nil + case "arm64": + name := fmt.Sprintf("node-v%s-linux-arm64.tar.xz", version) + return nodeArchiveSpec{FileName: name, StripPrefix: strings.TrimSuffix(name, ".tar.xz") + "/", Format: "tar.xz"}, nil + } + case "darwin": + switch goarch { + case "amd64": + name := fmt.Sprintf("node-v%s-darwin-x64.tar.gz", version) + return nodeArchiveSpec{FileName: name, StripPrefix: strings.TrimSuffix(name, ".tar.gz") + "/", Format: "tar.gz"}, nil + case "arm64": + name := fmt.Sprintf("node-v%s-darwin-arm64.tar.gz", version) + return nodeArchiveSpec{FileName: name, StripPrefix: strings.TrimSuffix(name, ".tar.gz") + "/", Format: "tar.gz"}, nil + } + } + + return nodeArchiveSpec{}, fmt.Errorf("当前平台暂不支持自动化运行时下载:%s/%s", goos, goarch) +} diff --git a/backend/internal/automation/script_build_pipeline.go b/backend/internal/automation/script_build_pipeline.go new file mode 100644 index 00000000..7e950724 --- /dev/null +++ b/backend/internal/automation/script_build_pipeline.go @@ -0,0 +1,248 @@ +package automation + +import ( + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/evanw/esbuild/pkg/api" +) + +type ImportOptions struct { + AllowTypeScriptBuild bool +} + +func importTypeScriptSingleFileBundle(nameHint string, data []byte, sourceLabel string) (ImportedBundle, error) { + fileName := filepath.Base(strings.TrimSpace(nameHint)) + if !isTypeScriptSourceFile(fileName) { + return ImportedBundle{}, fmt.Errorf("TypeScript source file is required") + } + + tempDir, err := os.MkdirTemp("", "ant-automation-ts-file-*") + if err != nil { + return ImportedBundle{}, fmt.Errorf("create typescript temp dir failed: %w", err) + } + defer os.RemoveAll(tempDir) + + sourcePath := filepath.Join(tempDir, fileName) + if err := os.WriteFile(sourcePath, data, 0o644); err != nil { + return ImportedBundle{}, fmt.Errorf("write typescript source failed: %w", err) + } + + return importTypeScriptDirectoryBundle(tempDir, sourcePath, map[string]any{ + "name": trimExtension(fileName), + "type": "playwright-cdp", + "entryFile": fileName, + }, sourceLabel) +} + +func importTypeScriptDirectoryBundle(packageRoot string, entryPath string, descriptor map[string]any, sourceLabel string) (ImportedBundle, error) { + sourceFiles, err := collectImportedBundleFiles(packageRoot) + if err != nil { + return ImportedBundle{}, err + } + if err := validateTypeScriptSourceFiles(sourceFiles); err != nil { + return ImportedBundle{}, err + } + + entryPath = filepath.Clean(entryPath) + entryRelPath, err := filepath.Rel(packageRoot, entryPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("resolve typescript entry file failed: %w", err) + } + entryRelPath = filepath.ToSlash(entryRelPath) + + compiledEntryFile, compiledEntryContent, err := buildTypeScriptEntry(packageRoot, entryRelPath) + if err != nil { + return ImportedBundle{}, err + } + + record, err := buildImportedRecord(scriptImportEnvelope{ + Format: mapStringValueAny(descriptor, "format"), + PackageFormat: mapStringValueAny(descriptor, "packageFormat"), + ManifestVersion: mapIntValueAny(descriptor, "manifestVersion"), + Name: mapStringValueAny(descriptor, "name"), + Description: mapStringValueAny(descriptor, "description"), + Type: mapStringValueAny(descriptor, "type"), + Status: mapStringValueAny(descriptor, "status"), + EntryFile: compiledEntryFile, + Tags: mapStringSliceValue(descriptor, "tags"), + Selector: descriptor["selector"], + SelectorText: descriptor["selectorText"], + Params: descriptor["params"], + ParamsText: descriptor["paramsText"], + ScriptText: string(compiledEntryContent), + Notes: appendTypeScriptBuildNote(mapStringValueAny(descriptor, "notes")), + Source: mapObjectValue(descriptor, "source"), + }, filepath.Base(packageRoot), sourceLabel) + if err != nil { + return ImportedBundle{}, err + } + + files := make([]ImportedBundleFile, 0, len(sourceFiles)+1) + files = append(files, ImportedBundleFile{ + Path: compiledEntryFile, + Content: compiledEntryContent, + }) + for _, file := range sourceFiles { + relativePath, err := normalizeBundleFilePath(file.Path) + if err != nil { + return ImportedBundle{}, err + } + if relativePath == compiledEntryFile || isTypeScriptSourceFile(relativePath) { + continue + } + files = append(files, ImportedBundleFile{ + Path: relativePath, + Content: file.Content, + }) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + + bundle := ImportedBundle{ + Record: record, + Files: files, + } + if err := validateImportedBundle(bundle.Record, bundle.Files); err != nil { + return ImportedBundle{}, err + } + return bundle, nil +} + +func validateTypeScriptSourceFiles(files []ImportedBundleFile) error { + fileIndex := make(map[string][]byte, len(files)) + for _, file := range files { + relativePath, err := normalizeBundleFilePath(file.Path) + if err != nil { + return err + } + fileIndex[relativePath] = file.Content + } + return validateImportedPackageJSONFiles(fileIndex) +} + +func buildTypeScriptEntry(packageRoot string, entryRelPath string) (string, []byte, error) { + compiledEntryFile := compiledTypeScriptEntryFile(entryRelPath) + entryAbsPath := filepath.Join(packageRoot, filepath.FromSlash(entryRelPath)) + outfile := filepath.Join(packageRoot, filepath.FromSlash(compiledEntryFile)) + + result := api.Build(api.BuildOptions{ + AbsWorkingDir: packageRoot, + EntryPoints: []string{entryAbsPath}, + Outfile: outfile, + Bundle: true, + Write: false, + Platform: api.PlatformNode, + Format: api.FormatCommonJS, + Target: api.ES2020, + Sourcemap: api.SourceMapNone, + SourcesContent: api.SourcesContentExclude, + LegalComments: api.LegalCommentsNone, + LogLevel: api.LogLevelSilent, + Plugins: []api.Plugin{ + buildTypeScriptImportGuardPlugin(), + }, + }) + if len(result.Errors) > 0 { + return "", nil, fmt.Errorf("TypeScript 构建失败: %s", formatTypeScriptBuildMessages(result.Errors)) + } + + for _, output := range result.OutputFiles { + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(output.Path)), ".map") { + continue + } + return compiledEntryFile, output.Contents, nil + } + + return "", nil, fmt.Errorf("TypeScript 构建失败: 未生成输出文件") +} + +func buildTypeScriptImportGuardPlugin() api.Plugin { + return api.Plugin{ + Name: "automation-typescript-import-guard", + Setup: func(build api.PluginBuild) { + build.OnResolve(api.OnResolveOptions{Filter: `^[^./].*`}, func(args api.OnResolveArgs) (api.OnResolveResult, error) { + if args.Kind == api.ResolveEntryPoint { + return api.OnResolveResult{}, nil + } + specifier := strings.TrimSpace(args.Path) + if isAllowedRuntimeModule(specifier) { + return api.OnResolveResult{ + Path: specifier, + External: true, + }, nil + } + return api.OnResolveResult{}, fmt.Errorf("发现不受支持的外部依赖 %q,只允许相对路径、Node 内置模块、playwright、playwright-core", specifier) + }) + build.OnResolve(api.OnResolveOptions{Filter: `^(?:/|[A-Za-z]:[\\/])`}, func(args api.OnResolveArgs) (api.OnResolveResult, error) { + if args.Kind == api.ResolveEntryPoint { + return api.OnResolveResult{}, nil + } + return api.OnResolveResult{}, fmt.Errorf("不支持绝对路径依赖 %q", strings.TrimSpace(args.Path)) + }) + }, + } +} + +func compiledTypeScriptEntryFile(entryRelPath string) string { + normalized := path.Clean(filepath.ToSlash(strings.TrimSpace(entryRelPath))) + dir := path.Dir(normalized) + baseName := strings.TrimSuffix(path.Base(normalized), path.Ext(normalized)) + if baseName == "" || baseName == "." { + baseName = "index" + } + + compiledName := baseName + ".cjs" + if dir == "." || dir == "/" || dir == "" { + return compiledName + } + return path.Join(dir, compiledName) +} + +func isTypeScriptSourceFile(filePath string) bool { + switch strings.ToLower(path.Ext(strings.TrimSpace(filepath.ToSlash(filePath)))) { + case ".ts", ".cts", ".mts": + return true + default: + return false + } +} + +func appendTypeScriptBuildNote(notes string) string { + line := "构建: TypeScript -> CommonJS" + trimmed := strings.TrimSpace(notes) + if trimmed == "" { + return line + } + if strings.Contains(trimmed, line) { + return trimmed + } + return trimmed + "\n" + line +} + +func formatTypeScriptBuildMessages(messages []api.Message) string { + if len(messages) == 0 { + return "unknown build error" + } + + parts := make([]string, 0, len(messages)) + for _, message := range messages { + text := strings.TrimSpace(message.Text) + if message.Location != nil && strings.TrimSpace(message.Location.File) != "" { + text = fmt.Sprintf("%s:%d:%d %s", filepath.ToSlash(message.Location.File), message.Location.Line, message.Location.Column, text) + } + if text != "" { + parts = append(parts, text) + } + } + if len(parts) == 0 { + return "unknown build error" + } + return strings.Join(parts, "; ") +} diff --git a/backend/internal/automation/script_importer.go b/backend/internal/automation/script_importer.go new file mode 100644 index 00000000..eaa0240d --- /dev/null +++ b/backend/internal/automation/script_importer.go @@ -0,0 +1,35 @@ +package automation + +const ( + maxImportedBundleFiles = 256 + maxImportedBundleBytes = 16 << 20 +) + +var importManifestCandidates = []string{ + "automation.script.json", + "ant-automation.json", + "manifest.json", +} + +type scriptImportEnvelope struct { + Format string `json:"format"` + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + Manifest map[string]any `json:"manifest"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + Selector any `json:"selector"` + SelectorText any `json:"selectorText"` + Params any `json:"params"` + ParamsText any `json:"paramsText"` + Script string `json:"script"` + ScriptText string `json:"scriptText"` + Notes string `json:"notes"` + TargetConfig map[string]any `json:"targetConfig"` + Source map[string]any `json:"source"` + Files []scriptTemplateFile `json:"files"` +} diff --git a/backend/internal/automation/script_importer_bundle.go b/backend/internal/automation/script_importer_bundle.go new file mode 100644 index 00000000..db5de0b9 --- /dev/null +++ b/backend/internal/automation/script_importer_bundle.go @@ -0,0 +1,100 @@ +package automation + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func collectImportedBundleFiles(root string) ([]ImportedBundleFile, error) { + files := make([]ImportedBundleFile, 0, 8) + totalSize := 0 + + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relativePath, err := filepath.Rel(root, path) + if err != nil { + return err + } + relativePath = filepath.ToSlash(relativePath) + if relativePath == "." { + return nil + } + + if entry.IsDir() { + name := strings.ToLower(entry.Name()) + if name == ".git" { + return filepath.SkipDir + } + if name == "node_modules" { + return fmt.Errorf("script bundle must not include node_modules") + } + return nil + } + + if relativePath == "manifest.json" || relativePath == "automation.script.json" || relativePath == "ant-automation.json" { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + totalSize += len(content) + if totalSize > maxImportedBundleBytes { + return fmt.Errorf("script bundle is too large") + } + if len(files) >= maxImportedBundleFiles { + return fmt.Errorf("script bundle contains too many files") + } + + files = append(files, ImportedBundleFile{ + Path: relativePath, + Content: content, + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("collect script bundle files failed: %w", err) + } + + return files, nil +} + +func resolveImportManifest(root string) (string, error) { + for _, candidate := range importManifestCandidates { + targetPath := filepath.Join(root, candidate) + if _, err := os.Stat(targetPath); err == nil { + return targetPath, nil + } + } + return "", nil +} + +func resolvePathUnderRoot(root string, target string) (string, error) { + cleanRoot := filepath.Clean(strings.TrimSpace(root)) + if cleanRoot == "" || cleanRoot == "." { + return "", fmt.Errorf("script root path is required") + } + + candidate := cleanRoot + if strings.TrimSpace(target) != "" { + candidate = filepath.Clean(filepath.Join(cleanRoot, target)) + } + + relativePath, err := filepath.Rel(cleanRoot, candidate) + if err != nil { + return "", fmt.Errorf("resolve script path failed: %w", err) + } + relativePath = filepath.ToSlash(relativePath) + if relativePath == ".." || strings.HasPrefix(relativePath, "../") { + return "", fmt.Errorf("script path escapes package root") + } + + return candidate, nil +} diff --git a/backend/internal/automation/script_importer_directory.go b/backend/internal/automation/script_importer_directory.go new file mode 100644 index 00000000..2d728aac --- /dev/null +++ b/backend/internal/automation/script_importer_directory.go @@ -0,0 +1,147 @@ +package automation + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func ImportBundleFromDirectory(rootDir string, targetPath string, sourceLabel string) (ImportedBundle, error) { + return ImportBundleFromDirectoryWithOptions(rootDir, targetPath, sourceLabel, ImportOptions{}) +} + +func ImportBundleFromDirectoryWithOptions(rootDir string, targetPath string, sourceLabel string, options ImportOptions) (ImportedBundle, error) { + baseDir := filepath.Clean(strings.TrimSpace(rootDir)) + if baseDir == "" || baseDir == "." { + return ImportedBundle{}, fmt.Errorf("script directory is required") + } + + resolvedPath, err := resolvePathUnderRoot(baseDir, targetPath) + if err != nil { + return ImportedBundle{}, err + } + + info, err := os.Stat(resolvedPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("stat script path failed: %w", err) + } + + if !info.IsDir() { + for _, candidate := range importManifestCandidates { + if strings.EqualFold(filepath.Base(resolvedPath), candidate) { + return ImportBundleFromDirectoryWithOptions(filepath.Dir(resolvedPath), "", sourceLabel, options) + } + } + return ImportBundleFromFileWithOptions(resolvedPath, sourceLabel, options) + } + + manifestPath, err := resolveImportManifest(resolvedPath) + if err != nil { + return ImportedBundle{}, err + } + + if manifestPath == "" { + entryCandidates := []string{"index.cjs", "index.js", "index.mjs"} + if options.AllowTypeScriptBuild { + entryCandidates = append(entryCandidates, "index.ts", "index.cts", "index.mts") + } + for _, candidate := range entryCandidates { + entryPath := filepath.Join(resolvedPath, candidate) + if _, statErr := os.Stat(entryPath); statErr == nil { + if isTypeScriptSourceFile(candidate) { + return importTypeScriptDirectoryBundle(resolvedPath, entryPath, map[string]any{ + "name": strings.TrimSpace(filepath.Base(resolvedPath)), + "type": "playwright-cdp", + "entryFile": candidate, + }, sourceLabel) + } + return importDirectoryBundle(resolvedPath, entryPath, map[string]any{ + "name": strings.TrimSpace(filepath.Base(resolvedPath)), + "type": "playwright-cdp", + "entryFile": candidate, + }, sourceLabel) + } + } + return ImportedBundle{}, fmt.Errorf("no supported script manifest or entry file found in %s", resolvedPath) + } + + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("read script manifest failed: %w", err) + } + + descriptor, err := parseImportManifest(manifestData) + if err != nil { + return ImportedBundle{}, err + } + + entryFile := normalizeScriptEntryFile(mapStringValueAny(descriptor, "entryFile")) + if isTypeScriptSourceFile(entryFile) { + if !options.AllowTypeScriptBuild { + return ImportedBundle{}, fmt.Errorf("当前环境未开启 TypeScript 脚本构建支持,暂不支持 .ts / .mts / .cts 入口") + } + entryPath := filepath.Join(resolvedPath, filepath.FromSlash(entryFile)) + if _, err := os.Stat(entryPath); err != nil { + return ImportedBundle{}, fmt.Errorf("entry file %s not found", entryFile) + } + return importTypeScriptDirectoryBundle(resolvedPath, entryPath, descriptor, sourceLabel) + } + + entryPath := filepath.Join(resolvedPath, filepath.FromSlash(entryFile)) + if _, err := os.Stat(entryPath); err != nil { + return ImportedBundle{}, fmt.Errorf("entry file %s not found", entryFile) + } + + return importDirectoryBundle(resolvedPath, entryPath, descriptor, sourceLabel) +} + +func importDirectoryBundle(packageRoot string, entryPath string, descriptor map[string]any, sourceLabel string) (ImportedBundle, error) { + files, err := collectImportedBundleFiles(packageRoot) + if err != nil { + return ImportedBundle{}, err + } + + entryPath = filepath.Clean(entryPath) + entryRelPath, err := filepath.Rel(packageRoot, entryPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("resolve entry file failed: %w", err) + } + entryRelPath = filepath.ToSlash(entryRelPath) + + entryData, err := os.ReadFile(entryPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("read entry file failed: %w", err) + } + + record, err := buildImportedRecord(scriptImportEnvelope{ + Format: mapStringValueAny(descriptor, "format"), + PackageFormat: mapStringValueAny(descriptor, "packageFormat"), + ManifestVersion: mapIntValueAny(descriptor, "manifestVersion"), + Name: mapStringValueAny(descriptor, "name"), + Description: mapStringValueAny(descriptor, "description"), + Type: mapStringValueAny(descriptor, "type"), + Status: mapStringValueAny(descriptor, "status"), + EntryFile: entryRelPath, + Tags: mapStringSliceValue(descriptor, "tags"), + Selector: descriptor["selector"], + SelectorText: descriptor["selectorText"], + Params: descriptor["params"], + ParamsText: descriptor["paramsText"], + ScriptText: string(entryData), + Notes: appendImportSourceNote(mapStringValueAny(descriptor, "notes"), sourceLabel), + Source: mapObjectValue(descriptor, "source"), + }, filepath.Base(packageRoot), sourceLabel) + if err != nil { + return ImportedBundle{}, err + } + + bundle := ImportedBundle{ + Record: record, + Files: files, + } + if err := validateImportedBundle(bundle.Record, bundle.Files); err != nil { + return ImportedBundle{}, err + } + return bundle, nil +} diff --git a/backend/internal/automation/script_importer_file.go b/backend/internal/automation/script_importer_file.go new file mode 100644 index 00000000..fb788662 --- /dev/null +++ b/backend/internal/automation/script_importer_file.go @@ -0,0 +1,34 @@ +package automation + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func ImportBundleFromFile(path string, sourceLabel string) (ImportedBundle, error) { + return ImportBundleFromFileWithOptions(path, sourceLabel, ImportOptions{}) +} + +func ImportBundleFromFileWithOptions(path string, sourceLabel string, options ImportOptions) (ImportedBundle, error) { + normalizedPath := strings.TrimSpace(path) + if normalizedPath == "" { + return ImportedBundle{}, fmt.Errorf("script file path is required") + } + for _, candidate := range importManifestCandidates { + if strings.EqualFold(filepath.Base(normalizedPath), candidate) { + return ImportBundleFromDirectoryWithOptions(filepath.Dir(normalizedPath), "", sourceLabel, options) + } + } + if strings.EqualFold(filepath.Ext(normalizedPath), ".zip") { + return ImportBundleFromZipWithOptions(normalizedPath, sourceLabel, options) + } + + data, err := os.ReadFile(normalizedPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("read script file failed: %w", err) + } + + return ImportBundleFromBytesWithOptions(filepath.Base(normalizedPath), data, sourceLabel, options) +} diff --git a/backend/internal/automation/script_importer_inline.go b/backend/internal/automation/script_importer_inline.go new file mode 100644 index 00000000..2e66d24e --- /dev/null +++ b/backend/internal/automation/script_importer_inline.go @@ -0,0 +1,123 @@ +package automation + +import ( + "bytes" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +func ImportBundleFromBytes(nameHint string, data []byte, sourceLabel string) (ImportedBundle, error) { + return ImportBundleFromBytesWithOptions(nameHint, data, sourceLabel, ImportOptions{}) +} + +func ImportBundleFromBytesWithOptions(nameHint string, data []byte, sourceLabel string, options ImportOptions) (ImportedBundle, error) { + if len(bytes.TrimSpace(data)) == 0 { + return ImportedBundle{}, fmt.Errorf("script content is empty") + } + if isZipArchiveData(nameHint, data) { + return importBundleFromZipBytes(nameHint, data, sourceLabel, options) + } + + inline, inlineErr := importInlineBundle(filepath.Base(nameHint), data, sourceLabel) + if inlineErr == nil { + return inline, nil + } + + ext := strings.ToLower(filepath.Ext(nameHint)) + if ext == ".json" { + for _, candidate := range importManifestCandidates { + if strings.EqualFold(filepath.Base(nameHint), candidate) { + return ImportedBundle{}, fmt.Errorf("manifest file needs to be imported from a directory or git repository") + } + } + return ImportedBundle{}, inlineErr + } + if ext == ".js" || ext == ".cjs" || ext == ".mjs" { + return importPlainScriptBundle(filepath.Base(nameHint), data, sourceLabel) + } + if isTypeScriptSourceFile(nameHint) { + if !options.AllowTypeScriptBuild { + return ImportedBundle{}, fmt.Errorf("当前环境未开启 TypeScript 脚本构建支持,暂不支持 .ts / .mts / .cts 入口") + } + return importTypeScriptSingleFileBundle(filepath.Base(nameHint), data, sourceLabel) + } + + for _, candidate := range importManifestCandidates { + if strings.EqualFold(filepath.Base(nameHint), candidate) { + return ImportedBundle{}, fmt.Errorf("manifest file needs to be imported from a directory or git repository") + } + } + + return importPlainScriptBundle(filepath.Base(nameHint), data, sourceLabel) +} + +func importInlineBundle(nameHint string, data []byte, sourceLabel string) (ImportedBundle, error) { + var envelope scriptImportEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + return ImportedBundle{}, fmt.Errorf("import content is not valid JSON") + } + + record, err := buildImportedRecord(envelope, trimExtension(filepath.Base(nameHint)), sourceLabel) + if err != nil { + return ImportedBundle{}, err + } + record.Notes = appendImportSourceNote(record.Notes, sourceLabel) + + extraFiles, err := decodeScriptTemplateFiles(envelope.Files, record.EntryFile) + if err != nil { + return ImportedBundle{}, err + } + + bundle := ImportedBundle{ + Record: record, + Files: append([]ImportedBundleFile{ + { + Path: record.EntryFile, + Content: []byte(record.ScriptText), + }, + }, extraFiles...), + } + if err := validateImportedBundle(bundle.Record, bundle.Files); err != nil { + return ImportedBundle{}, err + } + return bundle, nil +} + +func importPlainScriptBundle(nameHint string, data []byte, sourceLabel string) (ImportedBundle, error) { + record, _ := normalizeScriptRecord(ScriptRecord{ + PackageFormat: defaultScriptPackageFormat, + ManifestVersion: defaultScriptManifestVersion, + ID: uuid.NewString(), + Name: trimExtension(filepath.Base(nameHint)), + Description: "", + Type: "playwright-cdp", + Status: "draft", + EntryFile: normalizeScriptEntryFile(defaultEntryFileForName(nameHint)), + ScriptText: string(data), + Notes: appendImportSourceNote("", sourceLabel), + Source: inferImportSource(sourceLabel), + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + SelectorText: "", + ParamsText: "", + }, ScriptRecord{}) + + bundle := ImportedBundle{ + Record: record, + Files: []ImportedBundleFile{ + { + Path: record.EntryFile, + Content: []byte(record.ScriptText), + }, + }, + } + if err := validateImportedBundle(bundle.Record, bundle.Files); err != nil { + return ImportedBundle{}, err + } + return bundle, nil +} diff --git a/backend/internal/automation/script_importer_manifest.go b/backend/internal/automation/script_importer_manifest.go new file mode 100644 index 00000000..372ddfe9 --- /dev/null +++ b/backend/internal/automation/script_importer_manifest.go @@ -0,0 +1,165 @@ +package automation + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +func parseImportManifest(data []byte) (map[string]any, error) { + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + return nil, fmt.Errorf("script manifest is not valid JSON") + } + + if manifest, ok := parsed["manifest"].(map[string]any); ok { + for key, value := range parsed { + if key == "manifest" { + continue + } + if _, exists := manifest[key]; !exists { + manifest[key] = value + } + } + if mapStringValueAny(manifest, "script") == "" && mapStringValueAny(manifest, "scriptText") == "" && parsed["script"] == nil && parsed["scriptText"] == nil { + return manifest, nil + } + } + + if mapStringValueAny(parsed, "script") != "" || mapStringValueAny(parsed, "scriptText") != "" { + return parsed, nil + } + + if mapStringValueAny(parsed, "entryFile") == "" { + return nil, fmt.Errorf("script manifest is missing entryFile") + } + return parsed, nil +} + +func buildImportedRecord(envelope scriptImportEnvelope, defaultName string, sourceLabel string) (ScriptRecord, error) { + descriptor := map[string]any{} + if envelope.Manifest != nil { + for key, value := range envelope.Manifest { + descriptor[key] = value + } + } + mergeDescriptorValue(descriptor, "format", envelope.Format) + mergeDescriptorValue(descriptor, "packageFormat", envelope.PackageFormat) + if envelope.ManifestVersion > 0 { + descriptor["manifestVersion"] = envelope.ManifestVersion + } + mergeDescriptorValue(descriptor, "name", envelope.Name) + mergeDescriptorValue(descriptor, "description", envelope.Description) + mergeDescriptorValue(descriptor, "type", envelope.Type) + mergeDescriptorValue(descriptor, "status", envelope.Status) + mergeDescriptorValue(descriptor, "entryFile", envelope.EntryFile) + if len(envelope.Tags) > 0 { + descriptor["tags"] = envelope.Tags + } + mergeDescriptorValue(descriptor, "notes", envelope.Notes) + if envelope.TargetConfig != nil { + descriptor["targetConfig"] = envelope.TargetConfig + } + if envelope.Selector != nil { + descriptor["selector"] = envelope.Selector + } + if envelope.SelectorText != nil { + descriptor["selectorText"] = envelope.SelectorText + } + if envelope.Params != nil { + descriptor["params"] = envelope.Params + } + if envelope.ParamsText != nil { + descriptor["paramsText"] = envelope.ParamsText + } + if envelope.Source != nil { + descriptor["source"] = envelope.Source + } + + scriptText := firstNonEmpty(strings.TrimSpace(envelope.Script), strings.TrimSpace(envelope.ScriptText)) + if scriptText == "" { + if raw, exists := descriptor["script"]; exists { + scriptText = strings.TrimSpace(fmt.Sprint(raw)) + } + } + if scriptText == "" { + if raw, exists := descriptor["scriptText"]; exists { + scriptText = strings.TrimSpace(fmt.Sprint(raw)) + } + } + if scriptText == "" { + return ScriptRecord{}, fmt.Errorf("inline script content is missing") + } + + now := time.Now().Format(time.RFC3339) + source := inferImportSource(sourceLabel) + if explicitSource, ok := descriptor["source"].(map[string]any); ok { + source = mergeImportedSource(source, explicitSource) + } + return normalizeScriptRecord(ScriptRecord{ + PackageFormat: normalizeScriptPackageFormat(firstNonEmpty(mapStringValueAny(descriptor, "packageFormat"), mapStringValueAny(descriptor, "format"))), + ManifestVersion: normalizeScriptManifestVersion(mapIntValueAny(descriptor, "manifestVersion"), 0), + ID: uuid.NewString(), + Name: firstNonEmpty(mapStringValueAny(descriptor, "name"), defaultName, "导入脚本"), + Description: mapStringValueAny(descriptor, "description"), + Type: mapStringValueAny(descriptor, "type"), + Status: "draft", + EntryFile: normalizeScriptEntryFile(firstNonEmpty(mapStringValueAny(descriptor, "entryFile"), defaultEntryFileForName(defaultName))), + Tags: mapStringSliceValue(descriptor, "tags"), + SelectorText: stringifyImportJSONValue(firstNonNil(descriptor["selectorText"], descriptor["selector"])), + ParamsText: stringifyImportJSONValue(firstNonNil(descriptor["paramsText"], descriptor["params"])), + ScriptText: scriptText, + Notes: mapStringValueAny(descriptor, "notes"), + TargetConfig: mapScriptTargetConfigValue(descriptor["targetConfig"]), + Source: source, + CreatedAt: now, + UpdatedAt: now, + }, ScriptRecord{}) +} + +func appendImportSourceNote(notes string, sourceLabel string) string { + sourceLabel = strings.TrimSpace(sourceLabel) + if sourceLabel == "" { + return strings.TrimSpace(notes) + } + + line := "来源: " + sourceLabel + if strings.TrimSpace(notes) == "" { + return line + } + if strings.Contains(notes, line) { + return strings.TrimSpace(notes) + } + return strings.TrimSpace(notes) + "\n" + line +} + +func defaultEntryFileForName(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".js", ".cjs", ".mjs": + return filepath.Base(name) + default: + return defaultScriptEntryFile + } +} + +func trimExtension(name string) string { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "导入脚本" + } + base := filepath.Base(trimmed) + ext := filepath.Ext(base) + if ext == "" { + return base + } + nameWithoutExt := strings.TrimSpace(strings.TrimSuffix(base, ext)) + if nameWithoutExt == "" { + return base + } + return nameWithoutExt +} diff --git a/backend/internal/automation/script_importer_mapping.go b/backend/internal/automation/script_importer_mapping.go new file mode 100644 index 00000000..beb9e5f0 --- /dev/null +++ b/backend/internal/automation/script_importer_mapping.go @@ -0,0 +1,205 @@ +package automation + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" +) + +func stringifyImportJSONValue(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return strings.TrimSpace(fmt.Sprint(value)) + } + return string(bytes.TrimSpace(encoded)) +} + +func mergeDescriptorValue(target map[string]any, key string, value string) { + if strings.TrimSpace(value) == "" { + return + } + if _, exists := target[key]; exists { + return + } + target[key] = strings.TrimSpace(value) +} + +func mapStringSliceValue(payload map[string]any, key string) []string { + raw, exists := payload[key] + if !exists || raw == nil { + return nil + } + items, ok := raw.([]any) + if !ok { + if stringsValue, ok := raw.([]string); ok { + return normalizeScriptTags(stringsValue) + } + return nil + } + result := make([]string, 0, len(items)) + for _, item := range items { + value := strings.TrimSpace(fmt.Sprint(item)) + if value != "" { + result = append(result, value) + } + } + return normalizeScriptTags(result) +} + +func mapStringValueAny(payload map[string]any, key string) string { + if payload == nil { + return "" + } + value, exists := payload[key] + if !exists || value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func mapIntValueAny(payload map[string]any, key string) int { + if payload == nil { + return 0 + } + value, exists := payload[key] + if !exists || value == nil { + return 0 + } + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + if parsed, err := typed.Int64(); err == nil { + return int(parsed) + } + } + return 0 +} + +func mapObjectValue(payload map[string]any, key string) map[string]any { + if payload == nil { + return nil + } + value, exists := payload[key] + if !exists || value == nil { + return nil + } + if object, ok := value.(map[string]any); ok { + return object + } + return nil +} + +func inferImportSource(sourceLabel string) ScriptSource { + now := time.Now().Format(time.RFC3339) + trimmed := strings.TrimSpace(sourceLabel) + source := ScriptSource{ + ImportedAt: now, + } + switch { + case strings.HasPrefix(trimmed, "本地文件 "): + source.Type = "local-file" + source.URI = strings.TrimSpace(strings.TrimPrefix(trimmed, "本地文件 ")) + case strings.HasPrefix(trimmed, "本地目录 "): + source.Type = "local-dir" + source.URI = strings.TrimSpace(strings.TrimPrefix(trimmed, "本地目录 ")) + case strings.HasPrefix(trimmed, "远程地址 "): + source.Type = "remote-url" + source.URI = strings.TrimSpace(strings.TrimPrefix(trimmed, "远程地址 ")) + case strings.HasPrefix(trimmed, "Git "): + source.Type = "git" + rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "Git ")) + repo := rest + if index := strings.Index(rest, " : "); index >= 0 { + source.Path = strings.TrimSpace(rest[index+3:]) + repo = strings.TrimSpace(rest[:index]) + } + if index := strings.Index(repo, " @ "); index >= 0 { + source.Ref = strings.TrimSpace(repo[index+3:]) + repo = strings.TrimSpace(repo[:index]) + } + source.URI = repo + default: + if trimmed != "" { + source.Type = "manual" + source.URI = trimmed + } + } + return source +} + +func mergeImportedSource(base ScriptSource, override map[string]any) ScriptSource { + if override == nil { + return base + } + next := base + if value := mapStringValueAny(override, "type"); value != "" { + next.Type = value + } + if value := firstNonEmpty(mapStringValueAny(override, "uri"), mapStringValueAny(override, "url")); value != "" { + next.URI = value + } + if value := mapStringValueAny(override, "ref"); value != "" { + next.Ref = value + } + if value := mapStringValueAny(override, "path"); value != "" { + next.Path = value + } + if value := mapStringValueAny(override, "importedAt"); value != "" { + next.ImportedAt = value + } + return next +} + +func mapScriptTargetConfigValue(value any) ScriptTargetConfig { + object, ok := value.(map[string]any) + if !ok || object == nil { + return ScriptTargetConfig{} + } + + return ScriptTargetConfig{ + Mode: mapStringValueAny(object, "mode"), + Selector: mapScriptTargetSelectorValue(object["selector"]), + TemplateSelector: mapScriptTargetSelectorValue(object["templateSelector"]), + CreateNameTemplate: mapStringValueAny(object, "createNameTemplate"), + } +} + +func mapScriptTargetSelectorValue(value any) ScriptTargetSelector { + object, ok := value.(map[string]any) + if !ok || object == nil { + return ScriptTargetSelector{} + } + + return ScriptTargetSelector{ + Code: firstNonEmpty(mapStringValueAny(object, "code"), mapStringValueAny(object, "launchCode")), + ProfileID: mapStringValueAny(object, "profileId"), + ProfileName: mapStringValueAny(object, "profileName"), + GroupID: mapStringValueAny(object, "groupId"), + Keywords: mapStringSliceValue(object, "keywords"), + Tags: mapStringSliceValue(object, "tags"), + } +} + +func firstNonNil(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} diff --git a/backend/internal/automation/script_importer_test.go b/backend/internal/automation/script_importer_test.go new file mode 100644 index 00000000..58d4850c --- /dev/null +++ b/backend/internal/automation/script_importer_test.go @@ -0,0 +1,388 @@ +package automation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestImportBundleFromBytesSupportsInlineJSONPackage(t *testing.T) { + bundle, err := ImportBundleFromBytes("demo-script.json", []byte(`{ + "manifest": { + "name": "远程示例", + "description": "用于导入测试", + "type": "playwright-cdp", + "entryFile": "index.cjs", + "tags": ["demo", "remote"] + }, + "selector": { + "code": "DEMO_001" + }, + "params": { + "url": "https://example.com" + }, + "script": "module.exports.run = async () => ({ ok: true })" +}`), "远程地址 https://example.com/demo-script.json") + if err != nil { + t.Fatalf("ImportBundleFromBytes returned error: %v", err) + } + + if bundle.Record.Name != "远程示例" { + t.Fatalf("unexpected script name: %s", bundle.Record.Name) + } + if bundle.Record.PackageFormat != defaultScriptPackageFormat { + t.Fatalf("unexpected package format: %s", bundle.Record.PackageFormat) + } + if bundle.Record.ManifestVersion != defaultScriptManifestVersion { + t.Fatalf("unexpected manifest version: %d", bundle.Record.ManifestVersion) + } + if bundle.Record.Type != "playwright-cdp" { + t.Fatalf("unexpected script type: %s", bundle.Record.Type) + } + if bundle.Record.Status != "draft" { + t.Fatalf("expected imported script to be draft, got %s", bundle.Record.Status) + } + if strings.TrimSpace(bundle.Record.SelectorText) != "{\n \"code\": \"DEMO_001\"\n}" { + t.Fatalf("unexpected selector text: %s", bundle.Record.SelectorText) + } + if len(bundle.Files) != 1 || bundle.Files[0].Path != "index.cjs" { + t.Fatalf("unexpected bundle files: %+v", bundle.Files) + } + if bundle.Record.Source.Type != "remote-url" { + t.Fatalf("expected remote-url source, got %+v", bundle.Record.Source) + } + if bundle.Record.Source.URI != "https://example.com/demo-script.json" { + t.Fatalf("unexpected source uri: %+v", bundle.Record.Source) + } + if !strings.Contains(bundle.Record.Notes, "来源: 远程地址 https://example.com/demo-script.json") { + t.Fatalf("expected notes to include source, got %q", bundle.Record.Notes) + } +} + +func TestImportBundleFromBytesRejectsInvalidJSONTemplate(t *testing.T) { + if _, err := ImportBundleFromBytes("broken-template.json", []byte(`{"manifest":`), "文本导入"); err == nil { + t.Fatalf("expected invalid JSON template to fail") + } +} + +func TestImportBundleFromDirectorySupportsNestedEntryAndExtraFiles(t *testing.T) { + rootDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(rootDir, "scripts", "helpers"), 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "Git 示例", + "description": "包含额外依赖文件", + "type": "playwright-cdp", + "entryFile": "scripts/index.cjs", + "selector": { + "code": "DEMO_GIT" + } +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "scripts", "index.cjs"), []byte(`const helper = require('./helpers/helper.cjs') + +module.exports.run = async () => helper.run()`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "scripts", "helpers", "helper.cjs"), []byte(`module.exports.run = async () => ({ ok: true })`), 0o644); err != nil { + t.Fatalf("write helper file failed: %v", err) + } + + bundle, err := ImportBundleFromDirectory(rootDir, "", "Git https://example.com/demo.git") + if err != nil { + t.Fatalf("ImportBundleFromDirectory returned error: %v", err) + } + + if bundle.Record.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected entry file: %s", bundle.Record.EntryFile) + } + if bundle.Record.Source.Type != "git" { + t.Fatalf("expected git source, got %+v", bundle.Record.Source) + } + if !strings.Contains(bundle.Record.ScriptText, "require('./helpers/helper.cjs')") { + t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) + } + + paths := make([]string, 0, len(bundle.Files)) + for _, file := range bundle.Files { + paths = append(paths, file.Path) + } + if !containsString(paths, "scripts/index.cjs") || !containsString(paths, "scripts/helpers/helper.cjs") { + t.Fatalf("expected nested files to be included, got %+v", paths) + } +} + +func TestScriptStoreImportBundlePersistsNestedFiles(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + record, err := store.ImportBundle(ImportedBundle{ + Record: ScriptRecord{ + ID: "git-imported", + Name: "Git 导入脚本", + Type: "playwright-cdp", + Status: "draft", + EntryFile: "scripts/index.cjs", + ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + }, + Files: []ImportedBundleFile{ + { + Path: "scripts/index.cjs", + Content: []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), + }, + { + Path: "scripts/helpers/helper.cjs", + Content: []byte("module.exports.run = async () => ({ ok: true })"), + }, + }, + }) + if err != nil { + t.Fatalf("ImportBundle returned error: %v", err) + } + + if record.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected entry file: %s", record.EntryFile) + } + if record.PackageFormat != defaultScriptPackageFormat { + t.Fatalf("unexpected package format: %s", record.PackageFormat) + } + if _, err := os.Stat(filepath.Join(store.rootDir, "git-imported", "scripts", "helpers", "helper.cjs")); err != nil { + t.Fatalf("expected helper file to exist: %v", err) + } + + loaded, err := store.Get("git-imported") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if loaded.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected loaded entry file: %s", loaded.EntryFile) + } + if !strings.Contains(loaded.ScriptText, "helper.run") { + t.Fatalf("unexpected loaded script text: %s", loaded.ScriptText) + } +} + +func TestImportBundleFromDirectoryRejectsNodeModules(t *testing.T) { + rootDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(rootDir, "node_modules", "left-pad"), 0o755); err != nil { + t.Fatalf("create node_modules failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "Bad Package", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "index.cjs"), []byte(`module.exports.run = async () => ({ ok: true })`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + + if _, err := ImportBundleFromDirectory(rootDir, "", "本地目录 "+rootDir); err == nil || !strings.Contains(err.Error(), "node_modules") { + t.Fatalf("expected node_modules validation error, got %v", err) + } +} + +func TestImportBundleFromDirectoryRejectsPackageJSONDependencies(t *testing.T) { + rootDir := t.TempDir() + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "Bad Package", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "package.json"), []byte(`{ + "name": "bad-package", + "dependencies": { + "axios": "^1.0.0" + } +}`), 0o644); err != nil { + t.Fatalf("write package.json failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "index.cjs"), []byte(`module.exports.run = async () => ({ ok: true })`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + + if _, err := ImportBundleFromDirectory(rootDir, "", "本地目录 "+rootDir); err == nil || !strings.Contains(err.Error(), "dependencies") { + t.Fatalf("expected package dependencies validation error, got %v", err) + } +} + +func TestImportBundleFromDirectoryRejectsExternalDependencySpecifier(t *testing.T) { + rootDir := t.TempDir() + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "External Dependency", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "index.cjs"), []byte(`const axios = require('axios') +module.exports.run = async () => ({ ok: !!axios })`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + + if _, err := ImportBundleFromDirectory(rootDir, "", "本地目录 "+rootDir); err == nil || !strings.Contains(err.Error(), `axios`) { + t.Fatalf("expected external dependency validation error, got %v", err) + } +} + +func TestImportBundleFromDirectoryRejectsMissingLocalDependency(t *testing.T) { + rootDir := t.TempDir() + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "Missing Local Dependency", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "index.cjs"), []byte(`const helper = require('./helpers/helper.cjs') +module.exports.run = async () => helper.run()`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + + if _, err := ImportBundleFromDirectory(rootDir, "", "本地目录 "+rootDir); err == nil || !strings.Contains(err.Error(), "本地依赖") { + t.Fatalf("expected missing local dependency validation error, got %v", err) + } +} + +func TestImportBundleFromDirectoryRejectsTypeScriptEntryWhenBuildDisabled(t *testing.T) { + rootDir := t.TempDir() + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "TypeScript Entry", + "type": "playwright-cdp", + "entryFile": "index.ts" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "index.ts"), []byte(`export async function run() { return { ok: true } }`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + + if _, err := ImportBundleFromDirectory(rootDir, "", "本地目录 "+rootDir); err == nil || !strings.Contains(err.Error(), "未开启 TypeScript 脚本构建支持") { + t.Fatalf("expected disabled TypeScript build error, got %v", err) + } +} + +func TestImportBundleFromBytesBuildsTypeScriptWhenEnabled(t *testing.T) { + bundle, err := ImportBundleFromBytesWithOptions("demo-script.ts", []byte(`export async function run() { return { ok: true, source: 'ts-single-file' } }`), "本地文件 demo-script.ts", ImportOptions{ + AllowTypeScriptBuild: true, + }) + if err != nil { + t.Fatalf("ImportBundleFromBytesWithOptions returned error: %v", err) + } + + if bundle.Record.EntryFile != "demo-script.cjs" { + t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) + } + if !strings.Contains(bundle.Record.ScriptText, "ts-single-file") { + t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) + } + if hasTypeScriptSource(bundle.Files) { + t.Fatalf("expected built bundle to omit TypeScript sources, got %+v", bundle.Files) + } +} + +func TestImportBundleFromDirectoryBuildsTypeScriptEntryWhenEnabled(t *testing.T) { + rootDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(rootDir, "scripts", "helpers"), 0o755); err != nil { + t.Fatalf("create helper dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "TypeScript Entry", + "type": "playwright-cdp", + "entryFile": "scripts/index.ts" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "scripts", "index.ts"), []byte(`import { helperValue } from './helpers/helper' + +export async function run() { + return { ok: helperValue, source: 'ts-dir' } +}`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "scripts", "helpers", "helper.ts"), []byte(`export const helperValue = true`), 0o644); err != nil { + t.Fatalf("write helper file failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "assets.json"), []byte(`{"ok":true}`), 0o644); err != nil { + t.Fatalf("write asset file failed: %v", err) + } + + bundle, err := ImportBundleFromDirectoryWithOptions(rootDir, "", "本地目录 "+rootDir, ImportOptions{ + AllowTypeScriptBuild: true, + }) + if err != nil { + t.Fatalf("ImportBundleFromDirectoryWithOptions returned error: %v", err) + } + + if bundle.Record.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) + } + if !strings.Contains(bundle.Record.ScriptText, "ts-dir") { + t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) + } + if hasTypeScriptSource(bundle.Files) { + t.Fatalf("expected built bundle to omit TypeScript sources, got %+v", bundle.Files) + } + if !hasBundleFile(bundle.Files, "assets.json", []byte(`{"ok":true}`)) { + t.Fatalf("expected non-TypeScript asset to be preserved, got %+v", bundle.Files) + } +} + +func TestImportBundleFromDirectoryAllowsBuiltinsAndPlaywrightModules(t *testing.T) { + rootDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(rootDir, "helpers"), 0o755); err != nil { + t.Fatalf("create helper dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "automation.script.json"), []byte(`{ + "name": "Supported Dependencies", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "index.cjs"), []byte(`const fs = require('fs') +const path = require('node:path') +const playwright = require('playwright') +const core = require('playwright-core') +const helper = require('./helpers/helper.cjs') + +module.exports.run = async () => ({ + ok: !!fs && !!path && !!playwright && !!core && helper.ok, +})`), 0o644); err != nil { + t.Fatalf("write entry file failed: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "helpers", "helper.cjs"), []byte(`module.exports = { ok: true }`), 0o644); err != nil { + t.Fatalf("write helper file failed: %v", err) + } + + bundle, err := ImportBundleFromDirectory(rootDir, "", "本地目录 "+rootDir) + if err != nil { + t.Fatalf("expected supported package to import, got %v", err) + } + if bundle.Record.Name != "Supported Dependencies" { + t.Fatalf("unexpected imported record: %+v", bundle.Record) + } +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func hasTypeScriptSource(files []ImportedBundleFile) bool { + for _, file := range files { + if isTypeScriptSourceFile(file.Path) { + return true + } + } + return false +} diff --git a/backend/internal/automation/script_package_directory.go b/backend/internal/automation/script_package_directory.go new file mode 100644 index 00000000..2a9ca3e9 --- /dev/null +++ b/backend/internal/automation/script_package_directory.go @@ -0,0 +1,60 @@ +package automation + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func WriteScriptPackageDirectory(dirPath string, bundle ImportedBundle) error { + normalizedPath := filepath.Clean(strings.TrimSpace(dirPath)) + if normalizedPath == "" || normalizedPath == "." { + return fmt.Errorf("script package directory path is required") + } + + record, files, err := collectScriptPackageExportFiles(bundle) + if err != nil { + return err + } + + manifestData, err := MarshalScriptPackageManifest(record) + if err != nil { + return fmt.Errorf("marshal script package manifest failed: %w", err) + } + + if info, err := os.Stat(normalizedPath); err == nil { + if info.IsDir() { + return fmt.Errorf("script package directory already exists") + } + return fmt.Errorf("script package path is not a directory") + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat script package directory failed: %w", err) + } + + parentDir := filepath.Dir(normalizedPath) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return fmt.Errorf("create script package parent dir failed: %w", err) + } + + tempDir, err := os.MkdirTemp(parentDir, filepath.Base(normalizedPath)+".tmp-*") + if err != nil { + return fmt.Errorf("create script package temp dir failed: %w", err) + } + defer os.RemoveAll(tempDir) + + if err := writeFileAtomic(filepath.Join(tempDir, scriptPackageManifestName), manifestData, 0o644); err != nil { + return fmt.Errorf("write script package manifest failed: %w", err) + } + for _, bundleFile := range files { + targetPath := filepath.Join(tempDir, filepath.FromSlash(bundleFile.Path)) + if err := writeFileAtomic(targetPath, bundleFile.Content, 0o644); err != nil { + return fmt.Errorf("write script package file %s failed: %w", bundleFile.Path, err) + } + } + + if err := os.Rename(tempDir, normalizedPath); err != nil { + return fmt.Errorf("move script package directory failed: %w", err) + } + return nil +} diff --git a/backend/internal/automation/script_package_directory_test.go b/backend/internal/automation/script_package_directory_test.go new file mode 100644 index 00000000..8c7f00bd --- /dev/null +++ b/backend/internal/automation/script_package_directory_test.go @@ -0,0 +1,58 @@ +package automation + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteScriptPackageDirectoryRoundTripsAdditionalFiles(t *testing.T) { + exportDir := filepath.Join(t.TempDir(), "demo-package") + + if err := WriteScriptPackageDirectory(exportDir, ImportedBundle{ + Record: ScriptRecord{ + ID: "dir-roundtrip", + Name: "目录导出", + Description: "包含额外文件", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + }, + Files: []ImportedBundleFile{ + { + Path: "scripts/index.cjs", + Content: []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), + }, + { + Path: "scripts/helpers/helper.cjs", + Content: []byte("module.exports.run = async () => ({ ok: true })"), + }, + { + Path: "assets/raw.bin", + Content: []byte{0x00, 0x01, 0x02, 0xff}, + }, + }, + }); err != nil { + t.Fatalf("WriteScriptPackageDirectory returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(exportDir, scriptPackageManifestName)); err != nil { + t.Fatalf("expected manifest to exist: %v", err) + } + + imported, err := ImportBundleFromDirectory(exportDir, "", "本地目录 "+exportDir) + if err != nil { + t.Fatalf("ImportBundleFromDirectory returned error: %v", err) + } + + if imported.Record.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected entry file: %s", imported.Record.EntryFile) + } + if !hasBundleFile(imported.Files, "scripts/helpers/helper.cjs", []byte("module.exports.run = async () => ({ ok: true })")) { + t.Fatalf("expected helper file to round-trip, got %+v", imported.Files) + } + if !hasBundleFile(imported.Files, "assets/raw.bin", []byte{0x00, 0x01, 0x02, 0xff}) { + t.Fatalf("expected binary file to round-trip, got %+v", imported.Files) + } +} diff --git a/backend/internal/automation/script_package_validator_bundle.go b/backend/internal/automation/script_package_validator_bundle.go new file mode 100644 index 00000000..621045df --- /dev/null +++ b/backend/internal/automation/script_package_validator_bundle.go @@ -0,0 +1,110 @@ +package automation + +import ( + "encoding/json" + "fmt" + "path" +) + +func validateImportedBundle(record ScriptRecord, files []ImportedBundleFile) error { + fileIndex, err := buildImportedBundleFileIndex(record, files) + if err != nil { + return err + } + + entryFile, err := normalizeBundleFilePath(record.EntryFile) + if err != nil { + return fmt.Errorf("脚本入口文件无效: %w", err) + } + if !isSupportedScriptModuleFile(entryFile) { + return fmt.Errorf("脚本入口文件必须是 .js / .cjs / .mjs,当前为 %s", path.Ext(entryFile)) + } + + if err := validateImportedPackageJSONFiles(fileIndex); err != nil { + return err + } + return validateReachableScriptModules(entryFile, fileIndex) +} + +func buildImportedBundleFileIndex(record ScriptRecord, files []ImportedBundleFile) (map[string][]byte, error) { + fileIndex := make(map[string][]byte, len(files)+1) + for _, file := range files { + relativePath, err := normalizeBundleFilePath(file.Path) + if err != nil { + return nil, err + } + if containsNodeModulesPath(relativePath) { + return nil, fmt.Errorf("脚本包不能包含 node_modules,请改成自包含脚本包") + } + fileIndex[relativePath] = file.Content + } + + entryFile, err := normalizeBundleFilePath(record.EntryFile) + if err != nil { + return nil, err + } + if _, exists := fileIndex[entryFile]; !exists { + fileIndex[entryFile] = []byte(record.ScriptText) + } + return fileIndex, nil +} + +func validateImportedPackageJSONFiles(fileIndex map[string][]byte) error { + for filePath, content := range fileIndex { + if path.Base(filePath) != "package.json" { + continue + } + + var pkg importedPackageJSON + if err := json.Unmarshal(content, &pkg); err != nil { + return fmt.Errorf("%s 不是合法的 package.json: %w", filePath, err) + } + + switch { + case len(pkg.Dependencies) > 0: + return fmt.Errorf("%s 包含 dependencies,当前脚本包不支持外部 npm 依赖", filePath) + case len(pkg.DevDependencies) > 0: + return fmt.Errorf("%s 包含 devDependencies,当前脚本包不支持依赖安装流程", filePath) + case len(pkg.PeerDependencies) > 0: + return fmt.Errorf("%s 包含 peerDependencies,当前脚本包不支持外部 npm 依赖", filePath) + case len(pkg.OptionalDependencies) > 0: + return fmt.Errorf("%s 包含 optionalDependencies,当前脚本包不支持外部 npm 依赖", filePath) + } + } + return nil +} + +func validateReachableScriptModules(entryFile string, fileIndex map[string][]byte) error { + queue := []string{entryFile} + visited := make(map[string]struct{}, len(fileIndex)) + + for len(queue) > 0 { + current := queue[len(queue)-1] + queue = queue[:len(queue)-1] + + if _, seen := visited[current]; seen { + continue + } + visited[current] = struct{}{} + + content, exists := fileIndex[current] + if !exists { + return fmt.Errorf("脚本入口 %s 不存在", current) + } + if !isSupportedScriptModuleFile(current) { + continue + } + + for _, specifier := range extractImportedModuleSpecifiers(string(content)) { + resolved, err := validateImportedSpecifier(current, specifier, fileIndex) + if err != nil { + return fmt.Errorf("%s: %w", current, err) + } + if resolved != "" && isSupportedScriptModuleFile(resolved) { + queue = append(queue, resolved) + } + } + } + + return nil +} diff --git a/backend/internal/automation/script_package_validator_patterns.go b/backend/internal/automation/script_package_validator_patterns.go new file mode 100644 index 00000000..1c48451e --- /dev/null +++ b/backend/internal/automation/script_package_validator_patterns.go @@ -0,0 +1,72 @@ +package automation + +import "regexp" + +var ( + scriptRequirePattern = regexp.MustCompile(`\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)`) + scriptDynamicImportRegex = regexp.MustCompile(`\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)`) + scriptImportFromPattern = regexp.MustCompile(`(?m)\bimport\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]`) + scriptExportFromPattern = regexp.MustCompile(`(?m)\bexport\s+[^'"]*?\s+from\s+['"]([^'"]+)['"]`) + nodeBuiltinModules = map[string]struct{}{ + "assert": {}, + "async_hooks": {}, + "buffer": {}, + "child_process": {}, + "cluster": {}, + "console": {}, + "constants": {}, + "crypto": {}, + "dgram": {}, + "diagnostics_channel": {}, + "dns": {}, + "domain": {}, + "events": {}, + "fs": {}, + "http": {}, + "http2": {}, + "https": {}, + "inspector": {}, + "module": {}, + "net": {}, + "os": {}, + "path": {}, + "perf_hooks": {}, + "process": {}, + "punycode": {}, + "querystring": {}, + "readline": {}, + "repl": {}, + "stream": {}, + "string_decoder": {}, + "sys": {}, + "timers": {}, + "tls": {}, + "trace_events": {}, + "tty": {}, + "url": {}, + "util": {}, + "v8": {}, + "vm": {}, + "wasi": {}, + "worker_threads": {}, + "zlib": {}, + } + supportedScriptModuleExtensions = map[string]struct{}{ + ".js": {}, + ".cjs": {}, + ".mjs": {}, + } + supportedLocalImportExtensions = map[string]struct{}{ + ".js": {}, + ".cjs": {}, + ".mjs": {}, + ".json": {}, + } +) + +type importedPackageJSON struct { + Dependencies map[string]any `json:"dependencies"` + DevDependencies map[string]any `json:"devDependencies"` + PeerDependencies map[string]any `json:"peerDependencies"` + OptionalDependencies map[string]any `json:"optionalDependencies"` +} diff --git a/backend/internal/automation/script_package_validator_resolve.go b/backend/internal/automation/script_package_validator_resolve.go new file mode 100644 index 00000000..57c5c377 --- /dev/null +++ b/backend/internal/automation/script_package_validator_resolve.go @@ -0,0 +1,161 @@ +package automation + +import ( + "fmt" + "path" + "regexp" + "strings" +) + +func extractImportedModuleSpecifiers(scriptText string) []string { + specifiers := make([]string, 0, 8) + seen := make(map[string]struct{}, 8) + + appendMatches := func(pattern *regexp.Regexp) { + for _, match := range pattern.FindAllStringSubmatch(scriptText, -1) { + if len(match) < 2 { + continue + } + specifier := strings.TrimSpace(match[1]) + if specifier == "" { + continue + } + if _, exists := seen[specifier]; exists { + continue + } + seen[specifier] = struct{}{} + specifiers = append(specifiers, specifier) + } + } + + appendMatches(scriptRequirePattern) + appendMatches(scriptDynamicImportRegex) + appendMatches(scriptImportFromPattern) + appendMatches(scriptExportFromPattern) + + return specifiers +} + +func validateImportedSpecifier(importerPath string, specifier string, fileIndex map[string][]byte) (string, error) { + normalized := strings.TrimSpace(specifier) + if normalized == "" { + return "", nil + } + + if strings.HasPrefix(normalized, "./") || strings.HasPrefix(normalized, "../") { + return resolveImportedLocalModule(importerPath, normalized, fileIndex) + } + if strings.HasPrefix(normalized, "/") || looksLikeWindowsAbsolutePath(normalized) { + return "", fmt.Errorf("不支持绝对路径依赖 %q", normalized) + } + if isAllowedRuntimeModule(normalized) { + return "", nil + } + return "", fmt.Errorf("发现不受支持的外部依赖 %q,只允许相对路径、Node 内置模块、playwright、playwright-core", normalized) +} + +func resolveImportedLocalModule(importerPath string, specifier string, fileIndex map[string][]byte) (string, error) { + candidate := path.Clean(path.Join(path.Dir(importerPath), specifier)) + if candidate == "." || candidate == ".." || strings.HasPrefix(candidate, "../") { + return "", fmt.Errorf("本地依赖 %q 超出了脚本包范围", specifier) + } + + if resolved, err := resolveImportedLocalCandidate(candidate, fileIndex); err == nil { + return resolved, nil + } else if err != nil { + return "", fmt.Errorf("本地依赖 %q 无法解析: %w", specifier, err) + } + return "", fmt.Errorf("本地依赖 %q 无法解析", specifier) +} + +func resolveImportedLocalCandidate(candidate string, fileIndex map[string][]byte) (string, error) { + if content, exists := fileIndex[candidate]; exists { + _ = content + if isSupportedLocalImportFile(candidate) { + return candidate, nil + } + return "", fmt.Errorf("文件 %s 使用了不支持的扩展名 %s", candidate, path.Ext(candidate)) + } + + if path.Ext(candidate) == "" { + for _, extension := range []string{".js", ".cjs", ".mjs", ".json"} { + withExtension := candidate + extension + if _, exists := fileIndex[withExtension]; exists { + return withExtension, nil + } + } + } + + if hasImportedBundleDir(candidate, fileIndex) { + for _, extension := range []string{"/index.js", "/index.cjs", "/index.mjs", "/index.json"} { + indexFile := candidate + extension + if _, exists := fileIndex[indexFile]; exists { + return indexFile, nil + } + } + } + + return "", fmt.Errorf("找不到文件") +} + +func hasImportedBundleDir(target string, fileIndex map[string][]byte) bool { + prefix := strings.TrimSuffix(strings.TrimSpace(target), "/") + "/" + for filePath := range fileIndex { + if strings.HasPrefix(filePath, prefix) { + return true + } + } + return false +} + +func containsNodeModulesPath(filePath string) bool { + for _, segment := range strings.Split(filepathToSlash(filePath), "/") { + if strings.EqualFold(segment, "node_modules") { + return true + } + } + return false +} + +func filepathToSlash(value string) string { + return strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") +} + +func isSupportedScriptModuleFile(filePath string) bool { + _, exists := supportedScriptModuleExtensions[strings.ToLower(path.Ext(strings.TrimSpace(filePath)))] + return exists +} + +func isSupportedLocalImportFile(filePath string) bool { + _, exists := supportedLocalImportExtensions[strings.ToLower(path.Ext(strings.TrimSpace(filePath)))] + return exists +} + +func isAllowedRuntimeModule(specifier string) bool { + switch strings.TrimSpace(specifier) { + case "playwright", "playwright-core": + return true + } + + normalized := strings.TrimPrefix(strings.TrimSpace(specifier), "node:") + if normalized == "" { + return false + } + root := normalized + if index := strings.Index(root, "/"); index >= 0 { + root = root[:index] + } + _, exists := nodeBuiltinModules[root] + return exists +} + +func looksLikeWindowsAbsolutePath(value string) bool { + if len(value) < 3 { + return false + } + drive := value[0] + if !((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) { + return false + } + return value[1] == ':' && (value[2] == '\\' || value[2] == '/') +} diff --git a/backend/internal/automation/script_package_zip_helpers.go b/backend/internal/automation/script_package_zip_helpers.go new file mode 100644 index 00000000..74ec2830 --- /dev/null +++ b/backend/internal/automation/script_package_zip_helpers.go @@ -0,0 +1,74 @@ +package automation + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +func sanitizedImportedZipPath(destDir string, rawName string) (string, bool, error) { + name := strings.TrimSpace(rawName) + if name == "" { + return "", true, nil + } + + cleanName := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(filepath.ToSlash(name), "/"))) + if cleanName == "." || cleanName == "" { + return "", true, nil + } + + targetPath := filepath.Join(destDir, cleanName) + cleanDest := filepath.Clean(destDir) + cleanTarget := filepath.Clean(targetPath) + if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) { + return "", false, fmt.Errorf("script zip contains invalid path %s", rawName) + } + + return targetPath, false, nil +} + +func shouldSkipImportedZipEntry(name string) bool { + normalized := filepath.ToSlash(strings.TrimSpace(name)) + if normalized == "" { + return true + } + + base := pathBase(normalized) + if base == ".DS_Store" || strings.HasPrefix(base, "._") { + return true + } + for _, segment := range strings.Split(normalized, "/") { + if segment == "__MACOSX" { + return true + } + } + return false +} + +func isImportManifestPath(relativePath string) bool { + normalized := filepath.ToSlash(strings.TrimSpace(relativePath)) + for _, candidate := range importManifestCandidates { + if strings.EqualFold(normalized, candidate) { + return true + } + } + return false +} + +func isZipArchiveData(nameHint string, data []byte) bool { + if strings.EqualFold(strings.TrimSpace(filepath.Ext(nameHint)), ".zip") { + return true + } + return len(data) >= 4 && bytes.Equal(data[:4], []byte("PK\x03\x04")) +} + +func pathBase(value string) string { + trimmed := strings.TrimSuffix(strings.TrimSpace(value), "/") + if trimmed == "" { + return "" + } + parts := strings.Split(trimmed, "/") + return parts[len(parts)-1] +} diff --git a/backend/internal/automation/script_package_zip_import.go b/backend/internal/automation/script_package_zip_import.go new file mode 100644 index 00000000..2d1fe269 --- /dev/null +++ b/backend/internal/automation/script_package_zip_import.go @@ -0,0 +1,157 @@ +package automation + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func ImportBundleFromZip(path string, sourceLabel string) (ImportedBundle, error) { + return ImportBundleFromZipWithOptions(path, sourceLabel, ImportOptions{}) +} + +func ImportBundleFromZipWithOptions(path string, sourceLabel string, options ImportOptions) (ImportedBundle, error) { + normalizedPath := strings.TrimSpace(path) + if normalizedPath == "" { + return ImportedBundle{}, fmt.Errorf("script zip path is required") + } + + reader, err := zip.OpenReader(normalizedPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("open script zip failed: %w", err) + } + defer reader.Close() + + return importBundleFromZipReader(&reader.Reader, sourceLabel, options) +} + +func importBundleFromZipBytes(nameHint string, data []byte, sourceLabel string, options ImportOptions) (ImportedBundle, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return ImportedBundle{}, fmt.Errorf("open script zip failed: %w", err) + } + return importBundleFromZipReader(reader, sourceLabel, options) +} + +func importBundleFromZipReader(reader *zip.Reader, sourceLabel string, options ImportOptions) (ImportedBundle, error) { + extractRoot, err := os.MkdirTemp("", "ant-automation-zip-*") + if err != nil { + return ImportedBundle{}, fmt.Errorf("create script zip temp dir failed: %w", err) + } + defer os.RemoveAll(extractRoot) + + if err := extractImportedZip(reader, extractRoot); err != nil { + return ImportedBundle{}, err + } + + bundle, err := ImportBundleFromDirectoryWithOptions(extractRoot, "", sourceLabel, options) + if err == nil { + return bundle, nil + } + + nestedRoot, nestedFound, nestedErr := detectSingleImportedZipRoot(extractRoot) + if nestedErr != nil { + return ImportedBundle{}, nestedErr + } + if nestedFound { + return ImportBundleFromDirectoryWithOptions(nestedRoot, "", sourceLabel, options) + } + return ImportedBundle{}, err +} + +func extractImportedZip(reader *zip.Reader, destDir string) error { + fileCount := 0 + totalBytes := 0 + + for _, file := range reader.File { + if shouldSkipImportedZipEntry(file.Name) { + continue + } + + targetPath, skip, err := sanitizedImportedZipPath(destDir, file.Name) + if err != nil { + return err + } + if skip { + continue + } + + mode := file.Mode() + if file.FileInfo().IsDir() { + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return fmt.Errorf("create script zip dir failed: %w", err) + } + continue + } + if !mode.IsRegular() { + return fmt.Errorf("script zip contains unsupported entry %s", file.Name) + } + + fileCount++ + if fileCount > maxImportedZipFiles { + return fmt.Errorf("script zip contains too many files") + } + + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("create script zip file dir failed: %w", err) + } + + src, err := file.Open() + if err != nil { + return fmt.Errorf("open script zip entry failed: %w", err) + } + + dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + src.Close() + return fmt.Errorf("create script zip file failed: %w", err) + } + + written, copyErr := io.Copy(dst, io.LimitReader(src, int64(maxImportedZipBytes-totalBytes)+1)) + closeErr := dst.Close() + srcCloseErr := src.Close() + if copyErr != nil { + return fmt.Errorf("extract script zip entry failed: %w", copyErr) + } + if closeErr != nil { + return fmt.Errorf("close extracted script file failed: %w", closeErr) + } + if srcCloseErr != nil { + return fmt.Errorf("close script zip entry failed: %w", srcCloseErr) + } + + totalBytes += int(written) + if totalBytes > maxImportedZipBytes { + return fmt.Errorf("script zip is too large") + } + } + + return nil +} + +func detectSingleImportedZipRoot(root string) (string, bool, error) { + entries, err := os.ReadDir(root) + if err != nil { + return "", false, fmt.Errorf("read script zip temp dir failed: %w", err) + } + + directories := make([]string, 0, 1) + for _, entry := range entries { + if shouldSkipImportedZipEntry(entry.Name()) { + continue + } + if !entry.IsDir() { + return "", false, nil + } + directories = append(directories, filepath.Join(root, entry.Name())) + } + + if len(directories) != 1 { + return "", false, nil + } + return directories[0], true, nil +} diff --git a/backend/internal/automation/script_package_zip_test.go b/backend/internal/automation/script_package_zip_test.go new file mode 100644 index 00000000..1a8f8abd --- /dev/null +++ b/backend/internal/automation/script_package_zip_test.go @@ -0,0 +1,177 @@ +package automation + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func TestWriteScriptPackageZipRoundTripsAdditionalFiles(t *testing.T) { + zipPath := filepath.Join(t.TempDir(), "demo-package.zip") + + if err := WriteScriptPackageZip(zipPath, ImportedBundle{ + Record: ScriptRecord{ + ID: "zip-roundtrip", + Name: "ZIP 导出", + Description: "包含额外文件", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + SelectorText: `{"code":"ZIP_DEMO"}`, + ParamsText: `{"url":"https://example.com"}`, + ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + }, + Files: []ImportedBundleFile{ + { + Path: "scripts/index.cjs", + Content: []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), + }, + { + Path: "scripts/helpers/helper.cjs", + Content: []byte("module.exports.run = async () => ({ ok: true })"), + }, + { + Path: "assets/raw.bin", + Content: []byte{0x00, 0x01, 0x02, 0xff}, + }, + }, + }); err != nil { + t.Fatalf("WriteScriptPackageZip returned error: %v", err) + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip failed: %v", err) + } + defer reader.Close() + + if !zipContainsEntry(reader.File, scriptPackageManifestName) { + t.Fatalf("expected %s in zip", scriptPackageManifestName) + } + + imported, err := ImportBundleFromZip(zipPath, "本地文件 "+zipPath) + if err != nil { + t.Fatalf("ImportBundleFromZip returned error: %v", err) + } + + if imported.Record.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected entry file: %s", imported.Record.EntryFile) + } + if imported.Record.Source.Type != "local-file" { + t.Fatalf("unexpected source: %+v", imported.Record.Source) + } + if !hasBundleFile(imported.Files, "scripts/helpers/helper.cjs", []byte("module.exports.run = async () => ({ ok: true })")) { + t.Fatalf("expected helper file to round-trip, got %+v", imported.Files) + } + if !hasBundleFile(imported.Files, "assets/raw.bin", []byte{0x00, 0x01, 0x02, 0xff}) { + t.Fatalf("expected binary file to round-trip, got %+v", imported.Files) + } +} + +func TestImportBundleFromBytesSupportsZipPackage(t *testing.T) { + zipData := buildScriptPackageZipBytes(t, map[string]string{ + scriptPackageManifestName: `{ + "name": "远程 ZIP 脚本", + "type": "playwright-cdp", + "entryFile": "scripts/index.cjs" +}`, + "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'zip-bytes' })", + }) + + bundle, err := ImportBundleFromBytes("remote-package.zip", zipData, "远程地址 https://example.com/demo-package.zip") + if err != nil { + t.Fatalf("ImportBundleFromBytes returned error: %v", err) + } + + if bundle.Record.Name != "远程 ZIP 脚本" { + t.Fatalf("unexpected script name: %s", bundle.Record.Name) + } + if bundle.Record.Source.Type != "remote-url" { + t.Fatalf("unexpected source: %+v", bundle.Record.Source) + } + if !strings.Contains(bundle.Record.ScriptText, "zip-bytes") { + t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) + } +} + +func TestImportBundleFromZipSupportsSingleRootDirectory(t *testing.T) { + zipPath := filepath.Join(t.TempDir(), "nested.zip") + if err := os.WriteFile(zipPath, buildScriptPackageZipBytes(t, map[string]string{ + "demo/automation.script.json": `{ + "name": "单根目录 ZIP", + "type": "playwright-cdp", + "entryFile": "scripts/index.cjs" +}`, + "demo/scripts/index.cjs": "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + "demo/scripts/helpers/helper.cjs": "module.exports.run = async () => ({ ok: true })", + "__MACOSX/demo/._index.cjs": "ignored", + "demo/.DS_Store": "ignored", + }), 0o644); err != nil { + t.Fatalf("write nested zip failed: %v", err) + } + + bundle, err := ImportBundleFromZip(zipPath, "本地文件 "+zipPath) + if err != nil { + t.Fatalf("ImportBundleFromZip returned error: %v", err) + } + + if bundle.Record.Name != "单根目录 ZIP" { + t.Fatalf("unexpected script name: %s", bundle.Record.Name) + } + if !hasBundleFile(bundle.Files, "scripts/helpers/helper.cjs", []byte("module.exports.run = async () => ({ ok: true })")) { + t.Fatalf("expected nested helper file, got %+v", bundle.Files) + } +} + +func TestImportBundleFromZipRejectsZipSlip(t *testing.T) { + zipPath := filepath.Join(t.TempDir(), "zip-slip.zip") + if err := os.WriteFile(zipPath, buildScriptPackageZipBytes(t, map[string]string{ + "../evil.cjs": "module.exports.run = async () => ({ ok: false })", + }), 0o644); err != nil { + t.Fatalf("write zip failed: %v", err) + } + + if _, err := ImportBundleFromZip(zipPath, "本地文件 "+zipPath); err == nil || !strings.Contains(err.Error(), "invalid path") { + t.Fatalf("expected zip slip error, got %v", err) + } +} + +func buildScriptPackageZipBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + + paths := make([]string, 0, len(files)) + for relativePath := range files { + paths = append(paths, relativePath) + } + sort.Strings(paths) + + for _, relativePath := range paths { + entry, err := writer.Create(relativePath) + if err != nil { + t.Fatalf("create zip entry failed: %v", err) + } + if _, err := entry.Write([]byte(files[relativePath])); err != nil { + t.Fatalf("write zip entry failed: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close zip writer failed: %v", err) + } + return buf.Bytes() +} + +func zipContainsEntry(files []*zip.File, target string) bool { + for _, file := range files { + if file.Name == target { + return true + } + } + return false +} diff --git a/backend/internal/automation/script_package_zip_write.go b/backend/internal/automation/script_package_zip_write.go new file mode 100644 index 00000000..d90dfc24 --- /dev/null +++ b/backend/internal/automation/script_package_zip_write.go @@ -0,0 +1,140 @@ +package automation + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + scriptPackageManifestName = "automation.script.json" + maxImportedZipFiles = maxImportedBundleFiles + 8 + maxImportedZipBytes = maxImportedBundleBytes + (256 << 10) +) + +func WriteScriptPackageZip(zipPath string, bundle ImportedBundle) error { + normalizedPath := strings.TrimSpace(zipPath) + if normalizedPath == "" { + return fmt.Errorf("script zip path is required") + } + + record, files, err := collectScriptPackageExportFiles(bundle) + if err != nil { + return err + } + + manifestData, err := MarshalScriptPackageManifest(record) + if err != nil { + return fmt.Errorf("marshal script package manifest failed: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(normalizedPath), 0o755); err != nil { + return fmt.Errorf("create script zip dir failed: %w", err) + } + + tmpPath := normalizedPath + ".tmp" + _ = os.Remove(tmpPath) + + file, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("create script zip failed: %w", err) + } + + success := false + defer func() { + _ = file.Close() + if !success { + _ = os.Remove(tmpPath) + } + }() + + writer := zip.NewWriter(file) + if err := writeScriptZipEntry(writer, scriptPackageManifestName, manifestData); err != nil { + _ = writer.Close() + return fmt.Errorf("write script package manifest failed: %w", err) + } + for _, bundleFile := range files { + if err := writeScriptZipEntry(writer, bundleFile.Path, bundleFile.Content); err != nil { + _ = writer.Close() + return fmt.Errorf("write script package file %s failed: %w", bundleFile.Path, err) + } + } + if err := writer.Close(); err != nil { + return fmt.Errorf("finalize script zip failed: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close script zip failed: %w", err) + } + if err := replaceFile(tmpPath, normalizedPath); err != nil { + return fmt.Errorf("move script zip failed: %w", err) + } + + success = true + return nil +} + +func collectScriptPackageExportFiles(bundle ImportedBundle) (ScriptRecord, []ImportedBundleFile, error) { + record, err := normalizeScriptRecord(bundle.Record, ScriptRecord{}) + if err != nil { + return ScriptRecord{}, nil, err + } + if err := validateImportedBundle(record, bundle.Files); err != nil { + return ScriptRecord{}, nil, err + } + + fileIndex, err := buildImportedBundleFileIndex(record, bundle.Files) + if err != nil { + return ScriptRecord{}, nil, err + } + + paths := make([]string, 0, len(fileIndex)) + for relativePath := range fileIndex { + if isImportManifestPath(relativePath) { + continue + } + paths = append(paths, relativePath) + } + sort.Strings(paths) + + files := make([]ImportedBundleFile, 0, len(paths)) + for _, relativePath := range paths { + files = append(files, ImportedBundleFile{ + Path: relativePath, + Content: fileIndex[relativePath], + }) + } + return record, files, nil +} + +func writeScriptZipEntry(writer *zip.Writer, archivePath string, content []byte) error { + normalizedPath, err := normalizeBundleFilePath(archivePath) + if err != nil { + return err + } + + header := &zip.FileHeader{ + Name: normalizedPath, + Method: zip.Deflate, + } + header.SetMode(0o644) + + entryWriter, err := writer.CreateHeader(header) + if err != nil { + return err + } + _, err = entryWriter.Write(content) + return err +} + +func replaceFile(sourcePath string, targetPath string) error { + if err := os.Rename(sourcePath, targetPath); err == nil { + return nil + } + if removeErr := os.Remove(targetPath); removeErr != nil && !os.IsNotExist(removeErr) { + return removeErr + } + return os.Rename(sourcePath, targetPath) +} diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go new file mode 100644 index 00000000..41f0841b --- /dev/null +++ b/backend/internal/automation/script_run_store.go @@ -0,0 +1,145 @@ +package automation + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +type ScriptRunRecord struct { + ID string `json:"id"` + ScriptID string `json:"scriptId"` + ScriptName string `json:"scriptName"` + ScriptType string `json:"scriptType"` + Status string `json:"status"` + Summary string `json:"summary"` + Error string `json:"error"` + ResultText string `json:"resultText"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` + DurationMs int64 `json:"durationMs"` +} + +type ScriptRunRequest struct { + ScriptID string `json:"scriptId"` + SelectorText string `json:"selectorText"` + ParamsText string `json:"paramsText"` + UseScriptSelector bool `json:"useScriptSelector"` + UseScriptParams bool `json:"useScriptParams"` +} + +type ScriptRunStore struct { + rootDir string +} + +func NewScriptRunStore(rootDir string) *ScriptRunStore { + return &ScriptRunStore{ + rootDir: filepath.Clean(strings.TrimSpace(rootDir)), + } +} + +func (s *ScriptRunStore) Save(input ScriptRunRecord) (ScriptRunRecord, error) { + record := normalizeScriptRunRecord(input) + if err := os.MkdirAll(s.rootDir, 0o755); err != nil { + return ScriptRunRecord{}, fmt.Errorf("create automation run dir failed: %w", err) + } + + data, err := json.MarshalIndent(record, "", " ") + if err != nil { + return ScriptRunRecord{}, fmt.Errorf("marshal automation run record failed: %w", err) + } + + if err := writeFileAtomic(filepath.Join(s.rootDir, record.ID+".json"), data, 0o644); err != nil { + return ScriptRunRecord{}, fmt.Errorf("write automation run record failed: %w", err) + } + return record, nil +} + +func (s *ScriptRunStore) List(limit int) ([]ScriptRunRecord, error) { + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + + if err := os.MkdirAll(s.rootDir, 0o755); err != nil { + return nil, fmt.Errorf("create automation run dir failed: %w", err) + } + + entries, err := os.ReadDir(s.rootDir) + if err != nil { + if os.IsNotExist(err) { + return []ScriptRunRecord{}, nil + } + return nil, fmt.Errorf("read automation run dir failed: %w", err) + } + + items := make([]ScriptRunRecord, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(s.rootDir, entry.Name())) + if err != nil { + continue + } + var record ScriptRunRecord + if err := json.Unmarshal(data, &record); err != nil { + continue + } + items = append(items, normalizeScriptRunRecord(record)) + } + + sort.Slice(items, func(i, j int) bool { + return parseRFC3339OrZero(items[i].StartedAt).After(parseRFC3339OrZero(items[j].StartedAt)) + }) + + if len(items) > limit { + items = items[:limit] + } + return items, nil +} + +func normalizeScriptRunRecord(input ScriptRunRecord) ScriptRunRecord { + now := time.Now().Format(time.RFC3339) + + id := strings.TrimSpace(input.ID) + if id == "" { + id = uuid.NewString() + } + + status := strings.TrimSpace(input.Status) + switch status { + case "success", "failed", "running": + default: + status = "failed" + } + + startedAt := firstNonEmpty(input.StartedAt, now) + finishedAt := firstNonEmpty(input.FinishedAt, startedAt) + durationMs := input.DurationMs + if durationMs < 0 { + durationMs = 0 + } + + return ScriptRunRecord{ + ID: id, + ScriptID: strings.TrimSpace(input.ScriptID), + ScriptName: strings.TrimSpace(input.ScriptName), + ScriptType: strings.TrimSpace(input.ScriptType), + Status: status, + Summary: strings.TrimSpace(input.Summary), + Error: strings.TrimSpace(input.Error), + ResultText: strings.TrimSpace(input.ResultText), + StartedAt: startedAt, + FinishedAt: finishedAt, + DurationMs: durationMs, + } +} diff --git a/backend/internal/automation/script_run_store_test.go b/backend/internal/automation/script_run_store_test.go new file mode 100644 index 00000000..aa53b520 --- /dev/null +++ b/backend/internal/automation/script_run_store_test.go @@ -0,0 +1,51 @@ +package automation + +import ( + "path/filepath" + "testing" +) + +func TestScriptRunStoreSaveAndList(t *testing.T) { + store := NewScriptRunStore(filepath.Join(t.TempDir(), "data", "automation", "runs")) + + first, err := store.Save(ScriptRunRecord{ + ID: "run-1", + ScriptID: "script-1", + ScriptName: "脚本 1", + Status: "success", + Summary: "ok", + StartedAt: "2026-04-02T09:00:00Z", + FinishedAt: "2026-04-02T09:00:01Z", + DurationMs: 1000, + }) + if err != nil { + t.Fatalf("Save first returned error: %v", err) + } + if first.ID != "run-1" { + t.Fatalf("expected run id run-1, got %q", first.ID) + } + + if _, err := store.Save(ScriptRunRecord{ + ID: "run-2", + ScriptID: "script-2", + ScriptName: "脚本 2", + Status: "failed", + Summary: "bad", + StartedAt: "2026-04-02T10:00:00Z", + FinishedAt: "2026-04-02T10:00:02Z", + DurationMs: 2000, + }); err != nil { + t.Fatalf("Save second returned error: %v", err) + } + + items, err := store.List(10) + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected two runs, got %d", len(items)) + } + if items[0].ID != "run-2" { + t.Fatalf("expected latest run first, got %q", items[0].ID) + } +} diff --git a/backend/internal/automation/script_template.go b/backend/internal/automation/script_template.go new file mode 100644 index 00000000..7838f8e4 --- /dev/null +++ b/backend/internal/automation/script_template.go @@ -0,0 +1,196 @@ +package automation + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf8" +) + +type scriptTemplateFile struct { + Path string `json:"path"` + Content string `json:"content"` + Encoding string `json:"encoding,omitempty"` +} + +func MarshalScriptPackageManifest(record ScriptRecord) ([]byte, error) { + normalized, err := normalizeScriptRecord(record, ScriptRecord{}) + if err != nil { + return nil, err + } + + payload := map[string]any{ + "format": normalized.PackageFormat, + "packageFormat": normalized.PackageFormat, + "manifestVersion": normalized.ManifestVersion, + "id": normalized.ID, + "name": normalized.Name, + "description": normalized.Description, + "type": normalized.Type, + "status": normalized.Status, + "entryFile": normalized.EntryFile, + "tags": append([]string{}, normalized.Tags...), + "notes": normalized.Notes, + "targetConfig": normalized.TargetConfig, + "source": map[string]any{ + "type": normalized.Source.Type, + "uri": normalized.Source.URI, + "ref": normalized.Source.Ref, + "path": normalized.Source.Path, + "importedAt": normalized.Source.ImportedAt, + }, + "createdAt": normalized.CreatedAt, + "updatedAt": normalized.UpdatedAt, + } + + if selectorValue := parseScriptTemplateJSON(normalized.SelectorText); selectorValue != nil { + payload["selector"] = selectorValue + } + if paramsValue := parseScriptTemplateJSON(normalized.ParamsText); paramsValue != nil { + payload["params"] = paramsValue + } + + return json.MarshalIndent(payload, "", " ") +} + +func MarshalScriptTemplate(bundle ImportedBundle) ([]byte, error) { + record, err := normalizeScriptRecord(bundle.Record, ScriptRecord{}) + if err != nil { + return nil, err + } + + files, err := buildTemplateFiles(bundle.Files, record.EntryFile) + if err != nil { + return nil, err + } + + envelope := scriptImportEnvelope{ + Format: record.PackageFormat, + PackageFormat: record.PackageFormat, + ManifestVersion: record.ManifestVersion, + Manifest: map[string]any{ + "packageFormat": record.PackageFormat, + "manifestVersion": record.ManifestVersion, + "id": record.ID, + "name": record.Name, + "description": record.Description, + "type": record.Type, + "status": record.Status, + "entryFile": record.EntryFile, + "tags": append([]string{}, record.Tags...), + "notes": record.Notes, + "targetConfig": record.TargetConfig, + "source": record.Source, + "createdAt": record.CreatedAt, + "updatedAt": record.UpdatedAt, + }, + ScriptText: record.ScriptText, + Notes: record.Notes, + Source: map[string]any{ + "type": record.Source.Type, + "uri": record.Source.URI, + "ref": record.Source.Ref, + "path": record.Source.Path, + "importedAt": record.Source.ImportedAt, + }, + Files: files, + } + + if selectorValue := parseScriptTemplateJSON(record.SelectorText); selectorValue != nil { + envelope.Selector = selectorValue + } + if paramsValue := parseScriptTemplateJSON(record.ParamsText); paramsValue != nil { + envelope.Params = paramsValue + } + + return json.MarshalIndent(envelope, "", " ") +} + +func decodeScriptTemplateFiles(files []scriptTemplateFile, entryFile string) ([]ImportedBundleFile, error) { + entryFile = strings.TrimSpace(entryFile) + result := make([]ImportedBundleFile, 0, len(files)) + + for _, file := range files { + normalizedPath, err := normalizeBundleFilePath(file.Path) + if err != nil { + return nil, err + } + if normalizedPath == entryFile { + continue + } + + content, err := decodeScriptTemplateFileContent(file) + if err != nil { + return nil, fmt.Errorf("decode template file %s failed: %w", normalizedPath, err) + } + result = append(result, ImportedBundleFile{ + Path: normalizedPath, + Content: content, + }) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Path < result[j].Path + }) + return result, nil +} + +func buildTemplateFiles(files []ImportedBundleFile, entryFile string) ([]scriptTemplateFile, error) { + entryFile = strings.TrimSpace(entryFile) + items := make([]scriptTemplateFile, 0, len(files)) + + for _, file := range files { + normalizedPath, err := normalizeBundleFilePath(file.Path) + if err != nil { + return nil, err + } + if normalizedPath == entryFile || normalizedPath == scriptStoreConfigFileName { + continue + } + + content, encoding := encodeScriptTemplateFileContent(file.Content) + items = append(items, scriptTemplateFile{ + Path: normalizedPath, + Content: content, + Encoding: encoding, + }) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Path < items[j].Path + }) + return items, nil +} + +func encodeScriptTemplateFileContent(content []byte) (string, string) { + if utf8.Valid(content) { + return string(content), "utf8" + } + return base64.StdEncoding.EncodeToString(content), "base64" +} + +func decodeScriptTemplateFileContent(file scriptTemplateFile) ([]byte, error) { + switch strings.TrimSpace(strings.ToLower(file.Encoding)) { + case "", "utf8", "text": + return []byte(file.Content), nil + case "base64": + return base64.StdEncoding.DecodeString(strings.TrimSpace(file.Content)) + default: + return nil, fmt.Errorf("unsupported encoding %q", file.Encoding) + } +} + +func parseScriptTemplateJSON(text string) any { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return nil + } + + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return trimmed + } + return decoded +} diff --git a/backend/internal/automation/script_template_test.go b/backend/internal/automation/script_template_test.go new file mode 100644 index 00000000..8222f1f9 --- /dev/null +++ b/backend/internal/automation/script_template_test.go @@ -0,0 +1,116 @@ +package automation + +import ( + "bytes" + "path/filepath" + "testing" +) + +func TestMarshalScriptTemplateRoundTripsAdditionalFiles(t *testing.T) { + templateData, err := MarshalScriptTemplate(ImportedBundle{ + Record: ScriptRecord{ + ID: "template-roundtrip", + Name: "模板导出", + Description: "包含额外文件", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + SelectorText: `{"code":"DEMO_TEMPLATE"}`, + ParamsText: `{"url":"https://example.com"}`, + ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + }, + Files: []ImportedBundleFile{ + { + Path: "scripts/index.cjs", + Content: []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), + }, + { + Path: "scripts/helpers/helper.cjs", + Content: []byte("module.exports.run = async () => ({ ok: true })"), + }, + { + Path: "manifest.json", + Content: []byte(`{"custom":true}`), + }, + { + Path: "assets/raw.bin", + Content: []byte{0x00, 0x01, 0x02, 0xff}, + }, + }, + }) + if err != nil { + t.Fatalf("MarshalScriptTemplate returned error: %v", err) + } + + imported, err := ImportBundleFromBytes("template.json", templateData, "文本导入") + if err != nil { + t.Fatalf("ImportBundleFromBytes returned error: %v", err) + } + + if imported.Record.EntryFile != "scripts/index.cjs" { + t.Fatalf("unexpected entry file: %s", imported.Record.EntryFile) + } + if !bytes.Equal([]byte(imported.Record.ScriptText), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()")) { + t.Fatalf("unexpected script text: %q", imported.Record.ScriptText) + } + if !hasBundleFile(imported.Files, "scripts/helpers/helper.cjs", []byte("module.exports.run = async () => ({ ok: true })")) { + t.Fatalf("expected helper file to round-trip, got %+v", imported.Files) + } + if !hasBundleFile(imported.Files, "manifest.json", []byte(`{"custom":true}`)) { + t.Fatalf("expected manifest.json to round-trip as a regular file, got %+v", imported.Files) + } + if !hasBundleFile(imported.Files, "assets/raw.bin", []byte{0x00, 0x01, 0x02, 0xff}) { + t.Fatalf("expected binary file to round-trip, got %+v", imported.Files) + } +} + +func TestScriptStoreExportBundleIncludesNestedFiles(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + if _, err := store.ImportBundle(ImportedBundle{ + Record: ScriptRecord{ + ID: "export-bundle", + Name: "导出 bundle", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + }, + Files: []ImportedBundleFile{ + { + Path: "scripts/index.cjs", + Content: []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), + }, + { + Path: "scripts/helpers/helper.cjs", + Content: []byte("module.exports.run = async () => ({ ok: true })"), + }, + }, + }); err != nil { + t.Fatalf("ImportBundle returned error: %v", err) + } + + exported, err := store.ExportBundle("export-bundle") + if err != nil { + t.Fatalf("ExportBundle returned error: %v", err) + } + + if exported.Record.ID != "export-bundle" { + t.Fatalf("unexpected exported record: %+v", exported.Record) + } + if !hasBundleFile(exported.Files, "scripts/index.cjs", []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()")) { + t.Fatalf("expected entry file in exported bundle, got %+v", exported.Files) + } + if !hasBundleFile(exported.Files, "scripts/helpers/helper.cjs", []byte("module.exports.run = async () => ({ ok: true })")) { + t.Fatalf("expected helper file in exported bundle, got %+v", exported.Files) + } +} + +func hasBundleFile(files []ImportedBundleFile, targetPath string, expectedContent []byte) bool { + for _, file := range files { + if file.Path == targetPath && bytes.Equal(file.Content, expectedContent) { + return true + } + } + return false +} diff --git a/backend/internal/automation/scripts_store.go b/backend/internal/automation/scripts_store.go new file mode 100644 index 00000000..05b364fa --- /dev/null +++ b/backend/internal/automation/scripts_store.go @@ -0,0 +1,225 @@ +package automation + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + defaultScriptEntryFile = "index.cjs" + defaultScriptPackageFormat = "ant-automation-script" + defaultScriptManifestVersion = 1 + defaultScriptCreateNameTemplate = "${templateName}-${timestamp}" + scriptStoreConfigFileName = "config" + scriptStoreLegacyConfigName = "manifest.json" +) + +type ScriptSource struct { + Type string `json:"type"` + URI string `json:"uri"` + Ref string `json:"ref"` + Path string `json:"path"` + ImportedAt string `json:"importedAt"` +} + +type ScriptTargetSelector struct { + Code string `json:"code"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + GroupID string `json:"groupId"` + Keywords []string `json:"keywords"` + Tags []string `json:"tags"` +} + +type ScriptTargetConfig struct { + Mode string `json:"mode"` + Selector ScriptTargetSelector `json:"selector"` + TemplateSelector ScriptTargetSelector `json:"templateSelector"` + CreateNameTemplate string `json:"createNameTemplate"` +} + +type ScriptRecord struct { + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + SelectorText string `json:"selectorText"` + ParamsText string `json:"paramsText"` + ScriptText string `json:"scriptText"` + Notes string `json:"notes"` + TargetConfig ScriptTargetConfig `json:"targetConfig"` + Source ScriptSource `json:"source"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type ImportedBundleFile struct { + Path string + Content []byte +} + +type ImportedBundle struct { + Record ScriptRecord + Files []ImportedBundleFile +} + +type scriptStoreConfig struct { + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + SelectorText string `json:"selectorText"` + ParamsText string `json:"paramsText"` + Notes string `json:"notes"` + TargetConfig ScriptTargetConfig `json:"targetConfig"` + Source ScriptSource `json:"source"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type ScriptStore struct { + rootDir string +} + +func NewScriptStore(rootDir string) *ScriptStore { + return &ScriptStore{ + rootDir: filepath.Clean(strings.TrimSpace(rootDir)), + } +} + +func (s *ScriptStore) List() ([]ScriptRecord, error) { + if err := s.ensureRoot(); err != nil { + return nil, err + } + + entries, err := os.ReadDir(s.rootDir) + if err != nil { + if os.IsNotExist(err) { + return []ScriptRecord{}, nil + } + return nil, fmt.Errorf("read automation scripts dir failed: %w", err) + } + + items := make([]ScriptRecord, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + record, err := s.readScriptDir(filepath.Join(s.rootDir, entry.Name())) + if err != nil { + continue + } + items = append(items, record) + } + + sort.Slice(items, func(i, j int) bool { + return parseRFC3339OrZero(items[i].UpdatedAt).After(parseRFC3339OrZero(items[j].UpdatedAt)) + }) + + return items, nil +} + +func (s *ScriptStore) Save(input ScriptRecord) (ScriptRecord, error) { + if err := s.ensureRoot(); err != nil { + return ScriptRecord{}, err + } + + normalizedInput, err := normalizeScriptRecord(input, ScriptRecord{}) + if err != nil { + return ScriptRecord{}, err + } + input.ID = normalizedInput.ID + + dir, err := s.scriptDir(input.ID) + if err != nil { + return ScriptRecord{}, err + } + + existing, _ := s.readScriptDir(dir) + record, err := normalizeScriptRecord(input, existing) + if err != nil { + return ScriptRecord{}, err + } + + return s.writeRecord(dir, record, existing, nil) +} + +func (s *ScriptStore) Get(scriptID string) (ScriptRecord, error) { + dir, err := s.scriptDir(scriptID) + if err != nil { + return ScriptRecord{}, err + } + return s.readScriptDir(dir) +} + +func (s *ScriptStore) ExportBundle(scriptID string) (ImportedBundle, error) { + dir, err := s.scriptDir(scriptID) + if err != nil { + return ImportedBundle{}, err + } + + record, err := s.readScriptDir(dir) + if err != nil { + return ImportedBundle{}, err + } + + files, err := collectScriptStoreBundleFiles(dir) + if err != nil { + return ImportedBundle{}, err + } + + return ImportedBundle{ + Record: record, + Files: files, + }, nil +} + +func (s *ScriptStore) Dir(scriptID string) (string, error) { + return s.scriptDir(scriptID) +} + +func (s *ScriptStore) ImportBundle(bundle ImportedBundle) (ScriptRecord, error) { + if err := s.ensureRoot(); err != nil { + return ScriptRecord{}, err + } + + record, err := normalizeScriptRecord(bundle.Record, ScriptRecord{}) + if err != nil { + return ScriptRecord{}, err + } + record.ID = firstNonEmpty(record.ID, bundle.Record.ID) + + dir, err := s.scriptDir(record.ID) + if err != nil { + return ScriptRecord{}, err + } + + existing, _ := s.readScriptDir(dir) + return s.writeRecord(dir, record, existing, bundle.Files) +} + +func (s *ScriptStore) Delete(scriptID string) error { + dir, err := s.scriptDir(scriptID) + if err != nil { + return err + } + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("delete automation script failed: %w", err) + } + return nil +} diff --git a/backend/internal/automation/scripts_store_files.go b/backend/internal/automation/scripts_store_files.go new file mode 100644 index 00000000..494a9327 --- /dev/null +++ b/backend/internal/automation/scripts_store_files.go @@ -0,0 +1,238 @@ +package automation + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +func (s *ScriptStore) ensureRoot() error { + if s.rootDir == "" || s.rootDir == "." { + return fmt.Errorf("automation script root dir is empty") + } + return os.MkdirAll(s.rootDir, 0o755) +} + +func (s *ScriptStore) readScriptDir(dir string) (ScriptRecord, error) { + data, err := readScriptStoreConfigFile(dir) + if err != nil { + return ScriptRecord{}, err + } + + var config scriptStoreConfig + if err := json.Unmarshal(data, &config); err != nil { + return ScriptRecord{}, fmt.Errorf("unmarshal automation script config failed: %w", err) + } + + record, err := normalizeScriptRecord(ScriptRecord{ + PackageFormat: config.PackageFormat, + ManifestVersion: config.ManifestVersion, + ID: config.ID, + Name: config.Name, + Description: config.Description, + Type: config.Type, + Status: config.Status, + EntryFile: config.EntryFile, + Tags: config.Tags, + SelectorText: config.SelectorText, + ParamsText: config.ParamsText, + Notes: config.Notes, + TargetConfig: config.TargetConfig, + Source: config.Source, + CreatedAt: config.CreatedAt, + UpdatedAt: config.UpdatedAt, + }, ScriptRecord{}) + if err != nil { + return ScriptRecord{}, err + } + + scriptData, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(record.EntryFile))) + if err != nil { + if !os.IsNotExist(err) { + return ScriptRecord{}, fmt.Errorf("read automation script file failed: %w", err) + } + record.ScriptText = "" + return record, nil + } + record.ScriptText = string(scriptData) + return record, nil +} + +func (s *ScriptStore) scriptDir(scriptID string) (string, error) { + normalizedID := strings.TrimSpace(scriptID) + if normalizedID == "" { + return "", fmt.Errorf("script id is required") + } + if !isSafeScriptID(normalizedID) { + return "", fmt.Errorf("script id is invalid") + } + return filepath.Join(s.rootDir, normalizedID), nil +} + +func (s *ScriptStore) writeRecord(dir string, record ScriptRecord, existing ScriptRecord, files []ImportedBundleFile) (ScriptRecord, error) { + hadStoreConfig := scriptStoreFileExists(filepath.Join(dir, scriptStoreConfigFileName)) + hadLegacyConfigOnly := !hadStoreConfig && scriptStoreFileExists(filepath.Join(dir, scriptStoreLegacyConfigName)) + + if err := os.MkdirAll(dir, 0o755); err != nil { + return ScriptRecord{}, fmt.Errorf("create automation script dir failed: %w", err) + } + + if len(files) > 0 { + if err := os.RemoveAll(dir); err != nil { + return ScriptRecord{}, fmt.Errorf("reset automation script dir failed: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return ScriptRecord{}, fmt.Errorf("create automation script dir failed: %w", err) + } + } + + for _, file := range files { + relativePath, err := normalizeBundleFilePath(file.Path) + if err != nil { + return ScriptRecord{}, err + } + if relativePath == scriptStoreConfigFileName { + continue + } + targetPath := filepath.Join(dir, filepath.FromSlash(relativePath)) + if err := writeFileAtomic(targetPath, file.Content, 0o644); err != nil { + return ScriptRecord{}, fmt.Errorf("write automation script bundle file failed: %w", err) + } + } + + scriptPath := filepath.Join(dir, filepath.FromSlash(record.EntryFile)) + if err := writeFileAtomic(scriptPath, []byte(record.ScriptText), 0o644); err != nil { + return ScriptRecord{}, fmt.Errorf("write automation script file failed: %w", err) + } + + config := scriptStoreConfig{ + PackageFormat: record.PackageFormat, + ManifestVersion: record.ManifestVersion, + ID: record.ID, + Name: record.Name, + Description: record.Description, + Type: record.Type, + Status: record.Status, + EntryFile: record.EntryFile, + Tags: append([]string{}, record.Tags...), + SelectorText: record.SelectorText, + ParamsText: record.ParamsText, + Notes: record.Notes, + TargetConfig: record.TargetConfig, + Source: record.Source, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + } + configData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return ScriptRecord{}, fmt.Errorf("marshal automation script config failed: %w", err) + } + if err := writeFileAtomic(filepath.Join(dir, scriptStoreConfigFileName), configData, 0o644); err != nil { + return ScriptRecord{}, fmt.Errorf("write automation script config failed: %w", err) + } + if len(files) == 0 && hadLegacyConfigOnly { + _ = os.Remove(filepath.Join(dir, scriptStoreLegacyConfigName)) + } + + if len(files) == 0 && existing.EntryFile != "" && existing.EntryFile != record.EntryFile { + _ = os.Remove(filepath.Join(dir, filepath.FromSlash(existing.EntryFile))) + } + + return record, nil +} + +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Chmod(mode); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err == nil { + return nil + } + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return removeErr + } + return os.Rename(tmpPath, path) +} + +func collectScriptStoreBundleFiles(root string) ([]ImportedBundleFile, error) { + files := make([]ImportedBundleFile, 0, 8) + + err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() { + return nil + } + + relativePath, err := filepath.Rel(root, path) + if err != nil { + return err + } + relativePath = filepath.ToSlash(relativePath) + if relativePath == "." || relativePath == scriptStoreConfigFileName { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + files = append(files, ImportedBundleFile{ + Path: relativePath, + Content: content, + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("collect automation script export files failed: %w", err) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files, nil +} + +func readScriptStoreConfigFile(dir string) ([]byte, error) { + for _, candidate := range []string{scriptStoreConfigFileName, scriptStoreLegacyConfigName} { + data, err := os.ReadFile(filepath.Join(dir, candidate)) + if err == nil { + return data, nil + } + if !os.IsNotExist(err) { + return nil, err + } + } + return nil, os.ErrNotExist +} + +func scriptStoreFileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/automation/scripts_store_normalize.go b/backend/internal/automation/scripts_store_normalize.go new file mode 100644 index 00000000..20aa017a --- /dev/null +++ b/backend/internal/automation/scripts_store_normalize.go @@ -0,0 +1,227 @@ +package automation + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +func normalizeScriptRecord(input ScriptRecord, existing ScriptRecord) (ScriptRecord, error) { + now := time.Now().Format(time.RFC3339) + + id := strings.TrimSpace(input.ID) + if id == "" { + id = uuid.NewString() + } + if !isSafeScriptID(id) { + return ScriptRecord{}, fmt.Errorf("script id is invalid") + } + + entryFile := normalizeScriptEntryFile(input.EntryFile) + recordType := normalizeScriptType(input.Type) + recordStatus := normalizeScriptStatus(input.Status) + packageFormat := normalizeScriptPackageFormat(firstNonEmpty(strings.TrimSpace(input.PackageFormat), strings.TrimSpace(existing.PackageFormat))) + manifestVersion := normalizeScriptManifestVersion(input.ManifestVersion, existing.ManifestVersion) + createdAt := firstNonEmpty(strings.TrimSpace(existing.CreatedAt), strings.TrimSpace(input.CreatedAt), now) + updatedAt := firstNonEmpty(strings.TrimSpace(input.UpdatedAt), now) + + if strings.TrimSpace(input.Name) == "" { + return ScriptRecord{}, fmt.Errorf("script name is required") + } + + return ScriptRecord{ + PackageFormat: packageFormat, + ManifestVersion: manifestVersion, + ID: id, + Name: strings.TrimSpace(input.Name), + Description: strings.TrimSpace(input.Description), + Type: recordType, + Status: recordStatus, + EntryFile: entryFile, + Tags: normalizeScriptTags(input.Tags), + SelectorText: normalizeScriptJSONText(input.SelectorText), + ParamsText: normalizeScriptJSONText(input.ParamsText), + ScriptText: normalizeScriptText(input.ScriptText), + Notes: strings.TrimSpace(input.Notes), + TargetConfig: normalizeScriptTargetConfig(input.TargetConfig), + Source: normalizeScriptSource(input.Source, existing.Source), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func normalizeScriptPackageFormat(value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return defaultScriptPackageFormat + } + return normalized +} + +func normalizeScriptManifestVersion(value int, fallback int) int { + if value > 0 { + return value + } + if fallback > 0 { + return fallback + } + return defaultScriptManifestVersion +} + +func normalizeScriptType(value string) string { + switch strings.TrimSpace(value) { + case "launch-api": + return "launch-api" + default: + return "playwright-cdp" + } +} + +func normalizeScriptStatus(value string) string { + switch strings.TrimSpace(value) { + case "ready": + return "ready" + case "disabled": + return "disabled" + default: + return "draft" + } +} + +func normalizeScriptEntryFile(value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return defaultScriptEntryFile + } + normalized = filepath.ToSlash(filepath.Clean(normalized)) + if normalized == "." || normalized == "/" || normalized == scriptStoreConfigFileName { + return defaultScriptEntryFile + } + if strings.HasPrefix(normalized, "../") || normalized == ".." || filepath.IsAbs(normalized) { + return defaultScriptEntryFile + } + return normalized +} + +func normalizeBundleFilePath(value string) (string, error) { + normalized := filepath.ToSlash(filepath.Clean(strings.TrimSpace(value))) + if normalized == "." || normalized == "/" || normalized == "" { + return "", fmt.Errorf("bundle file path is invalid") + } + if strings.HasPrefix(normalized, "../") || normalized == ".." || filepath.IsAbs(normalized) { + return "", fmt.Errorf("bundle file path is invalid") + } + return normalized, nil +} + +func normalizeScriptTags(tags []string) []string { + deduped := make(map[string]struct{}, len(tags)) + result := make([]string, 0, len(tags)) + for _, tag := range tags { + normalized := strings.TrimSpace(tag) + if normalized == "" { + continue + } + if _, exists := deduped[normalized]; exists { + continue + } + deduped[normalized] = struct{}{} + result = append(result, normalized) + } + return result +} + +func normalizeScriptJSONText(value string) string { + return strings.TrimSpace(value) +} + +func normalizeScriptText(value string) string { + return strings.ReplaceAll(value, "\r\n", "\n") +} + +func normalizeScriptSource(input ScriptSource, existing ScriptSource) ScriptSource { + source := ScriptSource{ + Type: firstNonEmpty(strings.TrimSpace(input.Type), strings.TrimSpace(existing.Type)), + URI: firstNonEmpty(strings.TrimSpace(input.URI), strings.TrimSpace(existing.URI)), + Ref: firstNonEmpty(strings.TrimSpace(input.Ref), strings.TrimSpace(existing.Ref)), + Path: firstNonEmpty(strings.TrimSpace(input.Path), strings.TrimSpace(existing.Path)), + ImportedAt: firstNonEmpty(strings.TrimSpace(input.ImportedAt), strings.TrimSpace(existing.ImportedAt)), + } + if source.Type == "" && (source.URI != "" || source.Ref != "" || source.Path != "" || source.ImportedAt != "") { + source.Type = "manual" + } + return source +} + +func normalizeScriptTargetConfig(input ScriptTargetConfig) ScriptTargetConfig { + mode := normalizeScriptTargetMode(input.Mode) + createNameTemplate := strings.TrimSpace(input.CreateNameTemplate) + if createNameTemplate == "" && mode == "create" { + createNameTemplate = defaultScriptCreateNameTemplate + } + + return ScriptTargetConfig{ + Mode: mode, + Selector: normalizeScriptTargetSelector(input.Selector), + TemplateSelector: normalizeScriptTargetSelector(input.TemplateSelector), + CreateNameTemplate: createNameTemplate, + } +} + +func normalizeScriptTargetMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "existing": + return "existing" + case "create": + return "create" + case "rotate": + return "rotate" + default: + return "manual" + } +} + +func normalizeScriptTargetSelector(input ScriptTargetSelector) ScriptTargetSelector { + return ScriptTargetSelector{ + Code: strings.ToUpper(strings.TrimSpace(input.Code)), + ProfileID: strings.TrimSpace(input.ProfileID), + ProfileName: strings.TrimSpace(input.ProfileName), + GroupID: strings.TrimSpace(input.GroupID), + Keywords: normalizeScriptTags(input.Keywords), + Tags: normalizeScriptTags(input.Tags), + } +} + +func isSafeScriptID(value string) bool { + for _, ch := range value { + switch { + case ch >= 'a' && ch <= 'z': + case ch >= 'A' && ch <= 'Z': + case ch >= '0' && ch <= '9': + case ch == '-', ch == '_', ch == '.': + default: + return false + } + } + return true +} + +func parseRFC3339OrZero(value string) time.Time { + ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) + if err != nil { + return time.Time{} + } + return ts +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/internal/automation/scripts_store_test.go b/backend/internal/automation/scripts_store_test.go new file mode 100644 index 00000000..fbe489b6 --- /dev/null +++ b/backend/internal/automation/scripts_store_test.go @@ -0,0 +1,280 @@ +package automation + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScriptStoreSaveListAndDelete(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + saved, err := store.Save(ScriptRecord{ + ID: "buyer-script", + Name: "买家脚本", + Description: "用于接管页面并截图", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + Tags: []string{"Playwright", "CDP", "Playwright"}, + SelectorText: `{"code":"BUYER_001"}`, + ParamsText: `{"url":"https://example.com"}`, + ScriptText: "module.exports.run = async () => ({ ok: true })\r\n", + Notes: "stable", + }) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if saved.ID != "buyer-script" { + t.Fatalf("expected saved id buyer-script, got %q", saved.ID) + } + if saved.PackageFormat != defaultScriptPackageFormat { + t.Fatalf("expected package format %q, got %q", defaultScriptPackageFormat, saved.PackageFormat) + } + if saved.ManifestVersion != defaultScriptManifestVersion { + t.Fatalf("expected manifest version %d, got %d", defaultScriptManifestVersion, saved.ManifestVersion) + } + if len(saved.Tags) != 2 { + t.Fatalf("expected deduped tags, got %#v", saved.Tags) + } + if saved.CreatedAt == "" || saved.UpdatedAt == "" { + t.Fatalf("expected timestamps to be populated, got %+v", saved) + } + if saved.ScriptText != "module.exports.run = async () => ({ ok: true })\n" { + t.Fatalf("expected normalized script line endings, got %q", saved.ScriptText) + } + + configPath := filepath.Join(store.rootDir, "buyer-script", scriptStoreConfigFileName) + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected config to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(store.rootDir, "buyer-script", scriptStoreLegacyConfigName)); !os.IsNotExist(err) { + t.Fatalf("expected legacy manifest to be absent, got %v", err) + } + scriptPath := filepath.Join(store.rootDir, "buyer-script", "index.cjs") + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("expected script file to exist: %v", err) + } + + items, err := store.List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one item, got %d", len(items)) + } + if items[0].ScriptText == "" { + t.Fatalf("expected script text to be loaded from file") + } + if items[0].PackageFormat != defaultScriptPackageFormat { + t.Fatalf("expected loaded package format %q, got %q", defaultScriptPackageFormat, items[0].PackageFormat) + } + + if err := store.Delete("buyer-script"); err != nil { + t.Fatalf("Delete returned error: %v", err) + } + if _, err := os.Stat(filepath.Join(store.rootDir, "buyer-script")); !os.IsNotExist(err) { + t.Fatalf("expected script dir to be removed, got %v", err) + } +} + +func TestScriptStoreSaveRenamedEntryRemovesOldFile(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + original, err := store.Save(ScriptRecord{ + ID: "rename-script", + Name: "重命名入口", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + }) + if err != nil { + t.Fatalf("initial Save returned error: %v", err) + } + + updated, err := store.Save(ScriptRecord{ + ID: original.ID, + Name: original.Name, + Type: original.Type, + Status: original.Status, + EntryFile: "runner.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + CreatedAt: original.CreatedAt, + }) + if err != nil { + t.Fatalf("second Save returned error: %v", err) + } + + if updated.EntryFile != "runner.cjs" { + t.Fatalf("expected entry file runner.cjs, got %q", updated.EntryFile) + } + if updated.PackageFormat != defaultScriptPackageFormat { + t.Fatalf("expected package format %q, got %q", defaultScriptPackageFormat, updated.PackageFormat) + } + if updated.CreatedAt != original.CreatedAt { + t.Fatalf("expected createdAt to be preserved, got %q want %q", updated.CreatedAt, original.CreatedAt) + } + if _, err := os.Stat(filepath.Join(store.rootDir, original.ID, "runner.cjs")); err != nil { + t.Fatalf("expected new entry file to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(store.rootDir, original.ID, "index.cjs")); !os.IsNotExist(err) { + t.Fatalf("expected old entry file to be removed, got %v", err) + } +} + +func TestScriptStoreSaveGeneratesIDForNewScript(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + saved, err := store.Save(ScriptRecord{ + Name: "自动生成 ID", + ScriptText: "module.exports.run = async () => ({ ok: true })", + }) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + if saved.ID == "" { + t.Fatalf("expected generated id to be populated") + } + if _, err := os.Stat(filepath.Join(store.rootDir, saved.ID)); err != nil { + t.Fatalf("expected generated script dir to exist: %v", err) + } +} + +func TestScriptStorePersistsTargetConfig(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + saved, err := store.Save(ScriptRecord{ + ID: "target-config-script", + Name: "实例策略脚本", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + TargetConfig: ScriptTargetConfig{ + Mode: "rotate", + Selector: ScriptTargetSelector{ + Tags: []string{"pool", "pool"}, + Keywords: []string{"buyer"}, + }, + }, + }) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if saved.TargetConfig.Mode != "rotate" { + t.Fatalf("expected target mode rotate, got %+v", saved.TargetConfig) + } + if len(saved.TargetConfig.Selector.Tags) != 1 { + t.Fatalf("expected normalized target tags, got %+v", saved.TargetConfig.Selector.Tags) + } + + loaded, err := store.Get(saved.ID) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if loaded.TargetConfig.Mode != "rotate" { + t.Fatalf("expected persisted target mode rotate, got %+v", loaded.TargetConfig) + } + if len(loaded.TargetConfig.Selector.Tags) != 1 || loaded.TargetConfig.Selector.Tags[0] != "pool" { + t.Fatalf("unexpected persisted target tags: %+v", loaded.TargetConfig.Selector.Tags) + } +} + +func TestScriptStoreReadsLegacyManifestAndMigratesOnSave(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + scriptDir := filepath.Join(store.rootDir, "legacy-script") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + + legacyConfig := `{ + "packageFormat": "ant-automation-script", + "manifestVersion": 1, + "id": "legacy-script", + "name": "旧结构脚本", + "type": "playwright-cdp", + "status": "ready", + "entryFile": "index.cjs", + "createdAt": "2026-04-03T00:00:00Z", + "updatedAt": "2026-04-03T00:00:00Z" +}` + if err := os.WriteFile(filepath.Join(scriptDir, scriptStoreLegacyConfigName), []byte(legacyConfig), 0o644); err != nil { + t.Fatalf("write legacy manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(scriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true })"), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + loaded, err := store.Get("legacy-script") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if loaded.Name != "旧结构脚本" { + t.Fatalf("unexpected loaded record: %+v", loaded) + } + + loaded.ScriptText = "module.exports.run = async () => ({ ok: false })" + saved, err := store.Save(loaded) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + if saved.ID != "legacy-script" { + t.Fatalf("unexpected saved record: %+v", saved) + } + if _, err := os.Stat(filepath.Join(scriptDir, scriptStoreConfigFileName)); err != nil { + t.Fatalf("expected new config to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(scriptDir, scriptStoreLegacyConfigName)); !os.IsNotExist(err) { + t.Fatalf("expected legacy manifest to be removed, got %v", err) + } +} + +func TestScriptStoreKeepsManifestAsRegularFile(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + record, err := store.ImportBundle(ImportedBundle{ + Record: ScriptRecord{ + ID: "regular-manifest", + Name: "普通 manifest 文件", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + }, + Files: []ImportedBundleFile{ + { + Path: "index.cjs", + Content: []byte("module.exports.run = async () => ({ ok: true })"), + }, + { + Path: "manifest.json", + Content: []byte(`{"keep":true}`), + }, + }, + }) + if err != nil { + t.Fatalf("ImportBundle returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(store.rootDir, record.ID, scriptStoreConfigFileName)); err != nil { + t.Fatalf("expected system config to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(store.rootDir, record.ID, "manifest.json")); err != nil { + t.Fatalf("expected manifest.json to be kept as a regular file: %v", err) + } + + record.ScriptText = "module.exports.run = async () => ({ ok: false })" + if _, err := store.Save(record); err != nil { + t.Fatalf("Save returned error: %v", err) + } + if _, err := os.Stat(filepath.Join(store.rootDir, record.ID, "manifest.json")); err != nil { + t.Fatalf("expected manifest.json to survive Save: %v", err) + } + + exported, err := store.ExportBundle(record.ID) + if err != nil { + t.Fatalf("ExportBundle returned error: %v", err) + } + if !hasBundleFile(exported.Files, "manifest.json", []byte(`{"keep":true}`)) { + t.Fatalf("expected manifest.json in exported files, got %+v", exported.Files) + } +} diff --git a/backend/internal/automation/sysproc_others.go b/backend/internal/automation/sysproc_others.go new file mode 100644 index 00000000..32cd94c7 --- /dev/null +++ b/backend/internal/automation/sysproc_others.go @@ -0,0 +1,9 @@ +//go:build !windows +// +build !windows + +package automation + +import "os/exec" + +func hideWindow(cmd *exec.Cmd) { +} diff --git a/backend/internal/automation/sysproc_windows.go b/backend/internal/automation/sysproc_windows.go new file mode 100644 index 00000000..0b19cb56 --- /dev/null +++ b/backend/internal/automation/sysproc_windows.go @@ -0,0 +1,13 @@ +//go:build windows +// +build windows + +package automation + +import ( + "os/exec" + "syscall" +) + +func hideWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/backend/internal/automation/task_runner_exec.go b/backend/internal/automation/task_runner_exec.go new file mode 100644 index 00000000..61cf5e16 --- /dev/null +++ b/backend/internal/automation/task_runner_exec.go @@ -0,0 +1,169 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const TaskEventName = "automation:task:state" + +const ( + taskTypeScript = "script" +) + +func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (ScriptTaskResult, error) { + if ctx == nil { + ctx = context.Background() + } + + state := m.CurrentState() + if !state.Ready { + return ScriptTaskResult{}, fmt.Errorf("自动化运行时尚未就绪") + } + + req.TaskKey = strings.TrimSpace(req.TaskKey) + if req.TaskKey == "" { + return ScriptTaskResult{}, fmt.Errorf("taskKey is required") + } + req.ScriptPath = strings.TrimSpace(req.ScriptPath) + if req.ScriptPath == "" { + return ScriptTaskResult{}, fmt.Errorf("scriptPath is required") + } + req.LaunchBaseURL = strings.TrimSpace(req.LaunchBaseURL) + if req.LaunchBaseURL == "" { + return ScriptTaskResult{}, fmt.Errorf("launchBaseUrl is required") + } + + payload := taskRunnerPayload{ + TaskType: taskTypeScript, + RuntimeDir: state.RuntimeDir, + ScriptPath: req.ScriptPath, + Selector: req.Selector, + Params: req.Params, + LaunchBaseURL: req.LaunchBaseURL, + LaunchAuthHeader: strings.TrimSpace(req.LaunchAuthHeader), + LaunchAuthValue: strings.TrimSpace(req.LaunchAuthValue), + ArtifactDir: strings.TrimSpace(req.ArtifactDir), + } + + taskID, runnerResp, rawOutput, durationMs, err := m.executeTask( + ctx, + req.TaskKey, + payload, + "自动化 script task 已启动", + "自动化 script task 已完成", + ) + if err != nil { + return ScriptTaskResult{}, err + } + + result := ScriptTaskResult{ + TaskID: taskID, + TaskKey: req.TaskKey, + OK: runnerResp.OK, + Summary: strings.TrimSpace(runnerResp.Summary), + Error: strings.TrimSpace(runnerResp.Error), + ResultText: rawOutput, + DurationMs: durationMs, + StartedAt: runnerResp.StartedAt, + FinishedAt: runnerResp.FinishedAt, + RuntimeVersion: state.RuntimeVersion, + NodeVersion: state.NodeVersion, + PlaywrightVersion: state.PlaywrightVersion, + } + if result.Summary == "" { + if result.OK { + result.Summary = "脚本执行完成" + } else { + result.Summary = "脚本执行失败" + } + } + return result, nil +} + +func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string) (string, taskRunnerResponse, string, int64, error) { + taskID, err := m.registerTask(taskKey) + if err != nil { + return "", taskRunnerResponse{}, "", 0, err + } + defer m.unregisterTask(taskID) + + payloadPath, err := m.writeTaskPayload(payload) + if err != nil { + return "", taskRunnerResponse{}, "", 0, err + } + defer os.Remove(payloadPath) + + state := m.CurrentState() + cmd := exec.CommandContext(ctx, state.NodePath, state.RunnerPath, payloadPath) + cmd.Dir = state.RuntimeDir + hideWindow(cmd) + + startedAt := time.Now() + m.attachTaskCommand(taskID, cmd) + m.emitTaskEvent(TaskEvent{ + TaskID: taskID, + ProfileID: taskKey, + Phase: "started", + Message: startMessage, + StartedAt: startedAt.Format(time.RFC3339), + }) + + output, runErr := cmd.CombinedOutput() + durationMs := time.Since(startedAt).Milliseconds() + if runErr != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = runErr.Error() + } + m.emitTaskEvent(TaskEvent{ + TaskID: taskID, + ProfileID: taskKey, + Phase: "failed", + Message: message, + StartedAt: startedAt.Format(time.RFC3339), + FinishedAt: time.Now().Format(time.RFC3339), + DurationMs: durationMs, + }) + return "", taskRunnerResponse{}, "", durationMs, fmt.Errorf("自动化任务执行失败: %s", message) + } + + var runnerResp taskRunnerResponse + if err := json.Unmarshal(output, &runnerResp); err != nil { + return "", taskRunnerResponse{}, "", durationMs, fmt.Errorf("解析自动化任务结果失败: %w", err) + } + + m.emitTaskEvent(TaskEvent{ + TaskID: taskID, + ProfileID: taskKey, + Phase: "completed", + Message: completeMessage, + StartedAt: runnerResp.StartedAt, + FinishedAt: runnerResp.FinishedAt, + DurationMs: durationMs, + }) + + return taskID, runnerResp, string(output), durationMs, nil +} + +func (m *Manager) writeTaskPayload(payload taskRunnerPayload) (string, error) { + tempDir := filepath.Join(m.runtimeRoot(), "tmp") + if err := os.MkdirAll(tempDir, 0o755); err != nil { + return "", fmt.Errorf("创建自动化任务临时目录失败: %w", err) + } + file, err := os.CreateTemp(tempDir, "task-*.json") + if err != nil { + return "", fmt.Errorf("创建自动化任务临时文件失败: %w", err) + } + defer file.Close() + if err := json.NewEncoder(file).Encode(payload); err != nil { + return "", fmt.Errorf("写入自动化任务 payload 失败: %w", err) + } + return file.Name(), nil +} diff --git a/backend/internal/automation/task_runner_process.go b/backend/internal/automation/task_runner_process.go new file mode 100644 index 00000000..db158163 --- /dev/null +++ b/backend/internal/automation/task_runner_process.go @@ -0,0 +1,92 @@ +package automation + +import ( + "fmt" + "os/exec" + goruntime "runtime" + "strings" + + "github.com/google/uuid" +) + +func (m *Manager) StopAllTasks() { + m.mu.Lock() + tasks := make([]*activeTask, 0, len(m.activeTasks)) + for _, task := range m.activeTasks { + tasks = append(tasks, task) + } + m.activeTasks = make(map[string]*activeTask) + m.profileTask = make(map[string]string) + m.mu.Unlock() + + for _, task := range tasks { + if task == nil || task.cmd == nil || task.cmd.Process == nil { + continue + } + _ = stopTaskProcess(task.cmd) + } +} + +func (m *Manager) registerTask(profileID string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if existing, ok := m.profileTask[profileID]; ok && strings.TrimSpace(existing) != "" { + return "", fmt.Errorf("实例 %s 已有自动化任务在运行中", profileID) + } + taskID := uuid.NewString() + m.profileTask[profileID] = taskID + m.activeTasks[taskID] = &activeTask{ + taskID: taskID, + profileID: profileID, + } + return taskID, nil +} + +func (m *Manager) attachTaskCommand(taskID string, cmd *exec.Cmd) { + m.mu.Lock() + defer m.mu.Unlock() + if task, ok := m.activeTasks[taskID]; ok && task != nil { + task.cmd = cmd + } +} + +func (m *Manager) unregisterTask(taskID string) { + m.mu.Lock() + defer m.mu.Unlock() + task, ok := m.activeTasks[taskID] + if !ok || task == nil { + return + } + delete(m.activeTasks, taskID) + if current, ok := m.profileTask[task.profileID]; ok && current == taskID { + delete(m.profileTask, task.profileID) + } +} + +func (m *Manager) emitTaskEvent(event TaskEvent) { + if m.emit == nil { + return + } + m.emit(TaskEventName, event) +} + +func stopTaskProcess(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if goruntime.GOOS == "windows" { + killCmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", cmd.Process.Pid)) + hideWindow(killCmd) + if err := killCmd.Run(); err == nil { + return nil + } + } + err := cmd.Process.Kill() + if err == nil { + return nil + } + if strings.Contains(strings.ToLower(err.Error()), "already finished") { + return nil + } + return err +} diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go new file mode 100644 index 00000000..35217e73 --- /dev/null +++ b/backend/internal/automation/task_runner_test.go @@ -0,0 +1,508 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "ant-chrome/backend/internal/config" +) + +func TestRunScriptTaskExecutesCustomRunner(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + receivedBody := map[string]any{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/launch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode request body failed: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script.cjs") + scriptSource := `const fs = require('fs'); + +module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { + const session = await launch({ + selector, + startUrls: params.startUrls, + skipDefaultStartUrls: true, + }) + + const { browser } = await connect(session) + const context = browser.contexts()[0] + const page = context.pages()[0] || await context.newPage() + await page.goto(params.url, { waitUntil: 'domcontentloaded', timeout: params.timeoutMs || 30000 }) + + const filePath = artifact('script-output.txt') + fs.writeFileSync(filePath, 'artifact-ready') + log('profile', session.profileId) + + return { + ok: true, + summary: '脚本执行成功', + profileId: session.profileId, + url: page.url(), + artifactPath: filePath, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + artifactDir := filepath.Join(t.TempDir(), "artifacts") + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:test", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "BUYER_001"}, + Params: map[string]any{"url": "https://example.com/script", "startUrls": []string{"https://example.com/script"}}, + LaunchBaseURL: server.URL, + ArtifactDir: artifactDir, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + if result.Summary != "脚本执行成功" { + t.Fatalf("unexpected summary: %s", result.Summary) + } + if result.Error != "" { + t.Fatalf("unexpected error: %s", result.Error) + } + if !strings.Contains(result.ResultText, `"profileId":"profile-script"`) { + t.Fatalf("expected result text to contain profileId, got %s", result.ResultText) + } + if !strings.Contains(result.ResultText, `"artifactPath":"`) { + t.Fatalf("expected result text to contain artifact path, got %s", result.ResultText) + } + + if selector, ok := receivedBody["selector"].(map[string]any); !ok || selector["code"] != "BUYER_001" { + t.Fatalf("unexpected selector payload: %+v", receivedBody) + } + + artifactData, err := os.ReadFile(filepath.Join(artifactDir, "script-output.txt")) + if err != nil { + t.Fatalf("read script artifact failed: %v", err) + } + if string(artifactData) != "artifact-ready" { + t.Fatalf("unexpected script artifact payload: %s", string(artifactData)) + } +} + +func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + type launchRequestPayload struct { + Code string `json:"code"` + Key string `json:"key"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Keyword string `json:"keyword"` + Keywords []string `json:"keywords"` + Tag string `json:"tag"` + Tags []string `json:"tags"` + GroupID string `json:"groupId"` + MatchMode string `json:"matchMode"` + Selector map[string]any `json:"selector"` + LaunchArgs []string `json:"launchArgs"` + StartURLs []string `json:"startUrls"` + SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` + } + + receivedBody := launchRequestPayload{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/launch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&receivedBody); err != nil { + t.Fatalf("decode launch request body failed: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-launch-filter.cjs") + scriptSource := `module.exports.run = async ({ launch, selector, params }) => { + const session = await launch({ + selector, + startUrls: params.startUrls, + skipDefaultStartUrls: true, + }) + + return { + ok: true, + summary: '脚本执行成功', + profileId: session.profileId, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:launch-filter", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + Params: map[string]any{"url": "https://www.baidu.com", "keyword": "OpenAI", "captureScreenshot": true, "waitAfterSearchMs": 1500, "startUrls": []string{"https://www.baidu.com"}}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + if receivedBody.Selector["code"] != "DEMO_READY" { + t.Fatalf("unexpected selector payload: %+v", receivedBody) + } + if len(receivedBody.StartURLs) != 1 || receivedBody.StartURLs[0] != "https://www.baidu.com" { + t.Fatalf("unexpected startUrls payload: %+v", receivedBody.StartURLs) + } + if !receivedBody.SkipDefaultStartURLs { + t.Fatalf("expected skipDefaultStartUrls to be true") + } + if receivedBody.Keyword != "" { + t.Fatalf("expected non-launch params to be filtered, got keyword=%q", receivedBody.Keyword) + } +} + +func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/launch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 0, + "debugReady": false, + "cdpUrl": "http://127.0.0.1:0", + }) + })) + defer server.Close() + + if err := writeMockPlaywrightModuleWithExpectedEndpoint(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, server.URL); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-fallback.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { + const session = await launch({ selector }) + const connection = await connect(session) + + return { + ok: true, + summary: '脚本已通过 Launch 地址回退连接', + connectedEndpoint: connection.session.cdpUrl, + profileId: session.profileId, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:fallback", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + if result.Summary != "脚本已通过 Launch 地址回退连接" { + t.Fatalf("unexpected summary: %s", result.Summary) + } + if !strings.Contains(result.ResultText, `"connectedEndpoint":"`+server.URL+`"`) { + t.Fatalf("expected result text to contain fallback endpoint, got %s", result.ResultText) + } +} + +func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModuleWithPersistentConnection(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, ""); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script-close", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-close.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { + const session = await launch({ selector }) + const connection = await connect(session) + + return { + ok: true, + summary: '脚本执行成功', + connectedEndpoint: connection.session.cdpUrl, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + result, err := manager.RunScriptTask(ctx, ScriptTaskRequest{ + TaskKey: "script:close", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } +} + +func lookupNodeExecutable(t *testing.T) string { + t.Helper() + + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skipf("node is not available: %v", err) + } + + cmd := exec.Command(nodePath, "-p", "process.execPath") + output, err := cmd.Output() + if err != nil { + return nodePath + } + + resolved := strings.TrimSpace(string(output)) + if resolved == "" { + return nodePath + } + return resolved +} + +func writeMockPlaywrightModule(runtimeDir, version string) error { + return writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, "") +} + +func writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, expectedEndpoint string) error { + return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, false) +} + +func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expectedEndpoint string) error { + return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true) +} + +func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error { + moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + return err + } + + packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) + if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { + return err + } + + expectedEndpointJSON, err := json.Marshal(expectedEndpoint) + if err != nil { + return err + } + persistentConnectionJSON, err := json.Marshal(persistentConnection) + if err != nil { + return err + } + + indexJS := fmt.Sprintf(`const fs = require('fs'); + +const expectedEndpoint = %s; +const persistentConnection = %s; + +function createPage() { + let currentURL = 'about:blank'; + return { + async goto(url) { + currentURL = url; + }, + async waitForTimeout() {}, + async screenshot(options) { + fs.writeFileSync(options.path, 'mock-screenshot'); + }, + async title() { + return 'Mock Page Title'; + }, + url() { + return currentURL; + }, + async close() {}, + }; +} + +const context = { + async newPage() { + return createPage(); + }, + pages() { + return []; + }, +}; + +exports.chromium = { + async connectOverCDP(endpoint) { + if (String(endpoint).includes(':0')) { + throw new Error('invalid cdp endpoint'); + } + if (expectedEndpoint && endpoint !== expectedEndpoint) { + throw new Error('unexpected cdp endpoint: ' + endpoint); + } + const hold = persistentConnection ? setInterval(() => {}, 1000) : null; + return { + contexts() { + return [context]; + }, + async close() { + if (hold) { + clearInterval(hold); + } + }, + }; + }, +}; +`, string(expectedEndpointJSON), string(persistentConnectionJSON)) + return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) +} diff --git a/backend/internal/automation/task_runner_types.go b/backend/internal/automation/task_runner_types.go new file mode 100644 index 00000000..10fce170 --- /dev/null +++ b/backend/internal/automation/task_runner_types.go @@ -0,0 +1,61 @@ +package automation + +type ScriptTaskRequest struct { + TaskKey string `json:"taskKey"` + ScriptPath string `json:"scriptPath"` + Selector map[string]any `json:"selector,omitempty"` + Params map[string]any `json:"params,omitempty"` + LaunchBaseURL string `json:"launchBaseUrl"` + LaunchAuthHeader string `json:"launchAuthHeader,omitempty"` + LaunchAuthValue string `json:"launchAuthValue,omitempty"` + ArtifactDir string `json:"artifactDir,omitempty"` +} + +type ScriptTaskResult struct { + TaskID string `json:"taskId"` + TaskKey string `json:"taskKey"` + OK bool `json:"ok"` + Summary string `json:"summary"` + Error string `json:"error"` + ResultText string `json:"resultText"` + DurationMs int64 `json:"durationMs"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` + RuntimeVersion string `json:"runtimeVersion"` + NodeVersion string `json:"nodeVersion"` + PlaywrightVersion string `json:"playwrightVersion"` +} + +type taskRunnerPayload struct { + TaskType string `json:"taskType,omitempty"` + RuntimeDir string `json:"runtimeDir"` + ScriptPath string `json:"scriptPath,omitempty"` + Selector map[string]any `json:"selector,omitempty"` + Params map[string]any `json:"params,omitempty"` + LaunchBaseURL string `json:"launchBaseUrl,omitempty"` + LaunchAuthHeader string `json:"launchAuthHeader,omitempty"` + LaunchAuthValue string `json:"launchAuthValue,omitempty"` + ArtifactDir string `json:"artifactDir,omitempty"` +} + +type taskRunnerResponse struct { + OK bool `json:"ok"` + Summary string `json:"summary,omitempty"` + Error string `json:"error,omitempty"` + Title string `json:"title"` + URL string `json:"url"` + ScreenshotPath string `json:"screenshotPath,omitempty"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` + IsolatedPage bool `json:"isolatedPage"` +} + +type TaskEvent struct { + TaskID string `json:"taskId"` + ProfileID string `json:"profileId"` + Phase string `json:"phase"` + Message string `json:"message,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` +} diff --git a/backend/internal/backup/spec_paths.go b/backend/internal/backup/spec_paths.go new file mode 100644 index 00000000..b184c430 --- /dev/null +++ b/backend/internal/backup/spec_paths.go @@ -0,0 +1,51 @@ +package backup + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +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.go b/backend/internal/backup/spec_scope.go similarity index 71% rename from backend/internal/backup/spec.go rename to backend/internal/backup/spec_scope.go index 6367729e..a3161dc2 100644 --- a/backend/internal/backup/spec.go +++ b/backend/internal/backup/spec_scope.go @@ -3,87 +3,12 @@ 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) @@ -308,10 +233,6 @@ func detectLogDir(appRootAbs, logPath string) string { return filepath.Clean(dir) } -type scopeBuilder struct { - entries []ScopeEntry -} - func newScopeBuilder(_ string) *scopeBuilder { return &scopeBuilder{ entries: make([]ScopeEntry, 0, 12), @@ -364,46 +285,3 @@ func (b *scopeBuilder) isCoveredByExisting(candidate string) bool { } 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_types.go b/backend/internal/backup/spec_types.go new file mode 100644 index 00000000..79b9292c --- /dev/null +++ b/backend/internal/backup/spec_types.go @@ -0,0 +1,80 @@ +package backup + +import "ant-chrome/backend/internal/config" + +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 +} + +type scopeBuilder struct { + entries []ScopeEntry +} diff --git a/backend/internal/browser/connector.go b/backend/internal/browser/connector.go index b982e298..a5394c49 100644 --- a/backend/internal/browser/connector.go +++ b/backend/internal/browser/connector.go @@ -1,13 +1,10 @@ package browser -var defaultVerificationURLs = []string{ - "https://ippure.com/", - "https://iplark.com/", - "https://ping0.cc/", -} - // BuildLaunchArgs 构建启动参数 -func BuildLaunchArgs(args []string, profile *Profile) []string { - args = append(args, defaultVerificationURLs...) +func BuildLaunchArgs(args []string, startURLs []string) []string { + if len(startURLs) == 0 { + return args + } + args = append(args, startURLs...) return args } diff --git a/backend/internal/browser/connector_test.go b/backend/internal/browser/connector_test.go index dc6ec393..28e14fd1 100644 --- a/backend/internal/browser/connector_test.go +++ b/backend/internal/browser/connector_test.go @@ -9,7 +9,11 @@ func TestBuildLaunchArgsAppendsDefaultVerificationURLs(t *testing.T) { t.Parallel() baseArgs := []string{"--disable-sync"} - got := BuildLaunchArgs(append([]string{}, baseArgs...), &Profile{}) + got := BuildLaunchArgs(append([]string{}, baseArgs...), []string{ + "https://ippure.com/", + "https://iplark.com/", + "https://ping0.cc/", + }) want := []string{ "--disable-sync", "https://ippure.com/", diff --git a/backend/internal/browser/core.go b/backend/internal/browser/core.go deleted file mode 100644 index 6bac1e75..00000000 --- a/backend/internal/browser/core.go +++ /dev/null @@ -1,365 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/fsutil" - "ant-chrome/backend/internal/logger" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/google/uuid" -) - -func normalizeProfileCoreID(coreId string) string { - coreId = strings.TrimSpace(coreId) - if strings.EqualFold(coreId, "default") { - return "" - } - return coreId -} - -// GetCore 根据 coreId 获取内核配置 -func (m *Manager) GetCore(coreId string) (Core, bool) { - coreId = normalizeProfileCoreID(coreId) - if coreId == "" { - return Core{}, false - } - for _, core := range m.ListCores() { - if strings.EqualFold(core.CoreId, coreId) { - return core, true - } - } - return Core{}, false -} - -// GetDefaultCore 获取默认内核 -func (m *Manager) GetDefaultCore() (Core, bool) { - cores := m.ListCores() - for _, core := range cores { - if core.IsDefault { - return core, true - } - } - if len(cores) > 0 { - return 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, _, ok := FindCoreExecutable(baseDir) - if !ok { - return "", fmt.Errorf("浏览器内核目录无效:未找到可执行文件(候选:%s)。请检查内核目录是否完整或重新下载内核", strings.Join(CoreExecutableCandidates(), ", ")) - } - if err := fsutil.EnsureExecutable(exePath); err != nil { - return "", fmt.Errorf("浏览器内核文件不可执行:%s。原因:%w。请检查文件权限或重新解压内核", 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, _, ok := FindCoreExecutable(baseDir) - if !ok { - return CoreValidateResult{Valid: false, Message: fmt.Sprintf("未找到浏览器可执行文件(候选:%s)", strings.Join(CoreExecutableCandidates(), ", "))} - } - if err := fsutil.ValidateExecutable(exePath); err != nil { - return CoreValidateResult{Valid: false, Message: fmt.Sprintf("浏览器可执行文件不可用:%v", err)} - } - - 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 := normalizeProfileCoreID(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 - countByCoreID := func(profileCoreId string) { - // 如果实例的 CoreId 为空,则使用默认内核 - if profileCoreId == "" { - defaultCore, found := m.GetDefaultCore() - if found && strings.EqualFold(defaultCore.CoreId, coreId) { - count++ - } - } else if strings.EqualFold(profileCoreId, coreId) { - count++ - } - } - - if len(m.Profiles) > 0 { - for _, profile := range m.Profiles { - countByCoreID(normalizeProfileCoreID(profile.CoreId)) - } - return count - } - - for _, profile := range m.Config.Browser.Profiles { - countByCoreID(normalizeProfileCoreID(profile.CoreId)) - } - 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_info.go b/backend/internal/browser/core_info.go new file mode 100644 index 00000000..5c9345bc --- /dev/null +++ b/backend/internal/browser/core_info.go @@ -0,0 +1,89 @@ +package browser + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// 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 + countByCoreID := func(profileCoreId string) { + // 如果实例的 CoreId 为空,则使用默认内核 + if profileCoreId == "" { + defaultCore, found := m.GetDefaultCore() + if found && strings.EqualFold(defaultCore.CoreId, coreId) { + count++ + } + } else if strings.EqualFold(profileCoreId, coreId) { + count++ + } + } + + if len(m.Profiles) > 0 { + for _, profile := range m.Profiles { + countByCoreID(normalizeProfileCoreID(profile.CoreId)) + } + return count + } + + for _, profile := range m.Config.Browser.Profiles { + countByCoreID(normalizeProfileCoreID(profile.CoreId)) + } + 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_lookup.go b/backend/internal/browser/core_lookup.go new file mode 100644 index 00000000..86d88729 --- /dev/null +++ b/backend/internal/browser/core_lookup.go @@ -0,0 +1,115 @@ +package browser + +import ( + "ant-chrome/backend/internal/fsutil" + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "strings" +) + +func normalizeProfileCoreID(coreId string) string { + coreId = strings.TrimSpace(coreId) + if strings.EqualFold(coreId, "default") { + return "" + } + return coreId +} + +// GetCore 根据 coreId 获取内核配置 +func (m *Manager) GetCore(coreId string) (Core, bool) { + coreId = normalizeProfileCoreID(coreId) + if coreId == "" { + return Core{}, false + } + for _, core := range m.ListCores() { + if strings.EqualFold(core.CoreId, coreId) { + return core, true + } + } + return Core{}, false +} + +// GetDefaultCore 获取默认内核 +func (m *Manager) GetDefaultCore() (Core, bool) { + cores := m.ListCores() + for _, core := range cores { + if core.IsDefault { + return core, true + } + } + if len(cores) > 0 { + return 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, _, ok := FindCoreExecutable(baseDir) + if !ok { + return "", fmt.Errorf("浏览器内核目录无效:未找到可执行文件(候选:%s)。请检查内核目录是否完整或重新下载内核", strings.Join(CoreExecutableCandidates(), ", ")) + } + if err := fsutil.EnsureExecutable(exePath); err != nil { + return "", fmt.Errorf("浏览器内核文件不可执行:%s。原因:%w。请检查文件权限或重新解压内核", 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, _, ok := FindCoreExecutable(baseDir) + if !ok { + return CoreValidateResult{Valid: false, Message: fmt.Sprintf("未找到浏览器可执行文件(候选:%s)", strings.Join(CoreExecutableCandidates(), ", "))} + } + if err := fsutil.ValidateExecutable(exePath); err != nil { + return CoreValidateResult{Valid: false, Message: fmt.Sprintf("浏览器可执行文件不可用:%v", err)} + } + + return CoreValidateResult{Valid: true, Message: fmt.Sprintf("路径有效: %s", exePath)} +} + +// ResolveChromeBinary 解析 Chrome 二进制路径(简化版) +func (m *Manager) ResolveChromeBinary(profile *Profile) (string, error) { + log := logger.New("Browser") + coreId := normalizeProfileCoreID(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 +} diff --git a/backend/internal/browser/core_store.go b/backend/internal/browser/core_store.go new file mode 100644 index 00000000..8057271f --- /dev/null +++ b/backend/internal/browser/core_store.go @@ -0,0 +1,178 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// 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 + } +} diff --git a/backend/internal/browser/download_core.go b/backend/internal/browser/download_core.go deleted file mode 100644 index 2791acce..00000000 --- a/backend/internal/browser/download_core.go +++ /dev/null @@ -1,427 +0,0 @@ -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" -) - -// 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) -} - -// 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 := readSystemProxy(); 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, fmt.Sprintf("解压后未找到浏览器可执行文件(候选:%s),请检查压缩包内容!", strings.Join(CoreExecutableCandidates(), ", "))) - } -} - -// 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/download_core_extract.go b/backend/internal/browser/download_core_extract.go new file mode 100644 index 00000000..40784047 --- /dev/null +++ b/backend/internal/browser/download_core_extract.go @@ -0,0 +1,107 @@ +package browser + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// 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 +} diff --git a/backend/internal/browser/download_core_task.go b/backend/internal/browser/download_core_task.go new file mode 100644 index 00000000..0404b400 --- /dev/null +++ b/backend/internal/browser/download_core_task.go @@ -0,0 +1,137 @@ +package browser + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "ant-chrome/backend/internal/logger" + "github.com/google/uuid" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// 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 := readSystemProxy(); 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, fmt.Sprintf("解压后未找到浏览器可执行文件(候选:%s),请检查压缩包内容!", strings.Join(CoreExecutableCandidates(), ", "))) + } +} diff --git a/backend/internal/browser/download_core_transfer.go b/backend/internal/browser/download_core_transfer.go new file mode 100644 index 00000000..49f84529 --- /dev/null +++ b/backend/internal/browser/download_core_transfer.go @@ -0,0 +1,181 @@ +package browser + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" +) + +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/download_core_types.go b/backend/internal/browser/download_core_types.go new file mode 100644 index 00000000..ac6b339c --- /dev/null +++ b/backend/internal/browser/download_core_types.go @@ -0,0 +1,24 @@ +package browser + +import "context" + +// 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) +} diff --git a/backend/internal/browser/profile.go b/backend/internal/browser/profile.go deleted file mode 100644 index 9d5aad4c..00000000 --- a/backend/internal/browser/profile.go +++ /dev/null @@ -1,501 +0,0 @@ -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 { - p.CoreId = normalizeProfileCoreID(p.CoreId) - 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: normalizeProfileCoreID(item.CoreId), - FingerprintArgs: append([]string{}, item.FingerprintArgs...), - ProxyId: item.ProxyId, - ProxyConfig: item.ProxyConfig, - ProxyBindSourceID: item.ProxyBindSourceID, - ProxyBindSourceURL: item.ProxyBindSourceURL, - ProxyBindName: item.ProxyBindName, - ProxyBindUpdatedAt: item.ProxyBindUpdatedAt, - 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 { - profile.CoreId = normalizeProfileCoreID(profile.CoreId) - 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: normalizeProfileCoreID(profile.CoreId), - FingerprintArgs: append([]string{}, profile.FingerprintArgs...), - ProxyId: profile.ProxyId, - ProxyConfig: profile.ProxyConfig, - ProxyBindSourceID: profile.ProxyBindSourceID, - ProxyBindSourceURL: profile.ProxyBindSourceURL, - ProxyBindName: profile.ProxyBindName, - ProxyBindUpdatedAt: profile.ProxyBindUpdatedAt, - 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) - selectedProxy := Proxy{} - hasSelectedProxy := false - if proxyId != "" { - if proxyItem, ok := m.GetProxyByID(proxyId); ok { - proxyConfig = strings.TrimSpace(proxyItem.ProxyConfig) - selectedProxy = proxyItem - hasSelectedProxy = true - } else { - log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", proxyId)) - } - } - coreId := normalizeProfileCoreID(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, - } - if hasSelectedProxy { - _ = BindProfileToProxy(profile, selectedProxy, true) - } - 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 = normalizeProfileCoreID(input.CoreId) - profile.FingerprintArgs = input.FingerprintArgs - profile.ProxyId = strings.TrimSpace(input.ProxyId) - if profile.ProxyId != "" { - if proxyItem, ok := m.GetProxyByID(profile.ProxyId); ok { - _ = BindProfileToProxy(profile, proxyItem, true) - } else { - log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId)) - } - } else { - profile.ProxyConfig = input.ProxyConfig - _ = ClearProfileProxyBinding(profile) - } - 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 - } - profile.CoreId = normalizeProfileCoreID(profile.CoreId) - if profile.CoreId == "" { - if defaultCore, ok := m.GetDefaultCore(); ok { - profile.CoreId = defaultCore.CoreId - } - } - proxyChanged := false - bindChanged, boundInPool, bindMode := m.ResolveProfileProxyBinding(profile) - if bindChanged { - proxyChanged = true - } - if bindMode != "" && bindMode != "proxy_id" { - log.Info("实例代理自动重关联", - logger.F("profile_id", profile.ProfileId), - logger.F("proxy_id", profile.ProxyId), - logger.F("mode", bindMode), - ) - } - if profile.ProxyId != "" && !boundInPool { - if strings.TrimSpace(profile.ProxyConfig) == "" { - log.Error("实例代理未找到", logger.F("profile_id", profile.ProfileId), logger.F("proxy_id", profile.ProxyId)) - } else { - log.Warn("实例代理未找到,回退使用历史代理配置", 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: normalizeProfileCoreID(src.CoreId), - FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), // 使用默认指纹(新种子) - ProxyId: src.ProxyId, - ProxyConfig: src.ProxyConfig, - ProxyBindSourceID: src.ProxyBindSourceID, - ProxyBindSourceURL: src.ProxyBindSourceURL, - ProxyBindName: src.ProxyBindName, - ProxyBindUpdatedAt: src.ProxyBindUpdatedAt, - 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_copy.go b/backend/internal/browser/profile_copy.go new file mode 100644 index 00000000..4bd7fc37 --- /dev/null +++ b/backend/internal/browser/profile_copy.go @@ -0,0 +1,71 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// Copy 复制实例配置(除指纹参数外全部复制,指纹使用默认值生成新种子) +func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + + if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit { + log.Error("复制实例失败: 达到数量上限", logger.F("limit", m.Config.App.MaxProfileLimit)) + return nil, newProfileLimitExceededError(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: normalizeProfileCoreID(src.CoreId), + FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), + ProxyId: src.ProxyId, + ProxyConfig: src.ProxyConfig, + ProxyBindSourceID: src.ProxyBindSourceID, + ProxyBindSourceURL: src.ProxyBindSourceURL, + ProxyBindName: src.ProxyBindName, + ProxyBindUpdatedAt: src.ProxyBindUpdatedAt, + 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 + } + + m.ensureProfileLaunchCode(profile) + return profile, nil +} diff --git a/backend/internal/browser/profile_create.go b/backend/internal/browser/profile_create.go new file mode 100644 index 00000000..21343718 --- /dev/null +++ b/backend/internal/browser/profile_create.go @@ -0,0 +1,94 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// Create 创建配置 +func (m *Manager) Create(input ProfileInput) (*Profile, error) { + log := logger.New("Browser") + m.InitData() + m.Mutex.Lock() + defer m.Mutex.Unlock() + + 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 + } + resolvedProxy, err := m.resolveProfileProxyInput(input.ProxyId, input.ProxyConfig) + if err != nil { + log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", strings.TrimSpace(input.ProxyId)), logger.F("error", err.Error())) + return nil, err + } + coreId := normalizeProfileCoreID(input.CoreId) + if coreId == "" { + if defaultCore, ok := m.GetDefaultCore(); ok { + coreId = defaultCore.CoreId + } + } + profile := &Profile{ + ProfileId: profileId, + ProfileName: input.ProfileName, + UserDataDir: userDataDir, + CoreId: coreId, + FingerprintArgs: input.FingerprintArgs, + ProxyId: resolvedProxy.ProxyId, + ProxyConfig: resolvedProxy.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, + } + if resolvedProxy.HasSelectedProxy { + _ = BindProfileToProxy(profile, resolvedProxy.SelectedProxy, true) + } else if resolvedProxy.FallbackToDirect { + _ = m.bindProfileToDirectProxy(profile) + } + if resolvedProxy.UsedConfigFallback { + log.Warn("代理ID未命中,已改为使用输入的代理配置", + logger.F("profile_id", profileId), + logger.F("proxy_id", strings.TrimSpace(input.ProxyId)), + ) + } + 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 + } + m.ensureProfileLaunchCode(profile) + return profile, nil +} + +func (m *Manager) ensureProfileLaunchCode(profile *Profile) { + if m.CodeProvider == nil || profile == nil { + return + } + if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil { + profile.LaunchCode = code + } +} + +func newProfileLimitExceededError(limit int, action string) error { + return fmt.Errorf("实例数量已达上限 (%d个),无法%s。请兑换额度后重试!", limit, action) +} + +func buildProfileGroupID(value string) string { + return strings.TrimSpace(value) +} diff --git a/backend/internal/browser/profile_defaults.go b/backend/internal/browser/profile_defaults.go new file mode 100644 index 00000000..0e46f755 --- /dev/null +++ b/backend/internal/browser/profile_defaults.go @@ -0,0 +1,122 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "strings" +) + +const directProxyID = "__direct__" + +// 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 + } + profile.CoreId = normalizeProfileCoreID(profile.CoreId) + if profile.CoreId == "" { + if defaultCore, ok := m.GetDefaultCore(); ok { + profile.CoreId = defaultCore.CoreId + } + } + + proxyChanged := false + bindChanged, boundInPool, bindMode := m.ResolveProfileProxyBinding(profile) + if bindChanged { + proxyChanged = true + } + if bindMode != "" && bindMode != "proxy_id" { + log.Info("实例代理自动重关联", + logger.F("profile_id", profile.ProfileId), + logger.F("proxy_id", profile.ProxyId), + logger.F("mode", bindMode), + ) + } + + if strings.TrimSpace(profile.ProxyId) == "" { + if proxy, ok := m.resolvePoolProxyByConfig(profile.ProxyConfig); ok { + if BindProfileToProxy(profile, proxy, true) { + proxyChanged = true + } + boundInPool = true + } else if strings.TrimSpace(profile.ProxyConfig) == "" && m.bindProfileToDirectProxy(profile) { + proxyChanged = true + boundInPool = true + } + } + + if profile.ProxyId != "" && !boundInPool { + missingProxyID := profile.ProxyId + if strings.TrimSpace(profile.ProxyConfig) != "" { + profile.ProxyId = "" + proxyChanged = true + if ClearProfileProxyBinding(profile) { + proxyChanged = true + } + log.Warn("实例代理ID未找到,已改为使用实例代理配置", + logger.F("profile_id", profile.ProfileId), + logger.F("missing_proxy_id", missingProxyID), + ) + } else if m.bindProfileToDirectProxy(profile) { + proxyChanged = true + log.Warn("实例代理未找到,已回退到直连", + logger.F("profile_id", profile.ProfileId), + logger.F("missing_proxy_id", missingProxyID), + ) + } + } + + return proxyChanged +} + +func (m *Manager) bindProfileToDirectProxy(profile *Profile) bool { + if profile == nil { + return false + } + if proxy, ok := m.GetProxyByID(directProxyID); ok { + return BindProfileToProxy(profile, proxy, true) + } + + changed := false + if strings.TrimSpace(profile.ProxyId) != "" { + profile.ProxyId = "" + changed = true + } + if strings.TrimSpace(profile.ProxyConfig) != "" { + profile.ProxyConfig = "" + changed = true + } + if ClearProfileProxyBinding(profile) { + changed = true + } + return changed +} + +func (m *Manager) resolvePoolProxyByConfig(proxyConfig string) (Proxy, bool) { + target := normalizeProxyBindValue(proxyConfig) + if target == "" { + return Proxy{}, false + } + proxies := m.listProxyCatalog() + return uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.ProxyConfig) == target + }) +} + +// 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_defaults_test.go b/backend/internal/browser/profile_defaults_test.go new file mode 100644 index 00000000..329fa95e --- /dev/null +++ b/backend/internal/browser/profile_defaults_test.go @@ -0,0 +1,116 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "testing" +) + +func TestApplyDefaultsDoesNotFallbackToDirectAfterPoolBindByProxyConfig(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, "") + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + {ProxyId: "pool-1", ProxyName: "香港-01", ProxyConfig: "socks5://127.0.0.1:1080"}, + }, + } + + profile := &Profile{ + ProfileId: "pf-apply-defaults-1", + ProxyId: "", + ProxyConfig: "socks5://127.0.0.1:1080", + } + + changed := mgr.ApplyDefaults(profile) + if !changed { + t.Fatalf("expected proxy binding to change") + } + if profile.ProxyId != "pool-1" { + t.Fatalf("expected proxyId to bind to pool-1, got=%q", profile.ProxyId) + } + if profile.ProxyId == directProxyID { + t.Fatalf("expected not to fallback to direct proxy") + } +} + +func TestApplyDefaultsKeepsCustomProxyConfigWhenNotInPool(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, "") + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + {ProxyId: "pool-2", ProxyName: "日本-01", ProxyConfig: "socks5://127.0.0.1:2080"}, + }, + } + + profile := &Profile{ + ProfileId: "pf-apply-defaults-2", + ProxyId: "", + ProxyConfig: "http://127.0.0.1:9090", + } + + _ = mgr.ApplyDefaults(profile) + if profile.ProxyId != "" { + t.Fatalf("expected proxyId to stay empty for custom proxyConfig, got=%q", profile.ProxyId) + } + if profile.ProxyConfig != "http://127.0.0.1:9090" { + t.Fatalf("expected proxyConfig to be preserved, got=%q", profile.ProxyConfig) + } +} + +func TestApplyDefaultsClearsMissingProxyIdButPreservesProxyConfig(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, "") + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + }, + } + + profile := &Profile{ + ProfileId: "pf-apply-defaults-3", + ProxyId: "missing-proxy-id", + ProxyConfig: "http://127.0.0.1:9090", + } + + changed := mgr.ApplyDefaults(profile) + if !changed { + t.Fatalf("expected proxy binding to change when clearing missing proxyId") + } + if profile.ProxyId != "" { + t.Fatalf("expected missing proxyId to be cleared, got=%q", profile.ProxyId) + } + if profile.ProxyConfig != "http://127.0.0.1:9090" { + t.Fatalf("expected proxyConfig to be preserved, got=%q", profile.ProxyConfig) + } + if profile.ProxyId == directProxyID { + t.Fatalf("expected not to fallback to direct proxy when proxyConfig is present") + } +} + +func TestApplyDefaultsFallsBackToDirectWhenProxyMissing(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, "") + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + }, + } + + profile := &Profile{ + ProfileId: "pf-apply-defaults-4", + ProxyId: "", + ProxyConfig: "", + } + + changed := mgr.ApplyDefaults(profile) + if !changed { + t.Fatalf("expected direct proxy fallback to change profile") + } + if profile.ProxyId != directProxyID { + t.Fatalf("expected fallback to direct proxy id, got=%q", profile.ProxyId) + } + if profile.ProxyConfig != "direct://" { + t.Fatalf("expected fallback proxy config to be direct://, got=%q", profile.ProxyConfig) + } +} diff --git a/backend/internal/browser/profile_delete.go b/backend/internal/browser/profile_delete.go new file mode 100644 index 00000000..dd1157dc --- /dev/null +++ b/backend/internal/browser/profile_delete.go @@ -0,0 +1,37 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "fmt" +) + +// 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)) + + 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 +} diff --git a/backend/internal/browser/profile_proxy_input.go b/backend/internal/browser/profile_proxy_input.go new file mode 100644 index 00000000..2cbe7187 --- /dev/null +++ b/backend/internal/browser/profile_proxy_input.go @@ -0,0 +1,58 @@ +package browser + +import ( + "fmt" + "strings" +) + +type resolvedProfileProxyInput struct { + ProxyId string + ProxyConfig string + SelectedProxy Proxy + HasSelectedProxy bool + FallbackToDirect bool + UsedConfigFallback bool +} + +// resolveProfileProxyInput 统一处理实例输入中的代理参数。 +// 规则: +// 1. proxyId 命中代理池 => 使用代理池配置; +// 2. proxyId 未命中且提供 proxyConfig => 改为自定义代理(清空 proxyId); +// 3. proxyId/proxyConfig 都为空 => 回退直连; +// 4. proxyId 未命中且 proxyConfig 为空 => 直接报错,避免静默回退。 +func (m *Manager) resolveProfileProxyInput(proxyIdInput string, proxyConfigInput string) (resolvedProfileProxyInput, error) { + proxyId := strings.TrimSpace(proxyIdInput) + proxyConfig := strings.TrimSpace(proxyConfigInput) + + if proxyId != "" { + if proxyItem, ok := m.GetProxyByID(proxyId); ok { + return resolvedProfileProxyInput{ + ProxyId: strings.TrimSpace(proxyItem.ProxyId), + ProxyConfig: strings.TrimSpace(proxyItem.ProxyConfig), + SelectedProxy: proxyItem, + HasSelectedProxy: true, + }, nil + } + if proxyConfig != "" { + return resolvedProfileProxyInput{ + ProxyId: "", + ProxyConfig: proxyConfig, + UsedConfigFallback: true, + }, nil + } + return resolvedProfileProxyInput{}, fmt.Errorf("代理ID不存在(proxy id not found: %s),且未提供 proxyConfig", proxyId) + } + + if proxyConfig != "" { + return resolvedProfileProxyInput{ + ProxyId: "", + ProxyConfig: proxyConfig, + }, nil + } + + return resolvedProfileProxyInput{ + ProxyId: "", + ProxyConfig: "", + FallbackToDirect: true, + }, nil +} diff --git a/backend/internal/browser/profile_proxy_input_test.go b/backend/internal/browser/profile_proxy_input_test.go new file mode 100644 index 00000000..10235ee5 --- /dev/null +++ b/backend/internal/browser/profile_proxy_input_test.go @@ -0,0 +1,129 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "strings" + "testing" +) + +func newProfileProxyInputTestManager(t *testing.T) *Manager { + t.Helper() + cfg := config.DefaultConfig() + mgr := NewManager(cfg, t.TempDir()) + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, + {ProxyId: "proxy-us", ProxyName: "US", ProxyConfig: "socks5://127.0.0.1:1080"}, + }, + } + return mgr +} + +func TestCreateProfileRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { + mgr := newProfileProxyInputTestManager(t) + _, err := mgr.Create(ProfileInput{ + ProfileName: "buyer-1", + ProxyId: "missing-id", + }) + if err == nil { + t.Fatalf("expected create to fail for missing proxy id without proxyConfig") + } + if !strings.Contains(strings.ToLower(err.Error()), "proxy id not found") { + t.Fatalf("unexpected error: %v", err) + } + if len(mgr.Profiles) != 0 { + t.Fatalf("profile should not be created on proxy validation failure") + } +} + +func TestCreateProfileFallsBackToCustomProxyConfigWhenProxyIDMissing(t *testing.T) { + mgr := newProfileProxyInputTestManager(t) + profile, err := mgr.Create(ProfileInput{ + ProfileName: "buyer-2", + ProxyId: "missing-id", + ProxyConfig: "http://127.0.0.1:18080", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if profile.ProxyId != "" { + t.Fatalf("expected proxyId to be cleared, got=%q", profile.ProxyId) + } + if profile.ProxyConfig != "http://127.0.0.1:18080" { + t.Fatalf("expected proxyConfig to be preserved, got=%q", profile.ProxyConfig) + } +} + +func TestCreateProfileFallsBackToDirectWhenProxyInputEmpty(t *testing.T) { + mgr := newProfileProxyInputTestManager(t) + profile, err := mgr.Create(ProfileInput{ + ProfileName: "buyer-3", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if profile.ProxyId != directProxyID { + t.Fatalf("expected direct proxy id, got=%q", profile.ProxyId) + } + if profile.ProxyConfig != "direct://" { + t.Fatalf("expected direct proxy config, got=%q", profile.ProxyConfig) + } +} + +func TestUpdateProfileRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { + mgr := newProfileProxyInputTestManager(t) + profile, err := mgr.Create(ProfileInput{ + ProfileName: "buyer-old", + ProxyId: "proxy-us", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + beforeName := profile.ProfileName + beforeProxyID := profile.ProxyId + beforeProxyConfig := profile.ProxyConfig + + _, err = mgr.Update(profile.ProfileId, ProfileInput{ + ProfileName: "buyer-new", + ProxyId: "missing-id", + }) + if err == nil { + t.Fatalf("expected update to fail for missing proxy id without proxyConfig") + } + current := mgr.Profiles[profile.ProfileId] + if current.ProfileName != beforeName { + t.Fatalf("profile name should stay unchanged on failure, got=%q", current.ProfileName) + } + if current.ProxyId != beforeProxyID || current.ProxyConfig != beforeProxyConfig { + t.Fatalf("proxy fields should stay unchanged on failure, got=%q/%q", current.ProxyId, current.ProxyConfig) + } +} + +func TestUpdateProfileFallsBackToCustomProxyConfigWhenProxyIDMissing(t *testing.T) { + mgr := newProfileProxyInputTestManager(t) + profile, err := mgr.Create(ProfileInput{ + ProfileName: "buyer-old", + ProxyId: "proxy-us", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + + updated, err := mgr.Update(profile.ProfileId, ProfileInput{ + ProfileName: "buyer-new", + ProxyId: "missing-id", + ProxyConfig: "http://127.0.0.1:19090", + }) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if updated.ProfileName != "buyer-new" { + t.Fatalf("expected updated name, got=%q", updated.ProfileName) + } + if updated.ProxyId != "" { + t.Fatalf("expected proxyId to be cleared, got=%q", updated.ProxyId) + } + if updated.ProxyConfig != "http://127.0.0.1:19090" { + t.Fatalf("expected proxyConfig to be updated, got=%q", updated.ProxyConfig) + } +} diff --git a/backend/internal/browser/profile_query.go b/backend/internal/browser/profile_query.go new file mode 100644 index 00000000..925d5d69 --- /dev/null +++ b/backend/internal/browser/profile_query.go @@ -0,0 +1,71 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "sort" + "strings" +) + +// 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) + } + 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 +} diff --git a/backend/internal/browser/profile_store.go b/backend/internal/browser/profile_store.go new file mode 100644 index 00000000..98b6018e --- /dev/null +++ b/backend/internal/browser/profile_store.go @@ -0,0 +1,138 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "os/exec" + "strings" + "time" +) + +// 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") + + if m.ProfileDAO != nil { + profiles, err := m.ProfileDAO.List() + if err != nil { + log.Error("从数据库加载实例配置失败", logger.F("error", err)) + } else { + for _, p := range profiles { + p.CoreId = normalizeProfileCoreID(p.CoreId) + m.Profiles[p.ProfileId] = p + } + if len(profiles) > 0 { + log.Info("实例配置从数据库加载完成", logger.F("count", len(profiles))) + } else { + log.Info("实例表为空,用户可手动创建新实例") + } + return + } + } + + 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: normalizeProfileCoreID(item.CoreId), + FingerprintArgs: append([]string{}, item.FingerprintArgs...), + ProxyId: item.ProxyId, + ProxyConfig: item.ProxyConfig, + ProxyBindSourceID: item.ProxyBindSourceID, + ProxyBindSourceURL: item.ProxyBindSourceURL, + ProxyBindName: item.ProxyBindName, + ProxyBindUpdatedAt: item.ProxyBindUpdatedAt, + 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 { + profile.CoreId = normalizeProfileCoreID(profile.CoreId) + 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 + } + + 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: normalizeProfileCoreID(profile.CoreId), + FingerprintArgs: append([]string{}, profile.FingerprintArgs...), + ProxyId: profile.ProxyId, + ProxyConfig: profile.ProxyConfig, + ProxyBindSourceID: profile.ProxyBindSourceID, + ProxyBindSourceURL: profile.ProxyBindSourceURL, + ProxyBindName: profile.ProxyBindName, + ProxyBindUpdatedAt: profile.ProxyBindUpdatedAt, + 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 +} diff --git a/backend/internal/browser/profile_update.go b/backend/internal/browser/profile_update.go new file mode 100644 index 00000000..f665bca8 --- /dev/null +++ b/backend/internal/browser/profile_update.go @@ -0,0 +1,79 @@ +package browser + +import ( + "ant-chrome/backend/internal/logger" + "fmt" + "strings" + "time" +) + +// 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") + } + resolvedProxy, err := m.resolveProfileProxyInput(input.ProxyId, input.ProxyConfig) + if err != nil { + log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", strings.TrimSpace(input.ProxyId)), logger.F("error", err.Error())) + return nil, err + } + + profile.ProfileName = input.ProfileName + profile.UserDataDir = input.UserDataDir + profile.CoreId = normalizeProfileCoreID(input.CoreId) + profile.FingerprintArgs = input.FingerprintArgs + if resolvedProxy.HasSelectedProxy { + _ = BindProfileToProxy(profile, resolvedProxy.SelectedProxy, true) + } else if resolvedProxy.FallbackToDirect { + _ = m.bindProfileToDirectProxy(profile) + } else { + profile.ProxyId = resolvedProxy.ProxyId + profile.ProxyConfig = resolvedProxy.ProxyConfig + _ = ClearProfileProxyBinding(profile) + } + if resolvedProxy.UsedConfigFallback { + log.Warn("代理ID未命中,已改为使用输入的代理配置", + logger.F("profile_id", profileId), + logger.F("proxy_id", strings.TrimSpace(input.ProxyId)), + ) + } + profile.LaunchArgs = input.LaunchArgs + profile.Tags = input.Tags + profile.Keywords = append([]string{}, input.Keywords...) + profile.GroupId = buildProfileGroupID(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 +} + +// 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 +} diff --git a/backend/internal/browser/session_restore.go b/backend/internal/browser/session_restore.go new file mode 100644 index 00000000..112ff71c --- /dev/null +++ b/backend/internal/browser/session_restore.go @@ -0,0 +1,45 @@ +package browser + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +var sessionRestoreLegacyFiles = []string{ + "Current Session", + "Current Tabs", + "Last Session", + "Last Tabs", +} + +// ClearSessionRestoreData 删除 Chromium 用于恢复上次标签页的会话文件, +// 保留 cookies、Local Storage 等其他用户数据不变。 +func ClearSessionRestoreData(userDataDir string) error { + rootDir := strings.TrimSpace(userDataDir) + if rootDir == "" { + return fmt.Errorf("user data dir is empty") + } + + profileDir := filepath.Join(rootDir, "Default") + sessionsDir := filepath.Join(profileDir, "Sessions") + var errs []error + + if err := os.RemoveAll(sessionsDir); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove sessions dir: %w", err)) + } else if err == nil { + if mkErr := os.MkdirAll(sessionsDir, 0o755); mkErr != nil { + errs = append(errs, fmt.Errorf("recreate sessions dir: %w", mkErr)) + } + } + + for _, name := range sessionRestoreLegacyFiles { + if err := os.Remove(filepath.Join(profileDir, name)); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove %s: %w", name, err)) + } + } + + return errors.Join(errs...) +} diff --git a/backend/internal/browser/session_restore_test.go b/backend/internal/browser/session_restore_test.go new file mode 100644 index 00000000..516f7cad --- /dev/null +++ b/backend/internal/browser/session_restore_test.go @@ -0,0 +1,54 @@ +package browser + +import ( + "os" + "path/filepath" + "testing" +) + +func TestClearSessionRestoreDataRemovesSessionArtifactsOnly(t *testing.T) { + t.Parallel() + + userDataDir := t.TempDir() + profileDir := filepath.Join(userDataDir, "Default") + sessionsDir := filepath.Join(profileDir, "Sessions") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatalf("创建 Sessions 目录失败: %v", err) + } + + filesToCreate := []string{ + filepath.Join(sessionsDir, "Session_1"), + filepath.Join(sessionsDir, "Tabs_1"), + filepath.Join(profileDir, "Last Session"), + filepath.Join(profileDir, "Current Tabs"), + filepath.Join(profileDir, "Preferences"), + } + for _, path := range filesToCreate { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("创建目录失败: %v", err) + } + if err := os.WriteFile(path, []byte("stub"), 0o644); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + } + + if err := ClearSessionRestoreData(userDataDir); err != nil { + t.Fatalf("ClearSessionRestoreData 返回错误: %v", err) + } + + if entries, err := os.ReadDir(sessionsDir); err != nil { + t.Fatalf("读取 Sessions 目录失败: %v", err) + } else if len(entries) != 0 { + t.Fatalf("Sessions 目录应为空: got=%d", len(entries)) + } + + for _, name := range []string{"Last Session", "Current Tabs"} { + if _, err := os.Stat(filepath.Join(profileDir, name)); !os.IsNotExist(err) { + t.Fatalf("%s 应已删除: err=%v", name, err) + } + } + + if _, err := os.Stat(filepath.Join(profileDir, "Preferences")); err != nil { + t.Fatalf("Preferences 不应被删除: %v", err) + } +} diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go index 4075d9fd..14a3b349 100644 --- a/backend/internal/browser/types.go +++ b/backend/internal/browser/types.go @@ -64,7 +64,8 @@ type Settings struct { UserDataRoot string `json:"userDataRoot"` DefaultFingerprintArgs []string `json:"defaultFingerprintArgs"` DefaultLaunchArgs []string `json:"defaultLaunchArgs"` - DefaultProxy string `json:"defaultProxy"` + DefaultStartURLs []string `json:"defaultStartUrls"` + RestoreLastSession bool `json:"restoreLastSession"` StartReadyTimeoutMs int `json:"startReadyTimeoutMs"` StartStableWindowMs int `json:"startStableWindowMs"` } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2ec7ed10..16551b3c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,14 +1,6 @@ package config -import ( - "fmt" - "os" - "path/filepath" - goruntime "runtime" - "strings" - - "gopkg.in/yaml.v3" -) +import "strings" const ( DefaultMaxProfileLimit = 20 @@ -18,6 +10,16 @@ const ( GithubStarProfileTotal = DefaultMaxProfileLimit + GithubStarProfileBonus DefaultLaunchServerPort = 19876 DefaultLaunchServerAPIKeyHeader = "X-Ant-Api-Key" + DefaultAutomationInstallPolicy = "on_demand" + DefaultAutomationNodeSource = "auto" + DefaultAutomationNodeVersion = "22.15.1" + DefaultAutomationPWVersion = "1.59.0" +) + +const ( + AutomationNodeSourceAuto = "auto" + AutomationNodeSourceSystem = "system" + AutomationNodeSourceBundled = "bundled" ) // RewardForUsedKey 返回指定兑换记录对应的永久额度奖励。 @@ -52,10 +54,7 @@ func MinimumProfileLimitForUsedKeys(keys []string) int { // LaunchServerConfig Launch HTTP 服务配置 type LaunchServerConfig struct { - // Port 为对外暴露的固定入口端口。 - // Launch API 与 CDP 代理共用此端口,便于外部工具固定接入。 - Port int `yaml:"port"` - // Auth 为 Launch API 的可选本地认证配置。 + Port int `yaml:"port"` Auth LaunchServerAuthConfig `yaml:"auth"` } @@ -65,6 +64,19 @@ type LaunchServerAuthConfig struct { Header string `yaml:"header"` } +type AutomationConfig struct { + Enabled bool `yaml:"enabled"` + InstallPolicy string `yaml:"install_policy,omitempty"` + RuntimeVersion string `yaml:"runtime_version,omitempty"` + HeadlessDefault bool `yaml:"headless_default,omitempty"` + KeepRuntimeOnDisable bool `yaml:"keep_runtime_on_disable,omitempty"` + AllowTypeScriptBuild bool `yaml:"allow_typescript_build,omitempty"` + NodeSource string `yaml:"node_source,omitempty"` + SystemNodePath string `yaml:"system_node_path,omitempty"` + NodeVersion string `yaml:"node_version,omitempty"` + PlaywrightCoreVersion string `yaml:"playwright_core_version,omitempty"` +} + // Config 应用配置 type Config struct { Database DatabaseConfig `yaml:"database"` @@ -73,20 +85,18 @@ type Config struct { Logging LoggingConfig `yaml:"logging"` Browser BrowserConfig `yaml:"browser"` LaunchServer LaunchServerConfig `yaml:"launch_server"` + Automation AutomationConfig `yaml:"automation"` } -// 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"` @@ -94,7 +104,6 @@ type AppConfig struct { UsedCDKeys []string `yaml:"used_cd_keys"` } -// WindowConfig 窗口配置 type WindowConfig struct { Width int `yaml:"width"` Height int `yaml:"height"` @@ -102,10 +111,9 @@ type WindowConfig struct { MinHeight int `yaml:"min_height"` } -// RuntimeConfig 运行时配置 type RuntimeConfig struct { - MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存软限制(MB),0 表示禁用 - GCPercent int `yaml:"gc_percent"` // GC 触发百分比 + MaxMemoryMB int `yaml:"max_memory_mb"` + GCPercent int `yaml:"gc_percent"` } type BrowserBookmark struct { @@ -117,22 +125,22 @@ 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"` + DefaultStartURLs []string `yaml:"default_start_urls"` + RestoreLastSession bool `yaml:"restore_last_session"` StartReadyTimeoutMs int `yaml:"start_ready_timeout_ms,omitempty"` StartStableWindowMs int `yaml:"start_stable_window_ms,omitempty"` 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"` + 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 { @@ -143,26 +151,22 @@ type BrowserCore struct { } 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 导入自动刷新开关与间隔(分钟) + 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"` + SourceNamePrefix string `yaml:"source_name_prefix,omitempty" json:"sourceNamePrefix,omitempty"` 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"` + LastLatencyMs int64 `yaml:"-" json:"lastLatencyMs"` + LastTestOk bool `yaml:"-" json:"lastTestOk"` + LastTestedAt string `yaml:"-" json:"lastTestedAt"` + LastIPHealthJSON string `yaml:"-" json:"lastIPHealthJson,omitempty"` } type BrowserEnvironment struct { @@ -193,320 +197,29 @@ type BrowserProfileConfig struct { 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"` + Level string `yaml:"level"` + FileEnabled bool `yaml:"file_enabled"` + FilePath string `yaml:"file_path"` + Format string `yaml:"format"` + BufferSize int `yaml:"buffer_size"` + 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" + MaxSizeMB int `yaml:"max_size_mb"` + MaxAge int `yaml:"max_age"` + MaxBackups int `yaml:"max_backups"` + TimeInterval string `yaml:"time_interval"` } -// 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 缺失或被写成过小值, - // 通过兑换记录重新计算最低应得额度,避免基础额度或奖励额度丢失。 - expectedLimit := MinimumProfileLimitForUsedKeys(config.App.UsedCDKeys) - 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.StartReadyTimeoutMs <= 0 { - config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs - } - if config.Browser.StartStableWindowMs <= 0 { - config.Browser.StartStableWindowMs = defaultConfig.Browser.StartStableWindowMs - } - 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 - } - config.LaunchServer.Auth.APIKey = strings.TrimSpace(config.LaunchServer.Auth.APIKey) - if strings.TrimSpace(config.LaunchServer.Auth.Header) == "" { - config.LaunchServer.Auth.Header = defaultConfig.LaunchServer.Auth.Header - } -} - -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: DefaultMaxProfileLimit, - UsedCDKeys: []string{}, - }, - Runtime: RuntimeConfig{ - MaxMemoryMB: 0, // 默认禁用软限制,避免把运行中的前后端直接顶死 - GCPercent: 100, // 默认 100% - }, - Browser: BrowserConfig{ - UserDataRoot: "data", - DefaultFingerprintArgs: defaultFingerprintArgsForOS(goruntime.GOOS), - DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"}, - DefaultProxy: "", - StartReadyTimeoutMs: 3000, - StartStableWindowMs: 1200, - }, - 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: DefaultLaunchServerPort, - Auth: LaunchServerAuthConfig{ - Enabled: false, - APIKey: "", - Header: DefaultLaunchServerAPIKeyHeader, - }, - }, - } -} - -func defaultFingerprintArgsForOS(goos string) []string { - platform := "windows" - switch strings.ToLower(strings.TrimSpace(goos)) { - case "darwin": - platform = "mac" - case "linux": - platform = "linux" - } - return []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=" + platform} -} - -// Save 保存配置到文件 -func (c *Config) Save(configPath string) error { - data, err := yaml.Marshal(c) - if err != nil { - return fmt.Errorf("序列化配置失败: %w", err) - } - - if err := os.MkdirAll(filepath.Dir(configPath), 0755); 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.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("创建代理目录失败: %w", err) - } - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("写入代理文件失败: %w", err) - } - return nil + LogParameters bool `yaml:"log_parameters"` + LogResults bool `yaml:"log_results"` + SensitiveFields []string `yaml:"sensitive_fields"` } diff --git a/backend/internal/config/config_defaults.go b/backend/internal/config/config_defaults.go new file mode 100644 index 00000000..b620f4a1 --- /dev/null +++ b/backend/internal/config/config_defaults.go @@ -0,0 +1,285 @@ +package config + +import ( + "fmt" + "path/filepath" + goruntime "runtime" + "strings" +) + +var defaultBrowserStartURLs = []string{ + "https://ippure.com/", + "https://iplark.com/", + "https://ping0.cc/", +} + +func DefaultBrowserStartURLs() []string { + return append([]string{}, defaultBrowserStartURLs...) +} + +// 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{} + } + + expectedLimit := MinimumProfileLimitForUsedKeys(config.App.UsedCDKeys) + 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.DefaultStartURLs == nil { + config.Browser.DefaultStartURLs = append([]string{}, defaultConfig.Browser.DefaultStartURLs...) + } + if config.Browser.StartReadyTimeoutMs <= 0 { + config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs + } + if config.Browser.StartStableWindowMs <= 0 { + config.Browser.StartStableWindowMs = defaultConfig.Browser.StartStableWindowMs + } + 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 + } + config.LaunchServer.Auth.APIKey = strings.TrimSpace(config.LaunchServer.Auth.APIKey) + if strings.TrimSpace(config.LaunchServer.Auth.Header) == "" { + config.LaunchServer.Auth.Header = defaultConfig.LaunchServer.Auth.Header + } + + automationUnset := !config.Automation.Enabled && + !config.Automation.HeadlessDefault && + !config.Automation.KeepRuntimeOnDisable && + strings.TrimSpace(config.Automation.InstallPolicy) == "" && + strings.TrimSpace(config.Automation.RuntimeVersion) == "" && + strings.TrimSpace(config.Automation.NodeSource) == "" && + strings.TrimSpace(config.Automation.SystemNodePath) == "" && + strings.TrimSpace(config.Automation.NodeVersion) == "" && + strings.TrimSpace(config.Automation.PlaywrightCoreVersion) == "" + if automationUnset { + config.Automation = defaultConfig.Automation + } else { + if strings.TrimSpace(config.Automation.InstallPolicy) == "" { + config.Automation.InstallPolicy = defaultConfig.Automation.InstallPolicy + } + if strings.TrimSpace(config.Automation.NodeVersion) == "" { + config.Automation.NodeVersion = defaultConfig.Automation.NodeVersion + } + if strings.TrimSpace(config.Automation.PlaywrightCoreVersion) == "" { + config.Automation.PlaywrightCoreVersion = defaultConfig.Automation.PlaywrightCoreVersion + } + config.Automation.NodeSource = normalizeAutomationNodeSource(config.Automation.NodeSource) + config.Automation.SystemNodePath = strings.TrimSpace(config.Automation.SystemNodePath) + if strings.TrimSpace(config.Automation.RuntimeVersion) == "" { + config.Automation.RuntimeVersion = DefaultAutomationRuntimeVersion( + config.Automation.NodeVersion, + config.Automation.PlaywrightCoreVersion, + ) + } + } +} + +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: DefaultMaxProfileLimit, + UsedCDKeys: []string{}, + }, + Runtime: RuntimeConfig{ + MaxMemoryMB: 0, + GCPercent: 100, + }, + Browser: BrowserConfig{ + UserDataRoot: "data", + DefaultFingerprintArgs: defaultFingerprintArgsForOS(goruntime.GOOS), + DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"}, + DefaultStartURLs: DefaultBrowserStartURLs(), + RestoreLastSession: false, + StartReadyTimeoutMs: 3000, + StartStableWindowMs: 1200, + }, + Logging: LoggingConfig{ + Level: "info", + FileEnabled: false, + FilePath: "data/logs/app.log", + Format: "text", + BufferSize: 4, + AsyncQueueSize: 1000, + FlushIntervalMs: 1000, + 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: DefaultLaunchServerPort, + Auth: LaunchServerAuthConfig{ + Enabled: false, + APIKey: "", + Header: DefaultLaunchServerAPIKeyHeader, + }, + }, + Automation: AutomationConfig{ + Enabled: false, + InstallPolicy: DefaultAutomationInstallPolicy, + RuntimeVersion: DefaultAutomationRuntimeVersion(DefaultAutomationNodeVersion, DefaultAutomationPWVersion), + HeadlessDefault: false, + KeepRuntimeOnDisable: true, + AllowTypeScriptBuild: false, + NodeSource: DefaultAutomationNodeSource, + SystemNodePath: "", + NodeVersion: DefaultAutomationNodeVersion, + PlaywrightCoreVersion: DefaultAutomationPWVersion, + }, + } +} + +func defaultFingerprintArgsForOS(goos string) []string { + platform := "windows" + switch strings.ToLower(strings.TrimSpace(goos)) { + case "darwin": + platform = "mac" + case "linux": + platform = "linux" + } + return []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=" + platform} +} +func DefaultAutomationRuntimeVersion(nodeVersion, playwrightVersion string) string { + return fmt.Sprintf("node-%s-playwright-core-%s", strings.TrimSpace(nodeVersion), strings.TrimSpace(playwrightVersion)) +} + +func normalizeAutomationNodeSource(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case AutomationNodeSourceSystem: + return AutomationNodeSourceSystem + case AutomationNodeSourceBundled: + return AutomationNodeSourceBundled + default: + return AutomationNodeSourceAuto + } +} diff --git a/backend/internal/config/config_io.go b/backend/internal/config/config_io.go new file mode 100644 index 00000000..183bd7ff --- /dev/null +++ b/backend/internal/config/config_io.go @@ -0,0 +1,83 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// 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 +} + +// Save 保存配置到文件 +func (c *Config) Save(configPath string) error { + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("序列化配置失败: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return fmt.Errorf("创建配置目录失败: %w", err) + } + if err := os.WriteFile(configPath, data, 0o644); 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.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("创建代理目录失败: %w", err) + } + if err := os.WriteFile(path, data, 0o644); 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 index b0400463..4d0458bc 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -58,6 +58,12 @@ browser: {} if len(cfg.Browser.DefaultFingerprintArgs) == 0 || len(cfg.Browser.DefaultLaunchArgs) == 0 { t.Fatalf("Browser 默认启动参数未补齐") } + if len(cfg.Browser.DefaultStartURLs) != 3 { + t.Fatalf("Browser 默认启动页面未补齐: got=%v", cfg.Browser.DefaultStartURLs) + } + if cfg.Browser.RestoreLastSession { + t.Fatalf("Browser.RestoreLastSession 默认应为 false") + } if cfg.Browser.Cores == nil || cfg.Browser.Proxies == nil || cfg.Browser.Profiles == nil { t.Fatalf("Browser 列表字段应初始化为空切片") } @@ -73,6 +79,30 @@ browser: {} if cfg.LaunchServer.Auth.Header != DefaultLaunchServerAPIKeyHeader { t.Fatalf("LaunchServer.Auth.Header 未补齐: got=%q", cfg.LaunchServer.Auth.Header) } + if cfg.Automation.InstallPolicy != DefaultAutomationInstallPolicy { + t.Fatalf("Automation.InstallPolicy 未补齐: got=%q", cfg.Automation.InstallPolicy) + } + if cfg.Automation.RuntimeVersion != DefaultAutomationRuntimeVersion(DefaultAutomationNodeVersion, DefaultAutomationPWVersion) { + t.Fatalf("Automation.RuntimeVersion 未补齐: got=%q", cfg.Automation.RuntimeVersion) + } + if !cfg.Automation.KeepRuntimeOnDisable { + t.Fatalf("Automation.KeepRuntimeOnDisable 默认应为 true") + } + if cfg.Automation.NodeVersion != DefaultAutomationNodeVersion { + t.Fatalf("Automation.NodeVersion 未补齐: got=%q", cfg.Automation.NodeVersion) + } + if cfg.Automation.NodeSource != DefaultAutomationNodeSource { + t.Fatalf("Automation.NodeSource 未补齐: got=%q", cfg.Automation.NodeSource) + } + if cfg.Automation.SystemNodePath != "" { + t.Fatalf("Automation.SystemNodePath 默认应为空: got=%q", cfg.Automation.SystemNodePath) + } + if cfg.Automation.PlaywrightCoreVersion != DefaultAutomationPWVersion { + t.Fatalf("Automation.PlaywrightCoreVersion 未补齐: got=%q", cfg.Automation.PlaywrightCoreVersion) + } + if cfg.Automation.AllowTypeScriptBuild { + t.Fatalf("Automation.AllowTypeScriptBuild 默认应为 false") + } } func TestDefaultFingerprintArgsForOS(t *testing.T) { @@ -158,6 +188,8 @@ browser: - --fingerprint-brand=Edge default_launch_args: - --start-maximized + default_start_urls: [] + restore_last_session: true default_proxy: direct:// default_bookmarks: [] cores: [] @@ -169,6 +201,17 @@ launch_server: enabled: true api_key: secret-key header: X-Custom-Ant-Key +automation: + enabled: true + install_policy: on_demand + runtime_version: custom-runtime + headless_default: true + keep_runtime_on_disable: false + allow_typescript_build: true + node_source: system + system_node_path: C:/tools/node/node.exe + node_version: 22.15.1 + playwright_core_version: 1.59.0 ` if err := os.WriteFile(configPath, []byte(customConfig), 0o644); err != nil { t.Fatalf("写入测试配置失败: %v", err) @@ -197,7 +240,13 @@ launch_server: 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://" { + if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 { + t.Fatalf("Browser.DefaultStartURLs 显式空配置被覆盖: got=%v", cfg.Browser.DefaultStartURLs) + } + if !cfg.Browser.RestoreLastSession { + t.Fatalf("Browser.RestoreLastSession 显式 true 被覆盖") + } + if cfg.Browser.UserDataRoot != "custom_data" { t.Fatalf("Browser 显式配置被覆盖: got=%+v", cfg.Browser) } if cfg.LaunchServer.Port != 30000 { @@ -212,6 +261,24 @@ launch_server: if cfg.LaunchServer.Auth.Header != "X-Custom-Ant-Key" { t.Fatalf("LaunchServer.Auth.Header 显式配置被覆盖: got=%q", cfg.LaunchServer.Auth.Header) } + if !cfg.Automation.Enabled || !cfg.Automation.HeadlessDefault { + t.Fatalf("Automation 显式配置被覆盖: got=%+v", cfg.Automation) + } + if cfg.Automation.RuntimeVersion != "custom-runtime" { + t.Fatalf("Automation.RuntimeVersion 显式配置被覆盖: got=%q", cfg.Automation.RuntimeVersion) + } + if cfg.Automation.KeepRuntimeOnDisable { + t.Fatalf("Automation.KeepRuntimeOnDisable 显式 false 被覆盖") + } + if cfg.Automation.NodeSource != AutomationNodeSourceSystem { + t.Fatalf("Automation.NodeSource 显式配置被覆盖: got=%q", cfg.Automation.NodeSource) + } + if cfg.Automation.SystemNodePath != "C:/tools/node/node.exe" { + t.Fatalf("Automation.SystemNodePath 显式配置被覆盖: got=%q", cfg.Automation.SystemNodePath) + } + if !cfg.Automation.AllowTypeScriptBuild { + t.Fatalf("Automation.AllowTypeScriptBuild 显式 true 被覆盖") + } } func TestLoadMigratesLegacyRootLogPath(t *testing.T) { diff --git a/backend/internal/launchcode/automation_api.go b/backend/internal/launchcode/automation_api.go new file mode 100644 index 00000000..568be5a5 --- /dev/null +++ b/backend/internal/launchcode/automation_api.go @@ -0,0 +1,395 @@ +package launchcode + +import ( + "encoding/json" + "io" + "net/http" + "os" + "strconv" + "strings" + + "ant-chrome/backend/internal/automation" +) + +type automationScriptRunAPIRequest struct { + ScriptID string `json:"scriptId"` + Selector json.RawMessage `json:"selector"` + Params json.RawMessage `json:"params"` + UseScriptSelector *bool `json:"useScriptSelector"` + UseScriptParams *bool `json:"useScriptParams"` +} + +type automationScriptSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + Selector map[string]interface{} `json:"selector"` + Params map[string]interface{} `json:"params"` + Notes string `json:"notes"` + TargetConfig automation.ScriptTargetConfig `json:"targetConfig"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type automationScriptDetail struct { + automationScriptSummary + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + Source automation.ScriptSource `json:"source"` +} + +func (s *LaunchServer) handleAutomationScripts(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 + } + + lister, ok := s.starter.(AutomationScriptLister) + if !ok { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": "automation script api is unavailable", + }) + return + } + + items, err := lister.AutomationScriptList() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + + result := make([]automationScriptSummary, 0, len(items)) + for _, item := range items { + result = append(result, summarizeAutomationScript(item)) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "count": len(result), + "items": result, + }) +} + +func (s *LaunchServer) handleAutomationScriptByID(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 + } + + scriptID, ok := parseAutomationScriptPathID(r.URL.Path) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]interface{}{ + "ok": false, + "error": "script not found", + }) + return + } + + getter, ok := s.starter.(AutomationScriptGetter) + if !ok { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": "automation script api is unavailable", + }) + return + } + + item, err := getter.AutomationScriptGet(scriptID) + if err != nil { + message := strings.TrimSpace(err.Error()) + if os.IsNotExist(err) { + writeJSON(w, http.StatusNotFound, map[string]interface{}{ + "ok": false, + "error": "script not found", + }) + return + } + if strings.Contains(strings.ToLower(message), "script id is invalid") || strings.Contains(strings.ToLower(message), "script id is required") { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": message, + }) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": message, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "item": detailAutomationScript(*item), + }) +} + +func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": "method not allowed", + }) + return + } + + runner, ok := s.starter.(AutomationScriptRunner) + if !ok { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": "automation script api is unavailable", + }) + return + } + + var req automationScriptRunAPIRequest + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": "invalid request body", + }) + return + } + + input, err := normalizeAutomationRunRequest(req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + + run, err := runner.AutomationScriptRunWithOptions(input) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "run": run, + }) +} + +func (s *LaunchServer) handleAutomationScriptRuns(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 + } + + lister, ok := s.starter.(AutomationScriptRunLister) + if !ok { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": "automation script api is unavailable", + }) + return + } + + limit := 20 + 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, err := lister.AutomationScriptRunList(limit) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "count": len(items), + "limit": limit, + "items": items, + }) +} + +func summarizeAutomationScript(record automation.ScriptRecord) automationScriptSummary { + return automationScriptSummary{ + ID: strings.TrimSpace(record.ID), + Name: strings.TrimSpace(record.Name), + Description: strings.TrimSpace(record.Description), + Type: strings.TrimSpace(record.Type), + Status: strings.TrimSpace(record.Status), + EntryFile: strings.TrimSpace(record.EntryFile), + Tags: append([]string(nil), record.Tags...), + Selector: parseJSONObjectText(record.SelectorText), + Params: parseJSONObjectText(record.ParamsText), + Notes: strings.TrimSpace(record.Notes), + TargetConfig: record.TargetConfig, + CreatedAt: strings.TrimSpace(record.CreatedAt), + UpdatedAt: strings.TrimSpace(record.UpdatedAt), + } +} + +func detailAutomationScript(record automation.ScriptRecord) automationScriptDetail { + return automationScriptDetail{ + automationScriptSummary: summarizeAutomationScript(record), + PackageFormat: strings.TrimSpace(record.PackageFormat), + ManifestVersion: record.ManifestVersion, + Source: record.Source, + } +} + +func parseAutomationScriptPathID(path string) (string, bool) { + path = strings.TrimPrefix(path, "/api/automation/scripts/") + path = strings.Trim(path, "/") + path = strings.TrimSpace(path) + if path == "" || strings.Contains(path, "/") { + return "", false + } + return path, true +} + +func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automation.ScriptRunRequest, error) { + scriptID := strings.TrimSpace(req.ScriptID) + if scriptID == "" { + return automation.ScriptRunRequest{}, badAutomationRequest("scriptId is required") + } + + selector, hasSelector, err := decodeJSONObjectRaw(req.Selector, "selector") + if err != nil { + return automation.ScriptRunRequest{}, err + } + params, hasParams, err := decodeJSONObjectRaw(req.Params, "params") + if err != nil { + return automation.ScriptRunRequest{}, err + } + + useScriptSelector, err := resolveUseScriptField("selector", req.UseScriptSelector, hasSelector) + if err != nil { + return automation.ScriptRunRequest{}, err + } + useScriptParams, err := resolveUseScriptField("params", req.UseScriptParams, hasParams) + if err != nil { + return automation.ScriptRunRequest{}, err + } + + selectorText := "" + if !useScriptSelector { + encodedSelector, err := json.Marshal(selector) + if err != nil { + return automation.ScriptRunRequest{}, badAutomationRequest("selector must be a JSON object") + } + selectorText = string(encodedSelector) + } + + paramsText := "" + if !useScriptParams { + encodedParams, err := json.Marshal(params) + if err != nil { + return automation.ScriptRunRequest{}, badAutomationRequest("params must be a JSON object") + } + paramsText = string(encodedParams) + } + + return automation.ScriptRunRequest{ + ScriptID: scriptID, + SelectorText: selectorText, + ParamsText: paramsText, + UseScriptSelector: useScriptSelector, + UseScriptParams: useScriptParams, + }, nil +} + +func resolveUseScriptField(name string, explicit *bool, hasObject bool) (bool, error) { + if explicit == nil { + return !hasObject, nil + } + if *explicit && hasObject { + return false, badAutomationRequest(name + " conflicts with useScript" + upperFirst(name) + "=true") + } + if !*explicit && !hasObject { + return false, badAutomationRequest(name + " is required when useScript" + upperFirst(name) + "=false") + } + return *explicit, nil +} + +func decodeJSONObjectRaw(raw json.RawMessage, fieldName string) (map[string]interface{}, bool, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return nil, false, nil + } + + var value interface{} + if err := json.Unmarshal(raw, &value); err != nil { + return nil, false, badAutomationRequest(fieldName + " must be a JSON object") + } + + obj, ok := value.(map[string]interface{}) + if !ok { + return nil, false, badAutomationRequest(fieldName + " must be a JSON object") + } + return obj, true, nil +} + +func parseJSONObjectText(text string) map[string]interface{} { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return nil + } + + var value interface{} + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return nil + } + + obj, ok := value.(map[string]interface{}) + if !ok { + return nil + } + return obj +} + +func upperFirst(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return strings.ToUpper(value[:1]) + value[1:] +} + +func badAutomationRequest(message string) error { + return automationRequestError(strings.TrimSpace(message)) +} + +type automationRequestError string + +func (e automationRequestError) Error() string { + return strings.TrimSpace(string(e)) +} diff --git a/backend/internal/launchcode/profile_api.go b/backend/internal/launchcode/profile_api.go index 1bbf12bf..22581593 100644 --- a/backend/internal/launchcode/profile_api.go +++ b/backend/internal/launchcode/profile_api.go @@ -1,14 +1,9 @@ package launchcode import ( - "encoding/json" - "io" "net/http" - "strings" - "time" "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/logger" ) // ProfileWriteRequest 用于创建/更新实例配置。 @@ -47,7 +42,7 @@ func (s *LaunchServer) handleProfiles(w http.ResponseWriter, r *http.Request) { } func (s *LaunchServer) handleProfileByID(w http.ResponseWriter, r *http.Request) { - profileID, ok := parseProfilePathID(r.URL.Path) + profileID, action, ok := parseProfilePath(r.URL.Path) if !ok { writeJSON(w, http.StatusNotFound, map[string]interface{}{ "ok": false, @@ -56,6 +51,15 @@ func (s *LaunchServer) handleProfileByID(w http.ResponseWriter, r *http.Request) return } + switch action { + case "status": + s.handleProfileStatus(w, r, profileID) + return + case "stop": + s.handleStopProfile(w, r, profileID) + return + } + switch r.Method { case http.MethodGet: s.handleGetProfile(w, r, profileID) @@ -70,506 +74,3 @@ func (s *LaunchServer) handleProfileByID(w http.ResponseWriter, r *http.Request) }) } } - -// handleCreateProfile POST /api/profiles -func (s *LaunchServer) handleCreateProfile(w http.ResponseWriter, r *http.Request) { - log := logger.New("LaunchServer") - startAt := time.Now() - - req, status, errMsg := decodeProfileWriteRequest(r) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - input := normalizeProfileInput(*req.Profile) - profile, launchCode, status, errMsg := s.createProfile(input, req.LaunchCode) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req) - if launchErr != nil { - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "ok": false, - "created": true, - "updated": false, - "launched": false, - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "launchCode": launchCode, - "profile": profile, - "error": launchErr.Error(), - }) - log.Warn("Profile API 创建后自动启动失败", - logger.F("profile_id", profile.ProfileId), - logger.F("profile_name", profile.ProfileName), - logger.F("launch_code", launchCode), - logger.F("duration_ms", time.Since(startAt).Milliseconds()), - logger.F("error", launchErr.Error()), - ) - return - } - if launched { - mergeProfileRuntime(profile, launchedProfile) - s.SetActiveProfile(profile) - } - - writeJSON(w, http.StatusCreated, s.profileWriteSuccessPayload(profile, launchCode, true, false, launched)) - log.Info("Profile API 创建实例", - logger.F("profile_id", profile.ProfileId), - logger.F("profile_name", profile.ProfileName), - logger.F("launch_code", launchCode), - logger.F("auto_launch", launched), - logger.F("duration_ms", time.Since(startAt).Milliseconds()), - ) -} - -func (s *LaunchServer) handleListProfiles(w http.ResponseWriter, _ *http.Request) { - items, status, errMsg := s.listProfiles() - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "count": len(items), - "items": items, - }) -} - -func (s *LaunchServer) handleGetProfile(w http.ResponseWriter, _ *http.Request, profileID string) { - profile, status, errMsg := s.profileSnapshotByID(profileID) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "launchCode": profile.LaunchCode, - "profile": profile, - }) -} - -func (s *LaunchServer) handleUpdateProfile(w http.ResponseWriter, r *http.Request, profileID string) { - log := logger.New("LaunchServer") - startAt := time.Now() - - previous, status, errMsg := s.profileSnapshotByID(profileID) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - req, status, errMsg := decodeProfileWriteRequest(r) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - input := normalizeProfileInput(*req.Profile) - profile, launchCode, status, errMsg := s.updateProfile(profileID, input, req.LaunchCode, previous) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - - launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req) - if launchErr != nil { - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "ok": false, - "created": false, - "updated": true, - "launched": false, - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "launchCode": launchCode, - "profile": profile, - "error": launchErr.Error(), - }) - log.Warn("Profile API 更新后自动启动失败", - logger.F("profile_id", profile.ProfileId), - logger.F("profile_name", profile.ProfileName), - logger.F("launch_code", launchCode), - logger.F("duration_ms", time.Since(startAt).Milliseconds()), - logger.F("error", launchErr.Error()), - ) - return - } - if launched { - mergeProfileRuntime(profile, launchedProfile) - s.SetActiveProfile(profile) - } - - writeJSON(w, http.StatusOK, s.profileWriteSuccessPayload(profile, launchCode, false, true, launched)) - log.Info("Profile API 更新实例", - logger.F("profile_id", profile.ProfileId), - logger.F("profile_name", profile.ProfileName), - logger.F("launch_code", launchCode), - logger.F("auto_launch", launched), - logger.F("duration_ms", time.Since(startAt).Milliseconds()), - ) -} - -func (s *LaunchServer) handleDeleteProfile(w http.ResponseWriter, _ *http.Request, profileID string) { - profile, status, errMsg := s.profileSnapshotByID(profileID) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - return - } - if profile.Running { - writeJSON(w, http.StatusConflict, map[string]interface{}{ - "ok": false, - "error": "running profile cannot be deleted", - }) - return - } - - if err := s.deleteProfileInternal(profileID); err != nil { - writeJSON(w, mapProfileWriteErrorStatus(err), map[string]interface{}{ - "ok": false, - "error": err.Error(), - }) - return - } - if s.service != nil { - _ = s.service.Remove(profileID) - } - s.ClearActiveProfile(profileID) - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "deleted": true, - "profileId": profileID, - "profileName": profile.ProfileName, - "launchCode": profile.LaunchCode, - }) -} - -func (s *LaunchServer) createProfile(input browser.ProfileInput, requestedCode string) (*browser.Profile, string, int, string) { - profile, err := s.createProfileInternal(input) - if err != nil { - return nil, "", mapProfileWriteErrorStatus(err), err.Error() - } - if profile == nil { - return nil, "", http.StatusInternalServerError, "profile creation returned nil profile" - } - - launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, strings.TrimSpace(profile.LaunchCode), requestedCode) - if errMsg != "" { - _ = s.deleteCreatedProfile(profile.ProfileId) - return nil, "", status, errMsg - } - profile.LaunchCode = launchCode - return profile, launchCode, http.StatusCreated, "" -} - -func (s *LaunchServer) updateProfile(profileID string, input browser.ProfileInput, requestedCode string, previous *browser.Profile) (*browser.Profile, string, int, string) { - profile, err := s.updateProfileInternal(profileID, input) - if err != nil { - return nil, "", mapProfileWriteErrorStatus(err), err.Error() - } - if profile == nil { - return nil, "", http.StatusInternalServerError, "profile update returned nil profile" - } - - currentCode := "" - if previous != nil { - currentCode = strings.TrimSpace(previous.LaunchCode) - } - launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, currentCode, requestedCode) - if errMsg != "" { - if rollbackErr := s.rollbackProfileUpdate(profileID, previous); rollbackErr != nil { - logger.New("LaunchServer").Warn("Profile API 更新回滚失败", - logger.F("profile_id", profileID), - logger.F("error", rollbackErr.Error()), - ) - } - return nil, "", status, errMsg - } - profile.LaunchCode = launchCode - return profile, launchCode, http.StatusOK, "" -} - -func (s *LaunchServer) maybeAutoLaunchProfile(profile *browser.Profile, req ProfileWriteRequest) (*browser.Profile, bool, error) { - if profile == nil || !req.AutoLaunch { - return nil, false, nil - } - - params := LaunchRequestParams{} - if req.Start != nil { - params = LaunchRequestParams{ - LaunchArgs: normalizeStringSlice(req.Start.LaunchArgs), - StartURLs: normalizeStringSlice(req.Start.StartURLs), - SkipDefaultStartURLs: req.Start.SkipDefaultStartURLs, - } - } - - launchedProfile, err := s.launchProfile(profile.ProfileId, params) - if err != nil { - return nil, false, err - } - return launchedProfile, true, nil -} - -func (s *LaunchServer) createProfileInternal(input browser.ProfileInput) (*browser.Profile, error) { - if creator, ok := s.starter.(profileCreator); ok { - return creator.CreateProfile(input) - } - if s.browserMgr != nil { - return s.browserMgr.Create(input) - } - return nil, http.ErrNotSupported -} - -func (s *LaunchServer) updateProfileInternal(profileID string, input browser.ProfileInput) (*browser.Profile, error) { - if updater, ok := s.starter.(profileUpdater); ok { - return updater.UpdateProfile(profileID, input) - } - if s.browserMgr != nil { - return s.browserMgr.Update(profileID, input) - } - return nil, http.ErrNotSupported -} - -func (s *LaunchServer) deleteCreatedProfile(profileID string) error { - if deleter, ok := s.starter.(profileDeleter); ok { - return deleter.DeleteProfile(profileID) - } - if s.browserMgr != nil { - return s.browserMgr.Delete(profileID) - } - return nil -} - -func (s *LaunchServer) deleteProfileInternal(profileID string) error { - return s.deleteCreatedProfile(profileID) -} - -func (s *LaunchServer) rollbackProfileUpdate(profileID string, previous *browser.Profile) error { - if previous == nil { - return nil - } - _, err := s.updateProfileInternal(profileID, profileToInput(previous)) - return err -} - -func (s *LaunchServer) listProfiles() ([]browser.Profile, int, string) { - if s.browserMgr == nil { - return nil, http.StatusServiceUnavailable, "profile catalog is not available" - } - - items := s.browserMgr.List() - for i := range items { - items[i].LaunchCode = s.resolveProfileLaunchCode(items[i].ProfileId, items[i].LaunchCode) - } - return items, http.StatusOK, "" -} - -func (s *LaunchServer) profileSnapshotByID(profileID string) (*browser.Profile, int, string) { - profileID = strings.TrimSpace(profileID) - if profileID == "" { - return nil, http.StatusNotFound, "profile not found" - } - if s.browserMgr == nil { - return nil, http.StatusServiceUnavailable, "profile catalog is not available" - } - - s.browserMgr.Mutex.Lock() - profile, ok := s.browserMgr.Profiles[profileID] - var snapshot browser.Profile - if ok && profile != nil { - snapshot = *profile - } - s.browserMgr.Mutex.Unlock() - if !ok { - return nil, http.StatusNotFound, "profile not found" - } - - snapshot.LaunchCode = s.resolveProfileLaunchCode(snapshot.ProfileId, snapshot.LaunchCode) - return &snapshot, http.StatusOK, "" -} - -func (s *LaunchServer) applyRequestedLaunchCode(profileID, currentCode, requestedCode string) (string, int, string) { - currentCode = strings.TrimSpace(currentCode) - requestedCode = strings.TrimSpace(requestedCode) - if requestedCode == "" { - return s.resolveProfileLaunchCode(profileID, currentCode), http.StatusOK, "" - } - if s.service == nil { - return "", http.StatusServiceUnavailable, "launch code service is unavailable" - } - - code, err := s.service.SetCode(profileID, requestedCode) - if err != nil { - return "", mapProfileWriteErrorStatus(err), err.Error() - } - return code, http.StatusOK, "" -} - -func (s *LaunchServer) resolveProfileLaunchCode(profileID, currentCode string) string { - if trimmed := strings.TrimSpace(currentCode); trimmed != "" { - return trimmed - } - if s.service == nil || strings.TrimSpace(profileID) == "" { - return "" - } - code, err := s.service.EnsureCode(profileID) - if err != nil { - return "" - } - return code -} - -func (s *LaunchServer) profileWriteSuccessPayload(profile *browser.Profile, launchCode string, created bool, updated bool, launched bool) map[string]interface{} { - payload := map[string]interface{}{ - "ok": true, - "created": created, - "updated": updated, - "launched": launched, - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "launchCode": launchCode, - "profile": profile, - } - - if !launched { - return payload - } - - for key, value := range s.launchSuccessPayload(profile, launchCode) { - payload[key] = value - } - payload["created"] = created - payload["updated"] = updated - payload["launched"] = true - payload["profile"] = profile - return payload -} - -func decodeProfileWriteRequest(r *http.Request) (ProfileWriteRequest, int, string) { - if r.Method != http.MethodPost && r.Method != http.MethodPut { - return ProfileWriteRequest{}, http.StatusMethodNotAllowed, "method not allowed" - } - - var req ProfileWriteRequest - dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) - dec.DisallowUnknownFields() - if err := dec.Decode(&req); err != nil { - return ProfileWriteRequest{}, http.StatusBadRequest, "invalid request body" - } - if req.Profile == nil { - return ProfileWriteRequest{}, http.StatusBadRequest, "profile is required" - } - return req, http.StatusOK, "" -} - -func normalizeProfileInput(input browser.ProfileInput) browser.ProfileInput { - return browser.ProfileInput{ - ProfileName: strings.TrimSpace(input.ProfileName), - UserDataDir: strings.TrimSpace(input.UserDataDir), - CoreId: strings.TrimSpace(input.CoreId), - FingerprintArgs: normalizeStringSlice(input.FingerprintArgs), - ProxyId: strings.TrimSpace(input.ProxyId), - ProxyConfig: strings.TrimSpace(input.ProxyConfig), - LaunchArgs: normalizeStringSlice(input.LaunchArgs), - Tags: normalizeStringSlice(input.Tags), - Keywords: normalizeStringSlice(input.Keywords), - GroupId: strings.TrimSpace(input.GroupId), - } -} - -func profileToInput(profile *browser.Profile) browser.ProfileInput { - if profile == nil { - return browser.ProfileInput{} - } - return browser.ProfileInput{ - ProfileName: strings.TrimSpace(profile.ProfileName), - UserDataDir: strings.TrimSpace(profile.UserDataDir), - CoreId: strings.TrimSpace(profile.CoreId), - FingerprintArgs: append([]string{}, profile.FingerprintArgs...), - ProxyId: strings.TrimSpace(profile.ProxyId), - ProxyConfig: strings.TrimSpace(profile.ProxyConfig), - LaunchArgs: append([]string{}, profile.LaunchArgs...), - Tags: append([]string{}, profile.Tags...), - Keywords: append([]string{}, profile.Keywords...), - GroupId: strings.TrimSpace(profile.GroupId), - } -} - -func mergeProfileRuntime(target, runtimeProfile *browser.Profile) { - if target == nil || runtimeProfile == nil { - return - } - target.Running = runtimeProfile.Running - target.DebugPort = runtimeProfile.DebugPort - target.DebugReady = runtimeProfile.DebugReady - target.Pid = runtimeProfile.Pid - target.RuntimeWarning = runtimeProfile.RuntimeWarning - target.LastError = runtimeProfile.LastError - target.LastStartAt = runtimeProfile.LastStartAt - target.LastStopAt = runtimeProfile.LastStopAt -} - -func parseProfilePathID(path string) (string, bool) { - path = strings.TrimPrefix(path, "/api/profiles/") - path = strings.TrimSpace(path) - if path == "" || strings.Contains(path, "/") { - return "", false - } - return path, true -} - -func mapProfileWriteErrorStatus(err error) int { - if err == nil { - return http.StatusOK - } - - msg := strings.ToLower(strings.TrimSpace(err.Error())) - switch { - case msg == strings.ToLower(strings.TrimSpace(http.ErrNotSupported.Error())): - return http.StatusServiceUnavailable - case strings.Contains(msg, "profile not found"): - return http.StatusNotFound - case strings.Contains(msg, "running profile cannot be deleted"): - return http.StatusConflict - case strings.Contains(msg, "launch code already exists"): - return http.StatusConflict - case strings.Contains(msg, "launch code format invalid"), - strings.Contains(msg, "launch code must be"): - return http.StatusBadRequest - case strings.Contains(msg, "实例数量已达上限"): - return http.StatusConflict - default: - return http.StatusInternalServerError - } -} diff --git a/backend/internal/launchcode/profile_api_handlers.go b/backend/internal/launchcode/profile_api_handlers.go new file mode 100644 index 00000000..71de86ce --- /dev/null +++ b/backend/internal/launchcode/profile_api_handlers.go @@ -0,0 +1,212 @@ +package launchcode + +import ( + "net/http" + "time" + + "ant-chrome/backend/internal/logger" +) + +// handleCreateProfile POST /api/profiles +func (s *LaunchServer) handleCreateProfile(w http.ResponseWriter, r *http.Request) { + log := logger.New("LaunchServer") + startAt := time.Now() + + req, status, errMsg := decodeProfileWriteRequest(r) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + input := normalizeProfileInput(*req.Profile) + profile, launchCode, status, errMsg := s.createProfile(input, req.LaunchCode) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req) + if launchErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "created": true, + "updated": false, + "launched": false, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "profile": profile, + "error": launchErr.Error(), + }) + log.Warn("Profile API 创建后自动启动失败", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + logger.F("error", launchErr.Error()), + ) + return + } + if launched { + mergeProfileRuntime(profile, launchedProfile) + s.SetActiveProfile(profile) + } + + writeJSON(w, http.StatusCreated, s.profileWriteSuccessPayload(profile, launchCode, true, false, launched)) + log.Info("Profile API 创建实例", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("auto_launch", launched), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + ) +} + +func (s *LaunchServer) handleListProfiles(w http.ResponseWriter, _ *http.Request) { + items, status, errMsg := s.listProfiles() + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "count": len(items), + "items": items, + }) +} + +func (s *LaunchServer) handleGetProfile(w http.ResponseWriter, _ *http.Request, profileID string) { + profile, status, errMsg := s.profileSnapshotByID(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + "profile": profile, + }) +} + +func (s *LaunchServer) handleUpdateProfile(w http.ResponseWriter, r *http.Request, profileID string) { + log := logger.New("LaunchServer") + startAt := time.Now() + + previous, status, errMsg := s.profileSnapshotByID(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + req, status, errMsg := decodeProfileWriteRequest(r) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + input := normalizeProfileInput(*req.Profile) + profile, launchCode, status, errMsg := s.updateProfile(profileID, input, req.LaunchCode, previous) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req) + if launchErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "created": false, + "updated": true, + "launched": false, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "profile": profile, + "error": launchErr.Error(), + }) + log.Warn("Profile API 更新后自动启动失败", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + logger.F("error", launchErr.Error()), + ) + return + } + if launched { + mergeProfileRuntime(profile, launchedProfile) + s.SetActiveProfile(profile) + } + + writeJSON(w, http.StatusOK, s.profileWriteSuccessPayload(profile, launchCode, false, true, launched)) + log.Info("Profile API 更新实例", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("auto_launch", launched), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + ) +} + +func (s *LaunchServer) handleDeleteProfile(w http.ResponseWriter, _ *http.Request, profileID string) { + profile, status, errMsg := s.profileSnapshotByID(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + if profile.Running { + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "ok": false, + "error": "running profile cannot be deleted", + }) + return + } + + if err := s.deleteProfileInternal(profileID); err != nil { + writeJSON(w, mapProfileWriteErrorStatus(err), map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + if s.service != nil { + _ = s.service.Remove(profileID) + } + s.ClearActiveProfile(profileID) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "deleted": true, + "profileId": profileID, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + }) +} diff --git a/backend/internal/launchcode/profile_api_helpers.go b/backend/internal/launchcode/profile_api_helpers.go new file mode 100644 index 00000000..a62ca768 --- /dev/null +++ b/backend/internal/launchcode/profile_api_helpers.go @@ -0,0 +1,138 @@ +package launchcode + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "ant-chrome/backend/internal/browser" +) + +func decodeProfileWriteRequest(r *http.Request) (ProfileWriteRequest, int, string) { + if r.Method != http.MethodPost && r.Method != http.MethodPut { + return ProfileWriteRequest{}, http.StatusMethodNotAllowed, "method not allowed" + } + + var req ProfileWriteRequest + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return ProfileWriteRequest{}, http.StatusBadRequest, "invalid request body" + } + if req.Profile == nil { + return ProfileWriteRequest{}, http.StatusBadRequest, "profile is required" + } + return req, http.StatusOK, "" +} + +func normalizeProfileInput(input browser.ProfileInput) browser.ProfileInput { + return browser.ProfileInput{ + ProfileName: strings.TrimSpace(input.ProfileName), + UserDataDir: strings.TrimSpace(input.UserDataDir), + CoreId: strings.TrimSpace(input.CoreId), + FingerprintArgs: normalizeStringSlice(input.FingerprintArgs), + ProxyId: strings.TrimSpace(input.ProxyId), + ProxyConfig: strings.TrimSpace(input.ProxyConfig), + LaunchArgs: normalizeStringSlice(input.LaunchArgs), + Tags: normalizeStringSlice(input.Tags), + Keywords: normalizeStringSlice(input.Keywords), + GroupId: strings.TrimSpace(input.GroupId), + } +} + +func profileToInput(profile *browser.Profile) browser.ProfileInput { + if profile == nil { + return browser.ProfileInput{} + } + return browser.ProfileInput{ + ProfileName: strings.TrimSpace(profile.ProfileName), + UserDataDir: strings.TrimSpace(profile.UserDataDir), + CoreId: strings.TrimSpace(profile.CoreId), + FingerprintArgs: append([]string{}, profile.FingerprintArgs...), + ProxyId: strings.TrimSpace(profile.ProxyId), + ProxyConfig: strings.TrimSpace(profile.ProxyConfig), + LaunchArgs: append([]string{}, profile.LaunchArgs...), + Tags: append([]string{}, profile.Tags...), + Keywords: append([]string{}, profile.Keywords...), + GroupId: strings.TrimSpace(profile.GroupId), + } +} + +func mergeProfileRuntime(target, runtimeProfile *browser.Profile) { + if target == nil || runtimeProfile == nil { + return + } + target.Running = runtimeProfile.Running + target.DebugPort = runtimeProfile.DebugPort + target.DebugReady = runtimeProfile.DebugReady + target.Pid = runtimeProfile.Pid + target.RuntimeWarning = runtimeProfile.RuntimeWarning + target.LastError = runtimeProfile.LastError + target.LastStartAt = runtimeProfile.LastStartAt + target.LastStopAt = runtimeProfile.LastStopAt +} + +func parseProfilePath(path string) (string, string, bool) { + path = strings.TrimPrefix(path, "/api/profiles/") + path = strings.Trim(path, "/") + path = strings.TrimSpace(path) + if path == "" { + return "", "", false + } + + parts := strings.Split(path, "/") + if len(parts) == 1 { + return strings.TrimSpace(parts[0]), "", strings.TrimSpace(parts[0]) != "" + } + if len(parts) == 2 { + profileID := strings.TrimSpace(parts[0]) + action := strings.ToLower(strings.TrimSpace(parts[1])) + if profileID == "" || action == "" { + return "", "", false + } + switch action { + case "status", "stop": + return profileID, action, true + default: + return "", "", false + } + } + return "", "", false +} + +func parseProfilePathID(path string) (string, bool) { + profileID, action, ok := parseProfilePath(path) + if !ok || action != "" { + return "", false + } + return profileID, true +} + +func mapProfileWriteErrorStatus(err error) int { + if err == nil { + return http.StatusOK + } + + msg := strings.ToLower(strings.TrimSpace(err.Error())) + switch { + case msg == strings.ToLower(strings.TrimSpace(http.ErrNotSupported.Error())): + return http.StatusServiceUnavailable + case strings.Contains(msg, "profile not found"): + return http.StatusNotFound + case strings.Contains(msg, "running profile cannot be deleted"): + return http.StatusConflict + case strings.Contains(msg, "launch code already exists"): + return http.StatusConflict + case strings.Contains(msg, "launch code format invalid"), + strings.Contains(msg, "launch code must be"): + return http.StatusBadRequest + case strings.Contains(msg, "proxy id not found"), + strings.Contains(msg, "代理id不存在"): + return http.StatusBadRequest + case strings.Contains(msg, "实例数量已达上限"): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} diff --git a/backend/internal/launchcode/profile_api_ops.go b/backend/internal/launchcode/profile_api_ops.go new file mode 100644 index 00000000..ac62b425 --- /dev/null +++ b/backend/internal/launchcode/profile_api_ops.go @@ -0,0 +1,210 @@ +package launchcode + +import ( + "net/http" + "strings" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/logger" +) + +func (s *LaunchServer) createProfile(input browser.ProfileInput, requestedCode string) (*browser.Profile, string, int, string) { + profile, err := s.createProfileInternal(input) + if err != nil { + return nil, "", mapProfileWriteErrorStatus(err), err.Error() + } + if profile == nil { + return nil, "", http.StatusInternalServerError, "profile creation returned nil profile" + } + + launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, strings.TrimSpace(profile.LaunchCode), requestedCode) + if errMsg != "" { + _ = s.deleteCreatedProfile(profile.ProfileId) + return nil, "", status, errMsg + } + profile.LaunchCode = launchCode + return profile, launchCode, http.StatusCreated, "" +} + +func (s *LaunchServer) updateProfile(profileID string, input browser.ProfileInput, requestedCode string, previous *browser.Profile) (*browser.Profile, string, int, string) { + profile, err := s.updateProfileInternal(profileID, input) + if err != nil { + return nil, "", mapProfileWriteErrorStatus(err), err.Error() + } + if profile == nil { + return nil, "", http.StatusInternalServerError, "profile update returned nil profile" + } + + currentCode := "" + if previous != nil { + currentCode = strings.TrimSpace(previous.LaunchCode) + } + launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, currentCode, requestedCode) + if errMsg != "" { + if rollbackErr := s.rollbackProfileUpdate(profileID, previous); rollbackErr != nil { + logger.New("LaunchServer").Warn("Profile API 更新回滚失败", + logger.F("profile_id", profileID), + logger.F("error", rollbackErr.Error()), + ) + } + return nil, "", status, errMsg + } + profile.LaunchCode = launchCode + return profile, launchCode, http.StatusOK, "" +} + +func (s *LaunchServer) maybeAutoLaunchProfile(profile *browser.Profile, req ProfileWriteRequest) (*browser.Profile, bool, error) { + if profile == nil || !req.AutoLaunch { + return nil, false, nil + } + + params := LaunchRequestParams{} + if req.Start != nil { + params = LaunchRequestParams{ + LaunchArgs: normalizeStringSlice(req.Start.LaunchArgs), + StartURLs: normalizeStringSlice(req.Start.StartURLs), + SkipDefaultStartURLs: req.Start.SkipDefaultStartURLs, + } + } + + launchedProfile, err := s.launchProfile(profile.ProfileId, params) + if err != nil { + return nil, false, err + } + return launchedProfile, true, nil +} + +func (s *LaunchServer) createProfileInternal(input browser.ProfileInput) (*browser.Profile, error) { + if creator, ok := s.starter.(profileCreator); ok { + return creator.CreateProfile(input) + } + if s.browserMgr != nil { + return s.browserMgr.Create(input) + } + return nil, http.ErrNotSupported +} + +func (s *LaunchServer) updateProfileInternal(profileID string, input browser.ProfileInput) (*browser.Profile, error) { + if updater, ok := s.starter.(profileUpdater); ok { + return updater.UpdateProfile(profileID, input) + } + if s.browserMgr != nil { + return s.browserMgr.Update(profileID, input) + } + return nil, http.ErrNotSupported +} + +func (s *LaunchServer) deleteCreatedProfile(profileID string) error { + if deleter, ok := s.starter.(profileDeleter); ok { + return deleter.DeleteProfile(profileID) + } + if s.browserMgr != nil { + return s.browserMgr.Delete(profileID) + } + return nil +} + +func (s *LaunchServer) deleteProfileInternal(profileID string) error { + return s.deleteCreatedProfile(profileID) +} + +func (s *LaunchServer) rollbackProfileUpdate(profileID string, previous *browser.Profile) error { + if previous == nil { + return nil + } + _, err := s.updateProfileInternal(profileID, profileToInput(previous)) + return err +} + +func (s *LaunchServer) listProfiles() ([]browser.Profile, int, string) { + if s.browserMgr == nil { + return nil, http.StatusServiceUnavailable, "profile catalog is not available" + } + + items := s.browserMgr.List() + for i := range items { + items[i].LaunchCode = s.resolveProfileLaunchCode(items[i].ProfileId, items[i].LaunchCode) + } + return items, http.StatusOK, "" +} + +func (s *LaunchServer) profileSnapshotByID(profileID string) (*browser.Profile, int, string) { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return nil, http.StatusNotFound, "profile not found" + } + if s.browserMgr == nil { + return nil, http.StatusServiceUnavailable, "profile catalog is not available" + } + + s.browserMgr.Mutex.Lock() + profile, ok := s.browserMgr.Profiles[profileID] + var snapshot browser.Profile + if ok && profile != nil { + snapshot = *profile + } + s.browserMgr.Mutex.Unlock() + if !ok { + return nil, http.StatusNotFound, "profile not found" + } + + snapshot.LaunchCode = s.resolveProfileLaunchCode(snapshot.ProfileId, snapshot.LaunchCode) + return &snapshot, http.StatusOK, "" +} + +func (s *LaunchServer) applyRequestedLaunchCode(profileID, currentCode, requestedCode string) (string, int, string) { + currentCode = strings.TrimSpace(currentCode) + requestedCode = strings.TrimSpace(requestedCode) + if requestedCode == "" { + return s.resolveProfileLaunchCode(profileID, currentCode), http.StatusOK, "" + } + if s.service == nil { + return "", http.StatusServiceUnavailable, "launch code service is unavailable" + } + + code, err := s.service.SetCode(profileID, requestedCode) + if err != nil { + return "", mapProfileWriteErrorStatus(err), err.Error() + } + return code, http.StatusOK, "" +} + +func (s *LaunchServer) resolveProfileLaunchCode(profileID, currentCode string) string { + if trimmed := strings.TrimSpace(currentCode); trimmed != "" { + return trimmed + } + if s.service == nil || strings.TrimSpace(profileID) == "" { + return "" + } + code, err := s.service.EnsureCode(profileID) + if err != nil { + return "" + } + return code +} + +func (s *LaunchServer) profileWriteSuccessPayload(profile *browser.Profile, launchCode string, created bool, updated bool, launched bool) map[string]interface{} { + payload := map[string]interface{}{ + "ok": true, + "created": created, + "updated": updated, + "launched": launched, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "profile": profile, + } + + if !launched { + return payload + } + + for key, value := range s.launchSuccessPayload(profile, launchCode) { + payload[key] = value + } + payload["created"] = created + payload["updated"] = updated + payload["launched"] = true + payload["profile"] = profile + return payload +} diff --git a/backend/internal/launchcode/profile_api_runtime.go b/backend/internal/launchcode/profile_api_runtime.go new file mode 100644 index 00000000..55290e2a --- /dev/null +++ b/backend/internal/launchcode/profile_api_runtime.go @@ -0,0 +1,155 @@ +package launchcode + +import ( + "fmt" + "net/http" + "strings" + + "ant-chrome/backend/internal/browser" +) + +func (s *LaunchServer) handleProfileStatus(w http.ResponseWriter, r *http.Request, profileID string) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": "method not allowed", + }) + return + } + + profile, status, errMsg := s.statusProfile(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + writeJSON(w, http.StatusOK, s.profileRuntimePayload(profile)) +} + +func (s *LaunchServer) handleStopProfile(w http.ResponseWriter, r *http.Request, profileID string) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": "method not allowed", + }) + return + } + + profile, status, errMsg := s.stopProfile(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + payload := s.profileRuntimePayload(profile) + payload["stopped"] = true + writeJSON(w, http.StatusOK, payload) +} + +func (s *LaunchServer) statusProfile(profileID string) (*browser.Profile, int, string) { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return nil, http.StatusNotFound, "profile not found" + } + + if provider, ok := s.starter.(BrowserStatusProvider); ok { + profile, err := provider.StatusInstance(profileID) + if err != nil { + return nil, mapProfileWriteErrorStatus(err), err.Error() + } + return s.normalizeRuntimeProfile(profile), http.StatusOK, "" + } + + return s.profileSnapshotByID(profileID) +} + +func (s *LaunchServer) stopProfile(profileID string) (*browser.Profile, int, string) { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return nil, http.StatusNotFound, "profile not found" + } + + stopper, ok := s.starter.(BrowserStopper) + if !ok { + return nil, http.StatusServiceUnavailable, "profile runtime control is not available" + } + + profile, err := stopper.StopInstance(profileID) + if err != nil { + return nil, mapProfileWriteErrorStatus(err), err.Error() + } + + snapshot := s.normalizeRuntimeProfile(profile) + if snapshot == nil { + return nil, http.StatusInternalServerError, "profile stop returned nil profile" + } + if !snapshot.Running { + s.ClearActiveProfile(snapshot.ProfileId) + } + return snapshot, http.StatusOK, "" +} + +func (s *LaunchServer) normalizeRuntimeProfile(profile *browser.Profile) *browser.Profile { + if profile == nil { + return nil + } + + snapshot := *profile + snapshot.LaunchCode = s.resolveProfileLaunchCode(snapshot.ProfileId, snapshot.LaunchCode) + return &snapshot +} + +func (s *LaunchServer) profileRuntimePayload(profile *browser.Profile) map[string]interface{} { + normalized := s.normalizeRuntimeProfile(profile) + if normalized == nil { + return map[string]interface{}{ + "ok": false, + "error": "profile runtime is not available", + } + } + + activePort, activeID, _ := s.activeTarget() + active := strings.TrimSpace(normalized.ProfileId) != "" && normalized.ProfileId == activeID && activePort > 0 + + directDebugURL := "" + if normalized.DebugReady && normalized.DebugPort > 0 { + directDebugURL = fmt.Sprintf("http://127.0.0.1:%d", normalized.DebugPort) + } + + cdpPort := 0 + cdpURL := "" + if active { + cdpPort = s.Port() + cdpURL = s.CDPURL() + if cdpURL == "" && directDebugURL != "" { + cdpPort = normalized.DebugPort + cdpURL = directDebugURL + } + } + + return map[string]interface{}{ + "ok": true, + "profileId": normalized.ProfileId, + "profileName": normalized.ProfileName, + "launchCode": normalized.LaunchCode, + "running": normalized.Running, + "pid": normalized.Pid, + "debugPort": normalized.DebugPort, + "debugReady": normalized.DebugReady, + "runtimeWarning": normalized.RuntimeWarning, + "lastError": normalized.LastError, + "lastStartAt": normalized.LastStartAt, + "lastStopAt": normalized.LastStopAt, + "active": active, + "cdpPort": cdpPort, + "cdpUrl": cdpURL, + "directDebugUrl": directDebugURL, + "profile": normalized, + } +} diff --git a/backend/internal/launchcode/runtime_api.go b/backend/internal/launchcode/runtime_api.go new file mode 100644 index 00000000..0b65bffb --- /dev/null +++ b/backend/internal/launchcode/runtime_api.go @@ -0,0 +1,289 @@ +package launchcode + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "ant-chrome/backend/internal/browser" +) + +type RuntimeRequest struct { + Code string `json:"code"` + Key string `json:"key"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Keyword string `json:"keyword"` + Keywords []string `json:"keywords"` + Tag string `json:"tag"` + Tags []string `json:"tags"` + GroupID string `json:"groupId"` + MatchMode string `json:"matchMode"` + Selector *LaunchSelector `json:"selector"` +} + +func decodeRuntimeRequest(r *http.Request) (RuntimeRequest, int, string) { + if r.Method != http.MethodPost { + return RuntimeRequest{}, http.StatusMethodNotAllowed, "method not allowed" + } + + var req RuntimeRequest + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return RuntimeRequest{}, http.StatusBadRequest, "invalid request body" + } + return req, http.StatusOK, "" +} + +func mergeRuntimeSelector(req RuntimeRequest) LaunchSelector { + var nested LaunchSelector + if req.Selector != nil { + nested = *req.Selector + } + + return normalizeRuntimeSelector(buildMergedSelector(selectorMergeInput{ + Code: firstNonEmpty(nested.Code, req.Code), + Key: firstNonEmpty(nested.Key, req.Key), + ProfileID: firstNonEmpty(nested.ProfileID, req.ProfileID), + ProfileName: firstNonEmpty(nested.ProfileName, req.ProfileName), + Keywords: appendSelectorTerms(nil, "", nested.Keywords, nested.Keyword, req.Keyword, req.Keywords), + Tags: appendSelectorTerms(nil, nested.Tag, nested.Tags, req.Tag, req.Tags), + GroupID: firstNonEmpty(nested.GroupID, req.GroupID), + MatchMode: firstNonEmpty(nested.MatchMode, req.MatchMode), + })) +} + +func validateRuntimeSelector(selector LaunchSelector) error { + if err := selector.Validate(); err != nil { + return err + } + if selector.MatchMode == launchMatchModeAll { + return httpError("matchMode must be unique or first for runtime control") + } + return nil +} + +func (s *LaunchServer) handleRuntimeStatus(w http.ResponseWriter, r *http.Request) { + s.handleRuntimeControl(w, r, "status") +} + +func (s *LaunchServer) handleRuntimeActive(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 + } + + activePort, activeProfileID, activeProfileName := s.activeTarget() + if activePort <= 0 || strings.TrimSpace(activeProfileID) == "" { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "active": false, + "profileId": "", + "profileName": "", + "launchCode": "", + "running": false, + "pid": 0, + "debugPort": 0, + "debugReady": false, + "runtimeWarning": "", + "lastError": "", + "lastStartAt": "", + "lastStopAt": "", + "cdpPort": 0, + "cdpUrl": "", + "directDebugUrl": "", + "profile": nil, + }) + return + } + + profile, status, errMsg := s.statusProfile(activeProfileID) + if errMsg != "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": errMsg, + "activeProfileId": activeProfileID, + "activeProfileName": activeProfileName, + "activeDebugPort": activePort, + "statusCode": status, + }) + return + } + + writeJSON(w, http.StatusOK, s.profileRuntimePayload(profile)) +} + +func (s *LaunchServer) handleRuntimeStop(w http.ResponseWriter, r *http.Request) { + s.handleRuntimeControl(w, r, "stop") +} + +func (s *LaunchServer) handleRuntimeControl(w http.ResponseWriter, r *http.Request, action string) { + startAt := time.Now() + clientIP := remoteIP(r.RemoteAddr) + selector := LaunchSelector{} + + req, status, errMsg := decodeRuntimeRequest(r) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) + return + } + + selector = mergeRuntimeSelector(req) + if selector.IsEmpty() { + errMsg = "selector is required" + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, http.StatusBadRequest, errMsg, "", "", startAt) + return + } + if err := validateRuntimeSelector(selector); err != nil { + errMsg = err.Error() + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, LaunchRequestParams{}, false, http.StatusBadRequest, errMsg, "", "", startAt) + return + } + + var ( + profile *browser.Profile + launchCode string + ) + switch action { + case "status": + profile, launchCode, status, errMsg = s.statusBySelector(selector) + case "stop": + profile, launchCode, status, errMsg = s.stopBySelector(selector) + default: + errMsg = "unsupported runtime action" + status = http.StatusInternalServerError + } + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) + return + } + + payload := s.profileRuntimePayload(profile) + if strings.TrimSpace(launchCode) != "" { + payload["launchCode"] = launchCode + if nested, ok := payload["profile"].(*browser.Profile); ok && nested != nil && strings.TrimSpace(nested.LaunchCode) == "" { + nested.LaunchCode = launchCode + } + } + if action == "stop" { + payload["stopped"] = true + } + writeJSON(w, http.StatusOK, payload) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) +} + +func (s *LaunchServer) statusBySelector(selector LaunchSelector) (*browser.Profile, string, int, string) { + target, status, errMsg := s.resolveRuntimeTarget(selector) + if errMsg != "" { + return nil, target.LaunchCode, status, errMsg + } + + profile, status, errMsg := s.statusProfile(target.ProfileID) + if errMsg != "" { + return nil, target.LaunchCode, status, errMsg + } + if profile != nil && target.LaunchCode != "" { + profile.LaunchCode = target.LaunchCode + } + return profile, target.LaunchCode, http.StatusOK, "" +} + +func (s *LaunchServer) stopBySelector(selector LaunchSelector) (*browser.Profile, string, int, string) { + target, status, errMsg := s.resolveRuntimeTarget(selector) + if errMsg != "" { + return nil, target.LaunchCode, status, errMsg + } + + profile, status, errMsg := s.stopProfile(target.ProfileID) + if errMsg != "" { + return nil, target.LaunchCode, status, errMsg + } + if profile != nil && target.LaunchCode != "" { + profile.LaunchCode = target.LaunchCode + } + return profile, target.LaunchCode, http.StatusOK, "" +} + +type runtimeTarget struct { + ProfileID string + LaunchCode string +} + +func (s *LaunchServer) resolveRuntimeTarget(selector LaunchSelector) (runtimeTarget, int, string) { + selector = normalizeRuntimeSelector(selector) + if selector.IsEmpty() { + return runtimeTarget{}, http.StatusBadRequest, "selector is required" + } + if err := validateRuntimeSelector(selector); err != nil { + return runtimeTarget{}, http.StatusBadRequest, err.Error() + } + selector = s.withCodeKeywordFallback(selector, true) + + if selector.OnlyCode() { + if s.service == nil { + return runtimeTarget{}, http.StatusServiceUnavailable, "launch code service is unavailable" + } + profileID, err := s.service.Resolve(selector.Code) + if err != nil { + return runtimeTarget{LaunchCode: selector.Code}, http.StatusNotFound, "launch code not found" + } + return runtimeTarget{ProfileID: profileID, LaunchCode: selector.Code}, http.StatusOK, "" + } + + if selector.ProfileID != "" && + selector.Key == "" && + selector.ProfileName == "" && + selector.GroupID == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 { + return runtimeTarget{ + ProfileID: selector.ProfileID, + LaunchCode: s.resolveProfileLaunchCode(selector.ProfileID, ""), + }, http.StatusOK, "" + } + + profile, status, errMsg := s.findProfileBySelector(selector) + if errMsg != "" { + if selector.Code != "" { + return runtimeTarget{LaunchCode: selector.Code}, status, errMsg + } + return runtimeTarget{}, status, errMsg + } + + return runtimeTarget{ + ProfileID: profile.ProfileId, + LaunchCode: profile.LaunchCode, + }, http.StatusOK, "" +} + +type runtimeRequestError string + +func (e runtimeRequestError) Error() string { + return string(e) +} + +func httpError(message string) error { + return runtimeRequestError(message) +} diff --git a/backend/internal/launchcode/runtime_session_api.go b/backend/internal/launchcode/runtime_session_api.go new file mode 100644 index 00000000..839da8de --- /dev/null +++ b/backend/internal/launchcode/runtime_session_api.go @@ -0,0 +1,187 @@ +package launchcode + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "ant-chrome/backend/internal/browser" +) + +const ( + defaultRuntimeSessionTimeout = 45 * time.Second + minRuntimeSessionTimeout = 1 * time.Second + maxRuntimeSessionTimeout = 2 * time.Minute +) + +type RuntimeSessionRequest struct { + RuntimeRequest + LaunchRequestParams + TimeoutMs int `json:"timeoutMs"` +} + +func decodeRuntimeSessionRequest(r *http.Request) (RuntimeSessionRequest, int, string) { + if r.Method != http.MethodPost { + return RuntimeSessionRequest{}, http.StatusMethodNotAllowed, "method not allowed" + } + + var req RuntimeSessionRequest + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return RuntimeSessionRequest{}, http.StatusBadRequest, "invalid request body" + } + return req, http.StatusOK, "" +} + +func normalizeRuntimeSessionTimeout(timeoutMs int) time.Duration { + if timeoutMs <= 0 { + return defaultRuntimeSessionTimeout + } + + timeout := time.Duration(timeoutMs) * time.Millisecond + if timeout < minRuntimeSessionTimeout { + return minRuntimeSessionTimeout + } + if timeout > maxRuntimeSessionTimeout { + return maxRuntimeSessionTimeout + } + return timeout +} + +func (s *LaunchServer) handleRuntimeSession(w http.ResponseWriter, r *http.Request) { + startAt := time.Now() + clientIP := remoteIP(r.RemoteAddr) + selector := LaunchSelector{} + + req, status, errMsg := decodeRuntimeSessionRequest(r) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) + return + } + + selector = mergeRuntimeSelector(req.RuntimeRequest) + if selector.IsEmpty() { + errMsg = "selector is required" + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, req.LaunchRequestParams, false, http.StatusBadRequest, errMsg, "", "", startAt) + return + } + if err := validateRuntimeSelector(selector); err != nil { + errMsg = err.Error() + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, false, http.StatusBadRequest, errMsg, "", "", startAt) + return + } + + req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) + req.StartURLs = normalizeStringSlice(req.StartURLs) + profile, launchCode, status, errMsg := s.launchBySelector(selector, req.LaunchRequestParams) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) + return + } + + waitTimeout := normalizeRuntimeSessionTimeout(req.TimeoutMs) + profile, ready, err := s.prepareRuntimeSession(profile, waitTimeout) + if err != nil { + writeJSON(w, mapProfileWriteErrorStatus(err), map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, false, mapProfileWriteErrorStatus(err), err.Error(), "", "", startAt) + return + } + if profile == nil { + errMsg = "runtime session is not available" + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, false, http.StatusServiceUnavailable, errMsg, "", "", startAt) + return + } + if launchCode != "" && profile.LaunchCode == "" { + profile.LaunchCode = launchCode + } + + responseStatus := http.StatusAccepted + if ready { + responseStatus = http.StatusOK + } + payload := s.runtimeSessionPayload(profile, waitTimeout, ready) + writeJSON(w, responseStatus, payload) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, ready, responseStatus, "", profile.ProfileId, profile.ProfileName, startAt) +} + +func (s *LaunchServer) prepareRuntimeSession(profile *browser.Profile, timeout time.Duration) (*browser.Profile, bool, error) { + normalized := s.normalizeRuntimeProfile(profile) + if normalized == nil { + return nil, false, nil + } + if normalized.DebugReady { + s.SetActiveProfile(normalized) + return normalized, true, nil + } + if timeout <= 0 { + return normalized, false, nil + } + + if waiter, ok := s.starter.(BrowserDebugWaiter); ok { + waited, ready, err := waiter.WaitInstanceDebugReady(normalized.ProfileId, normalized.DebugPort, timeout) + if err != nil { + return nil, false, err + } + if waited != nil { + normalized = s.normalizeRuntimeProfile(waited) + } + if normalized != nil && ready && normalized.DebugReady { + s.SetActiveProfile(normalized) + return normalized, true, nil + } + return normalized, normalized != nil && normalized.DebugReady, nil + } + + deadline := time.Now().Add(timeout) + for { + snapshot, _, errMsg := s.statusProfile(normalized.ProfileId) + if errMsg != "" { + return nil, false, runtimeRequestError(errMsg) + } + if snapshot != nil { + normalized = snapshot + } + if normalized != nil && normalized.DebugReady { + s.SetActiveProfile(normalized) + return normalized, true, nil + } + if time.Now().After(deadline) { + return normalized, false, nil + } + time.Sleep(250 * time.Millisecond) + } +} + +func (s *LaunchServer) runtimeSessionPayload(profile *browser.Profile, timeout time.Duration, ready bool) map[string]interface{} { + payload := s.profileRuntimePayload(profile) + payload["ready"] = ready + payload["timeoutMs"] = timeout.Milliseconds() + payload["waitTimedOut"] = !ready + payload["retryable"] = !ready + return payload +} diff --git a/backend/internal/launchcode/selector.go b/backend/internal/launchcode/selector.go deleted file mode 100644 index 159bea53..00000000 --- a/backend/internal/launchcode/selector.go +++ /dev/null @@ -1,382 +0,0 @@ -package launchcode - -import ( - "ant-chrome/backend/internal/browser" - "fmt" - "net/http" - "sort" - "strings" -) - -const ( - launchMatchModeUnique = "unique" - launchMatchModeFirst = "first" - launchMatchModeAll = "all" -) - -// LaunchSelector 定义实例选择条件。 -// 推荐在 POST /api/launch 中通过 selector 传入,兼容旧版 top-level code 用法。 -type LaunchSelector struct { - Code string `json:"code,omitempty"` - Key string `json:"key,omitempty"` - ProfileID string `json:"profileId,omitempty"` - ProfileName string `json:"profileName,omitempty"` - Keyword string `json:"keyword,omitempty"` - Keywords []string `json:"keywords,omitempty"` - Tag string `json:"tag,omitempty"` - Tags []string `json:"tags,omitempty"` - GroupID string `json:"groupId,omitempty"` - MatchMode string `json:"matchMode,omitempty"` -} - -func mergeLaunchSelector(req LaunchRequest) LaunchSelector { - var nested LaunchSelector - if req.Selector != nil { - nested = *req.Selector - } - - return normalizeLaunchSelector(LaunchSelector{ - Code: firstNonEmpty(nested.Code, req.Code), - Key: firstNonEmpty(nested.Key, req.Key), - ProfileID: firstNonEmpty(nested.ProfileID, req.ProfileID), - ProfileName: firstNonEmpty(nested.ProfileName, req.ProfileName), - Keywords: appendSelectorTerms(nil, "", nested.Keywords, nested.Keyword, req.Keyword, req.Keywords), - Tags: appendSelectorTerms(nil, nested.Tag, nested.Tags, req.Tag, req.Tags), - GroupID: firstNonEmpty(nested.GroupID, req.GroupID), - MatchMode: firstNonEmpty(nested.MatchMode, req.MatchMode), - }) -} - -func normalizeLaunchSelector(selector LaunchSelector) LaunchSelector { - selector.Code = normalizeCode(selector.Code) - selector.Key = strings.TrimSpace(selector.Key) - selector.Keywords = normalizeSelectorTerms(appendSelectorTerms(nil, "", selector.Keywords, selector.Keyword)) - selector.Tags = normalizeSelectorTerms(appendSelectorTerms(nil, selector.Tag, selector.Tags)) - selector.ProfileID = strings.TrimSpace(selector.ProfileID) - selector.ProfileName = strings.TrimSpace(selector.ProfileName) - selector.GroupID = strings.TrimSpace(selector.GroupID) - selector.MatchMode = strings.ToLower(strings.TrimSpace(selector.MatchMode)) - if selector.MatchMode == "" { - selector.MatchMode = defaultLaunchMatchMode(selector) - } - selector.Keyword = "" - selector.Tag = "" - return selector -} - -func (selector LaunchSelector) IsEmpty() bool { - return selector.Code == "" && - selector.Key == "" && - selector.ProfileID == "" && - selector.ProfileName == "" && - selector.GroupID == "" && - len(selector.Keywords) == 0 && - len(selector.Tags) == 0 -} - -func (selector LaunchSelector) OnlyCode() bool { - return selector.Code != "" && - selector.Key == "" && - selector.ProfileID == "" && - selector.ProfileName == "" && - selector.GroupID == "" && - len(selector.Keywords) == 0 && - len(selector.Tags) == 0 -} - -func (selector LaunchSelector) Validate() error { - switch selector.MatchMode { - case "", launchMatchModeUnique, launchMatchModeFirst, launchMatchModeAll: - return nil - default: - return fmt.Errorf("matchMode must be unique, first or all") - } -} - -func defaultLaunchMatchMode(selector LaunchSelector) string { - if selector.Code != "" || selector.Key != "" || len(selector.Keywords) > 0 { - return launchMatchModeFirst - } - return launchMatchModeUnique -} - -func (s *LaunchServer) findProfilesBySelector(selector LaunchSelector) ([]browser.Profile, int, string) { - if selector.IsEmpty() { - return nil, http.StatusBadRequest, "selector is required" - } - if err := selector.Validate(); err != nil { - return nil, http.StatusBadRequest, err.Error() - } - if s.browserMgr == nil { - return nil, http.StatusInternalServerError, "advanced profile selector is not available" - } - - snapshots := s.profileSnapshots() - if len(snapshots) == 0 { - return nil, http.StatusNotFound, "profile selector matched no instance" - } - - if selector.Code != "" { - profileID, err := s.service.Resolve(selector.Code) - if err != nil { - return nil, http.StatusNotFound, "launch code not found" - } - filtered := make([]browser.Profile, 0, 1) - for _, item := range snapshots { - if item.ProfileId == profileID { - filtered = append(filtered, item) - break - } - } - snapshots = filtered - } - - if selector.ProfileID != "" { - snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { - return item.ProfileId == selector.ProfileID - }) - } - - if selector.ProfileName != "" { - snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { - return strings.EqualFold(strings.TrimSpace(item.ProfileName), selector.ProfileName) - }) - } - - if selector.GroupID != "" { - snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { - return strings.TrimSpace(item.GroupId) == selector.GroupID - }) - } - - if len(selector.Tags) > 0 { - snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { - return profileHasAllTags(item, selector.Tags) - }) - } - - fuzzyQueries := selector.Keywords - if selector.Key != "" { - exactMatches := filterProfiles(snapshots, func(item browser.Profile) bool { - return profileHasExactKeyword(item, selector.Key) - }) - if len(exactMatches) > 0 { - snapshots = exactMatches - } else { - fuzzyQueries = normalizeSelectorTerms(append([]string{selector.Key}, fuzzyQueries...)) - } - } - - if len(fuzzyQueries) > 0 { - snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { - return profileMatchesAllKeywordQueries(item, fuzzyQueries) - }) - } - - if len(snapshots) == 0 { - if selector.OnlyCode() { - return nil, http.StatusNotFound, "launch code not found" - } - return nil, http.StatusNotFound, "profile selector matched no instance" - } - - sortProfilesForSelector(snapshots) - return snapshots, http.StatusOK, "" -} - -func (s *LaunchServer) findProfileBySelector(selector LaunchSelector) (browser.Profile, int, string) { - snapshots, status, errMsg := s.findProfilesBySelector(selector) - if errMsg != "" { - return browser.Profile{}, status, errMsg - } - if len(snapshots) > 1 && selector.MatchMode != launchMatchModeFirst { - return browser.Profile{}, http.StatusConflict, buildAmbiguousSelectorError(snapshots) - } - return snapshots[0], http.StatusOK, "" -} - -func (s *LaunchServer) profileSnapshots() []browser.Profile { - if s.browserMgr == nil { - return nil - } - - s.browserMgr.Mutex.Lock() - items := make([]browser.Profile, 0, len(s.browserMgr.Profiles)) - for _, profile := range s.browserMgr.Profiles { - if profile == nil { - continue - } - items = append(items, *profile) - } - s.browserMgr.Mutex.Unlock() - - if s.service != nil { - for i := range items { - if code, err := s.service.EnsureCode(items[i].ProfileId); err == nil { - items[i].LaunchCode = code - } - } - } - return items -} - -func filterProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile { - filtered := make([]browser.Profile, 0, len(items)) - for _, item := range items { - if keep(item) { - filtered = append(filtered, item) - } - } - return filtered -} - -func profileHasAllTags(profile browser.Profile, required []string) bool { - if len(required) == 0 { - return true - } - if len(profile.Tags) == 0 { - return false - } - - for _, want := range required { - found := false - for _, tag := range profile.Tags { - if strings.EqualFold(strings.TrimSpace(tag), want) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func profileHasExactKeyword(profile browser.Profile, expected string) bool { - expected = strings.TrimSpace(expected) - if expected == "" || len(profile.Keywords) == 0 { - return false - } - - for _, keyword := range profile.Keywords { - if strings.EqualFold(strings.TrimSpace(keyword), expected) { - return true - } - } - return false -} - -func profileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool { - if len(queries) == 0 { - return true - } - if len(profile.Keywords) == 0 { - return false - } - - for _, query := range queries { - queryLower := strings.ToLower(query) - found := false - for _, keyword := range profile.Keywords { - if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func sortProfilesForSelector(items []browser.Profile) { - sort.Slice(items, func(i, j int) bool { - leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName)) - rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName)) - if leftName != rightName { - return leftName < rightName - } - return items[i].ProfileId < items[j].ProfileId - }) -} - -func buildAmbiguousSelectorError(items []browser.Profile) string { - const maxPreview = 5 - parts := make([]string, 0, minInt(len(items), maxPreview)) - for i := 0; i < len(items) && i < maxPreview; i++ { - label := strings.TrimSpace(items[i].ProfileName) - if label == "" { - label = items[i].ProfileId - } - if items[i].LaunchCode != "" { - parts = append(parts, fmt.Sprintf("%s[id=%s, code=%s]", label, items[i].ProfileId, items[i].LaunchCode)) - continue - } - parts = append(parts, fmt.Sprintf("%s[id=%s]", label, items[i].ProfileId)) - } - suffix := "" - if len(items) > maxPreview { - suffix = fmt.Sprintf(" ... and %d more", len(items)-maxPreview) - } - return fmt.Sprintf("selector matched %d profiles: %s%s; use code/profileId or add groupId/tags/keywords, or set matchMode=first", len(items), strings.Join(parts, ", "), suffix) -} - -func appendSelectorTerms(dst []string, single string, many []string, moreSinglesAndSlices ...interface{}) []string { - if trimmed := strings.TrimSpace(single); trimmed != "" { - dst = append(dst, trimmed) - } - dst = append(dst, many...) - for _, item := range moreSinglesAndSlices { - switch v := item.(type) { - case string: - if trimmed := strings.TrimSpace(v); trimmed != "" { - dst = append(dst, trimmed) - } - case []string: - dst = append(dst, v...) - } - } - return dst -} - -func normalizeSelectorTerms(items []string) []string { - if len(items) == 0 { - return nil - } - seen := make(map[string]struct{}, len(items)) - result := make([]string, 0, len(items)) - for _, item := range items { - trimmed := strings.TrimSpace(item) - if trimmed == "" { - continue - } - key := strings.ToLower(trimmed) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - result = append(result, trimmed) - } - if len(result) == 0 { - return nil - } - return result -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/backend/internal/launchcode/selector_helpers.go b/backend/internal/launchcode/selector_helpers.go new file mode 100644 index 00000000..095672d1 --- /dev/null +++ b/backend/internal/launchcode/selector_helpers.go @@ -0,0 +1,147 @@ +package launchcode + +import ( + "ant-chrome/backend/internal/browser" + "fmt" + "strings" +) + +func profileHasAllTags(profile browser.Profile, required []string) bool { + if len(required) == 0 { + return true + } + if len(profile.Tags) == 0 { + return false + } + + for _, want := range required { + found := false + for _, tag := range profile.Tags { + if strings.EqualFold(strings.TrimSpace(tag), want) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func profileHasExactKeyword(profile browser.Profile, expected string) bool { + expected = strings.TrimSpace(expected) + if expected == "" || len(profile.Keywords) == 0 { + return false + } + + for _, keyword := range profile.Keywords { + if strings.EqualFold(strings.TrimSpace(keyword), expected) { + return true + } + } + return false +} + +func profileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool { + if len(queries) == 0 { + return true + } + if len(profile.Keywords) == 0 { + return false + } + + for _, query := range queries { + queryLower := strings.ToLower(query) + found := false + for _, keyword := range profile.Keywords { + if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func buildAmbiguousSelectorError(items []browser.Profile) string { + const maxPreview = 5 + parts := make([]string, 0, minInt(len(items), maxPreview)) + for i := 0; i < len(items) && i < maxPreview; i++ { + label := strings.TrimSpace(items[i].ProfileName) + if label == "" { + label = items[i].ProfileId + } + if items[i].LaunchCode != "" { + parts = append(parts, fmt.Sprintf("%s[id=%s, code=%s]", label, items[i].ProfileId, items[i].LaunchCode)) + continue + } + parts = append(parts, fmt.Sprintf("%s[id=%s]", label, items[i].ProfileId)) + } + suffix := "" + if len(items) > maxPreview { + suffix = fmt.Sprintf(" ... and %d more", len(items)-maxPreview) + } + return fmt.Sprintf("selector matched %d profiles: %s%s; use code/profileId or add groupId/tags/keywords, or set matchMode=first", len(items), strings.Join(parts, ", "), suffix) +} + +func appendSelectorTerms(dst []string, single string, many []string, moreSinglesAndSlices ...interface{}) []string { + if trimmed := strings.TrimSpace(single); trimmed != "" { + dst = append(dst, trimmed) + } + dst = append(dst, many...) + for _, item := range moreSinglesAndSlices { + switch v := item.(type) { + case string: + if trimmed := strings.TrimSpace(v); trimmed != "" { + dst = append(dst, trimmed) + } + case []string: + dst = append(dst, v...) + } + } + return dst +} + +func normalizeSelectorTerms(items []string) []string { + if len(items) == 0 { + return nil + } + seen := make(map[string]struct{}, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/launchcode/selector_match.go b/backend/internal/launchcode/selector_match.go new file mode 100644 index 00000000..8d2bfdcf --- /dev/null +++ b/backend/internal/launchcode/selector_match.go @@ -0,0 +1,149 @@ +package launchcode + +import ( + "ant-chrome/backend/internal/browser" + "net/http" + "sort" + "strings" +) + +func (s *LaunchServer) findProfilesBySelector(selector LaunchSelector) ([]browser.Profile, int, string) { + if selector.IsEmpty() { + return nil, http.StatusBadRequest, "selector is required" + } + if err := selector.Validate(); err != nil { + return nil, http.StatusBadRequest, err.Error() + } + if s.browserMgr == nil { + return nil, http.StatusInternalServerError, "advanced profile selector is not available" + } + + snapshots := s.profileSnapshots() + if len(snapshots) == 0 { + return nil, http.StatusNotFound, "profile selector matched no instance" + } + + if selector.Code != "" { + profileID, err := s.service.Resolve(selector.Code) + if err != nil { + return nil, http.StatusNotFound, "launch code not found" + } + filtered := make([]browser.Profile, 0, 1) + for _, item := range snapshots { + if item.ProfileId == profileID { + filtered = append(filtered, item) + break + } + } + snapshots = filtered + } + + if selector.ProfileID != "" { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return item.ProfileId == selector.ProfileID + }) + } + + if selector.ProfileName != "" { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return strings.EqualFold(strings.TrimSpace(item.ProfileName), selector.ProfileName) + }) + } + + if selector.GroupID != "" { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return strings.TrimSpace(item.GroupId) == selector.GroupID + }) + } + + if len(selector.Tags) > 0 { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return profileHasAllTags(item, selector.Tags) + }) + } + + fuzzyQueries := selector.Keywords + if selector.Key != "" { + exactMatches := filterProfiles(snapshots, func(item browser.Profile) bool { + return profileHasExactKeyword(item, selector.Key) + }) + if len(exactMatches) > 0 { + snapshots = exactMatches + } else { + fuzzyQueries = normalizeSelectorTerms(append([]string{selector.Key}, fuzzyQueries...)) + } + } + + if len(fuzzyQueries) > 0 { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return profileMatchesAllKeywordQueries(item, fuzzyQueries) + }) + } + + if len(snapshots) == 0 { + if selector.OnlyCode() { + return nil, http.StatusNotFound, "launch code not found" + } + return nil, http.StatusNotFound, "profile selector matched no instance" + } + + sortProfilesForSelector(snapshots) + return snapshots, http.StatusOK, "" +} + +func (s *LaunchServer) findProfileBySelector(selector LaunchSelector) (browser.Profile, int, string) { + snapshots, status, errMsg := s.findProfilesBySelector(selector) + if errMsg != "" { + return browser.Profile{}, status, errMsg + } + if len(snapshots) > 1 && selector.MatchMode != launchMatchModeFirst { + return browser.Profile{}, http.StatusConflict, buildAmbiguousSelectorError(snapshots) + } + return snapshots[0], http.StatusOK, "" +} + +func (s *LaunchServer) profileSnapshots() []browser.Profile { + if s.browserMgr == nil { + return nil + } + + s.browserMgr.Mutex.Lock() + items := make([]browser.Profile, 0, len(s.browserMgr.Profiles)) + for _, profile := range s.browserMgr.Profiles { + if profile == nil { + continue + } + items = append(items, *profile) + } + s.browserMgr.Mutex.Unlock() + + if s.service != nil { + for i := range items { + if code, err := s.service.EnsureCode(items[i].ProfileId); err == nil { + items[i].LaunchCode = code + } + } + } + return items +} + +func filterProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile { + filtered := make([]browser.Profile, 0, len(items)) + for _, item := range items { + if keep(item) { + filtered = append(filtered, item) + } + } + return filtered +} + +func sortProfilesForSelector(items []browser.Profile) { + sort.Slice(items, func(i, j int) bool { + leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName)) + rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName)) + if leftName != rightName { + return leftName < rightName + } + return items[i].ProfileId < items[j].ProfileId + }) +} diff --git a/backend/internal/launchcode/selector_types.go b/backend/internal/launchcode/selector_types.go new file mode 100644 index 00000000..cba557b9 --- /dev/null +++ b/backend/internal/launchcode/selector_types.go @@ -0,0 +1,134 @@ +package launchcode + +import ( + "fmt" + "strings" +) + +const ( + launchMatchModeUnique = "unique" + launchMatchModeFirst = "first" + launchMatchModeAll = "all" +) + +// LaunchSelector 定义实例选择条件。 +// 推荐在 POST /api/launch 中通过 selector 传入,兼容旧版 top-level code 用法。 +type LaunchSelector struct { + Code string `json:"code,omitempty"` + Key string `json:"key,omitempty"` + ProfileID string `json:"profileId,omitempty"` + ProfileName string `json:"profileName,omitempty"` + Keyword string `json:"keyword,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Tag string `json:"tag,omitempty"` + Tags []string `json:"tags,omitempty"` + GroupID string `json:"groupId,omitempty"` + MatchMode string `json:"matchMode,omitempty"` +} + +func mergeLaunchSelector(req LaunchRequest) LaunchSelector { + var nested LaunchSelector + if req.Selector != nil { + nested = *req.Selector + } + + return normalizeLaunchSelector(buildMergedSelector(selectorMergeInput{ + Code: firstNonEmpty(nested.Code, req.Code), + Key: firstNonEmpty(nested.Key, req.Key), + ProfileID: firstNonEmpty(nested.ProfileID, req.ProfileID), + ProfileName: firstNonEmpty(nested.ProfileName, req.ProfileName), + Keywords: appendSelectorTerms(nil, "", nested.Keywords, nested.Keyword, req.Keyword, req.Keywords), + Tags: appendSelectorTerms(nil, nested.Tag, nested.Tags, req.Tag, req.Tags), + GroupID: firstNonEmpty(nested.GroupID, req.GroupID), + MatchMode: firstNonEmpty(nested.MatchMode, req.MatchMode), + })) +} + +func normalizeLaunchSelector(selector LaunchSelector) LaunchSelector { + return normalizeSelectorWithDefault(selector, defaultLaunchMatchMode) +} + +func normalizeRuntimeSelector(selector LaunchSelector) LaunchSelector { + return normalizeSelectorWithDefault(selector, defaultRuntimeMatchMode) +} + +type selectorMergeInput struct { + Code string + Key string + ProfileID string + ProfileName string + Keywords []string + Tags []string + GroupID string + MatchMode string +} + +func buildMergedSelector(input selectorMergeInput) LaunchSelector { + return LaunchSelector{ + Code: input.Code, + Key: input.Key, + ProfileID: input.ProfileID, + ProfileName: input.ProfileName, + Keywords: input.Keywords, + Tags: input.Tags, + GroupID: input.GroupID, + MatchMode: input.MatchMode, + } +} + +func normalizeSelectorWithDefault(selector LaunchSelector, defaultMode func(LaunchSelector) string) LaunchSelector { + selector.Code = normalizeCode(selector.Code) + selector.Key = strings.TrimSpace(selector.Key) + selector.Keywords = normalizeSelectorTerms(appendSelectorTerms(nil, "", selector.Keywords, selector.Keyword)) + selector.Tags = normalizeSelectorTerms(appendSelectorTerms(nil, selector.Tag, selector.Tags)) + selector.ProfileID = strings.TrimSpace(selector.ProfileID) + selector.ProfileName = strings.TrimSpace(selector.ProfileName) + selector.GroupID = strings.TrimSpace(selector.GroupID) + selector.MatchMode = strings.ToLower(strings.TrimSpace(selector.MatchMode)) + if selector.MatchMode == "" { + selector.MatchMode = defaultMode(selector) + } + selector.Keyword = "" + selector.Tag = "" + return selector +} + +func (selector LaunchSelector) IsEmpty() bool { + return selector.Code == "" && + selector.Key == "" && + selector.ProfileID == "" && + selector.ProfileName == "" && + selector.GroupID == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 +} + +func (selector LaunchSelector) OnlyCode() bool { + return selector.Code != "" && + selector.Key == "" && + selector.ProfileID == "" && + selector.ProfileName == "" && + selector.GroupID == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 +} + +func (selector LaunchSelector) Validate() error { + switch selector.MatchMode { + case "", launchMatchModeUnique, launchMatchModeFirst, launchMatchModeAll: + return nil + default: + return fmt.Errorf("matchMode must be unique, first or all") + } +} + +func defaultLaunchMatchMode(selector LaunchSelector) string { + if selector.Code != "" || selector.Key != "" || len(selector.Keywords) > 0 { + return launchMatchModeFirst + } + return launchMatchModeUnique +} + +func defaultRuntimeMatchMode(_ LaunchSelector) string { + return launchMatchModeUnique +} diff --git a/backend/internal/launchcode/server.go b/backend/internal/launchcode/server.go index 39ec087c..6acba63f 100644 --- a/backend/internal/launchcode/server.go +++ b/backend/internal/launchcode/server.go @@ -2,18 +2,15 @@ package launchcode import ( "context" - "encoding/json" "fmt" - "io" "net" "net/http" - "net/http/httputil" - "net/url" "strconv" "strings" "sync" "time" + "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/logger" ) @@ -23,6 +20,11 @@ type BrowserStarter interface { StartInstance(profileId string) (*browser.Profile, error) } +// BrowserStatusProvider 可选接口:提供实例运行态查询。 +type BrowserStatusProvider interface { + StatusInstance(profileId string) (*browser.Profile, error) +} + // LaunchRequestParams 支持外部自动化透传的一次性启动参数 type LaunchRequestParams struct { LaunchArgs []string `json:"launchArgs"` @@ -51,6 +53,36 @@ type BrowserStarterWithParams interface { StartInstanceWithParams(profileId string, params LaunchRequestParams) (*browser.Profile, error) } +// BrowserStopper 可选接口:支持停止运行中的实例。 +type BrowserStopper interface { + StopInstance(profileId string) (*browser.Profile, error) +} + +// BrowserDebugWaiter 可选接口:等待实例调试端口进入可接管状态。 +type BrowserDebugWaiter interface { + WaitInstanceDebugReady(profileId string, debugPort int, timeout time.Duration) (*browser.Profile, bool, error) +} + +// AutomationScriptLister 可选接口:提供自动化脚本列表。 +type AutomationScriptLister interface { + AutomationScriptList() ([]automation.ScriptRecord, error) +} + +// AutomationScriptGetter 可选接口:提供单个自动化脚本详情。 +type AutomationScriptGetter interface { + AutomationScriptGet(scriptID string) (*automation.ScriptRecord, error) +} + +// AutomationScriptRunner 可选接口:执行自动化脚本。 +type AutomationScriptRunner interface { + AutomationScriptRunWithOptions(input automation.ScriptRunRequest) (*automation.ScriptRunRecord, error) +} + +// AutomationScriptRunLister 可选接口:提供自动化脚本运行记录。 +type AutomationScriptRunLister interface { + AutomationScriptRunList(limit int) ([]automation.ScriptRunRecord, error) +} + // LaunchCallRecord 接口调用记录 type LaunchCallRecord struct { Timestamp string `json:"timestamp"` @@ -139,27 +171,6 @@ func (s *LaunchServer) Start() error { return nil } -func (s *LaunchServer) buildMux() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("/api/health", s.handleHealth) - mux.HandleFunc("/api/profiles", s.handleProfiles) - mux.HandleFunc("/api/profiles/", s.handleProfileByID) - mux.HandleFunc("/api/launch", s.handleLaunchWithBody) - mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs) - mux.HandleFunc("/api/launch/", s.handleLaunch) - mux.HandleFunc("/", s.handleCDPProxy) - return mux -} - -func (s *LaunchServer) buildHandler(includeLocalhost bool) http.Handler { - var handler http.Handler = s.buildMux() - handler = s.apiAuthMiddleware(handler) - if includeLocalhost { - handler = s.localhostMiddleware(handler) - } - return handler -} - func bindLaunchListener(preferredPort int) (net.Listener, int, error) { if preferredPort <= 0 { ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -274,518 +285,8 @@ func (s *LaunchServer) activeTarget() (int, string, string) { return s.activePort, s.activeID, s.activeName } -// 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}) -} - -// handleCDPProxy 将统一端口上的非 /api 请求转发到当前活动实例的 CDP 端口。 -func (s *LaunchServer) handleCDPProxy(w http.ResponseWriter, r *http.Request) { - debugPort, profileID, profileName := s.activeTarget() - if debugPort <= 0 { - writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ - "ok": false, - "error": "no active browser debug target", - "profileId": profileID, - "profileName": profileName, - }) - return - } - - target, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", debugPort)) - if err != nil { - http.Error(w, fmt.Sprintf("invalid cdp target: %v", err), http.StatusInternalServerError) - return - } - - proxy := httputil.NewSingleHostReverseProxy(target) - proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, proxyErr error) { - http.Error(w, fmt.Sprintf("cdp proxy error: %v", proxyErr), http.StatusBadGateway) - } - proxy.ServeHTTP(w, r) -} - -// handleLaunch GET /api/launch/{code} -func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) { - startAt := time.Now() - clientIP := remoteIP(r.RemoteAddr) - selector := LaunchSelector{} - 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, "", selector, 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, "", selector, LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt) - return - } - - selector = normalizeLaunchSelector(LaunchSelector{Code: code}) - profile, launchCode, 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, selector.Code, selector, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) - return - } - - s.SetActiveProfile(profile) - writeJSON(w, http.StatusOK, s.launchSuccessPayload(profile, launchCode)) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) -} - -func (s *LaunchServer) launchSuccessPayload(profile *browser.Profile, launchCode string) map[string]interface{} { - cdpURL := s.CDPURL() - cdpPort := s.Port() - if cdpURL == "" && profile != nil && profile.DebugReady && profile.DebugPort > 0 { - cdpPort = profile.DebugPort - cdpURL = fmt.Sprintf("http://127.0.0.1:%d", profile.DebugPort) - } - - return map[string]interface{}{ - "ok": true, - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "launchCode": launchCode, - "pid": profile.Pid, - "debugPort": profile.DebugPort, - "debugReady": profile.DebugReady, - "runtimeWarning": profile.RuntimeWarning, - "cdpPort": cdpPort, - "cdpUrl": cdpURL, - } -} - -// handleLaunchWithBody POST /api/launch -func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Request) { - startAt := time.Now() - clientIP := remoteIP(r.RemoteAddr) - selector := LaunchSelector{} - 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, "", selector, 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, "", selector, LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt) - return - } - - selector = mergeLaunchSelector(req) - if selector.IsEmpty() { - msg := "selector is required" - writeJSON(w, http.StatusBadRequest, map[string]interface{}{ - "ok": false, - "error": msg, - }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt) - return - } - - req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) - req.StartURLs = normalizeStringSlice(req.StartURLs) - if selector.MatchMode == launchMatchModeAll { - profiles, status, errMsg := s.launchAllBySelector(selector, req.LaunchRequestParams) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) - return - } - - activeProfile, profileIDs, profileNames := summarizeLaunchedProfiles(profiles) - if activeProfile != nil { - s.SetActiveProfile(activeProfile) - } - writeJSON(w, http.StatusOK, s.launchBatchSuccessPayload(profiles)) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, true, http.StatusOK, "", profileIDs, profileNames, startAt) - return - } - - profile, launchCode, status, errMsg := s.launchBySelector(selector, req.LaunchRequestParams) - if errMsg != "" { - writeJSON(w, status, map[string]interface{}{ - "ok": false, - "error": errMsg, - }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) - return - } - - s.SetActiveProfile(profile) - writeJSON(w, http.StatusOK, s.launchSuccessPayload(profile, launchCode)) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, 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, string, int, string) { - return s.launchBySelectorInternal(normalizeLaunchSelector(LaunchSelector{Code: code}), params, false) -} - -func (s *LaunchServer) launchBySelector(selector LaunchSelector, params LaunchRequestParams) (*browser.Profile, string, int, string) { - return s.launchBySelectorInternal(selector, params, true) -} - -func (s *LaunchServer) launchProfile(profileID string, params LaunchRequestParams) (*browser.Profile, error) { - if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok { - profile, err := starterWithParams.StartInstanceWithParams(profileID, params) - return normalizeLaunchedProfileRuntime(profile), err - } - profile, err := s.starter.StartInstance(profileID) - return normalizeLaunchedProfileRuntime(profile), err -} - -func normalizeLaunchedProfileRuntime(profile *browser.Profile) *browser.Profile { - if profile == nil { - return nil - } - - // Backward compatibility: older starter implementations only filled pid/debugPort. - if !profile.Running && (profile.Pid > 0 || profile.DebugPort > 0) { - profile.Running = true - } - if !profile.DebugReady && - profile.DebugPort > 0 && - strings.TrimSpace(profile.RuntimeWarning) == "" && - (profile.Running || profile.Pid > 0) { - profile.DebugReady = true - } - - return profile -} - -func (s *LaunchServer) launchBySelectorInternal(selector LaunchSelector, params LaunchRequestParams, allowCodeKeywordFallback bool) (*browser.Profile, string, int, string) { - var ( - profileID string - launchCode string - err error - ) - - selector = normalizeLaunchSelector(selector) - if selector.IsEmpty() { - return nil, "", http.StatusBadRequest, "selector is required" - } - if err = selector.Validate(); err != nil { - return nil, "", http.StatusBadRequest, err.Error() - } - selector = s.withCodeKeywordFallback(selector, allowCodeKeywordFallback) - - if selector.OnlyCode() { - profileID, err = s.service.Resolve(selector.Code) - if err != nil { - return nil, "", http.StatusNotFound, "launch code not found" - } - launchCode = selector.Code - } else { - profileSnapshot, status, errMsg := s.findProfileBySelector(selector) - if errMsg != "" { - if selector.Code != "" { - launchCode = selector.Code - } - return nil, launchCode, status, errMsg - } - profileID = profileSnapshot.ProfileId - launchCode = profileSnapshot.LaunchCode - } - - profile, err := s.launchProfile(profileID, params) - if err != nil { - return nil, launchCode, http.StatusInternalServerError, err.Error() - } - - if launchCode == "" && s.service != nil && profile != nil { - if code, codeErr := s.service.EnsureCode(profile.ProfileId); codeErr == nil { - launchCode = code - } - } - if profile != nil && launchCode != "" { - profile.LaunchCode = launchCode - } - - return profile, launchCode, http.StatusOK, "" -} - -func (s *LaunchServer) launchAllBySelector(selector LaunchSelector, params LaunchRequestParams) ([]*browser.Profile, int, string) { - selector = normalizeLaunchSelector(selector) - if selector.IsEmpty() { - return nil, http.StatusBadRequest, "selector is required" - } - if err := selector.Validate(); err != nil { - return nil, http.StatusBadRequest, err.Error() - } - selector = s.withCodeKeywordFallback(selector, true) - - snapshots, status, errMsg := s.findProfilesBySelector(selector) - if errMsg != "" { - return nil, status, errMsg - } - - profiles := make([]*browser.Profile, 0, len(snapshots)) - for _, snapshot := range snapshots { - profile, err := s.launchProfile(snapshot.ProfileId, params) - if err != nil { - label := strings.TrimSpace(snapshot.ProfileName) - if label == "" { - label = snapshot.ProfileId - } - return profiles, http.StatusInternalServerError, fmt.Sprintf("failed to start profile %s after launching %d profile(s): %v", label, len(profiles), err) - } - - launchCode := snapshot.LaunchCode - if launchCode == "" && s.service != nil && profile != nil { - if code, codeErr := s.service.EnsureCode(profile.ProfileId); codeErr == nil { - launchCode = code - } - } - if profile != nil && launchCode != "" { - profile.LaunchCode = launchCode - } - - profiles = append(profiles, profile) - } - - return profiles, http.StatusOK, "" -} - -func (s *LaunchServer) withCodeKeywordFallback(selector LaunchSelector, allow bool) LaunchSelector { - if !allow || strings.TrimSpace(selector.Code) == "" { - return selector - } - if s.service != nil { - if _, err := s.service.Resolve(selector.Code); err == nil { - return selector - } - } - - fallback := selector - if strings.TrimSpace(fallback.Key) == "" { - fallback.Key = selector.Code - } - fallback.Code = "" - return fallback -} - -func (s *LaunchServer) launchBatchSuccessPayload(profiles []*browser.Profile) map[string]interface{} { - items := make([]map[string]interface{}, 0, len(profiles)) - for i, profile := range profiles { - if profile == nil { - continue - } - item := map[string]interface{}{ - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "launchCode": profile.LaunchCode, - "pid": profile.Pid, - "debugPort": profile.DebugPort, - "debugReady": profile.DebugReady, - "runtimeWarning": profile.RuntimeWarning, - "isActive": i == len(profiles)-1, - } - items = append(items, item) - } - - activeProfile, _, _ := summarizeLaunchedProfiles(profiles) - cdpURL := s.CDPURL() - cdpPort := s.Port() - if cdpURL == "" && activeProfile != nil && activeProfile.DebugReady && activeProfile.DebugPort > 0 { - cdpPort = activeProfile.DebugPort - cdpURL = fmt.Sprintf("http://127.0.0.1:%d", activeProfile.DebugPort) - } - - payload := map[string]interface{}{ - "ok": true, - "matchMode": launchMatchModeAll, - "count": len(items), - "items": items, - "cdpPort": cdpPort, - "cdpUrl": cdpURL, - } - if activeProfile != nil { - payload["activeProfileId"] = activeProfile.ProfileId - payload["activeProfileName"] = activeProfile.ProfileName - } - return payload -} - -func summarizeLaunchedProfiles(profiles []*browser.Profile) (*browser.Profile, string, string) { - if len(profiles) == 0 { - return nil, "", "" - } - - ids := make([]string, 0, len(profiles)) - names := make([]string, 0, len(profiles)) - var active *browser.Profile - for _, profile := range profiles { - if profile == nil { - continue - } - active = profile - ids = append(ids, profile.ProfileId) - if trimmed := strings.TrimSpace(profile.ProfileName); trimmed != "" { - names = append(names, trimmed) - } - } - return active, strings.Join(ids, ","), strings.Join(names, ",") -} - -// 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 { - return s.buildHandler(false) -} - -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, selector LaunchSelector, 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), - Selector: selector, - 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 +// ActiveProfile 返回当前统一 CDP 入口对应的实例信息。 +func (s *LaunchServer) ActiveProfile() (string, string, int) { + port, profileID, profileName := s.activeTarget() + return profileID, profileName, port } diff --git a/backend/internal/launchcode/server_http.go b/backend/internal/launchcode/server_http.go new file mode 100644 index 00000000..9f5844e1 --- /dev/null +++ b/backend/internal/launchcode/server_http.go @@ -0,0 +1,42 @@ +package launchcode + +import "net/http" + +func (s *LaunchServer) buildMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/automation/scripts", s.handleAutomationScripts) + mux.HandleFunc("/api/automation/scripts/", s.handleAutomationScriptByID) + mux.HandleFunc("/api/automation/scripts/run", s.handleAutomationScriptRun) + mux.HandleFunc("/api/automation/scripts/runs", s.handleAutomationScriptRuns) + mux.HandleFunc("/api/profiles", s.handleProfiles) + mux.HandleFunc("/api/profiles/", s.handleProfileByID) + mux.HandleFunc("/api/runtime/active", s.handleRuntimeActive) + mux.HandleFunc("/api/runtime/session", s.handleRuntimeSession) + mux.HandleFunc("/api/runtime/status", s.handleRuntimeStatus) + mux.HandleFunc("/api/runtime/stop", s.handleRuntimeStop) + mux.HandleFunc("/api/launch", s.handleLaunchWithBody) + mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs) + mux.HandleFunc("/api/launch/", s.handleLaunch) + mux.HandleFunc("/", s.handleCDPProxy) + return mux +} + +func (s *LaunchServer) buildHandler(includeLocalhost bool) http.Handler { + var handler http.Handler = s.buildMux() + handler = s.apiAuthMiddleware(handler) + if includeLocalhost { + handler = s.localhostMiddleware(handler) + } + return handler +} + +// handleHealth GET /api/health +func (s *LaunchServer) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// NewTestHandler 返回不含 localhost 限制的 handler,仅供测试使用 +func NewTestHandler(s *LaunchServer) http.Handler { + return s.buildHandler(false) +} diff --git a/backend/internal/launchcode/server_http_launch.go b/backend/internal/launchcode/server_http_launch.go new file mode 100644 index 00000000..c23cc603 --- /dev/null +++ b/backend/internal/launchcode/server_http_launch.go @@ -0,0 +1,158 @@ +package launchcode + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// handleLaunch GET /api/launch/{code} +func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) { + startAt := time.Now() + clientIP := remoteIP(r.RemoteAddr) + selector := LaunchSelector{} + 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, "", selector, 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, "", selector, LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt) + return + } + + selector = normalizeLaunchSelector(LaunchSelector{Code: code}) + profile, launchCode, 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, selector.Code, selector, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) + return + } + + s.SetActiveProfile(profile) + writeJSON(w, http.StatusOK, s.launchSuccessPayload(profile, launchCode)) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, 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) + selector := LaunchSelector{} + 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, "", selector, 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, "", selector, LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt) + return + } + + selector = mergeLaunchSelector(req) + if selector.IsEmpty() { + msg := "selector is required" + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, + "error": msg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt) + return + } + + req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) + req.StartURLs = normalizeStringSlice(req.StartURLs) + if selector.MatchMode == launchMatchModeAll { + profiles, status, errMsg := s.launchAllBySelector(selector, req.LaunchRequestParams) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) + return + } + + activeProfile, profileIDs, profileNames := summarizeLaunchedProfiles(profiles) + if activeProfile != nil { + s.SetActiveProfile(activeProfile) + } + writeJSON(w, http.StatusOK, s.launchBatchSuccessPayload(profiles)) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, true, http.StatusOK, "", profileIDs, profileNames, startAt) + return + } + + profile, launchCode, status, errMsg := s.launchBySelector(selector, req.LaunchRequestParams) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) + return + } + + s.SetActiveProfile(profile) + writeJSON(w, http.StatusOK, s.launchSuccessPayload(profile, launchCode)) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, 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, + }) +} diff --git a/backend/internal/launchcode/server_http_utils.go b/backend/internal/launchcode/server_http_utils.go new file mode 100644 index 00000000..649eed4e --- /dev/null +++ b/backend/internal/launchcode/server_http_utils.go @@ -0,0 +1,84 @@ +package launchcode + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// 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) + }) +} + +// handleCDPProxy 将统一端口上的非 /api 请求转发到当前活动实例的 CDP 端口。 +func (s *LaunchServer) handleCDPProxy(w http.ResponseWriter, r *http.Request) { + debugPort, profileID, profileName := s.activeTarget() + if debugPort <= 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": "no active browser debug target", + "profileId": profileID, + "profileName": profileName, + }) + return + } + + target, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", debugPort)) + if err != nil { + http.Error(w, fmt.Sprintf("invalid cdp target: %v", err), http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, proxyErr error) { + http.Error(w, fmt.Sprintf("cdp proxy error: %v", proxyErr), http.StatusBadGateway) + } + proxy.ServeHTTP(w, r) +} + +// 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) +} + +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 remoteIP(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr + } + return host +} diff --git a/backend/internal/launchcode/server_launch.go b/backend/internal/launchcode/server_launch.go new file mode 100644 index 00000000..88f4f43e --- /dev/null +++ b/backend/internal/launchcode/server_launch.go @@ -0,0 +1,241 @@ +package launchcode + +import ( + "fmt" + "net/http" + "strings" + + "ant-chrome/backend/internal/browser" +) + +func (s *LaunchServer) launchSuccessPayload(profile *browser.Profile, launchCode string) map[string]interface{} { + cdpURL := s.CDPURL() + cdpPort := s.Port() + if cdpURL == "" && profile != nil && profile.DebugReady && profile.DebugPort > 0 { + cdpPort = profile.DebugPort + cdpURL = fmt.Sprintf("http://127.0.0.1:%d", profile.DebugPort) + } + + return map[string]interface{}{ + "ok": true, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + "debugReady": profile.DebugReady, + "runtimeWarning": profile.RuntimeWarning, + "cdpPort": cdpPort, + "cdpUrl": cdpURL, + } +} + +func (s *LaunchServer) launchByCode(code string, params LaunchRequestParams) (*browser.Profile, string, int, string) { + return s.launchBySelectorInternal(normalizeLaunchSelector(LaunchSelector{Code: code}), params, false) +} + +func (s *LaunchServer) launchBySelector(selector LaunchSelector, params LaunchRequestParams) (*browser.Profile, string, int, string) { + return s.launchBySelectorInternal(selector, params, true) +} + +func (s *LaunchServer) launchProfile(profileID string, params LaunchRequestParams) (*browser.Profile, error) { + if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok { + profile, err := starterWithParams.StartInstanceWithParams(profileID, params) + return normalizeLaunchedProfileRuntime(profile), err + } + profile, err := s.starter.StartInstance(profileID) + return normalizeLaunchedProfileRuntime(profile), err +} + +func normalizeLaunchedProfileRuntime(profile *browser.Profile) *browser.Profile { + if profile == nil { + return nil + } + + // Backward compatibility: older starter implementations only filled pid/debugPort. + if !profile.Running && (profile.Pid > 0 || profile.DebugPort > 0) { + profile.Running = true + } + if !profile.DebugReady && + profile.DebugPort > 0 && + strings.TrimSpace(profile.RuntimeWarning) == "" && + (profile.Running || profile.Pid > 0) { + profile.DebugReady = true + } + + return profile +} + +func (s *LaunchServer) launchBySelectorInternal(selector LaunchSelector, params LaunchRequestParams, allowCodeKeywordFallback bool) (*browser.Profile, string, int, string) { + var ( + profileID string + launchCode string + err error + ) + + selector = normalizeLaunchSelector(selector) + if selector.IsEmpty() { + return nil, "", http.StatusBadRequest, "selector is required" + } + if err = selector.Validate(); err != nil { + return nil, "", http.StatusBadRequest, err.Error() + } + selector = s.withCodeKeywordFallback(selector, allowCodeKeywordFallback) + + if selector.OnlyCode() { + profileID, err = s.service.Resolve(selector.Code) + if err != nil { + return nil, "", http.StatusNotFound, "launch code not found" + } + launchCode = selector.Code + } else { + profileSnapshot, status, errMsg := s.findProfileBySelector(selector) + if errMsg != "" { + if selector.Code != "" { + launchCode = selector.Code + } + return nil, launchCode, status, errMsg + } + profileID = profileSnapshot.ProfileId + launchCode = profileSnapshot.LaunchCode + } + + profile, err := s.launchProfile(profileID, params) + if err != nil { + return nil, launchCode, http.StatusInternalServerError, err.Error() + } + + if launchCode == "" && s.service != nil && profile != nil { + if code, codeErr := s.service.EnsureCode(profile.ProfileId); codeErr == nil { + launchCode = code + } + } + if profile != nil && launchCode != "" { + profile.LaunchCode = launchCode + } + + return profile, launchCode, http.StatusOK, "" +} + +func (s *LaunchServer) launchAllBySelector(selector LaunchSelector, params LaunchRequestParams) ([]*browser.Profile, int, string) { + selector = normalizeLaunchSelector(selector) + if selector.IsEmpty() { + return nil, http.StatusBadRequest, "selector is required" + } + if err := selector.Validate(); err != nil { + return nil, http.StatusBadRequest, err.Error() + } + selector = s.withCodeKeywordFallback(selector, true) + + snapshots, status, errMsg := s.findProfilesBySelector(selector) + if errMsg != "" { + return nil, status, errMsg + } + + profiles := make([]*browser.Profile, 0, len(snapshots)) + for _, snapshot := range snapshots { + profile, err := s.launchProfile(snapshot.ProfileId, params) + if err != nil { + label := strings.TrimSpace(snapshot.ProfileName) + if label == "" { + label = snapshot.ProfileId + } + return profiles, http.StatusInternalServerError, fmt.Sprintf("failed to start profile %s after launching %d profile(s): %v", label, len(profiles), err) + } + + launchCode := snapshot.LaunchCode + if launchCode == "" && s.service != nil && profile != nil { + if code, codeErr := s.service.EnsureCode(profile.ProfileId); codeErr == nil { + launchCode = code + } + } + if profile != nil && launchCode != "" { + profile.LaunchCode = launchCode + } + + profiles = append(profiles, profile) + } + + return profiles, http.StatusOK, "" +} + +func (s *LaunchServer) withCodeKeywordFallback(selector LaunchSelector, allow bool) LaunchSelector { + if !allow || strings.TrimSpace(selector.Code) == "" { + return selector + } + if s.service != nil { + if _, err := s.service.Resolve(selector.Code); err == nil { + return selector + } + } + + fallback := selector + if strings.TrimSpace(fallback.Key) == "" { + fallback.Key = selector.Code + } + fallback.Code = "" + return fallback +} + +func (s *LaunchServer) launchBatchSuccessPayload(profiles []*browser.Profile) map[string]interface{} { + items := make([]map[string]interface{}, 0, len(profiles)) + for i, profile := range profiles { + if profile == nil { + continue + } + item := map[string]interface{}{ + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + "debugReady": profile.DebugReady, + "runtimeWarning": profile.RuntimeWarning, + "isActive": i == len(profiles)-1, + } + items = append(items, item) + } + + activeProfile, _, _ := summarizeLaunchedProfiles(profiles) + cdpURL := s.CDPURL() + cdpPort := s.Port() + if cdpURL == "" && activeProfile != nil && activeProfile.DebugReady && activeProfile.DebugPort > 0 { + cdpPort = activeProfile.DebugPort + cdpURL = fmt.Sprintf("http://127.0.0.1:%d", activeProfile.DebugPort) + } + + payload := map[string]interface{}{ + "ok": true, + "matchMode": launchMatchModeAll, + "count": len(items), + "items": items, + "cdpPort": cdpPort, + "cdpUrl": cdpURL, + } + if activeProfile != nil { + payload["activeProfileId"] = activeProfile.ProfileId + payload["activeProfileName"] = activeProfile.ProfileName + } + return payload +} + +func summarizeLaunchedProfiles(profiles []*browser.Profile) (*browser.Profile, string, string) { + if len(profiles) == 0 { + return nil, "", "" + } + + ids := make([]string, 0, len(profiles)) + names := make([]string, 0, len(profiles)) + var active *browser.Profile + for _, profile := range profiles { + if profile == nil { + continue + } + active = profile + ids = append(ids, profile.ProfileId) + if trimmed := strings.TrimSpace(profile.ProfileName); trimmed != "" { + names = append(names, trimmed) + } + } + return active, strings.Join(ids, ","), strings.Join(names, ",") +} diff --git a/backend/internal/launchcode/server_logs.go b/backend/internal/launchcode/server_logs.go new file mode 100644 index 00000000..59b632fc --- /dev/null +++ b/backend/internal/launchcode/server_logs.go @@ -0,0 +1,61 @@ +package launchcode + +import ( + "strings" + "time" + + "ant-chrome/backend/internal/logger" +) + +func (s *LaunchServer) appendLaunchLog(method, path, clientIP, code string, selector LaunchSelector, 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), + Selector: selector, + 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 +} diff --git a/backend/internal/logger/interceptor.go b/backend/internal/logger/interceptor.go deleted file mode 100644 index 075dd0a0..00000000 --- a/backend/internal/logger/interceptor.go +++ /dev/null @@ -1,488 +0,0 @@ -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_logging.go b/backend/internal/logger/interceptor_logging.go new file mode 100644 index 00000000..19ef75a9 --- /dev/null +++ b/backend/internal/logger/interceptor_logging.go @@ -0,0 +1,155 @@ +package logger + +import ( + "fmt" + "runtime" + "strings" + "time" +) + +// 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) + } +} + +// 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") && !strings.Contains(file, "interceptor_") { + // 只保留文件名 + 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]) +} diff --git a/backend/internal/logger/interceptor_masking.go b/backend/internal/logger/interceptor_masking.go new file mode 100644 index 00000000..5976c9ee --- /dev/null +++ b/backend/internal/logger/interceptor_masking.go @@ -0,0 +1,115 @@ +package logger + +import ( + "fmt" + "reflect" + "strings" +) + +// 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)) +} diff --git a/backend/internal/logger/interceptor_types.go b/backend/internal/logger/interceptor_types.go new file mode 100644 index 00000000..ca6d8010 --- /dev/null +++ b/backend/internal/logger/interceptor_types.go @@ -0,0 +1,89 @@ +package logger + +import ( + "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() +} + +// 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 +} + +// 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_wrap.go b/backend/internal/logger/interceptor_wrap.go new file mode 100644 index 00000000..ab78d6b7 --- /dev/null +++ b/backend/internal/logger/interceptor_wrap.go @@ -0,0 +1,144 @@ +package logger + +import "time" + +// 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 + } +} + +// 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) +} diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go deleted file mode 100644 index d7a51593..00000000 --- a/backend/internal/logger/logger.go +++ /dev/null @@ -1,546 +0,0 @@ -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_api.go b/backend/internal/logger/logger_api.go new file mode 100644 index 00000000..8d94fc0e --- /dev/null +++ b/backend/internal/logger/logger_api.go @@ -0,0 +1,229 @@ +package logger + +import ( + "fmt" +) + +// 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_init.go b/backend/internal/logger/logger_init.go new file mode 100644 index 00000000..ab2799c3 --- /dev/null +++ b/backend/internal/logger/logger_init.go @@ -0,0 +1,203 @@ +package logger + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// 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, + } +} diff --git a/backend/internal/logger/logger_types.go b/backend/internal/logger/logger_types.go new file mode 100644 index 00000000..e7ffcacd --- /dev/null +++ b/backend/internal/logger/logger_types.go @@ -0,0 +1,125 @@ +package logger + +import ( + "context" + "strings" + "sync" +) + +// 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", + }, + } +} diff --git a/backend/internal/logger/rotation.go b/backend/internal/logger/rotation.go deleted file mode 100644 index d0d37522..00000000 --- a/backend/internal/logger/rotation.go +++ /dev/null @@ -1,524 +0,0 @@ -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_filename.go b/backend/internal/logger/rotation_filename.go new file mode 100644 index 00000000..0f3b6ada --- /dev/null +++ b/backend/internal/logger/rotation_filename.go @@ -0,0 +1,71 @@ +package logger + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + "time" +) + +// 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_manager.go b/backend/internal/logger/rotation_manager.go new file mode 100644 index 00000000..cde8b8bc --- /dev/null +++ b/backend/internal/logger/rotation_manager.go @@ -0,0 +1,240 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// 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 +} diff --git a/backend/internal/logger/rotation_policy.go b/backend/internal/logger/rotation_policy.go new file mode 100644 index 00000000..eb4fc323 --- /dev/null +++ b/backend/internal/logger/rotation_policy.go @@ -0,0 +1,232 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "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 +} diff --git a/backend/internal/logger/writer_console.go b/backend/internal/logger/writer_console.go new file mode 100644 index 00000000..e6a21795 --- /dev/null +++ b/backend/internal/logger/writer_console.go @@ -0,0 +1,51 @@ +package logger + +import ( + "fmt" + "os" + "sync" +) + +// 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 +} diff --git a/backend/internal/logger/writer.go b/backend/internal/logger/writer_file.go similarity index 77% rename from backend/internal/logger/writer.go rename to backend/internal/logger/writer_file.go index 9f244cce..542ce4c4 100644 --- a/backend/internal/logger/writer.go +++ b/backend/internal/logger/writer_file.go @@ -9,50 +9,6 @@ import ( "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 // 日志文件路径 @@ -333,43 +289,3 @@ func (w *FileWriter) QueueLength() int { } 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_multi.go b/backend/internal/logger/writer_multi.go new file mode 100644 index 00000000..f0dfe9c8 --- /dev/null +++ b/backend/internal/logger/writer_multi.go @@ -0,0 +1,41 @@ +package logger + +// 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/proxy/parser.go b/backend/internal/proxy/parser.go index 6c06ba9c..763d0c59 100644 --- a/backend/internal/proxy/parser.go +++ b/backend/internal/proxy/parser.go @@ -4,10 +4,7 @@ import ( "encoding/json" "fmt" "net/url" - "strconv" "strings" - - "gopkg.in/yaml.v3" ) const chainSocks5Prefix = "chain+socks5://" @@ -114,331 +111,6 @@ func ParseProxyNode(node string) (string, map[string]interface{}, error) { 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://") { @@ -461,300 +133,3 @@ func buildXrayOutbound(node string) (map[string]interface{}, error) { } 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/parser_clash_entry.go b/backend/internal/proxy/parser_clash_entry.go new file mode 100644 index 00000000..5cf05145 --- /dev/null +++ b/backend/internal/proxy/parser_clash_entry.go @@ -0,0 +1,84 @@ +package proxy + +import ( + "fmt" + "net/url" + "strings" + + "gopkg.in/yaml.v3" +) + +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) +} diff --git a/backend/internal/proxy/parser_clash_other_protocols.go b/backend/internal/proxy/parser_clash_other_protocols.go new file mode 100644 index 00000000..c8ef0dbd --- /dev/null +++ b/backend/internal/proxy/parser_clash_other_protocols.go @@ -0,0 +1,93 @@ +package proxy + +import "fmt" + +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) { + return nil, "", fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理") +} + +// 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, + }, + } + if plugin := getMapString(node, "plugin"); plugin != "" { + pluginOpts := getMapString(node, "plugin-opts") + _ = pluginOpts + } + return out, "", nil +} diff --git a/backend/internal/proxy/parser_clash_v_protocols.go b/backend/internal/proxy/parser_clash_v_protocols.go new file mode 100644 index 00000000..d331cb6d --- /dev/null +++ b/backend/internal/proxy/parser_clash_v_protocols.go @@ -0,0 +1,183 @@ +package proxy + +import "strings" + +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 := 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 { + 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" { + 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") + 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 := 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 + } + 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") + 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 +} diff --git a/backend/internal/proxy/parser_uri.go b/backend/internal/proxy/parser_uri.go new file mode 100644 index 00000000..b1a12a34 --- /dev/null +++ b/backend/internal/proxy/parser_uri.go @@ -0,0 +1,267 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" +) + +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) { + 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 +} + +// 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://") + if idx := strings.Index(raw, "#"); idx >= 0 { + raw = raw[:idx] + } + raw = strings.TrimSpace(raw) + + var host, method, password string + var port int + + 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() + if decoded, err := decodeBase64String(userInfo); err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + } else { + parts := strings.SplitN(userInfo, ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + } + } else { + decoded, err := decodeBase64String(raw) + if err != nil { + return nil, fmt.Errorf("ss base64 解析失败: %v", err) + } + 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/runtime_bridge_helpers.go b/backend/internal/proxy/runtime_bridge_helpers.go new file mode 100644 index 00000000..6c6726f7 --- /dev/null +++ b/backend/internal/proxy/runtime_bridge_helpers.go @@ -0,0 +1,96 @@ +package proxy + +import ( + "ant-chrome/backend/internal/fsutil" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +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 = fsutil.NormalizePathInput(path) + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return path + } + if appRoot != "" { + candidate := filepath.Join(appRoot, path) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + if exePath, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exePath), path) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + 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() + 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/singbox.go b/backend/internal/proxy/singbox_bridge_runtime.go similarity index 58% rename from backend/internal/proxy/singbox.go rename to backend/internal/proxy/singbox_bridge_runtime.go index 2f64f074..36958a86 100644 --- a/backend/internal/proxy/singbox.go +++ b/backend/internal/proxy/singbox_bridge_runtime.go @@ -1,50 +1,16 @@ package proxy import ( - "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/fsutil" "ant-chrome/backend/internal/logger" - "encoding/json" "fmt" "os" "os/exec" "path/filepath" - goruntime "runtime" "strings" - "sync" "time" ) -// SingBoxBridge sing-box 桥接进程 -type SingBoxBridge struct { - NodeKey string - Port int - Cmd *exec.Cmd - Pid int - Running bool - Stopping bool - LastError string -} - -// SingBoxManager sing-box 桥接管理器 -type SingBoxManager struct { - Config *config.Config - AppRoot string // 应用根目录,所有相对路径基于此解析 - Bridges map[string]*SingBoxBridge - OnBridgeDied func(key string, err error) - mu sync.Mutex -} - -// 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") @@ -261,130 +227,3 @@ func (m *SingBoxManager) stopBridgeProcess(bridge *SingBoxBridge) { } _ = bridge.Cmd.Process.Kill() } - -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 { - if err := fsutil.EnsureExecutable(resolved); err != nil { - return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", resolved, err) - } - return resolved, nil - } - } - } - if env := strings.TrimSpace(os.Getenv("SINGBOX_BINARY_PATH")); env != "" { - if _, err := os.Stat(env); err == nil { - if err := fsutil.EnsureExecutable(env); err != nil { - return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", env, err) - } - return env, nil - } - } - - binaryNames := []string{"sing-box"} - if goruntime.GOOS == "windows" { - binaryNames = []string{"sing-box.exe", "sing-box"} - } - platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH) - - searchDirs := make([]string, 0, 4) - if m.AppRoot != "" { - searchDirs = append(searchDirs, - filepath.Join(m.AppRoot, "bin", platformDir), - filepath.Join(m.AppRoot, "bin"), - ) - } - if exePath, err := os.Executable(); err == nil { - exeDir := filepath.Dir(exePath) - searchDirs = append(searchDirs, - filepath.Join(exeDir, "bin", platformDir), - filepath.Join(exeDir, "bin"), - ) - } - - for _, dir := range searchDirs { - for _, name := range binaryNames { - candidate := filepath.Join(dir, name) - if _, err := os.Stat(candidate); err == nil { - if err := fsutil.EnsureExecutable(candidate); err != nil { - return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", candidate, err) - } - return candidate, nil - } - } - } - - for _, name := range binaryNames { - if path, err := exec.LookPath(name); err == nil { - if err := fsutil.EnsureExecutable(path); err != nil { - return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", path, err) - } - return path, nil - } - } - - return "", fmt.Errorf("未找到 sing-box 可执行文件。请将 sing-box 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 SingBoxBinaryPath", platformDir) -} - -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) { - root = apppath.Resolve(m.AppRoot, root) - } - return filepath.Join(root, "_singbox", key) -} diff --git a/backend/internal/proxy/singbox_runtime_helpers.go b/backend/internal/proxy/singbox_runtime_helpers.go new file mode 100644 index 00000000..55c79e5d --- /dev/null +++ b/backend/internal/proxy/singbox_runtime_helpers.go @@ -0,0 +1,140 @@ +package proxy + +import ( + "ant-chrome/backend/internal/apppath" + "ant-chrome/backend/internal/fsutil" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" +) + +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 { + if err := fsutil.EnsureExecutable(resolved); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", resolved, err) + } + return resolved, nil + } + } + } + if env := strings.TrimSpace(os.Getenv("SINGBOX_BINARY_PATH")); env != "" { + if _, err := os.Stat(env); err == nil { + if err := fsutil.EnsureExecutable(env); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", env, err) + } + return env, nil + } + } + + binaryNames := []string{"sing-box"} + if goruntime.GOOS == "windows" { + binaryNames = []string{"sing-box.exe", "sing-box"} + } + platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH) + + searchDirs := make([]string, 0, 4) + if m.AppRoot != "" { + searchDirs = append(searchDirs, + filepath.Join(m.AppRoot, "bin", platformDir), + filepath.Join(m.AppRoot, "bin"), + ) + } + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + searchDirs = append(searchDirs, + filepath.Join(exeDir, "bin", platformDir), + filepath.Join(exeDir, "bin"), + ) + } + + for _, dir := range searchDirs { + for _, name := range binaryNames { + candidate := filepath.Join(dir, name) + if _, err := os.Stat(candidate); err == nil { + if err := fsutil.EnsureExecutable(candidate); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", candidate, err) + } + return candidate, nil + } + } + } + + for _, name := range binaryNames { + if path, err := exec.LookPath(name); err == nil { + if err := fsutil.EnsureExecutable(path); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", path, err) + } + return path, nil + } + } + + return "", fmt.Errorf("未找到 sing-box 可执行文件。请将 sing-box 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 SingBoxBinaryPath", platformDir) +} + +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) { + root = apppath.Resolve(m.AppRoot, root) + } + return filepath.Join(root, "_singbox", key) +} diff --git a/backend/internal/proxy/singbox_types.go b/backend/internal/proxy/singbox_types.go new file mode 100644 index 00000000..d99838b7 --- /dev/null +++ b/backend/internal/proxy/singbox_types.go @@ -0,0 +1,36 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "os/exec" + "sync" +) + +// SingBoxBridge sing-box 桥接进程 +type SingBoxBridge struct { + NodeKey string + Port int + Cmd *exec.Cmd + Pid int + Running bool + Stopping bool + LastError string +} + +// SingBoxManager sing-box 桥接管理器 +type SingBoxManager struct { + Config *config.Config + AppRoot string // 应用根目录,所有相对路径基于此解析 + Bridges map[string]*SingBoxBridge + OnBridgeDied func(key string, err error) + mu sync.Mutex +} + +// NewSingBoxManager 创建 sing-box 管理器 +func NewSingBoxManager(cfg *config.Config, appRoot string) *SingBoxManager { + return &SingBoxManager{ + Config: cfg, + AppRoot: appRoot, + Bridges: make(map[string]*SingBoxBridge), + } +} diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go index 342eeef3..71f2d514 100644 --- a/backend/internal/proxy/speedtest.go +++ b/backend/internal/proxy/speedtest.go @@ -1,17 +1,10 @@ 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" @@ -53,7 +46,6 @@ func SpeedTest( cfg = &c } - // 查找代理配置 src := "" for _, item := range proxies { if strings.EqualFold(item.ProxyId, proxyId) { @@ -74,36 +66,15 @@ func SpeedTest( testURL = cfg.URLs[0] } - resolvedSrc := src - if IsChainSocks5Proxy(src) { - if xrayMgr == nil { - log.Warn("链式代理测速缺少 Xray 管理器,降级到 TCP ping", - logger.F("proxy_id", proxyId), - ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) - } - bridgeSocksURL, bridgeErr := xrayMgr.EnsureBridge(src, proxies, proxyId) - if bridgeErr != nil { - log.Warn("链式代理桥接失败,降级到 TCP ping", - logger.F("proxy_id", proxyId), - logger.F("error", bridgeErr.Error()), - ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) - } - resolvedSrc = strings.TrimSpace(bridgeSocksURL) - } - - // 将代理配置转换为 mihomo mapping - mapping, err := proxyConfigToMapping(resolvedSrc) + mapping, err := proxyConfigToMapping(src) if err != nil { log.Warn("代理配置解析失败,降级到 TCP ping", logger.F("proxy_id", proxyId), logger.F("error", err.Error()), ) - return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) } - // 使用 mihomo adapter.ParseProxy 创建代理实例 proxyInstance, err := adapter.ParseProxy(mapping) if err != nil { log.Warn("mihomo 代理创建失败,降级到 TCP ping", @@ -111,230 +82,8 @@ func SpeedTest( logger.F("error", err.Error()), logger.F("type", mapping["type"]), ) - return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) + 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/speedtest_fallback.go b/backend/internal/proxy/speedtest_fallback.go new file mode 100644 index 00000000..80a05332 --- /dev/null +++ b/backend/internal/proxy/speedtest_fallback.go @@ -0,0 +1,28 @@ +package proxy + +import ( + "fmt" + "net" + "time" + + "ant-chrome/backend/internal/logger" +) + +// ─── 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} +} diff --git a/backend/internal/proxy/speedtest_mapping.go b/backend/internal/proxy/speedtest_mapping.go new file mode 100644 index 00000000..f869d920 --- /dev/null +++ b/backend/internal/proxy/speedtest_mapping.go @@ -0,0 +1,106 @@ +package proxy + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +func proxyConfigToMapping(src string) (map[string]any, error) { + src = strings.TrimSpace(src) + l := strings.ToLower(src) + + if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") { + return parseStandardProxy(src, "http") + } + if strings.HasPrefix(l, "socks5://") { + return parseStandardProxy(src, "socks5") + } + + if strings.Contains(l, "://") && !strings.Contains(l, "type:") { + return nil, fmt.Errorf("URI 格式暂不支持: %s", l[:min(30, len(l))]) + } + + 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 +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/proxy/speedtest_test.go b/backend/internal/proxy/speedtest_test.go new file mode 100644 index 00000000..b171ac81 --- /dev/null +++ b/backend/internal/proxy/speedtest_test.go @@ -0,0 +1,78 @@ +package proxy + +import "testing" + +func TestProxyConfigToMappingStandardProxy(t *testing.T) { + t.Parallel() + + mapping, err := proxyConfigToMapping("http://user:pass@example.com:8080/path") + if err != nil { + t.Fatalf("proxyConfigToMapping returned error: %v", err) + } + + if got := mapping["type"]; got != "http" { + t.Fatalf("type = %v, want http", got) + } + if got := mapping["server"]; got != "example.com" { + t.Fatalf("server = %v, want example.com", got) + } + if got := mapping["port"]; got != 8080 { + t.Fatalf("port = %v, want 8080", got) + } + if got := mapping["username"]; got != "user" { + t.Fatalf("username = %v, want user", got) + } + if got := mapping["password"]; got != "pass" { + t.Fatalf("password = %v, want pass", got) + } +} + +func TestProxyConfigToMappingClashYAML(t *testing.T) { + t.Parallel() + + src := "proxies:\n - type: vmess\n server: test.example.com\n port: 443\n" + mapping, err := proxyConfigToMapping(src) + if err != nil { + t.Fatalf("proxyConfigToMapping returned error: %v", err) + } + + if got := mapping["type"]; got != "vmess" { + t.Fatalf("type = %v, want vmess", got) + } + if got := mapping["server"]; got != "test.example.com" { + t.Fatalf("server = %v, want test.example.com", got) + } + if got := mapping["port"]; got != 443 { + t.Fatalf("port = %v, want 443", got) + } + if got := mapping["name"]; got != "speedtest-proxy" { + t.Fatalf("name = %v, want speedtest-proxy", got) + } +} + +func TestProxyConfigToMappingUnsupportedURI(t *testing.T) { + t.Parallel() + + if _, err := proxyConfigToMapping("vmess://example"); err == nil { + t.Fatal("expected unsupported URI error") + } +} + +func TestURLToMeta(t *testing.T) { + t.Parallel() + + meta, err := urlToMeta("https://1.2.3.4:8443/path") + if err != nil { + t.Fatalf("urlToMeta returned error: %v", err) + } + + if meta.Host != "1.2.3.4" { + t.Fatalf("host = %q, want 1.2.3.4", meta.Host) + } + if meta.DstPort != 8443 { + t.Fatalf("port = %d, want 8443", meta.DstPort) + } + if !meta.DstIP.IsValid() || meta.DstIP.String() != "1.2.3.4" { + t.Fatalf("DstIP = %v, want 1.2.3.4", meta.DstIP) + } +} diff --git a/backend/internal/proxy/speedtest_unified_delay.go b/backend/internal/proxy/speedtest_unified_delay.go new file mode 100644 index 00000000..fab7c387 --- /dev/null +++ b/backend/internal/proxy/speedtest_unified_delay.go @@ -0,0 +1,109 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + "time" + + C "github.com/metacubex/mihomo/constant" +) + +// 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)} + } + + conn, err := px.DialContext(ctx, &addr) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理连接失败: %v", err)} + } + defer conn.Close() + + 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() + + 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() + + 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") + } + + 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 +} diff --git a/backend/internal/proxy/utils.go b/backend/internal/proxy/utils_connectivity.go similarity index 55% rename from backend/internal/proxy/utils.go rename to backend/internal/proxy/utils_connectivity.go index 67b36987..50a2ccd9 100644 --- a/backend/internal/proxy/utils.go +++ b/backend/internal/proxy/utils_connectivity.go @@ -1,82 +1,17 @@ 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 { @@ -109,105 +44,6 @@ func TestConnectivity(proxyId string, proxyConfig string, proxies []config.Brows 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 地址后发送请求 @@ -244,7 +80,6 @@ func TestRealConnectivityWithSingBox( var client *http.Client if IsSingBoxProtocol(src) { - // hysteria2/tuic → sing-box 桥接 if singboxMgr == nil { return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"} } @@ -264,7 +99,6 @@ func TestRealConnectivityWithSingBox( 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 管理器未初始化"} } @@ -272,7 +106,6 @@ func TestRealConnectivityWithSingBox( 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 { @@ -285,7 +118,6 @@ func TestRealConnectivityWithSingBox( 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)} diff --git a/backend/internal/proxy/utils_parse.go b/backend/internal/proxy/utils_parse.go new file mode 100644 index 00000000..211b8bca --- /dev/null +++ b/backend/internal/proxy/utils_parse.go @@ -0,0 +1,158 @@ +package proxy + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// proxyEndpoint 从代理配置中提取 server:port,用于 TCP ping +func proxyEndpoint(src string) (string, error) { + src = strings.TrimSpace(src) + l := strings.ToLower(src) + + 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 + } + + 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 + } + } + } + + 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 + } + } + + 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("无法解析代理地址") +} + +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 解析失败") +} + +func isUnsupportedProtocol(src string) bool { + l := strings.ToLower(strings.TrimSpace(src)) + return strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") +} diff --git a/backend/internal/proxy/utils_types.go b/backend/internal/proxy/utils_types.go new file mode 100644 index 00000000..b219202f --- /dev/null +++ b/backend/internal/proxy/utils_types.go @@ -0,0 +1,9 @@ +package proxy + +// TestResult 代理测试结果 +type TestResult struct { + ProxyId string + Ok bool + LatencyMs int64 + Error string +} diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index ab09dc50..9036f1a7 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -1,25 +1,11 @@ package proxy import ( - "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/fsutil" - "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 ( @@ -64,32 +50,21 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox } } if !found { - // 兼容模式:如果 profile 内仍保留了可解析的 proxyConfig,则允许回退使用。 - // 这样可兼容历史版本中 proxyId 失效后的启动流程,避免升级后强制手工重绑。 if src == "" { return false, fmt.Sprintf("代理链路不可用:代理池节点已不存在(proxyId=%s)。可能因订阅刷新后节点下线或被删除,请重新选择代理后再启动。", proxyId) } } } if src == "" { - return true, "" // 无代理配置,允许启动 + 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, "" } - if IsChainSocks5Proxy(src) { - if _, err := ParseChainSocks5Config(src); err != nil { - return false, fmt.Sprintf("链式代理配置解析失败: %v", err) - } - return true, "" - } - - // hysteria2/tuic 通过 sing-box 支持,先做可解析性校验 if IsSingBoxProtocol(src) { if _, err := BuildSingBoxOutbound(src); err != nil { return false, fmt.Sprintf("代理配置解析失败: %v", err) @@ -97,7 +72,6 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox return true, "" } - // 其余协议交给统一解析器校验,防止无效字符串被当成代理参数透传给 Chrome standardProxy, outbound, err := ParseProxyNode(src) if err != nil { return false, fmt.Sprintf("代理配置解析失败: %v", err) @@ -125,25 +99,16 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s 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 } - if IsChainSocks5Proxy(src) { - return true - } - - // 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 } @@ -204,700 +169,3 @@ func (m *XrayManager) StopAll() { 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) - - var ( - outbounds []interface{} - routes []interface{} - preferredPort int - ) - - if IsChainSocks5Proxy(src) { - chainCfg, err := ParseChainSocks5Config(src) - if err != nil { - log.Error("链式节点解析失败", logger.F("error", err)) - return "", "", err - } - outbounds = []interface{}{ - chainSocks5Outbound(chainCfg.First, "first-hop", ""), - chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"), - } - routes = []interface{}{ - map[string]interface{}{ - "type": "field", - "inboundTag": []string{"socks-in"}, - "outboundTag": "second-hop", - }, - } - preferredPort = chainCfg.LocalPort - } else { - 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("节点解析失败") - } - outbounds = []interface{}{outbound} - routes = []interface{}{ - map[string]interface{}{ - "type": "field", - "inboundTag": []string{"socks-in"}, - "outboundTag": "proxy-out", - }, - } - } - - 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 - } - maxLaunchRetries := 3 - if preferredPort > 0 { - maxLaunchRetries = 1 - } - var lastErr error - for attempt := 1; attempt <= maxLaunchRetries; attempt++ { - var port int - if preferredPort > 0 { - port = preferredPort - } else { - port, err = nextAvailablePort() - if err != nil { - log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) - lastErr = err - continue - } - } - - cfgPath, err := m.buildRuntimeConfigWithRoute(key, outbounds, routes, 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() - } - 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 - 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 { - if err := fsutil.EnsureExecutable(resolved); err != nil { - return "", fmt.Errorf("xray 文件不可执行: %s: %w", resolved, err) - } - return resolved, nil - } - } - } - env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH")) - if env != "" { - if _, err := os.Stat(env); err == nil { - if err := fsutil.EnsureExecutable(env); err != nil { - return "", fmt.Errorf("xray 文件不可执行: %s: %w", env, err) - } - return env, nil - } - } - - binaryNames := []string{"xray"} - if goruntime.GOOS == "windows" { - binaryNames = []string{"xray.exe", "xray"} - } - platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH) - - searchDirs := make([]string, 0, 4) - if m.AppRoot != "" { - searchDirs = append(searchDirs, - filepath.Join(m.AppRoot, "bin", platformDir), - filepath.Join(m.AppRoot, "bin"), - ) - } - if exePath, err := os.Executable(); err == nil { - exeDir := filepath.Dir(exePath) - searchDirs = append(searchDirs, - filepath.Join(exeDir, "bin", platformDir), - filepath.Join(exeDir, "bin"), - ) - } - - for _, dir := range searchDirs { - for _, name := range binaryNames { - candidate := filepath.Join(dir, name) - if _, err := os.Stat(candidate); err == nil { - if err := fsutil.EnsureExecutable(candidate); err != nil { - return "", fmt.Errorf("xray 文件不可执行: %s: %w", candidate, err) - } - return candidate, nil - } - } - } - - for _, name := range binaryNames { - if path, err := exec.LookPath(name); err == nil { - if err := fsutil.EnsureExecutable(path); err != nil { - return "", fmt.Errorf("xray 文件不可执行: %s: %w", path, err) - } - return path, nil - } - } - - return "", fmt.Errorf("未找到 xray 可执行文件。请将 xray 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 XrayBinaryPath", platformDir) -} - -// 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 chainSocks5Outbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} { - user := map[string]interface{}{} - if strings.TrimSpace(hop.Username) != "" { - user["user"] = hop.Username - if strings.TrimSpace(hop.Password) != "" { - user["pass"] = hop.Password - } - } - servers := []interface{}{ - map[string]interface{}{ - "address": hop.Server, - "port": hop.Port, - "users": []interface{}{user}, - }, - } - if len(user) == 0 { - servers = []interface{}{ - map[string]interface{}{ - "address": hop.Server, - "port": hop.Port, - }, - } - } - - outbound := map[string]interface{}{ - "protocol": "socks", - "tag": tag, - "settings": map[string]interface{}{ - "servers": servers, - }, - } - if strings.TrimSpace(nextTag) != "" { - outbound["proxySettings"] = map[string]interface{}{ - "tag": nextTag, - } - } - return outbound -} - -func (m *XrayManager) buildRuntimeConfigWithRoute( - key string, - outbounds []interface{}, - rules []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": append(outbounds, - map[string]interface{}{ - "protocol": "direct", - "tag": "direct", - }, - map[string]interface{}{ - "protocol": "blackhole", - "tag": "block", - }, - ), - "routing": map[string]interface{}{ - "rules": rules, - }, - } - 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) { - root = apppath.Resolve(m.AppRoot, 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 = fsutil.NormalizePathInput(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_bridge_cleanup.go b/backend/internal/proxy/xray_bridge_cleanup.go new file mode 100644 index 00000000..1fa83a76 --- /dev/null +++ b/backend/internal/proxy/xray_bridge_cleanup.go @@ -0,0 +1,61 @@ +package proxy + +import ( + "ant-chrome/backend/internal/logger" + "time" +) + +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() +} diff --git a/backend/internal/proxy/xray_bridge_launch.go b/backend/internal/proxy/xray_bridge_launch.go new file mode 100644 index 00000000..a1c42257 --- /dev/null +++ b/backend/internal/proxy/xray_bridge_launch.go @@ -0,0 +1,155 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +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 + } + + const maxLaunchRetries = 3 + var lastErr error + for attempt := 1; attempt <= maxLaunchRetries; attempt++ { + socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbound, dnsServers, pin, attempt) + if err == nil { + return socksURL, key, nil + } + if bridge != nil && bridge.Running { + go m.watchBridge(bridge, key) + } + lastErr = err + } + return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr) +} + +func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binaryPath string, outbound map[string]interface{}, dnsServers string, pin bool, attempt int) (string, *XrayBridge, error) { + port, err := nextAvailablePort() + if err != nil { + log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) + return "", nil, err + } + cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers) + if err != nil { + log.Error("xray 配置生成失败", logger.F("error", err)) + return "", nil, 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)) + return "", nil, err + } + + 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 := m.waitBridgeReady(log, bridge, cfgPath, stderrPath, stderrFile, attempt); err != nil { + return "", nil, err + } + + 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, nil, nil + } + + return fmt.Sprintf("socks5://127.0.0.1:%d", port), bridge, nil +} + +func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cfgPath string, stderrPath string, stderrFile *os.File, attempt int) error { + if err := waitPortReady("127.0.0.1", bridge.Port, 10*time.Second); err != nil { + if stderrFile != nil { + stderrFile.Close() + } + m.logBridgeStartupError(log, cfgPath, stderrPath) + bridge.Stopping = true + m.stopBridgeProcess(bridge) + bridge.Running = false + bridge.Pid = 0 + bridge.LastError = err.Error() + log.Error("xray 端口不可用,重试", logger.F("key", bridge.NodeKey), logger.F("error", err), logger.F("port", bridge.Port), logger.F("attempt", attempt)) + time.Sleep(200 * time.Millisecond) + return err + } + if stderrFile != nil { + stderrFile.Close() + } + return nil +} + +func (m *XrayManager) logBridgeStartupError(log *logger.Logger, cfgPath string, stderrPath string) { + if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 { + log.Error("xray stderr", logger.F("output", string(stderrContent))) + return + } + + 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))) + } +} diff --git a/backend/internal/proxy/xray_bridge_store.go b/backend/internal/proxy/xray_bridge_store.go new file mode 100644 index 00000000..4b702fea --- /dev/null +++ b/backend/internal/proxy/xray_bridge_store.go @@ -0,0 +1,97 @@ +package proxy + +import ( + "fmt" + "time" +) + +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 { + if existing == bridge { + m.mu.Unlock() + return "", false + } + + 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 桥接进程意外退出")) + } +} diff --git a/backend/internal/proxy/xray_runtime_binary.go b/backend/internal/proxy/xray_runtime_binary.go new file mode 100644 index 00000000..2ac65ccb --- /dev/null +++ b/backend/internal/proxy/xray_runtime_binary.go @@ -0,0 +1,79 @@ +package proxy + +import ( + "ant-chrome/backend/internal/fsutil" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" +) + +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 { + if err := fsutil.EnsureExecutable(resolved); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", resolved, err) + } + return resolved, nil + } + } + } + env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH")) + if env != "" { + if _, err := os.Stat(env); err == nil { + if err := fsutil.EnsureExecutable(env); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", env, err) + } + return env, nil + } + } + + binaryNames := []string{"xray"} + if goruntime.GOOS == "windows" { + binaryNames = []string{"xray.exe", "xray"} + } + platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH) + + searchDirs := make([]string, 0, 4) + if m.AppRoot != "" { + searchDirs = append(searchDirs, + filepath.Join(m.AppRoot, "bin", platformDir), + filepath.Join(m.AppRoot, "bin"), + ) + } + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + searchDirs = append(searchDirs, + filepath.Join(exeDir, "bin", platformDir), + filepath.Join(exeDir, "bin"), + ) + } + + for _, dir := range searchDirs { + for _, name := range binaryNames { + candidate := filepath.Join(dir, name) + if _, err := os.Stat(candidate); err == nil { + if err := fsutil.EnsureExecutable(candidate); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", candidate, err) + } + return candidate, nil + } + } + } + + for _, name := range binaryNames { + if path, err := exec.LookPath(name); err == nil { + if err := fsutil.EnsureExecutable(path); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", path, err) + } + return path, nil + } + } + + return "", fmt.Errorf("未找到 xray 可执行文件。请将 xray 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 XrayBinaryPath", platformDir) +} diff --git a/backend/internal/proxy/xray_runtime_config.go b/backend/internal/proxy/xray_runtime_config.go new file mode 100644 index 00000000..c96b3ed0 --- /dev/null +++ b/backend/internal/proxy/xray_runtime_config.go @@ -0,0 +1,79 @@ +package proxy + +import ( + "ant-chrome/backend/internal/apppath" + "encoding/json" + "os" + "path/filepath" + "strings" +) + +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, 0o755); 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, 0o644); 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) { + root = apppath.Resolve(m.AppRoot, root) + } + return filepath.Join(root, "_xray", key) +} diff --git a/backend/internal/proxy/xray_runtime_dns.go b/backend/internal/proxy/xray_runtime_dns.go new file mode 100644 index 00000000..e13477ef --- /dev/null +++ b/backend/internal/proxy/xray_runtime_dns.go @@ -0,0 +1,76 @@ +package proxy + +import ( + "strings" + + "gopkg.in/yaml.v3" +) + +// 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 + } + + 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} + } + } + + 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 { + return nil + } + + servers := make([]interface{}, len(result)) + for i, s := range result { + servers[i] = s + } + return map[string]interface{}{"servers": servers} +} + +// 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 +} diff --git a/backend/internal/proxy/xray_runtime_helpers_test.go b/backend/internal/proxy/xray_runtime_helpers_test.go new file mode 100644 index 00000000..30d9b83e --- /dev/null +++ b/backend/internal/proxy/xray_runtime_helpers_test.go @@ -0,0 +1,51 @@ +package proxy + +import ( + "reflect" + "testing" +) + +func TestParseDnsConfigFromClashYAML(t *testing.T) { + t.Parallel() + + raw := ` +dns: + enable: true + nameserver: + - 8.8.8.8 + - tls://1.1.1.1 + fallback: + - https://dns.google/dns-query +` + + got := parseDnsConfig(raw) + want := map[string]interface{}{ + "servers": []interface{}{"8.8.8.8", "https://dns.google/dns-query"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseDnsConfig() = %#v, want %#v", got, want) + } +} + +func TestParseDnsConfigFromCommaList(t *testing.T) { + t.Parallel() + + got := parseDnsConfig("8.8.8.8, tls://1.1.1.1, 127.0.0.1:53") + want := map[string]interface{}{ + "servers": []interface{}{"8.8.8.8", "127.0.0.1:53"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseDnsConfig() = %#v, want %#v", got, want) + } +} + +func TestNormalizeNodeScheme(t *testing.T) { + t.Parallel() + + if got := normalizeNodeScheme("hysteria://example"); got != "hysteria2://example" { + t.Fatalf("normalizeNodeScheme() = %q", got) + } + if got := normalizeNodeScheme("vmess://example"); got != "vmess://example" { + t.Fatalf("normalizeNodeScheme() unexpectedly changed vmess: %q", got) + } +} diff --git a/backend/internal/proxy/xray_test.go b/backend/internal/proxy/xray_test.go new file mode 100644 index 00000000..1fbfbf00 --- /dev/null +++ b/backend/internal/proxy/xray_test.go @@ -0,0 +1,55 @@ +package proxy + +import "testing" + +func TestXrayRegisterBridgeStoresNewBridge(t *testing.T) { + t.Parallel() + + manager := &XrayManager{ + Bridges: make(map[string]*XrayBridge), + } + bridge := &XrayBridge{ + NodeKey: "node-a", + Port: 21001, + Running: true, + } + + socksURL, reused := manager.registerBridge("node-a", bridge, false) + if reused { + t.Fatalf("expected new bridge registration, got reused with %q", socksURL) + } + if socksURL != "" { + t.Fatalf("expected empty socksURL for new bridge registration, got %q", socksURL) + } + if manager.Bridges["node-a"] != bridge { + t.Fatalf("bridge was not stored in manager") + } +} + +func TestXrayRegisterBridgeIgnoresSamePointer(t *testing.T) { + t.Parallel() + + manager := &XrayManager{ + Bridges: make(map[string]*XrayBridge), + } + bridge := &XrayBridge{ + NodeKey: "node-a", + Port: 21001, + Running: true, + } + manager.Bridges["node-a"] = bridge + + socksURL, reused := manager.registerBridge("node-a", bridge, false) + if reused { + t.Fatalf("same bridge pointer must not be treated as duplicate, got reused with %q", socksURL) + } + if socksURL != "" { + t.Fatalf("expected empty socksURL when registering same pointer, got %q", socksURL) + } + if manager.Bridges["node-a"] != bridge { + t.Fatalf("bridge mapping changed unexpectedly") + } + if bridge.Stopping { + t.Fatalf("same bridge pointer should not be marked as stopping") + } +} diff --git a/backend/test/launchcode/server_automation_test.go b/backend/test/launchcode/server_automation_test.go new file mode 100644 index 00000000..10acaad3 --- /dev/null +++ b/backend/test/launchcode/server_automation_test.go @@ -0,0 +1,377 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "ant-chrome/backend/internal/automation" +) + +type mockAutomationStarter struct { + *mockStarterWithParams + scripts []automation.ScriptRecord + runs []automation.ScriptRunRecord + lastGetID string + lastRunRequest automation.ScriptRunRequest + lastRunListLimit int + runResult *automation.ScriptRunRecord + getErr error + listErr error + runErr error + runListErr error +} + +func newMockAutomationStarter() *mockAutomationStarter { + return &mockAutomationStarter{ + mockStarterWithParams: newMockStarterWithParams(), + } +} + +func (m *mockAutomationStarter) AutomationScriptList() ([]automation.ScriptRecord, error) { + if m.listErr != nil { + return nil, m.listErr + } + return append([]automation.ScriptRecord(nil), m.scripts...), nil +} + +func (m *mockAutomationStarter) AutomationScriptGet(scriptID string) (*automation.ScriptRecord, error) { + m.lastGetID = scriptID + if m.getErr != nil { + return nil, m.getErr + } + for _, item := range m.scripts { + if item.ID == scriptID { + record := item + return &record, nil + } + } + return nil, os.ErrNotExist +} + +func (m *mockAutomationStarter) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) (*automation.ScriptRunRecord, error) { + m.lastRunRequest = input + if m.runErr != nil { + return nil, m.runErr + } + if m.runResult == nil { + return &automation.ScriptRunRecord{ + ID: "run-default", + ScriptID: input.ScriptID, + Status: "success", + }, nil + } + + record := *m.runResult + return &record, nil +} + +func (m *mockAutomationStarter) AutomationScriptRunList(limit int) ([]automation.ScriptRunRecord, error) { + m.lastRunListLimit = limit + if m.runListErr != nil { + return nil, m.runListErr + } + + items := append([]automation.ScriptRunRecord(nil), m.runs...) + if limit > 0 && len(items) > limit { + items = items[:limit] + } + return items, nil +} + +func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "news-query-txt", + Name: "查询新闻并写 TXT", + Description: "测试脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + Tags: []string{"Playwright", "新闻"}, + SelectorText: `{"code":"BUYER_001"}`, + ParamsText: `{"keyword":"OpenAI","limit":10}`, + ScriptText: `module.exports.run = async () => ({ ok: true })`, + Notes: "note", + CreatedAt: "2026-04-08T10:00:00Z", + UpdatedAt: "2026-04-08T11:00:00Z", + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "scriptText") { + t.Fatalf("公共脚本列表不应返回脚本文本: %s", w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Count int `json:"count"` + Items []struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Selector map[string]interface{} `json:"selector"` + Params map[string]interface{} `json:"params"` + } `json:"items"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Count != 1 || len(resp.Items) != 1 { + t.Fatalf("响应结构错误: %+v", resp) + } + if resp.Items[0].ID != "news-query-txt" || resp.Items[0].Type != "playwright-cdp" || resp.Items[0].Status != "ready" { + t.Fatalf("脚本元数据错误: %+v", resp.Items[0]) + } + if resp.Items[0].Selector["code"] != "BUYER_001" { + t.Fatalf("selector 解析错误: %+v", resp.Items[0].Selector) + } + if resp.Items[0].Params["keyword"] != "OpenAI" { + t.Fatalf("params 解析错误: %+v", resp.Items[0].Params) + } +} + +func TestAutomationScriptDetailEndpointReturnsSingleScript(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + PackageFormat: "ant-automation-script", + ManifestVersion: 1, + ID: "news-query-txt", + Name: "查询新闻并写 TXT", + Description: "测试脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + Tags: []string{"Playwright", "新闻"}, + SelectorText: `{"code":"BUYER_001"}`, + ParamsText: `{"keyword":"OpenAI","limit":10}`, + ScriptText: `module.exports.run = async () => ({ ok: true })`, + Notes: "note", + Source: automation.ScriptSource{ + Type: "git", + URI: "https://example.com/repo.git", + Ref: "main", + }, + CreatedAt: "2026-04-08T10:00:00Z", + UpdatedAt: "2026-04-08T11:00:00Z", + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/news-query-txt", nil) + 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.lastGetID != "news-query-txt" { + t.Fatalf("scriptId 路径解析错误: %s", starter.lastGetID) + } + if strings.Contains(w.Body.String(), "scriptText") { + t.Fatalf("公共脚本详情不应返回脚本文本: %s", w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Item struct { + ID string `json:"id"` + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + Source automation.ScriptSource `json:"source"` + Selector map[string]interface{} `json:"selector"` + } `json:"item"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Item.ID != "news-query-txt" { + t.Fatalf("详情响应错误: %+v", resp) + } + if resp.Item.PackageFormat != "ant-automation-script" || resp.Item.ManifestVersion != 1 { + t.Fatalf("详情元数据错误: %+v", resp.Item) + } + if resp.Item.Source.Type != "git" || resp.Item.Source.URI != "https://example.com/repo.git" { + t.Fatalf("source 返回错误: %+v", resp.Item.Source) + } + if resp.Item.Selector["code"] != "BUYER_001" { + t.Fatalf("selector 解析错误: %+v", resp.Item.Selector) + } +} + +func TestAutomationScriptDetailEndpointReturnsNotFound(t *testing.T) { + handler := buildTestHandlerWithManager(newInMemoryService(), newMockAutomationStarter(), nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/missing-script", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "script not found") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } +} + +func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-1", + ScriptID: "news-query-txt", + Status: "success", + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ + "scriptId":"news-query-txt", + "selector":{"code":"BUYER_001"}, + "params":{"keyword":"OpenAI"} + }`)) + 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.lastRunRequest.ScriptID != "news-query-txt" { + t.Fatalf("scriptId 传递错误: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { + t.Fatalf("对象参数应关闭脚本默认 selector/params: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` { + t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText) + } + if starter.lastRunRequest.ParamsText != `{"keyword":"OpenAI"}` { + t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) + } + + var resp struct { + OK bool `json:"ok"` + Run struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"run"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Run.ID != "run-1" || resp.Run.Status != "success" { + t.Fatalf("run 响应错误: %+v", resp) + } +} + +func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{"scriptId":"news-query-txt"}`)) + 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.lastRunRequest.UseScriptSelector || !starter.lastRunRequest.UseScriptParams { + t.Fatalf("缺省时应回退到脚本默认 selector/params: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.SelectorText != "" || starter.lastRunRequest.ParamsText != "" { + t.Fatalf("缺省时不应透传 selectorText/paramsText: %+v", starter.lastRunRequest) + } +} + +func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.runs = []automation.ScriptRunRecord{ + {ID: "run-1", ScriptID: "script-a", Status: "success"}, + {ID: "run-2", ScriptID: "script-b", Status: "failed"}, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil) + 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.lastRunListLimit != 1 { + t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit) + } + + var resp struct { + OK bool `json:"ok"` + Count int `json:"count"` + Items []automation.ScriptRunRecord `json:"items"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Count != 1 || len(resp.Items) != 1 { + t.Fatalf("runs 响应错误: %+v", resp) + } +} + +func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) { + handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) + } +} + +func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + handler := buildTestHandlerWithManager(svc, starter, nil) + + t.Run("invalid-json", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + }) + + t.Run("selector-must-be-object", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ + "scriptId":"news-query-txt", + "selector":"BUYER_001" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "selector must be a JSON object") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } + }) +} diff --git a/backend/test/launchcode/server_profile_create_test.go b/backend/test/launchcode/server_profile_create_test.go index a68f82fb..9c84875f 100644 --- a/backend/test/launchcode/server_profile_create_test.go +++ b/backend/test/launchcode/server_profile_create_test.go @@ -212,6 +212,31 @@ func TestCreateProfileAPIRejectsMissingProfile(t *testing.T) { } } +func TestCreateProfileAPIRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { + cfg.Browser.Proxies = []config.BrowserProxy{ + {ProxyId: "proxy-us", ProxyName: "US Residential", ProxyConfig: "socks5://127.0.0.1:1080"}, + } + }) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + req := httptest.NewRequest(http.MethodPost, "/api/profiles", bytes.NewBufferString(`{ + "profile": { + "profileName": "buyer-003", + "proxyId": "missing-proxy-id" + } + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } +} + func TestCreateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) { svc := newInMemoryService() mgr := newProfileCreateTestManager(t, nil) diff --git a/backend/test/launchcode/server_profile_manage_test.go b/backend/test/launchcode/server_profile_manage_test.go index 65da3a9e..96ebab5a 100644 --- a/backend/test/launchcode/server_profile_manage_test.go +++ b/backend/test/launchcode/server_profile_manage_test.go @@ -197,6 +197,47 @@ func TestUpdateProfileAPIUpdatesFieldsAndAutoLaunches(t *testing.T) { } } +func TestUpdateProfileAPIRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { + cfg.Browser.Proxies = []config.BrowserProxy{ + {ProxyId: "proxy-us", ProxyName: "US Residential", ProxyConfig: "socks5://127.0.0.1:1080"}, + } + }) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + profile, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-old", + ProxyId: "proxy-us", + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+profile.ProfileId, bytes.NewBufferString(`{ + "profile": { + "profileName": "buyer-new", + "proxyId": "missing-proxy-id" + } + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + + current, status, errMsg := handlerProfileSnapshot(t, mgr, svc, profile.ProfileId) + if errMsg != "" || status != http.StatusOK { + t.Fatalf("读取实例失败: status=%d err=%s", status, errMsg) + } + if current.ProfileName != "buyer-old" || current.ProxyId != "proxy-us" || current.ProxyConfig != "socks5://127.0.0.1:1080" { + t.Fatalf("失败请求不应污染原配置: %+v", current) + } +} + func TestUpdateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) { svc := newInMemoryService() mgr := newProfileCreateTestManager(t, nil) diff --git a/backend/test/launchcode/server_profile_runtime_test.go b/backend/test/launchcode/server_profile_runtime_test.go new file mode 100644 index 00000000..515b8989 --- /dev/null +++ b/backend/test/launchcode/server_profile_runtime_test.go @@ -0,0 +1,312 @@ +package launchcode_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "ant-chrome/backend/internal/browser" +) + +type lifecycleStarter struct { + mgr *browser.Manager + started []string + stopped []string +} + +func newLifecycleStarter(mgr *browser.Manager) *lifecycleStarter { + return &lifecycleStarter{mgr: mgr} +} + +func (m *lifecycleStarter) StartInstance(profileID string) (*browser.Profile, error) { + m.mgr.Mutex.Lock() + defer m.mgr.Mutex.Unlock() + + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, fmt.Errorf("profile not found") + } + + m.started = append(m.started, profileID) + profile.Running = true + profile.DebugReady = true + profile.Pid = 7000 + len(m.started) + profile.DebugPort = 9600 + len(m.started) + profile.RuntimeWarning = "" + profile.LastError = "" + profile.LastStartAt = time.Now().Format(time.RFC3339) + return profile, nil +} + +func (m *lifecycleStarter) StatusInstance(profileID string) (*browser.Profile, error) { + m.mgr.Mutex.Lock() + defer m.mgr.Mutex.Unlock() + + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, fmt.Errorf("profile not found") + } + return profile, nil +} + +func (m *lifecycleStarter) StopInstance(profileID string) (*browser.Profile, error) { + m.mgr.Mutex.Lock() + defer m.mgr.Mutex.Unlock() + + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, fmt.Errorf("profile not found") + } + + m.stopped = append(m.stopped, profileID) + profile.Running = false + profile.DebugReady = false + profile.Pid = 0 + profile.DebugPort = 0 + profile.RuntimeWarning = "" + profile.LastStopAt = time.Now().Format(time.RFC3339) + return profile, nil +} + +func TestProfileStatusEndpointReturnsRuntimePayload(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "profile-runtime-status", + ProfileName: "Runtime Status", + } + manager := newSelectorTestManager(profile) + starter := newLifecycleStarter(manager) + + code, err := svc.SetCode(profile.ProfileId, "runtime_status") + if err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + + handler := buildTestHandlerWithManager(svc, starter, manager) + + reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + wLaunch := httptest.NewRecorder() + handler.ServeHTTP(wLaunch, reqLaunch) + if wLaunch.Code != http.StatusOK { + t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String()) + } + + reqStatus := httptest.NewRequest(http.MethodGet, "/api/profiles/"+profile.ProfileId+"/status", nil) + wStatus := httptest.NewRecorder() + handler.ServeHTTP(wStatus, reqStatus) + + if wStatus.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", wStatus.Code, wStatus.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` + Running bool `json:"running"` + Active bool `json:"active"` + DebugReady bool `json:"debugReady"` + CDPURL string `json:"cdpUrl"` + DirectDebugURL string `json:"directDebugUrl"` + Profile *browser.Profile `json:"profile"` + } + if err := json.NewDecoder(wStatus.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + + if !resp.OK || resp.ProfileID != profile.ProfileId { + t.Fatalf("响应不正确: %+v", resp) + } + if resp.LaunchCode != "RUNTIME_STATUS" { + t.Fatalf("launchCode 不正确: %+v", resp) + } + if !resp.Running || !resp.Active || !resp.DebugReady { + t.Fatalf("运行态字段不正确: %+v", resp) + } + if resp.CDPURL == "" || resp.DirectDebugURL == "" { + t.Fatalf("应返回可连接的 CDP 信息: %+v", resp) + } + if resp.Profile == nil || !resp.Profile.Running || !resp.Profile.DebugReady { + t.Fatalf("嵌套 profile 运行态不正确: %+v", resp) + } +} + +func TestRuntimeActiveEndpointReportsCurrentTarget(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "profile-runtime-active", + ProfileName: "Runtime Active", + } + manager := newSelectorTestManager(profile) + starter := newLifecycleStarter(manager) + + code, err := svc.SetCode(profile.ProfileId, "runtime_active") + if err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + + handler := buildTestHandlerWithManager(svc, starter, manager) + + reqBefore := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil) + wBefore := httptest.NewRecorder() + handler.ServeHTTP(wBefore, reqBefore) + if wBefore.Code != http.StatusOK { + t.Fatalf("未激活前查询失败: status=%d body=%s", wBefore.Code, wBefore.Body.String()) + } + + var before struct { + OK bool `json:"ok"` + Active bool `json:"active"` + } + if err := json.NewDecoder(wBefore.Body).Decode(&before); err != nil { + t.Fatalf("解析未激活响应失败: %v", err) + } + if !before.OK || before.Active { + t.Fatalf("未激活响应不正确: %+v", before) + } + + reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + wLaunch := httptest.NewRecorder() + handler.ServeHTTP(wLaunch, reqLaunch) + if wLaunch.Code != http.StatusOK { + t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String()) + } + + reqAfter := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil) + wAfter := httptest.NewRecorder() + handler.ServeHTTP(wAfter, reqAfter) + if wAfter.Code != http.StatusOK { + t.Fatalf("激活后查询失败: status=%d body=%s", wAfter.Code, wAfter.Body.String()) + } + + var after struct { + OK bool `json:"ok"` + Active bool `json:"active"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` + CDPURL string `json:"cdpUrl"` + } + if err := json.NewDecoder(wAfter.Body).Decode(&after); err != nil { + t.Fatalf("解析激活响应失败: %v", err) + } + if !after.OK || !after.Active || after.ProfileID != profile.ProfileId { + t.Fatalf("激活响应不正确: %+v", after) + } + if after.LaunchCode != "RUNTIME_ACTIVE" || after.CDPURL == "" { + t.Fatalf("激活响应缺少 launchCode/CDP 地址: %+v", after) + } +} + +func TestProfileStopEndpointStopsAndClearsActiveTarget(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "profile-runtime-stop", + ProfileName: "Runtime Stop", + } + manager := newSelectorTestManager(profile) + starter := newLifecycleStarter(manager) + + code, err := svc.SetCode(profile.ProfileId, "runtime_stop") + if err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + + handler := buildTestHandlerWithManager(svc, starter, manager) + + reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + wLaunch := httptest.NewRecorder() + handler.ServeHTTP(wLaunch, reqLaunch) + if wLaunch.Code != http.StatusOK { + t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String()) + } + + reqStop := httptest.NewRequest(http.MethodPost, "/api/profiles/"+profile.ProfileId+"/stop", nil) + wStop := httptest.NewRecorder() + handler.ServeHTTP(wStop, reqStop) + + if wStop.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", wStop.Code, wStop.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Stopped bool `json:"stopped"` + Running bool `json:"running"` + Active bool `json:"active"` + CDPURL string `json:"cdpUrl"` + DirectDebugURL string `json:"directDebugUrl"` + Profile *browser.Profile `json:"profile"` + } + if err := json.NewDecoder(wStop.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + + if !resp.OK || !resp.Stopped { + t.Fatalf("停止响应不正确: %+v", resp) + } + if resp.Running || resp.Active { + t.Fatalf("停止后运行态应关闭: %+v", resp) + } + if resp.CDPURL != "" || resp.DirectDebugURL != "" { + t.Fatalf("停止后不应再暴露调试地址: %+v", resp) + } + if resp.Profile == nil || resp.Profile.Running || resp.Profile.DebugReady { + t.Fatalf("嵌套 profile 停止态不正确: %+v", resp) + } + + reqProxy := httptest.NewRequest(http.MethodGet, "/json/version", nil) + wProxy := httptest.NewRecorder() + handler.ServeHTTP(wProxy, reqProxy) + if wProxy.Code != http.StatusServiceUnavailable { + t.Fatalf("停止后应清空 active target: status=%d body=%s", wProxy.Code, wProxy.Body.String()) + } + + reqActive := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil) + wActive := httptest.NewRecorder() + handler.ServeHTTP(wActive, reqActive) + if wActive.Code != http.StatusOK { + t.Fatalf("停止后查询 active 失败: status=%d body=%s", wActive.Code, wActive.Body.String()) + } + + var activeResp struct { + OK bool `json:"ok"` + Active bool `json:"active"` + } + if err := json.NewDecoder(wActive.Body).Decode(&activeResp); err != nil { + t.Fatalf("解析停止后 active 响应失败: %v", err) + } + if !activeResp.OK || activeResp.Active { + t.Fatalf("停止后 active 响应不正确: %+v", activeResp) + } +} + +func TestProfileStopEndpointReturnsServiceUnavailableWhenRuntimeControlIsMissing(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "profile-runtime-unsupported", + ProfileName: "Runtime Unsupported", + } + manager := newSelectorTestManager(profile) + starter := newMockStarterWithParams() + starter.addProfile(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/profiles/"+profile.ProfileId+"/stop", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if resp["error"] != "profile runtime control is not available" { + t.Fatalf("错误信息不正确: %+v", resp) + } +} diff --git a/backend/test/launchcode/server_runtime_selector_test.go b/backend/test/launchcode/server_runtime_selector_test.go new file mode 100644 index 00000000..001fc7a2 --- /dev/null +++ b/backend/test/launchcode/server_runtime_selector_test.go @@ -0,0 +1,168 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/browser" +) + +func TestRuntimeStatusWithCodeFallbackReturnsConflictByDefault(t *testing.T) { + svc := newInMemoryService() + profileA := &browser.Profile{ + ProfileId: "runtime-status-a", + ProfileName: "A Account", + Keywords: []string{"shop"}, + Running: true, + DebugReady: true, + DebugPort: 9411, + } + profileB := &browser.Profile{ + ProfileId: "runtime-status-b", + ProfileName: "B Account", + Keywords: []string{"shop"}, + Running: true, + DebugReady: true, + DebugPort: 9412, + } + manager := newSelectorTestManager(profileA, profileB) + starter := newLifecycleStarter(manager) + handler := buildTestHandlerWithManager(svc, starter, manager) + + req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"code":"shop"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "matchMode=first") { + t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String()) + } +} + +func TestRuntimeStatusWithMatchModeFirstReturnsStableTarget(t *testing.T) { + svc := newInMemoryService() + profileB := &browser.Profile{ + ProfileId: "runtime-status-b", + ProfileName: "B Account", + Keywords: []string{"shop"}, + Running: true, + DebugReady: true, + DebugPort: 9412, + Pid: 3002, + } + profileA := &browser.Profile{ + ProfileId: "runtime-status-a", + ProfileName: "A Account", + Keywords: []string{"shop"}, + Running: true, + DebugReady: true, + DebugPort: 9411, + Pid: 3001, + } + manager := newSelectorTestManager(profileB, profileA) + starter := newLifecycleStarter(manager) + handler := buildTestHandlerWithManager(svc, starter, manager) + + req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"code":"shop","matchMode":"first"}`)) + 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()) + } + + var resp struct { + OK bool `json:"ok"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Running bool `json:"running"` + DebugReady bool `json:"debugReady"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + + if !resp.OK || resp.ProfileID != profileA.ProfileId || resp.ProfileName != profileA.ProfileName { + t.Fatalf("响应不正确: %+v", resp) + } + if !resp.Running || !resp.DebugReady { + t.Fatalf("运行态字段不正确: %+v", resp) + } +} + +func TestRuntimeStopWithExactLaunchCode(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "runtime-stop-code", + ProfileName: "Runtime Stop By Code", + Running: true, + DebugReady: true, + DebugPort: 9511, + Pid: 4001, + } + manager := newSelectorTestManager(profile) + starter := newLifecycleStarter(manager) + + if _, err := svc.SetCode(profile.ProfileId, "runtime-stop-code"); err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/runtime/stop", bytes.NewBufferString(`{"code":"runtime-stop-code"}`)) + 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()) + } + + var resp struct { + OK bool `json:"ok"` + Stopped bool `json:"stopped"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` + Running bool `json:"running"` + DebugReady bool `json:"debugReady"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + + if !resp.OK || !resp.Stopped || resp.ProfileID != profile.ProfileId { + t.Fatalf("停止响应不正确: %+v", resp) + } + if resp.LaunchCode != "RUNTIME-STOP-CODE" { + t.Fatalf("launchCode 不正确: %+v", resp) + } + if resp.Running || resp.DebugReady { + t.Fatalf("停止后运行态不正确: %+v", resp) + } +} + +func TestRuntimeStatusRejectsMatchModeAll(t *testing.T) { + svc := newInMemoryService() + manager := newSelectorTestManager() + starter := newLifecycleStarter(manager) + handler := buildTestHandlerWithManager(svc, starter, manager) + + req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"keyword":"shop","matchMode":"all"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "matchMode must be unique or first") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } +} diff --git a/backend/test/launchcode/server_runtime_session_test.go b/backend/test/launchcode/server_runtime_session_test.go new file mode 100644 index 00000000..7493a71a --- /dev/null +++ b/backend/test/launchcode/server_runtime_session_test.go @@ -0,0 +1,220 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/launchcode" +) + +type sessionStarter struct { + mgr *browser.Manager + waitReady bool + waitErr error + lastParams launchcode.LaunchRequestParams +} + +func newSessionStarter(mgr *browser.Manager, waitReady bool) *sessionStarter { + return &sessionStarter{ + mgr: mgr, + waitReady: waitReady, + } +} + +func (m *sessionStarter) StartInstance(profileID string) (*browser.Profile, error) { + return m.StartInstanceWithParams(profileID, launchcode.LaunchRequestParams{}) +} + +func (m *sessionStarter) StartInstanceWithParams(profileID string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { + m.lastParams = params + m.mgr.Mutex.Lock() + defer m.mgr.Mutex.Unlock() + + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, fmt.Errorf("profile not found") + } + + profile.Running = true + profile.DebugReady = false + profile.DebugPort = 9666 + profile.Pid = 4321 + profile.RuntimeWarning = "debug pending" + profile.LastError = "" + return profile, nil +} + +func (m *sessionStarter) StatusInstance(profileID string) (*browser.Profile, error) { + m.mgr.Mutex.Lock() + defer m.mgr.Mutex.Unlock() + + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, fmt.Errorf("profile not found") + } + return profile, nil +} + +func (m *sessionStarter) WaitInstanceDebugReady(profileID string, debugPort int, timeout time.Duration) (*browser.Profile, bool, error) { + if m.waitErr != nil { + return nil, false, m.waitErr + } + + m.mgr.Mutex.Lock() + defer m.mgr.Mutex.Unlock() + + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, false, fmt.Errorf("profile not found") + } + + if m.waitReady { + profile.Running = true + profile.DebugReady = true + profile.DebugPort = debugPort + profile.RuntimeWarning = "" + return profile, true, nil + } + + return profile, false, nil +} + +func TestRuntimeSessionWaitsUntilDebugReady(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "runtime-session-ready", + ProfileName: "Runtime Session Ready", + } + manager := newSelectorTestManager(profile) + starter := newSessionStarter(manager, true) + + if _, err := svc.SetCode(profile.ProfileId, "runtime-session-ready"); err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{ + "code":"runtime-session-ready", + "timeoutMs":5000, + "launchArgs":["--window-size=1400,900"], + "startUrls":["https://example.com"], + "skipDefaultStartUrls":true + }`)) + 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 len(starter.lastParams.LaunchArgs) != 1 || starter.lastParams.LaunchArgs[0] != "--window-size=1400,900" { + t.Fatalf("launchArgs 透传错误: %+v", starter.lastParams) + } + if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { + t.Fatalf("startUrls 透传错误: %+v", starter.lastParams) + } + if !starter.lastParams.SkipDefaultStartURLs { + t.Fatalf("skipDefaultStartUrls 透传错误: %+v", starter.lastParams) + } + + var resp struct { + OK bool `json:"ok"` + Ready bool `json:"ready"` + WaitTimedOut bool `json:"waitTimedOut"` + Retryable bool `json:"retryable"` + Active bool `json:"active"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` + DebugReady bool `json:"debugReady"` + CDPURL string `json:"cdpUrl"` + DirectDebugURL string `json:"directDebugUrl"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || !resp.Ready || resp.WaitTimedOut || resp.Retryable { + t.Fatalf("ready 响应不正确: %+v", resp) + } + if !resp.Active || !resp.DebugReady || resp.ProfileID != profile.ProfileId { + t.Fatalf("会话状态不正确: %+v", resp) + } + if resp.LaunchCode != "RUNTIME-SESSION-READY" { + t.Fatalf("launchCode 不正确: %+v", resp) + } + if resp.CDPURL == "" || resp.DirectDebugURL == "" { + t.Fatalf("应返回可接管地址: %+v", resp) + } +} + +func TestRuntimeSessionReturnsAcceptedWhileDebugIsPending(t *testing.T) { + svc := newInMemoryService() + profile := &browser.Profile{ + ProfileId: "runtime-session-pending", + ProfileName: "Runtime Session Pending", + } + manager := newSelectorTestManager(profile) + starter := newSessionStarter(manager, false) + + if _, err := svc.SetCode(profile.ProfileId, "runtime-session-pending"); err != nil { + t.Fatalf("SetCode 失败: %v", err) + } + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{ + "code":"runtime-session-pending", + "timeoutMs":1000 + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("期望 202,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Ready bool `json:"ready"` + WaitTimedOut bool `json:"waitTimedOut"` + Retryable bool `json:"retryable"` + Active bool `json:"active"` + DebugReady bool `json:"debugReady"` + RuntimeWarning string `json:"runtimeWarning"` + CDPURL string `json:"cdpUrl"` + DirectDebugURL string `json:"directDebugUrl"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Ready || !resp.WaitTimedOut || !resp.Retryable { + t.Fatalf("pending 响应不正确: %+v", resp) + } + if resp.Active || resp.DebugReady { + t.Fatalf("pending 会话不应标记为 active/debugReady: %+v", resp) + } + if resp.RuntimeWarning != "debug pending" { + t.Fatalf("runtimeWarning 不正确: %+v", resp) + } + if resp.CDPURL != "" || resp.DirectDebugURL != "" { + t.Fatalf("pending 会话不应返回可接管地址: %+v", resp) + } +} + +func TestRuntimeSessionRejectsMatchModeAll(t *testing.T) { + manager := newSelectorTestManager() + handler := buildTestHandlerWithManager(newInMemoryService(), newSessionStarter(manager, true), manager) + req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{"keyword":"shop","matchMode":"all"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } +} diff --git a/bat/build.ps1 b/bat/build.ps1 index b652dac5..8f2ad34b 100644 --- a/bat/build.ps1 +++ b/bat/build.ps1 @@ -115,7 +115,7 @@ try { } Push-Location (Join-Path $repoRoot "frontend") try { - Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "build") + Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "build:clean") } finally { Pop-Location diff --git a/config.yaml b/config.yaml index b9edb7bc..25a426ef 100644 --- a/config.yaml +++ b/config.yaml @@ -44,10 +44,19 @@ browser: default_launch_args: - --disable-sync - --no-first-run - default_proxy: "" default_bookmarks: [] cores: [] proxies: [] profiles: [] launch_server: port: 19876 +automation: + enabled: false + install_policy: on_demand + runtime_version: node-22.15.1-playwright-core-1.59.0 + headless_default: false + keep_runtime_on_disable: true + node_source: auto + system_node_path: "" + node_version: 22.15.1 + playwright_core_version: 1.59.0 diff --git a/dev.sh b/dev.sh new file mode 100644 index 00000000..0560a0d3 --- /dev/null +++ b/dev.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="${1:-stable}" + +usage() { + cat <<'EOF' +Usage: + ./dev.sh [stable|live|help] + +Modes: + stable Default. Build frontend static assets and start Wails without Vite dev server. + live Start the frontend dev server and connect Wails to it. + help Show this help. +EOF +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "[ERROR] Missing required command: $cmd" >&2 + exit 1 + fi +} + +is_tcp_port_busy() { + local port="$1" + ss -ltn "( sport = :$port )" | tail -n +2 | grep -q . +} + +resolve_wails_devserver() { + local start_port="${WAILS_DEVSERVER_PORT:-34115}" + local host="${WAILS_DEVSERVER_HOST:-127.0.0.1}" + local port="$start_port" + + require_cmd ss + + while is_tcp_port_busy "$port"; do + port=$((port + 1)) + done + + WAILS_DEVSERVER_ADDRESS="$host:$port" + export WAILS_DEVSERVER_ADDRESS +} + +prepare_env() { + require_cmd node + require_cmd npm + require_cmd go + require_cmd wails + + if [[ -n "${DEV_PROXY_URL:-}" ]]; then + export HTTP_PROXY="$DEV_PROXY_URL" + export HTTPS_PROXY="$DEV_PROXY_URL" + export http_proxy="$DEV_PROXY_URL" + export https_proxy="$DEV_PROXY_URL" + fi + + if [[ -n "${DEV_NO_PROXY:-}" ]]; then + export NO_PROXY="$DEV_NO_PROXY" + export no_proxy="$DEV_NO_PROXY" + fi + + if [[ -n "${DEV_GOPROXY:-}" ]]; then + export GOPROXY="$DEV_GOPROXY" + elif [[ -z "${GOPROXY:-}" ]]; then + export GOPROXY="https://goproxy.cn,direct" + fi +} + +install_frontend_deps() { + echo "Installing frontend dependencies..." + npm install +} + +build_frontend() { + echo "Building frontend assets..." + npm run build:clean +} + +run_stable() { + echo "========================================" + echo " Ant Chrome - Dev Launcher" + echo "========================================" + echo + echo "Current workdir: $ROOT_DIR" + echo "Mode: stable" + echo "Frontend mode: stable static assets" + echo "Wails frontend dev server: disabled" + echo + + prepare_env + resolve_wails_devserver + cd "$ROOT_DIR/frontend" + install_frontend_deps + build_frontend + + cd "$ROOT_DIR" + echo "Starting Wails dev..." + echo "Wails dev server: http://$WAILS_DEVSERVER_ADDRESS" + exec wails dev -m -nogorebuild -noreload -s -skipbindings -assetdir frontend/dist -devserver "$WAILS_DEVSERVER_ADDRESS" +} + +run_live() { + local frontend_port="${FRONTEND_PORT:-5218}" + local frontend_pid="" + + trap 'if [[ -n "$frontend_pid" ]] && kill -0 "$frontend_pid" >/dev/null 2>&1; then kill "$frontend_pid" >/dev/null 2>&1 || true; fi' EXIT + + echo "========================================" + echo " Ant Chrome - Dev Launcher" + echo "========================================" + echo + echo "Current workdir: $ROOT_DIR" + echo "Mode: live" + echo "Frontend URL: http://127.0.0.1:$frontend_port" + echo + + prepare_env + resolve_wails_devserver + + cd "$ROOT_DIR/frontend" + install_frontend_deps + npm run dev:raw -- --host 127.0.0.1 --port "$frontend_port" & + frontend_pid="$!" + + cd "$ROOT_DIR" + echo "Starting Wails dev..." + echo "Wails dev server: http://$WAILS_DEVSERVER_ADDRESS" + exec wails dev -m -s -skipbindings -frontenddevserverurl "http://127.0.0.1:$frontend_port" -viteservertimeout 60 -devserver "$WAILS_DEVSERVER_ADDRESS" +} + +case "$MODE" in + stable) + run_stable + ;; + live) + run_live + ;; + help|-h|--help) + usage + ;; + *) + echo "[ERROR] Unsupported mode: $MODE" >&2 + echo >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd0b35e0..e12aa14f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ant-browser-frontend", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ant-browser-frontend", - "version": "1.1.0", + "version": "1.2.0", "hasInstallScript": true, "dependencies": { "@types/js-yaml": "^4.0.9", diff --git a/frontend/package.json b/frontend/package.json index 5c1b9e54..664c0153 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,16 @@ { "name": "ant-browser-frontend", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "ensure:native": "node ./scripts/ensure-rollup-native.mjs", "postinstall": "npm run ensure:native", "dev": "node ./scripts/dev-watcher.mjs", "dev:raw": "npm run ensure:native && node --max-old-space-size=256 --max-semi-space-size=16 ./node_modules/vite/bin/vite.js", - "build": "npm run ensure:native && node --max-old-space-size=1024 ./node_modules/typescript/bin/tsc && node --max-old-space-size=1024 ./node_modules/vite/bin/vite.js build", + "build": "npm run build:keep", + "build:keep": "npm run ensure:native && node --max-old-space-size=1024 ./node_modules/typescript/bin/tsc && node --max-old-space-size=1024 ./node_modules/vite/bin/vite.js build", + "build:clean": "cross-env FRONTEND_CLEAN_DIST=1 npm run build:keep", "preview": "npm run ensure:native && node --max-old-space-size=256 --max-semi-space-size=16 ./node_modules/vite/bin/vite.js preview" }, "dependencies": { diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 257f3d9f..ab9424dd 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -7eabda3c0c6240dd458970bfdd19c33c \ No newline at end of file +bf6dd2f2f453474c0fc4b1cf2c98596b \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e92d9fe8..390bd7c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,183 +1,297 @@ -import { Suspense, lazy, useEffect, useState } from 'react' -import type { ComponentType } 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, Loading } from './shared/components' -import { AlertCircle } from 'lucide-react' -import { useNotificationStore } from './store/notificationStore' -import { useBackupStore } from './store/backupStore' -import { ForceQuit as ForceQuitApp, QuitAppOnly as QuitAppOnlyApp } from './wailsjs/go/main/App' -import { Environment, Quit, WindowHide, WindowMinimise } from './wailsjs/runtime/runtime' +import { Suspense, lazy, useEffect, useState } from "react"; +import type { ComponentType } 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, Loading } from "./shared/components"; +import { AlertCircle } from "lucide-react"; +import { useNotificationStore } from "./store/notificationStore"; +import { useBackupStore } from "./store/backupStore"; +import { + ForceQuit as ForceQuitApp, + QuitAppOnly as QuitAppOnlyApp, +} from "./wailsjs/go/main/App"; +import { + Environment, + Quit, + WindowHide, + WindowMinimise, +} from "./wailsjs/runtime/runtime"; + +const CHUNK_RELOAD_COOLDOWN_MS = 10000; +const CHUNK_RELOAD_TS_KEY = "__ant_chunk_reload_ts__"; + +function isDynamicImportFetchError(error: unknown) { + const message = + error instanceof Error ? error.message : String(error ?? ""); + return /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test( + message, + ); +} + +function reloadForStaleChunkOnce() { + if (typeof window === "undefined") { + return false; + } + + const now = Date.now(); + try { + const lastAttempt = Number( + window.sessionStorage.getItem(CHUNK_RELOAD_TS_KEY) || "0", + ); + if (Number.isFinite(lastAttempt) && now - lastAttempt < CHUNK_RELOAD_COOLDOWN_MS) { + return false; + } + window.sessionStorage.setItem(CHUNK_RELOAD_TS_KEY, String(now)); + } catch { + // ignore sessionStorage failures and still try a hard reload + } + + window.location.reload(); + return true; +} function lazyNamed>>( loader: () => Promise, exportName: keyof TModule, ) { return lazy(async () => { - const module = await loader() + let module: TModule; + try { + module = await loader(); + } catch (error) { + if (isDynamicImportFetchError(error) && reloadForStaleChunkOnce()) { + return new Promise(() => {}); + } + throw error; + } return { default: module[exportName] as ComponentType, - } - }) + }; + }); } -const DashboardPage = lazyNamed(() => import('./modules/dashboard/DashboardPage'), 'DashboardPage') -const SettingsPage = lazyNamed(() => import('./modules/settings/SettingsPage'), 'SettingsPage') -const ProfilePage = lazyNamed(() => import('./modules/profile/ProfilePage'), 'ProfilePage') -const AdminKeygenPage = lazyNamed(() => import('./modules/profile/AdminKeygenPage'), 'AdminKeygenPage') -const ChartsPage = lazyNamed(() => import('./modules/charts/ChartsPage'), 'ChartsPage') -const BrowserListPage = lazyNamed(() => import('./modules/browser/pages/BrowserListPage'), 'BrowserListPage') -const BrowserDetailPage = lazyNamed(() => import('./modules/browser/pages/BrowserDetailPage'), 'BrowserDetailPage') -const BrowserEditPage = lazyNamed(() => import('./modules/browser/pages/BrowserEditPage'), 'BrowserEditPage') -const BrowserCopyPage = lazyNamed(() => import('./modules/browser/pages/BrowserCopyPage'), 'BrowserCopyPage') -const BrowserLogsPage = lazyNamed(() => import('./modules/browser/pages/BrowserLogsPage'), 'BrowserLogsPage') -const ProxyPoolPage = lazyNamed(() => import('./modules/browser/pages/ProxyPoolPage'), 'ProxyPoolPage') -const CoreManagementPage = lazyNamed(() => import('./modules/browser/pages/CoreManagementPage'), 'CoreManagementPage') -const BookmarkSettingsPage = lazyNamed(() => import('./modules/browser/pages/BookmarkSettingsPage'), 'BookmarkSettingsPage') -const LaunchApiDocsPage = lazyNamed(() => import('./modules/browser/pages/LaunchApiDocsPage'), 'LaunchApiDocsPage') -const TagManagementPage = lazyNamed(() => import('./modules/browser/pages/TagManagementPage'), 'TagManagementPage') -const AutomationPage = lazyNamed(() => import('./modules/browser/pages/AutomationPage'), 'AutomationPage') -const UsageTutorialPage = lazyNamed(() => import('./modules/browser/pages/UsageTutorialPage'), 'UsageTutorialPage') -const QuickLaunchModal = lazyNamed(() => import('./modules/browser/components/QuickLaunchModal'), 'QuickLaunchModal') +const DashboardPage = lazyNamed( + () => import("./modules/dashboard/DashboardPage"), + "DashboardPage", +); +const SettingsPage = lazyNamed( + () => import("./modules/settings/SettingsPage"), + "SettingsPage", +); +const ProfilePage = lazyNamed( + () => import("./modules/profile/ProfilePage"), + "ProfilePage", +); +const AdminKeygenPage = lazyNamed( + () => import("./modules/profile/AdminKeygenPage"), + "AdminKeygenPage", +); +const ChartsPage = lazyNamed( + () => import("./modules/charts/ChartsPage"), + "ChartsPage", +); +const BrowserListPage = lazyNamed( + () => import("./modules/browser/pages/BrowserListPage"), + "BrowserListPage", +); +const BrowserDetailPage = lazyNamed( + () => import("./modules/browser/pages/BrowserDetailPage"), + "BrowserDetailPage", +); +const BrowserEditPage = lazyNamed( + () => import("./modules/browser/pages/BrowserEditPage"), + "BrowserEditPage", +); +const BrowserCopyPage = lazyNamed( + () => import("./modules/browser/pages/BrowserCopyPage"), + "BrowserCopyPage", +); +const BrowserLogsPage = lazyNamed( + () => import("./modules/browser/pages/BrowserLogsPage"), + "BrowserLogsPage", +); +const ProxyPoolPage = lazyNamed( + () => import("./modules/browser/pages/ProxyPoolPage"), + "ProxyPoolPage", +); +const CoreManagementPage = lazyNamed( + () => import("./modules/browser/pages/CoreManagementPage"), + "CoreManagementPage", +); +const BookmarkSettingsPage = lazyNamed( + () => import("./modules/browser/pages/BookmarkSettingsPage"), + "BookmarkSettingsPage", +); +const LaunchApiDocsPage = lazyNamed( + () => import("./modules/browser/pages/LaunchApiDocsPage"), + "LaunchApiDocsPage", +); +const TagManagementPage = lazyNamed( + () => import("./modules/browser/pages/TagManagementPage"), + "TagManagementPage", +); +const AutomationPage = lazyNamed( + () => import("./modules/browser/pages/AutomationPage"), + "AutomationPage", +); +const AutomationScriptDetailPage = lazyNamed( + () => import("./modules/browser/pages/AutomationScriptDetailPage"), + "AutomationScriptDetailPage", +); +const QuickLaunchModal = lazyNamed( + () => import("./modules/browser/components/QuickLaunchModal"), + "QuickLaunchModal", +); function useWailsNotifications() { - const addNotification = useNotificationStore((s) => s.addNotification) + const addNotification = useNotificationStore((s) => s.addNotification); useEffect(() => { - const runtime = (window as any).runtime - if (!runtime?.EventsOn) return + const runtime = (window as any).runtime; + if (!runtime?.EventsOn) return; const offCrashed = runtime.EventsOn( - 'browser:instance:crashed', + "browser:instance:crashed", (data: { profileId: string; profileName: string; error: string }) => { addNotification({ - type: 'error', - title: '实例异常退出', + type: "error", + title: "实例异常退出", message: `「${data.profileName || data.profileId}」意外崩溃:${data.error}`, - }) - } - ) + }); + }, + ); const offBridgeFailed = runtime.EventsOn( - 'proxy:bridge:failed', + "proxy:bridge:failed", (data: { profileId: string; profileName: string; error: string }) => { addNotification({ - type: 'error', - title: '代理连接失败', + type: "error", + title: "代理连接失败", message: `「${data.profileName || data.profileId}」代理桥接启动失败:${data.error}`, - }) - } - ) + }); + }, + ); const offBridgeDied = runtime.EventsOn( - 'proxy:bridge:died', + "proxy:bridge:died", (data: { key: string; error: string }) => { addNotification({ - type: 'warning', - title: '连接池节点失效', + type: "warning", + title: "连接池节点失效", message: `代理节点 ${data.key} 连接中断,相关实例可能无法访问网络`, - }) - } - ) + }); + }, + ); return () => { - offCrashed?.() - offBridgeFailed?.() - offBridgeDied?.() - } - }, [addNotification]) + offCrashed?.(); + offBridgeFailed?.(); + offBridgeDied?.(); + }; + }, [addNotification]); } function CloseConfirmModal() { - const [open, setOpen] = useState(false) - const [platform, setPlatform] = useState('windows') - const [quittingAction, setQuittingAction] = useState<'app-only' | 'app-and-browser' | null>(null) - const importInProgress = useBackupStore((s) => s.importInProgress) - const importProgress = useBackupStore((s) => s.importProgress) - const importMessage = useBackupStore((s) => s.importMessage) - const supportsTray = platform === 'windows' - const quitting = quittingAction !== null + const [open, setOpen] = useState(false); + const [platform, setPlatform] = useState("windows"); + const [quittingAction, setQuittingAction] = useState< + "app-only" | "app-and-browser" | null + >(null); + const importInProgress = useBackupStore((s) => s.importInProgress); + const importProgress = useBackupStore((s) => s.importProgress); + const importMessage = useBackupStore((s) => s.importMessage); + const supportsTray = platform === "windows"; + const quitting = quittingAction !== null; useEffect(() => { - const runtime = (window as any).runtime - if (!runtime?.EventsOn) return + const runtime = (window as any).runtime; + if (!runtime?.EventsOn) return; - const off = runtime.EventsOn('app:request-close', () => { - setQuittingAction(null) - setOpen(true) - }) + const off = runtime.EventsOn("app:request-close", () => { + setQuittingAction(null); + setOpen(true); + }); return () => { - if (typeof off === 'function') off() - } - }, []) + if (typeof off === "function") off(); + }; + }, []); useEffect(() => { - let cancelled = false + let cancelled = false; Environment() .then((info) => { if (!cancelled && info?.platform) { - setPlatform(info.platform) + setPlatform(info.platform); } }) - .catch(() => {}) + .catch(() => {}); return () => { - cancelled = true - } - }, []) + cancelled = true; + }; + }, []); const closeModal = () => { - if (quitting) return - setOpen(false) - } + if (quitting) return; + setOpen(false); + }; const handleMinimize = () => { - if (quitting) return - setOpen(false) + if (quitting) return; + setOpen(false); if (supportsTray) { - WindowHide() - return + WindowHide(); + return; } - WindowMinimise() - } + WindowMinimise(); + }; const handleQuitAppOnly = async () => { - setQuittingAction('app-only') + setQuittingAction("app-only"); try { - await QuitAppOnlyApp() + await QuitAppOnlyApp(); } catch (error) { - console.error('QuitAppOnly failed', error) - setQuittingAction(null) + console.error("QuitAppOnly failed", error); + setQuittingAction(null); } - } + }; const handleQuitAppAndBrowsers = async () => { - setQuittingAction('app-and-browser') + setQuittingAction("app-and-browser"); try { await Promise.race([ ForceQuitApp(), new Promise((resolve) => setTimeout(resolve, 1200)), - ]) + ]); } catch (error) { - console.error('ForceQuit failed, falling back to runtime.Quit()', error) + console.error("ForceQuit failed, falling back to runtime.Quit()", error); } - Quit() - } + Quit(); + }; return (
-
+
{importInProgress && ( @@ -188,9 +302,9 @@ function CloseConfirmModal() { {importInProgress ? (

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

) : (

@@ -198,17 +312,24 @@ function CloseConfirmModal() {

)} -
+
{importInProgress ? ( <> - @@ -221,12 +342,12 @@ function CloseConfirmModal() { onClick={supportsTray ? handleMinimize : closeModal} disabled={quitting} > - {supportsTray ? '最小化到托盘' : '取消'} + {supportsTray ? "最小化到托盘" : "取消"}
- ) + ); } function App() { - useWailsNotifications() - const [quickLaunchOpen, setQuickLaunchOpen] = useState(false) + useWailsNotifications(); + const [quickLaunchOpen, setQuickLaunchOpen] = useState(false); const routeFallback = (
- ) + ); 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) - } + 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) + window.addEventListener("keydown", onKeyDown); return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, []) + window.removeEventListener("keydown", onKeyDown); + }; + }, []); return ( @@ -284,18 +405,41 @@ function App() { } /> } /> } /> - } /> + } + /> } /> } /> - } /> + } + /> } /> } /> } /> - } /> + } + /> } /> - } /> + } + /> + } + /> + } + /> } /> - } /> + } + /> @@ -303,12 +447,15 @@ function App() { {quickLaunchOpen ? ( - setQuickLaunchOpen(false)} /> + setQuickLaunchOpen(false)} + /> ) : null} - ) + ); } -export default App +export default App; diff --git a/frontend/src/config/project.config.ts b/frontend/src/config/project.config.ts index 82e24a38..49a044a1 100644 --- a/frontend/src/config/project.config.ts +++ b/frontend/src/config/project.config.ts @@ -35,7 +35,7 @@ export const navigationConfig: NavSection[] = [ title: '指纹浏览器', items: [ { name: '实例列表', path: '/browser/list', icon: 'Monitor' }, - { name: '自动化接口(实验)', path: '/browser/automation', icon: 'Bot' }, + { 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' }, @@ -46,9 +46,8 @@ export const navigationConfig: NavSection[] = [ title: '系统维护', items: [ { name: '系统设置', path: '/settings', icon: 'Settings' }, - { name: '使用教程', path: '/system/tutorial', icon: 'BookOpen' }, + { name: '文档中心', path: '/system/docs', icon: 'BookOpen' }, { name: '日志查看', path: '/browser/logs', icon: 'FileText' }, - { name: '接口文档', path: '/browser/launch-api', icon: 'BookOpen' }, ] }, ] diff --git a/frontend/src/modules/browser/api.ts b/frontend/src/modules/browser/api.ts index 6af40dab..76a613c6 100644 --- a/frontend/src/modules/browser/api.ts +++ b/frontend/src/modules/browser/api.ts @@ -1,801 +1,12 @@ -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, - debugReady: false, - pid: 0, - runtimeWarning: '', - 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, - debugReady: false, - pid: 0, - runtimeWarning: '', - 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, - debugReady: false, - runtimeWarning: '', - 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, debugReady: true, pid: Math.floor(Math.random() * 100000), runtimeWarning: '', 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, debugReady: false, debugPort: 0, pid: 0, runtimeWarning: '', 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: '', startReadyTimeoutMs: 3000, startStableWindowMs: 1200 } - } - return { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '', startReadyTimeoutMs: 3000, startStableWindowMs: 1200 } -} - -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 - cdpUrl: string - activeDebugPort: number - ready: boolean - apiAuth: { - requested: boolean - configured: boolean - enabled: boolean - header: string - } -} - -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}` : '')) - const cdpUrl = String(payload?.cdpUrl || baseUrl) - const activeDebugPort = Number(payload?.activeDebugPort) || 0 - const apiAuthPayload = payload?.apiAuth || {} - const apiAuth = { - requested: !!apiAuthPayload?.requested, - configured: !!apiAuthPayload?.configured, - enabled: !!apiAuthPayload?.enabled, - header: String(apiAuthPayload?.header || 'X-Ant-Api-Key'), - } - - return { - host, - port: effectivePort, - preferredPort, - baseUrl, - cdpUrl, - activeDebugPort, - ready: !!payload?.ready && port > 0, - apiAuth, - } -} - -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', - cdpUrl: 'http://127.0.0.1:19876', - activeDebugPort: 0, - ready: false, - apiAuth: { - requested: false, - configured: false, - enabled: false, - header: 'X-Ant-Api-Key', - }, - } -} - -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 -} +export * from './api/profiles' +export * from './api/instances' +export * from './api/settings' +export * from './api/cores' +export * from './api/proxies' +export * from './api/cookies' +export * from './api/snapshots' +export * from './api/bookmarks' +export * from './api/groups' +export * from './api/launch' +export * from './api/automationDemo' +export * from './api/filesystem' diff --git a/frontend/src/modules/browser/api/automationDemo.ts b/frontend/src/modules/browser/api/automationDemo.ts new file mode 100644 index 00000000..1774163b --- /dev/null +++ b/frontend/src/modules/browser/api/automationDemo.ts @@ -0,0 +1,316 @@ +import { createBrowserProfile, deleteBrowserProfile } from './profiles' +import { fetchLaunchServerInfo } from './launch' +import { getBindings, getGoApp, getMockProfiles, nowISOString } from './runtime' +import { startBrowserInstance, stopBrowserInstance } from './instances' + +export interface AutomationDemoResult { + ok: boolean + status: number + method: string + path: string + baseUrl: string + requestedAt: string + error: string + requestedCode: string + profileId: string + profileName: string + launchCode: string + cdpUrl: string + debugPort: number + created: boolean + launched: boolean + deleted: boolean + stoppedBeforeDelete: boolean + stopError: string + authHeader: string + response: Record +} + +export interface AutomationDemoCreateOptions { + profileName?: string + launchCode?: string + startUrl?: string + launchArgs?: string[] + skipDefaultStartUrls?: boolean + autoLaunch?: boolean +} + +function normalizeAutomationDemoResult(raw: any, fallback: Partial = {}): AutomationDemoResult { + const response = + raw?.response && typeof raw.response === 'object' && !Array.isArray(raw.response) + ? (raw.response as Record) + : (fallback.response || {}) + + const status = Number(raw?.status ?? fallback.status ?? 200) || 0 + const ok = + raw?.ok !== undefined + ? !!raw.ok + : (fallback.ok !== undefined ? !!fallback.ok : status >= 200 && status < 300 && response.ok !== false) + + return { + ok, + status, + method: String(raw?.method || fallback.method || 'GET'), + path: String(raw?.path || fallback.path || ''), + baseUrl: String(raw?.baseUrl || fallback.baseUrl || ''), + requestedAt: String(raw?.requestedAt || fallback.requestedAt || nowISOString()), + error: String(raw?.error || fallback.error || ''), + requestedCode: String(raw?.requestedCode || fallback.requestedCode || ''), + profileId: String(raw?.profileId || fallback.profileId || ''), + profileName: String(raw?.profileName || fallback.profileName || ''), + launchCode: String(raw?.launchCode || fallback.launchCode || ''), + cdpUrl: String(raw?.cdpUrl || fallback.cdpUrl || ''), + debugPort: Number(raw?.debugPort ?? fallback.debugPort ?? 0) || 0, + created: raw?.created !== undefined ? !!raw.created : !!fallback.created, + launched: raw?.launched !== undefined ? !!raw.launched : !!fallback.launched, + deleted: raw?.deleted !== undefined ? !!raw.deleted : !!fallback.deleted, + stoppedBeforeDelete: raw?.stoppedBeforeDelete !== undefined ? !!raw.stoppedBeforeDelete : !!fallback.stoppedBeforeDelete, + stopError: String(raw?.stopError || fallback.stopError || ''), + authHeader: String(raw?.authHeader || fallback.authHeader || ''), + response, + } +} + +async function callAutomationDemoBinding(methodName: string, args: any[] = []): Promise { + const bindings: any = await getBindings() + if (typeof bindings?.[methodName] === 'function') { + return await bindings[methodName](...args) + } + + const goApp = getGoApp() + if (typeof goApp?.[methodName] === 'function') { + return await goApp[methodName](...args) + } + + return null +} + +function nextMockDemoCode(): string { + const token = Date.now().toString(36).replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-6).padStart(6, '0') + return `DEMO_${token}` +} + +function normalizeAutomationDemoCreateOptions(input: AutomationDemoCreateOptions = {}): AutomationDemoCreateOptions { + const launchArgs = Array.isArray(input.launchArgs) + ? input.launchArgs + .map((item) => String(item || '').trim()) + .filter(Boolean) + : [] + + return { + profileName: String(input.profileName || '').trim(), + launchCode: String(input.launchCode || '').trim().toUpperCase(), + startUrl: String(input.startUrl || '').trim(), + launchArgs, + skipDefaultStartUrls: input.skipDefaultStartUrls === true, + autoLaunch: input.autoLaunch === true, + } +} + +export async function automationDemoHealthCheck(): Promise { + const raw = await callAutomationDemoBinding('AutomationDemoHealthCheck') + if (raw !== null) { + return normalizeAutomationDemoResult(raw, { + method: 'GET', + path: '/api/health', + response: { ok: true }, + }) + } + + const info = await fetchLaunchServerInfo() + return normalizeAutomationDemoResult({ + ok: true, + status: 200, + method: 'GET', + path: '/api/health', + baseUrl: info.baseUrl, + response: { ok: true }, + }) +} + +export async function automationDemoCreateProfile(options: AutomationDemoCreateOptions = {}): Promise { + const normalizedOptions = normalizeAutomationDemoCreateOptions(options) + + let raw = null + if ( + normalizedOptions.profileName || + normalizedOptions.launchCode || + normalizedOptions.startUrl || + normalizedOptions.launchArgs?.length || + normalizedOptions.skipDefaultStartUrls || + normalizedOptions.autoLaunch + ) { + raw = await callAutomationDemoBinding('AutomationDemoCreateProfileWithOptions', [JSON.stringify(normalizedOptions)]) + } + if (raw === null) { + raw = await callAutomationDemoBinding('AutomationDemoCreateProfile') + } + if (raw !== null) { + return normalizeAutomationDemoResult(raw, { + method: 'POST', + path: '/api/profiles', + }) + } + + const launchCode = normalizedOptions.launchCode || nextMockDemoCode() + const profileName = normalizedOptions.profileName || `自动化 Demo ${launchCode}` + const profile = await createBrowserProfile({ + profileName, + userDataDir: `automation-demo-${launchCode.toLowerCase()}`, + coreId: '', + fingerprintArgs: [], + proxyId: '', + proxyConfig: '', + launchArgs: normalizedOptions.launchArgs || [], + tags: ['自动化', 'Demo'], + keywords: ['automation-demo', 'launch-api-demo'], + groupId: '', + }) + if (!profile) { + return normalizeAutomationDemoResult({ + ok: false, + status: 500, + method: 'POST', + path: '/api/profiles', + error: 'mock create profile failed', + response: { ok: false, error: 'mock create profile failed' }, + }) + } + + profile.launchCode = launchCode + let debugPort = 0 + let cdpUrl = '' + let launched = false + if (normalizedOptions.autoLaunch) { + const started = await startBrowserInstance(profile.profileId) + debugPort = started?.debugPort || 9222 + cdpUrl = `http://127.0.0.1:${debugPort}` + launched = !!started + } + + return normalizeAutomationDemoResult({ + ok: true, + status: 201, + method: 'POST', + path: '/api/profiles', + profileId: profile.profileId, + profileName, + launchCode, + created: true, + launched, + debugPort, + cdpUrl, + response: { + ok: true, + created: true, + profileId: profile.profileId, + profileName, + launchCode, + launched, + debugPort, + cdpUrl, + profile, + }, + }) +} + +export async function automationDemoLaunchProfile(code: string): Promise { + const normalizedCode = code.trim().toUpperCase() + const raw = await callAutomationDemoBinding('AutomationDemoLaunchProfile', [normalizedCode]) + if (raw !== null) { + return normalizeAutomationDemoResult(raw, { + method: 'POST', + path: '/api/launch', + requestedCode: normalizedCode, + }) + } + + const profile = getMockProfiles().find((item) => (item.launchCode || '').trim().toUpperCase() === normalizedCode) + if (!profile) { + return normalizeAutomationDemoResult({ + ok: false, + status: 404, + method: 'POST', + path: '/api/launch', + requestedCode: normalizedCode, + error: 'launch code not found', + response: { ok: false, error: 'launch code not found' }, + }) + } + + const started = await startBrowserInstance(profile.profileId) + const debugPort = started?.debugPort || 9222 + return normalizeAutomationDemoResult({ + ok: true, + status: 200, + method: 'POST', + path: '/api/launch', + requestedCode: normalizedCode, + profileId: profile.profileId, + profileName: profile.profileName, + launchCode: normalizedCode, + cdpUrl: `http://127.0.0.1:${debugPort}`, + debugPort, + launched: true, + response: { + ok: true, + profileId: profile.profileId, + profileName: profile.profileName, + launchCode: normalizedCode, + cdpUrl: `http://127.0.0.1:${debugPort}`, + debugPort, + launched: true, + }, + }) +} + +export async function automationDemoDeleteProfile(profileId: string): Promise { + const normalizedProfileID = profileId.trim() + const raw = await callAutomationDemoBinding('AutomationDemoDeleteProfile', [normalizedProfileID]) + if (raw !== null) { + return normalizeAutomationDemoResult(raw, { + method: 'DELETE', + path: `/api/profiles/${normalizedProfileID}`, + profileId: normalizedProfileID, + }) + } + + const profile = getMockProfiles().find((item) => item.profileId === normalizedProfileID) + if (!profile) { + return normalizeAutomationDemoResult({ + ok: false, + status: 404, + method: 'DELETE', + path: `/api/profiles/${normalizedProfileID}`, + profileId: normalizedProfileID, + error: 'profile not found', + response: { ok: false, error: 'profile not found' }, + }) + } + + let stoppedBeforeDelete = false + if (profile.running) { + await stopBrowserInstance(normalizedProfileID) + stoppedBeforeDelete = true + } + await deleteBrowserProfile(normalizedProfileID) + + return normalizeAutomationDemoResult({ + ok: true, + status: 200, + method: 'DELETE', + path: `/api/profiles/${normalizedProfileID}`, + profileId: normalizedProfileID, + profileName: profile.profileName, + launchCode: profile.launchCode || '', + deleted: true, + stoppedBeforeDelete, + response: { + ok: true, + deleted: true, + profileId: normalizedProfileID, + profileName: profile.profileName, + launchCode: profile.launchCode || '', + }, + }) +} diff --git a/frontend/src/modules/browser/api/bookmarks.ts b/frontend/src/modules/browser/api/bookmarks.ts new file mode 100644 index 00000000..89a3346a --- /dev/null +++ b/frontend/src/modules/browser/api/bookmarks.ts @@ -0,0 +1,34 @@ +import type { BrowserBookmark } from '../types' +import { getBindings } from './runtime' + +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 +} diff --git a/frontend/src/modules/browser/api/cookies.ts b/frontend/src/modules/browser/api/cookies.ts new file mode 100644 index 00000000..e024e76f --- /dev/null +++ b/frontend/src/modules/browser/api/cookies.ts @@ -0,0 +1,30 @@ +import type { CookieInfo } from '../types' +import { getBindings } from './runtime' + +export async function fetchBrowserCookies(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserGetCookies) { + return (await bindings.BrowserGetCookies(profileId)) || [] + } + 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' +} diff --git a/frontend/src/modules/browser/api/cores.ts b/frontend/src/modules/browser/api/cores.ts new file mode 100644 index 00000000..fffa4553 --- /dev/null +++ b/frontend/src/modules/browser/api/cores.ts @@ -0,0 +1,90 @@ +import type { BrowserCore, BrowserCoreExtended, BrowserCoreInput, BrowserCoreValidateResult } from '../types' +import { getBindings, getMockCores, setMockCores } from './runtime' + +export async function fetchBrowserCores(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreList) { + return (await bindings.BrowserCoreList()) || [] + } + return getMockCores() +} + +export async function saveBrowserCore(input: BrowserCoreInput): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreSave) { + await bindings.BrowserCoreSave(input) + return true + } + + const nextCores = [...getMockCores()] + const index = nextCores.findIndex((core) => core.coreId === input.coreId) + if (index >= 0) { + nextCores[index] = input + } else { + nextCores.push({ ...input, coreId: input.coreId || `core-${Date.now()}` }) + } + setMockCores(nextCores) + return true +} + +export async function deleteBrowserCore(coreId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserCoreDelete) { + await bindings.BrowserCoreDelete(coreId) + return true + } + setMockCores(getMockCores().filter((core) => core.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 + } + setMockCores(getMockCores().map((core) => ({ ...core, isDefault: core.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 getMockCores() +} + +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 +} + +export async function openCorePath(corePath: string): Promise { + const bindings: any = await getBindings() + if (bindings?.OpenCorePath) { + await bindings.OpenCorePath(corePath) + return true + } + return false +} diff --git a/frontend/src/modules/browser/api/filesystem.ts b/frontend/src/modules/browser/api/filesystem.ts new file mode 100644 index 00000000..a3b24547 --- /dev/null +++ b/frontend/src/modules/browser/api/filesystem.ts @@ -0,0 +1,10 @@ +import { getBindings } from './runtime' + +export async function openProjectRoot(): Promise { + const bindings: any = await getBindings() + if (bindings?.OpenProjectRoot) { + await bindings.OpenProjectRoot() + return true + } + return false +} diff --git a/frontend/src/modules/browser/api/groups.ts b/frontend/src/modules/browser/api/groups.ts new file mode 100644 index 00000000..92437930 --- /dev/null +++ b/frontend/src/modules/browser/api/groups.ts @@ -0,0 +1,44 @@ +import type { BrowserGroup, BrowserGroupInput, BrowserGroupWithCount } from '../types' +import { getBindings } from './runtime' + +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/api/instances.ts b/frontend/src/modules/browser/api/instances.ts new file mode 100644 index 00000000..52038b69 --- /dev/null +++ b/frontend/src/modules/browser/api/instances.ts @@ -0,0 +1,91 @@ +import type { BrowserProfile, BrowserTab } from '../types' +import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime' + +export async function startBrowserInstance(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceStart) { + return (await bindings.BrowserInstanceStart(profileId)) || null + } + + const nextProfiles = getMockProfiles().map((item) => + item.profileId === profileId + ? { + ...item, + running: true, + debugPort: 9222, + debugReady: true, + pid: Math.floor(Math.random() * 100000), + runtimeWarning: '', + lastStartAt: nowISOString(), + } + : item, + ) + setMockProfiles(nextProfiles) + return nextProfiles.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 = getMockProfiles().find((item) => (item.launchCode || '').toUpperCase() === normalized) + if (!profile) { + throw new Error('launch code not found') + } + return startBrowserInstance(profile.profileId) +} + +export async function stopBrowserInstance(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceStop) { + return (await bindings.BrowserInstanceStop(profileId)) || null + } + + const nextProfiles = getMockProfiles().map((item) => + item.profileId === profileId + ? { ...item, running: false, debugReady: false, debugPort: 0, pid: 0, runtimeWarning: '', lastStopAt: nowISOString() } + : item, + ) + setMockProfiles(nextProfiles) + return nextProfiles.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 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 }, + ] +} + +export async function openUserDataDir(userDataDir: string): Promise { + const bindings: any = await getBindings() + if (bindings?.OpenUserDataDir) { + await bindings.OpenUserDataDir(userDataDir) + return true + } + return false +} diff --git a/frontend/src/modules/browser/api/launch.ts b/frontend/src/modules/browser/api/launch.ts new file mode 100644 index 00000000..6afd38c8 --- /dev/null +++ b/frontend/src/modules/browser/api/launch.ts @@ -0,0 +1,81 @@ +import { getBindings, getGoApp } from './runtime' + +export interface LaunchServerInfo { + host: string + port: number + preferredPort: number + baseUrl: string + cdpUrl: string + activeDebugPort: number + activeProfileId: string + activeProfileName: string + ready: boolean + apiAuth: { + requested: boolean + configured: boolean + enabled: boolean + header: string + } +} + +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}` : '')) + const cdpUrl = String(payload?.cdpUrl || baseUrl) + const activeDebugPort = Number(payload?.activeDebugPort) || 0 + const activeProfileId = String(payload?.activeProfileId || '') + const activeProfileName = String(payload?.activeProfileName || '') + const apiAuthPayload = payload?.apiAuth || {} + + return { + host, + port: effectivePort, + preferredPort, + baseUrl, + cdpUrl, + activeDebugPort, + activeProfileId, + activeProfileName, + ready: !!payload?.ready && port > 0, + apiAuth: { + requested: !!apiAuthPayload?.requested, + configured: !!apiAuthPayload?.configured, + enabled: !!apiAuthPayload?.enabled, + header: String(apiAuthPayload?.header || 'X-Ant-Api-Key'), + }, + } +} + +export async function fetchLaunchServerInfo(): Promise { + const bindings: any = await getBindings() + if (bindings?.GetLaunchServerInfo) { + return normalizeLaunchServerInfo(await bindings.GetLaunchServerInfo()) + } + + const goApp = getGoApp() + 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', + cdpUrl: 'http://127.0.0.1:19876', + activeDebugPort: 0, + activeProfileId: '', + activeProfileName: '', + ready: false, + apiAuth: { + requested: false, + configured: false, + enabled: false, + header: 'X-Ant-Api-Key', + }, + } +} diff --git a/frontend/src/modules/browser/api/profiles.ts b/frontend/src/modules/browser/api/profiles.ts new file mode 100644 index 00000000..c6754085 --- /dev/null +++ b/frontend/src/modules/browser/api/profiles.ts @@ -0,0 +1,174 @@ +import type { BrowserProfile, BrowserProfileInput } from '../types' +import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime' + +export async function fetchBrowserProfiles(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileList) { + return (await bindings.BrowserProfileList()) || [] + } + return getMockProfiles() +} + +export async function fetchBrowserProfilesByTag(tag: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileListByTag) { + return (await bindings.BrowserProfileListByTag(tag)) || [] + } + return getMockProfiles().filter((profile) => profile.tags?.includes(tag)) +} + +export async function fetchAllTags(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserGetAllTags) { + return (await bindings.BrowserGetAllTags()) || [] + } + + const tags = new Set() + getMockProfiles().forEach((profile) => profile.tags?.forEach((tag) => tags.add(tag))) + return Array.from(tags).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, + debugReady: false, + pid: 0, + runtimeWarning: '', + lastError: '', + createdAt: nowISOString(), + updatedAt: nowISOString(), + } + setMockProfiles([profile, ...getMockProfiles()]) + 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 profiles = getMockProfiles() + const index = profiles.findIndex((item) => item.profileId === profileId) + if (index === -1) { + return null + } + + const nextProfiles = [...profiles] + nextProfiles[index] = { ...nextProfiles[index], ...input, updatedAt: nowISOString() } + setMockProfiles(nextProfiles) + return nextProfiles[index] +} + +export async function deleteBrowserProfile(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileDelete) { + await bindings.BrowserProfileDelete(profileId) + return true + } + + setMockProfiles(getMockProfiles().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 + } + + const source = getMockProfiles().find((profile) => profile.profileId === profileId) + if (!source) { + return null + } + + const timestamp = Date.now() + const launchCode = `MOCK_${timestamp.toString(36).toUpperCase().slice(-6).padStart(6, '0')}` + const copy: BrowserProfile = { + ...source, + profileId: `mock-${timestamp}`, + profileName: newName || `${source.profileName} (副本)`, + userDataDir: `mock-${timestamp}`, + launchCode, + running: false, + debugReady: false, + runtimeWarning: '', + createdAt: nowISOString(), + updatedAt: nowISOString(), + } + setMockProfiles([copy, ...getMockProfiles()]) + return copy +} + +export async function setProfileKeywords(profileId: string, keywords: string[]): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileSetKeywords) { + return (await bindings.BrowserProfileSetKeywords(profileId, keywords)) || null + } + + const nextProfiles = getMockProfiles().map((profile) => + profile.profileId === profileId ? { ...profile, keywords, updatedAt: nowISOString() } : profile, + ) + setMockProfiles(nextProfiles) + return nextProfiles.find((profile) => profile.profileId === profileId) || null +} + +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 +} diff --git a/frontend/src/modules/browser/api/proxies.ts b/frontend/src/modules/browser/api/proxies.ts new file mode 100644 index 00000000..703b81b5 --- /dev/null +++ b/frontend/src/modules/browser/api/proxies.ts @@ -0,0 +1,185 @@ +import type { BrowserProxy, ProxyIPHealthResult } from '../types' +import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime' + +export interface ClashImportURLResult { + url: string + content: string + proxyCount: number + dnsServers?: string + suggestedGroup?: string +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function fetchBrowserProxies(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyList) { + return (await bindings.BrowserProxyList()) || [] + } + return getMockProxies() +} + +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 getMockProxies().filter((proxy) => proxy.groupName === groupName) +} + +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, + } + ) + } + + const goApp = getGoApp() + 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 + } + setMockProxies(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: '调用失败' } + } + await sleep(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: '调用失败' } + } + await sleep(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 sleep(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)) || [] + } + await sleep(1000) + return proxyIds.map((proxyId) => ({ proxyId, 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: nowISOString(), + } + ) + } + + await sleep(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: nowISOString(), + } +} + +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 sleep(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: nowISOString(), + })) +} diff --git a/frontend/src/modules/browser/api/runtime.ts b/frontend/src/modules/browser/api/runtime.ts new file mode 100644 index 00000000..7b695c59 --- /dev/null +++ b/frontend/src/modules/browser/api/runtime.ts @@ -0,0 +1,83 @@ +import type { BrowserCore, BrowserProfile, BrowserProxy, BrowserSettings } from '../types' + +export async function getBindings() { + try { + return await import('../../../wailsjs/go/main/App') + } catch { + return null + } +} + +export function getGoApp(): any { + return (globalThis as any).go?.main?.App ?? null +} + +export function nowISOString(): string { + return new Date().toISOString() +} + +export function createDefaultBrowserSettings(): BrowserSettings { + return { + userDataRoot: 'data', + defaultFingerprintArgs: [], + defaultLaunchArgs: [], + defaultStartUrls: [ + 'https://ippure.com/', + 'https://iplark.com/', + 'https://ping0.cc/', + ], + restoreLastSession: false, + startReadyTimeoutMs: 3000, + startStableWindowMs: 1200, + } +} + +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, + debugReady: false, + pid: 0, + runtimeWarning: '', + lastError: '', + createdAt: nowISOString(), + updatedAt: nowISOString(), + }, +] + +let mockCores: BrowserCore[] = [] +let mockProxies: BrowserProxy[] = [] + +export function getMockProfiles(): BrowserProfile[] { + return mockProfiles +} + +export function setMockProfiles(next: BrowserProfile[]): void { + mockProfiles = next +} + +export function getMockCores(): BrowserCore[] { + return mockCores +} + +export function setMockCores(next: BrowserCore[]): void { + mockCores = next +} + +export function getMockProxies(): BrowserProxy[] { + return mockProxies +} + +export function setMockProxies(next: BrowserProxy[]): void { + mockProxies = next +} diff --git a/frontend/src/modules/browser/api/settings.ts b/frontend/src/modules/browser/api/settings.ts new file mode 100644 index 00000000..847cba1e --- /dev/null +++ b/frontend/src/modules/browser/api/settings.ts @@ -0,0 +1,19 @@ +import type { BrowserSettings } from '../types' +import { createDefaultBrowserSettings, getBindings } from './runtime' + +export async function fetchBrowserSettings(): Promise { + const bindings: any = await getBindings() + if (bindings?.GetBrowserSettings) { + return (await bindings.GetBrowserSettings()) || createDefaultBrowserSettings() + } + return createDefaultBrowserSettings() +} + +export async function saveBrowserSettings(settings: BrowserSettings): Promise { + const bindings: any = await getBindings() + if (bindings?.SaveBrowserSettings) { + await bindings.SaveBrowserSettings(settings) + return true + } + return true +} diff --git a/frontend/src/modules/browser/api/snapshots.ts b/frontend/src/modules/browser/api/snapshots.ts new file mode 100644 index 00000000..9c0225d2 --- /dev/null +++ b/frontend/src/modules/browser/api/snapshots.ts @@ -0,0 +1,42 @@ +import type { SnapshotInfo } from '../types' +import { getBindings, nowISOString } from './runtime' + +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 + } + return { + snapshotId: `snap-${Date.now()}`, + profileId, + name, + sizeMB: 12.5, + createdAt: nowISOString(), + } +} + +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 +} diff --git a/frontend/src/modules/browser/automationRuntime.ts b/frontend/src/modules/browser/automationRuntime.ts new file mode 100644 index 00000000..dd1a32bc --- /dev/null +++ b/frontend/src/modules/browser/automationRuntime.ts @@ -0,0 +1,44 @@ +import type { AutomationState } from '../settings/api' + +export function getAutomationRuntimeBadgeVariant(state: AutomationState): 'default' | 'success' | 'error' | 'warning' { + if (state.status.installing) return 'warning' + if (state.status.ready) return 'success' + if (state.status.lastError) return 'error' + return 'default' +} + +export function getAutomationRuntimeBadgeText(state: AutomationState): string { + if (!state.settings.enabled) return '未启用' + if (state.status.installing) return '准备中' + if (state.status.ready) return '已就绪' + if (state.status.installed) return '已安装' + if (state.status.lastError) return '异常' + return '待准备' +} + +export function getAutomationNodeSource(state: AutomationState): string { + return String(state.status.nodeSource || state.settings.nodeSource || 'auto').trim() || 'auto' +} + +export function getAutomationNodeSourceLabel(nodeSource: string): string { + switch (nodeSource) { + case 'system': + return '系统 Node' + case 'bundled': + return '内置 Node' + default: + return '自动选择' + } +} + +export function getAutomationNodeVersion(state: AutomationState): string { + return state.status.nodeVersion || state.settings.nodeVersion || '-' +} + +export function getAutomationPlaywrightVersion(state: AutomationState): string { + return state.status.playwrightVersion || state.settings.playwrightVersion || '-' +} + +export function getAutomationSystemNodePath(state: AutomationState): string { + return state.status.systemNodePath || state.settings.systemNodePath || '' +} diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts new file mode 100644 index 00000000..1fce4ffa --- /dev/null +++ b/frontend/src/modules/browser/automationScriptApi.ts @@ -0,0 +1,553 @@ +import { + exportAutomationScript, + importAutomationScript, + loadAutomationScripts, + normalizeAutomationScriptRecordPayload, + normalizeAutomationScriptTargetConfig, + saveAutomationScripts, + type AutomationScriptRunInput, + type AutomationScriptRunRecord, + type AutomationScriptRecord, +} from "./automationScripts"; +import { startBrowserInstanceByCode } from "./api/instances"; + +const getBindings = async () => { + try { + return await import("../../wailsjs/go/main/App"); + } catch { + return null; + } +}; + +function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord { + const normalized = normalizeAutomationScriptRecordPayload(payload); + if (normalized) { + return normalized; + } + + return { + packageFormat: String(payload?.packageFormat || "ant-automation-script"), + manifestVersion: Number(payload?.manifestVersion) || 1, + id: String(payload?.id || ""), + name: String(payload?.name || ""), + description: String(payload?.description || ""), + type: payload?.type === "launch-api" ? "launch-api" : "playwright-cdp", + status: + payload?.status === "ready" || payload?.status === "disabled" + ? payload.status + : "draft", + entryFile: String(payload?.entryFile || "index.cjs"), + tags: Array.isArray(payload?.tags) + ? payload.tags + .map((item: unknown) => String(item || "").trim()) + .filter(Boolean) + : [], + selectorText: String(payload?.selectorText || ""), + paramsText: String(payload?.paramsText || ""), + scriptText: String(payload?.scriptText || ""), + notes: String(payload?.notes || ""), + targetConfig: normalizeAutomationScriptTargetConfig(payload?.targetConfig), + source: { + type: String(payload?.source?.type || ""), + uri: String(payload?.source?.uri || ""), + ref: String(payload?.source?.ref || ""), + path: String(payload?.source?.path || ""), + importedAt: String(payload?.source?.importedAt || ""), + }, + createdAt: String(payload?.createdAt || ""), + updatedAt: String(payload?.updatedAt || ""), + }; +} + +function sortScripts( + items: AutomationScriptRecord[], +): AutomationScriptRecord[] { + return [...items].sort( + (left, right) => + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), + ); +} + +function normalizeAutomationScriptRunRecord( + payload: any, +): AutomationScriptRunRecord { + return { + id: String(payload?.id || ""), + scriptId: String(payload?.scriptId || ""), + scriptName: String(payload?.scriptName || ""), + scriptType: String(payload?.scriptType || ""), + status: + payload?.status === "success" || payload?.status === "running" + ? payload.status + : "failed", + summary: String(payload?.summary || ""), + error: String(payload?.error || ""), + resultText: String(payload?.resultText || ""), + startedAt: String(payload?.startedAt || ""), + finishedAt: String(payload?.finishedAt || ""), + durationMs: Number(payload?.durationMs) || 0, + }; +} + +export interface AutomationScriptExportResult { + cancelled: boolean; + format: string; + message: string; + path: string; + fileCount: number; +} + +function normalizeAutomationScriptRunInput( + input: string | AutomationScriptRunInput, +): AutomationScriptRunInput { + if (typeof input === "string") { + return { + scriptId: input, + selectorText: "", + paramsText: "", + useScriptSelector: true, + useScriptParams: true, + launchCode: "", + startByCodeBeforeRun: false, + }; + } + + return { + scriptId: String(input?.scriptId || ""), + selectorText: String(input?.selectorText || ""), + paramsText: String(input?.paramsText || ""), + useScriptSelector: input?.useScriptSelector !== false, + useScriptParams: input?.useScriptParams !== false, + launchCode: String(input?.launchCode || "") + .trim() + .toUpperCase(), + startByCodeBeforeRun: input?.startByCodeBeforeRun === true, + }; +} + +export async function fetchAutomationScripts(): Promise< + AutomationScriptRecord[] +> { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptList) { + const raw = (await bindings.AutomationScriptList()) || []; + return sortScripts( + Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [], + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptList === "function") { + const raw = (await goApp.AutomationScriptList()) || []; + return sortScripts( + Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [], + ); + } + + return loadAutomationScripts(); +} + +export async function saveAutomationScript( + script: AutomationScriptRecord, +): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptSave) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptSave(script), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptSave === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptSave(script), + ); + } + + const current = loadAutomationScripts(); + const next = current.some((item) => item.id === script.id) + ? current.map((item) => (item.id === script.id ? script : item)) + : [script, ...current]; + saveAutomationScripts(sortScripts(next)); + return script; +} + +export async function deleteAutomationScript(scriptId: string): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptDelete) { + await bindings.AutomationScriptDelete(scriptId); + return; + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptDelete === "function") { + await goApp.AutomationScriptDelete(scriptId); + return; + } + + saveAutomationScripts( + loadAutomationScripts().filter((item) => item.id !== scriptId), + ); +} + +export async function importAutomationScriptFromLocalFile(): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportLocalFile) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportLocalFile(), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportLocalFile === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportLocalFile(), + ); + } + + throw new Error("当前环境不支持本地文件导入"); +} + +export async function importAutomationScriptFromText( + text: string, +): Promise { + const normalizedText = String(text || "").trim(); + if (!normalizedText) { + throw new Error("导入内容不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportText) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportText(normalizedText), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportText === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportText(normalizedText), + ); + } + + return importAutomationScript(normalizedText); +} + +export async function importAutomationScriptFromLocalDirectory(): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportLocalDirectory) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportLocalDirectory(), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportLocalDirectory === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportLocalDirectory(), + ); + } + + throw new Error("当前环境不支持本地目录导入"); +} + +export async function importAutomationScriptFromRemote(url: string): Promise { + const normalizedURL = String(url || "").trim(); + if (!normalizedURL) { + throw new Error("远程脚本地址不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportRemote) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportRemote(normalizedURL), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportRemote === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportRemote(normalizedURL), + ); + } + + throw new Error("当前环境不支持远程脚本导入"); +} + +export async function importAutomationScriptFromGit( + repoURL: string, + ref = "", + scriptPath = "", +): Promise { + const normalizedRepoURL = String(repoURL || "").trim(); + if (!normalizedRepoURL) { + throw new Error("Git 仓库地址不能为空"); + } + + const normalizedRef = String(ref || "").trim(); + const normalizedScriptPath = String(scriptPath || "").trim(); + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportGit) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportGit( + normalizedRepoURL, + normalizedRef, + normalizedScriptPath, + ), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportGit === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportGit( + normalizedRepoURL, + normalizedRef, + normalizedScriptPath, + ), + ); + } + + throw new Error("当前环境不支持 Git 脚本导入"); +} + +export async function refreshAutomationScript( + scriptId: string, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptRefresh) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptRefresh(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptRefresh === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptRefresh(normalizedScriptId), + ); + } + + throw new Error("当前环境不支持按来源重新导入"); +} + +function normalizeAutomationScriptExportResult( + payload: any, +): AutomationScriptExportResult { + return { + cancelled: payload?.cancelled === true, + format: String(payload?.format || ""), + message: String(payload?.message || ""), + path: String(payload?.path || ""), + fileCount: Number(payload?.fileCount) || 0, + }; +} + +function buildAutomationTemplateFallbackFilename(script: AutomationScriptRecord): string { + const normalizedName = String(script.name || "") + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/^-+|-+$/g, ""); + + return `${normalizedName || "automation-script"}-template.json`; +} + +function downloadAutomationTemplate( + filename: string, + content: string, +): AutomationScriptExportResult { + const blob = new Blob([content], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } + + return { + cancelled: false, + format: "json", + message: "模板已导出", + path: filename, + fileCount: 1, + }; +} + +export async function exportAutomationScriptTemplate( + scriptId: string, + fallbackScript?: AutomationScriptRecord, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptExport) { + return normalizeAutomationScriptExportResult( + await bindings.AutomationScriptExport(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptExport === "function") { + return normalizeAutomationScriptExportResult( + await goApp.AutomationScriptExport(normalizedScriptId), + ); + } + + if (fallbackScript && typeof document !== "undefined") { + return downloadAutomationTemplate( + buildAutomationTemplateFallbackFilename(fallbackScript), + exportAutomationScript(fallbackScript), + ); + } + + throw new Error("当前环境不支持脚本模板导出"); +} + +export async function exportAutomationScriptZip( + scriptId: string, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptExportZip) { + return normalizeAutomationScriptExportResult( + await bindings.AutomationScriptExportZip(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptExportZip === "function") { + return normalizeAutomationScriptExportResult( + await goApp.AutomationScriptExportZip(normalizedScriptId), + ); + } + + throw new Error("当前环境不支持 ZIP 脚本包导出"); +} + +export async function exportAutomationScriptDirectory( + scriptId: string, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptExportDirectory) { + return normalizeAutomationScriptExportResult( + await bindings.AutomationScriptExportDirectory(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptExportDirectory === "function") { + return normalizeAutomationScriptExportResult( + await goApp.AutomationScriptExportDirectory(normalizedScriptId), + ); + } + + throw new Error("当前环境不支持目录脚本包导出"); +} + +export async function runAutomationScript( + input: string | AutomationScriptRunInput, +): Promise { + const request = normalizeAutomationScriptRunInput(input); + const { launchCode, startByCodeBeforeRun, ...bindingRequest } = request; + + if (startByCodeBeforeRun && launchCode) { + const startedProfile = await startBrowserInstanceByCode(launchCode); + if (!startedProfile) { + throw new Error(`通过 Launch Code 启动实例失败: ${launchCode}`); + } + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptRunWithOptions) { + return normalizeAutomationScriptRunRecord( + await bindings.AutomationScriptRunWithOptions(bindingRequest), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptRunWithOptions === "function") { + return normalizeAutomationScriptRunRecord( + await goApp.AutomationScriptRunWithOptions(bindingRequest), + ); + } + + if ( + bindings?.AutomationScriptRun && + bindingRequest.useScriptSelector && + bindingRequest.useScriptParams + ) { + return normalizeAutomationScriptRunRecord( + await bindings.AutomationScriptRun(bindingRequest.scriptId), + ); + } + + if ( + typeof goApp?.AutomationScriptRun === "function" && + bindingRequest.useScriptSelector && + bindingRequest.useScriptParams + ) { + return normalizeAutomationScriptRunRecord( + await goApp.AutomationScriptRun(bindingRequest.scriptId), + ); + } + + const now = new Date().toISOString(); + return { + id: `mock-run-${Date.now()}`, + scriptId: bindingRequest.scriptId, + scriptName: "", + scriptType: "", + status: "failed", + summary: "当前环境未接入自动化脚本执行", + error: "AutomationScriptRun binding is unavailable", + resultText: "", + startedAt: now, + finishedAt: now, + durationMs: 0, + }; +} + +export async function fetchAutomationScriptRuns( + limit = 20, +): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptRunList) { + const raw = (await bindings.AutomationScriptRunList(limit)) || []; + return Array.isArray(raw) + ? raw.map(normalizeAutomationScriptRunRecord) + : []; + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptRunList === "function") { + const raw = (await goApp.AutomationScriptRunList(limit)) || []; + return Array.isArray(raw) + ? raw.map(normalizeAutomationScriptRunRecord) + : []; + } + + return []; +} diff --git a/frontend/src/modules/browser/automationScripts.ts b/frontend/src/modules/browser/automationScripts.ts new file mode 100644 index 00000000..74d27e9c --- /dev/null +++ b/frontend/src/modules/browser/automationScripts.ts @@ -0,0 +1,1892 @@ +import type { BrowserProfile } from "./types"; + +export type AutomationScriptType = "playwright-cdp" | "launch-api"; + +export type AutomationScriptStatus = "draft" | "ready" | "disabled"; + +export type AutomationScriptTargetMode = + | "manual" + | "existing" + | "create" + | "rotate"; + +export interface AutomationScriptSource { + type: string; + uri: string; + ref: string; + path: string; + importedAt: string; +} + +export interface AutomationScriptTargetSelector { + code: string; + profileId: string; + profileName: string; + groupId: string; + keywords: string[]; + tags: string[]; +} + +export interface AutomationScriptTargetConfig { + mode: AutomationScriptTargetMode; + selector: AutomationScriptTargetSelector; + templateSelector: AutomationScriptTargetSelector; + createNameTemplate: string; +} + +export interface AutomationScriptRecord { + packageFormat: string; + manifestVersion: number; + id: string; + name: string; + description: string; + type: AutomationScriptType; + status: AutomationScriptStatus; + entryFile: string; + tags: string[]; + selectorText: string; + paramsText: string; + scriptText: string; + notes: string; + targetConfig: AutomationScriptTargetConfig; + source: AutomationScriptSource; + createdAt: string; + updatedAt: string; +} + +export interface AutomationScriptRunRecord { + id: string; + scriptId: string; + scriptName: string; + scriptType: string; + status: "success" | "failed" | "running"; + summary: string; + error: string; + resultText: string; + startedAt: string; + finishedAt: string; + durationMs: number; +} + +export interface AutomationScriptRunInput { + scriptId: string; + selectorText?: string; + paramsText?: string; + useScriptSelector?: boolean; + useScriptParams?: boolean; + launchCode?: string; + startByCodeBeforeRun?: boolean; +} + +const AUTOMATION_SCRIPTS_STORAGE_KEY = "automation_scripts_v1"; +export const AUTOMATION_SCRIPT_PACKAGE_FORMAT = "ant-automation-script"; +export const AUTOMATION_SCRIPT_MANIFEST_VERSION = 1; + +export const AUTOMATION_SCRIPT_TYPE_OPTIONS: Array<{ + value: AutomationScriptType; + label: string; +}> = [ + { value: "playwright-cdp", label: "Playwright CDP" }, + { value: "launch-api", label: "Launch API" }, +]; + +export const AUTOMATION_SCRIPT_STATUS_OPTIONS: Array<{ + value: AutomationScriptStatus; + label: string; +}> = [ + { value: "draft", label: "草稿" }, + { value: "ready", label: "可用" }, + { value: "disabled", label: "停用" }, +]; + +export const AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS: Array<{ + value: AutomationScriptTargetMode; + label: string; +}> = [ + { value: "manual", label: "手动 selector" }, + { value: "existing", label: "使用已有实例" }, + { value: "create", label: "按模板新建实例" }, + { value: "rotate", label: "按条件轮询实例" }, +]; + +export const DUAL_INSTANCE_RUNTIME_SCRIPT_ID = "dual-instance-runtime-switch"; +const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const; +const DUAL_INSTANCE_DEFAULT_START_URLS = [ + "https://finance.sina.com.cn/", + "https://map.baidu.com/", +] as const; + +function nowIso(): string { + return new Date().toISOString(); +} + +function createScriptId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + return `script-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeSource(source: unknown): AutomationScriptSource { + if (!source || typeof source !== "object") { + return { + type: "", + uri: "", + ref: "", + path: "", + importedAt: "", + }; + } + + const raw = source as Partial; + return { + type: typeof raw.type === "string" ? raw.type.trim() : "", + uri: typeof raw.uri === "string" ? raw.uri.trim() : "", + ref: typeof raw.ref === "string" ? raw.ref.trim() : "", + path: typeof raw.path === "string" ? raw.path.trim() : "", + importedAt: + typeof raw.importedAt === "string" ? raw.importedAt.trim() : "", + }; +} + +function normalizeTargetTerms(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const deduped = new Set(); + for (const item of value) { + const normalized = String(item || "").trim(); + if (normalized) { + deduped.add(normalized); + } + } + return Array.from(deduped); +} + +function normalizeTargetSelector( + selector: unknown, +): AutomationScriptTargetSelector { + if (!selector || typeof selector !== "object") { + return { + code: "", + profileId: "", + profileName: "", + groupId: "", + keywords: [], + tags: [], + }; + } + + const raw = selector as Partial; + return { + code: + typeof raw.code === "string" + ? raw.code.trim().toUpperCase() + : typeof (selector as { launchCode?: unknown }).launchCode === "string" + ? String((selector as { launchCode?: unknown }).launchCode) + .trim() + .toUpperCase() + : "", + profileId: + typeof raw.profileId === "string" ? raw.profileId.trim() : "", + profileName: + typeof raw.profileName === "string" ? raw.profileName.trim() : "", + groupId: typeof raw.groupId === "string" ? raw.groupId.trim() : "", + keywords: normalizeTargetTerms(raw.keywords), + tags: normalizeTargetTerms(raw.tags), + }; +} + +export function createAutomationScriptTargetSelector(): AutomationScriptTargetSelector { + return { + code: "", + profileId: "", + profileName: "", + groupId: "", + keywords: [], + tags: [], + }; +} + +export function normalizeAutomationScriptTargetConfig( + config: unknown, +): AutomationScriptTargetConfig { + if (!config || typeof config !== "object") { + return { + mode: "manual", + selector: createAutomationScriptTargetSelector(), + templateSelector: createAutomationScriptTargetSelector(), + createNameTemplate: "", + }; + } + + const raw = config as Partial; + const mode: AutomationScriptTargetMode = + raw.mode === "existing" || + raw.mode === "create" || + raw.mode === "rotate" + ? raw.mode + : "manual"; + + return { + mode, + selector: normalizeTargetSelector(raw.selector), + templateSelector: normalizeTargetSelector(raw.templateSelector), + createNameTemplate: + typeof raw.createNameTemplate === "string" + ? raw.createNameTemplate.trim() + : "", + }; +} + +function selectorSummaryParts(selector: AutomationScriptTargetSelector): string[] { + const parts: string[] = []; + if (selector.code) { + parts.push(`Code=${selector.code}`); + } + if (selector.profileName) { + parts.push(`实例=${selector.profileName}`); + } + if (selector.profileId && !selector.code) { + parts.push(`实例ID=${selector.profileId}`); + } + if (selector.groupId) { + parts.push(`分组=${selector.groupId}`); + } + if (selector.tags.length > 0) { + parts.push(`标签=${selector.tags.join(" / ")}`); + } + if (selector.keywords.length > 0) { + parts.push(`关键字=${selector.keywords.join(" / ")}`); + } + return parts; +} + +export function getAutomationScriptTargetModeLabel( + mode: AutomationScriptTargetMode, +): string { + return ( + AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS.find((item) => item.value === mode) + ?.label || mode + ); +} + +function normalizeSelectorCode(value?: string): string { + return String(value || "") + .trim() + .toUpperCase(); +} + +function normalizeSelectorText(value?: string): string { + return String(value || "").trim(); +} + +export function findAutomationTargetProfile( + selector: AutomationScriptTargetSelector, + profiles: BrowserProfile[], +): BrowserProfile | null { + const normalizedProfileId = normalizeSelectorText(selector.profileId); + if (normalizedProfileId) { + const matchedById = profiles.find( + (profile) => normalizeSelectorText(profile.profileId) === normalizedProfileId, + ); + if (matchedById) { + return matchedById; + } + } + + const normalizedCode = normalizeSelectorCode(selector.code); + if (normalizedCode) { + const matchedByCode = profiles.find( + (profile) => normalizeSelectorCode(profile.launchCode) === normalizedCode, + ); + if (matchedByCode) { + return matchedByCode; + } + } + + const normalizedProfileName = normalizeSelectorText(selector.profileName); + if (normalizedProfileName) { + const matchedByName = profiles.find( + (profile) => + normalizeSelectorText(profile.profileName).toLowerCase() === + normalizedProfileName.toLowerCase(), + ); + if (matchedByName) { + return matchedByName; + } + } + + return null; +} + +export function formatAutomationTargetIdentity( + selector: AutomationScriptTargetSelector, + profiles: BrowserProfile[], + options?: { + includeProfileId?: boolean; + fallback?: string; + }, +): string { + const profile = findAutomationTargetProfile(selector, profiles); + const code = normalizeSelectorCode(profile?.launchCode || selector.code); + const profileName = normalizeSelectorText( + profile?.profileName || selector.profileName, + ); + const profileId = normalizeSelectorText(profile?.profileId || selector.profileId); + + const parts = [code, profileName].filter(Boolean); + if (options?.includeProfileId && profileId) { + parts.push(profileId); + } + + if (parts.length > 0) { + return parts.join(" · "); + } + if (profileId) { + return options?.includeProfileId ? profileId : `实例 ID ${profileId}`; + } + + return options?.fallback || "-"; +} + +export function describeAutomationScriptTargetConfig( + config: AutomationScriptTargetConfig, +): string { + switch (config.mode) { + case "existing": { + const parts = selectorSummaryParts(config.selector); + return parts.length > 0 + ? `使用已有实例:${parts.join(" · ")}` + : "使用已有实例"; + } + case "create": { + const parts = selectorSummaryParts(config.templateSelector); + const namePart = config.createNameTemplate + ? `命名=${config.createNameTemplate}` + : ""; + return [ + "按模板新建实例", + ...parts, + namePart, + ] + .filter(Boolean) + .join(" · "); + } + case "rotate": { + const parts = selectorSummaryParts(config.selector); + return parts.length > 0 + ? `按条件轮询实例:${parts.join(" · ")}` + : "按条件轮询实例"; + } + default: + return "手动填写 selector JSON"; + } +} + +export function getAutomationScriptSourceLabel(source: AutomationScriptSource): string { + switch (source.type) { + case "builtin": + return "内置基线"; + case "local-file": + return "本地文件"; + case "local-dir": + return "本地目录"; + case "remote-url": + return "远程 URL"; + case "git": + return "Git"; + case "text": + return "文本导入"; + case "manual": + return "手动维护"; + default: + return source.type || "未标记"; + } +} + +export function canRefreshAutomationScriptSource( + source: AutomationScriptSource, +): boolean { + return ( + source.type === "local-file" || + source.type === "local-dir" || + source.type === "remote-url" || + source.type === "git" + ); +} + +export function getAutomationScriptRefreshLabel( + source: AutomationScriptSource, +): string { + return source.type === "git" ? "重新拉取" : "重新导入"; +} + +export function getAutomationScriptTypeLabel( + type: AutomationScriptType, +): string { + return ( + AUTOMATION_SCRIPT_TYPE_OPTIONS.find((item) => item.value === type)?.label || + type + ); +} + +export function getAutomationScriptStatusLabel( + status: AutomationScriptStatus, +): string { + return ( + AUTOMATION_SCRIPT_STATUS_OPTIONS.find((item) => item.value === status) + ?.label || status + ); +} + +function buildSelectorTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `{ + "code": "BUYER_001" +}`; + } + + return ""; +} + +function buildParamsTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `{ + "startUrls": ["https://example.com"], + "skipDefaultStartUrls": true +}`; + } + + return `{ + "url": "https://www.baidu.com", + "keyword": "OpenAI", + "timeoutMs": 30000, + "waitAfterSearchMs": 1500, + "captureScreenshot": true +}`; +} + +function buildScriptTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `export async function run({ baseUrl, apiKey, selector, params }) { + const response = await fetch(\`\${baseUrl}/api/launch\`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), + }, + body: JSON.stringify({ + selector, + ...(params || {}), + }), + }) + + if (!response.ok) { + throw new Error(\`launch failed: \${response.status}\`) + } + + return await response.json() +}`; + } + + return `module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { + const targetUrl = + typeof params.url === 'string' && params.url.trim() + ? params.url.trim() + : 'https://www.baidu.com' + const keyword = + typeof params.keyword === 'string' && params.keyword.trim() + ? params.keyword.trim() + : 'OpenAI' + const timeout = + Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0 + ? Math.round(Number(params.timeoutMs)) + : 30000 + const waitAfterSearchMs = + Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0 + ? Math.round(Number(params.waitAfterSearchMs)) + : 1500 + + const session = await launch({ + selector, + startUrls: params.startUrls || [targetUrl], + skipDefaultStartUrls: true, + }) + + const connection = await connect(session) + const browser = connection.browser + const context = connection.context || browser.contexts()[0] + const page = connection.page || context.pages()[0] || await context.newPage() + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout, + }) + + const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first() + await searchInput.waitFor({ + state: 'visible', + timeout, + }) + await searchInput.fill(keyword) + await searchInput.press('Enter').catch(async () => { + const submitButton = page.locator('#su, input[type="submit"]').first() + await submitButton.click({ timeout }) + }) + await page.waitForURL(/wd=/, { timeout }).catch(() => {}) + + if (waitAfterSearchMs > 0) { + await page.waitForTimeout(waitAfterSearchMs) + } + + if (params.captureScreenshot !== false) { + await page.screenshot({ + path: artifact('baidu-search.png'), + fullPage: true, + }) + } + + const title = await page.title() + log('keyword', keyword) + log('title', title) + + return { + ok: true, + summary: \`已在百度搜索 \${keyword}\`, + keyword, + url: page.url(), + title, + } +}`; +} + +function buildNotesTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。"; + } + + return "默认示例会启动浏览器并搜索 keyword。首次执行可先选择已有实例,或创建一个新实例后再执行。"; +} + +function buildDualInstanceRuntimeParamsText(codes = [...DUAL_INSTANCE_DEFAULT_CODES]): string { + return `{ + "browsers": [ + { + "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}", + "skipDefaultStartUrls": true, + "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"] + }, + { + "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}", + "skipDefaultStartUrls": true, + "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"] + } + ], + "timeoutMs": 45000 +}`; +} + +function buildDualInstanceRuntimeScriptText(): string { + return `export async function run({ baseUrl, apiKey, params, log }) { + const normalizeCode = (value, fallback) => + String(value || fallback || "").trim().toUpperCase() + const normalizeStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => String(item || "").trim()) + .filter(Boolean) + : [] + const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { + const raw = value && typeof value === "object" ? value : {} + const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) + if (!code) { + return null + } + const startUrls = normalizeStringArray(raw.startUrls) + const fallbackUrls = normalizeStringArray(fallbackStartUrls) + const launchArgs = normalizeStringArray(raw.launchArgs) + + return { + code, + skipDefaultStartUrls: + raw.skipDefaultStartUrls !== undefined + ? raw.skipDefaultStartUrls !== false + : defaultSkip, + startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, + launchArgs, + } + } + + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(1000, Math.round(Number(params.timeoutMs))) + : 45000 + const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false + + let browsers = Array.isArray(params.browsers) + ? params.browsers + .map((item, index) => + normalizeBrowserInput( + item, + ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "", + ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [], + defaultSkipDefaultStartUrls, + ), + ) + .filter(Boolean) + : [] + + if (browsers.length === 0) { + browsers = [ + normalizeBrowserInput( + { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])}, + ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])}, + defaultSkipDefaultStartUrls, + ), + normalizeBrowserInput( + { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])}, + ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])}, + defaultSkipDefaultStartUrls, + ), + ].filter(Boolean) + } + + if (browsers.length === 0) { + throw new Error("params.browsers 不能为空") + } + + const headers = { + "Content-Type": "application/json", + ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}), + } + + const post = async (path, payload) => { + const response = await fetch(\`\${baseUrl}\${path}\`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + const text = await response.text() + let body = text + try { + body = text ? JSON.parse(text) : null + } catch { + body = text + } + if (!response.ok) { + throw new Error(\`\${path} failed: \${response.status} \${text}\`) + } + return body + } + + const sessions = [] + + for (const browser of browsers) { + const sessionResult = await post("/api/runtime/session", { + selector: { code: browser.code, matchMode: "unique" }, + skipDefaultStartUrls: browser.skipDefaultStartUrls, + ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), + ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), + timeoutMs, + }) + + sessions.push(sessionResult) + } + + const browserCodes = browsers.map((item) => item.code) + log("browserCodes", browserCodes) + + return { + ok: true, + summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`, + browserCodes, + sessions, + } +}`; +} + +function normalizeDualInstanceRuntimeParamsText(text: string): string { + const fallback = buildDualInstanceRuntimeParamsText(); + + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return fallback; + } + + const raw = parsed as Record; + const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false; + const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : []; + const browsers = rawBrowsers + .map((item, index) => { + if (!item || typeof item !== "object") { + return null; + } + const entry = item as Record; + const code = normalizeTargetSelector({ + code: + typeof entry.code === "string" + ? entry.code + : typeof entry.launchCode === "string" + ? entry.launchCode + : "", + }).code; + if (!code) { + return null; + } + + const startUrls = Array.isArray(entry.startUrls) + ? entry.startUrls + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + const launchArgs = Array.isArray(entry.launchArgs) + ? entry.launchArgs + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + + const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : []; + + return { + code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "", + skipDefaultStartUrls: + entry.skipDefaultStartUrls !== undefined + ? entry.skipDefaultStartUrls !== false + : topLevelSkipDefaultStartUrls, + startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls, + ...(launchArgs.length > 0 ? { launchArgs } : {}), + }; + }) + .filter( + ( + item, + ): item is { + code: string; + skipDefaultStartUrls: boolean; + startUrls: string[]; + launchArgs?: string[]; + } => item !== null, + ); + + const legacyCodes = [ + normalizeTargetSelector({ + code: typeof raw.primaryCode === "string" ? raw.primaryCode : "", + }).code, + normalizeTargetSelector({ + code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "", + }).code, + ].filter(Boolean); + + const normalizedBrowsers = + browsers.length > 0 + ? browsers + : legacyCodes.length > 0 + ? legacyCodes.map((code, index) => ({ + code, + skipDefaultStartUrls: topLevelSkipDefaultStartUrls, + startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : [], + })) + : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({ + code, + skipDefaultStartUrls: true, + startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : [], + })); + + const timeoutMs = + Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0 + ? Math.round(Number(raw.timeoutMs)) + : 45000; + + return JSON.stringify( + { + browsers: normalizedBrowsers, + timeoutMs, + }, + null, + 2, + ); + } catch { + return fallback; + } +} + +function createNewsTxtScriptDraft(): AutomationScriptRecord { + const createdAt = nowIso(); + + return { + packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: "news-query-txt", + name: "查询新闻并写 TXT", + description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。", + type: "playwright-cdp", + status: "ready", + entryFile: "index.cjs", + tags: ["Playwright", "新闻", "TXT"], + selectorText: "", + paramsText: `{ + "keyword": "OpenAI", + "limit": 10, + "timeRange": "week", + "outputFileName": "openai-news.txt", + "timeoutMs": 30000, + "waitAfterLoadMs": 1500, + "captureScreenshot": false +}`, + scriptText: String.raw`const fs = require('fs') + +const DEFAULT_EXCLUDED_DOMAINS = [ + 'zhihu.com', + 'baidu.com', + 'qq.com', + '36kr.com', + 'apifox.com', + 'chatgpt-chinese.com', + 'openwebui.cn', + 'open-openai.com', + 'xiniushu.com', + 'reddit.com', + 'quora.com', + 'tieba.baidu.com', + 'weibo.com', + 'x.com', + 'twitter.com', + 'youtube.com', + 'bilibili.com', + 'douyin.com', + 'xiaohongshu.com', +] + +function normalizeInt(value, fallback, min, max) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + + const rounded = Math.round(parsed) + if (rounded < min) { + return min + } + if (rounded > max) { + return max + } + return rounded +} + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeDomainList(value) { + if (!Array.isArray(value)) { + return [] + } + + const deduped = new Set() + for (const item of value) { + const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase() + if (normalized) { + deduped.add(normalized) + } + } + return Array.from(deduped) +} + +function buildDefaultQuery(keyword) { + const normalizedKeyword = normalizeText(keyword) || 'OpenAI' + if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { + return normalizedKeyword + ' 新闻' + } + return normalizedKeyword + ' news' +} + +function buildFallbackQueries(keyword, baseQuery) { + const normalizedKeyword = normalizeText(keyword) || 'OpenAI' + const normalizedBaseQuery = normalizeText(baseQuery) + const candidates = [ + normalizedBaseQuery, + ] + + if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { + candidates.push(normalizedKeyword + ' 最新新闻') + } else { + candidates.push(normalizedKeyword + ' latest news') + } + + const deduped = new Set() + for (const item of candidates) { + const normalized = normalizeText(item) + if (normalized) { + deduped.add(normalized) + } + } + return Array.from(deduped) +} + +function buildSearchQuery(baseQuery, excludedDomains) { + const normalizedBaseQuery = normalizeText(baseQuery) + const normalizedDomains = normalizeDomainList(excludedDomains) + const parts = [normalizedBaseQuery] + + for (const domain of normalizedDomains) { + parts.push('-site:' + domain) + } + + return parts.filter(Boolean).join(' ') +} + +function mapTimeRangeToBingFilter(value) { + switch (normalizeText(value).toLowerCase()) { + case 'day': + case '24h': + case 'today': + return 'ex1:"ez1"' + case 'week': + return 'ex1:"ez2"' + case 'month': + return 'ex1:"ez3"' + default: + return '' + } +} + +function buildSearchURL(query, timeRange, firstResultIndex) { + const searchParams = new URLSearchParams({ q: query }) + const filter = mapTimeRangeToBingFilter(timeRange) + if (filter) { + searchParams.set('filters', filter) + } + if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) { + searchParams.set('first', String(firstResultIndex)) + } + return 'https://www.bing.com/search?' + searchParams.toString() +} + +function splitSnippet(snippet) { + const normalized = normalizeText(snippet) + if (!normalized) { + return { publishedAt: '', summary: '' } + } + + const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/) + if ( + match && + /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1]) + ) { + return { + publishedAt: normalizeText(match[1]), + summary: normalizeText(match[2]), + } + } + + return { + publishedAt: '', + summary: normalized, + } +} + +function parseHostname(rawUrl) { + const normalized = normalizeText(rawUrl) + if (!normalized) { + return '' + } + + try { + return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase() + } catch { + return '' + } +} + +function parsePathname(rawUrl) { + const normalized = normalizeText(rawUrl) + if (!normalized) { + return '' + } + + try { + const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase() + if (!pathname) { + return '' + } + return pathname === '/' ? pathname : pathname.replace(/\/$/, '') + } catch { + return '' + } +} + +function looksLikeQuestionTitle(title) { + const normalized = normalizeText(title) + if (!normalized) { + return false + } + + if (/[??]/.test(normalized)) { + return true + } + + return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized) +} + +function looksLikeAggregateText(text) { + const normalized = normalizeText(text).toLowerCase() + if (!normalized) { + return false + } + + return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized) +} + +function looksLikeListingPath(pathname) { + const normalized = normalizeText(pathname).toLowerCase() + if (!normalized || normalized === '/') { + return false + } + + if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) { + return true + } + + if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) { + return true + } + + return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized) +} + +function looksLikeListingText(text) { + const normalized = normalizeText(text).toLowerCase() + if (!normalized) { + return false + } + + return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized) +} + +function isBlockedHostname(hostname) { + const normalized = normalizeText(hostname).toLowerCase() + if (!normalized) { + return false + } + + const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS + const blockedKeywords = [ + 'aitrack', + 'aitoolly', + 'aiflashreport', + 'llm-stats', + 'opentools', + ] + + if (blockedSuffixes.some(function (suffix) { + return normalized === suffix || normalized.endsWith('.' + suffix) + })) { + return true + } + + return blockedKeywords.some(function (keyword) { + return normalized.includes(keyword) + }) +} + +function evaluateNewsItem(item) { + const hostname = parseHostname(item.url) + const pathname = parsePathname(item.url) + const summary = normalizeText(item.summary) + const source = normalizeText(item.source) + const reasons = [] + + if (!normalizeText(item.url)) { + reasons.push('missing-url') + } + if (!hostname) { + reasons.push('invalid-url') + } + if (hostname && isBlockedHostname(hostname)) { + reasons.push('blocked-host') + } + if (!source) { + reasons.push('missing-source') + } + if (summary.length < 20) { + reasons.push('summary-too-short') + } + if (looksLikeQuestionTitle(item.title)) { + reasons.push('question-title') + } + if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) { + reasons.push('aggregate-page') + } + if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) { + reasons.push('listing-page') + } + + return Object.assign({}, item, { + hostname: hostname, + pathname: pathname, + qualityAccepted: reasons.length === 0, + qualityReasons: reasons, + }) +} + +function formatRejectedReason(reason) { + switch (reason) { + case 'missing-url': + return '缺少链接' + case 'invalid-url': + return '链接无效' + case 'blocked-host': + return '来源站点已过滤' + case 'missing-source': + return '缺少来源' + case 'summary-too-short': + return '摘要过短' + case 'question-title': + return '标题更像问答' + case 'aggregate-page': + return '更像聚合页' + case 'listing-page': + return '更像列表页/专题页' + default: + return reason + } +} + +function formatReport(items, metadata) { + const lines = [ + '新闻抓取结果', + '查询词: ' + metadata.query, + '抓取时间: ' + metadata.generatedAt, + '搜索地址: ' + metadata.searchUrl, + '原始结果: ' + metadata.rawCount, + '通过校验: ' + items.length, + '过滤数量: ' + metadata.rejectedItems.length, + '', + ] + + for (const item of items) { + lines.push(item.rank + '. ' + item.title) + if (item.source) { + lines.push('来源: ' + item.source) + } + if (item.publishedAt) { + lines.push('时间: ' + item.publishedAt) + } + lines.push('链接: ' + item.url) + if (item.summary) { + lines.push('摘要: ' + item.summary) + } + lines.push('') + } + + if (metadata.rejectedItems.length > 0) { + lines.push('被过滤结果(最多展示 5 条)') + lines.push('') + for (const item of metadata.rejectedItems.slice(0, 5)) { + lines.push(item.rank + '. ' + item.title) + if (item.hostname) { + lines.push('站点: ' + item.hostname) + } + lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / ')) + lines.push('') + } + } + + return lines.join('\n') +} + +function pickBestAttempt(current, candidate) { + if (!current) { + return candidate + } + + if (candidate.acceptedItems.length !== current.acceptedItems.length) { + return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current + } + + if (candidate.distinctHostCount !== current.distinctHostCount) { + return candidate.distinctHostCount > current.distinctHostCount ? candidate : current + } + + if (candidate.rawItems.length !== current.rawItems.length) { + return candidate.rawItems.length > current.rawItems.length ? candidate : current + } + + return candidate +} + +module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { + const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000) + const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1500, 0, 10000) + const limit = normalizeInt(params.limit, 10, 1, 50) + const maxPages = normalizeInt(params.maxPages, 3, 1, 5) + const baseQuery = normalizeText(params.query) || buildDefaultQuery(params.keyword) + const excludedDomains = normalizeDomainList(params.excludeDomains).length > 0 + ? normalizeDomainList(params.excludeDomains) + : DEFAULT_EXCLUDED_DOMAINS + const outputFileName = normalizeText(params.outputFileName) || 'news-results.txt' + const scanLimit = Math.max(10, Math.min(20, limit * 2)) + const startUrls = Array.isArray(params.startUrls) && params.startUrls.length > 0 + ? params.startUrls + : undefined + + const session = await launch({ + selector, + startUrls, + skipDefaultStartUrls: true, + }) + + const connection = await connect(session) + const browser = connection.browser + const context = connection.context || browser.contexts()[0] + const page = await context.newPage() + const closeRunnerPage = async function () { + if (!page.isClosed()) { + await page.close().catch(function () {}) + } + } + + const searchCandidates = buildFallbackQueries(params.keyword, baseQuery) + const minAcceptedCount = Math.min(limit, Math.max(2, Math.ceil(limit * 0.2))) + const minDistinctHostCount = Math.min(3, minAcceptedCount) + let bestAttempt = null + + try { + for (const candidateQuery of searchCandidates) { + const searchQuery = buildSearchQuery(candidateQuery, excludedDomains) + const normalizedItems = [] + const seenUrls = new Set() + let scannedPageCount = 0 + let firstSearchUrl = '' + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const firstResultIndex = pageIndex * 10 + 1 + const searchUrl = buildSearchURL(searchQuery, params.timeRange, firstResultIndex) + + try { + await page.goto(searchUrl, { + waitUntil: 'domcontentloaded', + timeout, + }) + await page.waitForSelector('li.b_algo', { timeout }) + } catch (error) { + if (pageIndex > 0 && normalizedItems.length > 0) { + break + } + throw error + } + + if (waitAfterLoadMs > 0) { + await page.waitForTimeout(waitAfterLoadMs) + } + + if (!firstSearchUrl) { + firstSearchUrl = page.url() + } + + const pageItems = await page.$$eval('li.b_algo', function (nodes, maxItems) { + const clean = function (value) { + return String(value || '').replace(/\s+/g, ' ').trim() + } + + return nodes + .slice(0, maxItems) + .map(function (node) { + const titleLink = node.querySelector('h2 a') + const title = clean(titleLink && titleLink.textContent) + const url = titleLink ? titleLink.href : '' + const sourceNode = node.querySelector('.tptt') + const source = clean(sourceNode && sourceNode.textContent) + const citeNode = node.querySelector('.b_attribution cite') + const cite = clean(citeNode && citeNode.textContent) + const snippetNode = node.querySelector('.b_caption p') + const snippet = clean(snippetNode && snippetNode.textContent) + + if (!title) { + return null + } + + return { + title, + url, + source: source || cite, + snippet, + } + }) + .filter(Boolean) + }, scanLimit) + + let appendedCount = 0 + for (const item of pageItems) { + const dedupeKey = normalizeText(item.url) + if (!dedupeKey || seenUrls.has(dedupeKey)) { + continue + } + + seenUrls.add(dedupeKey) + normalizedItems.push( + evaluateNewsItem( + Object.assign( + { + rank: normalizedItems.length + 1, + }, + item, + splitSnippet(item.snippet) + ) + ) + ) + appendedCount += 1 + } + + scannedPageCount += 1 + if (appendedCount === 0 || pageItems.length < 8) { + break + } + } + + const acceptedItems = normalizedItems.filter(function (item) { + return item.qualityAccepted + }).slice(0, limit) + const rejectedItems = normalizedItems.filter(function (item) { + return !item.qualityAccepted + }) + const distinctHostCount = new Set( + acceptedItems + .map(function (item) { + return item.hostname + }) + .filter(Boolean) + ).size + + log('searchQuery', searchQuery) + log('rawItemCount', normalizedItems.length) + log('acceptedItemCount', acceptedItems.length) + log('rejectedItemCount', rejectedItems.length) + log('distinctHostCount', distinctHostCount) + log('scannedPageCount', scannedPageCount) + + bestAttempt = pickBestAttempt(bestAttempt, { + baseQuery: candidateQuery, + searchQuery: searchQuery, + searchUrl: firstSearchUrl || page.url(), + rawItems: normalizedItems, + acceptedItems: acceptedItems, + rejectedItems: rejectedItems, + distinctHostCount: distinctHostCount, + scannedPageCount: scannedPageCount, + }) + + if (acceptedItems.length >= minAcceptedCount && distinctHostCount >= minDistinctHostCount) { + break + } + } + } catch (error) { + await closeRunnerPage() + throw error + } + + if (!bestAttempt || bestAttempt.rawItems.length === 0) { + await closeRunnerPage() + throw new Error('未抓到新闻搜索结果,当前页面: ' + page.url()) + } + + const normalizedItems = bestAttempt.rawItems + const acceptedItems = bestAttempt.acceptedItems + const rejectedItems = bestAttempt.rejectedItems + const distinctHostCount = bestAttempt.distinctHostCount + const searchUrl = bestAttempt.searchUrl + const scannedPageCount = bestAttempt.scannedPageCount || 1 + + const outputName = outputFileName.toLowerCase().endsWith('.txt') + ? outputFileName + : outputFileName + '.txt' + const outputPath = artifact(outputName) + const reportText = formatReport(acceptedItems, { + query: bestAttempt.baseQuery, + generatedAt: new Date().toISOString(), + searchUrl: searchUrl, + rawCount: normalizedItems.length, + rejectedItems: rejectedItems, + }) + fs.writeFileSync(outputPath, reportText, 'utf8') + + let screenshotPath = '' + if (params.captureScreenshot === true) { + screenshotPath = artifact('news-search.png') + await page.screenshot({ + path: screenshotPath, + fullPage: true, + }) + } + + log('outputPath', outputPath) + await closeRunnerPage() + + if (acceptedItems.length < minAcceptedCount || distinctHostCount < minDistinctHostCount) { + return { + ok: false, + summary: '新闻结果质量不足,仅 ' + acceptedItems.length + '/' + normalizedItems.length + ' 条通过校验', + error: '搜索结果更像普通搜索、问答页或聚合页,未达到新闻抓取标准', + query: bestAttempt.baseQuery, + searchQuery: bestAttempt.searchQuery, + searchUrl: searchUrl, + outputPath, + screenshotPath, + rawItemCount: normalizedItems.length, + itemCount: acceptedItems.length, + rejectedCount: rejectedItems.length, + distinctHostCount: distinctHostCount, + scannedPageCount: scannedPageCount, + firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', + } + } + + return { + ok: true, + summary: '已筛出 ' + acceptedItems.length + ' 条有效新闻并写入 TXT', + query: bestAttempt.baseQuery, + searchQuery: bestAttempt.searchQuery, + searchUrl: searchUrl, + outputPath, + screenshotPath, + rawItemCount: normalizedItems.length, + itemCount: acceptedItems.length, + rejectedCount: rejectedItems.length, + distinctHostCount: distinctHostCount, + scannedPageCount: scannedPageCount, + firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', + } +}`, + notes: + "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行成功后可在结果里的 outputPath 找到文件。", + targetConfig: normalizeAutomationScriptTargetConfig(null), + source: { + type: "builtin", + uri: "repo://backend/internal/automation/default_scripts.go", + ref: "HEAD", + path: "news-query-txt", + importedAt: "", + }, + createdAt, + updatedAt: createdAt, + }; +} + +function createDualInstanceRuntimeScriptDraft(): AutomationScriptRecord { + const createdAt = nowIso(); + + return { + packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: DUAL_INSTANCE_RUNTIME_SCRIPT_ID, + name: "双实例启动与 Runtime 切换", + description: + "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。", + type: "launch-api", + status: "ready", + entryFile: "index.cjs", + tags: ["Launch API", "OpenClaw", "双实例"], + selectorText: "", + paramsText: buildDualInstanceRuntimeParamsText(), + scriptText: buildDualInstanceRuntimeScriptText(), + notes: + "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。", + targetConfig: normalizeAutomationScriptTargetConfig(null), + source: { + type: "builtin", + uri: "repo://backend/internal/automation/default_scripts.go", + ref: "HEAD", + path: "dual-instance-runtime-switch", + importedAt: "", + }, + createdAt, + updatedAt: createdAt, + }; +} + +function normalizeTags(tags: unknown): string[] { + if (!Array.isArray(tags)) { + return []; + } + + const deduped = new Set(); + for (const item of tags) { + const normalized = String(item || "").trim(); + if (normalized) { + deduped.add(normalized); + } + } + return Array.from(deduped); +} + +function normalizeScriptRecord(raw: unknown): AutomationScriptRecord | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const source = raw as Partial; + const type = source.type === "launch-api" ? "launch-api" : "playwright-cdp"; + const status = + source.status === "ready" || source.status === "disabled" + ? source.status + : "draft"; + const createdAt = + typeof source.createdAt === "string" && source.createdAt.trim() + ? source.createdAt + : nowIso(); + const updatedAt = + typeof source.updatedAt === "string" && source.updatedAt.trim() + ? source.updatedAt + : createdAt; + const normalizedSource = normalizeSource(source.source); + const normalizedTargetConfig = normalizeAutomationScriptTargetConfig( + source.targetConfig, + ); + + const record: AutomationScriptRecord = { + packageFormat: + typeof source.packageFormat === "string" && source.packageFormat.trim() + ? source.packageFormat.trim() + : AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: + typeof source.manifestVersion === "number" && source.manifestVersion > 0 + ? source.manifestVersion + : AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: + typeof source.id === "string" && source.id.trim() + ? source.id + : createScriptId(), + name: + typeof source.name === "string" && source.name.trim() + ? source.name.trim() + : "未命名脚本", + description: + typeof source.description === "string" ? source.description.trim() : "", + type, + status, + entryFile: + typeof source.entryFile === "string" && source.entryFile.trim() + ? source.entryFile.trim() + : "index.cjs", + tags: normalizeTags(source.tags), + selectorText: + typeof source.selectorText === "string" && source.selectorText.trim() + ? source.selectorText + : buildSelectorTemplate(type), + paramsText: + typeof source.paramsText === "string" && source.paramsText.trim() + ? source.paramsText + : buildParamsTemplate(type), + scriptText: + typeof source.scriptText === "string" && source.scriptText.trim() + ? source.scriptText + : buildScriptTemplate(type), + notes: + typeof source.notes === "string" && source.notes.trim() + ? source.notes + : buildNotesTemplate(type), + targetConfig: normalizedTargetConfig, + source: normalizedSource, + createdAt, + updatedAt, + }; + + if (record.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID) { + const dualInstanceDraft = createDualInstanceRuntimeScriptDraft(); + const usesLegacyDualInstanceScript = + record.scriptText.includes("params.primaryCode") || + record.scriptText.includes("params.secondaryCode"); + + return { + ...record, + selectorText: "", + paramsText: normalizeDualInstanceRuntimeParamsText(record.paramsText), + targetConfig: normalizeAutomationScriptTargetConfig(null), + scriptText: usesLegacyDualInstanceScript + ? dualInstanceDraft.scriptText + : record.scriptText, + }; + } + + return record; +} + +export function normalizeAutomationScriptRecordPayload( + raw: unknown, +): AutomationScriptRecord | null { + return normalizeScriptRecord(raw); +} + +export function createAutomationScriptDraft( + type: AutomationScriptType = "playwright-cdp", +): AutomationScriptRecord { + const createdAt = nowIso(); + const name = + type === "launch-api" ? "新建 Launch API 脚本" : "百度搜索示例"; + const description = + type === "launch-api" + ? "" + : "启动示例实例,打开百度并搜索关键词,用来验证 Launch API + Playwright CDP 链路。"; + + return { + packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: createScriptId(), + name, + description, + type, + status: type === "playwright-cdp" ? "ready" : "draft", + entryFile: "index.cjs", + tags: type === "launch-api" ? ["HTTP"] : ["Playwright", "示例"], + selectorText: buildSelectorTemplate(type), + paramsText: buildParamsTemplate(type), + scriptText: buildScriptTemplate(type), + notes: buildNotesTemplate(type), + targetConfig: normalizeAutomationScriptTargetConfig(null), + source: { + type: "manual", + uri: "", + ref: "", + path: "", + importedAt: "", + }, + createdAt, + updatedAt: createdAt, + }; +} + +export function duplicateAutomationScript( + script: AutomationScriptRecord, +): AutomationScriptRecord { + const createdAt = nowIso(); + return { + ...script, + id: createScriptId(), + name: `${script.name} - 副本`, + status: "draft", + createdAt, + updatedAt: createdAt, + }; +} + +function stringifyJsonBlock(value: unknown, fallback: string): string { + if (typeof value === "string" && value.trim()) { + return value; + } + + if (value && typeof value === "object") { + try { + return JSON.stringify(value, null, 2); + } catch { + return fallback; + } + } + + return fallback; +} + +export function importAutomationScript(text: string): AutomationScriptRecord { + const normalized = text.trim(); + if (!normalized) { + throw new Error("导入内容不能为空"); + } + + let parsed: any; + try { + parsed = JSON.parse(normalized); + } catch { + throw new Error("导入内容不是合法 JSON"); + } + + const manifest = + parsed?.manifest && typeof parsed.manifest === "object" + ? parsed.manifest + : parsed; + const type: AutomationScriptType = + manifest?.type === "launch-api" ? "launch-api" : "playwright-cdp"; + const timestamp = nowIso(); + const imported = normalizeScriptRecord({ + packageFormat: + typeof parsed?.packageFormat === "string" + ? parsed.packageFormat + : typeof parsed?.format === "string" + ? parsed.format + : AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: + typeof parsed?.manifestVersion === "number" + ? parsed.manifestVersion + : AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: createScriptId(), + name: typeof manifest?.name === "string" ? manifest.name : undefined, + description: + typeof manifest?.description === "string" ? manifest.description : "", + type, + status: "draft", + entryFile: + typeof manifest?.entryFile === "string" + ? manifest.entryFile + : "index.cjs", + tags: Array.isArray(manifest?.tags) ? manifest.tags : [], + selectorText: stringifyJsonBlock( + parsed?.selector ?? parsed?.selectorText, + buildSelectorTemplate(type), + ), + paramsText: stringifyJsonBlock( + parsed?.params ?? parsed?.paramsText, + buildParamsTemplate(type), + ), + scriptText: + typeof parsed?.script === "string" + ? parsed.script + : typeof parsed?.scriptText === "string" + ? parsed.scriptText + : buildScriptTemplate(type), + notes: + typeof parsed?.notes === "string" + ? parsed.notes + : typeof parsed?.manifest?.notes === "string" + ? parsed.manifest.notes + : buildNotesTemplate(type), + targetConfig: + parsed?.targetConfig && typeof parsed.targetConfig === "object" + ? parsed.targetConfig + : parsed?.manifest?.targetConfig && + typeof parsed.manifest.targetConfig === "object" + ? parsed.manifest.targetConfig + : null, + source: + parsed?.source && typeof parsed.source === "object" + ? parsed.source + : { + type: "text", + uri: "", + ref: "", + path: "", + importedAt: timestamp, + }, + createdAt: timestamp, + updatedAt: timestamp, + }); + + if (!imported) { + throw new Error("导入内容无法识别为脚本"); + } + + return imported; +} + +function buildDefaultScripts(): AutomationScriptRecord[] { + const dualInstanceScript = createDualInstanceRuntimeScriptDraft(); + const newsScript = createNewsTxtScriptDraft(); + + return [newsScript, dualInstanceScript]; +} + +function sortScripts( + items: AutomationScriptRecord[], +): AutomationScriptRecord[] { + return [...items].sort((left, right) => { + return ( + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime() + ); + }); +} + +export function loadAutomationScripts(): AutomationScriptRecord[] { + if (typeof window === "undefined" || !window.localStorage) { + return buildDefaultScripts(); + } + + try { + const raw = window.localStorage.getItem(AUTOMATION_SCRIPTS_STORAGE_KEY); + if (!raw) { + return buildDefaultScripts(); + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return buildDefaultScripts(); + } + + const scripts = parsed + .map((item) => normalizeScriptRecord(item)) + .filter((item): item is AutomationScriptRecord => item !== null); + + if (scripts.length === 0) { + return buildDefaultScripts(); + } + + return sortScripts(scripts); + } catch { + return buildDefaultScripts(); + } +} + +export function saveAutomationScripts(scripts: AutomationScriptRecord[]) { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + try { + window.localStorage.setItem( + AUTOMATION_SCRIPTS_STORAGE_KEY, + JSON.stringify(sortScripts(scripts)), + ); + } catch { + // Ignore storage failures and keep the editor usable. + } +} + +export function exportAutomationScript(script: AutomationScriptRecord): string { + return JSON.stringify( + { + manifest: { + packageFormat: script.packageFormat, + manifestVersion: script.manifestVersion, + id: script.id, + name: script.name, + description: script.description, + type: script.type, + status: script.status, + entryFile: script.entryFile, + tags: script.tags, + notes: script.notes, + targetConfig: script.targetConfig, + source: script.source, + createdAt: script.createdAt, + updatedAt: script.updatedAt, + }, + format: script.packageFormat, + manifestVersion: script.manifestVersion, + selector: safeParseJson(script.selectorText), + params: safeParseJson(script.paramsText), + script: script.scriptText, + notes: script.notes, + targetConfig: script.targetConfig, + source: script.source, + }, + null, + 2, + ); +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/frontend/src/modules/browser/components/AutomationEntryActions.tsx b/frontend/src/modules/browser/components/AutomationEntryActions.tsx new file mode 100644 index 00000000..094916b6 --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationEntryActions.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom' +import { BookOpen, Settings2 } from 'lucide-react' +import { Button } from '../../../shared/components' + +interface AutomationEntryActionsProps { + onBeforeNavigate?: () => void + size?: 'sm' | 'md' | 'lg' +} + +export function AutomationEntryActions({ + onBeforeNavigate, + size = 'sm', +}: AutomationEntryActionsProps) { + const navigate = useNavigate() + + const openRoute = (path: string) => { + onBeforeNavigate?.() + navigate(path) + } + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/modules/browser/components/AutomationRuntimeSnapshot.tsx b/frontend/src/modules/browser/components/AutomationRuntimeSnapshot.tsx new file mode 100644 index 00000000..dab13ace --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationRuntimeSnapshot.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { RefreshCw, Settings2 } from 'lucide-react' +import { Badge, Button, Card, Progress, toast } from '../../../shared/components' +import { EventsOn } from '../../../wailsjs/runtime/runtime' +import { defaultAutomationState, fetchAutomationState, type AutomationState } from '../../settings/api' +import { + getAutomationNodeSource, + getAutomationNodeSourceLabel, + getAutomationNodeVersion, + getAutomationPlaywrightVersion, + getAutomationRuntimeBadgeText, + getAutomationRuntimeBadgeVariant, + getAutomationSystemNodePath, +} from '../automationRuntime' + +interface AutomationRuntimeProgress { + phase: string + progress: number + message: string + component?: string +} + +interface AutomationRuntimeSnapshotProps { + title?: string + subtitle?: string + className?: string + showSettingsAction?: boolean +} + +function normalizeRuntimeProgress(payload: unknown): AutomationRuntimeProgress | null { + if (!payload || typeof payload !== 'object') { + return null + } + + const data = payload as Partial + return { + phase: typeof data.phase === 'string' ? data.phase : 'checking', + progress: Number.isFinite(data.progress) ? Math.max(0, Math.min(100, Math.round(Number(data.progress)))) : 0, + message: typeof data.message === 'string' && data.message.trim() + ? data.message.trim() + : '正在准备自动化运行时...', + component: typeof data.component === 'string' && data.component.trim() + ? data.component.trim() + : undefined, + } +} + +export function AutomationRuntimeSnapshot({ + title = '自动化运行时', + subtitle = '这里直接展示当前 Node 来源、版本和异常信息,避免排查时还要跳到设置页。', + className, + showSettingsAction = true, +}: AutomationRuntimeSnapshotProps) { + const navigate = useNavigate() + const [automationState, setAutomationState] = useState(defaultAutomationState) + const [runtimeProgress, setRuntimeProgress] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + useEffect(() => { + let disposed = false + + const loadState = async (showError: boolean) => { + try { + const nextState = await fetchAutomationState() + if (!disposed) { + setAutomationState(nextState) + } + } catch (error: any) { + if (showError) { + toast.error(error?.message || '自动化状态刷新失败') + } + } finally { + if (!disposed) { + setLoading(false) + setRefreshing(false) + } + } + } + + void loadState(false) + + const offRuntimeProgress = EventsOn('automation:runtime:progress', (payload: unknown) => { + const nextProgress = normalizeRuntimeProgress(payload) + if (!nextProgress) { + return + } + + setRuntimeProgress(nextProgress) + + if (nextProgress.phase === 'done' || nextProgress.phase === 'error') { + void loadState(false) + } + }) + + return () => { + disposed = true + offRuntimeProgress() + } + }, []) + + const handleRefresh = async () => { + setRefreshing(true) + try { + const nextState = await fetchAutomationState() + setAutomationState(nextState) + } catch (error: any) { + toast.error(error?.message || '自动化状态刷新失败') + } finally { + setLoading(false) + setRefreshing(false) + } + } + + const handleGoSettings = () => { + navigate('/settings') + } + + const nodeSource = getAutomationNodeSource(automationState) + const nodeSourceLabel = getAutomationNodeSourceLabel(nodeSource) + const nodeVersion = getAutomationNodeVersion(automationState) + const playwrightVersion = getAutomationPlaywrightVersion(automationState) + const systemNodePath = getAutomationSystemNodePath(automationState) + + return ( + + + {showSettingsAction && ( + + )} + + )} + > +
+
+ + 自动化支持 · {getAutomationRuntimeBadgeText(automationState)} + + + Node 来源 {nodeSourceLabel} + + + Node {nodeVersion} + + + playwright-core {playwrightVersion} + + {loading && ( + + 正在同步 + + )} +
+ + {runtimeProgress && ( +
+
+ {runtimeProgress.message} + + {runtimeProgress.component ? `${runtimeProgress.component} · ` : ''} + {runtimeProgress.phase} + +
+ +
+ )} + +
+
+ Runtime:{automationState.settings.runtimeVersion} + Node 来源:{nodeSourceLabel} + Node / playwright-core:{nodeVersion} / {playwrightVersion} +
+ {automationState.status.nodePath && ( +
+ Node 路径:{automationState.status.nodePath} +
+ )} + {systemNodePath && ( +
+ 系统 Node 路径:{systemNodePath} +
+ )} + {automationState.status.nodeResolution && ( +
+ 解析说明:{automationState.status.nodeResolution} +
+ )} + {automationState.status.runtimeDir && ( +
+ 运行时目录:{automationState.status.runtimeDir} +
+ )} + {automationState.status.systemNodeError && ( +
+ 系统 Node 异常:{automationState.status.systemNodeError} +
+ )} + {automationState.status.lastError && ( +
+ 最近错误:{automationState.status.lastError} +
+ )} + {!automationState.settings.enabled && ( +
+ 自动化尚未启用。打开开关后,首次真实使用时才会准备运行时。 +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/modules/browser/components/AutomationScriptExportModal.tsx b/frontend/src/modules/browser/components/AutomationScriptExportModal.tsx new file mode 100644 index 00000000..a6e5a5b4 --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationScriptExportModal.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { Button, Modal } from "../../../shared/components"; + +export type AutomationScriptExportFormat = "json" | "zip" | "directory"; + +interface AutomationScriptExportModalProps { + open: boolean; + busy: boolean; + onClose: () => void; + onSubmit: (format: AutomationScriptExportFormat) => void; +} + +const EXPORT_OPTIONS: Array<{ + value: AutomationScriptExportFormat; + title: string; + description: string; +}> = [ + { + value: "json", + title: "JSON 模板", + description: "适合复制、粘贴、文本分发和远程单文件导入。", + }, + { + value: "zip", + title: "ZIP 脚本包", + description: "适合对外分发和备份,多文件脚本会按真实目录结构导出。", + }, + { + value: "directory", + title: "目录脚本包", + description: "适合本地维护、人工查看和继续提交到 Git。", + }, +]; + +export function AutomationScriptExportModal({ + open, + busy, + onClose, + onSubmit, +}: AutomationScriptExportModalProps) { + const [selectedFormat, setSelectedFormat] = + useState("zip"); + + useEffect(() => { + if (open) { + setSelectedFormat("zip"); + } + }, [open]); + + return ( + undefined : onClose} + title="导出脚本" + width="560px" + footer={ + <> + + + + } + > +
+ {EXPORT_OPTIONS.map((option) => { + const active = option.value === selectedFormat; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx b/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx new file mode 100644 index 00000000..e3f7d058 --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx @@ -0,0 +1,447 @@ +import { + Fragment, + useEffect, + useState, + type KeyboardEvent, +} from "react"; +import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react"; +import { + Badge, + Button, + Modal, + toast, +} from "../../../shared/components"; +import { fetchAutomationScriptRuns } from "../automationScriptApi"; +import type { AutomationScriptRunRecord } from "../automationScripts"; + +interface AutomationScriptHistoryModalProps { + open: boolean; + onClose: () => void; +} + +function formatDateTime(value?: string): string { + if (!value) { + return "-"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function formatDuration(durationMs?: number): string { + if (!durationMs || durationMs <= 0) { + return "-"; + } + if (durationMs < 1000) { + return `${durationMs} ms`; + } + return `${(durationMs / 1000).toFixed(2)} s`; +} + +function getRunStatusLabel(status: AutomationScriptRunRecord["status"]): string { + switch (status) { + case "success": + return "成功"; + case "running": + return "运行中"; + default: + return "失败"; + } +} + +function getRunStatusBadgeVariant( + status: AutomationScriptRunRecord["status"], +): "success" | "info" | "error" { + switch (status) { + case "success": + return "success"; + case "running": + return "info"; + default: + return "error"; + } +} + +function normalizeRuns(items: AutomationScriptRunRecord[]): AutomationScriptRunRecord[] { + return [...items].sort( + (left, right) => + new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime(), + ); +} + +function HistoryStat({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function HistoryDetailField({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +export function AutomationScriptHistoryModal({ + open, + onClose, +}: AutomationScriptHistoryModalProps) { + const [runs, setRuns] = useState([]); + const [expandedRunId, setExpandedRunId] = useState(""); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + if (!open) { + setRuns([]); + setExpandedRunId(""); + setLoading(false); + return; + } + + let active = true; + setLoading(true); + + void fetchAutomationScriptRuns(200) + .then((items) => { + if (!active) { + return; + } + setRuns(normalizeRuns(items)); + }) + .catch((error: unknown) => { + if (!active) { + return; + } + setRuns([]); + const message = + error instanceof Error ? error.message : "调用记录加载失败"; + toast.error(message); + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + + return () => { + active = false; + }; + }, [open]); + + useEffect(() => { + if (!expandedRunId) { + return; + } + + if (!runs.some((item) => item.id === expandedRunId)) { + setExpandedRunId(""); + } + }, [expandedRunId, runs]); + + const handleRefresh = async () => { + setRefreshing(true); + try { + const items = await fetchAutomationScriptRuns(200); + setRuns(normalizeRuns(items)); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "调用记录刷新失败"; + toast.error(message); + } finally { + setRefreshing(false); + } + }; + + const toggleExpandedRun = (runId: string) => { + setExpandedRunId((current) => (current === runId ? "" : runId)); + }; + + const handleRowKeyDown = ( + event: KeyboardEvent, + runId: string, + ) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleExpandedRun(runId); + } + }; + + const latestRun = runs[0] || null; + const successCount = runs.filter((item) => item.status === "success").length; + const failedCount = runs.filter((item) => item.status === "failed").length; + const scriptCount = new Set( + runs.map((item) => String(item.scriptId || "").trim()).filter(Boolean), + ).size; + + return ( + + + + } + > +
+
+
+
+ 查看所有脚本最近的调用情况 +
+
+ 表格里只保留关键信息,点击某一行可展开查看错误、返回内容和完整摘要。 +
+
+ +
+ +
+ + + +
+ + {loading ? ( +
+
+
+
+ 正在加载调用记录... +
+
+
+ ) : runs.length === 0 ? ( +
+
+ 还没有调用记录 +
+
+ 脚本执行过之后,这里会显示调用时间、脚本名称、状态和结果摘要。 +
+
+ ) : ( +
+
+ + + + + + + + + + + + + + {runs.map((run) => { + const expanded = expandedRunId === run.id; + return ( + + toggleExpandedRun(run.id)} + onKeyDown={(event) => handleRowKeyDown(event, run.id)} + className="cursor-pointer transition-colors duration-150 hover:bg-[var(--color-bg-muted)]/55 focus:outline-none focus-visible:bg-[var(--color-accent-muted)]/40" + > + + + + + + + + + {expanded ? ( + + + + ) : null} + + ); + })} + +
+ 展开 + + 调用时间 + + 脚本 + + 状态 + + 类型 + + 耗时 + + 摘要 +
+ + {expanded ? ( + + ) : ( + + )} + + +
+ {formatDateTime(run.startedAt)} +
+
+
+ {run.scriptName || "未命名脚本"} +
+
+ + {getRunStatusLabel(run.status)} + + +
+ {run.scriptType || "-"} +
+
+ {formatDuration(run.durationMs)} + +
+ {run.summary || "未返回摘要"} +
+
+
+
+ + + + +
+ +
+ + +
+ +
+
+ 详细摘要 +
+
+ {run.summary || "未返回摘要"} +
+
+ + {run.error ? ( +
+
+ 错误信息 +
+
+                                      {run.error}
+                                    
+
+ ) : null} + + {run.resultText ? ( +
+
+ 返回内容 +
+
+                                      {run.resultText}
+                                    
+
+ ) : null} + + {!run.error && !run.resultText ? ( +
+ 这条记录没有更多详情。 +
+ ) : null} +
+
+
+
+ )} +
+ + ); +} diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx new file mode 100644 index 00000000..d6cf25ad --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx @@ -0,0 +1,937 @@ +import { useEffect, useState } from "react"; +import { Copy, Play } from "lucide-react"; +import { + Badge, + Button, + FormItem, + Input, + Modal, + Select, + Textarea, + toast, +} from "../../../shared/components"; +import { + copyBrowserProfile, + fetchBrowserProfiles, +} from "../api"; +import { runAutomationScript } from "../automationScriptApi"; +import { + DUAL_INSTANCE_RUNTIME_SCRIPT_ID, + describeAutomationScriptTargetConfig, + getAutomationScriptTypeLabel, + type AutomationScriptRecord, + type AutomationScriptRunRecord, +} from "../automationScripts"; +import { + type AutomationDemoSession, +} from "../demoSession"; +import { useAutomationDemoSession } from "../hooks/useAutomationDemoSession"; +import type { BrowserProfile } from "../types"; + +type DemoPreparationMode = "select" | "create"; + +type SelectableProfile = BrowserProfile & { + launchCode: string; +}; + +interface DemoCreateDraft { + profileName: string; + templateProfileId: string; +} + +interface AutomationScriptRunModalProps { + open: boolean; + script: AutomationScriptRecord | null; + dirty?: boolean; + onClose: () => void; +} + +const DEFAULT_DEMO_CREATE_DRAFT: DemoCreateDraft = { + profileName: "", + templateProfileId: "", +}; + +function validateJsonObjectText( + text: string, + label: string, + required: boolean, +): string { + const normalized = text.trim(); + if (!normalized) { + return required ? `${label}不能为空` : ""; + } + + try { + const parsed = JSON.parse(normalized); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return `${label}必须是 JSON 对象`; + } + return ""; + } catch { + return `${label}不是合法 JSON`; + } +} + +function formatDateTime(value?: string): string { + if (!value) { + return "-"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function formatDuration(durationMs?: number): string { + if (!durationMs || durationMs <= 0) { + return "-"; + } + if (durationMs < 1000) { + return `${durationMs} ms`; + } + return `${(durationMs / 1000).toFixed(2)} s`; +} + +async function copyToClipboard(text: string, successMessage: string) { + try { + await navigator.clipboard.writeText(text); + toast.success(successMessage); + } catch { + toast.error("复制失败"); + } +} + +function buildDemoSelectorText(launchCode: string) { + return JSON.stringify( + { + code: launchCode, + }, + null, + 2, + ); +} + +function normalizeLaunchCode(value?: string): string { + return String(value || "") + .trim() + .toUpperCase(); +} + +function isPlaceholderSelectorText(text: string): boolean { + const normalized = text.trim(); + if (!normalized) { + return true; + } + + try { + const parsed = JSON.parse(normalized); + const code = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? String((parsed as Record).code || "") + .trim() + .toUpperCase() + : ""; + return !code || code === "BUYER_001"; + } catch { + return false; + } +} + +function resolveInitialSelectorText( + script: AutomationScriptRecord, + demoSession: AutomationDemoSession, +): string { + if (script.targetConfig.mode !== "manual") { + return ""; + } + const currentSelectorText = String(script.selectorText || ""); + if ( + script.type === "playwright-cdp" && + isPlaceholderSelectorText(currentSelectorText) && + demoSession.launchCode + ) { + return buildDemoSelectorText(demoSession.launchCode); + } + return currentSelectorText; +} + +function resolveRunnableSelectorText( + script: AutomationScriptRecord, + currentSelectorText: string, + demoSession: AutomationDemoSession, +): string { + if (script.targetConfig.mode !== "manual") { + return currentSelectorText; + } + if ( + script.type === "playwright-cdp" && + isPlaceholderSelectorText(currentSelectorText) && + demoSession.launchCode + ) { + return buildDemoSelectorText(demoSession.launchCode); + } + return currentSelectorText; +} + +function resolveSelectorLaunchCode(text: string): string { + const normalized = text.trim(); + if (!normalized) { + return ""; + } + + try { + const parsed = JSON.parse(normalized); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return ""; + } + + return String((parsed as Record).code || "") + .trim() + .toUpperCase(); + } catch { + return ""; + } +} + +function filterSelectableProfiles(profiles: BrowserProfile[]): SelectableProfile[] { + return profiles + .flatMap((profile) => { + const launchCode = normalizeLaunchCode(profile.launchCode); + if (!launchCode) { + return []; + } + return [ + { + ...profile, + launchCode, + }, + ]; + }) + .sort((left, right) => { + if (left.running !== right.running) { + return left.running ? -1 : 1; + } + return left.profileName.localeCompare(right.profileName, "zh-CN"); + }); +} + +function resolvePreferredProfileId( + profiles: SelectableProfile[], + preferredProfileId: string, + preferredLaunchCode: string, +): string { + const normalizedProfileId = String(preferredProfileId || "").trim(); + const normalizedCode = normalizeLaunchCode(preferredLaunchCode); + if (!normalizedProfileId && !normalizedCode) { + return ""; + } + + if (normalizedProfileId) { + const matchedByID = profiles.find( + (profile) => profile.profileId === normalizedProfileId, + ); + if (matchedByID) { + return matchedByID.profileId; + } + } + + const matchedByCode = profiles.find( + (profile) => normalizeLaunchCode(profile.launchCode) === normalizedCode, + ); + if (matchedByCode) { + return matchedByCode.profileId; + } + + return ""; +} + +function buildSelectableProfileOptions(profiles: SelectableProfile[]) { + return profiles.map((profile) => ({ + value: profile.profileId, + label: `${profile.launchCode} · ${profile.profileName} · ${profile.running ? "运行中" : "已停止"}`, + })); +} + +function sortTemplateProfiles(profiles: BrowserProfile[]) { + return [...profiles].sort((left, right) => + left.profileName.localeCompare(right.profileName, "zh-CN"), + ); +} + +function buildTemplateProfileOptions(profiles: BrowserProfile[]) { + return profiles.map((profile) => ({ + value: profile.profileId, + label: [profile.launchCode || "", profile.profileName || profile.profileId] + .filter(Boolean) + .join(" · "), + })); +} + +export function AutomationScriptRunModal({ + open, + script, + dirty = false, + onClose, +}: AutomationScriptRunModalProps) { + const [selectorText, setSelectorText] = useState(""); + const [paramsText, setParamsText] = useState(""); + const [running, setRunning] = useState(false); + const [demoBusy, setDemoBusy] = useState(false); + const [lastRun, setLastRun] = useState( + null, + ); + const [demoMode, setDemoMode] = useState("select"); + const [availableProfiles, setAvailableProfiles] = useState( + [], + ); + const [templateProfiles, setTemplateProfiles] = useState([]); + const [profilesLoading, setProfilesLoading] = useState(false); + const [selectedProfileId, setSelectedProfileId] = useState(""); + const [createDraft, setCreateDraft] = useState( + DEFAULT_DEMO_CREATE_DRAFT, + ); + const { + demoSession, + setDemoSession, + reloadDemoSession, + } = useAutomationDemoSession({ enabled: open }); + + const selectedProfile = + availableProfiles.find((profile) => profile.profileId === selectedProfileId) || + null; + const selectedTemplateProfile = + templateProfiles.find( + (profile) => profile.profileId === createDraft.templateProfileId, + ) || null; + const isDualInstanceRuntimeScript = + script?.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID; + const usesStoredTargetConfig = + !!script && script.targetConfig.mode !== "manual"; + const showsSelectorInput = + !!script && !usesStoredTargetConfig && !isDualInstanceRuntimeScript; + const paramsLabel = isDualInstanceRuntimeScript ? "启动配置" : "运行参数"; + const paramsFieldLabel = isDualInstanceRuntimeScript + ? "浏览器列表 / 启动配置 JSON" + : "运行参数 JSON"; + const paramsPlaceholder = isDualInstanceRuntimeScript + ? `{ + "browsers": [ + { "code": "BUYER_001", "skipDefaultStartUrls": true }, + { "code": "BUYER_002", "skipDefaultStartUrls": true } + ], + "timeoutMs": 45000 +}` + : '{"startUrls":["https://example.com"]}'; + + const syncDemoSessionFromProfile = ( + profile: SelectableProfile, + actionLabel: string, + ) => { + setDemoSession((current) => ({ + ...current, + profileId: profile.profileId, + profileName: profile.profileName, + launchCode: profile.launchCode, + cdpUrl: + profile.running && profile.debugReady && profile.debugPort > 0 + ? `http://127.0.0.1:${profile.debugPort}` + : "", + debugPort: + profile.running && profile.debugReady && profile.debugPort > 0 + ? profile.debugPort + : 0, + lastAction: actionLabel, + })); + }; + + const refreshSelectableProfiles = async ( + preferredProfileId = "", + preferredLaunchCode = "", + showError = false, + ) => { + setProfilesLoading(true); + try { + const allProfiles = await fetchBrowserProfiles(); + const profiles = filterSelectableProfiles(allProfiles); + setAvailableProfiles(profiles); + setTemplateProfiles(sortTemplateProfiles(allProfiles)); + setSelectedProfileId((current) => { + const preferredProfile = resolvePreferredProfileId( + profiles, + preferredProfileId, + preferredLaunchCode, + ); + if (preferredProfile) { + return preferredProfile; + } + if (current && profiles.some((profile) => profile.profileId === current)) { + return current; + } + return ""; + }); + setCreateDraft((current) => { + if ( + current.templateProfileId && + allProfiles.some((profile) => profile.profileId === current.templateProfileId) + ) { + return current; + } + return { + ...current, + templateProfileId: allProfiles[0]?.profileId || "", + }; + }); + if (!profiles.length) { + setDemoMode("create"); + } + } catch (error: unknown) { + if (showError) { + const message = + error instanceof Error ? error.message : "实例列表刷新失败"; + toast.error(message); + } + } finally { + setProfilesLoading(false); + } + }; + + useEffect(() => { + if (!open || !script) { + return; + } + + const nextDemoSession = reloadDemoSession(); + const nextSelectorText = resolveInitialSelectorText(script, nextDemoSession); + setSelectorText(nextSelectorText); + setParamsText(script.paramsText || ""); + setLastRun(null); + setCreateDraft(DEFAULT_DEMO_CREATE_DRAFT); + setDemoMode( + nextDemoSession.launchCode || + resolveSelectorLaunchCode(nextSelectorText) + ? "select" + : "create", + ); + }, [open, script]); + + useEffect(() => { + if (!open || !script || script.type !== "playwright-cdp") { + setAvailableProfiles([]); + setSelectedProfileId(""); + return; + } + if (usesStoredTargetConfig) { + setAvailableProfiles([]); + setSelectedProfileId(""); + return; + } + + const nextDemoSession = reloadDemoSession(); + const nextSelectorText = resolveInitialSelectorText(script, nextDemoSession); + void refreshSelectableProfiles( + nextDemoSession.profileId, + resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode, + false, + ); + }, [open, reloadDemoSession, script, usesStoredTargetConfig]); + + useEffect(() => { + if (!open || !script || script.type !== "playwright-cdp") { + return; + } + if (usesStoredTargetConfig) { + return; + } + if (demoMode !== "select") { + return; + } + + void refreshSelectableProfiles("", demoSession.launchCode, false); + }, [demoMode, demoSession.launchCode, open, script, usesStoredTargetConfig]); + + const handleClose = () => { + if (running || demoBusy) { + return; + } + onClose(); + }; + + const executeRun = async (nextSelectorText: string, nextParamsText: string) => { + if (!script) { + return; + } + + const runnableSelectorText = usesStoredTargetConfig ? "" : nextSelectorText; + const launchCode = + script.type === "playwright-cdp" && !usesStoredTargetConfig + ? resolveSelectorLaunchCode(runnableSelectorText) + : ""; + + setRunning(true); + try { + const run = await runAutomationScript({ + scriptId: script.id, + selectorText: runnableSelectorText, + paramsText: nextParamsText, + useScriptSelector: usesStoredTargetConfig, + useScriptParams: false, + launchCode, + startByCodeBeforeRun: + script.type === "playwright-cdp" && + !usesStoredTargetConfig && + !!launchCode, + }); + setLastRun(run); + if (run.status === "success") { + toast.success(run.summary || "脚本执行完成"); + } else { + toast.error(run.error || run.summary || "脚本执行失败"); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "脚本执行失败"; + toast.error(message); + } finally { + setRunning(false); + } + }; + + const handleSelectedProfileChange = (profileId: string) => { + setSelectedProfileId(profileId); + const profile = + availableProfiles.find((item) => item.profileId === profileId) || null; + if (!profile) { + return; + } + + setSelectorText(buildDemoSelectorText(profile.launchCode)); + syncDemoSessionFromProfile(profile, "选择已有实例"); + }; + + const handleCreateProfileAndRun = async () => { + const paramsError = validateJsonObjectText(paramsText, paramsLabel, false); + if (paramsError) { + toast.warning(paramsError); + return; + } + + const profileName = createDraft.profileName.trim(); + if (!profileName) { + toast.warning("先输入实例名称"); + return; + } + if (!selectedTemplateProfile) { + toast.warning("先选择一个模板"); + return; + } + + setDemoBusy(true); + try { + const created = await copyBrowserProfile( + selectedTemplateProfile.profileId, + profileName, + ); + if (!created) { + throw new Error("实例创建失败"); + } + + const launchCode = normalizeLaunchCode(created.launchCode); + if (!launchCode) { + throw new Error("新实例未生成启动 code"); + } + + setDemoSession((current) => ({ + ...current, + profileId: created.profileId, + profileName: created.profileName, + launchCode, + cdpUrl: "", + debugPort: 0, + lastAction: "按模板创建实例", + })); + + const nextSelectorText = buildDemoSelectorText(launchCode); + setSelectorText(nextSelectorText); + setDemoMode("select"); + setCreateDraft((current) => ({ + ...current, + profileName: "", + })); + await refreshSelectableProfiles(created.profileId, launchCode, false); + setDemoBusy(false); + toast.success("实例已创建,开始执行脚本"); + await executeRun(nextSelectorText, paramsText); + return; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "实例创建或启动失败"; + toast.error(message); + } finally { + setDemoBusy(false); + } + }; + + const handleRun = async () => { + if (!script) { + return; + } + + let nextSelectorText = usesStoredTargetConfig + ? "" + : resolveRunnableSelectorText( + script, + selectorText, + demoSession, + ); + const selectorError = usesStoredTargetConfig + ? "" + : validateJsonObjectText( + nextSelectorText, + "目标选择器", + script.type === "launch-api" && + !usesStoredTargetConfig && + !isDualInstanceRuntimeScript, + ); + if (selectorError) { + toast.warning(selectorError); + return; + } + + const paramsError = validateJsonObjectText(paramsText, paramsLabel, false); + if (paramsError) { + toast.warning(paramsError); + return; + } + + if ( + script.type === "playwright-cdp" && + !usesStoredTargetConfig && + isPlaceholderSelectorText(nextSelectorText) + ) { + if (demoMode === "select" && selectedProfile) { + nextSelectorText = buildDemoSelectorText(selectedProfile.launchCode); + setSelectorText(nextSelectorText); + syncDemoSessionFromProfile(selectedProfile, "选择已有实例"); + toast.success("已自动回填所选实例 selector"); + } else { + toast.warning( + demoMode === "create" + ? "先创建一个实例,或填入可用 code" + : "先选择一个已有实例,或填入可用 code", + ); + return; + } + } + + if (nextSelectorText !== selectorText) { + setSelectorText(nextSelectorText); + } + + await executeRun(nextSelectorText, paramsText); + }; + + const handlePrimaryAction = async () => { + if (!script) { + return; + } + + if ( + script.type === "playwright-cdp" && + !usesStoredTargetConfig && + demoMode === "create" + ) { + await handleCreateProfileAndRun(); + return; + } + + await handleRun(); + }; + + if (!script) { + return null; + } + + const launchApiExecutable = script.status !== "disabled"; + const showDemoProfilePicker = + script.type === "playwright-cdp" && !usesStoredTargetConfig; + const selectableProfileOptions = buildSelectableProfileOptions(availableProfiles); + const templateProfileOptions = buildTemplateProfileOptions(templateProfiles); + + return ( + + + + + } + > +
+
+
+ + {getAutomationScriptTypeLabel(script.type)} + + + {script.status === "ready" + ? "可用" + : script.status === "disabled" + ? "停用" + : "草稿"} + +
+
+ {script.name} +
+
+ 最近更新 {formatDateTime(script.updatedAt)} +
+
+ + {dirty && ( +
+ {isDualInstanceRuntimeScript + ? "当前详情页还有未保存修改。本次执行只使用弹窗里的启动配置,不会自动保存页面内容。" + : "当前详情页还有未保存修改。本次执行只使用弹窗里的 selector / params,不会自动保存页面内容。"} +
+ )} + + {usesStoredTargetConfig && ( +
+
+ {describeAutomationScriptTargetConfig(script.targetConfig)} +
+
+ 本次执行会直接沿用脚本里保存的目标策略,弹窗中不会覆盖 selector。 +
+
+ )} + + {showDemoProfilePicker && ( +
+
+
+ 实例 +
+
+ + +
+
+ + {demoMode === "select" ? ( +
+ + setCreateDraft((current) => ({ + ...current, + profileName: event.target.value, + })) + } + placeholder="实例名称" + className="xl:w-56" + disabled={running || demoBusy} + /> +