V1.2.0 功能发布

This commit is contained in:
ant-black
2026-05-09 17:30:16 +08:00
parent 70132417ad
commit 5b4daa2cc6
100 changed files with 4317 additions and 613 deletions
+9
View File
@@ -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 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题
+24
View File
@@ -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, &notNull, &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)
+19
View File
@@ -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)
+2 -2
View File
@@ -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{
+92 -4
View File
@@ -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
}
+169
View File
@@ -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
}
-1
View File
@@ -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("代理表为空,初始化默认代理")
}
+33
View File
@@ -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
+9 -4
View File
@@ -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()
+5 -3
View File
@@ -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,
+10 -17
View File
@@ -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,
})
}
+144
View File
@@ -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
}
+21 -12
View File
@@ -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
+28
View File
@@ -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)
}
}
-1
View File
@@ -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
-1
View File
@@ -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 {
+11 -1
View File
@@ -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()
+40 -2
View File
@@ -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()
+35 -5
View File
@@ -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,
+20 -3
View File
@@ -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()
@@ -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 {
@@ -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}
}
@@ -11,3 +11,7 @@ import (
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
func prepareTaskCommand(cmd *exec.Cmd) {
hideWindow(cmd)
}
@@ -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 {
@@ -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 {
@@ -0,0 +1,10 @@
//go:build !windows
// +build !windows
package automation
import "syscall"
func killProcessGroup(pid int) error {
return syscall.Kill(-pid, syscall.SIGKILL)
}
@@ -0,0 +1,8 @@
//go:build windows
// +build windows
package automation
func killProcessGroup(pid int) error {
return nil
}
@@ -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()
@@ -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 {
+13 -4
View File
@@ -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
}
+36 -9
View File
@@ -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
+185
View File
@@ -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
}
+1 -8
View File
@@ -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) {
@@ -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)
+2 -2
View File
@@ -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)
}
}
@@ -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"},
},
}
@@ -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)
+21 -2
View File
@@ -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 {
+34 -5
View File
@@ -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,
+27 -2
View File
@@ -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()
+7
View File
@@ -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,
@@ -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
}
+89
View File
@@ -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
}
}
+12
View File
@@ -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 管理器未初始化")
+135 -18
View File
@@ -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 %dsource=%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 {
+51
View File
@@ -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)
}
}
+12 -8
View File
@@ -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)
+20
View File
@@ -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
}
@@ -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("未找到代理节点")
}
+29 -13
View File
@@ -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)
+16 -36
View File
@@ -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
+42 -1
View File
@@ -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")
}
}
+8 -57
View File
@@ -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()
+9 -3
View File
@@ -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://") {
+12 -10
View File
@@ -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
}
+192 -23
View File
@@ -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)))
+210
View File
@@ -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"])
}
}
+19 -10
View File
@@ -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 {
+173 -2
View File
@@ -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)
}
}
+4 -4
View File
@@ -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 {
+62 -55
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
bf6dd2f2f453474c0fc4b1cf2c98596b
7b7cd01deb4f6d205b686dee62014883
+5 -4
View File
@@ -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);
},
);
+1
View File
@@ -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'
+24 -6
View File
@@ -1,4 +1,4 @@
import type { BrowserBookmark } from '../types'
import type { BookmarkSyncResult, BrowserBookmark } from '../types'
import { getBindings } from './runtime'
export async function fetchBookmarks(): Promise<BrowserBookmark[]> {
@@ -7,11 +7,14 @@ export async function fetchBookmarks(): Promise<BrowserBookmark[]> {
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<boolean> {
}
return true
}
export async function syncBookmarksToProfiles(): Promise<BookmarkSyncResult> {
const bindings: any = await getBindings()
if (bindings?.BookmarkSyncToProfiles) {
return await bindings.BookmarkSyncToProfiles()
}
return {
total: 0,
synced: 0,
skipped: 0,
failed: 0,
skippedList: [],
failedList: [],
}
}
@@ -24,6 +24,14 @@ export async function startBrowserInstance(profileId: string): Promise<BrowserPr
return nextProfiles.find((item) => item.profileId === profileId) || null
}
export async function startBrowserInstanceDirect(profileId: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceStartDirect) {
return (await bindings.BrowserInstanceStartDirect(profileId)) || null
}
return startBrowserInstance(profileId)
}
export async function startBrowserInstanceByCode(code: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceStartByCode) {
+4 -4
View File
@@ -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<ProxyI
(await bindings.BrowserProxyCheckIPHealth(proxyId)) || {
proxyId,
ok: false,
source: 'ippure',
source: 'ip_health',
error: '调用失败',
ip: '',
fraudScore: 0,
@@ -144,7 +144,7 @@ export async function browserProxyCheckIPHealth(proxyId: string): Promise<ProxyI
return {
proxyId,
ok: true,
source: 'ippure',
source: 'ip_health',
error: '',
ip: '127.0.0.1',
fraudScore: Math.floor(Math.random() * 100),
@@ -169,7 +169,7 @@ export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurr
return proxyIds.map((proxyId) => ({
proxyId,
ok: true,
source: 'ippure',
source: 'ip_health',
error: '',
ip: '127.0.0.1',
fraudScore: Math.floor(Math.random() * 100),
@@ -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<ProxyCheckSettings> {
const bindings: any = await getBindings()
if (bindings?.GetProxyCheckSettings) {
return (await bindings.GetProxyCheckSettings()) || createDefaultProxyCheckSettings()
}
return createDefaultProxyCheckSettings()
}
export async function saveProxyCheckSettings(settings: ProxyCheckSettings): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.SaveProxyCheckSettings) {
await bindings.SaveProxyCheckSettings(settings)
return true
}
return true
}
+2 -6
View File
@@ -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,
@@ -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(),
@@ -74,6 +74,7 @@ export interface AutomationScriptRunInput {
paramsText?: string;
useScriptSelector?: boolean;
useScriptParams?: boolean;
timeoutMs?: number;
launchCode?: string;
startByCodeBeforeRun?: boolean;
}
@@ -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<string>();
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<string, unknown>,
)) {
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 (
<Modal
@@ -687,36 +853,49 @@ export function AutomationScriptRunModal({
>
<div className="space-y-5">
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)] px-4 py-4">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={script.type === "launch-api" ? "info" : "default"}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={script.type === "launch-api" ? "info" : "default"}
size="sm"
>
{getAutomationScriptTypeLabel(script.type)}
</Badge>
<Badge
variant={
script.status === "ready"
? "success"
: script.status === "disabled"
? "default"
: "warning"
}
size="sm"
dot
>
{script.status === "ready"
? "可用"
: script.status === "disabled"
? "停用"
: "草稿"}
</Badge>
</div>
<div className="mt-3 text-sm text-[var(--color-text-primary)]">
{script.name}
</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
{formatDateTime(script.updatedAt)}
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleOpenScriptDetail}
disabled={running || demoBusy}
>
{getAutomationScriptTypeLabel(script.type)}
</Badge>
<Badge
variant={
script.status === "ready"
? "success"
: script.status === "disabled"
? "default"
: "warning"
}
size="sm"
dot
>
{script.status === "ready"
? "可用"
: script.status === "disabled"
? "停用"
: "草稿"}
</Badge>
</div>
<div className="mt-3 text-sm text-[var(--color-text-primary)]">
{script.name}
</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
{formatDateTime(script.updatedAt)}
<FileText className="h-4 w-4" />
</Button>
</div>
</div>
@@ -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, "执行结果已复制")
}
>
<Copy className="h-3.5 w-3.5" />
@@ -923,10 +1101,39 @@ export function AutomationScriptRunModal({
</div>
<Textarea
rows={10}
value={lastRun.resultText}
value={formattedResultText}
readOnly
className="font-mono"
/>
{resultOutputs.length > 0 && (
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="space-y-2">
{resultOutputs.map((output) => (
<div
key={`${output.key}-${output.path}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="text-sm text-[var(--color-text-primary)]">
{output.label} · {formatRunResultOutputName(output.path)}
</div>
<div className="mt-1 break-all text-xs text-[var(--color-text-muted)]">
{output.path}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void handleOpenOutputPath(output.path)}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, XCircle } from 'lucide-react'
import { Button, Card, FormItem, Input, Modal, StatCard, Switch, Table, Textarea } from '../../../shared/components'
@@ -250,7 +250,7 @@ export function BrowserListSettingsModal({
value={startUrlsText}
onChange={(event) => onStartUrlsTextChange(event.target.value)}
rows={4}
placeholder="https://ippure.com/"
placeholder="启动 URL"
/>
</FormItem>
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开默认启动页或空白页">
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState } from 'react'
import { CheckCircle, Edit2, Plus, Star, Trash2, XCircle } from 'lucide-react'
import { Button, Card, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
@@ -132,7 +132,7 @@ export function BrowserSettingsModal({ open, onClose, settings: initSettings, co
<Textarea value={launchText} onChange={e => setLaunchText(e.target.value)} rows={3} placeholder="--disable-sync" />
</FormItem>
<FormItem label="默认启动页面(每行一个 URL">
<Textarea value={startUrlsText} onChange={e => setStartUrlsText(e.target.value)} rows={4} placeholder="https://ippure.com/" />
<Textarea value={startUrlsText} onChange={e => setStartUrlsText(e.target.value)} rows={4} placeholder="启动 URL" />
</FormItem>
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开上面配置的默认页面或空白页">
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border-default)] px-3 py-2">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { ChevronDown, ChevronUp, RefreshCw, Wand2 } from 'lucide-react'
import { ConfirmModal, FormItem, Input, Select, Textarea } from '../../../shared/components'
import {
@@ -60,7 +60,7 @@ const TIMEZONE_OPTIONS = [
{ value: 'America/Denver', label: 'America/Denver (UTC-7)' },
{ value: 'America/Toronto', label: 'America/Toronto (UTC-5)' },
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (UTC-3)' },
// 欧洲
// EMEA
{ value: 'Europe/London', label: 'Europe/London (UTC+0)' },
{ value: 'Europe/Paris', label: 'Europe/Paris (UTC+1)' },
{ value: 'Europe/Berlin', label: 'Europe/Berlin (UTC+1)' },
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import yaml from 'js-yaml'
import { Button, FormItem, Input, Modal, Select, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProxy } from '../types'
import { fetchClashImportFromURL, saveBrowserProxies } from '../api'
import { DIRECT_QUICK_IMPORT_TEMPLATE, buildDirectImportCandidatesFromText, parseDirectImportText } from '../pages/proxyPool/helpers'
interface ProxyImportModalProps {
open: boolean
@@ -35,6 +36,7 @@ interface DirectImportForm {
}
interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
@@ -67,12 +69,14 @@ const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
proxyName: '',
localPort: '',
first: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
second: {
protocol: 'http',
server: '',
port: '',
username: '',
@@ -83,6 +87,7 @@ const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
interface ImportCandidate {
proxyName: string
proxyConfig: string
groupName?: string
}
interface ProxyDisplayInfo {
@@ -98,7 +103,7 @@ interface ProxyDisplayInfo {
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
interface ChainSocks5HopConfig {
protocol: 'socks5'
protocol: 'http' | 'socks5'
server: string
port: number
username?: string
@@ -125,7 +130,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5') return null
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
@@ -138,7 +143,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
if (password && !username) return null
return {
protocol: 'socks5',
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
@@ -380,6 +385,7 @@ function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
@@ -408,7 +414,7 @@ function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
}
return {
protocol: 'socks5',
protocol,
server,
port,
username: username || undefined,
@@ -459,7 +465,7 @@ function buildImportPreview(candidates: ImportCandidate[], groupName: string): P
proxyId: `preview-${index}`,
proxyName: candidate.proxyName,
proxyConfig: candidate.proxyConfig,
groupName,
groupName: candidate.groupName || groupName,
type: info.type || '-',
server: info.server || '-',
port: info.port || 0,
@@ -563,6 +569,7 @@ export function ProxyImportModal({
const [importDnsServers, setImportDnsServers] = useState('')
const [importNamePrefix, setImportNamePrefix] = useState('')
const [importGroupName, setImportGroupName] = useState('')
const [directImportText, setDirectImportText] = useState('')
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
const [chainImportForm, setChainImportForm] = useState<ChainImportForm>(() => ({ ...INITIAL_CHAIN_IMPORT_FORM }))
const [previewModalOpen, setPreviewModalOpen] = useState(false)
@@ -583,6 +590,7 @@ export function ProxyImportModal({
setImportDnsServers('')
setImportNamePrefix('')
setImportGroupName('')
setDirectImportText('')
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
setChainImportForm({ ...INITIAL_CHAIN_IMPORT_FORM })
setPreviewList([])
@@ -644,16 +652,28 @@ export function ProxyImportModal({
const handleParseImport = () => {
try {
const prefix = importNamePrefix.trim()
const candidates = importMode === 'clash'
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
: importMode === 'direct'
? [buildDirectImportCandidate(directImportForm)]
: [buildChainImportCandidate(chainImportForm)]
let candidates
let previewGroupName = importGroupName.trim()
if (importMode === 'clash') {
candidates = buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
} else if (importMode === 'direct') {
if (directImportText.trim()) {
const parsed = buildDirectImportCandidatesFromText(directImportText)
candidates = parsed.candidates
if (!previewGroupName) {
previewGroupName = parsed.defaultGroupName
}
} else {
candidates = [buildDirectImportCandidate(directImportForm)]
}
} else {
candidates = [buildChainImportCandidate(chainImportForm)]
}
if (!candidates.length) {
toast.error('未解析到可导入代理')
return
}
const preview = buildImportPreview(candidates, importGroupName.trim())
const preview = buildImportPreview(candidates, previewGroupName)
setPreviewList(preview)
setPreviewModalOpen(true)
} catch (error: any) {
@@ -661,6 +681,36 @@ export function ProxyImportModal({
}
}
const handleFillDirectTemplate = () => {
setDirectImportText(DIRECT_QUICK_IMPORT_TEMPLATE)
}
const handleCopyDirectTemplate = async () => {
try {
if (!navigator?.clipboard?.writeText) {
throw new Error('当前环境不支持剪贴板')
}
await navigator.clipboard.writeText(DIRECT_QUICK_IMPORT_TEMPLATE)
toast.success('JSON 模板已复制')
} catch (error: any) {
toast.error(error?.message || '复制模板失败')
}
}
const handleApplyDirectText = () => {
try {
const { form, groupName } = parseDirectImportText(directImportText)
setDirectImportForm(form)
if (groupName) {
setImportGroupName(groupName)
}
setDirectImportText('')
toast.success('文本已应用')
} catch (error: any) {
toast.error(error?.message || '文本应用失败')
}
}
const handleConfirmImport = async () => {
if (previewList.length === 0) {
toast.error('请至少保留 1 个代理后再导入')
@@ -687,7 +737,7 @@ export function ProxyImportModal({
proxyName: p.proxyName,
proxyConfig: p.proxyConfig,
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
groupName: importGroupName.trim() || undefined,
groupName: p.groupName.trim() || undefined,
sourceId: sourceID || undefined,
sourceUrl: sourceURL || undefined,
sourceNamePrefix: sourceNamePrefix || undefined,
@@ -719,8 +769,11 @@ export function ProxyImportModal({
const canParseImport = importMode === 'clash'
? !!importText.trim()
: importMode === 'direct'
? !!directImportForm.server.trim() && !!directImportForm.port.trim()
: !!chainImportForm.first.server.trim() && !!chainImportForm.first.port.trim() && !!chainImportForm.second.server.trim() && !!chainImportForm.second.port.trim()
? !!directImportText.trim() || (!!directImportForm.server.trim() && !!directImportForm.port.trim())
: !!chainImportForm.first.server.trim()
&& !!chainImportForm.first.port.trim()
&& !!chainImportForm.second.server.trim()
&& !!chainImportForm.second.port.trim()
const previewColumns = useMemo<TableColumn<ProxyDisplayInfo>[]>(() => [
{ key: 'proxyName', title: '代理名称', width: '200px' },
@@ -769,7 +822,7 @@ export function ProxyImportModal({
variant={importMode === 'direct' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('direct')}
>
HTTP / SOCKS5
HTTP / SOCKS5
</Button>
<Button
variant={importMode === 'chain' ? undefined : 'secondary'}
@@ -782,7 +835,7 @@ export function ProxyImportModal({
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
@@ -798,7 +851,7 @@ export function ProxyImportModal({
setImportResolvedUrl('')
}
}}
placeholder="https://example.com/clash/subscription"
placeholder="订阅 URL"
className="flex-1"
/>
<Button
@@ -826,52 +879,76 @@ export function ProxyImportModal({
</>
)}
{importMode === 'direct' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="例如:香港节点"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="留空则不使用密码"
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
<FormItem label="文本辅助(可选)" hint="支持单个 JSON、JSON 数组,或多行 http:// / https:// / socks5://,每行一个">
<Textarea
value={directImportText}
onChange={e => setDirectImportText(e.target.value)}
rows={8}
placeholder={DIRECT_QUICK_IMPORT_TEMPLATE}
/>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={handleFillDirectTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={() => void handleCopyDirectTemplate()}>
</Button>
<Button size="sm" variant="secondary" onClick={handleApplyDirectText} disabled={!directImportText.trim()}>
</Button>
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
</p>
</FormItem>
</div>
)}
@@ -882,7 +959,7 @@ export function ProxyImportModal({
<Input
value={chainImportForm.proxyName}
onChange={e => setChainImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="例如:双层香港链路"
placeholder="链路名称"
/>
</FormItem>
<FormItem label="本地监听端口(可选)">
@@ -898,8 +975,18 @@ export function ProxyImportModal({
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"> SOCKS5</h4>
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.first.protocol}
onChange={e => updateChainHop('first', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.first.server}
@@ -936,8 +1023,18 @@ export function ProxyImportModal({
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"> SOCKS5</h4>
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.second.protocol}
onChange={e => updateChainHop('second', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.second.server}
@@ -979,7 +1076,7 @@ export function ProxyImportModal({
<Input
value={importGroupName}
onChange={e => setImportGroupName(e.target.value)}
placeholder="例如:香港、美国、机场A"
placeholder="分组名称"
list="proxy-groups-datalist"
/>
{groups.length > 0 && (
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, Loader2, Pencil, Plus, Search, Trash2, Wifi, X } from 'lucide-react'
import { Button, ConfirmModal, FormItem, Input, Modal, Textarea, toast } from '../../../shared/components'
import { Button, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components'
import type { BrowserProxy } from '../types'
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies } from '../api'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
@@ -19,7 +19,7 @@ interface ProxyPickerModalProps {
type SpeedResult = { ok: boolean; latencyMs: number; error: string }
type ChainSocksHop = {
protocol?: string
protocol?: 'http' | 'socks5'
server?: string
port?: number
username?: string
@@ -33,6 +33,7 @@ type ChainSocksConfig = {
}
interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
@@ -49,12 +50,10 @@ interface ChainEditForm {
const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
proxyName: '',
localPort: '',
first: { server: '', port: '', username: '', password: '' },
second: { server: '', port: '', username: '', password: '' },
first: { protocol: 'http', server: '', port: '', username: '', password: '' },
second: { protocol: 'http', server: '', port: '', username: '', password: '' },
}
const LOCAL_PROXY_ID = '__local__'
function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
@@ -69,7 +68,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5') return null
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
@@ -82,7 +81,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
if (password && !username) return null
return {
protocol: 'socks5',
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
@@ -118,12 +117,14 @@ function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditFor
proxyName,
localPort: cfg.localPort ? String(cfg.localPort) : '',
first: {
protocol: cfg.first?.protocol || 'socks5',
server: cfg.first?.server || '',
port: cfg.first?.port ? String(cfg.first.port) : '',
username: cfg.first?.username || '',
password: cfg.first?.password || '',
},
second: {
protocol: cfg.second?.protocol || 'socks5',
server: cfg.second?.server || '',
port: cfg.second?.port ? String(cfg.second.port) : '',
username: cfg.second?.username || '',
@@ -134,6 +135,7 @@ function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditFor
function buildChainProxyConfig(form: ChainEditForm): string {
const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
@@ -162,7 +164,7 @@ function buildChainProxyConfig(form: ChainEditForm): string {
}
return {
protocol: 'socks5',
protocol,
server,
port,
username: username || undefined,
@@ -499,7 +501,7 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
const handleDeleteClick = (proxy: BrowserProxy, e: React.MouseEvent) => {
e.stopPropagation()
if (proxy.proxyId === DIRECT_PROXY_ID || proxy.proxyId === LOCAL_PROXY_ID) return
if (proxy.proxyId === DIRECT_PROXY_ID) return
setDeleteCandidate(proxy)
}
@@ -639,12 +641,12 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
setEditName(e.target.value)
}
}}
placeholder="例如:香港节点"
placeholder="节点名称"
/>
</FormItem>
<FormItem label="分组名称(可选)">
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="例如:香港、美国" />
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="分组名称" />
</FormItem>
{chainEditMode ? (
@@ -661,8 +663,18 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
</FormItem>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"> SOCKS5</h4>
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainEditForm.first.protocol}
onChange={e => updateChainHop('first', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input value={chainEditForm.first.server} onChange={e => updateChainHop('first', 'server', e.target.value)} />
</FormItem>
@@ -679,8 +691,18 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"> SOCKS5</h4>
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainEditForm.second.protocol}
onChange={e => updateChainHop('second', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input value={chainEditForm.second.server} onChange={e => updateChainHop('second', 'server', e.target.value)} />
</FormItem>
@@ -771,8 +793,7 @@ function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResul
function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
const isDirect = proxy.proxyId === DIRECT_PROXY_ID
const isLocal = proxy.proxyId === LOCAL_PROXY_ID
const disableDelete = isDirect || isLocal
const disableDelete = isDirect
return (
<div
@@ -810,7 +831,7 @@ function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSele
<button
onClick={onDelete}
disabled={disableDelete}
title={isDirect ? '直连不可删除' : isLocal ? '本地代理不可删除' : '删除代理'}
title={isDirect ? '直连不可删除' : '删除代理'}
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, RotateCcw, GripVertical } from 'lucide-react'
import { Plus, Trash2, RotateCcw, GripVertical, RefreshCw } from 'lucide-react'
import { Button, Card, ConfirmModal, Input, toast } from '../../../shared/components'
import type { BrowserBookmark } from '../types'
import { fetchBookmarks, resetBookmarks, saveBookmarks } from '../api'
import { fetchBookmarks, resetBookmarks, saveBookmarks, syncBookmarksToProfiles } from '../api'
export function BookmarkSettingsPage() {
const [items, setItems] = useState<BrowserBookmark[]>([])
const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
const [resetOpen, setResetOpen] = useState(false)
const [syncOpen, setSyncOpen] = useState(false)
const [dragIndex, setDragIndex] = useState<number | null>(null)
useEffect(() => {
@@ -19,13 +21,17 @@ export function BookmarkSettingsPage() {
}
const handleAdd = () => {
setItems(prev => [...prev, { name: '', url: '' }])
setItems(prev => [...prev, { name: '', url: '', openOnStart: false }])
}
const handleDelete = (index: number) => {
setItems(prev => prev.filter((_, i) => i !== index))
}
const handleOpenOnStartChange = (index: number, checked: boolean) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, openOnStart: checked } : item))
}
const handleSave = async () => {
const valid = items.filter(i => i.name.trim() && i.url.trim())
if (valid.length !== items.length) {
@@ -35,7 +41,17 @@ export function BookmarkSettingsPage() {
setSaving(true)
try {
await saveBookmarks(items)
toast.success('书签已保存,下次新建实例时生效')
const result = await syncBookmarksToProfiles()
const parts = ['书签已保存']
if (result.synced > 0) parts.push(`已同步 ${result.synced} 个已有实例`)
if (result.skipped > 0) parts.push(`跳过运行中 ${result.skipped} 个,停止后再同步`)
if (result.failed > 0) parts.push(`失败 ${result.failed}`)
const message = parts.join('')
if (result.failed > 0 || result.skipped > 0) {
toast.warning(message)
} else {
toast.success(message)
}
} finally {
setSaving(false)
}
@@ -48,6 +64,27 @@ export function BookmarkSettingsPage() {
toast.success('已恢复默认书签')
}
const handleSync = async () => {
setSyncing(true)
try {
const result = await syncBookmarksToProfiles()
const parts = [`已同步 ${result.synced} 个实例`]
if (result.skipped > 0) parts.push(`跳过运行中 ${result.skipped} 个,停止后再同步`)
if (result.failed > 0) parts.push(`失败 ${result.failed}`)
const message = parts.join('')
if (result.failed > 0 || result.skipped > 0) {
toast.warning(message)
} else {
toast.success(message)
}
setSyncOpen(false)
} catch (error: any) {
toast.error(error?.message || '同步失败')
} finally {
setSyncing(false)
}
}
// 拖拽排序
const handleDragStart = (index: number) => setDragIndex(index)
const handleDragOver = (e: React.DragEvent, index: number) => {
@@ -71,6 +108,10 @@ export function BookmarkSettingsPage() {
<p className="text-sm text-[var(--color-text-muted)] mt-1"></p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setSyncOpen(true)} loading={syncing}>
<RefreshCw className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="secondary" size="sm" onClick={() => setResetOpen(true)}>
<RotateCcw className="w-4 h-4 mr-1.5" />
@@ -107,6 +148,15 @@ export function BookmarkSettingsPage() {
placeholder="https://..."
className="flex-1"
/>
<label className="flex items-center gap-1.5 px-2 text-xs text-[var(--color-text-secondary)] whitespace-nowrap select-none">
<input
type="checkbox"
checked={Boolean(item.openOnStart)}
onChange={e => handleOpenOnStartChange(index, e.target.checked)}
className="h-4 w-4 rounded border-[var(--color-border-default)] accent-[var(--color-accent)]"
/>
</label>
<button
type="button"
onClick={() => handleDelete(index)}
@@ -143,6 +193,15 @@ export function BookmarkSettingsPage() {
confirmText="确定恢复"
danger
/>
<ConfirmModal
open={syncOpen}
onClose={() => setSyncOpen(false)}
onConfirm={handleSync}
title="手动同步已有实例"
content="只会增量追加缺失的默认书签,不会删除、改名或移动用户已有书签。运行中的实例会跳过。"
confirmText="开始同步"
/>
</div>
)
}
@@ -183,6 +183,25 @@ export function BrowserEditPage() {
}
}
const handleProxyListUpdated = (nextProxies: BrowserProxy[]) => {
setProxies(nextProxies)
}
const handleProxyDeleted = (deletedProxyId: string, nextProxies: BrowserProxy[]) => {
setProxies(nextProxies)
if (formData.proxyId !== deletedProxyId) {
return
}
const fallbackProxy = nextProxies.find((proxy) => proxy.proxyId === directProxyID)
if (fallbackProxy) {
handleChange('proxyId', fallbackProxy.proxyId)
return
}
handleChange('proxyId', '')
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
@@ -281,6 +300,8 @@ export function BrowserEditPage() {
open={proxyPickerOpen}
currentProxyId={formData.proxyId}
onSelect={proxy => handleChange('proxyId', proxy.proxyId)}
onProxyListUpdated={handleProxyListUpdated}
onProxyDeleted={handleProxyDeleted}
onClose={() => setProxyPickerOpen(false)}
/>
@@ -25,6 +25,7 @@ import {
saveBrowserSettings,
setDefaultBrowserCore,
startBrowserInstance,
startBrowserInstanceDirect,
stopBrowserInstance,
validateBrowserCorePath,
validateProxyConfig,
@@ -417,6 +418,34 @@ export function BrowserListPage() {
}
}
const handleStartDirect = async (profileId: string) => {
updatePendingIds(setStartingIds, profileId, true)
try {
const startedProfile = await startBrowserInstanceDirect(profileId)
mergeProfileState(startedProfile)
setProxyErrorModal(false)
setPendingStartId(null)
if (startedProfile?.running && !startedProfile.debugReady && startedProfile.runtimeWarning) {
toast.warning(startedProfile.runtimeWarning)
} else {
toast.success(`实例已直连启动${startedProfile?.profileName ? `${startedProfile.profileName}` : ''}`)
}
await loadProfiles({ silent: true, syncRuntimeState: true })
} catch (error: any) {
setProxyErrorModal(false)
setPendingStartId(null)
const feedback = resolveActionFeedback(error, '实例直连启动失败')
if (feedback.tone === 'warning') {
toast.warning(feedback.message)
} else {
toast.error(feedback.message)
}
await loadProfiles({ silent: true, syncRuntimeState: true })
} finally {
updatePendingIds(setStartingIds, profileId, false)
}
}
const handleStop = async (profileId: string) => {
updatePendingIds(setStoppingIds, profileId, true)
try {
@@ -792,6 +821,12 @@ export function BrowserListPage() {
setProxyErrorModal(false)
setPendingStartId(null)
}}
onStartDirect={() => {
if (pendingStartId) {
void handleStartDirect(pendingStartId)
}
}}
startingDirect={pendingStartId ? startingIds.has(pendingStartId) : false}
kwModal={kwModal}
onCloseKeywords={closeKwModal}
onKeywordsSaved={(keywords) => {
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { FolderOpen, Settings, Edit2 } from 'lucide-react'
import { Badge, Button, Card, ConfirmModal, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
@@ -517,7 +517,7 @@ export function CoreManagementPage() {
value={settingsForm.defaultStartUrls}
onChange={e => setSettingsForm(prev => ({ ...prev, defaultStartUrls: e.target.value }))}
rows={4}
placeholder="https://ippure.com/"
placeholder="启动 URL"
/>
</FormItem>
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开默认启动页或空白页">
@@ -1,13 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ConfirmModal, toast } from '../../../shared/components'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, ConfirmModal, FormItem, Input, Modal, Textarea, toast } from '../../../shared/components'
import type { SortOrder } from '../../../shared/components/Table'
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
import { fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL } from '../api'
import type { BrowserProxy, ProxyCheckSettings, ProxyIPHealthResult } from '../types'
import { createDefaultProxyCheckSettings, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL, fetchProxyCheckSettings, saveProxyCheckSettings } from '../api'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
import {
BUILTIN_PROXY_IDS,
CHAIN_QUICK_IMPORT_TEMPLATE,
DIRECT_QUICK_IMPORT_TEMPLATE,
INITIAL_CHAIN_IMPORT_FORM,
INITIAL_DIRECT_IMPORT_FORM,
buildChainImportCandidate,
buildDirectImportCandidate,
buildDirectImportCandidatesFromText,
buildImportCandidatesFromClash,
buildImportPreview,
buildRefreshedSourceProxies,
@@ -16,10 +21,14 @@ import {
ensureBuiltinProxies,
normalizeRefreshIntervalM,
parseClashImportText,
parseChainImportJSON,
parseDirectImportText,
parseTimestampMs,
nextProxyID,
resolveImportSourceID,
toChainImportForm,
toDisplayList,
type ChainImportForm,
type DirectImportForm,
type ProxyDisplayInfo,
type ProxyImportMode,
@@ -48,6 +57,12 @@ import { ProxyPoolHeader } from './proxyPool/ProxyPoolHeader'
import { ProxyPoolTableCard } from './proxyPool/ProxyPoolTableCard'
export function ProxyPoolPage() {
const createInitialChainImportForm = (): ChainImportForm => ({
...INITIAL_CHAIN_IMPORT_FORM,
first: { ...INITIAL_CHAIN_IMPORT_FORM.first },
second: { ...INITIAL_CHAIN_IMPORT_FORM.second },
})
const [proxies, setProxies] = useState<BrowserProxy[]>([])
const [displayList, setDisplayList] = useState<ProxyDisplayInfo[]>([])
const [loading, setLoading] = useState(true)
@@ -64,6 +79,10 @@ export function ProxyPoolPage() {
const [ipHealthMap, setIPHealthMap] = useState<Record<string, ProxyIPHealthResult>>({})
const [checkingIPHealthIds, setCheckingIPHealthIds] = useState<Set<string>>(new Set())
const [checkingAllIPHealth, setCheckingAllIPHealth] = useState(false)
const [checkSettingsOpen, setCheckSettingsOpen] = useState(false)
const [checkSettings, setCheckSettings] = useState<ProxyCheckSettings>(() => createDefaultProxyCheckSettings())
const [checkTargetsText, setCheckTargetsText] = useState('')
const [savingCheckSettings, setSavingCheckSettings] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false)
@@ -76,6 +95,9 @@ export function ProxyPoolPage() {
const [importDnsServers, setImportDnsServers] = useState('')
const [importNamePrefix, setImportNamePrefix] = useState('')
const [importGroupName, setImportGroupName] = useState('')
const [chainImportText, setChainImportText] = useState('')
const [directImportText, setDirectImportText] = useState('')
const [chainImportForm, setChainImportForm] = useState<ChainImportForm>(() => createInitialChainImportForm())
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewList, setPreviewList] = useState<ProxyDisplayInfo[]>([])
@@ -89,6 +111,8 @@ export function ProxyPoolPage() {
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingProxy, setEditingProxy] = useState<BrowserProxy | null>(null)
const [chainEditMode, setChainEditMode] = useState(false)
const [chainEditForm, setChainEditForm] = useState<ChainImportForm>(() => createInitialChainImportForm())
const [editForm, setEditForm] = useState<ProxyEditFormValue>({
proxyName: '',
proxyConfig: '',
@@ -204,6 +228,27 @@ export function ProxyPoolPage() {
}
}
const openCheckSettings = async () => {
const settings = await fetchProxyCheckSettings()
setCheckSettings(settings)
setCheckTargetsText(JSON.stringify(settings.targets || [], null, 2))
setCheckSettingsOpen(true)
}
const saveCheckSettings = async () => {
setSavingCheckSettings(true)
try {
const targets = JSON.parse(checkTargetsText || '[]')
await saveProxyCheckSettings({ ...checkSettings, targets })
toast.success('检测设置已保存')
setCheckSettingsOpen(false)
} catch (error: any) {
toast.error(error?.message || '检测设置保存失败')
} finally {
setSavingCheckSettings(false)
}
}
// 直接保存完整列表,内置代理保护由后端负责
const saveProxies = useCallback(async (list: BrowserProxy[]) => {
await saveBrowserProxies(list)
@@ -347,10 +392,11 @@ export function ProxyPoolPage() {
const getLatencySortTuple = (proxyId: string): [number, number] => {
const v = latencyMap[proxyId]
if (v === undefined) return [4, Number.MAX_SAFE_INTEGER]
if (v === undefined) return [5, Number.MAX_SAFE_INTEGER]
if (v === -1) return [1, Number.MAX_SAFE_INTEGER] // 测试中
if (v === -2) return [2, Number.MAX_SAFE_INTEGER] // 超时
if (v === -3) return [3, Number.MAX_SAFE_INTEGER] // 不支持
if (v === -4) return [4, Number.MAX_SAFE_INTEGER] // 失败
return [0, v] // 正常延迟
}
@@ -560,23 +606,73 @@ export function ProxyPoolPage() {
setRemovedPreviewProxyNames(prev => [...prev, target.proxyName])
}
const updateChainImportHop = (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => {
setChainImportForm(prev => ({
...prev,
[hop]: {
...prev[hop],
[field]: value,
},
}))
}
const updateChainEditHop = (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => {
setChainEditForm(prev => ({
...prev,
[hop]: {
...prev[hop],
[field]: value,
},
}))
}
const handleEdit = (record: ProxyDisplayInfo) => {
const proxy = proxies.find(p => p.proxyId === record.proxyId)
if (proxy) {
setEditingProxy(proxy)
setEditForm({ proxyName: proxy.proxyName, proxyConfig: proxy.proxyConfig, dnsServers: proxy.dnsServers || '', groupName: proxy.groupName || '' })
const nextChainForm = toChainImportForm(proxy.proxyName, proxy.proxyConfig)
if (nextChainForm) {
setChainEditMode(true)
setChainEditForm(nextChainForm)
} else {
setChainEditMode(false)
setChainEditForm(createInitialChainImportForm())
}
setEditModalOpen(true)
}
}
const handleSaveProxy = async () => {
if (!editForm.proxyName.trim()) { toast.error('请输入代理名称'); return }
if (!editingProxy) return
let nextProxyName = editForm.proxyName.trim()
let nextProxyConfig = editForm.proxyConfig
if (chainEditMode) {
try {
const candidate = buildChainImportCandidate(chainEditForm)
nextProxyName = candidate.proxyName
nextProxyConfig = candidate.proxyConfig
} catch (error: any) {
toast.error(error?.message || '链式代理配置无效')
return
}
} else if (!nextProxyName) {
toast.error('请输入代理名称')
return
}
setSaving(true)
try {
const newProxies = proxies.map(p =>
p.proxyId === editingProxy.proxyId
? { ...p, proxyName: editForm.proxyName, proxyConfig: editForm.proxyConfig, dnsServers: editForm.dnsServers, groupName: editForm.groupName }
? {
...p,
proxyName: nextProxyName,
proxyConfig: nextProxyConfig,
dnsServers: editForm.dnsServers.trim() || undefined,
groupName: editForm.groupName.trim() || undefined,
}
: p
)
await saveProxies(newProxies)
@@ -616,6 +712,63 @@ export function ProxyPoolPage() {
}
}
const handleFillChainTemplate = () => {
setChainImportText(CHAIN_QUICK_IMPORT_TEMPLATE)
}
const handleFillDirectTemplate = () => {
setDirectImportText(DIRECT_QUICK_IMPORT_TEMPLATE)
}
const handleCopyChainTemplate = async () => {
try {
if (!navigator?.clipboard?.writeText) {
throw new Error('当前环境不支持剪贴板')
}
await navigator.clipboard.writeText(CHAIN_QUICK_IMPORT_TEMPLATE)
toast.success('JSON 模板已复制')
} catch (error: any) {
toast.error(error?.message || '复制模板失败')
}
}
const handleCopyDirectTemplate = async () => {
try {
if (!navigator?.clipboard?.writeText) {
throw new Error('当前环境不支持剪贴板')
}
await navigator.clipboard.writeText(DIRECT_QUICK_IMPORT_TEMPLATE)
toast.success('JSON 模板已复制')
} catch (error: any) {
toast.error(error?.message || '复制模板失败')
}
}
const handleApplyChainJSON = () => {
try {
const { form, groupName } = parseChainImportJSON(chainImportText)
setChainImportForm(form)
setImportGroupName(groupName)
toast.success('JSON 已应用')
} catch (error: any) {
toast.error(error?.message || 'JSON 应用失败')
}
}
const handleApplyDirectText = () => {
try {
const { form, groupName } = parseDirectImportText(directImportText)
setDirectImportForm(form)
if (groupName) {
setImportGroupName(groupName)
}
setDirectImportText('')
toast.success('文本已应用')
} catch (error: any) {
toast.error(error?.message || '文本应用失败')
}
}
const handleImportUrlChange = (nextValue: string) => {
setImportUrl(nextValue)
if (importResolvedUrl.trim() && nextValue.trim() !== importResolvedUrl.trim()) {
@@ -660,14 +813,28 @@ export function ProxyPoolPage() {
const handleParseImport = () => {
try {
const prefix = importNamePrefix.trim()
const candidates = importMode === 'clash'
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
: [buildDirectImportCandidate(directImportForm)]
let candidates
let previewGroupName = importGroupName.trim()
if (importMode === 'clash') {
candidates = buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
} else if (importMode === 'direct') {
if (directImportText.trim()) {
const parsed = buildDirectImportCandidatesFromText(directImportText)
candidates = parsed.candidates
if (!previewGroupName) {
previewGroupName = parsed.defaultGroupName
}
} else {
candidates = [buildDirectImportCandidate(directImportForm)]
}
} else {
candidates = [buildChainImportCandidate(chainImportForm)]
}
if (!candidates.length) {
toast.error('未解析到可导入代理')
return
}
const preview = buildImportPreview(candidates, importGroupName.trim())
const preview = buildImportPreview(candidates, previewGroupName)
setRemovedPreviewProxyNames([])
setPreviewList(preview)
setImportModalOpen(false)
@@ -701,7 +868,7 @@ export function ProxyPoolPage() {
proxyName: p.proxyName,
proxyConfig: p.proxyConfig,
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
groupName: importGroupName.trim() || undefined,
groupName: p.groupName.trim() || undefined,
sourceId: sourceID || undefined,
sourceUrl: sourceURL || undefined,
sourceNamePrefix: sourceNamePrefix || undefined,
@@ -723,6 +890,9 @@ export function ProxyPoolPage() {
setImportDnsServers('')
setImportNamePrefix('')
setImportGroupName('')
setChainImportText('')
setDirectImportText('')
setChainImportForm(createInitialChainImportForm())
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
setPreviewList([])
setRemovedPreviewProxyNames([])
@@ -737,7 +907,12 @@ export function ProxyPoolPage() {
const selectedCount = selectedIds.size
const canParseImport = importMode === 'clash'
? !!importText.trim()
: !!directImportForm.server.trim() && !!directImportForm.port.trim()
: importMode === 'direct'
? !!directImportText.trim() || (!!directImportForm.server.trim() && !!directImportForm.port.trim())
: !!chainImportForm.first.server.trim()
&& !!chainImportForm.first.port.trim()
&& !!chainImportForm.second.server.trim()
&& !!chainImportForm.second.port.trim()
return (
<div className="space-y-5 animate-fade-in">
@@ -745,6 +920,7 @@ export function ProxyPoolPage() {
checkingAllIPHealth={checkingAllIPHealth}
hasURLImportSources={hasURLImportSources}
onCheckAllIPHealth={handleCheckAllIPHealth}
onOpenSettings={() => void openCheckSettings()}
onOpenImport={() => setImportModalOpen(true)}
onRefreshAllSources={() => void handleRefreshAllSources(false)}
onTestAll={() => void handleTestAll()}
@@ -809,6 +985,9 @@ export function ProxyPoolPage() {
importDnsServers={importDnsServers}
importNamePrefix={importNamePrefix}
importGroupName={importGroupName}
chainImportText={chainImportText}
directImportText={directImportText}
chainImportForm={chainImportForm}
directImportForm={directImportForm}
fetchingImportUrl={fetchingImportUrl}
canParseImport={canParseImport}
@@ -821,6 +1000,16 @@ export function ProxyPoolPage() {
onImportDnsServersChange={setImportDnsServers}
onImportNamePrefixChange={setImportNamePrefix}
onImportGroupNameChange={setImportGroupName}
onChainImportTextChange={setChainImportText}
onDirectImportTextChange={setDirectImportText}
onApplyChainJSON={handleApplyChainJSON}
onApplyDirectText={handleApplyDirectText}
onChainImportFormChange={(patch) => setChainImportForm((prev) => ({ ...prev, ...patch }))}
onChainImportHopChange={updateChainImportHop}
onFillChainTemplate={handleFillChainTemplate}
onCopyChainTemplate={() => void handleCopyChainTemplate()}
onFillDirectTemplate={handleFillDirectTemplate}
onCopyDirectTemplate={() => void handleCopyDirectTemplate()}
onDirectImportFormChange={(patch) => setDirectImportForm((prev) => ({ ...prev, ...patch }))}
/>
@@ -845,9 +1034,13 @@ export function ProxyPoolPage() {
saving={saving}
groups={groups}
editForm={editForm}
chainEditMode={chainEditMode}
chainEditForm={chainEditForm}
onClose={() => setEditModalOpen(false)}
onSave={handleSaveProxy}
onChange={(patch) => setEditForm((prev) => ({ ...prev, ...patch }))}
onChainEditFormChange={(patch) => setChainEditForm((prev) => ({ ...prev, ...patch }))}
onChainEditHopChange={updateChainEditHop}
/>
<ProxyPoolIPHealthDetailModal
@@ -856,6 +1049,48 @@ export function ProxyPoolPage() {
onClose={() => setIPHealthDetailOpen(false)}
/>
<Modal
open={checkSettingsOpen}
onClose={() => setCheckSettingsOpen(false)}
title="检测设置"
width="760px"
footer={(
<>
<Button variant="secondary" onClick={() => setCheckSettingsOpen(false)}></Button>
<Button onClick={saveCheckSettings} loading={savingCheckSettings}></Button>
</>
)}
>
<div className="space-y-4">
<FormItem label="桥接启动等待" hint="毫秒" >
<Input
type="number"
value={checkSettings.bridgeStartTimeoutMs}
onChange={(e) => setCheckSettings(prev => ({ ...prev, bridgeStartTimeoutMs: Number(e.target.value) || 15000 }))}
/>
</FormItem>
<FormItem label="测速目标 ID">
<Input
value={checkSettings.speedTargetId}
onChange={(e) => setCheckSettings(prev => ({ ...prev, speedTargetId: e.target.value }))}
/>
</FormItem>
<FormItem label="IP 健康目标 ID">
<Input
value={checkSettings.ipHealthTargetId}
onChange={(e) => setCheckSettings(prev => ({ ...prev, ipHealthTargetId: e.target.value }))}
/>
</FormItem>
<FormItem label="检测目标列表(JSON,每项一个)" hint="可直接编辑 URL、超时、期望状态码">
<Textarea
value={checkTargetsText}
onChange={(e) => setCheckTargetsText(e.target.value)}
rows={14}
/>
</FormItem>
</div>
</Modal>
<ConfirmModal open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)} onConfirm={handleDeleteConfirm}
title="确认删除" content="确定要删除这个代理吗?此操作不可恢复。" confirmText="删除" danger />
@@ -9,6 +9,8 @@ interface BrowserListDialogsProps {
pendingStartId: string | null
proxyErrorMsg: string
onCloseProxyError: () => void
onStartDirect: () => void
startingDirect: boolean
kwModal: { open: boolean; profile: BrowserProfile | null }
onCloseKeywords: () => void
onKeywordsSaved: (keywords: string[]) => void
@@ -36,6 +38,8 @@ export function BrowserListDialogs({
pendingStartId,
proxyErrorMsg,
onCloseProxyError,
onStartDirect,
startingDirect,
kwModal,
onCloseKeywords,
onKeywordsSaved,
@@ -66,10 +70,15 @@ export function BrowserListDialogs({
width="420px"
footer={
<>
<Button variant="secondary" onClick={onCloseProxyError}></Button>
<Button variant="secondary" onClick={onCloseProxyError} disabled={startingDirect}></Button>
{pendingStartId && (
<Button variant="secondary" onClick={onStartDirect} loading={startingDirect}>
</Button>
)}
{pendingStartId && (
<Link to={`/browser/edit/${pendingStartId}`}>
<Button onClick={onCloseProxyError}></Button>
<Button onClick={onCloseProxyError} disabled={startingDirect}></Button>
</Link>
)}
</>
@@ -20,7 +20,7 @@ export function LaunchDocsFlowPage({ baseUrl }: LaunchDocsFlowPageProps) {
summary: '先准备浏览器内核,并在应用里确认已识别。',
path: '指纹浏览器 -> 内核管理 -> 下载内核 -> 设为默认',
example: `chrome/
chrome142/
chrome-<version>/
chrome.exe`,
},
{
@@ -7,6 +7,7 @@ import {
DOC_SKILL_USAGE,
DOC_TUTORIAL,
} from './contentIntro'
import { DOC_CHANGELOG } from './contentChangelog'
import {
DOC_API_PROFILES_LAUNCH,
DOC_API_RUNTIME,
@@ -60,6 +61,18 @@ export const DOC_GROUPS: LaunchDocGroup[] = [
},
],
},
{
id: 'changelog',
label: '更新日志',
items: [
{
id: 'changelog-versions',
label: '版本更新',
summary: '按大版本查看主要变化。',
content: DOC_CHANGELOG,
},
],
},
{
id: 'core',
label: '内核介绍',
@@ -0,0 +1,27 @@
export const DOC_CHANGELOG = `# 更新日志
## 1.2.0 - 2026-05-05
- 使
- Launch API code / selector runtime sessionruntime statusruntime stop CDP
- selector / params
- OpenClaw ant-chrome-openclaw skillHTTP CDP
- IP
- 便
## 1.1.0 - 2026-03-19
- Linux Linux 退
- macOS unsigned macOS app / zip Application Support
- SOCKS SOCKS
-
## 1.0.0
-
- Clash
- Chrome 使
- Code
-
- 使
`
@@ -1,4 +1,4 @@
export const DOC_TUTORIAL = `# 使用教程
export const DOC_TUTORIAL = `# 使用教程
## 使
@@ -178,7 +178,7 @@ export const DOC_CORE_INTRO = `# 内核介绍
\`\`\`text
chrome/
chrome142/
chrome-<version>/
chrome.exe
...
\`\`\`
@@ -190,7 +190,7 @@ chrome/
-> ->
B
ZIP -> chrome/ ->
ZIP -> chrome/<version>/ ->
\`\`\`
##
@@ -219,9 +219,9 @@ export const DOC_PROXY_INTRO = `# 代理介绍
##
\`\`\`text
http://127.0.0.1:7890
socks5://127.0.0.1:1080
http://user:pass@127.0.0.1:7890
<proxy-url>
<proxy-url>
<proxy-url>
\`\`\`
## Clash YAML
@@ -327,6 +327,8 @@ curl -H "X-Ant-Api-Key: <your-api-key>" http://127.0.0.1:19876/api/health
}
\`\`\`
\`code / profileId / profileName / keyword / tags / groupId / matchMode\` 放在请求体顶层;新接入建议统一放进 \`selector\`
##
| | |
@@ -440,10 +440,17 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
label: '按 selector 启动',
method: 'POST',
path: '/api/launch',
purpose: '按 selector 和启动参数启动实例。',
description: '更灵活的启动入口,支持 selector、launchArgs、startUrls 和 skipDefaultStartUrls 等临时参数。',
purpose: '按 selector 或兼容顶层字段启动实例。',
description: '更灵活的启动入口,支持 selector、兼容顶层选择字段、launchArgs、startUrls 和 skipDefaultStartUrls 等临时参数。',
fields: [
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
{ name: 'code', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.code。' },
{ name: 'profileId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileId。' },
{ name: 'profileName', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileName。' },
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.keyword / selector.keywords。' },
{ name: 'tag / tags', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.tag / selector.tags。' },
{ name: 'groupId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.groupId。' },
{ name: 'matchMode', type: 'unique | first | all', required: false, location: 'Body', description: '兼容写法:等价于 selector.matchMode。' },
{ name: 'launchArgs', type: 'string[]', required: false, location: 'Body', description: '本次启动的临时附加参数。' },
{ name: 'startUrls', type: 'string[]', required: false, location: 'Body', description: '本次启动后额外打开的网址。' },
{ name: 'skipDefaultStartUrls', type: 'boolean', required: false, location: 'Body', description: '是否跳过实例默认启动 URL。' },
@@ -479,6 +486,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
{ code: '409', description: 'selector 命中多个实例。' },
],
notes: [
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
'matchMode=all 只在这个接口可用。',
],
},
@@ -526,7 +534,14 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
purpose: '准备一个可 attach 的运行时会话。',
description: '按 selector 命中实例,必要时自动启动,并在给定超时时间内等待 debugReady=true。',
fields: [
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
{ name: 'code', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.code。' },
{ name: 'profileId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileId。' },
{ name: 'profileName', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileName。' },
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.keyword / selector.keywords。' },
{ name: 'tag / tags', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.tag / selector.tags。' },
{ name: 'groupId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.groupId。' },
{ name: 'matchMode', type: 'unique | first', required: false, location: 'Body', description: '兼容写法:等价于 selector.matchMode。' },
{ name: 'timeoutMs', type: 'integer', required: false, location: 'Body', description: '等待 debugReady 的超时时间。' },
{ name: 'startUrls', type: 'string[]', required: false, location: 'Body', description: '本次启动时额外打开的网址。' },
{ name: 'skipDefaultStartUrls', type: 'boolean', required: false, location: 'Body', description: '是否跳过实例默认启动 URL。' },
@@ -567,6 +582,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
{ code: '404', description: '目标实例不存在。' },
],
notes: [
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
'200 表示 ready,可直接接管。',
'202 表示未 ready,需要重试。',
],
@@ -580,7 +596,11 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
purpose: '按 selector 查询实例当前运行态。',
description: '不启动新实例,不等待 ready,只看当前 selector 命中的实例状态。',
fields: [
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
{ name: 'code / profileId / profileName', type: 'string', required: false, location: 'Body', description: '兼容顶层选择字段。' },
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
{ name: 'tag / tags / groupId', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
{ name: 'matchMode', type: 'unique | first', required: false, location: 'Body', description: '运行态控制不支持 all。' },
],
requestExample: {
language: 'bash',
@@ -610,6 +630,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
],
notes: [
'不会启动实例。',
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
],
},
{
@@ -621,7 +642,11 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
purpose: '按 selector 停止实例。',
description: '和 runtime/status 一样使用 selector,但动作改为停止实例,适合编排侧做统一回收。',
fields: [
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
{ name: 'code / profileId / profileName', type: 'string', required: false, location: 'Body', description: '兼容顶层选择字段。' },
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
{ name: 'tag / tags / groupId', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
{ name: 'matchMode', type: 'unique | first', required: false, location: 'Body', description: '运行态控制不支持 all。' },
],
requestExample: {
language: 'bash',
@@ -650,6 +675,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
{ code: '404', description: '目标实例不存在。' },
],
notes: [
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
'不支持 matchMode=all。',
],
},
@@ -670,7 +696,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
responseExample: {
language: 'json',
code: () => `{
"Browser": "Chrome/142.0.0.0",
"Browser": "Chrome/<version>",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0",
"webSocketDebuggerUrl": "ws://127.0.0.1:19876/devtools/browser/active"
@@ -848,6 +874,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
{ name: 'params', type: 'object', required: false, location: 'Body', description: '覆盖脚本默认 params。' },
{ name: 'useScriptSelector', type: 'boolean', required: false, location: 'Body', description: '显式指定是否沿用脚本默认 selector。' },
{ name: 'useScriptParams', type: 'boolean', required: false, location: 'Body', description: '显式指定是否沿用脚本默认 params。' },
{ name: 'timeoutMs', type: 'integer', required: false, location: 'Body', description: '本次脚本执行超时时间。' },
],
requestExample: {
language: 'bash',
@@ -5,6 +5,7 @@ interface ProxyPoolHeaderProps {
hasURLImportSources: boolean
onCheckAllIPHealth: () => void
onOpenImport: () => void
onOpenSettings: () => void
onRefreshAllSources: () => void
onTestAll: () => void
refreshingAllSources: boolean
@@ -17,6 +18,7 @@ export function ProxyPoolHeader({
hasURLImportSources,
onCheckAllIPHealth,
onOpenImport,
onOpenSettings,
onRefreshAllSources,
onTestAll,
refreshingAllSources,
@@ -30,6 +32,13 @@ export function ProxyPoolHeader({
<p className="text-sm text-[var(--color-text-muted)] mt-1"> Clash HTTPHTTPSSOCKS5</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={onOpenSettings}
>
</Button>
<Button
size="sm"
variant="secondary"
@@ -1,9 +1,12 @@
import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../../shared/components'
import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../../shared/components'
import type { TableColumn } from '../../../../shared/components/Table'
import type { ProxyIPHealthResult } from '../../types'
import {
CHAIN_QUICK_IMPORT_TEMPLATE,
DIRECT_QUICK_IMPORT_TEMPLATE,
DIRECT_PROXY_PROTOCOL_OPTIONS,
type ChainImportForm,
type DirectImportForm,
type ProxyDisplayInfo,
type ProxyImportMode,
@@ -26,6 +29,9 @@ interface ProxyPoolImportModalProps {
importDnsServers: string
importNamePrefix: string
importGroupName: string
chainImportText: string
directImportText: string
chainImportForm: ChainImportForm
directImportForm: DirectImportForm
fetchingImportUrl: boolean
canParseImport: boolean
@@ -38,6 +44,16 @@ interface ProxyPoolImportModalProps {
onImportDnsServersChange: (nextValue: string) => void
onImportNamePrefixChange: (nextValue: string) => void
onImportGroupNameChange: (nextValue: string) => void
onChainImportTextChange: (nextValue: string) => void
onDirectImportTextChange: (nextValue: string) => void
onApplyChainJSON: () => void
onApplyDirectText: () => void
onChainImportFormChange: (patch: Partial<ChainImportForm>) => void
onChainImportHopChange: (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => void
onFillChainTemplate: () => void
onCopyChainTemplate: () => void
onFillDirectTemplate: () => void
onCopyDirectTemplate: () => void
onDirectImportFormChange: (patch: Partial<DirectImportForm>) => void
}
@@ -51,6 +67,9 @@ export function ProxyPoolImportModal({
importDnsServers,
importNamePrefix,
importGroupName,
chainImportText,
directImportText,
chainImportForm,
directImportForm,
fetchingImportUrl,
canParseImport,
@@ -63,6 +82,16 @@ export function ProxyPoolImportModal({
onImportDnsServersChange,
onImportNamePrefixChange,
onImportGroupNameChange,
onChainImportTextChange,
onDirectImportTextChange,
onApplyChainJSON,
onApplyDirectText,
onChainImportFormChange,
onChainImportHopChange,
onFillChainTemplate,
onCopyChainTemplate,
onFillDirectTemplate,
onCopyDirectTemplate,
onDirectImportFormChange,
}: ProxyPoolImportModalProps) {
return (
@@ -83,7 +112,7 @@ export function ProxyPoolImportModal({
}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<Button
variant={importMode === 'clash' ? undefined : 'secondary'}
onClick={() => onImportModeChange('clash')}
@@ -94,13 +123,21 @@ export function ProxyPoolImportModal({
variant={importMode === 'direct' ? undefined : 'secondary'}
onClick={() => onImportModeChange('direct')}
>
HTTP / SOCKS5
HTTP / SOCKS5
</Button>
<Button
variant={importMode === 'chain' ? undefined : 'secondary'}
onClick={() => onImportModeChange('chain')}
>
</Button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'}
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,使用 JSON 导入,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
<>
@@ -109,7 +146,7 @@ export function ProxyPoolImportModal({
<Input
value={importUrl}
onChange={(event) => onImportUrlChange(event.target.value)}
placeholder="https://example.com/clash/subscription"
placeholder="订阅 URL"
className="flex-1"
/>
<Button
@@ -139,54 +176,210 @@ export function ProxyPoolImportModal({
</>
)}
{importMode === 'direct' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={(event) =>
onDirectImportFormChange({ protocol: event.target.value as DirectImportForm['protocol'] })
}
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={(event) =>
onDirectImportFormChange({ protocol: event.target.value as DirectImportForm['protocol'] })
}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={(event) => onDirectImportFormChange({ proxyName: event.target.value })}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={(event) => onDirectImportFormChange({ server: event.target.value })}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={(event) => onDirectImportFormChange({ port: event.target.value })}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={(event) => onDirectImportFormChange({ username: event.target.value })}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={(event) => onDirectImportFormChange({ password: event.target.value })}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
<FormItem label="文本辅助(可选)" hint="支持单个 JSON、JSON 数组,或多行 http:// / https:// / socks5://,每行一个">
<Textarea
value={directImportText}
onChange={(event) => onDirectImportTextChange(event.target.value)}
rows={8}
placeholder={DIRECT_QUICK_IMPORT_TEMPLATE}
/>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={onFillDirectTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={onCopyDirectTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={onApplyDirectText} disabled={!directImportText.trim()}>
</Button>
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
</p>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={(event) => onDirectImportFormChange({ proxyName: event.target.value })}
placeholder="例如:香港节点"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={(event) => onDirectImportFormChange({ server: event.target.value })}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={(event) => onDirectImportFormChange({ port: event.target.value })}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={(event) => onDirectImportFormChange({ username: event.target.value })}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={(event) => onDirectImportFormChange({ password: event.target.value })}
placeholder="留空则不使用密码"
</div>
)}
{importMode === 'chain' && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理名称(可选)">
<Input
value={chainImportForm.proxyName}
onChange={(event) => onChainImportFormChange({ proxyName: event.target.value })}
placeholder="例如:双层英国链路"
/>
</FormItem>
<FormItem label="本地监听端口(可选)">
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.localPort}
onChange={(event) => onChainImportFormChange({ localPort: event.target.value })}
placeholder="留空自动分配"
/>
</FormItem>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.first.protocol}
onChange={(event) => onChainImportHopChange('first', 'protocol', event.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.first.server}
onChange={(event) => onChainImportHopChange('first', 'server', event.target.value)}
placeholder="代理地址"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.first.port}
onChange={(event) => onChainImportHopChange('first', 'port', event.target.value)}
placeholder="端口"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainImportForm.first.username}
onChange={(event) => onChainImportHopChange('first', 'username', event.target.value)}
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainImportForm.first.password}
onChange={(event) => onChainImportHopChange('first', 'password', event.target.value)}
/>
</FormItem>
</div>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.second.protocol}
onChange={(event) => onChainImportHopChange('second', 'protocol', event.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.second.server}
onChange={(event) => onChainImportHopChange('second', 'server', event.target.value)}
placeholder="代理地址"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.second.port}
onChange={(event) => onChainImportHopChange('second', 'port', event.target.value)}
placeholder="端口"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainImportForm.second.username}
onChange={(event) => onChainImportHopChange('second', 'username', event.target.value)}
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainImportForm.second.password}
onChange={(event) => onChainImportHopChange('second', 'password', event.target.value)}
/>
</FormItem>
</div>
</div>
<FormItem label="JSON 辅助(可选)">
<Textarea
value={chainImportText}
onChange={(event) => onChainImportTextChange(event.target.value)}
rows={10}
placeholder={CHAIN_QUICK_IMPORT_TEMPLATE}
/>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={onFillChainTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={onCopyChainTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={onApplyChainJSON} disabled={!chainImportText.trim()}>
JSON
</Button>
</div>
</FormItem>
</div>
)}
@@ -194,7 +387,7 @@ export function ProxyPoolImportModal({
<Input
value={importGroupName}
onChange={(event) => onImportGroupNameChange(event.target.value)}
placeholder="例如:香港、美国、机场A"
placeholder="分组名称"
list="proxy-groups-datalist"
/>
{groups.length > 0 && (
@@ -317,9 +510,13 @@ interface ProxyPoolEditModalProps {
saving: boolean
groups: string[]
editForm: ProxyEditFormValue
chainEditMode: boolean
chainEditForm: ChainImportForm
onClose: () => void
onSave: () => void
onChange: (patch: Partial<ProxyEditFormValue>) => void
onChainEditFormChange: (patch: Partial<ChainImportForm>) => void
onChainEditHopChange: (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => void
}
export function ProxyPoolEditModal({
@@ -327,9 +524,13 @@ export function ProxyPoolEditModal({
saving,
groups,
editForm,
chainEditMode,
chainEditForm,
onClose,
onSave,
onChange,
onChainEditFormChange,
onChainEditHopChange,
}: ProxyPoolEditModalProps) {
return (
<Modal
@@ -351,16 +552,22 @@ export function ProxyPoolEditModal({
<div className="space-y-4">
<FormItem label="代理名称" required>
<Input
value={editForm.proxyName}
onChange={(event) => onChange({ proxyName: event.target.value })}
placeholder="例如:香港节点"
value={chainEditMode ? chainEditForm.proxyName : editForm.proxyName}
onChange={(event) => {
if (chainEditMode) {
onChainEditFormChange({ proxyName: event.target.value })
return
}
onChange({ proxyName: event.target.value })
}}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="分组名称(可选)">
<Input
value={editForm.groupName}
onChange={(event) => onChange({ groupName: event.target.value })}
placeholder="例如:香港、美国"
placeholder="分组名称"
list="edit-proxy-groups-datalist"
/>
<datalist id="edit-proxy-groups-datalist">
@@ -369,14 +576,115 @@ export function ProxyPoolEditModal({
))}
</datalist>
</FormItem>
<FormItem label="代理配置">
<Textarea
value={editForm.proxyConfig}
onChange={(event) => onChange({ proxyConfig: event.target.value })}
rows={10}
placeholder="支持 Clash YAML、http://、https://、socks5:// 代理配置"
/>
</FormItem>
{chainEditMode ? (
<div className="space-y-4">
<FormItem label="本地监听端口(可选)">
<Input
type="number"
min={1}
max={65535}
value={chainEditForm.localPort}
onChange={(event) => onChainEditFormChange({ localPort: event.target.value })}
placeholder="留空自动分配"
/>
</FormItem>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainEditForm.first.protocol}
onChange={(event) => onChainEditHopChange('first', 'protocol', event.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainEditForm.first.server}
onChange={(event) => onChainEditHopChange('first', 'server', event.target.value)}
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainEditForm.first.port}
onChange={(event) => onChainEditHopChange('first', 'port', event.target.value)}
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainEditForm.first.username}
onChange={(event) => onChainEditHopChange('first', 'username', event.target.value)}
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainEditForm.first.password}
onChange={(event) => onChainEditHopChange('first', 'password', event.target.value)}
/>
</FormItem>
</div>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainEditForm.second.protocol}
onChange={(event) => onChainEditHopChange('second', 'protocol', event.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainEditForm.second.server}
onChange={(event) => onChainEditHopChange('second', 'server', event.target.value)}
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainEditForm.second.port}
onChange={(event) => onChainEditHopChange('second', 'port', event.target.value)}
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainEditForm.second.username}
onChange={(event) => onChainEditHopChange('second', 'username', event.target.value)}
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainEditForm.second.password}
onChange={(event) => onChainEditHopChange('second', 'password', event.target.value)}
/>
</FormItem>
</div>
</div>
</div>
) : (
<FormItem label="代理配置">
<Textarea
value={editForm.proxyConfig}
onChange={(event) => onChange({ proxyConfig: event.target.value })}
rows={10}
placeholder="支持 Clash YAML、http://、https://、socks5://、chain+socks5://"
/>
</FormItem>
)}
<FormItem label="DNS 服务器(可选)">
<Textarea
value={editForm.dnsServers}
@@ -94,6 +94,7 @@ export function ProxyPoolTableCard({
if (value === -1) return <span className="text-[var(--color-text-muted)] text-xs animate-pulse">...</span>
if (value === -2) return <span className="text-red-500 text-xs"></span>
if (value === -3) return <span className="text-gray-400 text-xs"></span>
if (value === -4) return <span className="text-red-500 text-xs"></span>
const color = value < 200 ? 'text-green-500' : value < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium ${color}`}>{value} ms</span>
}
@@ -184,7 +185,14 @@ export function ProxyPoolTableCard({
},
{
key: 'ipHealth',
title: 'IP健康',
title: (
<div className="leading-tight">
<div>IP健康</div>
<div className="mt-0.5 text-[10px] font-normal text-[var(--color-text-muted)]">
</div>
</div>
),
width: '280px',
render: (_, record) => renderIPHealth(record),
},
@@ -1,12 +1,11 @@
import yaml from 'js-yaml'
import yaml from 'js-yaml'
import type { BrowserProxy } from '../../types'
export const BUILTIN_PROXY_IDS = new Set(['__direct__', '__local__'])
export const BUILTIN_PROXY_IDS = new Set(['__direct__'])
const BUILTIN_PROXIES: BrowserProxy[] = [
{ proxyId: '__direct__', proxyName: '直连(不走代理)', proxyConfig: 'direct://' },
{ proxyId: '__local__', proxyName: '本地代理', proxyConfig: 'http://127.0.0.1:7890' },
]
export interface ClashProxy {
@@ -17,7 +16,7 @@ export interface ClashProxy {
[key: string]: unknown
}
export type ProxyImportMode = 'clash' | 'direct'
export type ProxyImportMode = 'clash' | 'direct' | 'chain'
export interface DirectImportForm {
proxyName: string
@@ -28,6 +27,67 @@ export interface DirectImportForm {
password: string
}
export interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
password: string
}
export interface ChainImportForm {
proxyName: string
localPort: string
first: ChainHopForm
second: ChainHopForm
}
interface ChainSocks5HopConfig {
protocol: 'http' | 'socks5'
server: string
port: number
username?: string
password?: string
}
interface ChainSocks5Config {
localPort?: number
first: ChainSocks5HopConfig
second: ChainSocks5HopConfig
}
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
export const CHAIN_QUICK_IMPORT_TEMPLATE = `{
"name": "",
"group": "",
"localPort": "",
"first": {
"protocol": "http",
"server": "",
"port": "",
"username": "",
"password": ""
},
"second": {
"protocol": "http",
"server": "",
"port": "",
"username": "",
"password": ""
}
}`
export const DIRECT_QUICK_IMPORT_TEMPLATE = `{
"name": "",
"group": "",
"protocol": "http",
"server": "",
"port": "",
"username": "",
"password": ""
}`
export const DIRECT_PROXY_PROTOCOL_OPTIONS = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
@@ -43,9 +103,114 @@ export const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
password: '',
}
export const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
proxyName: '',
localPort: '',
first: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
second: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
}
function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return null
}
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) {
return null
}
const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
const portVal = Number(hop.port || 0)
if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
const username = String(hop.username || '').trim()
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
if (password && !username) return null
return {
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
password: password || undefined,
}
}
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as Record<string, unknown>
const first = normalizeHop(parsed.first)
const second = normalizeHop(parsed.second)
if (!first || !second) return null
const localPortRaw = parsed.localPort
const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
? 0
: Number(localPortRaw)
if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
return {
first,
second,
localPort: localPortNum > 0 ? localPortNum : undefined,
}
} catch {
return null
}
}
export function toChainImportForm(proxyName: string, proxyConfig: string): ChainImportForm | null {
const cfg = parseChainSocks5Config(proxyConfig)
if (!cfg) {
return null
}
return {
proxyName,
localPort: cfg.localPort ? String(cfg.localPort) : '',
first: {
protocol: cfg.first.protocol,
server: cfg.first.server,
port: String(cfg.first.port),
username: cfg.first.username || '',
password: cfg.first.password || '',
},
second: {
protocol: cfg.second.protocol,
server: cfg.second.server,
port: String(cfg.second.port),
username: cfg.second.username || '',
password: cfg.second.password || '',
},
}
}
export interface ImportCandidate {
proxyName: string
proxyConfig: string
groupName?: string
}
export interface ProxyDisplayInfo {
@@ -89,6 +254,11 @@ export function parseProxyInfo(proxyConfig: string): { type: string; server: str
const cfg = proxyConfig.trim()
if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 }
const chain = parseChainSocks5Config(cfg)
if (chain) {
return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 }
}
const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//)
if (urlMatch) {
const scheme = urlMatch[1].toLowerCase()
@@ -258,6 +428,54 @@ function formatDirectProxyHost(raw: string): string {
return host.includes(':') ? `[${host}]` : host
}
function normalizeDirectProtocol(raw: unknown): DirectImportForm['protocol'] {
const protocol = String(raw || '').trim().toLowerCase()
if (protocol === 'http' || protocol === 'https' || protocol === 'socks5') {
return protocol
}
if (protocol === 'socks' || protocol === 'socket') {
return 'socks5'
}
throw new Error('protocol 仅支持 http / https / socks5')
}
function parseDirectProxyURL(raw: string): DirectImportForm {
const normalized = normalizeDirectProxyConfig(raw)
if (!normalized) {
throw new Error('请输入标准代理地址')
}
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(normalized)) {
throw new Error('单行文本需要包含协议头,需要包含协议头')
}
let parsedURL: URL
try {
parsedURL = new URL(normalized)
} catch {
throw new Error('单行代理文本格式无效')
}
const protocol = normalizeDirectProtocol(parsedURL.protocol.replace(/:$/, ''))
const server = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1').trim()
if (!server) {
throw new Error('代理地址缺少主机名')
}
const port = Number(parsedURL.port)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('代理地址缺少有效端口')
}
return {
proxyName: '',
protocol,
server,
port: String(port),
username: parsedURL.username ? decodeURIComponent(parsedURL.username) : '',
password: parsedURL.password ? decodeURIComponent(parsedURL.password) : '',
}
}
export function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
const serverInput = form.server.trim()
if (!serverInput) {
@@ -311,6 +529,298 @@ export function buildDirectImportCandidate(form: DirectImportForm): ImportCandid
}
}
interface ParsedDirectImportItem {
form: DirectImportForm
groupName: string
}
function parseDirectImportObject(payload: Record<string, unknown>, fallbackGroupName: string): ParsedDirectImportItem {
const proxyName = String(payload.name ?? payload.proxyName ?? '').trim()
const groupName = String(payload.group ?? payload.groupName ?? fallbackGroupName).trim()
const proxyURL = String(payload.url ?? payload.proxyUrl ?? payload.proxy ?? payload.proxyConfig ?? '').trim()
if (proxyURL) {
const parsedForm = parseDirectProxyURL(proxyURL)
return {
form: {
...parsedForm,
proxyName: proxyName || parsedForm.proxyName,
},
groupName,
}
}
const protocol = normalizeDirectProtocol(payload.protocol ?? payload.scheme)
const server = String(payload.server ?? payload.host ?? '').trim()
if (!server) {
throw new Error('JSON 缺少 server')
}
const portValue = Number(payload.port)
if (!Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
throw new Error('JSON 缺少有效 port')
}
const username = String(payload.username ?? payload.user ?? '').trim()
const password = payload.password === undefined || payload.password === null ? '' : String(payload.password)
if (password && !username) {
throw new Error('填写 password 时请同时填写 username')
}
return {
form: {
proxyName,
protocol,
server,
port: String(portValue),
username,
password,
},
groupName,
}
}
function parseDirectImportItems(raw: string): { items: ParsedDirectImportItem[]; defaultGroupName: string } {
const text = raw.trim()
if (!text) {
throw new Error('请输入 HTTP / SOCKS5 文本')
}
if (text.startsWith('{') || text.startsWith('[')) {
let payload: unknown
try {
payload = JSON.parse(text)
} catch {
throw new Error('JSON 格式无效')
}
let defaultGroupName = ''
let sources: unknown[] = []
if (Array.isArray(payload)) {
sources = payload
} else if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>
defaultGroupName = String(record.group ?? record.groupName ?? '').trim()
if (Array.isArray(record.proxies)) {
sources = record.proxies
} else if (Array.isArray(record.items)) {
sources = record.items
} else if (Array.isArray(record.list)) {
sources = record.list
} else {
sources = [record]
}
} else {
throw new Error('JSON 根节点必须是对象或数组')
}
const items = sources.map((item, index) => {
if (typeof item === 'string') {
return {
form: parseDirectProxyURL(item),
groupName: defaultGroupName,
}
}
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new Error(`${index + 1} 项格式无效`)
}
return parseDirectImportObject(item as Record<string, unknown>, defaultGroupName)
})
if (items.length === 0) {
throw new Error('JSON 未解析到可导入代理')
}
return { items, defaultGroupName }
}
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && !line.startsWith('//'))
if (lines.length === 0) {
throw new Error('请输入标准代理地址')
}
return {
items: lines.map((line) => ({
form: parseDirectProxyURL(line),
groupName: '',
})),
defaultGroupName: '',
}
}
export function parseDirectImportText(raw: string): { form: DirectImportForm; groupName: string } {
const { items } = parseDirectImportItems(raw)
if (items.length !== 1) {
throw new Error('检测到多条代理,请直接点击解析进行批量导入')
}
return items[0]
}
export function buildDirectImportCandidatesFromText(raw: string): { candidates: ImportCandidate[]; defaultGroupName: string } {
const { items, defaultGroupName } = parseDirectImportItems(raw)
return {
candidates: items.map((item) => ({
...buildDirectImportCandidate(item.form),
groupName: item.groupName,
})),
defaultGroupName,
}
}
export function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
}
const portInput = hop.port.trim()
if (!portInput) {
throw new Error(`请输入${label}代理端口`)
}
if (!/^\d+$/.test(portInput)) {
throw new Error(`${label}代理端口必须为数字`)
}
const port = Number(portInput)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label}代理端口必须在 1-65535 之间`)
}
const username = hop.username.trim()
const password = hop.password
if (password && !username) {
throw new Error(`${label}填写密码时请同时填写账号`)
}
return {
protocol,
server,
port,
username: username || undefined,
password: password || undefined,
}
}
const localPortInput = form.localPort.trim()
if (localPortInput && !/^\d+$/.test(localPortInput)) {
throw new Error('本地监听端口必须为数字')
}
const localPort = localPortInput ? Number(localPortInput) : 0
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
throw new Error('本地监听端口必须在 1-65535 之间')
}
const payload: ChainSocks5Config = {
first: parseHop('第一层', form.first),
second: parseHop('第二层', form.second),
localPort: localPort > 0 ? localPort : undefined,
}
return {
proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
proxyConfig: `${CHAIN_SOCKS5_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`,
}
}
function parseOptionalChainPort(raw: unknown, label: string): number | undefined {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return undefined
}
const value = Number(raw)
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`${label}必须在 1-65535 之间`)
}
return value
}
function parseChainQuickImportHop(raw: unknown, label: string): ChainSocks5HopConfig {
if (!raw || typeof raw !== 'object') {
throw new Error(`${label}缺少配置`)
}
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || 'socks5').trim().toLowerCase()
if (protocol !== 'socks5' && protocol !== 'http') {
throw new Error(`${label}仅支持 http / socks5`)
}
const server = String(hop.server || '').trim()
if (!server) {
throw new Error(`${label}缺少 server`)
}
const portValue = Number(hop.port)
if (!Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
throw new Error(`${label}缺少有效 port`)
}
const username = String(hop.username || '').trim()
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
if (password && !username) {
throw new Error(`${label}填写 password 时请同时填写 username`)
}
return {
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portValue,
username: username || undefined,
password: password || undefined,
}
}
export function parseChainImportJSON(raw: string): { form: ChainImportForm; groupName: string } {
const text = raw.trim()
if (!text) {
throw new Error('请输入链式代理 JSON')
}
let payload: Record<string, unknown>
try {
payload = JSON.parse(text) as Record<string, unknown>
} catch {
throw new Error('JSON 格式无效')
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('JSON 根节点必须是对象')
}
const first = parseChainQuickImportHop(payload.first, '第一层')
const second = parseChainQuickImportHop(payload.second, '第二层')
const localPort = parseOptionalChainPort(payload.localPort, 'localPort')
const proxyName = String(payload.name ?? payload.proxyName ?? '').trim()
const groupName = String(payload.group ?? payload.groupName ?? '').trim()
return {
form: {
proxyName,
localPort: localPort ? String(localPort) : '',
first: {
protocol: first.protocol,
server: first.server,
port: String(first.port),
username: first.username || '',
password: first.password || '',
},
second: {
protocol: second.protocol,
server: second.server,
port: String(second.port),
username: second.username || '',
password: second.password || '',
},
},
groupName,
}
}
export function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
return parsedProxies.map((proxy, index) => ({
proxyName: resolveImportedProxyName(proxy, index, prefix),
@@ -325,7 +835,7 @@ export function buildImportPreview(candidates: ImportCandidate[], groupName: str
proxyId: `preview-${index}`,
proxyName: candidate.proxyName,
proxyConfig: candidate.proxyConfig,
groupName,
groupName: candidate.groupName || groupName,
sourceId: '',
sourceUrl: '',
sourceAutoRefresh: false,
@@ -116,7 +116,10 @@ export function writeGlobalRefreshConfig(enabled: boolean, intervalM: number) {
export function toLatencyValue(ok: boolean, latencyMs: number, error?: string): number {
if (ok) return latencyMs
return error?.includes('不支持') ? -3 : -2
const message = (error || '').toLowerCase()
if (message.includes('不支持')) return -3
if (message.includes('timeout') || message.includes('超时') || message.includes('deadline exceeded') || message.includes('i/o timeout')) return -2
return -4
}
export function readLatencyCache(): Record<string, number> {
+27
View File
@@ -57,6 +57,23 @@ export interface BrowserSettings {
startStableWindowMs: number
}
export interface ProxyCheckTarget {
id: string
name: string
type: string
url: string
parser?: string
timeoutMs?: number
expectedStatus?: number[]
}
export interface ProxyCheckSettings {
bridgeStartTimeoutMs: number
speedTargetId: string
ipHealthTargetId: string
targets: ProxyCheckTarget[]
}
export interface BrowserCore {
coreId: string
coreName: string
@@ -139,6 +156,16 @@ export interface SnapshotInfo {
export interface BrowserBookmark {
name: string
url: string
openOnStart?: boolean
}
export interface BookmarkSyncResult {
total: number
synced: number
skipped: number
failed: number
skippedList: string[]
failedList: string[]
}
+9 -1
View File
@@ -3,8 +3,8 @@
import {automation} from '../models';
import {backup} from '../models';
import {config} from '../models';
import {browser} from '../models';
import {backend} from '../models';
import {browser} from '../models';
import {logger} from '../models';
import {launchcode} from '../models';
import {time} from '../models';
@@ -71,6 +71,8 @@ export function BookmarkReset():Promise<void>;
export function BookmarkSave(arg1:Array<config.BrowserBookmark>):Promise<void>;
export function BookmarkSyncToProfiles():Promise<backend.BookmarkSyncResult>;
export function BrowserClearCookies(arg1:string):Promise<void>;
export function BrowserCoreDelete(arg1:string):Promise<void>;
@@ -105,6 +107,8 @@ export function BrowserInstanceStart(arg1:string):Promise<browser.Profile>;
export function BrowserInstanceStartByCode(arg1:string):Promise<browser.Profile>;
export function BrowserInstanceStartDirect(arg1:string):Promise<browser.Profile>;
export function BrowserInstanceStartWithParams(arg1:string,arg2:Array<string>,arg3:Array<string>,arg4:boolean):Promise<browser.Profile>;
export function BrowserInstanceStatus(arg1:string):Promise<browser.Profile>;
@@ -193,6 +197,8 @@ export function GetLogLevel():Promise<string>;
export function GetMemoryStats():Promise<Record<string, any>>;
export function GetProxyCheckSettings():Promise<config.ProxyCheckConfig>;
export function GetRunningInstances():Promise<Array<browser.Profile>>;
export function InstallAutomationRuntime():Promise<Record<string, any>>;
@@ -225,6 +231,8 @@ export function SaveBrowserProxies(arg1:Array<config.BrowserProxy>):Promise<void
export function SaveBrowserSettings(arg1:browser.Settings):Promise<void>;
export function SaveProxyCheckSettings(arg1:config.ProxyCheckConfig):Promise<void>;
export function SetLogLevel(arg1:string):Promise<void>;
export function StartInstance(arg1:string):Promise<browser.Profile>;
+16
View File
@@ -126,6 +126,10 @@ export function BookmarkSave(arg1) {
return window['go']['main']['App']['BookmarkSave'](arg1);
}
export function BookmarkSyncToProfiles() {
return window['go']['main']['App']['BookmarkSyncToProfiles']();
}
export function BrowserClearCookies(arg1) {
return window['go']['main']['App']['BrowserClearCookies'](arg1);
}
@@ -194,6 +198,10 @@ export function BrowserInstanceStartByCode(arg1) {
return window['go']['main']['App']['BrowserInstanceStartByCode'](arg1);
}
export function BrowserInstanceStartDirect(arg1) {
return window['go']['main']['App']['BrowserInstanceStartDirect'](arg1);
}
export function BrowserInstanceStartWithParams(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['BrowserInstanceStartWithParams'](arg1, arg2, arg3, arg4);
}
@@ -370,6 +378,10 @@ export function GetMemoryStats() {
return window['go']['main']['App']['GetMemoryStats']();
}
export function GetProxyCheckSettings() {
return window['go']['main']['App']['GetProxyCheckSettings']();
}
export function GetRunningInstances() {
return window['go']['main']['App']['GetRunningInstances']();
}
@@ -434,6 +446,10 @@ export function SaveBrowserSettings(arg1) {
return window['go']['main']['App']['SaveBrowserSettings'](arg1);
}
export function SaveProxyCheckSettings(arg1) {
return window['go']['main']['App']['SaveProxyCheckSettings'](arg1);
}
export function SetLogLevel(arg1) {
return window['go']['main']['App']['SetLogLevel'](arg1);
}
+86
View File
@@ -178,6 +178,7 @@ export namespace automation {
paramsText: string;
useScriptSelector: boolean;
useScriptParams: boolean;
timeoutMs?: number;
static createFrom(source: any = {}) {
return new ScriptRunRequest(source);
@@ -190,6 +191,7 @@ export namespace automation {
this.paramsText = source["paramsText"];
this.useScriptSelector = source["useScriptSelector"];
this.useScriptParams = source["useScriptParams"];
this.timeoutMs = source["timeoutMs"];
}
}
@@ -199,6 +201,28 @@ export namespace automation {
export namespace backend {
export class BookmarkSyncResult {
total: number;
synced: number;
skipped: number;
failed: number;
skippedList: string[];
failedList: string[];
static createFrom(source: any = {}) {
return new BookmarkSyncResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
this.synced = source["synced"];
this.skipped = source["skipped"];
this.failed = source["failed"];
this.skippedList = source["skippedList"];
this.failedList = source["failedList"];
}
}
export class CookieInfo {
name: string;
value: string;
@@ -733,6 +757,7 @@ export namespace config {
export class BrowserBookmark {
name: string;
url: string;
openOnStart: boolean;
static createFrom(source: any = {}) {
return new BrowserBookmark(source);
@@ -742,6 +767,7 @@ export namespace config {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.url = source["url"];
this.openOnStart = source["openOnStart"];
}
}
export class BrowserCore {
@@ -804,6 +830,66 @@ export namespace config {
this.lastIPHealthJson = source["lastIPHealthJson"];
}
}
export class ProxyCheckTarget {
id: string;
name: string;
type: string;
url: string;
parser?: string;
timeoutMs?: number;
expectedStatus?: number[];
static createFrom(source: any = {}) {
return new ProxyCheckTarget(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type = source["type"];
this.url = source["url"];
this.parser = source["parser"];
this.timeoutMs = source["timeoutMs"];
this.expectedStatus = source["expectedStatus"];
}
}
export class ProxyCheckConfig {
bridgeStartTimeoutMs: number;
speedTargetId: string;
ipHealthTargetId: string;
targets: ProxyCheckTarget[];
static createFrom(source: any = {}) {
return new ProxyCheckConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.bridgeStartTimeoutMs = source["bridgeStartTimeoutMs"];
this.speedTargetId = source["speedTargetId"];
this.ipHealthTargetId = source["ipHealthTargetId"];
this.targets = this.convertValues(source["targets"], ProxyCheckTarget);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
+1
View File
@@ -19,6 +19,7 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
-2
View File
@@ -467,8 +467,6 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
+13 -1
View File
@@ -122,6 +122,14 @@ FunctionEnd
Function WarnInstallDir
StrCpy $0 "$INSTDIR"
StrCpy $1 "$0" 2
${If} "$1" == "C:"
Goto warn_dir
${EndIf}
${If} "$1" == "c:"
Goto warn_dir
${EndIf}
StrLen $1 "$PROGRAMFILES64"
${If} $1 > 0
StrCpy $2 "$0" $1
@@ -149,7 +157,7 @@ Function WarnInstallDir
Return
warn_dir:
MessageBox MB_ICONEXCLAMATION|MB_OKCANCEL|MB_DEFBUTTON2 "当前安装目录位于 Program Files。$\r$\n$\r$\n在部分机器上,普通权限运行可能无法写入 data 目录,导致再次启动闪退或浏览器实例启动失败。$\r$\n$\r$\n建议改为可写目录如 D:\AntBrowser。$\r$\n$\r$\n点击“确定”继续安装到当前目录,点击“取消”返回修改安装路径。" IDOK continue_install IDCANCEL cancel_install
MessageBox MB_ICONEXCLAMATION|MB_OKCANCEL|MB_DEFBUTTON2 "不建议安装到 C 盘、Program Files 或其他受权限保护的目录。$\r$\n$\r$\nAnt Browser 会在安装目录写入 data、浏览器实例和运行时文件;普通权限运行可能写入失败,导致再次启动闪退或浏览器实例启动失败。$\r$\n$\r$\n建议改为非 C 盘可写目录,例如 D:\software\Ant Browser 或 E:\software\Ant Browser。$\r$\n$\r$\n点击“确定”继续安装到当前目录,点击“取消”返回修改安装路径。" IDOK continue_install IDCANCEL cancel_install
continue_install:
Return
@@ -173,9 +181,13 @@ RequestExecutionLevel admin
!define MUI_UNICON "..\build\windows\icon.ico"
!insertmacro MUI_PAGE_WELCOME
!define MUI_DIRECTORYPAGE_TEXT_TOP "请选择 Ant Browser 的安装目录。建议安装到非 C 盘的可写目录,例如 D:\software\Ant Browser 或 E:\software\Ant Browser;不要安装到 C 盘、Program Files 或其他受权限保护的目录。"
!define MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装目录(建议非 C 盘)"
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE WarnInstallDir
!insertmacro MUI_PAGE_DIRECTORY
!undef MUI_PAGE_CUSTOMFUNCTION_LEAVE
!undef MUI_DIRECTORYPAGE_TEXT_TOP
!undef MUI_DIRECTORYPAGE_TEXT_DESTINATION
!define MUI_COMPONENTSPAGE_SMALLDESC
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES