From 5b4daa2cc6c5e8de7c2b10ef8be55093410c5398 Mon Sep 17 00:00:00 2001 From: ant-black <1016930479@qq.com> Date: Sat, 9 May 2026 15:41:16 +0800 Subject: [PATCH] =?UTF-8?q?V1.2.0=20=E5=8A=9F=E8=83=BD=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + backend/app_backup_config.go | 24 + backend/app_backup_data_merge.go | 19 + backend/app_backup_ops_test.go | 4 +- backend/app_bookmark.go | 96 +++- backend/app_bookmark_test.go | 169 ++++++ backend/app_browser_profile_api.go | 1 - backend/app_instance_launch_args.go | 33 ++ backend/app_instance_start.go | 13 +- backend/app_instance_start_prepare.go | 8 +- backend/app_instance_start_proxy.go | 27 +- backend/app_proxy_check_config.go | 144 +++++ backend/app_proxy_health.go | 33 +- backend/app_proxy_health_test.go | 28 + backend/app_proxy_save.go | 1 - backend/app_utils.go | 1 - backend/automation_demo_api.go | 12 +- backend/automation_script_run_entry.go | 42 +- backend/automation_script_run_launch_api.go | 40 +- backend/automation_script_run_playwright.go | 23 +- .../internal/automation/script_run_store.go | 1 + backend/internal/automation/sysproc_others.go | 9 +- .../internal/automation/sysproc_windows.go | 4 + .../internal/automation/task_runner_exec.go | 58 +- .../automation/task_runner_process.go | 4 + .../automation/task_runner_process_others.go | 10 + .../automation/task_runner_process_windows.go | 8 + .../internal/automation/task_runner_test.go | 60 ++ .../internal/automation/task_runner_types.go | 3 + backend/internal/browser/bookmark_dao.go | 17 +- backend/internal/browser/bookmarks.go | 45 +- backend/internal/browser/bookmarks_test.go | 185 +++++++ backend/internal/browser/connector_test.go | 9 +- .../browser/download_core_proxy_windows.go | 4 +- backend/internal/browser/environment_test.go | 4 +- .../internal/browser/profile_defaults_test.go | 2 +- .../internal/browser/proxy_binding_test.go | 4 +- backend/internal/config/config.go | 23 +- backend/internal/config/config_defaults.go | 39 +- backend/internal/config/config_test.go | 29 +- backend/internal/database/sqlite.go | 7 + backend/internal/launchcode/automation_api.go | 2 + backend/internal/proxy/browser_bridge.go | 89 +++ backend/internal/proxy/http_client.go | 12 + backend/internal/proxy/iphealth.go | 153 +++++- backend/internal/proxy/iphealth_test.go | 51 ++ backend/internal/proxy/parser.go | 20 +- backend/internal/proxy/resolve.go | 20 + .../internal/proxy/singbox_bridge_runtime.go | 11 +- backend/internal/proxy/speedtest.go | 42 +- backend/internal/proxy/speedtest_mapping.go | 52 +- backend/internal/proxy/speedtest_test.go | 43 +- backend/internal/proxy/utils_connectivity.go | 65 +-- backend/internal/proxy/utils_parse.go | 12 +- backend/internal/proxy/xray.go | 22 +- backend/internal/proxy/xray_bridge_launch.go | 215 +++++++- backend/internal/proxy/xray_chain_test.go | 210 +++++++ backend/internal/proxy/xray_runtime_config.go | 29 +- backend/internal/proxy/xray_validate_test.go | 175 +++++- backend/test/proxy/speedtest_debug_test.go | 8 +- config.yaml | 117 ++-- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 9 +- frontend/src/modules/browser/api.ts | 1 + frontend/src/modules/browser/api/bookmarks.ts | 30 +- frontend/src/modules/browser/api/instances.ts | 8 + frontend/src/modules/browser/api/proxies.ts | 8 +- .../src/modules/browser/api/proxyCheck.ts | 28 + frontend/src/modules/browser/api/runtime.ts | 8 +- .../modules/browser/automationScriptApi.ts | 4 + .../src/modules/browser/automationScripts.ts | 1 + .../components/AutomationScriptRunModal.tsx | 299 ++++++++-- .../browser/components/BrowserListLayout.tsx | 4 +- .../components/BrowserSettingsModal.tsx | 4 +- .../browser/components/FingerprintPanel.tsx | 4 +- .../browser/components/ProxyImportModal.tsx | 231 +++++--- .../browser/components/ProxyPickerModal.tsx | 57 +- .../browser/pages/BookmarkSettingsPage.tsx | 67 ++- .../modules/browser/pages/BrowserEditPage.tsx | 21 + .../modules/browser/pages/BrowserListPage.tsx | 35 ++ .../browser/pages/CoreManagementPage.tsx | 4 +- .../modules/browser/pages/ProxyPoolPage.tsx | 261 ++++++++- .../pages/browserList/BrowserListDialogs.tsx | 13 +- .../launchApiDocs/LaunchDocsFlowPage.tsx | 2 +- .../browser/pages/launchApiDocs/catalog.ts | 13 + .../pages/launchApiDocs/contentChangelog.ts | 27 + .../pages/launchApiDocs/contentIntro.ts | 14 +- .../pages/launchApiDocs/structuredApiDocs.ts | 41 +- .../pages/proxyPool/ProxyPoolHeader.tsx | 9 + .../pages/proxyPool/ProxyPoolModals.tsx | 434 ++++++++++++--- .../pages/proxyPool/ProxyPoolTableCard.tsx | 10 +- .../browser/pages/proxyPool/helpers.ts | 520 +++++++++++++++++- .../browser/pages/proxyPool/storage.ts | 5 +- frontend/src/modules/browser/types.ts | 27 + frontend/src/wailsjs/go/main/App.d.ts | 10 +- frontend/src/wailsjs/go/main/App.js | 16 + frontend/src/wailsjs/go/models.ts | 86 +++ go.mod | 1 + go.sum | 2 - publish/installer.nsi | 14 +- 100 files changed, 4317 insertions(+), 613 deletions(-) create mode 100644 backend/app_bookmark_test.go create mode 100644 backend/app_proxy_check_config.go create mode 100644 backend/app_proxy_health_test.go create mode 100644 backend/internal/automation/task_runner_process_others.go create mode 100644 backend/internal/automation/task_runner_process_windows.go create mode 100644 backend/internal/browser/bookmarks_test.go create mode 100644 backend/internal/proxy/browser_bridge.go create mode 100644 backend/internal/proxy/iphealth_test.go create mode 100644 backend/internal/proxy/resolve.go create mode 100644 backend/internal/proxy/xray_chain_test.go create mode 100644 frontend/src/modules/browser/api/proxyCheck.ts create mode 100644 frontend/src/modules/browser/pages/launchApiDocs/contentChangelog.ts diff --git a/README.md b/README.md index 5d6835e1..cd3654e3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ Ant Browser 适合以下场景: ## 近期更新 +### 1.2.0 · 2026-05-09 + +- 重点升级接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力 +- 完善自动化接口链路:脚本执行支持 selector / params 覆盖和 `timeoutMs` 超时控制,双实例 runtime 流程支持超时取消与错误返回 +- 增强代理池:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入 +- 优化代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测 +- 改进实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点 +- 升级书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例 + ### 1.1.0 · 2026-03-19 - 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题 diff --git a/backend/app_backup_config.go b/backend/app_backup_config.go index bfd69e76..1c32d765 100644 --- a/backend/app_backup_config.go +++ b/backend/app_backup_config.go @@ -258,6 +258,30 @@ func backupSrcTableExists(tx *sql.Tx, table string) (bool, error) { return cnt > 0, nil } +func backupSrcColumnExists(tx *sql.Tx, table string, column string) (bool, error) { + rows, err := tx.Query("PRAGMA src.table_info(" + table + ")") + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name string + var columnType string + var notNull int + var defaultValue any + var pk int + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk); err != nil { + return false, err + } + if strings.EqualFold(name, column) { + return true, nil + } + } + return false, rows.Err() +} + func backupCountRows(tx *sql.Tx, tableName string) (int, error) { var cnt int row := tx.QueryRow("SELECT COUNT(1) FROM " + tableName) diff --git a/backend/app_backup_data_merge.go b/backend/app_backup_data_merge.go index 30ee8733..f08c0d17 100644 --- a/backend/app_backup_data_merge.go +++ b/backend/app_backup_data_merge.go @@ -195,6 +195,25 @@ WHERE NOT EXISTS ( if !resetFirst { sqlText = item.insertSafe } + if item.name == "browser_bookmarks" { + hasOpenOnStart, err := backupSrcColumnExists(tx, item.name, "open_on_start") + if err != nil { + return err + } + if hasOpenOnStart { + if resetFirst { + sqlText = `INSERT INTO browser_bookmarks (name, url, open_on_start, sort_order) +SELECT name, url, COALESCE(open_on_start,0), sort_order FROM src.browser_bookmarks` + } else { + sqlText = `INSERT INTO browser_bookmarks (name, url, open_on_start, sort_order) +SELECT s.name, s.url, COALESCE(s.open_on_start,0), s.sort_order +FROM src.browser_bookmarks s +WHERE NOT EXISTS ( + SELECT 1 FROM browser_bookmarks t WHERE lower(t.url) = lower(s.url) +)` + } + } + } res, err := tx.Exec(sqlText) if err != nil { return fmt.Errorf("导入数据表失败(%s): %w", item.name, err) diff --git a/backend/app_backup_ops_test.go b/backend/app_backup_ops_test.go index 002c2b81..d3e0cf28 100644 --- a/backend/app_backup_ops_test.go +++ b/backend/app_backup_ops_test.go @@ -24,7 +24,7 @@ func TestBackupMergeConfigDedup(t *testing.T) { {Name: "Google", URL: "https://www.google.com/"}, } current.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://proxy.invalid:8080"}, } current.Browser.Cores = []config.BrowserCore{ {CoreId: "c1", CoreName: "C1", CorePath: "chrome/c1"}, @@ -40,7 +40,7 @@ func TestBackupMergeConfigDedup(t *testing.T) { {Name: "ChatGPT", URL: "https://chatgpt.com/"}, } incoming.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://proxy.invalid:8080"}, {ProxyId: "p2", ProxyName: "P2", ProxyConfig: "socks5://127.0.0.1:1080"}, } incoming.Browser.Cores = []config.BrowserCore{ diff --git a/backend/app_bookmark.go b/backend/app_bookmark.go index 2bb3be4c..993fa858 100644 --- a/backend/app_bookmark.go +++ b/backend/app_bookmark.go @@ -1,18 +1,38 @@ package backend import ( + "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" + "strings" ) type BrowserBookmark = config.BrowserBookmark +type BookmarkSyncResult struct { + Total int `json:"total"` + Synced int `json:"synced"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + SkippedList []string `json:"skippedList"` + FailedList []string `json:"failedList"` +} + var defaultBookmarkList = []BrowserBookmark{ {Name: "Google", URL: "https://www.google.com/"}, {Name: "Gmail", URL: "https://mail.google.com/"}, {Name: "Claude", URL: "https://claude.ai/"}, {Name: "ChatGPT", URL: "https://chatgpt.com/"}, {Name: "YouTube", URL: "https://www.youtube.com/"}, + {Name: "IPPure", URL: "https://ippure.com/"}, + {Name: "IPLark", URL: "https://iplark.com/"}, + {Name: "Ping0", URL: "https://ping0.cc/"}, +} + +var verificationBookmarkList = []BrowserBookmark{ + {Name: "IPPure", URL: "https://ippure.com/"}, + {Name: "IPLark", URL: "https://iplark.com/"}, + {Name: "Ping0", URL: "https://ping0.cc/"}, } // BookmarkList 获取默认书签列表(优先 SQLite,降级 config.yaml) @@ -20,11 +40,11 @@ func (a *App) BookmarkList() []BrowserBookmark { if a.browserMgr.BookmarkDAO != nil { list, err := a.browserMgr.BookmarkDAO.List() if err == nil && len(list) > 0 { - return list + return mergeBookmarksByURL(list, verificationBookmarkList) } } if len(a.config.Browser.DefaultBookmarks) > 0 { - return append([]BrowserBookmark{}, a.config.Browser.DefaultBookmarks...) + return mergeBookmarksByURL(a.config.Browser.DefaultBookmarks, verificationBookmarkList) } return append([]BrowserBookmark{}, defaultBookmarkList...) } @@ -34,10 +54,13 @@ func (a *App) BookmarkSave(items []BrowserBookmark) error { log := logger.New("Bookmark") valid := make([]BrowserBookmark, 0, len(items)) for _, item := range items { - if item.Name != "" && item.URL != "" { - valid = append(valid, item) + name := strings.TrimSpace(item.Name) + url := strings.TrimSpace(item.URL) + if name != "" && url != "" { + valid = append(valid, BrowserBookmark{Name: name, URL: url, OpenOnStart: item.OpenOnStart}) } } + valid = mergeBookmarksByURL(valid, verificationBookmarkList) if a.browserMgr.BookmarkDAO != nil { if err := a.browserMgr.BookmarkDAO.ReplaceAll(valid); err != nil { @@ -62,3 +85,68 @@ func (a *App) BookmarkSave(items []BrowserBookmark) error { func (a *App) BookmarkReset() error { return a.BookmarkSave(append([]BrowserBookmark{}, defaultBookmarkList...)) } + +func mergeBookmarksByURL(items []BrowserBookmark, required []BrowserBookmark) []BrowserBookmark { + merged := make([]BrowserBookmark, 0, len(items)+len(required)) + seen := make(map[string]struct{}, len(items)+len(required)) + appendOne := func(item BrowserBookmark) { + name := strings.TrimSpace(item.Name) + url := strings.TrimSpace(item.URL) + if name == "" || url == "" { + return + } + key := strings.ToLower(url) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + merged = append(merged, BrowserBookmark{Name: name, URL: url, OpenOnStart: item.OpenOnStart}) + } + for _, item := range items { + appendOne(item) + } + for _, item := range required { + appendOne(item) + } + return merged +} + +// BookmarkSyncToProfiles 将当前默认书签增量同步到已有未运行实例。 +func (a *App) BookmarkSyncToProfiles() BookmarkSyncResult { + result := BookmarkSyncResult{} + log := logger.New("Bookmark") + bookmarks := a.BookmarkList() + if len(bookmarks) == 0 || a.browserMgr == nil { + return result + } + + a.browserMgr.InitData() + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + result.Total = len(a.browserMgr.Profiles) + for _, profile := range a.browserMgr.Profiles { + if profile == nil { + continue + } + if isBrowserProfileLive(profile, a.browserMgr.BrowserProcesses[profile.ProfileId]) { + result.Skipped++ + result.SkippedList = append(result.SkippedList, profile.ProfileName) + continue + } + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if err := browser.EnsureDefaultBookmarks(userDataDir, bookmarks); err != nil { + result.Failed++ + name := profile.ProfileName + if name == "" { + name = profile.ProfileId + } + result.FailedList = append(result.FailedList, name) + log.Error("同步默认书签到实例失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err.Error())) + continue + } + result.Synced++ + } + return result +} diff --git a/backend/app_bookmark_test.go b/backend/app_bookmark_test.go new file mode 100644 index 00000000..03ca5f96 --- /dev/null +++ b/backend/app_bookmark_test.go @@ -0,0 +1,169 @@ +package backend + +import ( + internalbrowser "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestBookmarkSyncToProfilesAppliesCurrentDefaults(t *testing.T) { + t.Parallel() + + appRoot := t.TempDir() + cfg := config.DefaultConfig() + cfg.Browser.UserDataRoot = "data" + cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{ + {Name: "默认书签", URL: "https://default.example/"}, + } + + app := NewApp(appRoot) + app.config = cfg + app.browserMgr = internalbrowser.NewManager(cfg, appRoot) + app.browserMgr.Profiles["profile-1"] = &internalbrowser.Profile{ + ProfileId: "profile-1", + ProfileName: "实例 1", + UserDataDir: "profile-1", + } + + result := app.BookmarkSyncToProfiles() + if result.Total != 1 || result.Synced != 1 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("unexpected sync result: %+v", result) + } + + bookmarksPath := filepath.Join(appRoot, "data", "profile-1", "Default", "Bookmarks") + data, err := os.ReadFile(bookmarksPath) + if err != nil { + t.Fatalf("read bookmarks: %v", err) + } + var root map[string]interface{} + if err := json.Unmarshal(data, &root); err != nil { + t.Fatalf("unmarshal bookmarks: %v", err) + } + if countBookmarkURLInRoot(root, "https://default.example/") != 1 { + t.Fatalf("default bookmark was not synced once: %s", string(data)) + } +} + +func TestBookmarkListAlwaysIncludesVerificationBookmarks(t *testing.T) { + t.Parallel() + + appRoot := t.TempDir() + cfg := config.DefaultConfig() + cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{ + {Name: "用户默认书签", URL: "https://user.example/"}, + } + + app := NewApp(appRoot) + app.config = cfg + app.browserMgr = internalbrowser.NewManager(cfg, appRoot) + + bookmarks := app.BookmarkList() + for _, url := range []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} { + if countBookmarkItemsByURL(bookmarks, url) != 1 { + t.Fatalf("expected verification bookmark %s exactly once, got %+v", url, bookmarks) + } + } +} + +func TestBookmarkSavePersistsVerificationBookmarks(t *testing.T) { + t.Parallel() + + appRoot := t.TempDir() + cfg := config.DefaultConfig() + app := NewApp(appRoot) + app.config = cfg + app.browserMgr = internalbrowser.NewManager(cfg, appRoot) + + if err := app.BookmarkSave([]config.BrowserBookmark{{Name: "用户默认书签", URL: "https://user.example/", OpenOnStart: true}}); err != nil { + t.Fatalf("BookmarkSave returned error: %v", err) + } + if item, ok := findBookmarkItemByURL(app.config.Browser.DefaultBookmarks, "https://user.example/"); !ok || !item.OpenOnStart { + t.Fatalf("expected open_on_start to be preserved, got %+v", app.config.Browser.DefaultBookmarks) + } + for _, url := range []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} { + if countBookmarkItemsByURL(app.config.Browser.DefaultBookmarks, url) != 1 { + t.Fatalf("expected saved verification bookmark %s exactly once, got %+v", url, app.config.Browser.DefaultBookmarks) + } + } +} + +func TestBrowserDefaultStartURLsIncludesOpenOnStartBookmarks(t *testing.T) { + t.Parallel() + + appRoot := t.TempDir() + cfg := config.DefaultConfig() + cfg.Browser.DefaultStartURLs = []string{"https://home.example/"} + cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{ + {Name: "启动打开", URL: "https://open.example/", OpenOnStart: true}, + {Name: "普通书签", URL: "https://closed.example/"}, + {Name: "重复启动页", URL: "https://home.example/", OpenOnStart: true}, + } + + app := NewApp(appRoot) + app.config = cfg + app.browserMgr = internalbrowser.NewManager(cfg, appRoot) + + got := app.browserDefaultStartURLs() + want := []string{"https://home.example/", "https://open.example/"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("default start urls mismatch: got=%v want=%v", got, want) + } +} + +func countBookmarkURLInRoot(root map[string]interface{}, url string) int { + count := 0 + roots, ok := root["roots"].(map[string]interface{}) + if !ok { + return count + } + for _, item := range roots { + folder, ok := item.(map[string]interface{}) + if !ok { + continue + } + if children, ok := folder["children"].([]interface{}); ok { + count += countBookmarkURLInNodes(children, url) + } + } + return count +} + +func countBookmarkURLInNodes(nodes []interface{}, url string) int { + count := 0 + for _, item := range nodes { + node, ok := item.(map[string]interface{}) + if !ok { + continue + } + if node["type"] == "url" && node["url"] == url { + count++ + } + if children, ok := node["children"].([]interface{}); ok { + count += countBookmarkURLInNodes(children, url) + } + } + return count +} + +func findBookmarkItemByURL(items []config.BrowserBookmark, url string) (config.BrowserBookmark, bool) { + for _, item := range items { + if item.URL == url { + return item, true + } + } + return config.BrowserBookmark{}, false +} + +func countBookmarkItemsByURL(items []config.BrowserBookmark, url string) int { + count := 0 + for _, item := range items { + if item.URL == url { + count++ + } + } + return count +} diff --git a/backend/app_browser_profile_api.go b/backend/app_browser_profile_api.go index 8e838efd..b702d1d8 100644 --- a/backend/app_browser_profile_api.go +++ b/backend/app_browser_profile_api.go @@ -78,7 +78,6 @@ func (a *App) migrateToSQLite() { } else { srcProxies = []browser.Proxy{ {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, } log.Info("代理表为空,初始化默认代理") } diff --git a/backend/app_instance_launch_args.go b/backend/app_instance_launch_args.go index e37bf7a5..d875ba88 100644 --- a/backend/app_instance_launch_args.go +++ b/backend/app_instance_launch_args.go @@ -58,6 +58,39 @@ func browserDefaultStartURLs(cfg *config.Config) []string { return config.DefaultBrowserStartURLs() } +func (a *App) browserDefaultStartURLs() []string { + return mergeStartURLs(browserDefaultStartURLs(a.config), bookmarkStartURLs(a.BookmarkList())) +} + +func bookmarkStartURLs(bookmarks []BrowserBookmark) []string { + if len(bookmarks) == 0 { + return nil + } + urls := make([]string, 0, len(bookmarks)) + for _, bookmark := range bookmarks { + if bookmark.OpenOnStart { + urls = append(urls, bookmark.URL) + } + } + return normalizeNonEmptyStrings(urls) +} + +func mergeStartURLs(groups ...[]string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, group := range groups { + for _, item := range normalizeNonEmptyStrings(group) { + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + } + return out +} + func browserRestoreLastSession(cfg *config.Config) bool { if cfg == nil { return false diff --git a/backend/app_instance_start.go b/backend/app_instance_start.go index 7e800b70..dec98fbc 100644 --- a/backend/app_instance_start.go +++ b/backend/app_instance_start.go @@ -1,21 +1,26 @@ package backend func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) { - return a.browserInstanceStartInternal(profileId, nil, nil, false, false) + return a.browserInstanceStartInternal(profileId, nil, nil, false, false, false) } func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool { return len(normalizeNonEmptyStrings(startURLs)) > 0 } +// BrowserInstanceStartDirect 仅本次启动走直连,不落库修改实例代理配置。 +func (a *App) BrowserInstanceStartDirect(profileId string) (*BrowserProfile, error) { + return a.browserInstanceStartInternal(profileId, nil, nil, false, false, true) +} + // 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) + return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, false) } -func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) (*BrowserProfile, error) { - input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow) +func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) (*BrowserProfile, error) { + input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, forceDirectProxy) a.browserMgr.Mutex.Lock() defer a.browserMgr.Mutex.Unlock() diff --git a/backend/app_instance_start_prepare.go b/backend/app_instance_start_prepare.go index e1df1962..e9762333 100644 --- a/backend/app_instance_start_prepare.go +++ b/backend/app_instance_start_prepare.go @@ -16,6 +16,7 @@ type browserStartInput struct { StartURLs []string SkipDefaultStartURLs bool PreferVisibleWindow bool + ForceDirectProxy bool } type browserStartPlan struct { @@ -33,7 +34,7 @@ type browserStartPlan struct { totalReadyTimeout time.Duration } -func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) browserStartInput { +func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) browserStartInput { normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs) if preferVisibleWindow { normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs) @@ -45,6 +46,7 @@ func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs StartURLs: normalizeNonEmptyStrings(startURLs), SkipDefaultStartURLs: skipDefaultStartURLs, PreferVisibleWindow: preferVisibleWindow, + ForceDirectProxy: forceDirectProxy, } } @@ -108,7 +110,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP return nil, err } - effectiveProxy, acquiredXrayBridgeKey, releaseXrayBridge, err := a.resolveBrowserStartProxy(input.ProfileID, profile) + effectiveProxy, acquiredXrayBridgeKey, releaseXrayBridge, err := a.resolveBrowserStartProxy(input, profile) if err != nil { return nil, err } @@ -133,7 +135,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP profile: profile, chromeBinaryPath: chromeBinaryPath, userDataDir: userDataDir, - args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, browserDefaultStartURLs(a.config), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)), + args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, a.browserDefaultStartURLs(), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)), effectiveProxy: effectiveProxy, acquiredXrayBridgeKey: acquiredXrayBridgeKey, releaseXrayBridge: releaseXrayBridge, diff --git a/backend/app_instance_start_proxy.go b/backend/app_instance_start_proxy.go index e558d943..f2a0b372 100644 --- a/backend/app_instance_start_proxy.go +++ b/backend/app_instance_start_proxy.go @@ -5,13 +5,12 @@ import ( "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) { +func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *BrowserProfile) (string, string, bool, error) { log := logger.New("Browser") proxies := a.getLatestProxies() + profileID := input.ProfileID resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig) if profile.ProxyId != "" { @@ -29,6 +28,13 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile logger.F("profile_proxy_config", profile.ProxyConfig), logger.F("resolved_proxy_config", resolvedProxyConfig), ) + if input.ForceDirectProxy { + log.Warn("按请求直连启动实例", + logger.F("profile_id", profileID), + logger.F("proxy_id", profile.ProxyId), + ) + return "direct://", "", false, nil + } if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported { startErr := fmt.Errorf("实例启动失败:%s", errorMsg) profile.LastError = startErr.Error() @@ -50,14 +56,13 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile 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) { + if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) { socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId) if bridgeErr != nil { startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) @@ -66,7 +71,6 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile 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)) @@ -75,14 +79,3 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile 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_proxy_check_config.go b/backend/app_proxy_check_config.go new file mode 100644 index 00000000..490c4730 --- /dev/null +++ b/backend/app_proxy_check_config.go @@ -0,0 +1,144 @@ +package backend + +import ( + "strings" + "time" + + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/proxy" +) + +type ProxyCheckSettings = config.ProxyCheckConfig +type ProxyCheckTarget = config.ProxyCheckTarget + +func (a *App) GetProxyCheckSettings() ProxyCheckSettings { + if a.config == nil { + return config.DefaultConfig().ProxyCheck + } + settings := a.config.ProxyCheck + settings.Targets = append([]config.ProxyCheckTarget{}, settings.Targets...) + return settings +} + +func (a *App) SaveProxyCheckSettings(settings ProxyCheckSettings) error { + if a.config == nil { + return nil + } + settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, 15000) + settings.SpeedTargetID = strings.TrimSpace(settings.SpeedTargetID) + settings.IPHealthTargetID = strings.TrimSpace(settings.IPHealthTargetID) + settings.Targets = normalizeProxyCheckTargets(settings.Targets) + if len(settings.Targets) == 0 { + settings.Targets = config.DefaultConfig().ProxyCheck.Targets + } + if settings.SpeedTargetID == "" { + settings.SpeedTargetID = firstProxyCheckTargetID(settings.Targets, "speed", "") + } + if settings.IPHealthTargetID == "" { + settings.IPHealthTargetID = firstProxyCheckTargetID(settings.Targets, "ip_health", "") + } + a.config.ProxyCheck = settings + return a.config.Save(a.resolveAppPath("config.yaml")) +} + +func (a *App) proxySpeedTestConfig() *proxy.SpeedTestConfig { + cfg := proxy.DefaultSpeedTestConfig + if a == nil || a.config == nil { + return &cfg + } + target := a.proxyCheckTarget(a.config.ProxyCheck.SpeedTargetID, "speed") + if strings.TrimSpace(target.URL) != "" { + cfg.URLs = []string{strings.TrimSpace(target.URL)} + } + if target.TimeoutMs > 0 { + cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond + } + return &cfg +} + +func (a *App) proxyIPHealthConfig() *proxy.IPHealthConfig { + cfg := &proxy.IPHealthConfig{Source: "ip_health"} + if a == nil || a.config == nil { + return cfg + } + target := a.proxyCheckTarget(a.config.ProxyCheck.IPHealthTargetID, "ip_health") + if strings.TrimSpace(target.URL) != "" { + cfg.URL = strings.TrimSpace(target.URL) + } + if strings.TrimSpace(target.ID) != "" { + cfg.Source = strings.TrimSpace(target.ID) + } + if strings.TrimSpace(target.Parser) != "" { + cfg.Parser = strings.TrimSpace(target.Parser) + } + if target.TimeoutMs > 0 { + cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond + } + return cfg +} + +func (a *App) proxyCheckTarget(id string, targetType string) config.ProxyCheckTarget { + if a == nil || a.config == nil { + return config.ProxyCheckTarget{} + } + normalizedID := strings.TrimSpace(id) + normalizedType := strings.TrimSpace(targetType) + for _, target := range a.config.ProxyCheck.Targets { + if normalizedID != "" && strings.EqualFold(strings.TrimSpace(target.ID), normalizedID) { + return target + } + } + for _, target := range a.config.ProxyCheck.Targets { + if normalizedType != "" && strings.EqualFold(strings.TrimSpace(target.Type), normalizedType) { + return target + } + } + return config.ProxyCheckTarget{} +} + +func normalizeProxyCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyCheckTarget { + result := make([]config.ProxyCheckTarget, 0, len(targets)) + seen := map[string]struct{}{} + for _, target := range targets { + target.ID = strings.TrimSpace(target.ID) + target.Name = strings.TrimSpace(target.Name) + target.Type = strings.TrimSpace(target.Type) + target.URL = strings.TrimSpace(target.URL) + target.Parser = strings.TrimSpace(target.Parser) + if target.ID == "" || target.URL == "" { + continue + } + key := strings.ToLower(target.ID) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if target.Name == "" { + target.Name = target.ID + } + if target.Type == "" { + target.Type = "speed" + } + if target.TimeoutMs <= 0 { + target.TimeoutMs = 10000 + } + result = append(result, target) + } + return result +} + +func firstProxyCheckTargetID(targets []config.ProxyCheckTarget, targetType string, fallback string) string { + for _, target := range targets { + if strings.EqualFold(strings.TrimSpace(target.Type), targetType) { + return strings.TrimSpace(target.ID) + } + } + return fallback +} + +func normalizePositiveInt(value int, fallback int) int { + if value > 0 { + return value + } + return fallback +} diff --git a/backend/app_proxy_health.go b/backend/app_proxy_health.go index 2053c151..a52ebf62 100644 --- a/backend/app_proxy_health.go +++ b/backend/app_proxy_health.go @@ -15,7 +15,7 @@ import ( // BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果 func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult { proxies := a.getLatestProxies() - result := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil) + result := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig()) if a.browserMgr.ProxyDAO != nil { testedAt := time.Now().Format(time.RFC3339) _ = a.browserMgr.ProxyDAO.UpdateSpeedResult(proxyId, result.Ok, result.LatencyMs, testedAt) @@ -49,7 +49,7 @@ func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []P go func() { defer wg.Done() for job := range jobs { - result := proxy.SpeedTest(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, nil) + result := proxy.SpeedTest(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig()) if a.browserMgr.ProxyDAO != nil { testedAt := time.Now().Format(time.RFC3339) _ = a.browserMgr.ProxyDAO.UpdateSpeedResult(job.ProxyId, result.Ok, result.LatencyMs, testedAt) @@ -73,10 +73,10 @@ func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []P return results } -// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息(通过 IPPure 接口) +// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息 func (a *App) BrowserProxyCheckIPHealth(proxyId string) ProxyIPHealthResult { proxies := a.getLatestProxies() - data, err := proxy.FetchIPPureInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr) + data, err := proxy.FetchIPHealthInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxyIPHealthConfig()) result := buildProxyIPHealthResult(proxyId, data, err) a.persistProxyIPHealthResult(result) if a.ctx != nil { @@ -111,7 +111,7 @@ func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int) go func() { defer wg.Done() for job := range jobs { - data, err := proxy.FetchIPPureInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr) + data, err := proxy.FetchIPHealthInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxyIPHealthConfig()) result := buildProxyIPHealthResult(job.ProxyId, data, err) a.persistProxyIPHealthResult(result) results[job.Idx] = result @@ -132,25 +132,26 @@ func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int) } func buildProxyIPHealthResult(proxyId string, data map[string]interface{}, err error) ProxyIPHealthResult { + if data == nil { + data = map[string]interface{}{} + } + if err != nil { + data["error"] = err.Error() return ProxyIPHealthResult{ ProxyId: proxyId, Ok: false, - Source: "ippure", + Source: mapStringDefault(data, "_source", "ip_health"), Error: err.Error(), - RawData: map[string]interface{}{}, + RawData: data, UpdatedAt: time.Now().Format(time.RFC3339), } } - if data == nil { - data = map[string]interface{}{} - } - return ProxyIPHealthResult{ ProxyId: proxyId, Ok: true, - Source: "ippure", + Source: mapStringDefault(data, "_source", "ip_health"), Error: "", IP: mapString(data, "ip"), FraudScore: mapInt64(data, "fraudScore"), @@ -165,6 +166,14 @@ func buildProxyIPHealthResult(proxyId string, data map[string]interface{}, err e } } +func mapStringDefault(data map[string]interface{}, key string, fallback string) string { + value := strings.TrimSpace(mapString(data, key)) + if value == "" { + return fallback + } + return value +} + func (a *App) persistProxyIPHealthResult(result ProxyIPHealthResult) { if a.browserMgr.ProxyDAO == nil { return diff --git a/backend/app_proxy_health_test.go b/backend/app_proxy_health_test.go new file mode 100644 index 00000000..7671d6e7 --- /dev/null +++ b/backend/app_proxy_health_test.go @@ -0,0 +1,28 @@ +package backend + +import ( + "errors" + "testing" +) + +func TestBuildProxyIPHealthResultPreservesErrorSourceMetadata(t *testing.T) { + result := buildProxyIPHealthResult("proxy-1", map[string]interface{}{ + "_source": "trace", + "_targetUrl": "https://example.invalid/trace", + "_parser": "cloudflare_trace", + }, errors.New("request failed")) + + if result.Source != "trace" { + t.Fatalf("source = %q, want trace", result.Source) + } + if result.Error != "request failed" { + t.Fatalf("error = %q, want request failed", result.Error) + } + rawError, _ := result.RawData["error"].(string) + if rawError != "request failed" { + t.Fatalf("raw error = %q, want request failed", rawError) + } + if got, _ := result.RawData["_targetUrl"].(string); got != "https://example.invalid/trace" { + t.Fatalf("target url = %q, want trace url", got) + } +} diff --git a/backend/app_proxy_save.go b/backend/app_proxy_save.go index 45b84ff8..831b5066 100644 --- a/backend/app_proxy_save.go +++ b/backend/app_proxy_save.go @@ -62,7 +62,6 @@ func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error { builtins := []BrowserProxy{ {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, } for _, builtin := range builtins { found := false diff --git a/backend/app_utils.go b/backend/app_utils.go index 60f3a108..405ff3da 100644 --- a/backend/app_utils.go +++ b/backend/app_utils.go @@ -179,7 +179,6 @@ func (a *App) loadProxies() { builtins := []browser.Proxy{ {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"}, } ensureBuiltins := func(list []browser.Proxy) []browser.Proxy { diff --git a/backend/automation_demo_api.go b/backend/automation_demo_api.go index d898f2bb..84c61e22 100644 --- a/backend/automation_demo_api.go +++ b/backend/automation_demo_api.go @@ -116,13 +116,20 @@ func (a *App) AutomationDemoDeleteProfile(profileId string) (map[string]interfac } func (a *App) automationDemoRequest(method string, apiPath string, body any) (int, map[string]interface{}, error) { + return a.automationDemoRequestWithContext(context.Background(), method, apiPath, body) +} + +func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) { + if ctx == nil { + ctx = context.Background() + } 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) + ctx, cancel := context.WithTimeout(ctx, automationDemoTimeout) defer cancel() var reader io.Reader @@ -147,6 +154,9 @@ func (a *App) automationDemoRequest(method string, apiPath string, body any) (in resp, err := (&http.Client{Timeout: automationDemoTimeout}).Do(req) if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return 0, nil, fmt.Errorf("call launch api failed: %w", ctxErr) + } return 0, nil, fmt.Errorf("call launch api failed: %w", err) } defer resp.Body.Close() diff --git a/backend/automation_script_run_entry.go b/backend/automation_script_run_entry.go index 0345c402..3b30de2a 100644 --- a/backend/automation_script_run_entry.go +++ b/backend/automation_script_run_entry.go @@ -1,6 +1,7 @@ package backend import ( + "context" "fmt" "path/filepath" "strings" @@ -9,6 +10,12 @@ import ( "ant-chrome/backend/internal/automation" ) +const ( + automationScriptRunDefaultTimeout = 5 * time.Minute + automationScriptRunMinTimeout = 1 * time.Second + automationScriptRunMaxTimeout = 30 * time.Minute +) + func (a *App) automationScriptRunStore() *automation.ScriptRunStore { return automation.NewScriptRunStore(a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "runs")))) } @@ -43,9 +50,16 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) run.ScriptName = script.Name run.ScriptType = script.Type + runCtx := a.ctx + if runCtx == nil { + runCtx = context.Background() + } + runCtx, cancel := context.WithTimeout(runCtx, automationScriptRunTimeout(input)) + defer cancel() + switch script.Type { case "launch-api": - resultText, summary, errText := a.runLaunchAPIScript(script, input) + resultText, summary, errText := a.runLaunchAPIScript(runCtx, script, input) run.ResultText = resultText run.Summary = summary run.Error = errText @@ -53,7 +67,7 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) run.Status = "success" } case "playwright-cdp": - resultText, summary, errText := a.runPlaywrightScript(script, input) + resultText, summary, errText := a.runPlaywrightScript(runCtx, script, input) run.ResultText = resultText run.Summary = summary run.Error = errText @@ -68,6 +82,30 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) return a.finalizeAutomationScriptRun(run, startedAt) } +func automationScriptRunTimeout(input automation.ScriptRunRequest) time.Duration { + if input.TimeoutMs <= 0 { + return automationScriptRunDefaultTimeout + } + timeout := time.Duration(input.TimeoutMs) * time.Millisecond + if timeout < automationScriptRunMinTimeout { + return automationScriptRunMinTimeout + } + if timeout > automationScriptRunMaxTimeout { + return automationScriptRunMaxTimeout + } + return timeout +} + +func automationRunContextErrorMessage(err error) string { + if err == context.DeadlineExceeded { + return "自动化任务超时,已终止" + } + if err == context.Canceled { + return "自动化任务已取消" + } + return err.Error() +} + 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() diff --git a/backend/automation_script_run_launch_api.go b/backend/automation_script_run_launch_api.go index f55b90f5..82820720 100644 --- a/backend/automation_script_run_launch_api.go +++ b/backend/automation_script_run_launch_api.go @@ -1,6 +1,7 @@ package backend import ( + "context" "encoding/json" "fmt" "net/http" @@ -34,10 +35,16 @@ type dualInstanceRuntimeBrowser struct { LaunchArgs []string } -func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { +func (a *App) runLaunchAPIScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } paramsText := resolveAutomationRunJSONText(input.ParamsText, script.ParamsText, input.UseScriptParams) if script.ID == automation.DualInstanceRuntimeScriptID { - return a.runDualInstanceRuntimeLaunchAPIScript(paramsText) + return a.runDualInstanceRuntimeLaunchAPIScript(ctx, paramsText) } selector, targetSummary, err := a.resolveAutomationEffectiveSelector(script, input, true) @@ -55,8 +62,11 @@ func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automatio body[key] = value } - status, payload, reqErr := a.automationDemoRequest(http.MethodPost, automationDemoLaunchPath, body) + status, payload, reqErr := a.automationDemoRequestWithContext(ctx, http.MethodPost, automationDemoLaunchPath, body) if reqErr != nil { + if err := ctx.Err(); err != nil { + return "", "Launch API 请求失败", automationRunContextErrorMessage(err) + } return "", "Launch API 请求失败", reqErr.Error() } @@ -80,7 +90,10 @@ func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automatio return responseText, summary, errorText } -func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, string, string) { +func (a *App) runDualInstanceRuntimeLaunchAPIScript(ctx context.Context, paramsText string) (string, string, string) { + if ctx == nil { + ctx = context.Background() + } browsers, timeoutMs, err := parseDualInstanceRuntimeParams(paramsText) if err != nil { return "", "脚本执行失败", err.Error() @@ -90,7 +103,16 @@ func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, browserCodes := make([]string, 0, len(browsers)) for _, browser := range browsers { - sessionStatus, sessionPayload, reqErr := a.automationDemoRequest( + if err := ctx.Err(); err != nil { + return buildDualInstanceRuntimeFailureResult( + sessions, + browserCodes, + "双实例流程超时", + automationRunContextErrorMessage(err), + ) + } + sessionStatus, sessionPayload, reqErr := a.automationDemoRequestWithContext( + ctx, http.MethodPost, automationDemoRuntimeSessionPath, map[string]any{ @@ -107,6 +129,14 @@ func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, sessionPayload = ensureAutomationPayload(sessionPayload, browser.Code) sessions = append(sessions, sessionPayload) if reqErr != nil { + if err := ctx.Err(); err != nil { + return buildDualInstanceRuntimeFailureResult( + sessions, + browserCodes, + "双实例流程超时", + automationRunContextErrorMessage(err), + ) + } return buildDualInstanceRuntimeFailureResult( sessions, browserCodes, diff --git a/backend/automation_script_run_playwright.go b/backend/automation_script_run_playwright.go index 591b4d28..9a17737c 100644 --- a/backend/automation_script_run_playwright.go +++ b/backend/automation_script_run_playwright.go @@ -1,6 +1,7 @@ package backend import ( + "context" "fmt" "strings" @@ -28,16 +29,25 @@ func (a *App) ensurePlaywrightTargetReady(selector map[string]any) error { return nil } -func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { +func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { + if ctx == nil { + ctx = context.Background() + } 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 { + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } + if err := a.automationMgr.EnsureInstalled(ctx); err != nil { return "", "脚本执行失败", err.Error() } + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } state := a.automationMgr.CurrentState() if !state.Ready { @@ -53,6 +63,9 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati if err := a.ensurePlaywrightTargetReady(selector); err != nil { return "", "脚本执行失败", err.Error() } + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } params, err := parseAutomationJSONObject(paramsText, false) if err != nil { return "", "脚本执行失败", err.Error() @@ -68,8 +81,11 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati return "", "脚本执行失败", err.Error() } defer cleanup() + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } - taskResult, err := a.automationMgr.RunScriptTask(a.ctx, automation.ScriptTaskRequest{ + taskResult, err := a.automationMgr.RunScriptTask(ctx, automation.ScriptTaskRequest{ TaskKey: "script:" + script.ID, ScriptPath: scriptPath, Selector: selector, @@ -78,6 +94,7 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati LaunchAuthHeader: authHeader, LaunchAuthValue: authValue, ArtifactDir: artifactDir, + Timeout: automationScriptRunTimeout(input), }) if err != nil { return "", "脚本执行失败", err.Error() diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go index 41f0841b..571d057d 100644 --- a/backend/internal/automation/script_run_store.go +++ b/backend/internal/automation/script_run_store.go @@ -32,6 +32,7 @@ type ScriptRunRequest struct { ParamsText string `json:"paramsText"` UseScriptSelector bool `json:"useScriptSelector"` UseScriptParams bool `json:"useScriptParams"` + TimeoutMs int `json:"timeoutMs,omitempty"` } type ScriptRunStore struct { diff --git a/backend/internal/automation/sysproc_others.go b/backend/internal/automation/sysproc_others.go index 32cd94c7..169e08c3 100644 --- a/backend/internal/automation/sysproc_others.go +++ b/backend/internal/automation/sysproc_others.go @@ -3,7 +3,14 @@ package automation -import "os/exec" +import ( + "os/exec" + "syscall" +) func hideWindow(cmd *exec.Cmd) { } + +func prepareTaskCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} diff --git a/backend/internal/automation/sysproc_windows.go b/backend/internal/automation/sysproc_windows.go index 0b19cb56..e082009e 100644 --- a/backend/internal/automation/sysproc_windows.go +++ b/backend/internal/automation/sysproc_windows.go @@ -11,3 +11,7 @@ import ( func hideWindow(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} } + +func prepareTaskCommand(cmd *exec.Cmd) { + hideWindow(cmd) +} diff --git a/backend/internal/automation/task_runner_exec.go b/backend/internal/automation/task_runner_exec.go index 61cf5e16..e34f8862 100644 --- a/backend/internal/automation/task_runner_exec.go +++ b/backend/internal/automation/task_runner_exec.go @@ -21,6 +21,14 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr if ctx == nil { ctx = context.Background() } + timeoutLimit := req.Timeout + if req.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, req.Timeout) + defer cancel() + } else if deadline, ok := ctx.Deadline(); ok { + timeoutLimit = time.Until(deadline) + } state := m.CurrentState() if !state.Ready { @@ -58,6 +66,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr payload, "自动化 script task 已启动", "自动化 script task 已完成", + timeoutLimit, ) if err != nil { return ScriptTaskResult{}, err @@ -87,7 +96,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr return result, nil } -func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string) (string, taskRunnerResponse, string, int64, error) { +func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string, timeoutLimit time.Duration) (string, taskRunnerResponse, string, int64, error) { taskID, err := m.registerTask(taskKey) if err != nil { return "", taskRunnerResponse{}, "", 0, err @@ -103,7 +112,11 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR state := m.CurrentState() cmd := exec.CommandContext(ctx, state.NodePath, state.RunnerPath, payloadPath) cmd.Dir = state.RuntimeDir - hideWindow(cmd) + prepareTaskCommand(cmd) + cmd.Cancel = func() error { + return stopTaskProcess(cmd) + } + cmd.WaitDelay = 5 * time.Second startedAt := time.Now() m.attachTaskCommand(taskID, cmd) @@ -118,6 +131,21 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR output, runErr := cmd.CombinedOutput() durationMs := time.Since(startedAt).Milliseconds() if runErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + _ = stopTaskProcess(cmd) + message := taskContextErrorMessage(ctxErr, timeoutLimit) + 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) + } + message := strings.TrimSpace(string(output)) if message == "" { message = runErr.Error() @@ -152,6 +180,32 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR return taskID, runnerResp, string(output), durationMs, nil } +func taskContextErrorMessage(err error, timeoutLimit time.Duration) string { + if err == context.DeadlineExceeded { + if timeoutText := formatTaskTimeout(timeoutLimit); timeoutText != "" { + return fmt.Sprintf("自动化任务超时,已终止(上限 %s)", timeoutText) + } + return "自动化任务超时,已终止" + } + if err == context.Canceled { + return "自动化任务已取消" + } + return err.Error() +} + +func formatTaskTimeout(timeout time.Duration) string { + if timeout <= 0 { + return "" + } + if timeout >= time.Minute && timeout%time.Minute == 0 { + return fmt.Sprintf("%d 分钟", int64(timeout/time.Minute)) + } + if timeout >= time.Second && timeout%time.Second == 0 { + return fmt.Sprintf("%d 秒", int64(timeout/time.Second)) + } + return fmt.Sprintf("%d 毫秒", timeout.Milliseconds()) +} + func (m *Manager) writeTaskPayload(payload taskRunnerPayload) (string, error) { tempDir := filepath.Join(m.runtimeRoot(), "tmp") if err := os.MkdirAll(tempDir, 0o755); err != nil { diff --git a/backend/internal/automation/task_runner_process.go b/backend/internal/automation/task_runner_process.go index db158163..d56289c8 100644 --- a/backend/internal/automation/task_runner_process.go +++ b/backend/internal/automation/task_runner_process.go @@ -80,6 +80,10 @@ func stopTaskProcess(cmd *exec.Cmd) error { if err := killCmd.Run(); err == nil { return nil } + } else if cmd.Process.Pid > 0 { + if err := killProcessGroup(cmd.Process.Pid); err == nil { + return nil + } } err := cmd.Process.Kill() if err == nil { diff --git a/backend/internal/automation/task_runner_process_others.go b/backend/internal/automation/task_runner_process_others.go new file mode 100644 index 00000000..a487c5e3 --- /dev/null +++ b/backend/internal/automation/task_runner_process_others.go @@ -0,0 +1,10 @@ +//go:build !windows +// +build !windows + +package automation + +import "syscall" + +func killProcessGroup(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) +} diff --git a/backend/internal/automation/task_runner_process_windows.go b/backend/internal/automation/task_runner_process_windows.go new file mode 100644 index 00000000..1726b4cc --- /dev/null +++ b/backend/internal/automation/task_runner_process_windows.go @@ -0,0 +1,8 @@ +//go:build windows +// +build windows + +package automation + +func killProcessGroup(pid int) error { + return nil +} diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go index 35217e73..d2d26963 100644 --- a/backend/internal/automation/task_runner_test.go +++ b/backend/internal/automation/task_runner_test.go @@ -395,6 +395,66 @@ func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { } } +func TestRunScriptTaskTerminatesHungScriptOnTimeout(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) + } + + 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-timeout.cjs") + scriptSource := `module.exports.run = async () => { + await new Promise(() => setInterval(() => {}, 1000)) +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + startedAt := time.Now() + _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:timeout", + ScriptPath: scriptPath, + LaunchBaseURL: "http://127.0.0.1", + Timeout: 150 * time.Millisecond, + }) + elapsed := time.Since(startedAt) + if err == nil { + t.Fatalf("expected RunScriptTask to fail on timeout") + } + if !strings.Contains(err.Error(), "超时") { + t.Fatalf("expected timeout error, got %v", err) + } + if elapsed > 3*time.Second { + t.Fatalf("expected timeout to terminate quickly, took %s", elapsed) + } + + manager.mu.Lock() + activeTaskCount := len(manager.activeTasks) + profileTaskCount := len(manager.profileTask) + manager.mu.Unlock() + if activeTaskCount != 0 || profileTaskCount != 0 { + t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount) + } +} + func lookupNodeExecutable(t *testing.T) string { t.Helper() diff --git a/backend/internal/automation/task_runner_types.go b/backend/internal/automation/task_runner_types.go index 10fce170..ba7e0e3c 100644 --- a/backend/internal/automation/task_runner_types.go +++ b/backend/internal/automation/task_runner_types.go @@ -1,5 +1,7 @@ package automation +import "time" + type ScriptTaskRequest struct { TaskKey string `json:"taskKey"` ScriptPath string `json:"scriptPath"` @@ -9,6 +11,7 @@ type ScriptTaskRequest struct { LaunchAuthHeader string `json:"launchAuthHeader,omitempty"` LaunchAuthValue string `json:"launchAuthValue,omitempty"` ArtifactDir string `json:"artifactDir,omitempty"` + Timeout time.Duration `json:"-"` } type ScriptTaskResult struct { diff --git a/backend/internal/browser/bookmark_dao.go b/backend/internal/browser/bookmark_dao.go index 25d6531c..ecc4ea15 100644 --- a/backend/internal/browser/bookmark_dao.go +++ b/backend/internal/browser/bookmark_dao.go @@ -26,7 +26,7 @@ func NewSQLiteBookmarkDAO(db *sql.DB) *SQLiteBookmarkDAO { // List 查询所有默认书签,按 sort_order 升序 func (d *SQLiteBookmarkDAO) List() ([]config.BrowserBookmark, error) { rows, err := d.db.Query(` - SELECT name, url FROM browser_bookmarks ORDER BY sort_order ASC, id ASC`) + SELECT name, url, COALESCE(open_on_start, 0) FROM browser_bookmarks ORDER BY sort_order ASC, id ASC`) if err != nil { return nil, fmt.Errorf("查询书签列表失败: %w", err) } @@ -35,9 +35,11 @@ func (d *SQLiteBookmarkDAO) List() ([]config.BrowserBookmark, error) { var list []config.BrowserBookmark for rows.Next() { var b config.BrowserBookmark - if err := rows.Scan(&b.Name, &b.URL); err != nil { + var openOnStart int + if err := rows.Scan(&b.Name, &b.URL, &openOnStart); err != nil { return nil, fmt.Errorf("读取书签行失败: %w", err) } + b.OpenOnStart = openOnStart != 0 list = append(list, b) } return list, rows.Err() @@ -59,11 +61,18 @@ func (d *SQLiteBookmarkDAO) ReplaceAll(bookmarks []config.BrowserBookmark) error continue } if _, err := tx.Exec( - `INSERT INTO browser_bookmarks (name, url, sort_order) VALUES (?, ?, ?)`, - b.Name, b.URL, i, + `INSERT INTO browser_bookmarks (name, url, open_on_start, sort_order) VALUES (?, ?, ?, ?)`, + b.Name, b.URL, boolToInt(b.OpenOnStart), i, ); err != nil { return fmt.Errorf("插入书签失败: %w", err) } } return tx.Commit() } + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/backend/internal/browser/bookmarks.go b/backend/internal/browser/bookmarks.go index 25711619..ae68bc3a 100644 --- a/backend/internal/browser/bookmarks.go +++ b/backend/internal/browser/bookmarks.go @@ -42,14 +42,19 @@ func EnsureDefaultBookmarks(userDataDir string, bookmarks []config.BrowserBookma root = newEmptyBookmarkRoot(now) } - // 取出 bookmark_bar children,收集已有 URL 集合 - barChildren, existingURLs := extractBarChildren(root) + // 取出 bookmark_bar children,按整个书签树收集已有 URL 集合 + barChildren := extractBarChildren(root) + existingURLs := collectRootURLs(root) // 计算当前最大 id,用于分配新 id maxID := findMaxID(root) // 把不存在的默认书签追加进去 + added := false for _, b := range bookmarks { + if b.Name == "" || b.URL == "" { + continue + } if existingURLs[b.URL] { continue } @@ -64,6 +69,12 @@ func EnsureDefaultBookmarks(userDataDir string, bookmarks []config.BrowserBookma "type": "url", "url": b.URL, }) + existingURLs[b.URL] = true + added = true + } + + if !added { + return nil } // 写回 @@ -121,9 +132,8 @@ func newEmptyBookmarkRoot(now string) map[string]interface{} { } } -// extractBarChildren 从根结构中提取书签栏 children 和已有 URL 集合 -func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string]bool) { - existing := map[string]bool{} +// extractBarChildren 从根结构中提取书签栏 children +func extractBarChildren(root map[string]interface{}) []interface{} { var children []interface{} roots, ok := root["roots"].(map[string]interface{}) @@ -135,7 +145,7 @@ func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string] "name": "书签栏", }, } - return children, existing + return children } bar, ok := roots["bookmark_bar"].(map[string]interface{}) @@ -146,14 +156,31 @@ func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string] "name": "书签栏", } root["roots"] = roots - return children, existing + return children } if c, ok := bar["children"].([]interface{}); ok { children = c - collectURLs(c, existing) } - return children, existing + return children +} + +func collectRootURLs(root map[string]interface{}) map[string]bool { + existing := map[string]bool{} + roots, ok := root["roots"].(map[string]interface{}) + if !ok { + return existing + } + for _, item := range roots { + folder, ok := item.(map[string]interface{}) + if !ok { + continue + } + if children, ok := folder["children"].([]interface{}); ok { + collectURLs(children, existing) + } + } + return existing } // collectURLs 递归收集所有书签 URL diff --git a/backend/internal/browser/bookmarks_test.go b/backend/internal/browser/bookmarks_test.go new file mode 100644 index 00000000..edf50d11 --- /dev/null +++ b/backend/internal/browser/bookmarks_test.go @@ -0,0 +1,185 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestEnsureDefaultBookmarksOnlyAppendsMissingItems(t *testing.T) { + t.Parallel() + + userDataDir := t.TempDir() + profileDir := filepath.Join(userDataDir, "Default") + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatalf("create profile dir: %v", err) + } + + root := newEmptyBookmarkRoot("0") + roots := root["roots"].(map[string]interface{}) + bar := roots["bookmark_bar"].(map[string]interface{}) + bar["children"] = []interface{}{ + map[string]interface{}{ + "id": "4", + "name": "用户自己的书签", + "type": "url", + "url": "https://user.example/", + }, + } + other := roots["other"].(map[string]interface{}) + other["children"] = []interface{}{ + map[string]interface{}{ + "id": "5", + "name": "其他文件夹已有默认书签", + "type": "url", + "url": "https://existing.example/", + }, + } + writeBookmarkRoot(t, profileDir, root) + + err := EnsureDefaultBookmarks(userDataDir, []config.BrowserBookmark{ + {Name: "已存在默认书签", URL: "https://existing.example/"}, + {Name: "新增默认书签", URL: "https://new.example/"}, + {Name: "", URL: "https://ignored-name.example/"}, + {Name: "忽略空 URL", URL: ""}, + }) + if err != nil { + t.Fatalf("EnsureDefaultBookmarks returned error: %v", err) + } + + updated := readBookmarkRoot(t, profileDir) + if got := countBookmarkURL(updated, "https://user.example/"); got != 1 { + t.Fatalf("用户自己的书签不应被改动: count=%d", got) + } + if got := countBookmarkURL(updated, "https://existing.example/"); got != 1 { + t.Fatalf("已存在 URL 不应跨文件夹重复添加: count=%d", got) + } + if got := countBookmarkURL(updated, "https://new.example/"); got != 1 { + t.Fatalf("新增默认书签应追加一次: count=%d", got) + } + if got := countBookmarkURL(updated, "https://ignored-name.example/"); got != 0 { + t.Fatalf("空名称书签不应写入: count=%d", got) + } + if !bookmarkBarHasURL(updated, "https://user.example/") { + t.Fatalf("用户自己的书签应保留在书签栏") + } + if !bookmarkBarHasURL(updated, "https://new.example/") { + t.Fatalf("新增默认书签应追加到书签栏") + } +} + +func TestEnsureDefaultBookmarksDoesNotRewriteWhenNothingMissing(t *testing.T) { + t.Parallel() + + userDataDir := t.TempDir() + profileDir := filepath.Join(userDataDir, "Default") + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatalf("create profile dir: %v", err) + } + + root := newEmptyBookmarkRoot("0") + roots := root["roots"].(map[string]interface{}) + bar := roots["bookmark_bar"].(map[string]interface{}) + bar["date_modified"] = "unchanged" + bar["children"] = []interface{}{ + map[string]interface{}{ + "id": "4", + "name": "已有默认书签", + "type": "url", + "url": "https://existing.example/", + }, + } + writeBookmarkRoot(t, profileDir, root) + before, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks")) + if err != nil { + t.Fatalf("read before: %v", err) + } + + err = EnsureDefaultBookmarks(userDataDir, []config.BrowserBookmark{ + {Name: "已有默认书签", URL: "https://existing.example/"}, + }) + if err != nil { + t.Fatalf("EnsureDefaultBookmarks returned error: %v", err) + } + after, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks")) + if err != nil { + t.Fatalf("read after: %v", err) + } + if string(after) != string(before) { + t.Fatalf("没有新增项时不应重写用户书签文件") + } +} + +func writeBookmarkRoot(t *testing.T, profileDir string, root map[string]interface{}) { + t.Helper() + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + t.Fatalf("marshal bookmarks: %v", err) + } + if err := os.WriteFile(filepath.Join(profileDir, "Bookmarks"), data, 0o644); err != nil { + t.Fatalf("write bookmarks: %v", err) + } +} + +func readBookmarkRoot(t *testing.T, profileDir string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks")) + if err != nil { + t.Fatalf("read bookmarks: %v", err) + } + var root map[string]interface{} + if err := json.Unmarshal(data, &root); err != nil { + t.Fatalf("unmarshal bookmarks: %v", err) + } + return root +} + +func countBookmarkURL(root map[string]interface{}, url string) int { + count := 0 + roots, ok := root["roots"].(map[string]interface{}) + if !ok { + return count + } + for _, item := range roots { + folder, ok := item.(map[string]interface{}) + if !ok { + continue + } + if children, ok := folder["children"].([]interface{}); ok { + count += countURLInNodes(children, url) + } + } + return count +} + +func countURLInNodes(nodes []interface{}, url string) int { + count := 0 + for _, item := range nodes { + node, ok := item.(map[string]interface{}) + if !ok { + continue + } + if node["type"] == "url" && node["url"] == url { + count++ + } + if children, ok := node["children"].([]interface{}); ok { + count += countURLInNodes(children, url) + } + } + return count +} + +func bookmarkBarHasURL(root map[string]interface{}, url string) bool { + roots, ok := root["roots"].(map[string]interface{}) + if !ok { + return false + } + bar, ok := roots["bookmark_bar"].(map[string]interface{}) + if !ok { + return false + } + children, ok := bar["children"].([]interface{}) + return ok && countURLInNodes(children, url) > 0 +} diff --git a/backend/internal/browser/connector_test.go b/backend/internal/browser/connector_test.go index 28e14fd1..8f41b618 100644 --- a/backend/internal/browser/connector_test.go +++ b/backend/internal/browser/connector_test.go @@ -9,16 +9,9 @@ func TestBuildLaunchArgsAppendsDefaultVerificationURLs(t *testing.T) { t.Parallel() baseArgs := []string{"--disable-sync"} - got := BuildLaunchArgs(append([]string{}, baseArgs...), []string{ - "https://ippure.com/", - "https://iplark.com/", - "https://ping0.cc/", - }) + got := BuildLaunchArgs(append([]string{}, baseArgs...), []string{}) want := []string{ "--disable-sync", - "https://ippure.com/", - "https://iplark.com/", - "https://ping0.cc/", } if !reflect.DeepEqual(got, want) { diff --git a/backend/internal/browser/download_core_proxy_windows.go b/backend/internal/browser/download_core_proxy_windows.go index 4c9adc2e..e32f3fc9 100644 --- a/backend/internal/browser/download_core_proxy_windows.go +++ b/backend/internal/browser/download_core_proxy_windows.go @@ -10,7 +10,7 @@ import ( ) // readSystemProxy 从 Windows 注册表读取当前系统代理(WinINet,Clash 会写这里)。 -// 返回格式如 "http://127.0.0.1:7890" 或 "socks5://127.0.0.1:7891"。 +// 返回格式如 "http://host:port" 或 "socks5://host:port"。 func readSystemProxy() (string, error) { k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, @@ -30,7 +30,7 @@ func readSystemProxy() (string, error) { return "", fmt.Errorf("代理地址为空") } - // proxyServer 可能是 "127.0.0.1:7890" 或 "http=..;https=.." 多协议格式 + // proxyServer 可能是 "host:port" 或 "http=..;https=.." 多协议格式 // 不含协议前缀时默认补 http:// if !strings.Contains(proxyServer, ":") { return "", fmt.Errorf("无效的代理格式: %s", proxyServer) diff --git a/backend/internal/browser/environment_test.go b/backend/internal/browser/environment_test.go index 53c140ad..a793116f 100644 --- a/backend/internal/browser/environment_test.go +++ b/backend/internal/browser/environment_test.go @@ -53,7 +53,7 @@ func TestGetProxyConfigByIdPreferDAO(t *testing.T) { func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) { cfg := config.DefaultConfig() cfg.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "pool-2", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "pool-2", ProxyConfig: "http://proxy.invalid:8080"}, } mgr := NewManager(cfg, "") @@ -63,7 +63,7 @@ func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) { if !ok { t.Fatalf("expected proxy to be found in config fallback") } - if got != "http://127.0.0.1:7890" { + if got != "http://proxy.invalid:8080" { t.Fatalf("unexpected proxy config: %q", got) } } diff --git a/backend/internal/browser/profile_defaults_test.go b/backend/internal/browser/profile_defaults_test.go index 329fa95e..96187d06 100644 --- a/backend/internal/browser/profile_defaults_test.go +++ b/backend/internal/browser/profile_defaults_test.go @@ -11,7 +11,7 @@ func TestApplyDefaultsDoesNotFallbackToDirectAfterPoolBindByProxyConfig(t *testi mgr.ProxyDAO = &proxyDAOStub{ list: []Proxy{ {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "pool-1", ProxyName: "香港-01", ProxyConfig: "socks5://127.0.0.1:1080"}, + {ProxyId: "pool-1", ProxyName: "节点-01", ProxyConfig: "socks5://127.0.0.1:1080"}, }, } diff --git a/backend/internal/browser/proxy_binding_test.go b/backend/internal/browser/proxy_binding_test.go index 2351d38e..45e859db 100644 --- a/backend/internal/browser/proxy_binding_test.go +++ b/backend/internal/browser/proxy_binding_test.go @@ -12,7 +12,7 @@ func TestResolveProfileProxyBindingBySourceAndName(t *testing.T) { list: []Proxy{ { ProxyId: "new-p1", - ProxyName: "香港-01", + ProxyName: "节点-01", ProxyConfig: "socks5://127.0.0.1:1080", SourceID: "src-hk", SourceURL: "https://example.com/sub", @@ -25,7 +25,7 @@ func TestResolveProfileProxyBindingBySourceAndName(t *testing.T) { ProxyId: "old-missing-id", ProxyConfig: "socks5://127.0.0.1:2080", ProxyBindSourceID: "src-hk", - ProxyBindName: "香港-01", + ProxyBindName: "节点-01", } changed, boundInPool, mode := mgr.ResolveProfileProxyBinding(profile) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 16551b3c..af1b5921 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -84,10 +84,28 @@ type Config struct { Runtime RuntimeConfig `yaml:"runtime"` Logging LoggingConfig `yaml:"logging"` Browser BrowserConfig `yaml:"browser"` + ProxyCheck ProxyCheckConfig `yaml:"proxy_check"` LaunchServer LaunchServerConfig `yaml:"launch_server"` Automation AutomationConfig `yaml:"automation"` } +type ProxyCheckConfig struct { + BridgeStartTimeoutMs int `yaml:"bridge_start_timeout_ms" json:"bridgeStartTimeoutMs"` + SpeedTargetID string `yaml:"speed_target_id" json:"speedTargetId"` + IPHealthTargetID string `yaml:"ip_health_target_id" json:"ipHealthTargetId"` + Targets []ProxyCheckTarget `yaml:"targets" json:"targets"` +} + +type ProxyCheckTarget struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Type string `yaml:"type" json:"type"` + URL string `yaml:"url" json:"url"` + Parser string `yaml:"parser,omitempty" json:"parser,omitempty"` + TimeoutMs int `yaml:"timeout_ms,omitempty" json:"timeoutMs,omitempty"` + ExpectedStatus []int `yaml:"expected_status,omitempty" json:"expectedStatus,omitempty"` +} + type DatabaseConfig struct { Type string `yaml:"type"` SQLite SQLiteConfig `yaml:"sqlite"` @@ -117,8 +135,9 @@ type RuntimeConfig struct { } type BrowserBookmark struct { - Name string `yaml:"name" json:"name"` - URL string `yaml:"url" json:"url"` + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` + OpenOnStart bool `yaml:"open_on_start,omitempty" json:"openOnStart"` } type BrowserConfig struct { diff --git a/backend/internal/config/config_defaults.go b/backend/internal/config/config_defaults.go index b620f4a1..83327668 100644 --- a/backend/internal/config/config_defaults.go +++ b/backend/internal/config/config_defaults.go @@ -7,11 +7,7 @@ import ( "strings" ) -var defaultBrowserStartURLs = []string{ - "https://ippure.com/", - "https://iplark.com/", - "https://ping0.cc/", -} +var defaultBrowserStartURLs = []string{} func DefaultBrowserStartURLs() []string { return append([]string{}, defaultBrowserStartURLs...) @@ -111,6 +107,8 @@ func normalizeConfig(config *Config) { } if config.Browser.DefaultStartURLs == nil { config.Browser.DefaultStartURLs = append([]string{}, defaultConfig.Browser.DefaultStartURLs...) + } else if isLegacyVerificationStartURLs(config.Browser.DefaultStartURLs) { + config.Browser.DefaultStartURLs = []string{} } if config.Browser.StartReadyTimeoutMs <= 0 { config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs @@ -130,6 +128,18 @@ func normalizeConfig(config *Config) { if config.Browser.Profiles == nil { config.Browser.Profiles = []BrowserProfileConfig{} } + if config.ProxyCheck.BridgeStartTimeoutMs <= 0 { + config.ProxyCheck.BridgeStartTimeoutMs = defaultConfig.ProxyCheck.BridgeStartTimeoutMs + } + if strings.TrimSpace(config.ProxyCheck.SpeedTargetID) == "" { + config.ProxyCheck.SpeedTargetID = defaultConfig.ProxyCheck.SpeedTargetID + } + if strings.TrimSpace(config.ProxyCheck.IPHealthTargetID) == "" { + config.ProxyCheck.IPHealthTargetID = defaultConfig.ProxyCheck.IPHealthTargetID + } + if len(config.ProxyCheck.Targets) == 0 { + config.ProxyCheck.Targets = append([]ProxyCheckTarget{}, defaultConfig.ProxyCheck.Targets...) + } if config.LaunchServer.Port <= 0 { config.LaunchServer.Port = defaultConfig.LaunchServer.Port @@ -181,6 +191,19 @@ func isLegacyDefaultLogPath(path string) bool { return strings.EqualFold(filepath.ToSlash(strings.TrimSpace(path)), "logs/app.log") } +func isLegacyVerificationStartURLs(urls []string) bool { + legacy := []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} + if len(urls) != len(legacy) { + return false + } + for i, url := range urls { + if !strings.EqualFold(strings.TrimSpace(url), legacy[i]) { + return false + } + } + return true +} + // DefaultConfig 返回默认配置 func DefaultConfig() *Config { return &Config{ @@ -214,6 +237,12 @@ func DefaultConfig() *Config { StartReadyTimeoutMs: 3000, StartStableWindowMs: 1200, }, + ProxyCheck: ProxyCheckConfig{ + BridgeStartTimeoutMs: 15000, + SpeedTargetID: "", + IPHealthTargetID: "", + Targets: []ProxyCheckTarget{}, + }, Logging: LoggingConfig{ Level: "info", FileEnabled: false, diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 4d0458bc..70823124 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -58,8 +58,8 @@ 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.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 { + t.Fatalf("Browser 默认启动页面应初始化为空切片: got=%v", cfg.Browser.DefaultStartURLs) } if cfg.Browser.RestoreLastSession { t.Fatalf("Browser.RestoreLastSession 默认应为 false") @@ -141,6 +141,31 @@ func TestDefaultConfigUsesCurrentOSFingerprintPlatform(t *testing.T) { } } +func TestLoadClearsLegacyVerificationStartURLs(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + legacyConfig := ` +browser: + default_start_urls: + - https://ippure.com/ + - https://iplark.com/ + - https://ping0.cc/ +` + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 { + t.Fatalf("旧默认检测页应迁移为空: got=%v", cfg.Browser.DefaultStartURLs) + } +} + func TestLoadPreservesExplicitConfig(t *testing.T) { t.Parallel() diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 7dadb3e4..ba6a8fd9 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -135,6 +135,13 @@ var migrations = []migration{ `ALTER TABLE browser_profiles ADD COLUMN proxy_bind_updated_at TEXT NOT NULL DEFAULT ''`, }, }, + { + version: 7, + desc: "书签表添加启动时打开字段", + stmts: []string{ + `ALTER TABLE browser_bookmarks ADD COLUMN open_on_start INTEGER NOT NULL DEFAULT 0`, + }, + }, // ── 新版本在此追加,格式: // { // version: 4, diff --git a/backend/internal/launchcode/automation_api.go b/backend/internal/launchcode/automation_api.go index 568be5a5..9aae627c 100644 --- a/backend/internal/launchcode/automation_api.go +++ b/backend/internal/launchcode/automation_api.go @@ -17,6 +17,7 @@ type automationScriptRunAPIRequest struct { Params json.RawMessage `json:"params"` UseScriptSelector *bool `json:"useScriptSelector"` UseScriptParams *bool `json:"useScriptParams"` + TimeoutMs int `json:"timeoutMs"` } type automationScriptSummary struct { @@ -324,6 +325,7 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio ParamsText: paramsText, UseScriptSelector: useScriptSelector, UseScriptParams: useScriptParams, + TimeoutMs: req.TimeoutMs, }, nil } diff --git a/backend/internal/proxy/browser_bridge.go b/backend/internal/proxy/browser_bridge.go new file mode 100644 index 00000000..ef479c8a --- /dev/null +++ b/backend/internal/proxy/browser_bridge.go @@ -0,0 +1,89 @@ +package proxy + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +type directProxyBridgeSpec struct { + Scheme string + Server string + Port int + Username string + Password string +} + +func RequiresLocalProxyBridgeForBrowser(src string) bool { + spec, err := parseDirectProxyBridgeSpec(src) + return err == nil && spec != nil +} + +func buildDirectProxyBridgeOutbound(src string) (map[string]interface{}, bool, error) { + spec, err := parseDirectProxyBridgeSpec(src) + if err != nil { + return nil, false, err + } + if spec == nil { + return nil, false, nil + } + + if spec.Scheme == "socks5" || spec.Scheme == "http" { + return chainSocks5Outbound(chainSocks5Hop{ + Protocol: spec.Scheme, + Server: spec.Server, + Port: spec.Port, + Username: spec.Username, + Password: spec.Password, + }, "proxy-out", ""), true, nil + } + + return nil, false, nil +} + +func parseDirectProxyBridgeSpec(src string) (*directProxyBridgeSpec, error) { + raw := strings.TrimSpace(src) + if raw == "" { + return nil, nil + } + lowerRaw := strings.ToLower(raw) + if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "socks5://") { + return nil, nil + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("代理地址解析失败: %w", err) + } + + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + switch scheme { + case "socks5", "http": + if parsed.User == nil { + return nil, nil + } + username := strings.TrimSpace(parsed.User.Username()) + if username == "" { + return nil, nil + } + server := strings.TrimSpace(parsed.Hostname()) + if server == "" { + return nil, fmt.Errorf("代理地址缺少主机名") + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("代理端口无效") + } + password, _ := parsed.User.Password() + return &directProxyBridgeSpec{ + Scheme: scheme, + Server: server, + Port: port, + Username: username, + Password: password, + }, nil + default: + return nil, nil + } +} diff --git a/backend/internal/proxy/http_client.go b/backend/internal/proxy/http_client.go index 423ccb52..04b565e7 100644 --- a/backend/internal/proxy/http_client.go +++ b/backend/internal/proxy/http_client.go @@ -21,11 +21,23 @@ func buildProxyHTTPClient( singboxMgr *SingBoxManager, timeout time.Duration, ) (*http.Client, error) { + src = resolveProxyConfig(src, proxies, proxyId) l := strings.ToLower(strings.TrimSpace(src)) if l == "" || l == "direct://" { return &http.Client{Timeout: timeout}, nil } + if IsChainSocks5Proxy(src) { + if xrayMgr == nil { + return nil, fmt.Errorf("xray 管理器未初始化") + } + socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId) + if err != nil { + return nil, fmt.Errorf("xray 桥接启动失败: %w", err) + } + return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout) + } + if IsSingBoxProtocol(src) { if singboxMgr == nil { return nil, fmt.Errorf("sing-box 管理器未初始化") diff --git a/backend/internal/proxy/iphealth.go b/backend/internal/proxy/iphealth.go index 225fd75b..7a68ff9c 100644 --- a/backend/internal/proxy/iphealth.go +++ b/backend/internal/proxy/iphealth.go @@ -5,64 +5,153 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" "ant-chrome/backend/internal/config" ) -const defaultIPPureInfoURL = "https://my.ippure.com/v1/info" +const DefaultIPHealthURL = "https://my.ippure.com/v1/info" -// FetchIPPureInfo 通过指定代理链路查询 IPPure 的出口 IP 健康信息。 +type IPHealthConfig struct { + URL string + Source string + Parser string + Timeout time.Duration +} + +// FetchDefaultIPHealthInfo 使用传入的检测目标查询出口 IP 健康信息。 // 返回值为第三方接口原始 JSON(map 形式),不做本地评分计算。 -func FetchIPPureInfo( +func FetchDefaultIPHealthInfo( proxyId string, proxies []config.BrowserProxy, xrayMgr *XrayManager, singboxMgr *SingBoxManager, ) (map[string]interface{}, error) { - src := "" - for _, item := range proxies { - if strings.EqualFold(item.ProxyId, proxyId) { - src = strings.TrimSpace(item.ProxyConfig) - break - } + return FetchIPHealthInfo(proxyId, proxies, xrayMgr, singboxMgr, nil) +} + +func FetchIPHealthInfo( + proxyId string, + proxies []config.BrowserProxy, + xrayMgr *XrayManager, + singboxMgr *SingBoxManager, + cfg *IPHealthConfig, +) (map[string]interface{}, error) { + if cfg == nil { + cfg = &IPHealthConfig{} } + targetURL := strings.TrimSpace(cfg.URL) + if targetURL == "" { + targetURL = DefaultIPHealthURL + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 20 * time.Second + } + source := resolveIPHealthSource(cfg, targetURL) + parser := resolveIPHealthParser(cfg.Parser) + meta := map[string]interface{}{ + "_source": source, + "_targetUrl": targetURL, + "_parser": parser, + } + if targetURL == "" { + meta["error"] = "IP 健康检测目标 URL 为空" + return meta, fmt.Errorf("IP 健康检测目标 URL 为空") + } + + src := resolveProxyConfig("", proxies, proxyId) if src == "" { - return nil, fmt.Errorf("未找到代理配置") + meta["error"] = "未找到代理配置" + return meta, fmt.Errorf("未找到代理配置") } - client, err := buildIPPureHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, 20*time.Second) + client, err := buildIPHealthHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout) if err != nil { - return nil, err + meta["error"] = err.Error() + return meta, fmt.Errorf("创建 IP 健康检测客户端失败(source=%s): %w", source, err) } - req, _ := http.NewRequest(http.MethodGet, defaultIPPureInfoURL, nil) + req, err := http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + meta["error"] = err.Error() + return meta, fmt.Errorf("创建 IP 健康检测请求失败(source=%s): %w", source, err) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "AntChrome/1.0") resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("调用 IPPure 接口失败: %w", err) + meta["error"] = err.Error() + return meta, fmt.Errorf("调用 IP 健康检测接口失败(source=%s): %w", source, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("读取 IPPure 响应失败: %w", err) + meta["error"] = err.Error() + return meta, fmt.Errorf("读取 IP 健康检测响应失败(source=%s): %w", source, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("IPPure HTTP %d: %s", resp.StatusCode, bodySnippet(body, 180)) + snippet := bodySnippet(body, 180) + meta["error"] = fmt.Sprintf("HTTP %d", resp.StatusCode) + meta["_statusCode"] = resp.StatusCode + if snippet != "" { + meta["_bodySnippet"] = snippet + } + return meta, fmt.Errorf("IP 健康检测 HTTP %d(source=%s): %s", resp.StatusCode, source, snippet) } + result, err := parseIPHealthBody(body, cfg.Parser) + if err != nil { + snippet := bodySnippet(body, 180) + meta["error"] = err.Error() + if snippet != "" { + meta["_bodySnippet"] = snippet + } + return meta, fmt.Errorf("IP 健康检测响应解析失败(source=%s, parser=%s): %w", source, parser, err) + } + result["_source"] = source + result["_targetUrl"] = targetURL + result["_parser"] = parser + return result, nil +} + +func parseIPHealthBody(body []byte, parser string) (map[string]interface{}, error) { + if strings.EqualFold(strings.TrimSpace(parser), "cloudflare_trace") { + result := map[string]interface{}{} + for _, line := range strings.Split(string(body), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if ok && strings.TrimSpace(key) != "" { + result[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + if ip := mapString(result, "ip"); ip != "" { + result["ip"] = ip + } + return result, nil + } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("IPPure JSON 解析失败: %w", err) + return nil, err } return result, nil } -func buildIPPureHTTPClient( +func mapString(data map[string]interface{}, key string) string { + value, ok := data[key] + if !ok || value == nil { + return "" + } + if text, ok := value.(string); ok { + return text + } + return fmt.Sprint(value) +} + +func buildIPHealthHTTPClient( src string, proxyId string, proxies []config.BrowserProxy, @@ -73,6 +162,34 @@ func buildIPPureHTTPClient( return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout) } +func resolveIPHealthSource(cfg *IPHealthConfig, targetURL string) string { + if cfg != nil { + if source := strings.TrimSpace(cfg.Source); source != "" { + return source + } + if parser := strings.TrimSpace(cfg.Parser); parser != "" { + return parser + } + } + if DefaultIPHealthURL != "" && strings.EqualFold(strings.TrimSpace(targetURL), DefaultIPHealthURL) { + return "ip_health" + } + if parsed, err := url.Parse(strings.TrimSpace(targetURL)); err == nil { + if host := strings.ToLower(strings.TrimSpace(parsed.Hostname())); host != "" { + return host + } + } + return "ip_health" +} + +func resolveIPHealthParser(parser string) string { + normalized := strings.TrimSpace(parser) + if normalized == "" { + return "json" + } + return normalized +} + func bodySnippet(body []byte, max int) string { s := strings.TrimSpace(string(body)) if len(s) <= max { diff --git a/backend/internal/proxy/iphealth_test.go b/backend/internal/proxy/iphealth_test.go new file mode 100644 index 00000000..7ab8ce78 --- /dev/null +++ b/backend/internal/proxy/iphealth_test.go @@ -0,0 +1,51 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/config" +) + +func TestFetchIPHealthInfoReturnsSourceMetadataOnParseError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer server.Close() + + data, err := FetchIPHealthInfo( + "proxy-1", + []config.BrowserProxy{{ProxyId: "proxy-1", ProxyConfig: "direct://"}}, + nil, + nil, + &IPHealthConfig{ + URL: server.URL, + Source: "json", + Parser: "json", + }, + ) + if err == nil { + t.Fatalf("expected parse error") + } + if !strings.Contains(err.Error(), "source=json") { + t.Fatalf("expected source in error, got %v", err) + } + if !strings.Contains(err.Error(), "parser=json") { + t.Fatalf("expected parser in error, got %v", err) + } + if got := mapString(data, "_source"); got != "json" { + t.Fatalf("source metadata = %q, want json", got) + } + if got := mapString(data, "_targetUrl"); got != server.URL { + t.Fatalf("target url metadata = %q, want %q", got, server.URL) + } + if got := mapString(data, "_parser"); got != "json" { + t.Fatalf("parser metadata = %q, want json", got) + } + if got := mapString(data, "_bodySnippet"); got != "not-json" { + t.Fatalf("body snippet = %q, want not-json", got) + } +} diff --git a/backend/internal/proxy/parser.go b/backend/internal/proxy/parser.go index 763d0c59..84352b10 100644 --- a/backend/internal/proxy/parser.go +++ b/backend/internal/proxy/parser.go @@ -56,15 +56,19 @@ func ParseChainSocks5Config(src string) (*chainSocks5Config, error) { if cfg.LocalPort < 0 || cfg.LocalPort > 65535 { return nil, fmt.Errorf("本地监听端口必须在 1-65535 之间") } - if cfg.First.Protocol == "" { - cfg.First.Protocol = "socks5" - } - if cfg.Second.Protocol == "" { - cfg.Second.Protocol = "socks5" - } + cfg.First.Protocol = normalizeChainHopProtocol(cfg.First.Protocol) + cfg.Second.Protocol = normalizeChainHopProtocol(cfg.Second.Protocol) return &cfg, nil } +func normalizeChainHopProtocol(protocol string) string { + normalized := strings.ToLower(strings.TrimSpace(protocol)) + if normalized == "" { + return "socks5" + } + return normalized +} + func validateChainSocks5Hop(label string, hop chainSocks5Hop) error { if strings.TrimSpace(hop.Server) == "" { return fmt.Errorf("%s代理地址不能为空", label) @@ -73,8 +77,8 @@ func validateChainSocks5Hop(label string, hop chainSocks5Hop) error { return fmt.Errorf("%s代理端口必须在 1-65535 之间", label) } protocol := strings.ToLower(strings.TrimSpace(hop.Protocol)) - if protocol != "" && protocol != "socks5" { - return fmt.Errorf("%s协议仅支持 socks5", label) + if protocol != "" && protocol != "socks5" && protocol != "http" { + return fmt.Errorf("%s协议仅支持 http 或 socks5", label) } if strings.TrimSpace(hop.Password) != "" && strings.TrimSpace(hop.Username) == "" { return fmt.Errorf("%s填写密码时请同时填写账号", label) diff --git a/backend/internal/proxy/resolve.go b/backend/internal/proxy/resolve.go new file mode 100644 index 00000000..003f897e --- /dev/null +++ b/backend/internal/proxy/resolve.go @@ -0,0 +1,20 @@ +package proxy + +import ( + "strings" + + "ant-chrome/backend/internal/config" +) + +func resolveProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) string { + src := strings.TrimSpace(proxyConfig) + if proxyId == "" { + return src + } + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + return strings.TrimSpace(item.ProxyConfig) + } + } + return src +} diff --git a/backend/internal/proxy/singbox_bridge_runtime.go b/backend/internal/proxy/singbox_bridge_runtime.go index 36958a86..d71b7444 100644 --- a/backend/internal/proxy/singbox_bridge_runtime.go +++ b/backend/internal/proxy/singbox_bridge_runtime.go @@ -7,22 +7,13 @@ import ( "os" "os/exec" "path/filepath" - "strings" "time" ) // EnsureBridge 确保 sing-box 桥接进程运行,返回 socks5://127.0.0.1:port func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) { log := logger.New("SingBox") - src := strings.TrimSpace(proxyConfig) - if proxyId != "" { - for _, item := range proxies { - if strings.EqualFold(item.ProxyId, proxyId) { - src = strings.TrimSpace(item.ProxyConfig) - break - } - } - } + src := resolveProxyConfig(proxyConfig, proxies, proxyId) if src == "" { return "", fmt.Errorf("未找到代理节点") } diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go index 71f2d514..58b5e167 100644 --- a/backend/internal/proxy/speedtest.go +++ b/backend/internal/proxy/speedtest.go @@ -13,7 +13,7 @@ import ( // ─── Clash 标准测速 URL ─── // 使用 HTTP 与 Clash 客户端保持一致 -const defaultTestURL = "http://www.gstatic.com/generate_204" +const DefaultSpeedTestURL = "http://www.gstatic.com/generate_204" // SpeedTestConfig 测速参数 type SpeedTestConfig struct { @@ -46,13 +46,7 @@ func SpeedTest( cfg = &c } - src := "" - for _, item := range proxies { - if strings.EqualFold(item.ProxyId, proxyId) { - src = strings.TrimSpace(item.ProxyConfig) - break - } - } + src := resolveProxyConfig("", proxies, proxyId) if src == "" { return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"} } @@ -61,18 +55,40 @@ func SpeedTest( return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0} } - testURL := defaultTestURL + testURL := strings.TrimSpace(DefaultSpeedTestURL) if len(cfg.URLs) > 0 { - testURL = cfg.URLs[0] + testURL = strings.TrimSpace(cfg.URLs[0]) + } + if testURL == "" { + return TestResult{ProxyId: proxyId, Ok: false, Error: "测速目标 URL 为空"} } - mapping, err := proxyConfigToMapping(src) + 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) + } + + mapping, err := proxyConfigToMapping(resolvedSrc) if err != nil { log.Warn("代理配置解析失败,降级到 TCP ping", logger.F("proxy_id", proxyId), logger.F("error", err.Error()), ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) } proxyInstance, err := adapter.ParseProxy(mapping) @@ -82,7 +98,7 @@ func SpeedTest( logger.F("error", err.Error()), logger.F("type", mapping["type"]), ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) } return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout) diff --git a/backend/internal/proxy/speedtest_mapping.go b/backend/internal/proxy/speedtest_mapping.go index f869d920..215df2b2 100644 --- a/backend/internal/proxy/speedtest_mapping.go +++ b/backend/internal/proxy/speedtest_mapping.go @@ -2,6 +2,8 @@ package proxy import ( "fmt" + "net/url" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -26,26 +28,24 @@ func proxyConfigToMapping(src string) (map[string]any, error) { } 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 + parsed, err := url.Parse(src) + if err != nil { + return nil, fmt.Errorf("代理地址解析失败: %w", err) + } + host := strings.TrimSpace(parsed.Hostname()) + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + port = 0 } - hostport = strings.SplitN(hostport, "/", 2)[0] - - host, port := splitHostPort(hostport) if host == "" || port == 0 { return nil, fmt.Errorf("无法解析地址: %s", src) } + username := "" + password := "" + if parsed.User != nil { + username = parsed.User.Username() + password, _ = parsed.User.Password() + } mapping := map[string]any{ "name": "speedtest-proxy", @@ -78,26 +78,6 @@ func parseClashYAMLToMapping(src string) (map[string]any, error) { 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 diff --git a/backend/internal/proxy/speedtest_test.go b/backend/internal/proxy/speedtest_test.go index b171ac81..4ad4f2f0 100644 --- a/backend/internal/proxy/speedtest_test.go +++ b/backend/internal/proxy/speedtest_test.go @@ -1,6 +1,9 @@ package proxy -import "testing" +import ( + "strings" + "testing" +) func TestProxyConfigToMappingStandardProxy(t *testing.T) { t.Parallel() @@ -27,6 +30,33 @@ func TestProxyConfigToMappingStandardProxy(t *testing.T) { } } +func TestProxyConfigToMappingEscapedCredentials(t *testing.T) { + t.Parallel() + + mapping, err := proxyConfigToMapping("http://user%40mail:p%40ss%3Aword@example.com:8080") + if err != nil { + t.Fatalf("proxyConfigToMapping returned error: %v", err) + } + if got := mapping["username"]; got != "user@mail" { + t.Fatalf("username = %v, want user@mail", got) + } + if got := mapping["password"]; got != "p@ss:word" { + t.Fatalf("password = %v, want p@ss:word", got) + } +} + +func TestProxyEndpointDropsCredentials(t *testing.T) { + t.Parallel() + + endpoint, err := proxyEndpoint("http://user:pass@example.com:8080") + if err != nil { + t.Fatalf("proxyEndpoint returned error: %v", err) + } + if endpoint != "example.com:8080" { + t.Fatalf("endpoint = %q, want example.com:8080", endpoint) + } +} + func TestProxyConfigToMappingClashYAML(t *testing.T) { t.Parallel() @@ -76,3 +106,14 @@ func TestURLToMeta(t *testing.T) { t.Fatalf("DstIP = %v, want 1.2.3.4", meta.DstIP) } } + +func TestDefaultProxyCheckURLsAreConfigured(t *testing.T) { + t.Parallel() + + if strings.TrimSpace(DefaultSpeedTestURL) == "" { + t.Fatalf("DefaultSpeedTestURL must not be empty") + } + if strings.TrimSpace(DefaultIPHealthURL) == "" { + t.Fatalf("DefaultIPHealthURL must not be empty") + } +} diff --git a/backend/internal/proxy/utils_connectivity.go b/backend/internal/proxy/utils_connectivity.go index 50a2ccd9..8bb99e5b 100644 --- a/backend/internal/proxy/utils_connectivity.go +++ b/backend/internal/proxy/utils_connectivity.go @@ -4,12 +4,10 @@ import ( "fmt" "net" "net/http" - "net/url" "strings" "time" "ant-chrome/backend/internal/config" - xproxy "golang.org/x/net/proxy" ) // TestConnectivity 通过 TCP 握手测试代理服务器的可达性和延迟 @@ -63,67 +61,20 @@ func TestRealConnectivityWithSingBox( xrayMgr *XrayManager, singboxMgr *SingBoxManager, ) TestResult { - src := "" - for _, item := range proxies { - if strings.EqualFold(item.ProxyId, proxyId) { - src = strings.TrimSpace(item.ProxyConfig) - break - } - } + src := resolveProxyConfig("", proxies, proxyId) if src == "" { return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"} } - const targetURL = "http://www.gstatic.com/generate_204" + targetURL := strings.TrimSpace(DefaultSpeedTestURL) + if targetURL == "" { + return TestResult{ProxyId: proxyId, Ok: false, Error: "真实连通性测试目标 URL 为空"} + } const timeout = 15 * time.Second - var client *http.Client - - if IsSingBoxProtocol(src) { - if singboxMgr == nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"} - } - socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId) - if err != nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("sing-box 桥接启动失败: %v", err)} - } - socks5Host := strings.TrimPrefix(socks5Addr, "socks5://") - dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct) - if err != nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)} - } - contextDialer, ok := dialer.(xproxy.ContextDialer) - if !ok { - return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"} - } - transport := &http.Transport{DialContext: contextDialer.DialContext} - client = &http.Client{Transport: transport, Timeout: timeout} - } else if RequiresBridge(src, proxies, proxyId) { - if xrayMgr == nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: "xray 管理器未初始化"} - } - socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId) - if err != nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("桥接启动失败: %v", err)} - } - socks5Host := strings.TrimPrefix(socks5Addr, "socks5://") - dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct) - if err != nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)} - } - contextDialer, ok := dialer.(xproxy.ContextDialer) - if !ok { - return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"} - } - transport := &http.Transport{DialContext: contextDialer.DialContext} - client = &http.Client{Transport: transport, Timeout: timeout} - } else { - proxyURL, err := url.Parse(src) - if err != nil { - return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理地址解析失败: %v", err)} - } - transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} - client = &http.Client{Transport: transport, Timeout: timeout} + client, err := buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout) + if err != nil { + return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()} } start := time.Now() diff --git a/backend/internal/proxy/utils_parse.go b/backend/internal/proxy/utils_parse.go index 211b8bca..fcd0ced1 100644 --- a/backend/internal/proxy/utils_parse.go +++ b/backend/internal/proxy/utils_parse.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/url" "strconv" "strings" @@ -16,9 +17,14 @@ func proxyEndpoint(src string) (string, error) { 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 + parsed, err := url.Parse(src) + if err != nil { + return "", err + } + if parsed.Host == "" { + return "", fmt.Errorf("缺少代理地址") + } + return parsed.Host, nil } if strings.HasPrefix(l, "vmess://") { diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index 9036f1a7..249c5329 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -20,6 +20,7 @@ type XrayManager struct { Bridges map[string]*XrayBridge OnBridgeDied func(key string, err error) // 桥接进程意外退出回调 mu sync.Mutex + launchMu sync.Mutex stopCh chan struct{} stopOnce sync.Once } @@ -40,8 +41,8 @@ func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager { // 返回: supported bool, errorMsg string func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (bool, string) { src := strings.TrimSpace(proxyConfig) - found := false if proxyId != "" { + found := false for _, item := range proxies { if strings.EqualFold(item.ProxyId, proxyId) { src = strings.TrimSpace(item.ProxyConfig) @@ -65,6 +66,12 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox 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, "" + } if IsSingBoxProtocol(src) { if _, err := BuildSingBoxOutbound(src); err != nil { return false, fmt.Sprintf("代理配置解析失败: %v", err) @@ -86,15 +93,7 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox // 注意: Xray 仅支持 vless/vmess/trojan/shadowsocks 等协议 // hysteria2 不支持,需要使用 Hysteria 客户端或 sing-box func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) bool { - src := strings.TrimSpace(proxyConfig) - if proxyId != "" { - for _, item := range proxies { - if strings.EqualFold(item.ProxyId, proxyId) { - src = strings.TrimSpace(item.ProxyConfig) - break - } - } - } + src := resolveProxyConfig(proxyConfig, proxies, proxyId) if src == "" { return false } @@ -102,6 +101,9 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { return false } + if IsChainSocks5Proxy(src) { + return true + } if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") { return false } diff --git a/backend/internal/proxy/xray_bridge_launch.go b/backend/internal/proxy/xray_bridge_launch.go index a1c42257..6575567b 100644 --- a/backend/internal/proxy/xray_bridge_launch.go +++ b/backend/internal/proxy/xray_bridge_launch.go @@ -13,12 +13,11 @@ import ( func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) { log := logger.New("Xray") - src := strings.TrimSpace(proxyConfig) + src := resolveProxyConfig(proxyConfig, proxies, proxyId) dnsServers := "" if proxyId != "" { for _, item := range proxies { if strings.EqualFold(item.ProxyId, proxyId) { - src = strings.TrimSpace(item.ProxyConfig) dnsServers = item.DnsServers break } @@ -28,19 +27,76 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP 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("节点解析失败") + + 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 { + directOutbound, shouldBridgeDirectProxy, err := buildDirectProxyBridgeOutbound(src) + if err != nil { + log.Error("直连代理桥接配置解析失败", logger.F("error", err)) + return "", "", err + } + if shouldBridgeDirectProxy { + outbounds = []interface{}{directOutbound} + routes = []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + } + } 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 + } + m.launchMu.Lock() + defer m.launchMu.Unlock() if socksURL, reused := m.tryReuseBridge(key, pin); reused { log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL)) return socksURL, key, nil @@ -52,10 +108,13 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP return "", "", err } - const maxLaunchRetries = 3 + maxLaunchRetries := 3 + if preferredPort > 0 { + maxLaunchRetries = 1 + } var lastErr error for attempt := 1; attempt <= maxLaunchRetries; attempt++ { - socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbound, dnsServers, pin, attempt) + socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbounds, routes, preferredPort, dnsServers, pin, attempt) if err == nil { return socksURL, key, nil } @@ -67,13 +126,17 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP 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 +func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binaryPath string, outbounds []interface{}, routes []interface{}, preferredPort int, dnsServers string, pin bool, attempt int) (string, *XrayBridge, error) { + port := preferredPort + if port <= 0 { + var err 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) + cfgPath, err := m.buildRuntimeConfigWithRoute(key, outbounds, routes, port, dnsServers) if err != nil { log.Error("xray 配置生成失败", logger.F("error", err)) return "", nil, err @@ -121,8 +184,77 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary return fmt.Sprintf("socks5://127.0.0.1:%d", port), bridge, nil } +func chainSocks5Outbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} { + protocol := normalizeChainHopProtocol(hop.Protocol) + if protocol == "http" { + return chainHTTPOutbound(hop, tag, nextTag) + } + + user := map[string]interface{}{} + if strings.TrimSpace(hop.Username) != "" { + user["user"] = strings.TrimSpace(hop.Username) + if strings.TrimSpace(hop.Password) != "" { + user["pass"] = hop.Password + } + } + + server := map[string]interface{}{ + "address": strings.TrimSpace(hop.Server), + "port": hop.Port, + } + if len(user) > 0 { + server["users"] = []interface{}{user} + } + + outbound := map[string]interface{}{ + "protocol": "socks", + "tag": tag, + "settings": map[string]interface{}{ + "servers": []interface{}{server}, + }, + } + if strings.TrimSpace(nextTag) != "" { + outbound["proxySettings"] = map[string]interface{}{ + "tag": strings.TrimSpace(nextTag), + } + } + return outbound +} + +func chainHTTPOutbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} { + user := map[string]interface{}{} + if strings.TrimSpace(hop.Username) != "" { + user["user"] = strings.TrimSpace(hop.Username) + if strings.TrimSpace(hop.Password) != "" { + user["pass"] = hop.Password + } + } + + server := map[string]interface{}{ + "address": strings.TrimSpace(hop.Server), + "port": hop.Port, + } + if len(user) > 0 { + server["users"] = []interface{}{user} + } + + outbound := map[string]interface{}{ + "protocol": "http", + "tag": tag, + "settings": map[string]interface{}{ + "servers": []interface{}{server}, + }, + } + if strings.TrimSpace(nextTag) != "" { + outbound["proxySettings"] = map[string]interface{}{ + "tag": strings.TrimSpace(nextTag), + } + } + return outbound +} + 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 err := waitPortReady("127.0.0.1", bridge.Port, m.bridgeStartTimeout()); err != nil { if stderrFile != nil { stderrFile.Close() } @@ -131,10 +263,10 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf m.stopBridgeProcess(bridge) bridge.Running = false bridge.Pid = 0 - bridge.LastError = err.Error() + bridge.LastError = m.describeBridgeReadyError(err, cfgPath, stderrPath) 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 + return fmt.Errorf("%s", bridge.LastError) } if stderrFile != nil { stderrFile.Close() @@ -142,6 +274,43 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf return nil } +func (m *XrayManager) bridgeStartTimeout() time.Duration { + if m != nil && m.Config != nil && m.Config.ProxyCheck.BridgeStartTimeoutMs > 0 { + return time.Duration(m.Config.ProxyCheck.BridgeStartTimeoutMs) * time.Millisecond + } + return 15 * time.Second +} + +func (m *XrayManager) describeBridgeReadyError(err error, cfgPath string, stderrPath string) string { + parts := []string{err.Error()} + if strings.TrimSpace(cfgPath) != "" { + parts = append(parts, "配置文件: "+cfgPath) + } + if tail := readLogTail(stderrPath, 1200); tail != "" { + parts = append(parts, "stderr: "+tail) + } else if cfgPath != "" { + if tail := readLogTail(filepath.Join(filepath.Dir(cfgPath), "xray-error.log"), 1200); tail != "" { + parts = append(parts, "error.log: "+tail) + } + } + return strings.Join(parts, ";") +} + +func readLogTail(path string, max int) string { + if strings.TrimSpace(path) == "" || max <= 0 { + return "" + } + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return "" + } + text := strings.TrimSpace(string(data)) + if len(text) <= max { + return text + } + return text[len(text)-max:] +} + 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))) diff --git a/backend/internal/proxy/xray_chain_test.go b/backend/internal/proxy/xray_chain_test.go new file mode 100644 index 00000000..68ee49fd --- /dev/null +++ b/backend/internal/proxy/xray_chain_test.go @@ -0,0 +1,210 @@ +package proxy + +import ( + "encoding/json" + "os" + "testing" + + "ant-chrome/backend/internal/config" +) + +func TestChainSocks5RuntimeConfigRoutesThroughSecondHop(t *testing.T) { + chainConfig := buildTestChainSocks5Config(t, 19090) + chainCfg, err := ParseChainSocks5Config(chainConfig) + if err != nil { + t.Fatalf("ParseChainSocks5Config returned error: %v", err) + } + + cfg := config.DefaultConfig() + cfg.Browser.UserDataRoot = t.TempDir() + manager := &XrayManager{ + Config: cfg, + AppRoot: t.TempDir(), + } + + cfgPath, err := manager.buildRuntimeConfigWithRoute( + "chain-test", + []interface{}{ + chainSocks5Outbound(chainCfg.First, "first-hop", ""), + chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"), + }, + []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "second-hop", + }, + }, + chainCfg.LocalPort, + "", + ) + if err != nil { + t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read runtime config failed: %v", err) + } + var runtimeConfig map[string]interface{} + if err := json.Unmarshal(data, &runtimeConfig); err != nil { + t.Fatalf("unmarshal runtime config failed: %v", err) + } + + inbounds := runtimeConfig["inbounds"].([]interface{}) + inbound := inbounds[0].(map[string]interface{}) + if got := int(inbound["port"].(float64)); got != 19090 { + t.Fatalf("inbound port = %d, want 19090", got) + } + + outbounds := runtimeConfig["outbounds"].([]interface{}) + byTag := map[string]map[string]interface{}{} + for _, item := range outbounds { + outbound := item.(map[string]interface{}) + if tag, ok := outbound["tag"].(string); ok { + byTag[tag] = outbound + } + } + secondHop := byTag["second-hop"] + if secondHop == nil { + t.Fatalf("second-hop outbound is missing: %+v", byTag) + } + proxySettings, ok := secondHop["proxySettings"].(map[string]interface{}) + if !ok { + t.Fatalf("second-hop proxySettings is missing: %+v", secondHop) + } + if got := proxySettings["tag"]; got != "first-hop" { + t.Fatalf("second-hop proxy tag = %v, want first-hop", got) + } + + routing := runtimeConfig["routing"].(map[string]interface{}) + rules := routing["rules"].([]interface{}) + rule := rules[0].(map[string]interface{}) + if got := rule["outboundTag"]; got != "second-hop" { + t.Fatalf("route outboundTag = %v, want second-hop", got) + } +} + +func TestAuthenticatedSocks5RuntimeConfigUsesLocalBridgeOutbound(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.UserDataRoot = t.TempDir() + manager := &XrayManager{ + Config: cfg, + AppRoot: t.TempDir(), + } + + outbound, ok, err := buildDirectProxyBridgeOutbound("socks5://user:pass@first-hop.invalid:1080") + if err != nil { + t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err) + } + if !ok { + t.Fatal("expected bridge outbound for authenticated socks5 proxy") + } + + cfgPath, err := manager.buildRuntimeConfigWithRoute( + "direct-auth-socks-test", + []interface{}{outbound}, + []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + }, + 19091, + "", + ) + if err != nil { + t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read runtime config failed: %v", err) + } + var runtimeConfig map[string]interface{} + if err := json.Unmarshal(data, &runtimeConfig); err != nil { + t.Fatalf("unmarshal runtime config failed: %v", err) + } + + outbounds := runtimeConfig["outbounds"].([]interface{}) + byTag := map[string]map[string]interface{}{} + for _, item := range outbounds { + current := item.(map[string]interface{}) + if tag, ok := current["tag"].(string); ok { + byTag[tag] = current + } + } + + proxyOut := byTag["proxy-out"] + if proxyOut == nil { + t.Fatalf("proxy-out outbound is missing: %+v", byTag) + } + if proxyOut["protocol"] != "socks" { + t.Fatalf("proxy-out protocol = %v, want socks", proxyOut["protocol"]) + } +} + +func TestChainHTTPOutboundUsesAuthenticatedHTTPServer(t *testing.T) { + outbound := chainSocks5Outbound(chainSocks5Hop{ + Protocol: "http", + Server: "first-hop.invalid", + Port: 1080, + Username: "user", + Password: "pass", + }, "first-hop", "") + + if outbound["protocol"] != "http" { + t.Fatalf("protocol = %v, want http", outbound["protocol"]) + } + settings, ok := outbound["settings"].(map[string]interface{}) + if !ok { + t.Fatalf("settings missing: %+v", outbound) + } + servers, ok := settings["servers"].([]interface{}) + if !ok || len(servers) != 1 { + t.Fatalf("servers invalid: %+v", settings["servers"]) + } + server, ok := servers[0].(map[string]interface{}) + if !ok { + t.Fatalf("server invalid: %+v", servers[0]) + } + if server["address"] != "first-hop.invalid" { + t.Fatalf("address = %v, want first-hop.invalid", server["address"]) + } + if server["port"] != 1080 { + t.Fatalf("port = %v, want 1080", server["port"]) + } + users, ok := server["users"].([]interface{}) + if !ok || len(users) != 1 { + t.Fatalf("users invalid: %+v", server["users"]) + } + user, ok := users[0].(map[string]interface{}) + if !ok { + t.Fatalf("user invalid: %+v", users[0]) + } + if user["user"] != "user" || user["pass"] != "pass" { + t.Fatalf("unexpected user payload: %+v", user) + } +} + +func TestChainMixedHTTPAndSocksRuntimeConfig(t *testing.T) { + chainConfig := "chain+socks5://%7B%22first%22%3A%7B%22protocol%22%3A%22http%22%2C%22server%22%3A%22127.0.0.1%22%2C%22port%22%3A8080%2C%22username%22%3A%22u1%22%2C%22password%22%3A%22p1%22%7D%2C%22second%22%3A%7B%22protocol%22%3A%22socks5%22%2C%22server%22%3A%22127.0.0.2%22%2C%22port%22%3A1080%2C%22username%22%3A%22u2%22%2C%22password%22%3A%22p2%22%7D%7D" + chainCfg, err := ParseChainSocks5Config(chainConfig) + if err != nil { + t.Fatalf("ParseChainSocks5Config returned error: %v", err) + } + + first := chainSocks5Outbound(chainCfg.First, "first-hop", "") + second := chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop") + if first["protocol"] != "http" { + t.Fatalf("first protocol = %v, want http", first["protocol"]) + } + if second["protocol"] != "socks" { + t.Fatalf("second protocol = %v, want socks", second["protocol"]) + } + proxySettings, ok := second["proxySettings"].(map[string]interface{}) + if !ok || proxySettings["tag"] != "first-hop" { + t.Fatalf("second proxySettings invalid: %+v", second["proxySettings"]) + } +} diff --git a/backend/internal/proxy/xray_runtime_config.go b/backend/internal/proxy/xray_runtime_config.go index c96b3ed0..499571a9 100644 --- a/backend/internal/proxy/xray_runtime_config.go +++ b/backend/internal/proxy/xray_runtime_config.go @@ -9,6 +9,22 @@ import ( ) func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) { + return m.buildRuntimeConfigWithRoute( + key, + []interface{}{outbound}, + []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + }, + port, + dnsServers, + ) +} + +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, 0o755); err != nil { return "", err @@ -33,8 +49,7 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa }, }, }, - "outbounds": []interface{}{ - outbound, + "outbounds": append(outbounds, map[string]interface{}{ "protocol": "direct", "tag": "direct", @@ -43,15 +58,9 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa "protocol": "blackhole", "tag": "block", }, - }, + ), "routing": map[string]interface{}{ - "rules": []interface{}{ - map[string]interface{}{ - "type": "field", - "inboundTag": []string{"socks-in"}, - "outboundTag": "proxy-out", - }, - }, + "rules": rules, }, } if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil { diff --git a/backend/internal/proxy/xray_validate_test.go b/backend/internal/proxy/xray_validate_test.go index 3a686096..30f86c94 100644 --- a/backend/internal/proxy/xray_validate_test.go +++ b/backend/internal/proxy/xray_validate_test.go @@ -2,6 +2,8 @@ package proxy import ( "ant-chrome/backend/internal/config" + "fmt" + "net/url" "strings" "testing" ) @@ -18,7 +20,7 @@ func TestValidateProxyConfigInvalidRawString(t *testing.T) { func TestValidateProxyConfigMissingProxyId(t *testing.T) { ok, msg := ValidateProxyConfig("", []config.BrowserProxy{ - {ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "p1", ProxyConfig: "http://proxy.invalid:8080"}, }, "missing-proxy") if ok { t.Fatalf("expected missing proxyId to fail validation") @@ -30,7 +32,7 @@ func TestValidateProxyConfigMissingProxyId(t *testing.T) { func TestValidateProxyConfigMissingProxyIdFallbackToRawConfig(t *testing.T) { ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", []config.BrowserProxy{ - {ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "p1", ProxyConfig: "http://proxy.invalid:8080"}, }, "missing-proxy") if !ok { t.Fatalf("expected fallback proxyConfig to pass, msg=%s", msg) @@ -43,3 +45,172 @@ func TestValidateProxyConfigStandardProxy(t *testing.T) { t.Fatalf("expected standard proxy to pass: %s", msg) } } + +func TestValidateProxyConfigChainSocks5Proxy(t *testing.T) { + chainConfig := buildTestChainSocks5Config(t, 0) + ok, msg := ValidateProxyConfig(chainConfig, nil, "") + if !ok { + t.Fatalf("expected chain proxy to pass: %s", msg) + } + if !RequiresBridge(chainConfig, nil, "") { + t.Fatalf("expected chain proxy to require bridge") + } +} + +func TestValidateProxyConfigChainHTTPProxy(t *testing.T) { + chainConfig := buildTestChainHTTPConfig(t) + ok, msg := ValidateProxyConfig(chainConfig, nil, "") + if !ok { + t.Fatalf("expected chain http proxy to pass: %s", msg) + } + if !RequiresBridge(chainConfig, nil, "") { + t.Fatalf("expected chain http proxy to require bridge") + } +} + +func TestRequiresLocalProxyBridgeForBrowserAuthenticatedSocks5(t *testing.T) { + if !RequiresLocalProxyBridgeForBrowser("socks5://user:pass@127.0.0.1:1080") { + t.Fatal("expected authenticated socks5 proxy to require browser bridge") + } + if !RequiresLocalProxyBridgeForBrowser("http://user:pass@127.0.0.1:8080") { + t.Fatal("expected authenticated http proxy to require browser bridge") + } + if RequiresLocalProxyBridgeForBrowser("socks5://127.0.0.1:1080") { + t.Fatal("expected unauthenticated socks5 proxy not to require browser bridge") + } + if RequiresLocalProxyBridgeForBrowser("http://127.0.0.1:8080") { + t.Fatal("expected unauthenticated http proxy not to require browser bridge") + } +} + +func TestBuildDirectProxyBridgeOutboundAuthenticatedSocks5(t *testing.T) { + outbound, ok, err := buildDirectProxyBridgeOutbound("socks5://user:pass@127.0.0.1:1080") + if err != nil { + t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err) + } + if !ok { + t.Fatal("expected authenticated socks5 proxy to build bridge outbound") + } + + if outbound["protocol"] != "socks" { + t.Fatalf("protocol = %v, want socks", outbound["protocol"]) + } + settings, ok := outbound["settings"].(map[string]interface{}) + if !ok { + t.Fatalf("settings missing: %+v", outbound) + } + servers, ok := settings["servers"].([]interface{}) + if !ok || len(servers) != 1 { + t.Fatalf("servers invalid: %+v", settings["servers"]) + } + server, ok := servers[0].(map[string]interface{}) + if !ok { + t.Fatalf("server invalid: %+v", servers[0]) + } + if server["address"] != "127.0.0.1" { + t.Fatalf("address = %v, want 127.0.0.1", server["address"]) + } + if got := int(server["port"].(int)); got != 1080 { + t.Fatalf("port = %d, want 1080", got) + } + users, ok := server["users"].([]interface{}) + if !ok || len(users) != 1 { + t.Fatalf("users invalid: %+v", server["users"]) + } + user, ok := users[0].(map[string]interface{}) + if !ok { + t.Fatalf("user invalid: %+v", users[0]) + } + if user["user"] != "user" || user["pass"] != "pass" { + t.Fatalf("unexpected user payload: %+v", user) + } +} + +func TestBuildDirectProxyBridgeOutboundAuthenticatedHTTP(t *testing.T) { + outbound, ok, err := buildDirectProxyBridgeOutbound("http://user:pass@127.0.0.1:8080") + if err != nil { + t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err) + } + if !ok { + t.Fatal("expected bridge outbound for authenticated http proxy") + } + if outbound["protocol"] != "http" { + t.Fatalf("protocol = %v, want http", outbound["protocol"]) + } + settings, ok := outbound["settings"].(map[string]interface{}) + if !ok { + t.Fatalf("settings missing: %+v", outbound) + } + servers, ok := settings["servers"].([]interface{}) + if !ok || len(servers) != 1 { + t.Fatalf("servers invalid: %+v", settings["servers"]) + } + server, ok := servers[0].(map[string]interface{}) + if !ok { + t.Fatalf("server invalid: %+v", servers[0]) + } + users, ok := server["users"].([]interface{}) + if !ok || len(users) != 1 { + t.Fatalf("users invalid: %+v", server["users"]) + } + user, ok := users[0].(map[string]interface{}) + if !ok { + t.Fatalf("user invalid: %+v", users[0]) + } + if user["user"] != "user" || user["pass"] != "pass" { + t.Fatalf("unexpected user payload: %+v", user) + } +} + +func buildTestChainSocks5Config(t *testing.T, localPort int) string { + t.Helper() + localPortField := "" + if localPort > 0 { + localPortField = fmt.Sprintf(`,"localPort":%d`, localPort) + } + raw := fmt.Sprintf(`{"first":{"protocol":"socks5","server":"127.0.0.1","port":1081,"username":"u1","password":"p1"},"second":{"protocol":"socks5","server":"127.0.0.2","port":1082}%s}`, localPortField) + return "chain+socks5://" + url.QueryEscape(raw) +} + +func buildTestChainHTTPConfig(t *testing.T) string { + t.Helper() + raw := `{"first":{"protocol":"http","server":"first-hop.invalid","port":8080,"username":"u1","password":"p1"},"second":{"protocol":"http","server":"second-hop.invalid","port":8081,"username":"u2","password":"p2"}}` + return "chain+socks5://" + url.QueryEscape(raw) +} + +func TestDirectProxyBridgeIgnoresClashYAML(t *testing.T) { + t.Parallel() + + src := "name: JP-Vmess(Dmit) 1.5x\n" + + "type: vmess\n" + + "server: example.com\n" + + "port: 10091\n" + + "uuid: 5ef4299f-a5eb-4aaa-9bf5-60b541b19294\n" + + "alterId: 0\n" + + "cipher: auto\n" + + "tls: true\n" + + "network: ws\n" + + "ws-opts:\n" + + " path: /\n" + + " headers:\n" + + " Host: example.com\n" + + outbound, ok, err := buildDirectProxyBridgeOutbound(src) + if err != nil { + t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err) + } + if ok || outbound != nil { + t.Fatalf("clash yaml must not be treated as direct proxy bridge: ok=%v outbound=%v", ok, outbound) + } + + standard, parsedOutbound, err := ParseProxyNode(src) + if err != nil { + t.Fatalf("ParseProxyNode returned error: %v", err) + } + if standard != "" { + t.Fatalf("expected xray outbound, got standard proxy %q", standard) + } + if parsedOutbound == nil || parsedOutbound["protocol"] != "vmess" { + t.Fatalf("expected vmess outbound, got %#v", parsedOutbound) + } +} diff --git a/backend/test/proxy/speedtest_debug_test.go b/backend/test/proxy/speedtest_debug_test.go index 0f1301f4..6a875253 100644 --- a/backend/test/proxy/speedtest_debug_test.go +++ b/backend/test/proxy/speedtest_debug_test.go @@ -10,7 +10,7 @@ import ( ) // 模拟数据库中实际存储的 Clash YAML 格式代理配置 -var testTrojanConfig = `- name: HK01|香港|x1.0 +var testTrojanConfig = `- name: Node01 type: trojan server: trojan.example.com port: 443 @@ -21,7 +21,7 @@ var testTrojanConfig = `- name: HK01|香港|x1.0 var testVmessConfig = `- name: DE-Vmess(NL1) 1x type: vmess - server: 203.0.113.55 + server: proxy.invalid port: 443 uuid: 11111111-1111-4111-8111-111111111111 alterId: 0 @@ -56,7 +56,7 @@ func TestProtocolDetection(t *testing.T) { {"vmess-clash", testVmessConfig}, {"hysteria2-clash", testHysteria2Config}, {"socks5-direct", "socks5://127.0.0.1:1080"}, - {"http-direct", "http://127.0.0.1:7890"}, + {"http-direct", "http://proxy.invalid:8080"}, } for _, tt := range tests { @@ -96,7 +96,7 @@ func TestSpeedTestWithMockProxies(t *testing.T) { {ProxyId: "test-trojan", ProxyName: "测试trojan", ProxyConfig: testTrojanConfig}, {ProxyId: "test-vmess", ProxyName: "测试vmess", ProxyConfig: testVmessConfig}, {ProxyId: "test-hysteria2", ProxyName: "测试hysteria2", ProxyConfig: testHysteria2Config}, - {ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://127.0.0.1:7890"}, + {ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://proxy.invalid:8080"}, } for _, p := range proxies { diff --git a/config.yaml b/config.yaml index 25a426ef..272d2778 100644 --- a/config.yaml +++ b/config.yaml @@ -1,62 +1,69 @@ database: - type: sqlite - sqlite: - path: data/app.db + type: sqlite + sqlite: + path: data/app.db app: - name: Ant Browser - window: - width: 1750 - height: 1000 - min_width: 1200 - min_height: 700 - max_profile_limit: 20 - used_cd_keys: [] + name: Ant Browser + window: + width: 1750 + height: 1000 + min_width: 1200 + min_height: 700 + max_profile_limit: 20 + used_cd_keys: [] runtime: - max_memory_mb: 0 - gc_percent: 100 + max_memory_mb: 0 + gc_percent: 100 logging: - level: info - file_enabled: false - file_path: data/logs/app.log - format: text - buffer_size: 4 - async_queue_size: 1000 - flush_interval_ms: 1000 - rotation: - enabled: false - max_size_mb: 100 - max_age: 7 - max_backups: 5 - time_interval: daily - interceptor: - enabled: true - log_parameters: true - log_results: true - sensitive_fields: - - password - - token - - secret + level: info + file_enabled: false + file_path: data/logs/app.log + format: text + buffer_size: 4 + async_queue_size: 1000 + flush_interval_ms: 1000 + rotation: + enabled: false + max_size_mb: 100 + max_age: 7 + max_backups: 5 + time_interval: daily + interceptor: + enabled: true + log_parameters: true + log_results: true + sensitive_fields: + - password + - token + - secret browser: - user_data_root: data - default_fingerprint_args: - - --fingerprint-brand=Chrome - - --fingerprint-platform=windows - default_launch_args: - - --disable-sync - - --no-first-run - default_bookmarks: [] - cores: [] - proxies: [] - profiles: [] + user_data_root: data + default_fingerprint_args: + - --fingerprint-brand=Chrome + - --fingerprint-platform=windows + default_launch_args: + - --disable-sync + - --no-first-run + default_start_urls: [] + restore_last_session: false + start_ready_timeout_ms: 3000 + start_stable_window_ms: 1200 +proxy_check: + bridge_start_timeout_ms: 15000 + speed_target_id: "" + ip_health_target_id: "" + targets: [] launch_server: - port: 19876 + port: 19876 + auth: + enabled: false + api_key: "" + header: X-Ant-Api-Key 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 + enabled: false + install_policy: on_demand + runtime_version: node-22.15.1-playwright-core-1.59.0 + keep_runtime_on_disable: true + node_source: auto + node_version: 22.15.1 + playwright_core_version: 1.59.0 diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ab9424dd..774be3c0 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -bf6dd2f2f453474c0fc4b1cf2c98596b \ No newline at end of file +7b7cd01deb4f6d205b686dee62014883 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 390bd7c1..d796082f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,7 @@ import { } from "react-router-dom"; import { ThemeProvider } from "./shared/theme"; import { Layout } from "./shared/layout"; -import { ToastContainer, Modal, Button, Loading } from "./shared/components"; +import { ToastContainer, Modal, Button, Loading, toast } from "./shared/components"; import { AlertCircle } from "lucide-react"; import { useNotificationStore } from "./store/notificationStore"; import { useBackupStore } from "./store/backupStore"; @@ -171,10 +171,11 @@ function useWailsNotifications() { "proxy:bridge:failed", (data: { profileId: string; profileName: string; error: string }) => { addNotification({ - type: "error", - title: "代理连接失败", - message: `「${data.profileName || data.profileId}」代理桥接启动失败:${data.error}`, + type: "warning", + title: "代理已降级直连", + message: `「${data.profileName || data.profileId}」${data.error}`, }); + toast.warning(`「${data.profileName || data.profileId}」代理桥接失败,已直连启动`, 6000); }, ); diff --git a/frontend/src/modules/browser/api.ts b/frontend/src/modules/browser/api.ts index 76a613c6..4489847d 100644 --- a/frontend/src/modules/browser/api.ts +++ b/frontend/src/modules/browser/api.ts @@ -3,6 +3,7 @@ export * from './api/instances' export * from './api/settings' export * from './api/cores' export * from './api/proxies' +export * from './api/proxyCheck' export * from './api/cookies' export * from './api/snapshots' export * from './api/bookmarks' diff --git a/frontend/src/modules/browser/api/bookmarks.ts b/frontend/src/modules/browser/api/bookmarks.ts index 89a3346a..55e6569a 100644 --- a/frontend/src/modules/browser/api/bookmarks.ts +++ b/frontend/src/modules/browser/api/bookmarks.ts @@ -1,4 +1,4 @@ -import type { BrowserBookmark } from '../types' +import type { BookmarkSyncResult, BrowserBookmark } from '../types' import { getBindings } from './runtime' export async function fetchBookmarks(): Promise { @@ -7,11 +7,14 @@ export async function fetchBookmarks(): Promise { 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/' }, + { name: 'Google', url: 'https://www.google.com/', openOnStart: false }, + { name: 'Gmail', url: 'https://mail.google.com/', openOnStart: false }, + { name: 'Claude', url: 'https://claude.ai/', openOnStart: false }, + { name: 'ChatGPT', url: 'https://chatgpt.com/', openOnStart: false }, + { name: 'YouTube', url: 'https://www.youtube.com/', openOnStart: false }, + { name: 'IPPure', url: 'https://ippure.com/', openOnStart: false }, + { name: 'IPLark', url: 'https://iplark.com/', openOnStart: false }, + { name: 'Ping0', url: 'https://ping0.cc/', openOnStart: false }, ] } @@ -32,3 +35,18 @@ export async function resetBookmarks(): Promise { } return true } + +export async function syncBookmarksToProfiles(): Promise { + const bindings: any = await getBindings() + if (bindings?.BookmarkSyncToProfiles) { + return await bindings.BookmarkSyncToProfiles() + } + return { + total: 0, + synced: 0, + skipped: 0, + failed: 0, + skippedList: [], + failedList: [], + } +} diff --git a/frontend/src/modules/browser/api/instances.ts b/frontend/src/modules/browser/api/instances.ts index 52038b69..bf0c01d0 100644 --- a/frontend/src/modules/browser/api/instances.ts +++ b/frontend/src/modules/browser/api/instances.ts @@ -24,6 +24,14 @@ export async function startBrowserInstance(profileId: string): Promise item.profileId === profileId) || null } +export async function startBrowserInstanceDirect(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserInstanceStartDirect) { + return (await bindings.BrowserInstanceStartDirect(profileId)) || null + } + return startBrowserInstance(profileId) +} + export async function startBrowserInstanceByCode(code: string): Promise { const bindings: any = await getBindings() if (bindings?.BrowserInstanceStartByCode) { diff --git a/frontend/src/modules/browser/api/proxies.ts b/frontend/src/modules/browser/api/proxies.ts index 703b81b5..1f4d9814 100644 --- a/frontend/src/modules/browser/api/proxies.ts +++ b/frontend/src/modules/browser/api/proxies.ts @@ -1,4 +1,4 @@ -import type { BrowserProxy, ProxyIPHealthResult } from '../types' +import type { BrowserProxy, ProxyIPHealthResult } from '../types' import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime' export interface ClashImportURLResult { @@ -124,7 +124,7 @@ export async function browserProxyCheckIPHealth(proxyId: string): Promise ({ proxyId, ok: true, - source: 'ippure', + source: 'ip_health', error: '', ip: '127.0.0.1', fraudScore: Math.floor(Math.random() * 100), diff --git a/frontend/src/modules/browser/api/proxyCheck.ts b/frontend/src/modules/browser/api/proxyCheck.ts new file mode 100644 index 00000000..a5751756 --- /dev/null +++ b/frontend/src/modules/browser/api/proxyCheck.ts @@ -0,0 +1,28 @@ +import type { ProxyCheckSettings } from '../types' +import { getBindings } from './runtime' + +export function createDefaultProxyCheckSettings(): ProxyCheckSettings { + return { + bridgeStartTimeoutMs: 15000, + speedTargetId: '', + ipHealthTargetId: '', + targets: [], + } +} + +export async function fetchProxyCheckSettings(): Promise { + const bindings: any = await getBindings() + if (bindings?.GetProxyCheckSettings) { + return (await bindings.GetProxyCheckSettings()) || createDefaultProxyCheckSettings() + } + return createDefaultProxyCheckSettings() +} + +export async function saveProxyCheckSettings(settings: ProxyCheckSettings): Promise { + const bindings: any = await getBindings() + if (bindings?.SaveProxyCheckSettings) { + await bindings.SaveProxyCheckSettings(settings) + return true + } + return true +} diff --git a/frontend/src/modules/browser/api/runtime.ts b/frontend/src/modules/browser/api/runtime.ts index 7b695c59..74e5c6eb 100644 --- a/frontend/src/modules/browser/api/runtime.ts +++ b/frontend/src/modules/browser/api/runtime.ts @@ -1,4 +1,4 @@ -import type { BrowserCore, BrowserProfile, BrowserProxy, BrowserSettings } from '../types' +import type { BrowserCore, BrowserProfile, BrowserProxy, BrowserSettings } from '../types' export async function getBindings() { try { @@ -21,11 +21,7 @@ export function createDefaultBrowserSettings(): BrowserSettings { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], - defaultStartUrls: [ - 'https://ippure.com/', - 'https://iplark.com/', - 'https://ping0.cc/', - ], + defaultStartUrls: [], restoreLastSession: false, startReadyTimeoutMs: 3000, startStableWindowMs: 1200, diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts index 1fce4ffa..9ef87a91 100644 --- a/frontend/src/modules/browser/automationScriptApi.ts +++ b/frontend/src/modules/browser/automationScriptApi.ts @@ -107,6 +107,7 @@ function normalizeAutomationScriptRunInput( paramsText: "", useScriptSelector: true, useScriptParams: true, + timeoutMs: 0, launchCode: "", startByCodeBeforeRun: false, }; @@ -118,6 +119,9 @@ function normalizeAutomationScriptRunInput( paramsText: String(input?.paramsText || ""), useScriptSelector: input?.useScriptSelector !== false, useScriptParams: input?.useScriptParams !== false, + timeoutMs: Number.isFinite(Number(input?.timeoutMs)) + ? Math.round(Number(input?.timeoutMs)) + : 0, launchCode: String(input?.launchCode || "") .trim() .toUpperCase(), diff --git a/frontend/src/modules/browser/automationScripts.ts b/frontend/src/modules/browser/automationScripts.ts index 74d27e9c..a8165a03 100644 --- a/frontend/src/modules/browser/automationScripts.ts +++ b/frontend/src/modules/browser/automationScripts.ts @@ -74,6 +74,7 @@ export interface AutomationScriptRunInput { paramsText?: string; useScriptSelector?: boolean; useScriptParams?: boolean; + timeoutMs?: number; launchCode?: string; startByCodeBeforeRun?: boolean; } diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx index d6cf25ad..5a4077e6 100644 --- a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx +++ b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { Copy, Play } from "lucide-react"; +import { Copy, FileText, FolderOpen, Play } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import { Badge, Button, @@ -13,6 +14,7 @@ import { import { copyBrowserProfile, fetchBrowserProfiles, + openCorePath, } from "../api"; import { runAutomationScript } from "../automationScriptApi"; import { @@ -39,6 +41,12 @@ interface DemoCreateDraft { templateProfileId: string; } +interface ResultOutputEntry { + key: string; + label: string; + path: string; +} + interface AutomationScriptRunModalProps { open: boolean; script: AutomationScriptRecord | null; @@ -95,6 +103,105 @@ function formatDuration(durationMs?: number): string { return `${(durationMs / 1000).toFixed(2)} s`; } +function parseRunResultOutputs(resultText?: string): ResultOutputEntry[] { + const normalized = String(resultText || "").trim(); + if (!normalized) { + return []; + } + + try { + const parsed = JSON.parse(normalized); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return []; + } + + const seen = new Set(); + const outputs: ResultOutputEntry[] = []; + + const addOutput = (key: string, value: string) => { + const path = value.trim(); + if (!path || seen.has(path)) { + return; + } + seen.add(path); + outputs.push({ + key, + label: formatRunResultOutputLabel(key), + path, + }); + }; + + const collectOutputs = (value: unknown, keyHint = "") => { + if (!value) { + return; + } + if (typeof value === "string") { + if (/path$/i.test(keyHint)) { + addOutput(keyHint, value); + } + return; + } + if (Array.isArray(value)) { + if (keyHint === "artifacts") { + value.forEach((item) => { + if (typeof item === "string") { + addOutput(keyHint, item); + } + }); + return; + } + value.forEach((item) => collectOutputs(item, keyHint)); + return; + } + if (typeof value !== "object") { + return; + } + + for (const [nestedKey, nestedValue] of Object.entries( + value as Record, + )) { + collectOutputs(nestedValue, nestedKey); + } + }; + + collectOutputs(parsed); + return outputs; + } catch { + return []; + } +} + +function formatRunResultOutputLabel(key: string): string { + switch (key) { + case "outputPath": + return "输出文件"; + case "screenshotPath": + return "截图文件"; + case "artifacts": + return "导出文件"; + default: + return key; + } +} + +function formatRunResultOutputName(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] || path; +} + +function formatRunResultText(resultText?: string): string { + const normalized = String(resultText || "").trim(); + if (!normalized) { + return ""; + } + + try { + return JSON.stringify(JSON.parse(normalized), null, 2); + } catch { + return resultText || ""; + } +} + async function copyToClipboard(text: string, successMessage: string) { try { await navigator.clipboard.writeText(text); @@ -134,7 +241,7 @@ function isPlaceholderSelectorText(text: string): boolean { .trim() .toUpperCase() : ""; - return !code || code === "BUYER_001"; + return !code || code === "BUYER_001" || code === "DEMO_ABC123"; } catch { return false; } @@ -251,10 +358,20 @@ function resolvePreferredProfileId( function buildSelectableProfileOptions(profiles: SelectableProfile[]) { return profiles.map((profile) => ({ value: profile.profileId, - label: `${profile.launchCode} · ${profile.profileName} · ${profile.running ? "运行中" : "已停止"}`, + label: `${profile.launchCode} · ${profile.profileName} · ${formatSelectableProfileStatus(profile)}`, })); } +function formatSelectableProfileStatus(profile: SelectableProfile): string { + if (profile.running && profile.debugReady && profile.debugPort > 0) { + return "可连接"; + } + if (profile.running) { + return "启动中"; + } + return "未启动,执行时自动启动"; +} + function sortTemplateProfiles(profiles: BrowserProfile[]) { return [...profiles].sort((left, right) => left.profileName.localeCompare(right.profileName, "zh-CN"), @@ -276,6 +393,7 @@ export function AutomationScriptRunModal({ dirty = false, onClose, }: AutomationScriptRunModalProps) { + const navigate = useNavigate(); const [selectorText, setSelectorText] = useState(""); const [paramsText, setParamsText] = useState(""); const [running, setRunning] = useState(false); @@ -356,22 +474,35 @@ export function AutomationScriptRunModal({ try { const allProfiles = await fetchBrowserProfiles(); const profiles = filterSelectableProfiles(allProfiles); - setAvailableProfiles(profiles); - setTemplateProfiles(sortTemplateProfiles(allProfiles)); - setSelectedProfileId((current) => { - const preferredProfile = resolvePreferredProfileId( + const nextSelectedProfileId = + resolvePreferredProfileId( profiles, preferredProfileId, preferredLaunchCode, + ) || + (selectedProfileId && + profiles.some((profile) => profile.profileId === selectedProfileId) + ? selectedProfileId + : profiles[0]?.profileId || ""); + const nextSelectedProfile = + profiles.find((profile) => profile.profileId === nextSelectedProfileId) || + null; + + setAvailableProfiles(profiles); + setTemplateProfiles(sortTemplateProfiles(allProfiles)); + setSelectedProfileId(nextSelectedProfileId); + if (demoMode === "select" && nextSelectedProfile) { + const nextSelectorText = buildDemoSelectorText( + nextSelectedProfile.launchCode, ); - if (preferredProfile) { - return preferredProfile; + if ( + resolveSelectorLaunchCode(selectorText) !== + nextSelectedProfile.launchCode + ) { + setSelectorText(nextSelectorText); } - if (current && profiles.some((profile) => profile.profileId === current)) { - return current; - } - return ""; - }); + syncDemoSessionFromProfile(nextSelectedProfile, "选择已有实例"); + } setCreateDraft((current) => { if ( current.templateProfileId && @@ -436,7 +567,7 @@ export function AutomationScriptRunModal({ resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode, false, ); - }, [open, reloadDemoSession, script, usesStoredTargetConfig]); + }, [open, script, usesStoredTargetConfig]); useEffect(() => { if (!open || !script || script.type !== "playwright-cdp") { @@ -585,6 +716,14 @@ export function AutomationScriptRunModal({ selectorText, demoSession, ); + if ( + script.type === "playwright-cdp" && + !usesStoredTargetConfig && + demoMode === "select" && + selectedProfile + ) { + nextSelectorText = buildDemoSelectorText(selectedProfile.launchCode); + } const selectorError = usesStoredTargetConfig ? "" : validateJsonObjectText( @@ -628,6 +767,14 @@ export function AutomationScriptRunModal({ if (nextSelectorText !== selectorText) { setSelectorText(nextSelectorText); } + if ( + script.type === "playwright-cdp" && + !usesStoredTargetConfig && + demoMode === "select" && + selectedProfile + ) { + syncDemoSessionFromProfile(selectedProfile, "选择已有实例"); + } await executeRun(nextSelectorText, paramsText); }; @@ -649,6 +796,23 @@ export function AutomationScriptRunModal({ await handleRun(); }; + const handleOpenScriptDetail = () => { + if (!script || running || demoBusy) { + return; + } + onClose(); + navigate(`/browser/automation/${script.id}`); + }; + + const handleOpenOutputPath = async (path: string) => { + try { + await openCorePath(path); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "打开目录失败"; + toast.error(message); + } + }; + if (!script) { return null; } @@ -658,6 +822,8 @@ export function AutomationScriptRunModal({ script.type === "playwright-cdp" && !usesStoredTargetConfig; const selectableProfileOptions = buildSelectableProfileOptions(availableProfiles); const templateProfileOptions = buildTemplateProfileOptions(templateProfiles); + const resultOutputs = parseRunResultOutputs(lastRun?.resultText); + const formattedResultText = formatRunResultText(lastRun?.resultText); return (
-
- +
+
+ + {getAutomationScriptTypeLabel(script.type)} + + + {script.status === "ready" + ? "可用" + : script.status === "disabled" + ? "停用" + : "草稿"} + +
+
+ {script.name} +
+
+ 最近更新 {formatDateTime(script.updatedAt)} +
+
+
-
- {script.name} -
-
- 最近更新 {formatDateTime(script.updatedAt)} + + 查看脚本详情 +
@@ -794,7 +973,6 @@ export function AutomationScriptRunModal({ disabled={ running || demoBusy || - profilesLoading || selectableProfileOptions.length === 0 } /> @@ -914,7 +1092,7 @@ export function AutomationScriptRunModal({ size="sm" variant="secondary" onClick={() => - void copyToClipboard(lastRun.resultText, "执行结果已复制") + void copyToClipboard(formattedResultText, "执行结果已复制") } > @@ -923,10 +1101,39 @@ export function AutomationScriptRunModal({