@@ -273,6 +281,8 @@ chrome/
先检查代理节点本身是否可用,再确认该实例已经正确绑定代理。建议启动后访问 IP 检测网站复核当前出口。
+如果代理池里本地客户端可用节点很多,但 Ant Browser 中“只展示可用”数量明显偏少,先确认当前 `default_connector_type` 是否与本地客户端一致。Ant Browser 不会在 `xray` 组合栈和 `mihomo` 栈之间自动混用;切换连接栈后需要重新测速。
+
### 3. 实例太多,怎么快速找到目标实例?
可以在 `实例列表` 中按状态、代理、内核、分组、关键字筛选,也可以通过 `Ctrl + K` 使用实例 Code 或名称快速启动。
diff --git a/backend/app.go b/backend/app.go
index b8153102..8545ca82 100644
--- a/backend/app.go
+++ b/backend/app.go
@@ -41,7 +41,7 @@ type App struct {
quitMode quitMode
maintenanceMu sync.Mutex
bridgeMu sync.Mutex
- xrayBridgeRefs map[string]string
+ profileBridgeRefs map[string]profileProxyBridgeRef
deferredStartTargetsMu sync.Mutex
deferredStartTargets map[string][]string
automationTargetMu sync.Mutex
@@ -59,7 +59,7 @@ func NewApp(appRoot string, appVersion ...string) *App {
return &App{
appRoot: strings.TrimSpace(appRoot),
version: version,
- xrayBridgeRefs: make(map[string]string),
+ profileBridgeRefs: make(map[string]profileProxyBridgeRef),
deferredStartTargets: make(map[string][]string),
automationTargetCursor: make(map[string]string),
}
diff --git a/backend/app_backup_runtime.go b/backend/app_backup_runtime.go
index ef58e659..4e9db91e 100644
--- a/backend/app_backup_runtime.go
+++ b/backend/app_backup_runtime.go
@@ -2,7 +2,7 @@ package backend
import (
"ant-chrome/backend/internal/browser"
- "ant-chrome/backend/internal/proxy"
+ "ant-chrome/backend/internal/config"
"os/exec"
"time"
)
@@ -22,7 +22,7 @@ func (a *App) backupStopRuntimeForMaintenance() {
if a.xrayMgr != nil {
a.xrayMgr.StopAll()
}
- a.clearProfileXrayBridges()
+ a.clearProfileProxyBridges()
if a.singboxMgr != nil {
a.singboxMgr.StopAll()
}
@@ -73,7 +73,8 @@ func (a *App) backupReloadAfterMutation() error {
a.speedScheduler = browser.NewProxySpeedScheduler(
a.browserMgr.ProxyDAO,
func(proxyID string) (bool, int64, string) {
- r := proxy.TestRealConnectivityWithConfig(proxyID, a.config.Browser.Proxies, a.xrayMgr, a.singboxMgr, nil)
+ connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
+ r := a.testProxySpeedWithConnector(proxyID, a.getLatestProxies(), connectorType)
return r.Ok, r.LatencyMs, r.Error
},
5*time.Minute,
diff --git a/backend/app_bridge_refs.go b/backend/app_bridge_refs.go
index db70e609..b8e3fa83 100644
--- a/backend/app_bridge_refs.go
+++ b/backend/app_bridge_refs.go
@@ -2,36 +2,73 @@ package backend
import "strings"
-func (a *App) bindProfileXrayBridge(profileId string, bridgeKey string) {
+const (
+ profileProxyBridgeEngineXray = "xray"
+ profileProxyBridgeEngineSingBox = "sing-box"
+)
+
+type profileProxyBridgeRef struct {
+ Engine string
+ Key string
+}
+
+func (ref profileProxyBridgeRef) valid() bool {
+ return strings.TrimSpace(ref.Engine) != "" && strings.TrimSpace(ref.Key) != ""
+}
+
+func newProfileProxyBridgeRef(engine string, key string) profileProxyBridgeRef {
+ return profileProxyBridgeRef{
+ Engine: strings.TrimSpace(engine),
+ Key: strings.TrimSpace(key),
+ }
+}
+
+func (a *App) bindProfileProxyBridge(profileId string, ref profileProxyBridgeRef) {
profileId = strings.TrimSpace(profileId)
- bridgeKey = strings.TrimSpace(bridgeKey)
- if profileId == "" || bridgeKey == "" {
+ if profileId == "" || !ref.valid() {
return
}
a.bridgeMu.Lock()
- a.xrayBridgeRefs[profileId] = bridgeKey
+ if a.profileBridgeRefs == nil {
+ a.profileBridgeRefs = make(map[string]profileProxyBridgeRef)
+ }
+ a.profileBridgeRefs[profileId] = ref
a.bridgeMu.Unlock()
}
-func (a *App) releaseProfileXrayBridge(profileId string) {
+func (a *App) releaseProfileProxyBridge(profileId string) {
profileId = strings.TrimSpace(profileId)
if profileId == "" {
return
}
a.bridgeMu.Lock()
- bridgeKey := a.xrayBridgeRefs[profileId]
- delete(a.xrayBridgeRefs, profileId)
+ ref := a.profileBridgeRefs[profileId]
+ delete(a.profileBridgeRefs, profileId)
a.bridgeMu.Unlock()
- if bridgeKey != "" && a.xrayMgr != nil {
- a.xrayMgr.ReleaseBridge(bridgeKey)
+ a.releaseProxyBridgeRef(ref)
+}
+
+func (a *App) releaseProxyBridgeRef(ref profileProxyBridgeRef) {
+ if !ref.valid() {
+ return
+ }
+ switch ref.Engine {
+ case profileProxyBridgeEngineXray:
+ if a.xrayMgr != nil {
+ a.xrayMgr.ReleaseBridge(ref.Key)
+ }
+ case profileProxyBridgeEngineSingBox:
+ if a.singboxMgr != nil {
+ a.singboxMgr.ReleaseBridge(ref.Key)
+ }
}
}
-func (a *App) clearProfileXrayBridges() {
+func (a *App) clearProfileProxyBridges() {
a.bridgeMu.Lock()
- a.xrayBridgeRefs = make(map[string]string)
+ a.profileBridgeRefs = make(map[string]profileProxyBridgeRef)
a.bridgeMu.Unlock()
}
diff --git a/backend/app_browser_profile_api.go b/backend/app_browser_profile_api.go
index a7eeaeec..20b63939 100644
--- a/backend/app_browser_profile_api.go
+++ b/backend/app_browser_profile_api.go
@@ -47,6 +47,22 @@ func (a *App) BrowserProfileUpdate(profileId string, input BrowserProfileInput)
func (a *App) BrowserProfileDelete(profileId string) error { return a.browserMgr.Delete(profileId) }
+// BrowserProfileTrashList 获取回收站实例列表
+func (a *App) BrowserProfileTrashList() []BrowserProfile { return a.browserMgr.ListDeleted() }
+
+// BrowserProfileRestore 从回收站恢复实例
+func (a *App) BrowserProfileRestore(profileId string) (*BrowserProfile, error) {
+ return a.browserMgr.Restore(profileId)
+}
+
+// BrowserProfilePermanentlyDelete 从回收站彻底删除实例
+func (a *App) BrowserProfilePermanentlyDelete(profileId string) error {
+ return a.browserMgr.PermanentlyDelete(profileId)
+}
+
+// BrowserProfileTrashCleanup 清理超过保留期的回收站实例
+func (a *App) BrowserProfileTrashCleanup() error { return a.browserMgr.CleanupExpiredTrash() }
+
// BrowserProfileCopy 复制实例配置(除指纹参数外全部复制)
func (a *App) BrowserProfileCopy(profileId string, newName string) (*BrowserProfile, error) {
return a.browserMgr.Copy(profileId, newName)
diff --git a/backend/app_instance_launch_args.go b/backend/app_instance_launch_args.go
index b229e43c..9fa8a6cf 100644
--- a/backend/app_instance_launch_args.go
+++ b/backend/app_instance_launch_args.go
@@ -124,7 +124,7 @@ func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile
profile.LastStopAt = time.Now().Format(time.RFC3339)
delete(a.browserMgr.BrowserProcesses, profileId)
a.clearDeferredStartTargets(profileId)
- a.releaseProfileXrayBridge(profileId)
+ a.releaseProfileProxyBridge(profileId)
if a.launchServer != nil {
a.launchServer.ClearActiveProfile(profileId)
}
@@ -165,3 +165,27 @@ func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraL
}
return nil
}
+
+func (a *App) openBrowserTabForRunningProfile(profile *BrowserProfile, extraLaunchArgs []string, startURLs []string) error {
+ explicitTargets := normalizeNonEmptyStrings(startURLs)
+ targets := explicitTargets
+ if len(targets) == 0 {
+ targets = []string{"about:blank"}
+ }
+ if profile != nil && profile.DebugReady && profile.DebugPort > 0 {
+ for _, target := range targets {
+ if err := createBrowserStartTarget(profile.DebugPort, target); err != nil {
+ if len(explicitTargets) == 0 && len(normalizeNonEmptyStrings(extraLaunchArgs)) == 0 {
+ return nil
+ }
+ return err
+ }
+ }
+ return nil
+ }
+ err := a.openBrowserWindowForRunningProfile(profile, extraLaunchArgs, targets)
+ if err != nil && len(explicitTargets) == 0 && len(normalizeNonEmptyStrings(extraLaunchArgs)) == 0 {
+ return nil
+ }
+ return err
+}
diff --git a/backend/app_instance_start_execute.go b/backend/app_instance_start_execute.go
index a37164d8..6ee2730f 100644
--- a/backend/app_instance_start_execute.go
+++ b/backend/app_instance_start_execute.go
@@ -46,9 +46,9 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser
stableDebugPort, readyErr := waitBrowserDebugPortStable(plan.assignedDebugPort, plan.userDataDir, plan.startReadyTimeout, plan.startStableWindow, monitor)
if readyErr == nil {
a.markProfileRunningLocked(input.ProfileID, profile, cmd, cmd.Process.Pid, stableDebugPort, true, "")
- if plan.acquiredXrayBridgeKey != "" {
- a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey)
- plan.releaseXrayBridge = false
+ if plan.acquiredProxyBridge.valid() {
+ a.bindProfileProxyBridge(input.ProfileID, plan.acquiredProxyBridge)
+ plan.releaseProxyBridge = false
}
if len(plan.deferredStartTargets) > 0 {
if err := openBrowserStartTargets(stableDebugPort, plan.deferredStartTargets); err != nil {
@@ -115,9 +115,9 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser
if len(plan.deferredStartTargets) > 0 {
a.storeDeferredStartTargets(input.ProfileID, plan.deferredStartTargets)
}
- if plan.acquiredXrayBridgeKey != "" {
- a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey)
- plan.releaseXrayBridge = false
+ if plan.acquiredProxyBridge.valid() {
+ a.bindProfileProxyBridge(input.ProfileID, plan.acquiredProxyBridge)
+ plan.releaseProxyBridge = false
}
log.Warn("浏览器窗口已启动,但调试接口在等待窗口内未就绪,转入后台附着",
diff --git a/backend/app_instance_start_prepare.go b/backend/app_instance_start_prepare.go
index 6fb8f90c..a9c53c43 100644
--- a/backend/app_instance_start_prepare.go
+++ b/backend/app_instance_start_prepare.go
@@ -22,29 +22,26 @@ type browserStartInput struct {
}
type browserStartPlan struct {
- profile *BrowserProfile
- chromeBinaryPath string
- userDataDir string
- args []string
- extensionDirs []string
- deferredStartTargets []string
- effectiveProxy string
- acquiredXrayBridgeKey string
- releaseXrayBridge bool
- assignedDebugPort int
- startReadyTimeout time.Duration
- startStableWindow time.Duration
- maxStartAttempts int
- totalReadyTimeout time.Duration
+ profile *BrowserProfile
+ chromeBinaryPath string
+ userDataDir string
+ args []string
+ extensionDirs []string
+ deferredStartTargets []string
+ effectiveProxy string
+ acquiredProxyBridge profileProxyBridgeRef
+ releaseProxyBridge bool
+ assignedDebugPort int
+ startReadyTimeout time.Duration
+ startStableWindow time.Duration
+ maxStartAttempts int
+ totalReadyTimeout time.Duration
}
var clearBrowserSessionRestoreData = browser.ClearSessionRestoreData
func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyID string, proxyConfig string) browserStartInput {
normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs)
- if preferVisibleWindow {
- normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs)
- }
return browserStartInput{
ProfileID: profileID,
@@ -66,8 +63,8 @@ func (plan *browserStartPlan) releaseBridgeIfNeeded(a *App) {
if plan == nil || a == nil {
return
}
- if plan.releaseXrayBridge && plan.acquiredXrayBridgeKey != "" && a.xrayMgr != nil {
- a.xrayMgr.ReleaseBridge(plan.acquiredXrayBridgeKey)
+ if plan.releaseProxyBridge {
+ a.releaseProxyBridgeRef(plan.acquiredProxyBridge)
}
}
@@ -96,18 +93,24 @@ func (a *App) resolveBrowserStartProfile(input browserStartInput) (*BrowserProfi
return profile, false, nil
}
- if input.PreferVisibleWindow {
- if err := a.openBrowserWindowForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil {
- startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err)
- log.Error("运行中实例窗口唤起失败",
- logger.F("profile_id", input.ProfileID),
- logger.F("debug_port", profile.DebugPort),
- logger.F("error", err.Error()),
- logger.F("reason", startErr.Error()),
- )
- profile.LastError = startErr.Error()
- return profile, true, startErr
+ if len(normalizeNonEmptyStrings(input.StartURLs)) == 0 && len(normalizeNonEmptyStrings(input.ExtraLaunchArgs)) == 0 {
+ if a.launchServer != nil && profile.DebugReady {
+ a.launchServer.SetActiveProfile(profile)
}
+ a.emitBrowserInstanceStarted(profile, true)
+ return profile, true, nil
+ }
+
+ if err := a.openBrowserTabForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil {
+ startErr := fmt.Errorf("实例已在运行,但新标签打开失败:%w", err)
+ log.Error("运行中实例新标签打开失败",
+ logger.F("profile_id", input.ProfileID),
+ logger.F("debug_port", profile.DebugPort),
+ logger.F("error", err.Error()),
+ logger.F("reason", startErr.Error()),
+ )
+ profile.LastError = startErr.Error()
+ return profile, true, startErr
}
if a.launchServer != nil && profile.DebugReady {
@@ -124,7 +127,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
return nil, err
}
- effectiveProxy, acquiredXrayBridgeKey, releaseXrayBridge, err := a.resolveBrowserStartProxy(input, profile)
+ effectiveProxy, acquiredProxyBridge, releaseProxyBridge, err := a.resolveBrowserStartProxy(input, profile)
if err != nil {
return nil, err
}
@@ -156,20 +159,20 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
}
return &browserStartPlan{
- profile: profile,
- chromeBinaryPath: chromeBinaryPath,
- userDataDir: userDataDir,
- extensionDirs: extensionDirs,
- args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, extensionDirs, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, launchTargets),
- deferredStartTargets: deferredStartTargets,
- effectiveProxy: effectiveProxy,
- acquiredXrayBridgeKey: acquiredXrayBridgeKey,
- releaseXrayBridge: releaseXrayBridge,
- assignedDebugPort: assignedDebugPort,
- startReadyTimeout: startReadyTimeout,
- startStableWindow: startStableWindow,
- maxStartAttempts: maxStartAttempts,
- totalReadyTimeout: totalReadyTimeout,
+ profile: profile,
+ chromeBinaryPath: chromeBinaryPath,
+ userDataDir: userDataDir,
+ extensionDirs: extensionDirs,
+ args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, extensionDirs, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, launchTargets),
+ deferredStartTargets: deferredStartTargets,
+ effectiveProxy: effectiveProxy,
+ acquiredProxyBridge: acquiredProxyBridge,
+ releaseProxyBridge: releaseProxyBridge,
+ assignedDebugPort: assignedDebugPort,
+ startReadyTimeout: startReadyTimeout,
+ startStableWindow: startStableWindow,
+ maxStartAttempts: maxStartAttempts,
+ totalReadyTimeout: totalReadyTimeout,
}, nil
}
@@ -223,12 +226,13 @@ func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *Brow
logger.F("pid", detection.PID),
logger.F("debug_port", detection.DebugPort),
)
- if input.PreferVisibleWindow {
- if err := a.openBrowserWindowForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil {
- startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err)
- profile.LastError = startErr.Error()
- return nil, nil, "", "", startErr
- }
+ if len(normalizeNonEmptyStrings(input.StartURLs)) == 0 && len(normalizeNonEmptyStrings(input.ExtraLaunchArgs)) == 0 {
+ return nil, nil, "", "", errBrowserStartHandledByRecoveredRuntime
+ }
+ if err := a.openBrowserTabForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil {
+ startErr := fmt.Errorf("实例已在运行,但新标签打开失败:%w", err)
+ profile.LastError = startErr.Error()
+ return nil, nil, "", "", startErr
}
return nil, nil, "", "", errBrowserStartHandledByRecoveredRuntime
}
diff --git a/backend/app_instance_start_proxy.go b/backend/app_instance_start_proxy.go
index 260a51d7..a79319db 100644
--- a/backend/app_instance_start_proxy.go
+++ b/backend/app_instance_start_proxy.go
@@ -1,7 +1,6 @@
package backend
import (
- "ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"ant-chrome/backend/internal/proxy"
"fmt"
@@ -10,7 +9,7 @@ import (
const temporaryDirectProxyID = "__direct__"
-func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *BrowserProfile) (string, string, bool, error) {
+func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *BrowserProfile) (string, profileProxyBridgeRef, bool, error) {
log := logger.New("Browser")
proxies := a.getLatestProxies()
profileID := input.ProfileID
@@ -20,7 +19,7 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
logger.F("profile_id", profileID),
logger.F("proxy_id", profile.ProxyId),
)
- return "direct://", "", false, nil
+ return "direct://", profileProxyBridgeRef{}, false, nil
}
resolvedProxyID := strings.TrimSpace(profile.ProxyId)
@@ -38,7 +37,7 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
logger.F("error", err.Error()),
logger.F("reason", startErr.Error()),
)
- return "", "", false, startErr
+ return "", profileProxyBridgeRef{}, false, startErr
}
} else if resolvedProxyID != "" {
for _, item := range proxies {
@@ -68,60 +67,73 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
logger.F("error", errorMsg),
logger.F("reason", startErr.Error()),
)
- return "", "", false, startErr
+ return "", profileProxyBridgeRef{}, false, startErr
}
- connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
- if connectorType == config.BrowserConnectorMihomo && (proxy.IsSingBoxProtocol(resolvedProxyConfig) || proxy.RequiresBridge(resolvedProxyConfig, proxies, resolvedProxyID) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig)) {
- log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "mihomo"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
+ resolution, err := proxy.ResolveProxyKernel(resolvedProxyConfig, proxies, resolvedProxyID, "")
+ if err != nil {
+ startErr := fmt.Errorf("实例启动失败:%s", err.Error())
+ profile.LastError = startErr.Error()
+ log.Error("代理内核选择失败",
+ logger.F("profile_id", profileID),
+ logger.F("proxy_id", resolvedProxyID),
+ logger.F("error", err.Error()),
+ logger.F("reason", startErr.Error()),
+ )
+ return "", profileProxyBridgeRef{}, false, startErr
+ }
+ log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", resolution.Kernel), logger.F("protocol", resolution.Protocol), logger.F("proxy_id", resolvedProxyID), logger.F("reason", resolution.Reason))
+
+ switch resolution.Kernel {
+ case proxy.ProxyKernelMihomo:
+ if a.clashMgr == nil {
+ startErr := fmt.Errorf("实例启动失败:mihomo 管理器未初始化,无法启动该协议代理。请先下载 Mihomo 内核。")
+ profile.LastError = startErr.Error()
+ return "", profileProxyBridgeRef{}, false, startErr
+ }
proxyURL, bridgeErr := a.clashMgr.EnsureNodeBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
- startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(mihomo)。原因:%v。请检查代理节点配置、mihomo 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
- log.Error("代理桥接失败(mihomo)",
- logger.F("error", bridgeErr.Error()),
- logger.F("reason", startErr.Error()),
- )
+ startErr := fmt.Errorf("实例启动失败:mihomo 代理桥接失败:%v", bridgeErr)
+ log.Error("代理桥接失败(mihomo)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
- return "", "", false, startErr
+ return "", profileProxyBridgeRef{}, false, startErr
}
- log.Info("mihomo 桥接成功", logger.F("engine", "mihomo"), logger.F("proxy_url", proxyURL))
- return proxyURL, "", false, nil
- }
-
- if proxy.IsSingBoxProtocol(resolvedProxyConfig) {
- log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "sing-box"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
- socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, resolvedProxyID)
+ return proxyURL, profileProxyBridgeRef{}, false, nil
+ case proxy.ProxyKernelSingBox:
+ if a.singboxMgr == nil {
+ startErr := fmt.Errorf("实例启动失败:sing-box 管理器未初始化,无法启动该协议代理。请检查 sing-box 内核配置。")
+ profile.LastError = startErr.Error()
+ return "", profileProxyBridgeRef{}, false, startErr
+ }
+ socksURL, bridgeKey, bridgeErr := a.singboxMgr.AcquireBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
- startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
- log.Error("代理桥接失败(sing-box)",
- logger.F("error", bridgeErr.Error()),
- logger.F("reason", startErr.Error()),
- )
+ startErr := fmt.Errorf("实例启动失败:sing-box 代理桥接失败:%v", bridgeErr)
+ log.Error("代理桥接失败(sing-box)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
- return "", "", false, startErr
+ return "", profileProxyBridgeRef{}, false, startErr
+ }
+ return socksURL, newProfileProxyBridgeRef(profileProxyBridgeEngineSingBox, bridgeKey), bridgeKey != "", nil
+ case proxy.ProxyKernelXray:
+ if a.xrayMgr == nil {
+ startErr := fmt.Errorf("实例启动失败:xray 管理器未初始化,无法启动该协议代理。")
+ profile.LastError = startErr.Error()
+ return "", profileProxyBridgeRef{}, false, startErr
}
- log.Info("sing-box 桥接成功", logger.F("engine", "sing-box"), logger.F("socks_url", socksURL))
- return socksURL, "", false, nil
- }
-
- if proxy.RequiresBridge(resolvedProxyConfig, proxies, resolvedProxyID) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) {
- log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "xray"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
- startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
- log.Error("代理桥接失败(xray)",
- logger.F("error", bridgeErr.Error()),
- logger.F("reason", startErr.Error()),
- )
+ startErr := fmt.Errorf("实例启动失败:Xray 代理桥接失败:%v", bridgeErr)
+ log.Error("代理桥接失败(xray)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
- return "", "", false, startErr
+ return "", profileProxyBridgeRef{}, false, startErr
}
- log.Info("xray 桥接成功", logger.F("engine", "xray"), logger.F("socks_url", socksURL))
- return socksURL, bridgeKey, bridgeKey != "", nil
+ return socksURL, newProfileProxyBridgeRef(profileProxyBridgeEngineXray, bridgeKey), bridgeKey != "", nil
+ case proxy.ProxyKernelNative:
+ return resolvedProxyConfig, profileProxyBridgeRef{}, false, nil
+ default:
+ startErr := fmt.Errorf("实例启动失败:无法为协议 %s 选择代理内核", resolution.Protocol)
+ profile.LastError = startErr.Error()
+ return "", profileProxyBridgeRef{}, false, startErr
}
-
- log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "native"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
- return resolvedProxyConfig, "", false, nil
}
func resolveTemporaryBrowserStartProxy(proxyID string, proxyConfig string, proxies []BrowserProxy) (string, string, error) {
diff --git a/backend/app_instance_start_ready_test.go b/backend/app_instance_start_ready_test.go
index 73fc291b..6fedc1c5 100644
--- a/backend/app_instance_start_ready_test.go
+++ b/backend/app_instance_start_ready_test.go
@@ -122,30 +122,30 @@ func TestResolveBrowserStartProxyUsesTemporaryProxyWithoutMutatingProfile(t *tes
}
input := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "runtime-proxy", "")
- effectiveProxy, bridgeKey, releaseBridge, err := app.resolveBrowserStartProxy(input, profile)
+ effectiveProxy, bridgeRef, releaseBridge, err := app.resolveBrowserStartProxy(input, profile)
if err != nil {
t.Fatalf("resolveBrowserStartProxy returned error: %v", err)
}
if effectiveProxy != "http://127.0.0.1:28080" {
t.Fatalf("expected temporary proxy, got %q", effectiveProxy)
}
- if bridgeKey != "" || releaseBridge {
- t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
+ if bridgeRef.valid() || releaseBridge {
+ t.Fatalf("plain HTTP proxy should not acquire bridge: ref=%+v release=%v", bridgeRef, releaseBridge)
}
if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
t.Fatalf("temporary proxy should not mutate profile: %+v", profile)
}
fallbackInput := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "missing-proxy", "http://127.0.0.1:38080")
- effectiveProxy, bridgeKey, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile)
+ effectiveProxy, bridgeRef, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile)
if err != nil {
t.Fatalf("fallback temporary proxy returned error: %v", err)
}
if effectiveProxy != "http://127.0.0.1:38080" {
t.Fatalf("expected fallback temporary proxy config, got %q", effectiveProxy)
}
- if bridgeKey != "" || releaseBridge {
- t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
+ if bridgeRef.valid() || releaseBridge {
+ t.Fatalf("fallback HTTP proxy should not acquire bridge: ref=%+v release=%v", bridgeRef, releaseBridge)
}
if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
t.Fatalf("fallback temporary proxy should not mutate profile: %+v", profile)
diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go
index 98be2571..2a47092b 100644
--- a/backend/app_instance_start_test.go
+++ b/backend/app_instance_start_test.go
@@ -69,6 +69,65 @@ func TestShouldPreferVisibleWindowForStartWithParams(t *testing.T) {
}
}
+func TestBrowserInstanceStartRunningProfileOpensNewTab(t *testing.T) {
+ app, exePath := newBrowserOpenURLTestAppWithCore(t)
+
+ cmd := longLivedCommand(2 * time.Second)
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("启动长生命周期测试进程失败: %v", err)
+ }
+ defer func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ _, _ = cmd.Process.Wait()
+ }
+ }()
+
+ profile := &BrowserProfile{
+ ProfileId: "profile-running-start",
+ ProfileName: "Running Browser",
+ UserDataDir: "profile-running-start",
+ Running: true,
+ DebugReady: false,
+ DebugPort: 0,
+ Pid: cmd.Process.Pid,
+ }
+ app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
+ app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd}
+
+ expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile)
+ var gotPath string
+ var gotArgs []string
+
+ originalStart := startBrowserWindowProcess
+ startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) {
+ gotPath = chromeBinaryPath
+ gotArgs = append([]string{}, args...)
+ return nil, nil
+ }
+ defer func() {
+ startBrowserWindowProcess = originalStart
+ }()
+
+ started, err := app.BrowserInstanceStart(profile.ProfileId)
+ if err != nil {
+ t.Fatalf("BrowserInstanceStart returned error: %v", err)
+ }
+ if started == nil || started.ProfileId != profile.ProfileId {
+ t.Fatalf("unexpected started profile: %#v", started)
+ }
+ if gotPath != exePath {
+ t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath)
+ }
+ wantArgs := []string{
+ "--user-data-dir=" + expectedUserDataDir,
+ "about:blank",
+ }
+ if !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs)
+ }
+}
+
func TestIsBrowserProfileLive(t *testing.T) {
t.Parallel()
diff --git a/backend/app_proxy_health.go b/backend/app_proxy_health.go
index 65f40a06..1ab1025d 100644
--- a/backend/app_proxy_health.go
+++ b/backend/app_proxy_health.go
@@ -1,6 +1,7 @@
package backend
import (
+ "ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/proxy"
"encoding/json"
"fmt"
@@ -15,30 +16,37 @@ import (
// BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果
func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult {
proxies := a.getLatestProxies()
- result := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig())
+ connectorType := a.defaultProxyConnectorType()
+ result := proxy.SpeedTestWithConnector(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, a.proxySpeedTestConfig())
if a.browserMgr.ProxyDAO != nil {
testedAt := time.Now().Format(time.RFC3339)
_ = a.browserMgr.ProxyDAO.UpdateSpeedResult(proxyId, result.Ok, result.LatencyMs, testedAt)
}
- return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error}
+ return buildProxyTestResult(result)
}
-// BrowserProxyBatchTestSpeed 批量并发测速,concurrency 控制并发数(默认 5,最高 5)。
+const (
+ defaultProxySpeedConcurrency = 5
+ maxProxySpeedConcurrency = 10
+)
+
+// BrowserProxyBatchTestSpeed 批量并发测速,concurrency 控制并发数(默认 5,最高 10)。
func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []ProxyTestResult {
if len(proxyIds) == 0 {
return []ProxyTestResult{}
}
if concurrency <= 0 {
- concurrency = 5
+ concurrency = defaultProxySpeedConcurrency
}
- if concurrency > 5 {
- concurrency = 5
+ if concurrency > maxProxySpeedConcurrency {
+ concurrency = maxProxySpeedConcurrency
}
if concurrency > len(proxyIds) {
concurrency = len(proxyIds)
}
proxies := a.getLatestProxies()
+ connectorType := a.defaultProxyConnectorType()
results := make([]ProxyTestResult, len(proxyIds))
type speedJob struct {
Idx int
@@ -52,12 +60,12 @@ 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, a.proxySpeedTestConfig())
+ result := proxy.SpeedTestWithConnector(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, a.proxySpeedTestConfig())
if a.browserMgr.ProxyDAO != nil {
testedAt := time.Now().Format(time.RFC3339)
_ = a.browserMgr.ProxyDAO.UpdateSpeedResult(job.ProxyId, result.Ok, result.LatencyMs, testedAt)
}
- item := ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error}
+ item := buildProxyTestResult(result)
results[job.Idx] = item
if a.ctx != nil {
@@ -76,10 +84,22 @@ func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []P
return results
}
+func (a *App) testProxySpeedWithConnector(proxyId string, proxies []BrowserProxy, connectorType string) proxy.TestResult {
+ return proxy.SpeedTestWithConnector(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, config.NormalizeBrowserConnectorType(connectorType), a.proxySpeedTestConfig())
+}
+
+func (a *App) defaultProxyConnectorType() string {
+ if a == nil || a.config == nil {
+ return config.BrowserConnectorXray
+ }
+ return config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
+}
+
// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息
func (a *App) BrowserProxyCheckIPHealth(proxyId string) ProxyIPHealthResult {
proxies := a.getLatestProxies()
- data, err := proxy.FetchIPHealthInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxyIPHealthConfig())
+ connectorType := a.defaultProxyConnectorType()
+ data, err := proxy.FetchIPHealthInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, a.proxyIPHealthConfig())
result := buildProxyIPHealthResult(proxyId, data, err)
a.persistProxyIPHealthResult(result)
if a.ctx != nil {
@@ -101,6 +121,7 @@ func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int)
}
proxies := a.getLatestProxies()
+ connectorType := a.defaultProxyConnectorType()
results := make([]ProxyIPHealthResult, len(proxyIds))
type healthJob struct {
Idx int
@@ -114,7 +135,7 @@ func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int)
go func() {
defer wg.Done()
for job := range jobs {
- data, err := proxy.FetchIPHealthInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxyIPHealthConfig())
+ data, err := proxy.FetchIPHealthInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, a.proxyIPHealthConfig())
result := buildProxyIPHealthResult(job.ProxyId, data, err)
a.persistProxyIPHealthResult(result)
results[job.Idx] = result
diff --git a/backend/app_proxy_health_test.go b/backend/app_proxy_health_test.go
index 7671d6e7..b8fb508e 100644
--- a/backend/app_proxy_health_test.go
+++ b/backend/app_proxy_health_test.go
@@ -1,8 +1,16 @@
package backend
import (
+ "ant-chrome/backend/internal/browser"
+ "ant-chrome/backend/internal/config"
+ "bufio"
"errors"
+ "fmt"
+ "net"
+ "strings"
+ "sync/atomic"
"testing"
+ "time"
)
func TestBuildProxyIPHealthResultPreservesErrorSourceMetadata(t *testing.T) {
@@ -26,3 +34,105 @@ func TestBuildProxyIPHealthResultPreservesErrorSourceMetadata(t *testing.T) {
t.Fatalf("target url = %q, want trace url", got)
}
}
+
+func TestProxySpeedWithConnectorHonorsXrayConnector(t *testing.T) {
+ var requests atomic.Int32
+ proxyURL, closeProxy := startBackendDelayedHTTPProxy(t, 10*time.Millisecond, &requests)
+ t.Cleanup(closeProxy)
+
+ cfg := config.DefaultConfig()
+ app := NewApp(t.TempDir())
+ app.config = cfg
+ app.browserMgr = browser.NewManager(cfg, t.TempDir())
+
+ result := app.testProxySpeedWithConnector(
+ "proxy-1",
+ []BrowserProxy{{ProxyId: "proxy-1", ProxyConfig: proxyURL}},
+ config.BrowserConnectorXray,
+ )
+ if !result.Ok {
+ t.Fatalf("testProxySpeedWithConnector failed: %+v", result)
+ }
+ if result.Engine != "native" {
+ t.Fatalf("engine = %q, want native", result.Engine)
+ }
+ if requests.Load() != 2 {
+ t.Fatalf("requests = %d, want unified-delay HTTP request pair", requests.Load())
+ }
+}
+
+func TestProxySpeedBatchConcurrencyDefaultsAreConservative(t *testing.T) {
+ if defaultProxySpeedConcurrency != 5 {
+ t.Fatalf("defaultProxySpeedConcurrency = %d, want 5", defaultProxySpeedConcurrency)
+ }
+ if maxProxySpeedConcurrency != 10 {
+ t.Fatalf("maxProxySpeedConcurrency = %d, want 10", maxProxySpeedConcurrency)
+ }
+}
+
+func TestProxySpeedWithXrayUsesSingBoxProtocolPath(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.DefaultConfig()
+ app := NewApp(t.TempDir())
+ app.config = cfg
+ app.browserMgr = browser.NewManager(cfg, t.TempDir())
+
+ result := app.testProxySpeedWithConnector(
+ "hy2-proxy",
+ []BrowserProxy{{ProxyId: "hy2-proxy", ProxyConfig: "hysteria2://pass@example.com:443?sni=example.com"}},
+ config.BrowserConnectorXray,
+ )
+ if result.Ok {
+ t.Fatalf("hysteria2 speed test should fail without sing-box manager: %+v", result)
+ }
+ if result.Engine != "sing-box" {
+ t.Fatalf("engine = %q, want sing-box", result.Engine)
+ }
+ if !strings.Contains(result.Error, "sing-box 管理器未初始化") {
+ t.Fatalf("error = %q, want sing-box manager guidance", result.Error)
+ }
+}
+
+func startBackendDelayedHTTPProxy(t *testing.T, delay time.Duration, requests *atomic.Int32) (string, func()) {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen failed: %v", err)
+ }
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ go handleBackendDelayedHTTPProxyConn(conn, delay, requests)
+ }
+ }()
+ return "http://" + listener.Addr().String(), func() {
+ _ = listener.Close()
+ <-done
+ }
+}
+
+func handleBackendDelayedHTTPProxyConn(conn net.Conn, delay time.Duration, requests *atomic.Int32) {
+ defer conn.Close()
+ reader := bufio.NewReader(conn)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ for {
+ header, err := reader.ReadString('\n')
+ if err != nil || strings.TrimSpace(header) == "" {
+ break
+ }
+ }
+ if strings.HasPrefix(line, "HEAD ") {
+ requests.Add(1)
+ time.Sleep(delay)
+ _, _ = fmt.Fprint(conn, "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
+ }
+}
diff --git a/backend/app_proxy_import.go b/backend/app_proxy_import.go
index 329efd00..d6a33227 100644
--- a/backend/app_proxy_import.go
+++ b/backend/app_proxy_import.go
@@ -1,11 +1,14 @@
package backend
import (
+ "ant-chrome/backend/internal/config"
+ "ant-chrome/backend/internal/proxy"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
+ "strconv"
"strings"
"time"
@@ -26,10 +29,20 @@ var clashSubscriptionUserAgents = []string{
// BrowserProxyFetchClashByURL 拉取 Clash 订阅 URL,并返回可直接导入的 YAML 文本与建议配置。
func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}, error) {
+ return a.browserProxyFetchClashByURL(rawURL, "")
+}
+
+// BrowserProxyFetchClashByURLWithProxy 按当前连接栈使用指定代理拉取 Clash 订阅。
+func (a *App) BrowserProxyFetchClashByURLWithProxy(rawURL string, proxyID string) (map[string]interface{}, error) {
+ return a.browserProxyFetchClashByURL(rawURL, proxyID)
+}
+
+func (a *App) browserProxyFetchClashByURL(rawURL string, proxyID string) (map[string]interface{}, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("订阅 URL 不能为空")
}
+ proxyID = strings.TrimSpace(proxyID)
parsedURL, err := url.Parse(rawURL)
if err != nil || parsedURL.Host == "" {
@@ -40,8 +53,13 @@ func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}
return nil, fmt.Errorf("仅支持 http/https URL")
}
- client := &http.Client{
- Timeout: clashSubscriptionTimeout,
+ client := &http.Client{Timeout: clashSubscriptionTimeout}
+ if proxyID != "" {
+ proxyClient, err := a.clashSubscriptionProxyClient(proxyID)
+ if err != nil {
+ return nil, err
+ }
+ client = proxyClient
}
content, payload, err := fetchClashSubscriptionWithFallback(client, parsedURL.String())
if err != nil {
@@ -65,6 +83,25 @@ func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}
}, nil
}
+func (a *App) clashSubscriptionProxyClient(proxyID string) (*http.Client, error) {
+ if a == nil || a.config == nil {
+ return nil, fmt.Errorf("代理拉取需要应用配置已初始化")
+ }
+ proxies := a.getLatestProxies()
+ found := false
+ for _, item := range proxies {
+ if strings.EqualFold(strings.TrimSpace(item.ProxyId), proxyID) && strings.TrimSpace(item.ProxyConfig) != "" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return nil, fmt.Errorf("拉取代理不存在或配置为空")
+ }
+ connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
+ return proxy.BuildProxyHTTPClient("", proxyID, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, clashSubscriptionTimeout)
+}
+
func fetchClashSubscriptionWithFallback(client *http.Client, targetURL string) (string, interface{}, error) {
var lastErr error
for _, userAgent := range clashSubscriptionUserAgents {
@@ -144,7 +181,141 @@ func normalizeClashSubscriptionContent(body []byte) (string, interface{}, error)
}
}
- return "", nil, fmt.Errorf("URL 内容不是有效 Clash YAML(需包含 proxies)")
+ for _, text := range tryTexts {
+ converted, ok := convertProxyURIListToClashYAML(text)
+ if !ok {
+ continue
+ }
+ payload, parsed := parseClashPayload(converted)
+ if parsed && clashProxyCount(payload) > 0 {
+ return converted, payload, nil
+ }
+ }
+
+ return "", nil, fmt.Errorf("URL 内容不是有效 Clash YAML 或 URI 订阅(需包含 proxies 或支持的代理 URI)")
+}
+
+func convertProxyURIListToClashYAML(text string) (string, bool) {
+ lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
+ proxies := make([]map[string]interface{}, 0)
+ for index, line := range lines {
+ raw := strings.TrimSpace(line)
+ if raw == "" {
+ continue
+ }
+ node, ok := proxyURIToClashNode(raw, index)
+ if ok {
+ proxies = append(proxies, node)
+ }
+ }
+ if len(proxies) == 0 {
+ return "", false
+ }
+ payload := map[string]interface{}{"proxies": proxies}
+ data, err := yaml.Marshal(payload)
+ if err != nil {
+ return "", false
+ }
+ return strings.TrimSpace(string(data)), true
+}
+
+func proxyURIToClashNode(raw string, index int) (map[string]interface{}, bool) {
+ u, err := url.Parse(raw)
+ if err != nil || strings.TrimSpace(u.Scheme) == "" || strings.TrimSpace(u.Hostname()) == "" {
+ return nil, false
+ }
+ scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
+ port := 0
+ if portText := strings.TrimSpace(u.Port()); portText != "" {
+ if parsedPort, err := strconv.Atoi(portText); err == nil {
+ port = parsedPort
+ }
+ }
+ if port == 0 {
+ return nil, false
+ }
+ name := proxyURIName(u, index)
+ switch scheme {
+ case "anytls":
+ password := u.User.Username()
+ if password == "" {
+ return nil, false
+ }
+ node := map[string]interface{}{
+ "name": name,
+ "type": "anytls",
+ "server": u.Hostname(),
+ "port": port,
+ "password": password,
+ }
+ q := u.Query()
+ if sni := firstNonEmptyQueryParam(q, "sni", "peer", "servername"); sni != "" {
+ node["sni"] = sni
+ }
+ if uriBoolParam(q, "insecure", "allowInsecure", "skip-cert-verify") {
+ node["skip-cert-verify"] = true
+ }
+ if fp := firstNonEmptyQueryParam(q, "client-fingerprint", "fingerprint", "fp"); fp != "" {
+ node["client-fingerprint"] = fp
+ }
+ return node, true
+ case "trojan":
+ password := u.User.Username()
+ if password == "" {
+ return nil, false
+ }
+ node := map[string]interface{}{
+ "name": name,
+ "type": "trojan",
+ "server": u.Hostname(),
+ "port": port,
+ "password": password,
+ }
+ q := u.Query()
+ if sni := firstNonEmptyQueryParam(q, "sni", "peer", "servername"); sni != "" {
+ node["sni"] = sni
+ }
+ if network := firstNonEmptyQueryParam(q, "type", "network"); network != "" {
+ node["network"] = network
+ }
+ if uriBoolParam(q, "insecure", "allowInsecure", "skip-cert-verify") {
+ node["skip-cert-verify"] = true
+ }
+ return node, true
+ default:
+ return nil, false
+ }
+}
+
+func proxyURIName(u *url.URL, index int) string {
+ if u.Fragment != "" {
+ if name, err := url.QueryUnescape(u.Fragment); err == nil && strings.TrimSpace(name) != "" {
+ return strings.TrimSpace(name)
+ }
+ if strings.TrimSpace(u.Fragment) != "" {
+ return strings.TrimSpace(u.Fragment)
+ }
+ }
+ return fmt.Sprintf("导入代理 %d", index+1)
+}
+
+func firstNonEmptyQueryParam(q url.Values, keys ...string) string {
+ for _, key := range keys {
+ if value := strings.TrimSpace(q.Get(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func uriBoolParam(q url.Values, keys ...string) bool {
+ for _, key := range keys {
+ value := strings.ToLower(strings.TrimSpace(q.Get(key)))
+ if value == "1" || value == "true" || value == "yes" {
+ return true
+ }
+ }
+ return false
}
func decodeBase64Text(raw string) (string, bool) {
diff --git a/backend/app_proxy_import_test.go b/backend/app_proxy_import_test.go
index ed26e3ca..5ca4eb87 100644
--- a/backend/app_proxy_import_test.go
+++ b/backend/app_proxy_import_test.go
@@ -1,6 +1,7 @@
package backend
import (
+ "encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
@@ -99,3 +100,22 @@ func TestBrowserProxyFetchClashByURLAllFallbackErrorsHideURL(t *testing.T) {
}
}
}
+
+func TestNormalizeClashSubscriptionContentSupportsBase64URIList(t *testing.T) {
+ raw := "anytls://secret@example.com:443?sni=sni.example.com&insecure=1#AnyTLS%20Node\n" +
+ "trojan://pass@trojan.example.com:8443?sni=trojan-sni.example.com#Trojan%20Node"
+ encoded := base64.StdEncoding.EncodeToString([]byte(raw))
+ content, payload, err := normalizeClashSubscriptionContent([]byte(encoded))
+ if err != nil {
+ t.Fatalf("normalizeClashSubscriptionContent returned error: %v", err)
+ }
+ if count := clashProxyCount(payload); count != 2 {
+ t.Fatalf("proxy count = %d, want 2", count)
+ }
+ if !strings.Contains(content, "type: anytls") {
+ t.Fatalf("content does not contain anytls node: %s", content)
+ }
+ if !strings.Contains(content, "type: trojan") {
+ t.Fatalf("content does not contain trojan node: %s", content)
+ }
+}
diff --git a/backend/app_proxy_query.go b/backend/app_proxy_query.go
index ea2f9ba4..617ba877 100644
--- a/backend/app_proxy_query.go
+++ b/backend/app_proxy_query.go
@@ -37,15 +37,19 @@ func (a *App) ValidateProxyConfig(proxyConfig string, proxyId string) ProxyValid
func (a *App) TestProxyConnectivity(proxyId string, proxyConfig string) ProxyTestResult {
proxies := a.getLatestProxies()
result := proxy.TestConnectivity(proxyId, proxyConfig, proxies, nil)
- return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error}
+ if result.Engine == "" {
+ result.Engine = "tcp"
+ }
+ return buildProxyTestResult(result)
}
// TestProxyRealConnectivity 通过真实 HTTP 请求测试代理连通性(Wails 绑定)
// 参考 Clash URLTest 策略:多 URL fallback + 复用桥接 + TCP ping 降级
func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult {
proxies := a.getLatestProxies()
- result := proxy.TestRealConnectivityWithRuntimeConfig(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType), nil)
- return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error}
+ connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
+ result := proxy.TestRealConnectivityWithRuntimeConfig(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, a.proxySpeedTestConfig())
+ return buildProxyTestResult(result)
}
// BrowserProxyWarmupBridge 只预热本地代理桥接,不执行外网测速。
@@ -106,43 +110,50 @@ func (a *App) warmupProxyBridge(proxyId string, proxyConfig string, proxies []Br
result.Error = "代理配置为空"
return result
}
- if strings.EqualFold(src, "direct://") {
- result.Ok = true
- result.Engine = "direct"
+
+ resolution, err := proxy.ResolveProxyKernel(src, proxies, proxyId, "")
+ result.Engine = resolution.Kernel
+ if err != nil {
+ result.Error = err.Error()
result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
- if !proxy.RequiresBridge(src, proxies, proxyId) && !proxy.RequiresLocalProxyBridgeForBrowser(src) && !proxy.IsSingBoxProtocol(src) {
+ if resolution.Kernel == proxy.ProxyKernelNative {
result.Ok = true
- result.Engine = "none"
+ if strings.EqualFold(src, "direct://") {
+ result.Engine = "direct"
+ }
result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
var socksURL string
- var err error
- connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
- if connectorType == config.BrowserConnectorMihomo {
- result.Engine = "mihomo"
+ switch resolution.Kernel {
+ case proxy.ProxyKernelMihomo:
if a.clashMgr == nil {
- result.Error = "mihomo 管理器不可用"
+ result.Error = "mihomo 管理器不可用,请先下载 Mihomo 内核"
+ result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
socksURL, err = a.clashMgr.EnsureNodeBridge(src, proxies, proxyId)
- } else if proxy.IsSingBoxProtocol(src) {
- result.Engine = "sing-box"
+ case proxy.ProxyKernelSingBox:
if a.singboxMgr == nil {
result.Error = "sing-box 管理器不可用"
+ result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
socksURL, err = a.singboxMgr.EnsureBridge(src, proxies, proxyId)
- } else {
- result.Engine = "xray"
+ case proxy.ProxyKernelXray:
if a.xrayMgr == nil {
result.Error = "xray 管理器不可用"
+ result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
socksURL, err = a.xrayMgr.EnsureBridge(src, proxies, proxyId)
+ default:
+ result.Error = "无法选择代理内核"
+ result.LatencyMs = time.Since(startedAt).Milliseconds()
+ return result
}
result.LatencyMs = time.Since(startedAt).Milliseconds()
if err != nil {
diff --git a/backend/app_proxy_types.go b/backend/app_proxy_types.go
index 380f8568..ced04166 100644
--- a/backend/app_proxy_types.go
+++ b/backend/app_proxy_types.go
@@ -16,9 +16,20 @@ type ProxyTestResult struct {
ProxyId string `json:"proxyId"`
Ok bool `json:"ok"`
LatencyMs int64 `json:"latencyMs"`
+ Engine string `json:"engine"`
Error string `json:"error"`
}
+func buildProxyTestResult(result proxy.TestResult) ProxyTestResult {
+ return ProxyTestResult{
+ ProxyId: result.ProxyId,
+ Ok: result.Ok,
+ LatencyMs: result.LatencyMs,
+ Engine: result.Engine,
+ Error: result.Error,
+ }
+}
+
type ProxyBrowserProbeRequest struct {
ProxyId string `json:"proxyId"`
URLs []string `json:"urls"`
diff --git a/backend/app_proxy_warmup_test.go b/backend/app_proxy_warmup_test.go
index 9e267531..713360f0 100644
--- a/backend/app_proxy_warmup_test.go
+++ b/backend/app_proxy_warmup_test.go
@@ -29,8 +29,8 @@ func TestWarmupProxyBridgeStandardProxyDoesNotRequireBridge(t *testing.T) {
if !result.Ok {
t.Fatalf("standard proxy warmup failed: %s", result.Error)
}
- if result.Engine != "none" {
- t.Fatalf("engine = %q, want none", result.Engine)
+ if result.Engine != "native" {
+ t.Fatalf("engine = %q, want native", result.Engine)
}
}
diff --git a/backend/app_shutdown.go b/backend/app_shutdown.go
index a6a11203..8bd2e4a7 100644
--- a/backend/app_shutdown.go
+++ b/backend/app_shutdown.go
@@ -91,7 +91,7 @@ func (a *App) stopRuntimeServices() {
if a.xrayMgr != nil {
a.xrayMgr.StopAll()
}
- a.clearProfileXrayBridges()
+ a.clearProfileProxyBridges()
if a.clashMgr != nil {
a.clashMgr.StopAll()
}
diff --git a/backend/app_startup.go b/backend/app_startup.go
index 294367d8..8b43c462 100644
--- a/backend/app_startup.go
+++ b/backend/app_startup.go
@@ -137,6 +137,9 @@ func (a *App) startupInitManagers(cfg *config.Config, db *database.DB) {
a.migrateToSQLite()
a.browserMgr.InitData()
+ if err := a.browserMgr.CleanupExpiredTrash(); err != nil {
+ logger.New("Browser").Error("启动清理回收站失败", logger.F("error", err))
+ }
a.autoDetectCores()
a.loadProxies()
a.reconcileProfileProxyBindings()
@@ -203,7 +206,8 @@ func (a *App) startupInitSpeedScheduler() {
a.speedScheduler = browser.NewProxySpeedScheduler(
a.browserMgr.ProxyDAO,
func(proxyId string) (bool, int64, string) {
- r := proxy.TestRealConnectivityWithConfig(proxyId, a.config.Browser.Proxies, a.xrayMgr, a.singboxMgr, nil)
+ connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
+ r := a.testProxySpeedWithConnector(proxyId, a.getLatestProxies(), connectorType)
return r.Ok, r.LatencyMs, r.Error
},
5*time.Minute,
diff --git a/backend/automation_script_run_integration_test.go b/backend/automation_script_run_integration_test.go
index ece7cc8c..3b00ab2e 100644
--- a/backend/automation_script_run_integration_test.go
+++ b/backend/automation_script_run_integration_test.go
@@ -320,7 +320,7 @@ func TestAutomationScriptRunWithOptionsAllowsSameScriptOnDifferentProfiles(t *te
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
- ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok' }\n}\n",
+ ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 120))\n return { ok: true, summary: 'slow ok' }\n}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
@@ -376,7 +376,7 @@ func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *te
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
- ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok a' }\n}\n",
+ ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 120))\n return { ok: true, summary: 'slow ok a' }\n}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
@@ -387,7 +387,7 @@ func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *te
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
- ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok b' }\n}\n",
+ ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 120))\n return { ok: true, summary: 'slow ok b' }\n}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
diff --git a/backend/internal/automation/assets/runner.cjs b/backend/internal/automation/assets/runner.cjs
index 53cbd6ac..cfd181de 100644
--- a/backend/internal/automation/assets/runner.cjs
+++ b/backend/internal/automation/assets/runner.cjs
@@ -15,6 +15,58 @@ const { loadScriptModule } = require('./runner_script_loader.cjs');
const ALLOWED_WAIT_UNTIL = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']);
+function getPageURL(page) {
+ if (!page || typeof page.url !== 'function') {
+ return '';
+ }
+ try {
+ return String(page.url() || '').trim();
+ } catch {
+ return '';
+ }
+}
+
+function normalizeComparableURL(value) {
+ const text = String(value || '').trim();
+ if (!text) {
+ return '';
+ }
+ if (text === 'about:blank') {
+ return text;
+ }
+ try {
+ return new URL(text).toString();
+ } catch {
+ return text;
+ }
+}
+
+function shouldReuseExistingPageByDefault(page, targetURL) {
+ const currentURL = normalizeComparableURL(getPageURL(page));
+ if (!currentURL || currentURL === 'about:blank') {
+ return true;
+ }
+ const nextURL = normalizeComparableURL(targetURL);
+ return nextURL !== '' && currentURL === nextURL;
+}
+
+function hasOpenPageIntent(options) {
+ const openOptions = options && typeof options === 'object' && !Array.isArray(options) ? options : {};
+ if (String(openOptions.url || '').trim()) {
+ return true;
+ }
+ if (openOptions.permissions !== undefined) {
+ return true;
+ }
+ if (typeof openOptions.permissionOrigin === 'string' && openOptions.permissionOrigin.trim()) {
+ return true;
+ }
+ if (openOptions.reuseCurrentPage === true || openOptions.bringToFront === true) {
+ return true;
+ }
+ return false;
+}
+
function buildLaunchRequestBody(defaultSelector, options) {
const launchOptions = options && typeof options === 'object' ? options : {};
const body = {};
@@ -51,6 +103,10 @@ function buildLaunchRequestBody(defaultSelector, options) {
body.selector = selector;
}
+ if (!Object.prototype.hasOwnProperty.call(body, 'skipDefaultStartUrls')) {
+ body.skipDefaultStartUrls = true;
+ }
+
return body;
}
@@ -259,22 +315,28 @@ async function runScriptTask(payload, chromium) {
options && typeof options === 'object' && !Array.isArray(options) ? options : {};
const { browser, context } = await resolveConnectionContext(connection);
const shouldReuseCurrentPage = openOptions.reuseCurrentPage === true;
+ const hasReuseCurrentPageOption = Object.prototype.hasOwnProperty.call(
+ openOptions,
+ 'reuseCurrentPage'
+ );
+ const targetURL = String(openOptions.url || '').trim();
let page = null;
+ const currentPage = connection && connection.page ? connection.page : null;
if (
- shouldReuseCurrentPage &&
- connection &&
- connection.page &&
- typeof connection.page.isClosed === 'function' &&
- !connection.page.isClosed()
+ currentPage &&
+ typeof currentPage.isClosed === 'function' &&
+ !currentPage.isClosed() &&
+ (shouldReuseCurrentPage ||
+ (!hasReuseCurrentPageOption && shouldReuseExistingPageByDefault(currentPage, targetURL)))
) {
- page = connection.page;
+ page = currentPage;
}
- if (!page) {
+ if (!page && targetURL) {
page = await context.newPage();
}
- if (typeof page.bringToFront === 'function' && openOptions.bringToFront !== false) {
+ if (page && typeof page.bringToFront === 'function' && openOptions.bringToFront !== false) {
await page.bringToFront().catch(() => {});
}
@@ -294,7 +356,6 @@ async function runScriptTask(payload, chromium) {
reason: '',
};
- const targetURL = String(openOptions.url || '').trim();
if (targetURL) {
const waitUntil = ALLOWED_WAIT_UNTIL.has(String(openOptions.waitUntil || '').trim())
? String(openOptions.waitUntil).trim()
@@ -368,7 +429,20 @@ async function runScriptTask(payload, chromium) {
const session = await launch(launchOptions);
const connection = await connect(session, connectOptions);
- const opened = await openPage(connection, openOptions);
+ const opened = hasOpenPageIntent(openOptions)
+ ? await openPage(connection, openOptions)
+ : {
+ browser: connection.browser,
+ context: connection.context,
+ page: connection.page || null,
+ permissionResult: {
+ applied: false,
+ permissions: [],
+ origin: '',
+ reason: '',
+ },
+ reusedPage: Boolean(connection.page),
+ };
return {
session,
connection,
diff --git a/backend/internal/automation/demo-library/news-query-txt/index.cjs b/backend/internal/automation/demo-library/news-query-txt/index.cjs
index 1a9e0a8d..b1f7356e 100644
--- a/backend/internal/automation/demo-library/news-query-txt/index.cjs
+++ b/backend/internal/automation/demo-library/news-query-txt/index.cjs
@@ -14,7 +14,7 @@ const {
pickBestAttempt,
} = require('./news-query-utils.cjs')
-module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => {
+module.exports.run = async ({ launch, connect, openPage, selector, params, log, artifact }) => {
const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000)
const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1500, 0, 10000)
const limit = normalizeInt(params.limit, 10, 1, 50)
@@ -36,9 +36,12 @@ module.exports.run = async ({ launch, connect, selector, params, log, artifact }
})
const connection = await connect(session)
- const browser = connection.browser
- const context = connection.context || browser.contexts()[0]
- const page = await context.newPage()
+ const opened = await openPage(connection, {
+ reuseCurrentPage: true,
+ bringToFront: true,
+ timeoutMs: timeout,
+ })
+ const page = opened.page
const closeRunnerPage = async function () {
if (!page.isClosed()) {
await page.close().catch(function () {})
diff --git a/backend/internal/automation/task_runner_launch_test.go b/backend/internal/automation/task_runner_launch_test.go
index eff8d369..04dfa2b5 100644
--- a/backend/internal/automation/task_runner_launch_test.go
+++ b/backend/internal/automation/task_runner_launch_test.go
@@ -108,6 +108,219 @@ func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) {
}
}
+func TestRunScriptTaskLaunchSkipsDefaultStartUrlsByDefault(t *testing.T) {
+ nodeExecPath := lookupNodeExecutable(t)
+
+ cfg := config.DefaultConfig()
+ cfg.Automation.Enabled = true
+ cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
+ cfg.Automation.SystemNodePath = nodeExecPath
+ cfg.Automation.NodeVersion = "test-node"
+ cfg.Automation.PlaywrightCoreVersion = "1.59.0"
+ cfg.Automation.RuntimeVersion = "test-runtime"
+
+ manager := NewManager(t.TempDir(), cfg, nil, Options{})
+ state := manager.CurrentState()
+ if err := writeRunnerScript(state.RunnerPath); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+ if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ type launchRequestPayload struct {
+ SkipDefaultStartURLs *bool `json:"skipDefaultStartUrls"`
+ }
+ receivedBody := launchRequestPayload{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/launch" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil {
+ t.Fatalf("decode launch request body failed: %v", err)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-script",
+ "debugPort": 9333,
+ "cdpUrl": "http://127.0.0.1:9333",
+ })
+ }))
+ defer server.Close()
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-launch-default-skip.cjs")
+ scriptSource := `module.exports.run = async ({ launch, selector }) => {
+ await launch({ selector })
+ return { ok: true, summary: '脚本执行成功' }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:launch-default-skip",
+ ScriptPath: scriptPath,
+ Selector: map[string]any{"code": "DEMO_READY"},
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+ if receivedBody.SkipDefaultStartURLs == nil || *receivedBody.SkipDefaultStartURLs != true {
+ t.Fatalf("expected skipDefaultStartUrls default true, got %+v", receivedBody)
+ }
+}
+
+func TestRunScriptTaskUseBrowserWithoutURLDoesNotCreateBlankPage(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)
+ }
+
+ markerPath := filepath.Join(t.TempDir(), "new-page-count.txt")
+ if err := writeMockPlaywrightModuleCountingNewPages(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, markerPath); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-script-no-blank",
+ "debugPort": 9333,
+ "cdpUrl": "http://127.0.0.1:9333",
+ })
+ }))
+ defer server.Close()
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-no-blank.cjs")
+ scriptSource := `module.exports.run = async ({ useBrowser, selector }) => {
+ const runtime = await useBrowser({ selector })
+ return { ok: true, summary: runtime.page ? runtime.page.url() : 'no-page' }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:no-blank",
+ ScriptPath: scriptPath,
+ Selector: map[string]any{"code": "DEMO_READY"},
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+ if result.Summary != "no-page" {
+ t.Fatalf("summary = %q, want no-page", result.Summary)
+ }
+ if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
+ if err != nil {
+ t.Fatalf("stat marker failed: %v", err)
+ }
+ data, _ := os.ReadFile(markerPath)
+ t.Fatalf("context.newPage should not be called, marker=%q", string(data))
+ }
+}
+
+func TestRunScriptTaskOpenPageWithoutURLDoesNotCreateBlankPage(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)
+ }
+
+ markerPath := filepath.Join(t.TempDir(), "new-page-count.txt")
+ if err := writeMockPlaywrightModuleCountingNewPages(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, markerPath); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-script-open-no-url",
+ "debugPort": 9333,
+ "cdpUrl": "http://127.0.0.1:9333",
+ })
+ }))
+ defer server.Close()
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-open-page-no-url.cjs")
+ scriptSource := `module.exports.run = async ({ launch, connect, openPage, selector }) => {
+ const session = await launch({ selector })
+ const connection = await connect(session)
+ const opened = await openPage(connection, { reuseCurrentPage: true })
+ return { ok: true, summary: opened.page ? opened.page.url() : 'no-page' }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:open-page-no-url",
+ ScriptPath: scriptPath,
+ Selector: map[string]any{"code": "DEMO_READY"},
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+ if result.Summary != "no-page" {
+ t.Fatalf("summary = %q, want no-page", result.Summary)
+ }
+ if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
+ if err != nil {
+ t.Fatalf("stat marker failed: %v", err)
+ }
+ data, _ := os.ReadFile(markerPath)
+ t.Fatalf("context.newPage should not be called, marker=%q", string(data))
+ }
+}
+
func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) {
nodeExecPath := lookupNodeExecutable(t)
diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go
index e1c9d71c..b635bc16 100644
--- a/backend/internal/automation/task_runner_test.go
+++ b/backend/internal/automation/task_runner_test.go
@@ -150,7 +150,7 @@ module.exports.run = async ({ launch, connect, selector, params, log, artifact }
}
}
-func TestRunScriptTaskOpenPageCreatesFreshPageAndGrantsPermissions(t *testing.T) {
+func TestRunScriptTaskOpenPageReusesInitialBlankPageAndGrantsPermissions(t *testing.T) {
nodeExecPath := lookupNodeExecutable(t)
cfg := config.DefaultConfig()
@@ -243,8 +243,8 @@ func TestRunScriptTaskOpenPageCreatesFreshPageAndGrantsPermissions(t *testing.T)
if parsed["permissionOrigin"] != "https://example.com" {
t.Fatalf("unexpected permissionOrigin: %+v", parsed)
}
- if parsed["reusedPage"] != false {
- t.Fatalf("expected reusedPage to be false, got %+v", parsed)
+ if parsed["reusedPage"] != true {
+ t.Fatalf("expected reusedPage to be true, got %+v", parsed)
}
if parsed["url"] != "https://example.com/inbox" {
t.Fatalf("unexpected url: %+v", parsed)
diff --git a/backend/internal/automation/task_runner_test_helpers_test.go b/backend/internal/automation/task_runner_test_helpers_test.go
index 28cb6464..d34a8d11 100644
--- a/backend/internal/automation/task_runner_test_helpers_test.go
+++ b/backend/internal/automation/task_runner_test_helpers_test.go
@@ -43,6 +43,63 @@ func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expe
return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true)
}
+func writeMockPlaywrightModuleCountingNewPages(runtimeDir, version, markerPath string) error {
+ moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
+ if err := os.MkdirAll(moduleDir, 0o755); err != nil {
+ return err
+ }
+
+ packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version)
+ if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil {
+ return err
+ }
+
+ markerPathJSON, err := json.Marshal(markerPath)
+ if err != nil {
+ return err
+ }
+
+ indexJS := fmt.Sprintf(`const fs = require('fs');
+
+const markerPath = %s;
+let newPageCount = 0;
+
+const context = {
+ async newPage() {
+ newPageCount += 1;
+ fs.writeFileSync(markerPath, String(newPageCount));
+ return {
+ async bringToFront() {},
+ async goto() {},
+ async waitForLoadState() {},
+ async waitForTimeout() {},
+ isClosed() {
+ return false;
+ },
+ url() {
+ return 'about:blank';
+ },
+ };
+ },
+ pages() {
+ return [];
+ },
+};
+
+exports.chromium = {
+ async connectOverCDP() {
+ return {
+ contexts() {
+ return [context];
+ },
+ async close() {},
+ };
+ },
+};
+`, string(markerPathJSON))
+ return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644)
+}
+
func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error {
moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
@@ -179,13 +236,15 @@ function createPage() {
};
}
+const initialPage = createPage();
+
const context = {
async grantPermissions() {},
async newPage() {
return createPage();
},
pages() {
- return [];
+ return [initialPage];
},
};
diff --git a/backend/internal/browser/extension_dao.go b/backend/internal/browser/extension_dao.go
index 0dcd99a0..10dd2352 100644
--- a/backend/internal/browser/extension_dao.go
+++ b/backend/internal/browser/extension_dao.go
@@ -17,6 +17,7 @@ type ExtensionDAO interface {
Delete(extensionID string) error
GetProfileSettings(profileID string) (ProfileExtensionSettings, error)
SetProfileSettings(profileID string, extensionIDs []string, configured bool) (ProfileExtensionSettings, error)
+ DeleteProfileSettings(profileID string) error
}
type SQLiteExtensionDAO struct {
@@ -175,6 +176,25 @@ func (d *SQLiteExtensionDAO) SetProfileSettings(profileID string, extensionIDs [
return d.GetProfileSettings(profileID)
}
+func (d *SQLiteExtensionDAO) DeleteProfileSettings(profileID string) error {
+ profileID = strings.TrimSpace(profileID)
+ if profileID == "" {
+ return nil
+ }
+ tx, err := d.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`DELETE FROM browser_profile_extensions WHERE profile_id = ?`, profileID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM browser_profile_extension_settings WHERE profile_id = ?`, profileID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
func (d *SQLiteExtensionDAO) listWhere(where string, args []any) ([]Extension, error) {
query := `
SELECT extension_id, name, version, description, icon_data_url, manifest_json, source_url, install_dir, enabled, installed_at, updated_at
diff --git a/backend/internal/browser/profile_dao.go b/backend/internal/browser/profile_dao.go
index a76d4a78..a13b44dc 100644
--- a/backend/internal/browser/profile_dao.go
+++ b/backend/internal/browser/profile_dao.go
@@ -11,9 +11,13 @@ import (
// ProfileDAO 实例配置持久化接口
type ProfileDAO interface {
List() ([]*Profile, error)
+ ListDeleted() ([]*Profile, error)
GetById(profileId string) (*Profile, error)
Upsert(profile *Profile) error
Delete(profileId string) error
+ SoftDelete(profileId string, deletedAt string) error
+ Restore(profileId string) error
+ ListExpiredDeleted(expiredBefore string) ([]*Profile, error)
}
// SQLiteProfileDAO 基于 SQLite 的 ProfileDAO 实现
@@ -34,8 +38,9 @@ func (d *SQLiteProfileDAO) List() ([]*Profile, error) {
COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''),
COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''),
launch_args,
- tags, keywords, group_id, created_at, updated_at
- FROM browser_profiles ORDER BY created_at ASC`)
+ tags, keywords, group_id, created_at, updated_at,
+ COALESCE(deleted_at, '')
+ FROM browser_profiles WHERE COALESCE(deleted_at, '') = '' ORDER BY created_at ASC`)
if err != nil {
return nil, fmt.Errorf("查询实例列表失败: %w", err)
}
@@ -52,6 +57,33 @@ func (d *SQLiteProfileDAO) List() ([]*Profile, error) {
return list, rows.Err()
}
+// ListDeleted 查询回收站实例,按删除时间倒序
+func (d *SQLiteProfileDAO) ListDeleted() ([]*Profile, error) {
+ rows, err := d.db.Query(`
+ SELECT profile_id, profile_name, user_data_dir, core_id,
+ fingerprint_args, proxy_id, proxy_config,
+ COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''),
+ COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''),
+ launch_args,
+ tags, keywords, group_id, created_at, updated_at,
+ COALESCE(deleted_at, '')
+ FROM browser_profiles WHERE COALESCE(deleted_at, '') != '' ORDER BY deleted_at DESC`)
+ if err != nil {
+ return nil, fmt.Errorf("查询回收站实例失败: %w", err)
+ }
+ defer rows.Close()
+
+ var list []*Profile
+ for rows.Next() {
+ p, err := scanProfile(rows)
+ if err != nil {
+ return nil, err
+ }
+ list = append(list, p)
+ }
+ return list, rows.Err()
+}
+
// GetById 根据 profileId 查询单个实例
func (d *SQLiteProfileDAO) GetById(profileId string) (*Profile, error) {
row := d.db.QueryRow(`
@@ -60,7 +92,8 @@ func (d *SQLiteProfileDAO) GetById(profileId string) (*Profile, error) {
COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''),
COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''),
launch_args,
- tags, keywords, group_id, created_at, updated_at
+ tags, keywords, group_id, created_at, updated_at,
+ COALESCE(deleted_at, '')
FROM browser_profiles WHERE profile_id = ?`, profileId)
p, err := scanProfile(row)
if errors.Is(err, sql.ErrNoRows) {
@@ -88,8 +121,8 @@ func (d *SQLiteProfileDAO) Upsert(profile *Profile) error {
INSERT INTO browser_profiles
(profile_id, profile_name, user_data_dir, core_id, fingerprint_args,
proxy_id, proxy_config, proxy_bind_source_id, proxy_bind_source_url, proxy_bind_name, proxy_bind_updated_at,
- launch_args, tags, keywords, group_id, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ launch_args, tags, keywords, group_id, created_at, updated_at, deleted_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_id) DO UPDATE SET
profile_name = excluded.profile_name,
user_data_dir = excluded.user_data_dir,
@@ -105,12 +138,13 @@ func (d *SQLiteProfileDAO) Upsert(profile *Profile) error {
tags = excluded.tags,
keywords = excluded.keywords,
group_id = excluded.group_id,
+ deleted_at = excluded.deleted_at,
updated_at = excluded.updated_at`,
profile.ProfileId, profile.ProfileName, profile.UserDataDir, profile.CoreId,
string(fingerprintArgs), profile.ProxyId, profile.ProxyConfig,
profile.ProxyBindSourceID, profile.ProxyBindSourceURL, profile.ProxyBindName, profile.ProxyBindUpdatedAt,
string(launchArgs), string(tags), string(keywords), profile.GroupId,
- profile.CreatedAt, profile.UpdatedAt,
+ profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt,
)
if err != nil {
return fmt.Errorf("保存实例配置失败: %w", err)
@@ -118,6 +152,63 @@ func (d *SQLiteProfileDAO) Upsert(profile *Profile) error {
return nil
}
+// SoftDelete 将实例移入回收站
+func (d *SQLiteProfileDAO) SoftDelete(profileId string, deletedAt string) error {
+ result, err := d.db.Exec(`UPDATE browser_profiles SET deleted_at = ?, updated_at = ? WHERE profile_id = ?`, deletedAt, deletedAt, profileId)
+ if err != nil {
+ return fmt.Errorf("移入回收站失败: %w", err)
+ }
+ if rows, _ := result.RowsAffected(); rows == 0 {
+ return fmt.Errorf("实例不存在: %s", profileId)
+ }
+ return nil
+}
+
+// Restore 从回收站恢复实例
+func (d *SQLiteProfileDAO) Restore(profileId string) error {
+ now := time.Now().Format(time.RFC3339)
+ result, err := d.db.Exec(`UPDATE browser_profiles SET deleted_at = '', updated_at = ? WHERE profile_id = ?`, now, profileId)
+ if err != nil {
+ return fmt.Errorf("恢复实例失败: %w", err)
+ }
+ if rows, _ := result.RowsAffected(); rows == 0 {
+ return fmt.Errorf("实例不存在: %s", profileId)
+ }
+ return nil
+}
+
+// ListExpiredDeleted 查询超过保留期的回收站实例
+func (d *SQLiteProfileDAO) ListExpiredDeleted(expiredBefore string) ([]*Profile, error) {
+ rows, err := d.db.Query(`
+ SELECT profile_id, profile_name, user_data_dir, core_id,
+ fingerprint_args, proxy_id, proxy_config,
+ COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''),
+ COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''),
+ launch_args,
+ tags, keywords, group_id, created_at, updated_at,
+ COALESCE(deleted_at, '')
+ FROM browser_profiles WHERE COALESCE(deleted_at, '') != '' AND deleted_at <= ?`, expiredBefore)
+ if err != nil {
+ return nil, fmt.Errorf("查询过期回收站实例失败: %w", err)
+ }
+ var expired []*Profile
+ for rows.Next() {
+ p, err := scanProfile(rows)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+ expired = append(expired, p)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return expired, nil
+}
+
// Delete 删除实例配置
func (d *SQLiteProfileDAO) Delete(profileId string) error {
_, err := d.db.Exec(`DELETE FROM browser_profiles WHERE profile_id = ?`, profileId)
@@ -152,8 +243,9 @@ func (d *SQLiteProfileDAO) ListByGroup(groupId string, includeChildren bool, chi
COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''),
COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''),
launch_args,
- tags, keywords, group_id, created_at, updated_at
- FROM browser_profiles WHERE group_id IN (%s) ORDER BY created_at ASC`, inClause), args...)
+ tags, keywords, group_id, created_at, updated_at,
+ COALESCE(deleted_at, '')
+ FROM browser_profiles WHERE COALESCE(deleted_at, '') = '' AND group_id IN (%s) ORDER BY created_at ASC`, inClause), args...)
} else {
// 仅查询指定分组
rows, err = d.db.Query(`
@@ -162,8 +254,9 @@ func (d *SQLiteProfileDAO) ListByGroup(groupId string, includeChildren bool, chi
COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''),
COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''),
launch_args,
- tags, keywords, group_id, created_at, updated_at
- FROM browser_profiles WHERE group_id = ? ORDER BY created_at ASC`, groupId)
+ tags, keywords, group_id, created_at, updated_at,
+ COALESCE(deleted_at, '')
+ FROM browser_profiles WHERE COALESCE(deleted_at, '') = '' AND group_id = ? ORDER BY created_at ASC`, groupId)
}
if err != nil {
@@ -219,7 +312,7 @@ func scanProfile(s scanner) (*Profile, error) {
&fingerprintArgsJSON, &p.ProxyId, &p.ProxyConfig,
&p.ProxyBindSourceID, &p.ProxyBindSourceURL, &p.ProxyBindName, &p.ProxyBindUpdatedAt,
&launchArgsJSON, &tagsJSON, &keywordsJSON, &p.GroupId,
- &p.CreatedAt, &p.UpdatedAt,
+ &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
)
if err != nil {
return nil, err
diff --git a/backend/internal/browser/profile_delete.go b/backend/internal/browser/profile_delete.go
index bd845e98..cd98cae7 100644
--- a/backend/internal/browser/profile_delete.go
+++ b/backend/internal/browser/profile_delete.go
@@ -6,9 +6,12 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
)
-// Delete 删除配置
+const profileTrashRetention = 72 * time.Hour
+
+// Delete 将配置移入回收站
func (m *Manager) Delete(profileId string) error {
log := logger.New("Browser")
m.InitData()
@@ -20,28 +23,203 @@ func (m *Manager) Delete(profileId string) error {
log.Error("浏览器配置不存在", logger.F("profile_id", profileId))
return fmt.Errorf("profile not found")
}
- userDataDir := m.ResolveUserDataDir(profile)
- delete(m.Profiles, profileId)
- log.Info("浏览器配置删除", logger.F("profile_id", profileId))
-
+ deletedAt := time.Now().Format(time.RFC3339)
if m.ProfileDAO != nil {
- if err := m.ProfileDAO.Delete(profileId); err != nil {
- log.Error("数据库删除实例失败", logger.F("profile_id", profileId), logger.F("error", err))
+ if err := m.ProfileDAO.SoftDelete(profileId, deletedAt); err != nil {
+ log.Error("数据库移入回收站失败", logger.F("profile_id", profileId), logger.F("error", err))
return err
}
} else {
+ profile.DeletedAt = deletedAt
+ profile.UpdatedAt = deletedAt
+ delete(m.Profiles, profileId)
if err := m.SaveProfiles(); err != nil {
return err
}
}
+ profile.DeletedAt = deletedAt
+ profile.UpdatedAt = deletedAt
+ delete(m.Profiles, profileId)
+ log.Info("浏览器配置移入回收站", logger.F("profile_id", profileId))
- if m.CodeProvider != nil {
- _ = m.CodeProvider.Remove(profileId)
+ return nil
+}
+
+// ListDeleted 获取回收站实例
+func (m *Manager) ListDeleted() []Profile {
+ log := logger.New("Browser")
+ m.InitData()
+ m.Mutex.Lock()
+ defer m.Mutex.Unlock()
+ m.cleanupExpiredTrashLocked(log)
+ if m.ProfileDAO == nil {
+ return []Profile{}
}
- if err := m.deleteProfileUserDataDir(userDataDir); err != nil {
- log.Error("删除实例数据目录失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err))
+ profiles, err := m.ProfileDAO.ListDeleted()
+ if err != nil {
+ log.Error("查询回收站实例失败", logger.F("error", err))
+ return []Profile{}
+ }
+ list := make([]Profile, 0, len(profiles))
+ for _, profile := range profiles {
+ p := *profile
+ if m.CodeProvider != nil {
+ if code, err := m.CodeProvider.EnsureCode(p.ProfileId); err == nil {
+ p.LaunchCode = code
+ }
+ }
+ list = append(list, p)
+ }
+ return list
+}
+
+// Restore 从回收站恢复实例
+func (m *Manager) Restore(profileId string) (*Profile, error) {
+ log := logger.New("Browser")
+ m.InitData()
+ m.Mutex.Lock()
+ defer m.Mutex.Unlock()
+ if m.ProfileDAO == nil {
+ return nil, fmt.Errorf("当前环境不支持回收站恢复")
+ }
+ profile, err := m.ProfileDAO.GetById(profileId)
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(profile.DeletedAt) == "" {
+ return nil, fmt.Errorf("实例不在回收站")
+ }
+ if err := m.ProfileDAO.Restore(profileId); err != nil {
+ return nil, err
+ }
+ profile.DeletedAt = ""
+ profile.UpdatedAt = time.Now().Format(time.RFC3339)
+ profile.CoreId = normalizeProfileCoreID(profile.CoreId)
+ m.Profiles[profile.ProfileId] = profile
+ log.Info("实例已从回收站恢复", logger.F("profile_id", profileId))
+ return profile, nil
+}
+
+// PermanentlyDelete 从回收站彻底删除实例及其关联数据
+func (m *Manager) PermanentlyDelete(profileId string) error {
+ log := logger.New("Browser")
+ m.InitData()
+ m.Mutex.Lock()
+ defer m.Mutex.Unlock()
+ if m.ProfileDAO == nil {
+ return fmt.Errorf("当前环境不支持回收站物理删除")
+ }
+ profile, err := m.ProfileDAO.GetById(profileId)
+ if err != nil {
return err
}
+ if strings.TrimSpace(profile.DeletedAt) == "" {
+ return fmt.Errorf("只能彻底删除回收站内的实例")
+ }
+ if err := m.deleteProfileRelatedDataLocked(log, profile); err != nil {
+ return err
+ }
+ if err := m.ProfileDAO.Delete(profileId); err != nil {
+ return err
+ }
+ log.Info("回收站实例已彻底删除", logger.F("profile_id", profileId))
+ return nil
+}
+
+// CleanupExpiredTrash 清理超过保留期的回收站实例
+func (m *Manager) CleanupExpiredTrash() error {
+ log := logger.New("Browser")
+ m.InitData()
+ m.Mutex.Lock()
+ defer m.Mutex.Unlock()
+ return m.cleanupExpiredTrashLocked(log)
+}
+
+func (m *Manager) cleanupExpiredTrashLocked(log *logger.Logger) error {
+ if m.ProfileDAO == nil {
+ return nil
+ }
+ expiredBefore := time.Now().Add(-profileTrashRetention).Format(time.RFC3339)
+ expired, err := m.ProfileDAO.ListExpiredDeleted(expiredBefore)
+ if err != nil {
+ log.Error("清理过期回收站实例失败", logger.F("error", err))
+ return err
+ }
+ cleaned := 0
+ for _, profile := range expired {
+ if err := m.deleteProfileRelatedDataLocked(log, profile); err != nil {
+ log.Error("清理过期回收站实例关联数据失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err))
+ continue
+ }
+ if err := m.ProfileDAO.Delete(profile.ProfileId); err != nil {
+ log.Error("删除过期回收站实例记录失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err))
+ continue
+ }
+ cleaned++
+ }
+ if cleaned > 0 {
+ log.Info("过期回收站实例已清理", logger.F("count", cleaned))
+ }
+ return nil
+}
+
+func (m *Manager) deleteProfileRelatedDataLocked(log *logger.Logger, profile *Profile) error {
+ if profile == nil {
+ return nil
+ }
+ var firstErr error
+ if m.CodeProvider != nil {
+ if err := m.CodeProvider.Remove(profile.ProfileId); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ if m.ExtensionDAO != nil {
+ if err := m.ExtensionDAO.DeleteProfileSettings(profile.ProfileId); err != nil {
+ log.Error("删除实例插件配置失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err))
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+ }
+ userDataDir := m.ResolveUserDataDir(profile)
+ if err := m.deleteProfileUserDataDir(userDataDir); err != nil {
+ log.Error("删除实例数据目录失败", logger.F("profile_id", profile.ProfileId), logger.F("dir", userDataDir), logger.F("error", err))
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+ if err := m.deleteProfileSnapshotDir(profile.ProfileId); err != nil {
+ log.Error("删除实例快照目录失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err))
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+}
+
+func (m *Manager) deleteProfileSnapshotDir(profileId string) error {
+ profileId = strings.TrimSpace(profileId)
+ if profileId == "" {
+ return nil
+ }
+ dataRoot, err := filepath.Abs(m.ResolveRelativePath("data"))
+ if err != nil {
+ return fmt.Errorf("解析数据根目录失败: %w", err)
+ }
+ snapshotRoot := filepath.Join(dataRoot, "snapshots")
+ target, err := filepath.Abs(filepath.Join(snapshotRoot, profileId))
+ if err != nil {
+ return fmt.Errorf("解析快照目录失败: %w", err)
+ }
+ dataRoot = filepath.Clean(dataRoot)
+ snapshotRoot = filepath.Clean(snapshotRoot)
+ target = filepath.Clean(target)
+ if samePath(target, snapshotRoot) || samePath(target, dataRoot) || !isPathInside(target, snapshotRoot) {
+ return nil
+ }
+ if err := os.RemoveAll(target); err != nil {
+ return fmt.Errorf("删除快照目录失败: %w", err)
+ }
return nil
}
diff --git a/backend/internal/browser/profile_delete_test.go b/backend/internal/browser/profile_delete_test.go
index 42e2e36b..b4877a0c 100644
--- a/backend/internal/browser/profile_delete_test.go
+++ b/backend/internal/browser/profile_delete_test.go
@@ -5,9 +5,10 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
)
-func TestDeleteRemovesProfileUserDataDir(t *testing.T) {
+func TestDeleteKeepsProfileUserDataDirDuringTrashRetention(t *testing.T) {
appRoot := t.TempDir()
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = "data"
@@ -23,8 +24,8 @@ func TestDeleteRemovesProfileUserDataDir(t *testing.T) {
if err := mgr.Delete(profile.ProfileId); err != nil {
t.Fatalf("Delete failed: %v", err)
}
- if _, err := os.Stat(profileDir); !os.IsNotExist(err) {
- t.Fatalf("expected profile dir removed, stat err=%v", err)
+ if _, err := os.Stat(profileDir); err != nil {
+ t.Fatalf("expected profile dir kept during trash retention, stat err=%v", err)
}
}
@@ -49,3 +50,50 @@ func TestDeleteKeepsUserDataRootWhenProfileDirIsRoot(t *testing.T) {
t.Fatalf("expected data root kept, stat err=%v", err)
}
}
+
+func TestCleanupExpiredTrashRemovesProfileDataAndSnapshots(t *testing.T) {
+ appRoot := t.TempDir()
+ cfg := config.DefaultConfig()
+ cfg.Browser.UserDataRoot = "data"
+ mgr := NewManager(cfg, appRoot)
+ profile := &Profile{
+ ProfileId: "profile-expired",
+ UserDataDir: "profile-expired",
+ DeletedAt: time.Now().Add(-profileTrashRetention - time.Hour).Format(time.RFC3339),
+ }
+
+ profileDir := filepath.Join(appRoot, "data", "profile-expired")
+ snapshotDir := filepath.Join(appRoot, "data", "snapshots", "profile-expired")
+ if err := os.MkdirAll(profileDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll profile dir failed: %v", err)
+ }
+ if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll snapshot dir failed: %v", err)
+ }
+
+ mgr.ProfileDAO = &memoryExpiredProfileDAO{expired: []*Profile{profile}}
+ if err := mgr.CleanupExpiredTrash(); err != nil {
+ t.Fatalf("CleanupExpiredTrash failed: %v", err)
+ }
+ if _, err := os.Stat(profileDir); !os.IsNotExist(err) {
+ t.Fatalf("expected profile dir removed, stat err=%v", err)
+ }
+ if _, err := os.Stat(snapshotDir); !os.IsNotExist(err) {
+ t.Fatalf("expected snapshot dir removed, stat err=%v", err)
+ }
+}
+
+type memoryExpiredProfileDAO struct {
+ expired []*Profile
+}
+
+func (d *memoryExpiredProfileDAO) List() ([]*Profile, error) { return nil, nil }
+func (d *memoryExpiredProfileDAO) ListDeleted() ([]*Profile, error) { return nil, nil }
+func (d *memoryExpiredProfileDAO) GetById(profileId string) (*Profile, error) { return nil, nil }
+func (d *memoryExpiredProfileDAO) Upsert(profile *Profile) error { return nil }
+func (d *memoryExpiredProfileDAO) Delete(profileId string) error { return nil }
+func (d *memoryExpiredProfileDAO) SoftDelete(profileId string, deletedAt string) error { return nil }
+func (d *memoryExpiredProfileDAO) Restore(profileId string) error { return nil }
+func (d *memoryExpiredProfileDAO) ListExpiredDeleted(expiredBefore string) ([]*Profile, error) {
+ return d.expired, nil
+}
diff --git a/backend/internal/browser/proxy_dao.go b/backend/internal/browser/proxy_dao.go
index 4fb46bce..9ba0acbe 100644
--- a/backend/internal/browser/proxy_dao.go
+++ b/backend/internal/browser/proxy_dao.go
@@ -31,7 +31,7 @@ func NewSQLiteProxyDAO(db *sql.DB) *SQLiteProxyDAO {
// List 查询所有代理,按 sort_order 升序
func (d *SQLiteProxyDAO) List() ([]Proxy, error) {
rows, err := d.db.Query(`
- SELECT proxy_id, proxy_name, proxy_config, dns_servers, COALESCE(group_name, ''),
+ SELECT proxy_id, proxy_name, proxy_config, COALESCE(preferred_kernel, ''), dns_servers, COALESCE(group_name, ''),
COALESCE(source_id, ''), COALESCE(source_url, ''), COALESCE(source_name_prefix, ''),
COALESCE(source_auto_refresh, 0), COALESCE(source_refresh_interval_m, 0), COALESCE(source_last_refresh_at, ''),
COALESCE(last_latency_ms, -1), COALESCE(last_test_ok, 0), COALESCE(last_tested_at, ''),
@@ -48,7 +48,7 @@ func (d *SQLiteProxyDAO) List() ([]Proxy, error) {
// ListByGroup 按分组名称查询代理
func (d *SQLiteProxyDAO) ListByGroup(groupName string) ([]Proxy, error) {
rows, err := d.db.Query(`
- SELECT proxy_id, proxy_name, proxy_config, dns_servers, COALESCE(group_name, ''),
+ SELECT proxy_id, proxy_name, proxy_config, COALESCE(preferred_kernel, ''), dns_servers, COALESCE(group_name, ''),
COALESCE(source_id, ''), COALESCE(source_url, ''), COALESCE(source_name_prefix, ''),
COALESCE(source_auto_refresh, 0), COALESCE(source_refresh_interval_m, 0), COALESCE(source_last_refresh_at, ''),
COALESCE(last_latency_ms, -1), COALESCE(last_test_ok, 0), COALESCE(last_tested_at, ''),
@@ -93,14 +93,15 @@ func (d *SQLiteProxyDAO) Upsert(proxy Proxy) error {
}
_, err := d.db.Exec(`
INSERT INTO browser_proxies (
- proxy_id, proxy_name, proxy_config, dns_servers, group_name,
+ proxy_id, proxy_name, proxy_config, preferred_kernel, dns_servers, group_name,
source_id, source_url, source_name_prefix, source_auto_refresh, source_refresh_interval_m, source_last_refresh_at,
sort_order, created_at
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(proxy_id) DO UPDATE SET
proxy_name = excluded.proxy_name,
proxy_config = excluded.proxy_config,
+ preferred_kernel = excluded.preferred_kernel,
dns_servers = excluded.dns_servers,
group_name = excluded.group_name,
source_id = excluded.source_id,
@@ -110,7 +111,7 @@ func (d *SQLiteProxyDAO) Upsert(proxy Proxy) error {
source_refresh_interval_m = excluded.source_refresh_interval_m,
source_last_refresh_at = excluded.source_last_refresh_at,
sort_order = excluded.sort_order`,
- proxy.ProxyId, proxy.ProxyName, proxy.ProxyConfig, proxy.DnsServers, proxy.GroupName,
+ proxy.ProxyId, proxy.ProxyName, proxy.ProxyConfig, proxy.PreferredKernel, proxy.DnsServers, proxy.GroupName,
proxy.SourceID, proxy.SourceURL, proxy.SourceNamePrefix, autoRefreshInt, proxy.SourceRefreshIntervalM, proxy.SourceLastRefreshAt,
proxy.SortOrder, now,
)
@@ -171,7 +172,7 @@ func scanProxies(rows *sql.Rows) ([]Proxy, error) {
var okInt int
var autoRefreshInt int
if err := rows.Scan(
- &p.ProxyId, &p.ProxyName, &p.ProxyConfig, &p.DnsServers, &p.GroupName,
+ &p.ProxyId, &p.ProxyName, &p.ProxyConfig, &p.PreferredKernel, &p.DnsServers, &p.GroupName,
&p.SourceID, &p.SourceURL, &p.SourceNamePrefix, &autoRefreshInt, &p.SourceRefreshIntervalM, &p.SourceLastRefreshAt,
&p.LastLatencyMs, &okInt, &p.LastTestedAt, &p.LastIPHealthJSON, &p.SortOrder,
); err != nil {
diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go
index 4da43e82..74e403e7 100644
--- a/backend/internal/browser/types.go
+++ b/backend/internal/browser/types.go
@@ -33,6 +33,7 @@ type Profile struct {
LastError string `json:"lastError"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
+ DeletedAt string `json:"deletedAt"`
LastStartAt string `json:"lastStartAt"`
LastStopAt string `json:"lastStopAt"`
}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 95c91820..35fcf120 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -175,6 +175,7 @@ type BrowserProxy struct {
ProxyId string `yaml:"proxy_id" json:"proxyId"`
ProxyName string `yaml:"proxy_name" json:"proxyName"`
ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"`
+ PreferredKernel string `yaml:"preferred_kernel,omitempty" json:"preferredKernel,omitempty"`
DnsServers string `yaml:"dns_servers,omitempty" json:"dnsServers,omitempty"`
GroupName string `yaml:"group_name,omitempty" json:"groupName,omitempty"`
SortOrder int `yaml:"sort_order,omitempty" json:"sortOrder,omitempty"`
diff --git a/backend/internal/config/config_defaults.go b/backend/internal/config/config_defaults.go
index 0414fcc0..49b2abd3 100644
--- a/backend/internal/config/config_defaults.go
+++ b/backend/internal/config/config_defaults.go
@@ -10,10 +10,20 @@ import (
var defaultBrowserStartURLs = []string{}
const (
- BrowserConnectorXray = "xray"
+ // BrowserConnectorXray 是历史 default_connector_type 的默认值。
+ // 新代理运行入口不再依赖全局连接栈,而是按单个代理自动解析 xray/sing-box/mihomo。
+ BrowserConnectorXray = "xray"
+ // BrowserConnectorMihomo 仅保留用于兼容旧配置、旧 API 和历史数据。
BrowserConnectorMihomo = "mihomo"
)
+const (
+ BrowserConnectorXrayStack = BrowserConnectorXray
+ BrowserConnectorMihomoStack = BrowserConnectorMihomo
+)
+
+// NormalizeBrowserConnectorType 只用于兼容历史 default_connector_type 输入。
+// 新代理执行入口应使用 proxy.ResolveProxyKernel 按单个代理选择内核。
func NormalizeBrowserConnectorType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case BrowserConnectorMihomo, "clash", "clash-meta":
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index 6abdd6de..9a0b2d29 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -161,6 +161,14 @@ func TestNormalizeBrowserConnectorTypeAliases(t *testing.T) {
}
}
+func TestSingBoxAliasStaysInsideXrayConnectorStack(t *testing.T) {
+ t.Parallel()
+
+ if got := NormalizeBrowserConnectorType("sing-box"); got != BrowserConnectorXrayStack {
+ t.Fatalf("sing-box alias = %q, want xray connector stack", got)
+ }
+}
+
func TestLoadClearsLegacyVerificationStartURLs(t *testing.T) {
t.Parallel()
diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go
index c3860c7f..e6f4b50b 100644
--- a/backend/internal/database/sqlite.go
+++ b/backend/internal/database/sqlite.go
@@ -189,6 +189,21 @@ var migrations = []migration{
`ALTER TABLE browser_extensions ADD COLUMN icon_data_url TEXT NOT NULL DEFAULT ''`,
},
},
+ {
+ version: 11,
+ desc: "实例表添加回收站字段",
+ stmts: []string{
+ `ALTER TABLE browser_profiles ADD COLUMN deleted_at TEXT NOT NULL DEFAULT ''`,
+ `CREATE INDEX IF NOT EXISTS idx_browser_profiles_deleted_at ON browser_profiles(deleted_at)`,
+ },
+ },
+ {
+ version: 12,
+ desc: "代理表添加指定内核字段",
+ stmts: []string{
+ `ALTER TABLE browser_proxies ADD COLUMN preferred_kernel TEXT NOT NULL DEFAULT ''`,
+ },
+ },
// ── 新版本在此追加,格式:
// {
// version: 4,
diff --git a/backend/internal/proxy/check_config.go b/backend/internal/proxy/check_config.go
index a18b75cf..16370c60 100644
--- a/backend/internal/proxy/check_config.go
+++ b/backend/internal/proxy/check_config.go
@@ -7,7 +7,8 @@ import (
)
const defaultBridgeStartTimeoutMs = 15000
-const defaultTargetTimeoutMs = 10000
+const defaultSpeedTargetTimeoutMs = 3000
+const defaultIPHealthTargetTimeoutMs = 10000
func NormalizeCheckSettings(settings config.ProxyCheckConfig) config.ProxyCheckConfig {
settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, defaultBridgeStartTimeoutMs)
@@ -28,6 +29,9 @@ func NormalizeCheckSettings(settings config.ProxyCheckConfig) config.ProxyCheckC
func BuildSpeedTestConfig(settings config.ProxyCheckConfig) *SpeedTestConfig {
cfg := DefaultSpeedTestConfig
+ if settings.BridgeStartTimeoutMs > 0 {
+ cfg.TCPTimeout = time.Duration(settings.BridgeStartTimeoutMs) * time.Millisecond
+ }
target := FindCheckTarget(settings.Targets, settings.SpeedTargetID, "speed")
if strings.TrimSpace(target.URL) != "" {
cfg.URLs = []string{strings.TrimSpace(target.URL)}
@@ -35,6 +39,9 @@ func BuildSpeedTestConfig(settings config.ProxyCheckConfig) *SpeedTestConfig {
if target.TimeoutMs > 0 {
cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
}
+ if len(target.ExpectedStatus) > 0 {
+ cfg.ExpectedStatus = append([]int{}, target.ExpectedStatus...)
+ }
return &cfg
}
@@ -96,7 +103,11 @@ func NormalizeCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyChec
target.Type = "speed"
}
if target.TimeoutMs <= 0 {
- target.TimeoutMs = defaultTargetTimeoutMs
+ if strings.EqualFold(target.Type, "ip_health") {
+ target.TimeoutMs = defaultIPHealthTargetTimeoutMs
+ } else {
+ target.TimeoutMs = defaultSpeedTargetTimeoutMs
+ }
}
result = append(result, target)
}
diff --git a/backend/internal/proxy/check_config_test.go b/backend/internal/proxy/check_config_test.go
index 1514fc6a..e9663c8b 100644
--- a/backend/internal/proxy/check_config_test.go
+++ b/backend/internal/proxy/check_config_test.go
@@ -17,14 +17,34 @@ func TestNormalizeCheckSettingsDefaultsAndSelectsTargets(t *testing.T) {
if settings.BridgeStartTimeoutMs != defaultBridgeStartTimeoutMs {
t.Fatalf("bridge timeout = %d", settings.BridgeStartTimeoutMs)
}
+ if settings.BridgeStartTimeoutMs != 15000 {
+ t.Fatalf("bridge timeout = %d, want 15000", settings.BridgeStartTimeoutMs)
+ }
if settings.SpeedTargetID != "speed-main" {
t.Fatalf("speed target id = %q", settings.SpeedTargetID)
}
if settings.IPHealthTargetID != "health-main" {
t.Fatalf("ip health target id = %q", settings.IPHealthTargetID)
}
- if settings.Targets[0].TimeoutMs != defaultTargetTimeoutMs {
- t.Fatalf("target timeout = %d", settings.Targets[0].TimeoutMs)
+ if settings.Targets[0].TimeoutMs != defaultSpeedTargetTimeoutMs {
+ t.Fatalf("speed target timeout = %d", settings.Targets[0].TimeoutMs)
+ }
+ if settings.Targets[1].TimeoutMs != 1500 {
+ t.Fatalf("ip health target timeout = %d", settings.Targets[1].TimeoutMs)
+ }
+}
+
+func TestNormalizeCheckTargetsUsesLongerDefaultForIPHealth(t *testing.T) {
+ targets := NormalizeCheckTargets([]config.ProxyCheckTarget{
+ {ID: "speed", URL: "https://speed.example.com", Type: "speed"},
+ {ID: "health", URL: "https://health.example.com", Type: "ip_health"},
+ })
+
+ if targets[0].TimeoutMs != defaultSpeedTargetTimeoutMs {
+ t.Fatalf("speed timeout = %d", targets[0].TimeoutMs)
+ }
+ if targets[1].TimeoutMs != defaultIPHealthTargetTimeoutMs {
+ t.Fatalf("ip health timeout = %d", targets[1].TimeoutMs)
}
}
@@ -45,10 +65,11 @@ func TestNormalizeCheckTargetsDropsInvalidAndDuplicateTargets(t *testing.T) {
func TestBuildProxyCheckConfigs(t *testing.T) {
settings := config.ProxyCheckConfig{
+ BridgeStartTimeoutMs: 15000,
SpeedTargetID: "speed-main",
IPHealthTargetID: "health-main",
Targets: []config.ProxyCheckTarget{
- {ID: "speed-main", Type: "speed", URL: "https://speed.example.com", TimeoutMs: 1200},
+ {ID: "speed-main", Type: "speed", URL: "https://speed.example.com", TimeoutMs: 1200, ExpectedStatus: []int{204}},
{ID: "health-main", Type: "ip_health", URL: "https://health.example.com", Parser: "ipqualityscore", TimeoutMs: 2300},
},
}
@@ -57,6 +78,12 @@ func TestBuildProxyCheckConfigs(t *testing.T) {
if len(speed.URLs) != 1 || speed.URLs[0] != "https://speed.example.com" || speed.Timeout != 1200*time.Millisecond {
t.Fatalf("speed config = %#v", speed)
}
+ if len(speed.ExpectedStatus) != 1 || speed.ExpectedStatus[0] != 204 {
+ t.Fatalf("speed expected status = %#v", speed.ExpectedStatus)
+ }
+ if speed.TCPTimeout != 15000*time.Millisecond {
+ t.Fatalf("speed tcp timeout = %s", speed.TCPTimeout)
+ }
health := BuildIPHealthConfig(settings)
if health.URL != "https://health.example.com" || health.Source != "health-main" || health.Parser != "ipqualityscore" || health.Timeout != 2300*time.Millisecond {
diff --git a/backend/internal/proxy/http_client.go b/backend/internal/proxy/http_client.go
index 0d26475b..736a3ab8 100644
--- a/backend/internal/proxy/http_client.go
+++ b/backend/internal/proxy/http_client.go
@@ -8,6 +8,7 @@ import (
"time"
"ant-chrome/backend/internal/config"
+ "ant-chrome/backend/internal/logger"
xproxy "golang.org/x/net/proxy"
)
@@ -36,87 +37,96 @@ func buildProxyHTTPClient(
connectorType string,
timeout time.Duration,
) (*http.Client, error) {
- src = resolveProxyConfig(src, proxies, proxyId)
+ src = strings.TrimSpace(resolveProxyConfig(src, proxies, proxyId))
+ log := logger.New("ProxyHTTPClient")
+ resolution, err := ResolveProxyKernel(src, proxies, proxyId, "")
+ if err != nil {
+ log.Warn("代理内核解析失败",
+ logger.F("proxy_id", proxyId),
+ logger.F("error", err.Error()),
+ )
+ return nil, err
+ }
+ log.Info("代理 HTTP 客户端内核选择",
+ logger.F("proxy_id", proxyId),
+ logger.F("protocol", resolution.Protocol),
+ logger.F("kernel", resolution.Kernel),
+ logger.F("preferred_kernel", resolution.PreferredKernel),
+ logger.F("supported_kernels", strings.Join(resolution.SupportedKernels, ",")),
+ logger.F("reason", resolution.Reason),
+ )
l := strings.ToLower(strings.TrimSpace(src))
- if l == "" || l == "direct://" {
+ if resolution.Kernel == ProxyKernelNative || l == "" || l == "direct://" {
+ if strings.HasPrefix(l, "socks5://") {
+ u, err := url.Parse(src)
+ if err != nil {
+ return nil, fmt.Errorf("SOCKS5 地址解析失败: %w", err)
+ }
+ var auth *xproxy.Auth
+ if u.User != nil {
+ pass, _ := u.User.Password()
+ auth = &xproxy.Auth{User: u.User.Username(), Password: pass}
+ }
+ dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct)
+ if err != nil {
+ return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
+ }
+ contextDialer, ok := dialer.(xproxy.ContextDialer)
+ if !ok {
+ return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
+ }
+ return &http.Client{Transport: &http.Transport{DialContext: contextDialer.DialContext}, Timeout: timeout}, nil
+ }
+ if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
+ proxyURL, err := url.Parse(src)
+ if err != nil {
+ return nil, fmt.Errorf("代理地址解析失败: %w", err)
+ }
+ return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, Timeout: timeout}, nil
+ }
return &http.Client{Timeout: timeout}, nil
}
- if config.NormalizeBrowserConnectorType(connectorType) == config.BrowserConnectorMihomo && (IsChainSocks5Proxy(src) || IsSingBoxProtocol(src) || RequiresBridge(src, proxies, proxyId) || RequiresLocalProxyBridgeForBrowser(src)) {
+ switch resolution.Kernel {
+ case ProxyKernelMihomo:
if clashMgr == nil {
- return nil, fmt.Errorf("mihomo 管理器未初始化")
+ log.Warn("Mihomo 管理器未初始化", logger.F("proxy_id", proxyId))
+ return nil, fmt.Errorf("Mihomo 管理器未初始化")
}
proxyAddr, err := clashMgr.EnsureNodeBridge(src, proxies, proxyId)
if err != nil {
- return nil, fmt.Errorf("mihomo 桥接启动失败: %w", err)
+ log.Warn("Mihomo 桥接启动失败", logger.F("proxy_id", proxyId), logger.F("error", err.Error()))
+ return nil, fmt.Errorf("Mihomo 桥接启动失败: %w", err)
}
+ log.Info("Mihomo 桥接已就绪", logger.F("proxy_id", proxyId), logger.F("proxy_addr", proxyAddr))
return buildHTTPProxyClient(proxyAddr, timeout)
- }
-
- 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) {
+ case ProxyKernelSingBox:
if singboxMgr == nil {
+ log.Warn("sing-box 管理器未初始化", logger.F("proxy_id", proxyId))
return nil, fmt.Errorf("sing-box 管理器未初始化")
}
socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId)
if err != nil {
+ log.Warn("sing-box 桥接启动失败", logger.F("proxy_id", proxyId), logger.F("error", err.Error()))
return nil, fmt.Errorf("sing-box 桥接启动失败: %w", err)
}
+ log.Info("sing-box 桥接已就绪", logger.F("proxy_id", proxyId), logger.F("socks5_addr", socks5Addr))
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
- }
-
- if RequiresBridge(src, proxies, proxyId) {
+ case ProxyKernelXray:
if xrayMgr == nil {
+ log.Warn("xray 管理器未初始化", logger.F("proxy_id", proxyId))
return nil, fmt.Errorf("xray 管理器未初始化")
}
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
if err != nil {
+ log.Warn("xray 桥接启动失败", logger.F("proxy_id", proxyId), logger.F("error", err.Error()))
return nil, fmt.Errorf("xray 桥接启动失败: %w", err)
}
+ log.Info("xray 桥接已就绪", logger.F("proxy_id", proxyId), logger.F("socks5_addr", socks5Addr))
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
+ default:
+ return nil, fmt.Errorf("无法为协议 %s 选择代理内核", resolution.Protocol)
}
-
- if strings.HasPrefix(l, "socks5://") {
- u, err := url.Parse(src)
- if err != nil {
- return nil, fmt.Errorf("SOCKS5 地址解析失败: %w", err)
- }
- var auth *xproxy.Auth
- if u.User != nil {
- pass, _ := u.User.Password()
- auth = &xproxy.Auth{
- User: u.User.Username(),
- Password: pass,
- }
- }
- dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct)
- if err != nil {
- return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
- }
- contextDialer, ok := dialer.(xproxy.ContextDialer)
- if !ok {
- return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
- }
- transport := &http.Transport{DialContext: contextDialer.DialContext}
- return &http.Client{Transport: transport, Timeout: timeout}, nil
- }
-
- proxyURL, err := url.Parse(src)
- if err != nil {
- return nil, fmt.Errorf("代理地址解析失败: %w", err)
- }
- transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
- return &http.Client{Transport: transport, Timeout: timeout}, nil
}
func buildHTTPProxyClient(proxyAddr string, timeout time.Duration) (*http.Client, error) {
diff --git a/backend/internal/proxy/iphealth.go b/backend/internal/proxy/iphealth.go
index 2eb57057..8cd72bb2 100644
--- a/backend/internal/proxy/iphealth.go
+++ b/backend/internal/proxy/iphealth.go
@@ -29,7 +29,7 @@ func FetchDefaultIPHealthInfo(
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
) (map[string]interface{}, error) {
- return FetchIPHealthInfo(proxyId, proxies, xrayMgr, singboxMgr, nil)
+ return FetchIPHealthInfo(proxyId, proxies, xrayMgr, singboxMgr, nil, config.BrowserConnectorXray, nil)
}
func FetchIPHealthInfo(
@@ -37,6 +37,8 @@ func FetchIPHealthInfo(
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
+ clashMgr *ClashManager,
+ connectorType string,
cfg *IPHealthConfig,
) (map[string]interface{}, error) {
if cfg == nil {
@@ -68,7 +70,7 @@ func FetchIPHealthInfo(
return meta, fmt.Errorf("未找到代理配置")
}
- client, err := buildIPHealthHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
+ client, err := buildIPHealthHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, timeout)
if err != nil {
meta["error"] = err.Error()
return meta, fmt.Errorf("创建 IP 健康检测客户端失败(source=%s): %w", source, err)
@@ -157,9 +159,11 @@ func buildIPHealthHTTPClient(
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
+ clashMgr *ClashManager,
+ connectorType string,
timeout time.Duration,
) (*http.Client, error) {
- return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, nil, config.BrowserConnectorXray, timeout)
+ return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, timeout)
}
func resolveIPHealthSource(cfg *IPHealthConfig, targetURL string) string {
diff --git a/backend/internal/proxy/iphealth_test.go b/backend/internal/proxy/iphealth_test.go
index 7ab8ce78..fbd23ddb 100644
--- a/backend/internal/proxy/iphealth_test.go
+++ b/backend/internal/proxy/iphealth_test.go
@@ -21,6 +21,8 @@ func TestFetchIPHealthInfoReturnsSourceMetadataOnParseError(t *testing.T) {
[]config.BrowserProxy{{ProxyId: "proxy-1", ProxyConfig: "direct://"}},
nil,
nil,
+ nil,
+ config.BrowserConnectorXray,
&IPHealthConfig{
URL: server.URL,
Source: "json",
diff --git a/backend/internal/proxy/kernel_resolver.go b/backend/internal/proxy/kernel_resolver.go
new file mode 100644
index 00000000..268f5cad
--- /dev/null
+++ b/backend/internal/proxy/kernel_resolver.go
@@ -0,0 +1,154 @@
+package proxy
+
+import (
+ "fmt"
+ "strings"
+
+ "ant-chrome/backend/internal/config"
+)
+
+const (
+ ProxyKernelAuto = "auto"
+ ProxyKernelNative = "native"
+ ProxyKernelXray = "xray"
+ ProxyKernelSingBox = "sing-box"
+ ProxyKernelMihomo = "mihomo"
+)
+
+type ProxyKernelResolution struct {
+ Protocol string `json:"protocol"`
+ PreferredKernel string `json:"preferredKernel"`
+ Kernel string `json:"kernel"`
+ SupportedKernels []string `json:"supportedKernels"`
+ MissingCore string `json:"missingCore,omitempty"`
+ Reason string `json:"reason"`
+}
+
+func NormalizePreferredKernel(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "", ProxyKernelAuto:
+ return ""
+ case ProxyKernelXray:
+ return ProxyKernelXray
+ case ProxyKernelSingBox, "singbox", "sing_box":
+ return ProxyKernelSingBox
+ case ProxyKernelMihomo, "clash", "clash-meta":
+ return ProxyKernelMihomo
+ case ProxyKernelNative:
+ return ProxyKernelNative
+ default:
+ return strings.ToLower(strings.TrimSpace(value))
+ }
+}
+
+func ResolveProxyKernel(proxyConfig string, proxies []config.BrowserProxy, proxyId string, preferredKernel string) (ProxyKernelResolution, error) {
+ src := strings.TrimSpace(resolveProxyConfig(proxyConfig, proxies, proxyId))
+ if strings.TrimSpace(preferredKernel) == "" && strings.TrimSpace(proxyId) != "" {
+ for _, item := range proxies {
+ if strings.EqualFold(strings.TrimSpace(item.ProxyId), strings.TrimSpace(proxyId)) {
+ preferredKernel = item.PreferredKernel
+ break
+ }
+ }
+ }
+ preferred := NormalizePreferredKernel(preferredKernel)
+ if preferred == "" {
+ preferred = ProxyKernelAuto
+ }
+ resolution := ProxyKernelResolution{PreferredKernel: preferred}
+ if src == "" || strings.EqualFold(src, "direct://") {
+ resolution.Protocol = "direct"
+ resolution.Kernel = ProxyKernelNative
+ resolution.SupportedKernels = []string{ProxyKernelNative}
+ resolution.Reason = "直连无需代理内核"
+ return resolution, validatePreferredKernel(resolution, preferred)
+ }
+
+ protocol := DetectProxyProtocol(src)
+ resolution.Protocol = protocol
+ resolution.SupportedKernels = SupportedKernelsForProtocol(protocol, src, proxies, proxyId)
+ if len(resolution.SupportedKernels) == 0 {
+ return resolution, fmt.Errorf("不支持的代理协议: %s", protocol)
+ }
+ if preferred != ProxyKernelAuto {
+ if !containsKernel(resolution.SupportedKernels, preferred) {
+ return resolution, fmt.Errorf("协议 %s 不支持指定内核 %s", protocol, preferred)
+ }
+ resolution.Kernel = preferred
+ resolution.Reason = "使用代理指定内核"
+ return resolution, nil
+ }
+ resolution.Kernel = resolution.SupportedKernels[0]
+ resolution.Reason = "按默认内核优先级自动选择"
+ return resolution, nil
+}
+
+func DetectProxyProtocol(proxyConfig string) string {
+ src := strings.TrimSpace(proxyConfig)
+ l := strings.ToLower(src)
+ if src == "" || strings.EqualFold(src, "direct://") {
+ return "direct"
+ }
+ if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
+ return "http"
+ }
+ if strings.HasPrefix(l, "socks5://") {
+ return "socks5"
+ }
+ if IsChainSocks5Proxy(src) {
+ return "chain+socks5"
+ }
+ if nodeType := clashNodeType(src); nodeType != "" {
+ return nodeType
+ }
+ for _, prefix := range []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria2://", "hysteria://", "tuic://", "anytls://"} {
+ if strings.HasPrefix(l, prefix) {
+ return strings.TrimSuffix(prefix, "://")
+ }
+ }
+ return "unknown"
+}
+
+func SupportedKernelsForProtocol(protocol string, proxyConfig string, proxies []config.BrowserProxy, proxyId string) []string {
+ switch strings.ToLower(strings.TrimSpace(protocol)) {
+ case "direct", "http", "https", "socks5":
+ return []string{ProxyKernelNative}
+ case "vmess", "vless", "trojan", "ss", "shadowsocks", "chain+socks5":
+ return []string{ProxyKernelXray, ProxyKernelMihomo}
+ case "hysteria", "hysteria2", "tuic", "anytls":
+ return []string{ProxyKernelSingBox, ProxyKernelMihomo}
+ case "mieru":
+ return []string{ProxyKernelMihomo}
+ default:
+ if RequiresLocalProxyBridgeForBrowser(proxyConfig) || RequiresBridge(proxyConfig, proxies, proxyId) {
+ return []string{ProxyKernelXray, ProxyKernelMihomo}
+ }
+ if IsSingBoxProtocol(proxyConfig) {
+ return []string{ProxyKernelSingBox, ProxyKernelMihomo}
+ }
+ if IsMihomoOnlyProtocol(proxyConfig) {
+ return []string{ProxyKernelMihomo}
+ }
+ return nil
+ }
+}
+
+func validatePreferredKernel(resolution ProxyKernelResolution, preferred string) error {
+ if preferred == "" || preferred == ProxyKernelAuto {
+ return nil
+ }
+ if !containsKernel(resolution.SupportedKernels, preferred) {
+ return fmt.Errorf("协议 %s 不支持指定内核 %s", resolution.Protocol, preferred)
+ }
+ return nil
+}
+
+func containsKernel(kernels []string, kernel string) bool {
+ kernel = NormalizePreferredKernel(kernel)
+ for _, item := range kernels {
+ if item == kernel {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/proxy/kernel_resolver_test.go b/backend/internal/proxy/kernel_resolver_test.go
new file mode 100644
index 00000000..9ea51849
--- /dev/null
+++ b/backend/internal/proxy/kernel_resolver_test.go
@@ -0,0 +1,50 @@
+package proxy
+
+import (
+ "testing"
+
+ "ant-chrome/backend/internal/config"
+)
+
+func TestResolveProxyKernelDefaultPriority(t *testing.T) {
+ cases := []struct {
+ name string
+ proxy string
+ wantKernel string
+ }{
+ {name: "vless uses xray", proxy: "vless://00000000-0000-0000-0000-000000000000@example.com:443", wantKernel: ProxyKernelXray},
+ {name: "hysteria2 uses sing-box", proxy: "hysteria2://pass@example.com:443", wantKernel: ProxyKernelSingBox},
+ {name: "anytls URI uses sing-box", proxy: "anytls://pass@example.com:443?sni=example.com", wantKernel: ProxyKernelSingBox},
+ {name: "mieru uses mihomo", proxy: mieruClashNode, wantKernel: ProxyKernelMihomo},
+ {name: "http uses native", proxy: "http://127.0.0.1:8080", wantKernel: ProxyKernelNative},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ResolveProxyKernel(tc.proxy, nil, "", "")
+ if err != nil {
+ t.Fatalf("ResolveProxyKernel returned error: %v", err)
+ }
+ if got.Kernel != tc.wantKernel {
+ t.Fatalf("kernel = %q, want %q; resolution=%+v", got.Kernel, tc.wantKernel, got)
+ }
+ })
+ }
+}
+
+func TestResolveProxyKernelRejectsUnsupportedPreferredKernel(t *testing.T) {
+ _, err := ResolveProxyKernel(mieruClashNode, nil, "", ProxyKernelXray)
+ if err == nil {
+ t.Fatal("expected mieru + xray preference to be rejected")
+ }
+}
+
+func TestResolveProxyKernelReadsPreferredKernelFromProxy(t *testing.T) {
+ proxyID := "p1"
+ got, err := ResolveProxyKernel("", []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: mieruClashNode, PreferredKernel: ProxyKernelMihomo}}, proxyID, "")
+ if err != nil {
+ t.Fatalf("ResolveProxyKernel returned error: %v", err)
+ }
+ if got.Kernel != ProxyKernelMihomo || got.PreferredKernel != ProxyKernelMihomo {
+ t.Fatalf("unexpected resolution: %+v", got)
+ }
+}
diff --git a/backend/internal/proxy/mihomo_bridge.go b/backend/internal/proxy/mihomo_bridge.go
index 61cd1676..64311b38 100644
--- a/backend/internal/proxy/mihomo_bridge.go
+++ b/backend/internal/proxy/mihomo_bridge.go
@@ -5,8 +5,11 @@ import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/fsutil"
"ant-chrome/backend/internal/logger"
+ "encoding/json"
"fmt"
"net"
+ "net/http"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -18,15 +21,16 @@ import (
)
type MihomoNodeBridge struct {
- NodeKey string
- Port int
- Cmd *exec.Cmd
- Pid int
- ConfigPath string
- Running bool
- LastUsedAt time.Time
- ExitDone chan struct{}
- ExitErr error
+ NodeKey string
+ Port int
+ ControllerPort int
+ Cmd *exec.Cmd
+ Pid int
+ ConfigPath string
+ Running bool
+ LastUsedAt time.Time
+ ExitDone chan struct{}
+ ExitErr error
}
func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
@@ -60,7 +64,11 @@ func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.Bro
if err != nil {
return "", err
}
- cfgPath, err := m.buildMihomoNodeConfig(key, node, port)
+ controllerPort, err := nextAvailablePort()
+ if err != nil {
+ return "", err
+ }
+ cfgPath, err := m.buildMihomoNodeConfig(key, node, port, controllerPort)
if err != nil {
return "", err
}
@@ -79,7 +87,7 @@ func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.Bro
}
return "", fmt.Errorf("mihomo 启动失败: %w", err)
}
- bridge := &MihomoNodeBridge{NodeKey: key, Port: port, Cmd: cmd, Pid: cmd.Process.Pid, ConfigPath: cfgPath, Running: true, LastUsedAt: time.Now(), ExitDone: make(chan struct{})}
+ bridge := &MihomoNodeBridge{NodeKey: key, Port: port, ControllerPort: controllerPort, Cmd: cmd, Pid: cmd.Process.Pid, ConfigPath: cfgPath, Running: true, LastUsedAt: time.Now(), ExitDone: make(chan struct{})}
m.watchMihomoNodeBridge(bridge)
if err := waitTCPPortReady("127.0.0.1", port, 10*time.Second); err != nil {
if stderrFile != nil {
@@ -88,6 +96,13 @@ func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.Bro
_ = cmd.Process.Kill()
return "", fmt.Errorf("mihomo mixed-port 未就绪: %w", err)
}
+ if err := waitTCPPortReady("127.0.0.1", controllerPort, 10*time.Second); err != nil {
+ if stderrFile != nil {
+ stderrFile.Close()
+ }
+ _ = cmd.Process.Kill()
+ return "", fmt.Errorf("mihomo 控制端口未就绪: %w", err)
+ }
if stderrFile != nil {
stderrFile.Close()
}
@@ -131,6 +146,72 @@ func (m *ClashManager) tryReuseMihomoNodeBridge(key string) (string, bool) {
return fmt.Sprintf("http://127.0.0.1:%d", bridge.Port), true
}
+func (m *ClashManager) TestNodeDelay(proxyId string, proxies []config.BrowserProxy, cfg *SpeedTestConfig) TestResult {
+ src := strings.TrimSpace(resolveProxyConfig("", proxies, proxyId))
+ if src == "" {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: "代理配置为空"}
+ }
+ if strings.EqualFold(src, "direct://") {
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0, Engine: "direct"}
+ }
+ if m == nil {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: "mihomo 管理器未初始化"}
+ }
+ if _, err := m.EnsureNodeBridge(src, proxies, proxyId); err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: err.Error()}
+ }
+ key := computeNodeKey(src + "\x00mihomo")
+ m.mu.Lock()
+ bridge := m.NodeBridges[key]
+ m.mu.Unlock()
+ if bridge == nil || !bridge.Running || bridge.ControllerPort <= 0 {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: "mihomo 控制端口未就绪"}
+ }
+ timeout := 10 * time.Second
+ testURL := DefaultSpeedTestURL
+ if cfg != nil {
+ if cfg.Timeout > 0 {
+ timeout = cfg.Timeout
+ }
+ if urls := normalizeSpeedTestURLs(cfg.URLs); len(urls) > 0 {
+ testURL = urls[0]
+ }
+ }
+ apiURL := fmt.Sprintf(
+ "http://127.0.0.1:%d/proxies/%s/delay?timeout=%d&url=%s",
+ bridge.ControllerPort,
+ url.PathEscape("proxy-out"),
+ int(timeout.Milliseconds()),
+ url.QueryEscape(testURL),
+ )
+ client := &http.Client{Timeout: timeout + time.Second}
+ resp, err := client.Get(apiURL)
+ if err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: "mihomo 延迟测试失败: " + err.Error()}
+ }
+ defer resp.Body.Close()
+ var payload struct {
+ Delay int64 `json:"delay"`
+ Error string `json:"error"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: "mihomo 延迟结果解析失败: " + err.Error()}
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ if payload.Error != "" {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: payload.Error}
+ }
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: fmt.Sprintf("mihomo 延迟测试失败: HTTP %d", resp.StatusCode)}
+ }
+ if payload.Delay <= 0 {
+ if payload.Error != "" {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: payload.Error}
+ }
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: "mihomo 延迟测试无结果"}
+ }
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: payload.Delay, Engine: "mihomo"}
+}
+
func (m *ClashManager) registerMihomoNodeBridge(key string, bridge *MihomoNodeBridge) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -189,7 +270,7 @@ func (m *ClashManager) lockLaunchForKey(key string) func() {
}
}
-func (m *ClashManager) buildMihomoNodeConfig(key string, node map[string]interface{}, port int) (string, error) {
+func (m *ClashManager) buildMihomoNodeConfig(key string, node map[string]interface{}, port int, controllerPort int) (string, error) {
baseDir := m.resolveMihomoWorkdir(key)
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return "", err
@@ -200,14 +281,15 @@ func (m *ClashManager) buildMihomoNodeConfig(key string, node map[string]interfa
node["name"] = name
}
payload := map[string]interface{}{
- "mixed-port": port,
- "allow-lan": false,
- "mode": "rule",
- "log-level": "warning",
- "ipv6": true,
- "unified-delay": true,
- "tcp-concurrent": false,
- "proxies": []interface{}{node},
+ "mixed-port": port,
+ "external-controller": fmt.Sprintf("127.0.0.1:%d", controllerPort),
+ "allow-lan": false,
+ "mode": "rule",
+ "log-level": "warning",
+ "ipv6": true,
+ "unified-delay": true,
+ "tcp-concurrent": false,
+ "proxies": []interface{}{node},
"proxy-groups": []interface{}{
map[string]interface{}{
"name": "proxy-out",
@@ -269,7 +351,14 @@ func (m *ClashManager) resolveMihomoBinary() (string, error) {
}
}
if m.AppRoot != "" {
- candidates = append(candidates, filepath.Join(m.AppRoot, "bin", "mihomo.exe"), filepath.Join(m.AppRoot, "bin", "mihomo"))
+ candidates = append(candidates,
+ filepath.Join(m.AppRoot, "bin", "mihomo.exe"),
+ filepath.Join(m.AppRoot, "bin", "mihomo"),
+ filepath.Join(m.AppRoot, "bin", runtime.GOOS+"-"+runtime.GOARCH, "mihomo", "mihomo.exe"),
+ filepath.Join(m.AppRoot, "bin", runtime.GOOS+"-"+runtime.GOARCH, "mihomo", "mihomo"),
+ filepath.Join(m.AppRoot, "bin", runtime.GOOS+"-"+runtime.GOARCH, "mihomo.exe"),
+ filepath.Join(m.AppRoot, "bin", runtime.GOOS+"-"+runtime.GOARCH, "mihomo"),
+ )
}
for _, candidate := range candidates {
candidate = fsutil.NormalizePathInput(candidate)
diff --git a/backend/internal/proxy/mihomo_protocol.go b/backend/internal/proxy/mihomo_protocol.go
new file mode 100644
index 00000000..83afb387
--- /dev/null
+++ b/backend/internal/proxy/mihomo_protocol.go
@@ -0,0 +1,57 @@
+package proxy
+
+import (
+ "fmt"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+func IsMihomoOnlyProtocol(proxyConfig string) bool {
+ return mihomoOnlyProtocolType(proxyConfig) != ""
+}
+
+func mihomoOnlyProtocolType(proxyConfig string) string {
+ nodeType := clashNodeType(proxyConfig)
+ switch nodeType {
+ case "mieru":
+ return nodeType
+ default:
+ return ""
+ }
+}
+
+func validateMihomoOnlyProtocol(proxyConfig string) error {
+ src := strings.TrimSpace(proxyConfig)
+ var payload interface{}
+ if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
+ return fmt.Errorf("YAML 解析失败: %w", err)
+ }
+ node := pickClashNode(payload)
+ if node == nil {
+ return fmt.Errorf("mihomo 节点解析失败")
+ }
+ if getMapString(node, "server") == "" {
+ return fmt.Errorf("mieru 节点缺少 server")
+ }
+ if getMapInt(node, "port") == 0 {
+ return fmt.Errorf("mieru 节点缺少 port")
+ }
+ return nil
+}
+
+func clashNodeType(proxyConfig string) string {
+ src := strings.TrimSpace(proxyConfig)
+ if src == "" || (!strings.Contains(strings.ToLower(src), "type:") && !strings.Contains(strings.ToLower(src), "proxies:")) {
+ return ""
+ }
+ var payload interface{}
+ if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
+ return ""
+ }
+ node := pickClashNode(payload)
+ if node == nil {
+ return ""
+ }
+ return strings.ToLower(strings.TrimSpace(getMapString(node, "type")))
+}
diff --git a/backend/internal/proxy/mihomo_protocol_test.go b/backend/internal/proxy/mihomo_protocol_test.go
new file mode 100644
index 00000000..52a09af0
--- /dev/null
+++ b/backend/internal/proxy/mihomo_protocol_test.go
@@ -0,0 +1,97 @@
+package proxy
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "ant-chrome/backend/internal/config"
+)
+
+const mieruClashNode = `
+name: 可乐云-HK01 Mieru
+type: mieru
+server: cursor.kaolun.cn
+port: 40001
+username: user-1
+password: pass-1
+transport: TCP
+`
+
+func TestMieruClashNodeIsMihomoOnlyProtocol(t *testing.T) {
+ if !IsMihomoOnlyProtocol(mieruClashNode) {
+ t.Fatalf("expected mieru Clash node to be treated as mihomo-only")
+ }
+ if IsSingBoxProtocol(mieruClashNode) {
+ t.Fatalf("mieru must not be treated as sing-box protocol")
+ }
+ if RequiresBridge(mieruClashNode, nil, "") {
+ t.Fatalf("mieru must not require xray bridge")
+ }
+ ok, msg := ValidateProxyConfig(mieruClashNode, nil, "")
+ if !ok {
+ t.Fatalf("ValidateProxyConfig rejected mieru node: %s", msg)
+ }
+}
+
+func TestMieruClashNodeBuildsMihomoNode(t *testing.T) {
+ node, err := buildMihomoNode(mieruClashNode)
+ if err != nil {
+ t.Fatalf("buildMihomoNode returned error: %v", err)
+ }
+ if node["type"] != "mieru" {
+ t.Fatalf("type = %v, want mieru", node["type"])
+ }
+ if node["server"] != "cursor.kaolun.cn" {
+ t.Fatalf("server = %v, want cursor.kaolun.cn", node["server"])
+ }
+ if node["port"] != 40001 {
+ t.Fatalf("port = %v, want 40001", node["port"])
+ }
+}
+
+func TestMieruSpeedTestRequiresMihomoConnector(t *testing.T) {
+ proxyID := "mieru-proxy"
+ result := SpeedTestWithConnector(
+ proxyID,
+ []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: mieruClashNode}},
+ nil,
+ nil,
+ nil,
+ config.BrowserConnectorXray,
+ &SpeedTestConfig{Timeout: 10, URLs: []string{"http://latency.test/generate_204"}},
+ )
+ if result.Ok {
+ t.Fatalf("speed test should fail without mihomo connector, got success: %+v", result)
+ }
+ if result.Engine != config.BrowserConnectorMihomo {
+ t.Fatalf("engine = %q, want mihomo; result=%+v", result.Engine, result)
+ }
+ if !strings.Contains(result.Error, "Mihomo") {
+ t.Fatalf("error = %q, want Mihomo guidance", result.Error)
+ }
+}
+
+func TestResolveMihomoBinaryFindsDownloadedCoreLayout(t *testing.T) {
+ root := t.TempDir()
+ binPath := filepath.Join(root, "bin", runtime.GOOS+"-"+runtime.GOARCH, "mihomo", "mihomo.exe")
+ if runtime.GOOS != "windows" {
+ binPath = filepath.Join(root, "bin", runtime.GOOS+"-"+runtime.GOARCH, "mihomo", "mihomo")
+ }
+ if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(binPath, []byte("test"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ manager := &ClashManager{Config: &config.Config{}, AppRoot: root}
+ got, err := manager.resolveMihomoBinary()
+ if err != nil {
+ t.Fatalf("resolveMihomoBinary returned error: %v", err)
+ }
+ if got != binPath {
+ t.Fatalf("resolveMihomoBinary = %q, want %q", got, binPath)
+ }
+}
diff --git a/backend/internal/proxy/normalize.go b/backend/internal/proxy/normalize.go
index 4888775d..0c5f2428 100644
--- a/backend/internal/proxy/normalize.go
+++ b/backend/internal/proxy/normalize.go
@@ -53,6 +53,7 @@ func NormalizeBrowserProxies(proxies []config.BrowserProxy, generateID func() st
ProxyId: proxyID,
ProxyName: proxyName,
ProxyConfig: proxyConfig,
+ PreferredKernel: NormalizePreferredKernel(item.PreferredKernel),
DnsServers: strings.TrimSpace(item.DnsServers),
GroupName: strings.TrimSpace(item.GroupName),
SourceID: sourceID,
diff --git a/backend/internal/proxy/normalize_test.go b/backend/internal/proxy/normalize_test.go
index 9f0e2983..a37fc1a4 100644
--- a/backend/internal/proxy/normalize_test.go
+++ b/backend/internal/proxy/normalize_test.go
@@ -73,3 +73,13 @@ func TestNormalizeBrowserProxiesKeepsExistingBuiltin(t *testing.T) {
t.Fatalf("len = %d, want 1", len(proxies))
}
}
+
+func TestNormalizeBrowserProxiesKeepsPreferredKernel(t *testing.T) {
+ proxies := NormalizeBrowserProxies([]config.BrowserProxy{
+ {ProxyId: "p1", ProxyName: "main", ProxyConfig: "http://127.0.0.1:8080", PreferredKernel: " singbox "},
+ }, nil)
+
+ if proxies[1].PreferredKernel != ProxyKernelSingBox {
+ t.Fatalf("preferred kernel = %q, want sing-box", proxies[1].PreferredKernel)
+ }
+}
diff --git a/backend/internal/proxy/parser_clash_other_protocols.go b/backend/internal/proxy/parser_clash_other_protocols.go
index 7b979920..3d33170f 100644
--- a/backend/internal/proxy/parser_clash_other_protocols.go
+++ b/backend/internal/proxy/parser_clash_other_protocols.go
@@ -11,22 +11,23 @@ func buildOutboundFromClashTrojan(node map[string]interface{}) (map[string]inter
sni = getMapString(node, "servername")
}
network := getMapString(node, "network")
- skipVerify := getMapBool(node, "skip-cert-verify")
-
out := map[string]interface{}{
"protocol": "trojan",
"tag": "proxy-out",
"settings": map[string]interface{}{
- "address": host,
- "port": port,
- "password": password,
+ "servers": []interface{}{
+ map[string]interface{}{
+ "address": host,
+ "port": port,
+ "password": password,
+ },
+ },
},
}
stream := map[string]interface{}{
"security": "tls",
"tlsSettings": map[string]interface{}{
- "serverName": sni,
- "allowInsecure": skipVerify,
+ "serverName": sni,
},
}
applyClashTLSClientOptions(node, stream["tlsSettings"].(map[string]interface{}))
diff --git a/backend/internal/proxy/parser_clash_transport.go b/backend/internal/proxy/parser_clash_transport.go
index adb72326..1565883e 100644
--- a/backend/internal/proxy/parser_clash_transport.go
+++ b/backend/internal/proxy/parser_clash_transport.go
@@ -1,11 +1,17 @@
package proxy
-import "strings"
+import (
+ "net/url"
+ "strings"
+)
func applyClashTLSClientOptions(node map[string]interface{}, tlsSettings map[string]interface{}) {
if fingerprint := getMapString(node, "client-fingerprint"); fingerprint != "" {
tlsSettings["fingerprint"] = fingerprint
}
+ if getMapBool(node, "skip-cert-verify") {
+ tlsSettings[xrayTLSInsecurePinKey] = true
+ }
if alpnRaw, ok := node["alpn"]; ok {
if alpnList := toStringSlice(alpnRaw); len(alpnList) > 0 {
tlsSettings["alpn"] = alpnList
@@ -78,3 +84,13 @@ func firstNonEmptyMapString(m map[string]interface{}, keys ...string) string {
}
return ""
}
+
+func queryBool(query url.Values, keys ...string) bool {
+ for _, key := range keys {
+ value := strings.ToLower(strings.TrimSpace(query.Get(key)))
+ if value == "1" || value == "true" || value == "yes" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/proxy/parser_clash_v_protocols.go b/backend/internal/proxy/parser_clash_v_protocols.go
index dd97446b..6ed13c47 100644
--- a/backend/internal/proxy/parser_clash_v_protocols.go
+++ b/backend/internal/proxy/parser_clash_v_protocols.go
@@ -63,7 +63,6 @@ func buildOutboundFromClashVless(node map[string]interface{}) (map[string]interf
if sni != "" {
tlsSettings["serverName"] = sni
}
- tlsSettings["allowInsecure"] = getMapBool(node, "skip-cert-verify")
applyClashTLSClientOptions(node, tlsSettings)
stream["security"] = "tls"
stream["tlsSettings"] = tlsSettings
@@ -122,7 +121,6 @@ func buildOutboundFromClashVmess(node map[string]interface{}) (map[string]interf
if sni != "" {
tlsSettings["serverName"] = sni
}
- tlsSettings["allowInsecure"] = getMapBool(node, "skip-cert-verify")
applyClashTLSClientOptions(node, tlsSettings)
stream["security"] = "tls"
stream["tlsSettings"] = tlsSettings
diff --git a/backend/internal/proxy/parser_compat_test.go b/backend/internal/proxy/parser_compat_test.go
index 2577d213..f9663639 100644
--- a/backend/internal/proxy/parser_compat_test.go
+++ b/backend/internal/proxy/parser_compat_test.go
@@ -29,6 +29,12 @@ ws-opts:
}
stream := outbound["streamSettings"].(map[string]interface{})
tlsSettings := stream["tlsSettings"].(map[string]interface{})
+ if _, ok := tlsSettings["allowInsecure"]; ok {
+ t.Fatalf("tlsSettings must not include deprecated allowInsecure: %#v", tlsSettings)
+ }
+ if tlsSettings[xrayTLSInsecurePinKey] != true {
+ t.Fatalf("tlsSettings missing insecure pin marker: %#v", tlsSettings)
+ }
if tlsSettings["fingerprint"] != "chrome" {
t.Fatalf("fingerprint = %v, want chrome", tlsSettings["fingerprint"])
}
@@ -43,6 +49,31 @@ ws-opts:
}
}
+func TestClashTrojanSkipCertVerifyUsesXrayPinMarker(t *testing.T) {
+ src := `
+name: trojan
+type: trojan
+server: edge.example.com
+port: 443
+password: pass
+sni: sni.example.com
+skip-cert-verify: true
+`
+
+ _, outbound, err := ParseProxyNode(src)
+ if err != nil {
+ t.Fatalf("ParseProxyNode returned error: %v", err)
+ }
+ stream := outbound["streamSettings"].(map[string]interface{})
+ tlsSettings := stream["tlsSettings"].(map[string]interface{})
+ if _, ok := tlsSettings["allowInsecure"]; ok {
+ t.Fatalf("tlsSettings must not include deprecated allowInsecure: %#v", tlsSettings)
+ }
+ if tlsSettings[xrayTLSInsecurePinKey] != true {
+ t.Fatalf("tlsSettings missing insecure pin marker: %#v", tlsSettings)
+ }
+}
+
func TestClashVmessGRPCFallbackServiceName(t *testing.T) {
src := `
name: vmess-grpc
@@ -206,6 +237,7 @@ func TestSingBoxHysteria2ClashKeepsTLSFingerprintAndCongestion(t *testing.T) {
type: hysteria2
server: hy.example.com
port: 443
+ports: 20000-50000, 51000-52000
password: test-password
sni: sni.example.com
alpn:
@@ -221,6 +253,13 @@ congestion-control: bbr
if out["congestion_control"] != "bbr" {
t.Fatalf("congestion_control = %v, want bbr", out["congestion_control"])
}
+ serverPorts, ok := out["server_ports"].(string)
+ if !ok {
+ t.Fatalf("server_ports is %T, want string", out["server_ports"])
+ }
+ if serverPorts != "20000:50000,51000:52000" {
+ t.Fatalf("server_ports = %#v, want converted ranges", serverPorts)
+ }
tls := out["tls"].(map[string]interface{})
alpn := tls["alpn"].([]string)
if len(alpn) != 1 || alpn[0] != "h3" {
diff --git a/backend/internal/proxy/parser_uri.go b/backend/internal/proxy/parser_uri.go
index d2f56acd..9dc912ac 100644
--- a/backend/internal/proxy/parser_uri.go
+++ b/backend/internal/proxy/parser_uri.go
@@ -87,6 +87,8 @@ func buildOutboundVless(node string) (map[string]interface{}, error) {
flow := q.Get("flow")
sec := strings.ToLower(q.Get("security"))
sni := q.Get("sni")
+ fingerprint := firstNonEmptyQueryValue(q, "fp", "client-fingerprint", "fingerprint")
+ insecure := queryBool(q, "insecure", "allowInsecure")
out := map[string]interface{}{
"protocol": "vless",
"tag": "proxy-out",
@@ -109,8 +111,18 @@ func buildOutboundVless(node string) (map[string]interface{}, error) {
stream := map[string]interface{}{}
if sec == "tls" || sec == "reality" {
stream["security"] = "tls"
+ tlsSettings := map[string]interface{}{}
if sni != "" {
- stream["tlsSettings"] = map[string]interface{}{"serverName": sni}
+ tlsSettings["serverName"] = sni
+ }
+ if fingerprint != "" {
+ tlsSettings["fingerprint"] = fingerprint
+ }
+ if insecure {
+ tlsSettings[xrayTLSInsecurePinKey] = true
+ }
+ if len(tlsSettings) > 0 {
+ stream["tlsSettings"] = tlsSettings
}
}
network := q.Get("type")
@@ -157,24 +169,36 @@ func buildOutboundTrojan(node string) (map[string]interface{}, error) {
if sni == "" {
sni = q.Get("peer")
}
- skipVerify := q.Get("allowInsecure") == "1" || strings.ToLower(q.Get("allowInsecure")) == "true"
+ fingerprint := firstNonEmptyQueryValue(q, "fp", "client-fingerprint", "fingerprint")
+ insecure := queryBool(q, "insecure", "allowInsecure")
network := q.Get("type")
out := map[string]interface{}{
"protocol": "trojan",
"tag": "proxy-out",
"settings": map[string]interface{}{
- "address": host,
- "port": p,
- "password": password,
+ "servers": []interface{}{
+ map[string]interface{}{
+ "address": host,
+ "port": p,
+ "password": password,
+ },
+ },
},
}
stream := map[string]interface{}{
- "security": "tls",
- "tlsSettings": map[string]interface{}{
- "serverName": sni,
- "allowInsecure": skipVerify,
- },
+ "security": "tls",
+ "tlsSettings": map[string]interface{}{},
+ }
+ tlsSettings := stream["tlsSettings"].(map[string]interface{})
+ if sni != "" {
+ tlsSettings["serverName"] = sni
+ }
+ if fingerprint != "" {
+ tlsSettings["fingerprint"] = fingerprint
+ }
+ if insecure {
+ tlsSettings[xrayTLSInsecurePinKey] = true
}
if network == "ws" {
stream["network"] = "ws"
diff --git a/backend/internal/proxy/runtime_config_test.go b/backend/internal/proxy/runtime_config_test.go
index 532948c8..5c932a45 100644
--- a/backend/internal/proxy/runtime_config_test.go
+++ b/backend/internal/proxy/runtime_config_test.go
@@ -60,6 +60,93 @@ func TestXrayRuntimeConfigEnablesBrowserSniffing(t *testing.T) {
}
}
+func TestXrayRuntimeConfigRemovesDeprecatedAllowInsecure(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Browser.UserDataRoot = t.TempDir()
+ manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()}
+
+ cfgPath, err := manager.buildRuntimeConfigWithRoute(
+ "deprecated-field-test",
+ []interface{}{
+ map[string]interface{}{
+ "protocol": "trojan",
+ "tag": "proxy-out",
+ "streamSettings": map[string]interface{}{
+ "security": "tls",
+ "tlsSettings": map[string]interface{}{
+ "serverName": "example.com",
+ "allowInsecure": true,
+ },
+ },
+ },
+ },
+ []interface{}{},
+ 19095,
+ "",
+ )
+ if err != nil {
+ t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err)
+ }
+
+ runtimeConfig := readRuntimeConfigMap(t, cfgPath)
+ outbounds := runtimeConfig["outbounds"].([]interface{})
+ outbound := outbounds[0].(map[string]interface{})
+ stream := outbound["streamSettings"].(map[string]interface{})
+ tlsSettings := stream["tlsSettings"].(map[string]interface{})
+ if _, ok := tlsSettings["allowInsecure"]; ok {
+ t.Fatalf("runtime config must not include deprecated allowInsecure: %#v", tlsSettings)
+ }
+}
+
+func TestXrayRuntimeConfigKeepsTrojanServersArray(t *testing.T) {
+ node := "trojan://password@example.com:443?peer=sni.example.com&sni=sni.example.com&type=tcp"
+ _, outbound, err := ParseProxyNode(node)
+ if err != nil {
+ t.Fatalf("ParseProxyNode returned error: %v", err)
+ }
+
+ cfg := config.DefaultConfig()
+ cfg.Browser.UserDataRoot = t.TempDir()
+ manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()}
+ cfgPath, err := manager.buildRuntimeConfigWithRoute(
+ "trojan-shape-test",
+ []interface{}{outbound},
+ []interface{}{},
+ 19096,
+ "",
+ )
+ if err != nil {
+ t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err)
+ }
+
+ runtimeConfig := readRuntimeConfigMap(t, cfgPath)
+ outbounds := runtimeConfig["outbounds"].([]interface{})
+ outboundConfig := outbounds[0].(map[string]interface{})
+ settings := outboundConfig["settings"].(map[string]interface{})
+ servers, ok := settings["servers"].([]interface{})
+ if !ok || len(servers) != 1 {
+ t.Fatalf("trojan settings.servers invalid: %#v", settings["servers"])
+ }
+ server := servers[0].(map[string]interface{})
+ if server["address"] != "example.com" || server["port"] != float64(443) || server["password"] != "password" {
+ t.Fatalf("trojan server invalid: %#v", server)
+ }
+ if _, ok := settings["address"]; ok {
+ t.Fatalf("legacy flat trojan settings should not be present: %#v", settings)
+ }
+}
+
+func TestSummarizeXrayErrorReturnsShortConfigReason(t *testing.T) {
+ raw := `Xray 26.6.20 (Xray, Penetrates Everything.)
+Failed to start: main: failed to load config files: [xray-config.json] > infra/conf: failed to build outbound config with tag proxy-out > infra/conf: Failed to build stream settings for outbound detour. > infra/conf: Failed to build TLS config. > The feature "allowInsecure" has been removed and migrated to "certificate". Please update your config(s) according to release note and documentation before removal.`
+
+ got := summarizeXrayError(raw)
+ want := "字段 allowInsecure 已被当前 Xray 移除"
+ if got != want {
+ t.Fatalf("summary = %q, want %q", got, want)
+ }
+}
+
func TestSingBoxRuntimeConfigUsesWarnLogLevel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = t.TempDir()
diff --git a/backend/internal/proxy/singbox_anytls_test.go b/backend/internal/proxy/singbox_anytls_test.go
index 744513f9..145222b6 100644
--- a/backend/internal/proxy/singbox_anytls_test.go
+++ b/backend/internal/proxy/singbox_anytls_test.go
@@ -152,3 +152,39 @@ port: 443
t.Fatalf("expected missing password to fail")
}
}
+
+func TestBuildSingBoxAnyTLSFromURI(t *testing.T) {
+ src := "anytls://test-password@anytls.example.com:25535?sni=ai.gitee.com&insecure=1&idle-session-timeout=30#AnyTLS"
+ if !IsSingBoxProtocol(src) {
+ t.Fatalf("expected anytls URI to be treated as sing-box protocol")
+ }
+ out, err := BuildSingBoxOutbound(src)
+ if err != nil {
+ t.Fatalf("BuildSingBoxOutbound returned error: %v", err)
+ }
+ if got := out["type"]; got != "anytls" {
+ t.Fatalf("type = %v, want anytls", got)
+ }
+ if got := out["server"]; got != "anytls.example.com" {
+ t.Fatalf("server = %v, want anytls.example.com", got)
+ }
+ if got := out["server_port"]; got != 25535 {
+ t.Fatalf("server_port = %v, want 25535", got)
+ }
+ if got := out["password"]; got != "test-password" {
+ t.Fatalf("password = %v, want test-password", got)
+ }
+ if got := out["idle_session_timeout"]; got != "30s" {
+ t.Fatalf("idle_session_timeout = %v, want 30s", got)
+ }
+ tls, ok := out["tls"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("tls is %T, want map[string]interface{}", out["tls"])
+ }
+ if got := tls["server_name"]; got != "ai.gitee.com" {
+ t.Fatalf("tls.server_name = %v, want ai.gitee.com", got)
+ }
+ if got := tls["insecure"]; got != true {
+ t.Fatalf("tls.insecure = %v, want true", got)
+ }
+}
diff --git a/backend/internal/proxy/singbox_bridge_cleanup.go b/backend/internal/proxy/singbox_bridge_cleanup.go
index 27d1060d..92d6f7d2 100644
--- a/backend/internal/proxy/singbox_bridge_cleanup.go
+++ b/backend/internal/proxy/singbox_bridge_cleanup.go
@@ -29,6 +29,9 @@ func (m *SingBoxManager) recycleIdleBridges() {
delete(m.Bridges, key)
continue
}
+ if bridge.RefCount > 0 {
+ continue
+ }
if now.Sub(bridge.LastUsedAt) < singBoxBridgeIdleTTL {
continue
}
diff --git a/backend/internal/proxy/singbox_bridge_recovery.go b/backend/internal/proxy/singbox_bridge_recovery.go
index fb862098..785f94bf 100644
--- a/backend/internal/proxy/singbox_bridge_recovery.go
+++ b/backend/internal/proxy/singbox_bridge_recovery.go
@@ -20,7 +20,7 @@ func cloneStringInterfaceMap(items map[string]interface{}) map[string]interface{
return cloned
}
-func (m *SingBoxManager) restartBridgeOnSamePort(log *logger.Logger, key string, bridge *SingBoxBridge) error {
+func (m *SingBoxManager) restartBridgeOnSamePort(log *logger.Logger, key string, bridge *SingBoxBridge, refCount int) error {
if bridge == nil {
return fmt.Errorf("sing-box 桥接不存在")
}
@@ -50,6 +50,7 @@ func (m *SingBoxManager) restartBridgeOnSamePort(log *logger.Logger, key string,
return err
}
restarted.RestartCount = bridge.RestartCount + 1
+ restarted.RefCount = refCount
restarted.LastUsedAt = time.Now()
m.mu.Lock()
if current := m.Bridges[key]; current != bridge {
diff --git a/backend/internal/proxy/singbox_bridge_runtime.go b/backend/internal/proxy/singbox_bridge_runtime.go
index 02e281f6..3701caf8 100644
--- a/backend/internal/proxy/singbox_bridge_runtime.go
+++ b/backend/internal/proxy/singbox_bridge_runtime.go
@@ -12,38 +12,68 @@ import (
"time"
)
-// EnsureBridge 确保 sing-box 桥接进程运行,返回 socks5://127.0.0.1:port
+// EnsureBridge 确保 sing-box 桥接进程运行,用于临时请求场景。
func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
+ socksURL, _, err := m.ensureBridge(proxyConfig, proxies, proxyId, false)
+ return socksURL, err
+}
+
+// AcquireBridge 获取一个带引用计数的 sing-box 桥接,用于浏览器实例等长生命周期场景。
+func (m *SingBoxManager) AcquireBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, string, error) {
+ return m.ensureBridge(proxyConfig, proxies, proxyId, true)
+}
+
+// ReleaseBridge 释放一个已占用的桥接引用;空闲桥接会由后台回收协程延迟清理。
+func (m *SingBoxManager) ReleaseBridge(key string) {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ bridge, ok := m.Bridges[key]
+ if !ok || bridge == nil {
+ return
+ }
+ if bridge.RefCount > 0 {
+ bridge.RefCount--
+ }
+ bridge.LastUsedAt = time.Now()
+}
+
+func (m *SingBoxManager) ensureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) {
log := logger.New("SingBox")
src := resolveProxyConfig(proxyConfig, proxies, proxyId)
if src == "" {
- return "", fmt.Errorf("未找到代理节点")
+ return "", "", fmt.Errorf("未找到代理节点")
}
src = normalizeNodeScheme(src)
outbound, err := BuildSingBoxOutbound(src)
if err != nil {
log.Error("节点解析失败", logger.F("error", err))
- return "", err
+ return "", "", err
}
key := computeNodeKey(src)
- if socksURL, reused := m.tryReuseBridge(key); reused {
+ if socksURL, reused := m.tryReuseBridge(key, pin); reused {
log.Info("复用 sing-box 桥接", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("socks_url", socksURL))
- return socksURL, nil
+ return socksURL, key, nil
}
unlockLaunch := m.lockLaunchForKey(key)
defer unlockLaunch()
- if socksURL, reused := m.tryReuseBridge(key); reused {
+ if socksURL, reused := m.tryReuseBridge(key, pin); reused {
log.Info("复用 sing-box 桥接", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("socks_url", socksURL))
- return socksURL, nil
+ return socksURL, key, nil
}
binaryPath, err := m.resolveBinary()
if err != nil {
log.Error("sing-box 不可用", logger.F("error", err), logger.F("appRoot", m.AppRoot))
- return "", err
+ return "", "", err
}
log.Debug("sing-box binary", logger.F("path", binaryPath))
@@ -67,18 +97,18 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows
continue
}
- if socksURL, reused := m.registerBridge(key, bridge); reused {
+ if socksURL, reused := m.registerBridge(key, bridge, pin); reused {
log.Info("复用已就绪 sing-box 桥接", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("socks_url", socksURL))
bridge.Stopping = true
m.stopBridgeProcess(bridge)
- return socksURL, nil
+ return socksURL, key, nil
}
go m.watchBridge(bridge, key)
- return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil
+ return fmt.Sprintf("socks5://127.0.0.1:%d", port), key, nil
}
- return "", fmt.Errorf("sing-box 启动失败(已尝试 %d 次): %w", attemptsUsed, lastErr)
+ return "", "", fmt.Errorf("sing-box 启动失败(已尝试 %d 次): %w", attemptsUsed, lastErr)
}
func (m *SingBoxManager) launchBridgeOnPort(log *logger.Logger, key string, binaryPath string, outbound map[string]interface{}, port int, attempt int) (*SingBoxBridge, error) {
@@ -120,7 +150,7 @@ func (m *SingBoxManager) launchBridgeOnPort(log *logger.Logger, key string, bina
bridge.startExitWatcher()
log.Info("sing-box 内核进程已启动", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
- if err := m.waitBridgeSocksReady(bridge, 10*time.Second); err != nil {
+ if err := m.waitBridgeSocksReady(bridge, m.bridgeStartTimeout()); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
@@ -146,6 +176,13 @@ func (m *SingBoxManager) launchBridgeOnPort(log *logger.Logger, key string, bina
return bridge, nil
}
+func (m *SingBoxManager) bridgeStartTimeout() time.Duration {
+ if m != nil && m.Config != nil && m.Config.ProxyCheck.BridgeStartTimeoutMs > 0 {
+ return time.Duration(m.Config.ProxyCheck.BridgeStartTimeoutMs) * time.Millisecond
+ }
+ return time.Duration(defaultBridgeStartTimeoutMs) * time.Millisecond
+}
+
type singBoxLaunchError struct {
err error
retryable bool
@@ -278,13 +315,17 @@ func (m *SingBoxManager) StopAll() {
}
}
-func (m *SingBoxManager) tryReuseBridge(key string) (string, bool) {
+func (m *SingBoxManager) tryReuseBridge(key string, pin bool) (string, bool) {
var stale *SingBoxBridge
m.mu.Lock()
if bridge, ok := m.Bridges[key]; ok && bridge != nil {
alive := bridge.Running && bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil
if alive && waitSocks5Ready("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
+ if pin {
+ bridge.RefCount++
+ }
+ bridge.LastUsedAt = time.Now()
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port)
m.mu.Unlock()
return socksURL, true
@@ -302,7 +343,7 @@ func (m *SingBoxManager) tryReuseBridge(key string) (string, bool) {
return "", false
}
-func (m *SingBoxManager) registerBridge(key string, bridge *SingBoxBridge) (string, bool) {
+func (m *SingBoxManager) registerBridge(key string, bridge *SingBoxBridge, pin bool) (string, bool) {
var duplicate *SingBoxBridge
m.mu.Lock()
@@ -314,6 +355,10 @@ func (m *SingBoxManager) registerBridge(key string, bridge *SingBoxBridge) (stri
alive := existing.Running && existing.Cmd != nil && existing.Cmd.Process != nil && existing.Cmd.ProcessState == nil
if alive && waitSocks5Ready("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
+ if pin {
+ existing.RefCount++
+ }
+ existing.LastUsedAt = time.Now()
duplicate = bridge
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port)
m.mu.Unlock()
@@ -324,10 +369,21 @@ func (m *SingBoxManager) registerBridge(key string, bridge *SingBoxBridge) (stri
return socksURL, true
}
+ transferredRefCount := 0
+ if existing.Restarting && existing.RefCount > 0 {
+ transferredRefCount = existing.RefCount
+ }
existing.Stopping = true
delete(m.Bridges, key)
duplicate = existing
+ if transferredRefCount > 0 && !pin {
+ bridge.RefCount = transferredRefCount
+ }
}
+ if pin {
+ bridge.RefCount = 1
+ }
+ bridge.LastUsedAt = time.Now()
m.Bridges[key] = bridge
m.mu.Unlock()
@@ -344,9 +400,12 @@ func (m *SingBoxManager) watchBridge(bridge *SingBoxBridge, key string) {
_ = bridge.waitExit()
var shouldRestart bool
+ var refCount int
m.mu.Lock()
if current, ok := m.Bridges[key]; ok && current == bridge {
- if !bridge.Stopping && !bridge.Restarting && bridge.RestartCount < 1 {
+ refCount = bridge.RefCount
+ if !bridge.Stopping && refCount > 0 && !bridge.Restarting {
+ bridge.Restarting = true
shouldRestart = true
} else {
delete(m.Bridges, key)
@@ -358,7 +417,7 @@ func (m *SingBoxManager) watchBridge(bridge *SingBoxBridge, key string) {
if shouldRestart {
log := logger.New("SingBox")
- if err := m.restartBridgeOnSamePort(log, key, bridge); err == nil {
+ if err := m.restartBridgeOnSamePort(log, key, bridge, refCount); err == nil {
return
} else if errors.Is(err, errSingBoxBridgeRestartNotNeeded) {
return
diff --git a/backend/internal/proxy/singbox_parser.go b/backend/internal/proxy/singbox_parser.go
index c32e5b62..09793881 100644
--- a/backend/internal/proxy/singbox_parser.go
+++ b/backend/internal/proxy/singbox_parser.go
@@ -12,7 +12,7 @@ import (
// IsSingBoxProtocol 判断是否为 sing-box 支持的协议(hysteria2/tuic)
func IsSingBoxProtocol(proxyConfig string) bool {
l := strings.ToLower(strings.TrimSpace(proxyConfig))
- if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
+ if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "anytls://") {
return true
}
// Clash YAML 格式
@@ -33,6 +33,9 @@ func BuildSingBoxOutbound(node string) (map[string]interface{}, error) {
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
return parseHysteria2URI(src)
}
+ if strings.HasPrefix(l, "anytls://") {
+ return parseAnyTLSURI(src)
+ }
// Clash YAML 格式
if strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
@@ -71,6 +74,7 @@ func parseHysteria2URI(node string) (map[string]interface{}, error) {
}
insecure := q.Get("insecure") == "1" || strings.ToLower(q.Get("insecure")) == "true"
obfsPassword := q.Get("obfs-password")
+ serverPorts := parseSingBoxServerPorts(q.Get("mport"))
if host == "" || port == 0 {
return nil, fmt.Errorf("hysteria2 节点信息不完整: host=%s port=%d", host, port)
@@ -91,6 +95,10 @@ func parseHysteria2URI(node string) (map[string]interface{}, error) {
if sni != "" {
out["tls"].(map[string]interface{})["server_name"] = sni
}
+ if serverPorts != "" {
+ out["server_ports"] = serverPorts
+ delete(out, "server_port")
+ }
applySingBoxTLSClientOptionsFromQuery(q, out["tls"].(map[string]interface{}))
if obfsPassword != "" {
@@ -103,6 +111,63 @@ func parseHysteria2URI(node string) (map[string]interface{}, error) {
return out, nil
}
+func parseAnyTLSURI(node string) (map[string]interface{}, error) {
+ u, err := url.Parse(node)
+ if err != nil {
+ return nil, fmt.Errorf("anytls URI 解析失败: %v", err)
+ }
+ host := u.Hostname()
+ portStr := u.Port()
+ port, _ := strconv.Atoi(portStr)
+ password := u.User.Username()
+ q := u.Query()
+ sni := firstNonEmptyQueryValue(q, "sni", "peer", "servername")
+ insecure := queryBool(q, "insecure", "allowInsecure", "skip-cert-verify")
+
+ if host == "" || port == 0 || password == "" {
+ return nil, fmt.Errorf("anytls URI 节点信息不完整: host=%s port=%d password_empty=%v", host, port, password == "")
+ }
+
+ tls := map[string]interface{}{
+ "enabled": true,
+ "insecure": insecure,
+ }
+ if sni != "" {
+ tls["server_name"] = sni
+ }
+ applySingBoxTLSClientOptionsFromQuery(q, tls)
+
+ out := map[string]interface{}{
+ "type": "anytls",
+ "tag": "proxy-out",
+ "server": host,
+ "server_port": port,
+ "password": password,
+ "tls": tls,
+ }
+ if interval := queryDurationSecondsString(q, "idle-session-check-interval"); interval != "" {
+ out["idle_session_check_interval"] = interval
+ }
+ if timeout := queryDurationSecondsString(q, "idle-session-timeout"); timeout != "" {
+ out["idle_session_timeout"] = timeout
+ }
+ if minIdleSession, err := strconv.Atoi(strings.TrimSpace(q.Get("min-idle-session"))); err == nil && minIdleSession > 0 {
+ out["min_idle_session"] = minIdleSession
+ }
+ return out, nil
+}
+
+func queryDurationSecondsString(q url.Values, key string) string {
+ value := strings.TrimSpace(q.Get(key))
+ if value == "" {
+ return ""
+ }
+ if _, err := strconv.Atoi(value); err == nil {
+ return value + "s"
+ }
+ return value
+}
+
// parseClashSingBoxNode 解析 Clash YAML 格式的 sing-box 节点
func parseClashSingBoxNode(src string) (map[string]interface{}, error) {
// 复用已有的 YAML 解析基础设施
@@ -204,6 +269,9 @@ func buildSingBoxHysteria2FromClash(node map[string]interface{}) (map[string]int
"password": password,
"tls": tls,
}
+ if serverPorts := clashHysteria2ServerPorts(node); serverPorts != "" {
+ out["server_ports"] = serverPorts
+ }
// 带宽限制(可选)
if up := getMapString(node, "up"); up != "" {
@@ -227,6 +295,26 @@ func buildSingBoxHysteria2FromClash(node map[string]interface{}) (map[string]int
return out, nil
}
+func clashHysteria2ServerPorts(node map[string]interface{}) string {
+ return parseSingBoxServerPorts(firstNonEmptyMapString(node, "ports", "mport"))
+}
+
+func parseSingBoxServerPorts(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return ""
+ }
+ ranges := make([]string, 0)
+ for _, item := range strings.Split(raw, ",") {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ item = strings.ReplaceAll(item, "-", ":")
+ ranges = append(ranges, item)
+ }
+ return strings.Join(ranges, ",")
+}
+
func buildSingBoxTUICFromClash(node map[string]interface{}) (map[string]interface{}, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
diff --git a/backend/internal/proxy/singbox_runtime_helpers.go b/backend/internal/proxy/singbox_runtime_helpers.go
index 75a37fdf..4630f499 100644
--- a/backend/internal/proxy/singbox_runtime_helpers.go
+++ b/backend/internal/proxy/singbox_runtime_helpers.go
@@ -91,6 +91,7 @@ func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}
"output": filepath.Join(baseDir, "singbox.log"),
"timestamp": true,
},
+ "dns": defaultSingBoxDNSConfig(),
"inbounds": []interface{}{
map[string]interface{}{
"type": "socks",
@@ -107,7 +108,12 @@ func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}
},
},
"route": map[string]interface{}{
+ "default_domain_resolver": "public-dns",
"rules": []interface{}{
+ map[string]interface{}{
+ "protocol": "dns",
+ "outbound": "direct",
+ },
map[string]interface{}{
"inbound": []string{"socks-in"},
"outbound": "proxy-out",
@@ -128,6 +134,25 @@ func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}
return cfgPath, nil
}
+func defaultSingBoxDNSConfig() map[string]interface{} {
+ return map[string]interface{}{
+ "servers": []interface{}{
+ map[string]interface{}{
+ "type": "udp",
+ "tag": "public-dns",
+ "server": "223.5.5.5",
+ },
+ map[string]interface{}{
+ "type": "udp",
+ "tag": "backup-dns",
+ "server": "119.29.29.29",
+ },
+ },
+ "final": "public-dns",
+ "strategy": "ipv4_only",
+ }
+}
+
func (m *SingBoxManager) resolveWorkdir(key string) string {
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
if root == "" {
diff --git a/backend/internal/proxy/singbox_runtime_helpers_test.go b/backend/internal/proxy/singbox_runtime_helpers_test.go
new file mode 100644
index 00000000..df1a886b
--- /dev/null
+++ b/backend/internal/proxy/singbox_runtime_helpers_test.go
@@ -0,0 +1,65 @@
+package proxy
+
+import (
+ "ant-chrome/backend/internal/config"
+ "encoding/json"
+ "os"
+ "testing"
+)
+
+func TestDefaultSingBoxDNSConfigUsesPublicIPv4Servers(t *testing.T) {
+ dns := defaultSingBoxDNSConfig()
+ if dns["final"] != "public-dns" {
+ t.Fatalf("dns.final = %v, want public-dns", dns["final"])
+ }
+ if dns["strategy"] != "ipv4_only" {
+ t.Fatalf("dns.strategy = %v, want ipv4_only", dns["strategy"])
+ }
+ servers, ok := dns["servers"].([]interface{})
+ if !ok || len(servers) < 2 {
+ t.Fatalf("dns.servers = %#v, want at least two servers", dns["servers"])
+ }
+ first, ok := servers[0].(map[string]interface{})
+ if !ok {
+ t.Fatalf("first dns server is %T, want map", servers[0])
+ }
+ if first["type"] != "udp" || first["server"] != "223.5.5.5" {
+ t.Fatalf("first dns server = %#v", first)
+ }
+}
+
+func TestDefaultXrayDNSConfigUsesPublicServers(t *testing.T) {
+ dns := defaultXrayDNSConfig()
+ servers, ok := dns["servers"].([]interface{})
+ if !ok || len(servers) != 2 {
+ t.Fatalf("dns.servers = %#v, want two servers", dns["servers"])
+ }
+ if servers[0] != "223.5.5.5" || servers[1] != "119.29.29.29" {
+ t.Fatalf("dns.servers = %#v", servers)
+ }
+}
+
+func TestSingBoxRouteUsesDefaultDomainResolver(t *testing.T) {
+ appConfig := config.DefaultConfig()
+ appConfig.Browser.UserDataRoot = t.TempDir()
+ m := &SingBoxManager{Config: appConfig, AppRoot: t.TempDir()}
+ cfgPath, err := m.buildConfig("dns-route-test", map[string]interface{}{"type": "direct", "tag": "proxy-out"}, 12345)
+ if err != nil {
+ t.Fatalf("buildConfig returned error: %v", err)
+ }
+ var generatedConfig map[string]interface{}
+ data, err := os.ReadFile(cfgPath)
+ if err != nil {
+ t.Fatalf("read config failed: %v", err)
+ }
+ if err := json.Unmarshal(data, &generatedConfig); err != nil {
+ t.Fatalf("decode config failed: %v", err)
+ }
+ route, ok := generatedConfig["route"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("route is %T, want map", generatedConfig["route"])
+ }
+ if route["default_domain_resolver"] != "public-dns" {
+ t.Fatalf("default_domain_resolver = %v", route["default_domain_resolver"])
+ }
+}
diff --git a/backend/internal/proxy/singbox_test.go b/backend/internal/proxy/singbox_test.go
index fe6f6096..a898e984 100644
--- a/backend/internal/proxy/singbox_test.go
+++ b/backend/internal/proxy/singbox_test.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
)
func TestSingBoxRegisterBridgeStoresNewBridge(t *testing.T) {
@@ -18,7 +19,7 @@ func TestSingBoxRegisterBridgeStoresNewBridge(t *testing.T) {
Running: true,
}
- socksURL, reused := manager.registerBridge("node-a", bridge)
+ socksURL, reused := manager.registerBridge("node-a", bridge, false)
if reused {
t.Fatalf("expected new bridge registration, got reused with %q", socksURL)
}
@@ -41,7 +42,7 @@ func TestSingBoxRegisterBridgeIgnoresSamePointer(t *testing.T) {
}
manager.Bridges["node-a"] = bridge
- socksURL, reused := manager.registerBridge("node-a", bridge)
+ socksURL, reused := manager.registerBridge("node-a", bridge, false)
if reused {
t.Fatalf("same bridge pointer must not be treated as duplicate, got reused with %q", socksURL)
}
@@ -56,6 +57,45 @@ func TestSingBoxRegisterBridgeIgnoresSamePointer(t *testing.T) {
}
}
+func TestSingBoxRegisterBridgePinsLongLivedBridge(t *testing.T) {
+ t.Parallel()
+
+ manager := &SingBoxManager{Bridges: make(map[string]*SingBoxBridge)}
+ bridge := &SingBoxBridge{NodeKey: "node-a", Port: 21001, Running: true}
+
+ _, reused := manager.registerBridge("node-a", bridge, true)
+ if reused {
+ t.Fatalf("new bridge should not be reported as reused")
+ }
+ if bridge.RefCount != 1 {
+ t.Fatalf("pinned bridge refcount = %d, want 1", bridge.RefCount)
+ }
+
+ manager.ReleaseBridge("node-a")
+ if bridge.RefCount != 0 {
+ t.Fatalf("released bridge refcount = %d, want 0", bridge.RefCount)
+ }
+}
+
+func TestSingBoxRecycleIdleBridgesSkipsPinnedBridge(t *testing.T) {
+ t.Parallel()
+
+ manager := &SingBoxManager{Bridges: make(map[string]*SingBoxBridge)}
+ pinned := &SingBoxBridge{NodeKey: "pinned-node-key", LastUsedAt: time.Now().Add(-2 * singBoxBridgeIdleTTL), RefCount: 1}
+ idle := &SingBoxBridge{NodeKey: "idle-node-key", LastUsedAt: time.Now().Add(-2 * singBoxBridgeIdleTTL)}
+ manager.Bridges[pinned.NodeKey] = pinned
+ manager.Bridges[idle.NodeKey] = idle
+
+ manager.recycleIdleBridges()
+
+ if manager.Bridges[pinned.NodeKey] != pinned {
+ t.Fatalf("pinned bridge should not be recycled")
+ }
+ if _, ok := manager.Bridges[idle.NodeKey]; ok {
+ t.Fatalf("idle bridge should be recycled")
+ }
+}
+
func TestSingBoxLaunchErrorRetryClassification(t *testing.T) {
t.Parallel()
@@ -99,7 +139,7 @@ func TestSingBoxRestartBridgeNotNeededWhenBridgeChanged(t *testing.T) {
oldBridge := &SingBoxBridge{NodeKey: "node-a", Port: 21001, Outbound: map[string]interface{}{"type": "direct"}}
manager.Bridges["node-a"] = &SingBoxBridge{NodeKey: "node-a", Port: 21002}
- err := manager.restartBridgeOnSamePort(nil, "node-a", oldBridge)
+ err := manager.restartBridgeOnSamePort(nil, "node-a", oldBridge, oldBridge.RefCount)
if !errors.Is(err, errSingBoxBridgeRestartNotNeeded) {
t.Fatalf("restartBridgeOnSamePort() error = %v, want restart-not-needed", err)
}
@@ -112,7 +152,7 @@ func TestSingBoxRestartBridgeRequiresContext(t *testing.T) {
bridge := &SingBoxBridge{NodeKey: "node-a", Port: 21001}
manager.Bridges["node-a"] = bridge
- err := manager.restartBridgeOnSamePort(nil, "node-a", bridge)
+ err := manager.restartBridgeOnSamePort(nil, "node-a", bridge, bridge.RefCount)
if err == nil {
t.Fatalf("restartBridgeOnSamePort() returned nil, want missing context error")
}
diff --git a/backend/internal/proxy/singbox_types.go b/backend/internal/proxy/singbox_types.go
index 444811ac..b4c4242b 100644
--- a/backend/internal/proxy/singbox_types.go
+++ b/backend/internal/proxy/singbox_types.go
@@ -22,6 +22,7 @@ type SingBoxBridge struct {
Stopping bool
LastError string
Outbound map[string]interface{}
+ RefCount int
LastUsedAt time.Time
Restarting bool
RestartCount int
diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go
index bc30390f..18004cd0 100644
--- a/backend/internal/proxy/speedtest.go
+++ b/backend/internal/proxy/speedtest.go
@@ -1,44 +1,75 @@
package proxy
import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
"strings"
"time"
"github.com/metacubex/mihomo/adapter"
- "github.com/metacubex/mihomo/component/resolver"
+ "github.com/metacubex/mihomo/common/utils"
+ C "github.com/metacubex/mihomo/constant"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
)
-// ─── Mihomo 标准测速 URL ───
-// 与 mihomo-party / mihomo 默认延迟检测保持一致。
+// ─── Clash 标准测速 URL ───
+// 使用 HTTP 与 Clash 客户端保持一致
-const DefaultSpeedTestURL = "https://www.gstatic.com/generate_204"
+const DefaultSpeedTestURL = "http://www.gstatic.com/generate_204"
// SpeedTestConfig 测速参数
type SpeedTestConfig struct {
- Timeout time.Duration
- TCPTimeout time.Duration
- URLs []string
+ Timeout time.Duration
+ TCPTimeout time.Duration
+ URLs []string
+ ExpectedStatus []int
}
var DefaultSpeedTestConfig = SpeedTestConfig{
- Timeout: 10 * time.Second,
- TCPTimeout: 5 * time.Second,
+ Timeout: 3 * time.Second,
+ TCPTimeout: 3 * time.Second,
}
// ─── 对外入口 ───
-// SpeedTest 使用 mihomo 代理适配器进行测速。
-// 采用 unified-delay 策略:先建立连接(预热),再单独计时 HTTP 往返,
-// 与 Clash 客户端 unified-delay: true 的延迟结果一致。
+// SpeedTest 按单个代理的内核决策执行轻量 HTTP 延迟测试。
func SpeedTest(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
cfg *SpeedTestConfig,
+) TestResult {
+ return SpeedTestWithConnector(proxyId, proxies, xrayMgr, singboxMgr, nil, config.BrowserConnectorXray, cfg)
+}
+
+// SpeedTestWithConnector 保留 connectorType 参数用于旧调用兼容。
+// 实际测速内核由 ResolveProxyKernel 按单个代理决定。
+func SpeedTestWithConnector(
+ proxyId string,
+ proxies []config.BrowserProxy,
+ xrayMgr *XrayManager,
+ singboxMgr *SingBoxManager,
+ clashMgr *ClashManager,
+ connectorType string,
+ cfg *SpeedTestConfig,
+) TestResult {
+ connectorType = config.NormalizeBrowserConnectorType(connectorType)
+ return lightHTTPDelayTestWithConnector(proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, cfg)
+}
+
+func lightHTTPDelayTestWithConnector(
+ proxyId string,
+ proxies []config.BrowserProxy,
+ xrayMgr *XrayManager,
+ singboxMgr *SingBoxManager,
+ clashMgr *ClashManager,
+ connectorType string,
+ cfg *SpeedTestConfig,
) TestResult {
log := logger.New("SpeedTest")
@@ -49,80 +80,242 @@ func SpeedTest(
src := resolveProxyConfig("", proxies, proxyId)
if src == "" {
- return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: connectorType, Error: "代理配置为空"}
}
if strings.ToLower(src) == "direct://" {
- return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0}
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0, Engine: "direct"}
}
- testURL := strings.TrimSpace(DefaultSpeedTestURL)
- if len(cfg.URLs) > 0 {
- testURL = strings.TrimSpace(cfg.URLs[0])
+ testURLs := speedTestTargetURLs(cfg)
+ if len(testURLs) == 0 {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: connectorType, Error: "测速目标 URL 为空"}
}
- if testURL == "" {
- return TestResult{ProxyId: proxyId, Ok: false, Error: "测速目标 URL 为空"}
- }
- enableMihomoIPv6()
+ engine := speedTestProbeEngine(src, proxies, proxyId, connectorType)
+ log.Info("开始代理测速",
+ logger.F("proxy_id", proxyId),
+ logger.F("engine", engine),
+ logger.F("timeout_ms", cfg.Timeout.Milliseconds()),
+ logger.F("tcp_timeout_ms", cfg.TCPTimeout.Milliseconds()),
+ logger.F("targets", strings.Join(testURLs, ",")),
+ )
- resolvedSrc := src
- if IsChainSocks5Proxy(src) || RequiresBridge(src, proxies, proxyId) {
- 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)
- } else if IsSingBoxProtocol(src) {
- if singboxMgr == nil {
- log.Warn("sing-box 协议测速缺少管理器,降级到 TCP ping",
- logger.F("proxy_id", proxyId),
- )
- return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
- }
- bridgeSocksURL, bridgeErr := singboxMgr.EnsureBridge(src, proxies, proxyId)
- if bridgeErr != nil {
- log.Warn("sing-box 协议桥接失败,降级到 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)
+ client, err := buildSpeedTestHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, cfg)
if err != nil {
- log.Warn("代理配置解析失败,降级到 TCP ping",
+ log.Warn("代理测速 HTTP 客户端创建失败",
logger.F("proxy_id", proxyId),
logger.F("error", err.Error()),
)
- return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log)
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: engine, Error: err.Error()}
}
- proxyInstance, err := adapter.ParseProxy(mapping)
- if err != nil {
- log.Warn("mihomo 代理创建失败,降级到 TCP ping",
+ var lastErr error
+ var lastLatency int64
+ for _, testURL := range testURLs {
+ latency, statusCode, err := doSpeedTestRequest(client, testURL)
+ lastLatency = latency
+ if err != nil {
+ lastErr = err
+ log.Warn("代理测速请求失败",
+ logger.F("proxy_id", proxyId),
+ logger.F("engine", engine),
+ logger.F("url", testURL),
+ logger.F("latency_ms", latency),
+ logger.F("error", err.Error()),
+ )
+ continue
+ }
+ if speedTestStatusOK(statusCode, cfg) {
+ log.Info("代理测速成功",
+ logger.F("proxy_id", proxyId),
+ logger.F("engine", engine),
+ logger.F("url", testURL),
+ logger.F("status", statusCode),
+ logger.F("latency_ms", latency),
+ )
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency, Engine: engine}
+ }
+ lastErr = fmt.Errorf("HTTP %d", statusCode)
+ log.Warn("代理测速状态码不符合预期",
logger.F("proxy_id", proxyId),
- logger.F("error", err.Error()),
- logger.F("type", mapping["type"]),
+ logger.F("engine", engine),
+ logger.F("url", testURL),
+ logger.F("status", statusCode),
+ logger.F("latency_ms", latency),
)
- return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log)
+ }
+
+ if lastErr != nil {
+ log.Warn("代理测速失败",
+ logger.F("proxy_id", proxyId),
+ logger.F("engine", engine),
+ logger.F("latency_ms", lastLatency),
+ logger.F("error", lastErr.Error()),
+ )
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Engine: engine, Error: lastErr.Error()}
+ }
+ log.Warn("代理测速失败", logger.F("proxy_id", proxyId), logger.F("engine", engine), logger.F("error", "测速失败"))
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Engine: engine, Error: "测速失败"}
+}
+
+func buildSpeedTestHTTPClient(
+ src string,
+ proxyId string,
+ proxies []config.BrowserProxy,
+ xrayMgr *XrayManager,
+ singboxMgr *SingBoxManager,
+ clashMgr *ClashManager,
+ connectorType string,
+ cfg *SpeedTestConfig,
+) (*http.Client, error) {
+ timeout := DefaultSpeedTestConfig.Timeout
+ prepareTimeout := DefaultSpeedTestConfig.TCPTimeout
+ if cfg != nil {
+ if cfg.Timeout > 0 {
+ timeout = cfg.Timeout
+ }
+ if cfg.TCPTimeout > 0 {
+ prepareTimeout = cfg.TCPTimeout
+ }
+ }
+ if prepareTimeout <= 0 {
+ prepareTimeout = timeout
+ }
+
+ type clientResult struct {
+ client *http.Client
+ err error
+ }
+ resultCh := make(chan clientResult, 1)
+ go func() {
+ client, err := buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, timeout)
+ resultCh <- clientResult{client: client, err: err}
+ }()
+
+ timer := time.NewTimer(prepareTimeout)
+ defer timer.Stop()
+ select {
+ case result := <-resultCh:
+ return result.client, result.err
+ case <-timer.C:
+ return nil, fmt.Errorf("代理准备超时(%dms)", prepareTimeout.Milliseconds())
+ }
+}
+
+func primarySpeedTestURL(cfg *SpeedTestConfig) string {
+ if cfg != nil {
+ if urls := normalizeSpeedTestURLs(cfg.URLs); len(urls) > 0 {
+ return urls[0]
+ }
+ }
+ return strings.TrimSpace(DefaultSpeedTestURL)
+}
+
+func speedTestTargetURLs(cfg *SpeedTestConfig) []string {
+ if cfg != nil {
+ if urls := uniqueSpeedTestURLs(cfg.URLs); len(urls) > 0 {
+ return urls
+ }
+ }
+ return []string{DefaultSpeedTestURL}
+}
+
+func speedTestProbeEngine(src string, proxies []config.BrowserProxy, proxyId string, connectorType string) string {
+ resolution, err := ResolveProxyKernel(src, proxies, proxyId, "")
+ if err != nil {
+ if resolution.Kernel != "" {
+ return resolution.Kernel
+ }
+ return config.NormalizeBrowserConnectorType(connectorType)
+ }
+ if resolution.Kernel == ProxyKernelNative {
+ return "native"
+ }
+ return resolution.Kernel
+}
+
+func doSpeedTestRequest(client *http.Client, testURL string) (int64, int, error) {
+ latency, statusCode, err := doSpeedTestRequestWithMethod(client, http.MethodHead, testURL)
+ if err != nil || statusCode != http.StatusMethodNotAllowed {
+ if err != nil {
+ return latency, statusCode, err
+ }
+ secondLatency, secondStatusCode, secondErr := doSpeedTestRequestWithMethod(client, http.MethodHead, testURL)
+ if secondErr == nil {
+ return secondLatency, secondStatusCode, nil
+ }
+ return latency, statusCode, nil
+ }
+ return doSpeedTestRequestWithMethod(client, http.MethodGet, testURL)
+}
+
+func doSpeedTestRequestWithMethod(client *http.Client, method string, testURL string) (int64, int, error) {
+ start := time.Now()
+ req, err := http.NewRequest(method, testURL, nil)
+ if err != nil {
+ return 0, 0, fmt.Errorf("测速请求创建失败: %w", err)
+ }
+ resp, err := client.Do(req)
+ latency := time.Since(start).Milliseconds()
+ if err != nil {
+ return latency, 0, err
+ }
+ _ = resp.Body.Close()
+ return latency, resp.StatusCode, nil
+}
+
+func speedTestStatusOK(statusCode int, cfg *SpeedTestConfig) bool {
+ if cfg != nil && len(cfg.ExpectedStatus) > 0 {
+ for _, expected := range cfg.ExpectedStatus {
+ if statusCode == expected {
+ return true
+ }
+ }
+ return false
+ }
+ return isSpeedTestSuccessStatus(statusCode)
+}
+
+func mihomoURLTest(proxyId string, proxyInstance C.Proxy, testURL string, cfg *SpeedTestConfig) TestResult {
+ timeout := DefaultSpeedTestConfig.Timeout
+ if cfg != nil && cfg.Timeout > 0 {
+ timeout = cfg.Timeout
+ }
+
+ expectedStatus, err := speedTestExpectedStatus(cfg)
+ if err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "mihomo", Error: err.Error()}
}
adapter.UnifiedDelay.Store(true)
- return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout)
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ delay, err := proxyInstance.URLTest(ctx, testURL, expectedStatus)
+ latency := int64(delay)
+ if ctx.Err() != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Engine: "mihomo", Error: "测速超时"}
+ }
+ if err != nil || delay == 0 {
+ if err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Engine: "mihomo", Error: err.Error()}
+ }
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Engine: "mihomo", Error: "mihomo 延迟测试无结果"}
+ }
+
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency, Engine: "mihomo"}
}
-func enableMihomoIPv6() {
- resolver.DisableIPv6 = false
+func speedTestExpectedStatus(cfg *SpeedTestConfig) (utils.IntRanges[uint16], error) {
+ if cfg == nil || len(cfg.ExpectedStatus) == 0 {
+ return nil, nil
+ }
+ items := make([]string, 0, len(cfg.ExpectedStatus))
+ for _, status := range cfg.ExpectedStatus {
+ if status <= 0 || status > 65535 {
+ return nil, fmt.Errorf("无效测速状态码: %d", status)
+ }
+ items = append(items, strconv.Itoa(status))
+ }
+ return utils.NewUnsignedRangesFromList[uint16](items)
}
diff --git a/backend/internal/proxy/speedtest_fallback.go b/backend/internal/proxy/speedtest_fallback.go
index 80a05332..6ddd19c8 100644
--- a/backend/internal/proxy/speedtest_fallback.go
+++ b/backend/internal/proxy/speedtest_fallback.go
@@ -21,8 +21,8 @@ func tcpPingFallback(proxyId, src string, timeout time.Duration, log *logger.Log
latency := time.Since(start).Milliseconds()
if err != nil {
- return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("TCP 连接失败: %v", err)}
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Engine: "tcp", Error: fmt.Sprintf("TCP 连接失败: %v", err)}
}
conn.Close()
- return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency, Engine: "tcp"}
}
diff --git a/backend/internal/proxy/speedtest_test.go b/backend/internal/proxy/speedtest_test.go
index 5120d327..de773054 100644
--- a/backend/internal/proxy/speedtest_test.go
+++ b/backend/internal/proxy/speedtest_test.go
@@ -1,11 +1,16 @@
package proxy
import (
+ "bufio"
"encoding/base64"
+ "fmt"
+ "net"
"strings"
+ "sync/atomic"
"testing"
+ "time"
- "github.com/metacubex/mihomo/component/resolver"
+ "ant-chrome/backend/internal/config"
)
func TestProxyConfigToMappingStandardProxy(t *testing.T) {
@@ -33,17 +38,6 @@ func TestProxyConfigToMappingStandardProxy(t *testing.T) {
}
}
-func TestEnableMihomoIPv6(t *testing.T) {
- resolver.DisableIPv6 = true
- t.Cleanup(func() { resolver.DisableIPv6 = true })
-
- enableMihomoIPv6()
-
- if resolver.DisableIPv6 {
- t.Fatal("expected mihomo IPv6 resolver to be enabled")
- }
-}
-
func TestProxyConfigToMappingEscapedCredentials(t *testing.T) {
t.Parallel()
@@ -150,3 +144,181 @@ func TestDefaultProxyCheckURLsAreConfigured(t *testing.T) {
t.Fatalf("DefaultIPHealthURL must not be empty")
}
}
+
+func TestDefaultSpeedTestTimeoutsAreShort(t *testing.T) {
+ t.Parallel()
+
+ if DefaultSpeedTestConfig.Timeout != 3*time.Second {
+ t.Fatalf("speed timeout = %s, want 3s", DefaultSpeedTestConfig.Timeout)
+ }
+ if DefaultSpeedTestConfig.TCPTimeout != 3*time.Second {
+ t.Fatalf("speed tcp timeout = %s, want 3s", DefaultSpeedTestConfig.TCPTimeout)
+ }
+}
+
+func TestSpeedTestDefaultsToXrayLightHTTPDelay(t *testing.T) {
+ var requests atomic.Int32
+ const responseDelay = 120 * time.Millisecond
+ proxyURL, closeProxy := startDelayedConnectProxy(t, responseDelay, &requests)
+ t.Cleanup(closeProxy)
+
+ proxyID := "delayed-http-proxy"
+ result := SpeedTest(
+ proxyID,
+ []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: proxyURL}},
+ nil,
+ nil,
+ &SpeedTestConfig{Timeout: 2 * time.Second, URLs: []string{"http://latency.test/generate_204"}},
+ )
+ if !result.Ok {
+ t.Fatalf("SpeedTest failed: %+v", result)
+ }
+ if requests.Load() == 0 {
+ t.Fatal("test proxy did not receive any speed-test request")
+ }
+ if requests.Load() != 2 {
+ t.Fatalf("requests = %d, want unified delay to perform two HEAD requests", requests.Load())
+ }
+ if result.Engine != "native" {
+ t.Fatalf("engine = %q, want native", result.Engine)
+ }
+ if result.LatencyMs <= 0 || result.LatencyMs >= int64(responseDelay/time.Millisecond) {
+ t.Fatalf("latency = %dms, want second unified-delay probe below first-connection delay", result.LatencyMs)
+ }
+}
+
+func TestSpeedTestFallsBackAcrossTargets(t *testing.T) {
+ var requests atomic.Int32
+ proxyURL, closeProxy := startDelayedConnectProxy(t, 10*time.Millisecond, &requests)
+ t.Cleanup(closeProxy)
+
+ proxyID := "fallback-http-proxy"
+ result := SpeedTestWithConnector(
+ proxyID,
+ []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: proxyURL}},
+ nil,
+ nil,
+ nil,
+ config.BrowserConnectorXray,
+ &SpeedTestConfig{Timeout: 2 * time.Second, URLs: []string{"http://latency.test/fail", "http://latency.test/generate_204"}},
+ )
+ if !result.Ok {
+ t.Fatalf("SpeedTestWithConnector should fallback to second target: %+v", result)
+ }
+ if requests.Load() != 4 {
+ t.Fatalf("requests = %d, want fallback to perform unified-delay HEAD pair per target", requests.Load())
+ }
+}
+
+func TestSpeedTestTargetsDoNotIncludeRealConnectivityFallbacks(t *testing.T) {
+ t.Parallel()
+
+ targets := speedTestTargetURLs(&SpeedTestConfig{})
+ if len(targets) != 1 {
+ t.Fatalf("targets = %#v, want only default speed test URL", targets)
+ }
+ if targets[0] != DefaultSpeedTestURL {
+ t.Fatalf("target = %q, want %q", targets[0], DefaultSpeedTestURL)
+ }
+ for _, target := range targets {
+ if strings.Contains(target, "cloudflare") || strings.Contains(target, "msftconnecttest") {
+ t.Fatalf("speed test target unexpectedly includes real-connectivity URL: %#v", targets)
+ }
+ }
+}
+
+func TestSpeedTestUsesSingBoxProtocolWhenXrayConnectorSelected(t *testing.T) {
+ proxyID := "hy2-proxy"
+ result := SpeedTestWithConnector(
+ proxyID,
+ []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: "hysteria2://pass@example.com:443?sni=example.com"}},
+ nil,
+ nil,
+ nil,
+ config.BrowserConnectorXray,
+ &SpeedTestConfig{Timeout: 10 * time.Millisecond, URLs: []string{"http://latency.test/generate_204"}},
+ )
+ if result.Ok {
+ t.Fatalf("speed test should fail without sing-box manager, got success: %+v", result)
+ }
+ if result.Engine != "sing-box" {
+ t.Fatalf("engine = %q, want sing-box; result=%+v", result.Engine, result)
+ }
+ if !strings.Contains(result.Error, "sing-box 管理器未初始化") {
+ t.Fatalf("error = %q, want sing-box manager guidance", result.Error)
+ }
+}
+
+func startDelayedConnectProxy(t *testing.T, delay time.Duration, requests *atomic.Int32) (string, func()) {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen failed: %v", err)
+ }
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ go handleDelayedConnectProxyConn(conn, delay, requests)
+ }
+ }()
+ return "http://" + listener.Addr().String(), func() {
+ _ = listener.Close()
+ <-done
+ }
+}
+
+func handleDelayedConnectProxyConn(conn net.Conn, delay time.Duration, requests *atomic.Int32) {
+ defer conn.Close()
+ reader := bufio.NewReader(conn)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ for {
+ header, err := reader.ReadString('\n')
+ if err != nil || strings.TrimSpace(header) == "" {
+ break
+ }
+ }
+ if strings.HasPrefix(line, "CONNECT ") {
+ _, _ = fmt.Fprint(conn, "HTTP/1.1 200 Connection Established\r\n\r\n")
+ line, err = reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ for {
+ header, err := reader.ReadString('\n')
+ if err != nil || strings.TrimSpace(header) == "" {
+ break
+ }
+ }
+ }
+ for strings.HasPrefix(line, "HEAD ") {
+ requestCount := requests.Add(1)
+ if requestCount == 1 {
+ time.Sleep(delay)
+ } else {
+ time.Sleep(10 * time.Millisecond)
+ }
+ statusLine := "HTTP/1.1 204 No Content"
+ if strings.Contains(line, "/fail") {
+ statusLine = "HTTP/1.1 500 Internal Server Error"
+ }
+ _, _ = fmt.Fprintf(conn, "%s\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n", statusLine)
+ line, err = reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ for {
+ header, err := reader.ReadString('\n')
+ if err != nil || strings.TrimSpace(header) == "" {
+ break
+ }
+ }
+ }
+}
diff --git a/backend/internal/proxy/speedtest_unified_delay.go b/backend/internal/proxy/speedtest_unified_delay.go
index fd4c6e43..49bcf6e5 100644
--- a/backend/internal/proxy/speedtest_unified_delay.go
+++ b/backend/internal/proxy/speedtest_unified_delay.go
@@ -3,28 +3,96 @@ package proxy
import (
"context"
"fmt"
+ "net"
+ "net/http"
+ "net/netip"
+ "strings"
"time"
C "github.com/metacubex/mihomo/constant"
)
-// unifiedDelayTest 使用 mihomo 原生 URLTest 实现延迟检测。
-// 这与 mihomo-party 调用 mihomo core 的 /proxies/{name}/delay 是同一套连接逻辑,
-// 避免手写 HTTP 复用连接在部分节点上触发重复 HEAD 或 TLS 处理差异。
+// unifiedDelayTest 通过代理访问测速 URL,记录从代理拨号到首个 HTTP 响应完成的端到端耗时。
+// master 版本只统计第二次复用连接 HEAD 的 RTT,会漏掉代理拨号/握手耗时,导致显示延迟偏低。
func unifiedDelayTest(proxyId string, px C.Proxy, testURL string, timeout time.Duration) TestResult {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
- delay, err := px.URLTest(ctx, testURL, nil)
- if ctx.Err() != nil {
- return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("测速超时: %v", ctx.Err())}
- }
+ addr, err := urlToMeta(testURL)
if err != nil {
- return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()}
- }
- if delay == 0 {
- return TestResult{ProxyId: proxyId, Ok: false, Error: "delay test returned 0"}
+ return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("URL 解析失败: %v", err)}
}
- return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: int64(delay)}
+ start := time.Now()
+ conn, err := px.DialContext(ctx, &addr)
+ if err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理连接失败: %v", err)}
+ }
+ defer conn.Close()
+
+ transport := &http.Transport{
+ DialContext: func(context.Context, string, string) (net.Conn, error) {
+ return conn, nil
+ },
+ DisableKeepAlives: false,
+ }
+ client := &http.Client{
+ Transport: transport,
+ Timeout: timeout,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ defer client.CloseIdleConnections()
+
+ req, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
+ resp, err := client.Do(req)
+ latency := time.Since(start).Milliseconds()
+ if err != nil {
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
+ }
+ resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+ return TestResult{
+ ProxyId: proxyId,
+ Ok: false,
+ LatencyMs: latency,
+ Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
+ }
+ }
+
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
+}
+
+// urlToMeta 将 URL 转换为 mihomo Metadata
+func urlToMeta(rawURL string) (C.Metadata, error) {
+ var host string
+ var portNum uint16
+ if strings.HasPrefix(rawURL, "https://") {
+ host = rawURL[len("https://"):]
+ portNum = 443
+ } else if strings.HasPrefix(rawURL, "http://") {
+ host = rawURL[len("http://"):]
+ portNum = 80
+ } else {
+ return C.Metadata{}, fmt.Errorf("不支持的 URL scheme")
+ }
+
+ if idx := strings.Index(host, "/"); idx >= 0 {
+ host = host[:idx]
+ }
+ if h, p, err := net.SplitHostPort(host); err == nil {
+ host = h
+ fmt.Sscanf(p, "%d", &portNum)
+ }
+
+ meta := C.Metadata{
+ Host: host,
+ DstPort: portNum,
+ }
+ if addr, err := netip.ParseAddr(host); err == nil {
+ meta.DstIP = addr
+ }
+ return meta, nil
}
diff --git a/backend/internal/proxy/utils_connectivity.go b/backend/internal/proxy/utils_connectivity.go
index 3c78499c..d8f673a6 100644
--- a/backend/internal/proxy/utils_connectivity.go
+++ b/backend/internal/proxy/utils_connectivity.go
@@ -1,11 +1,9 @@
package proxy
import (
- "errors"
"fmt"
"net"
"net/http"
- "os"
"strings"
"time"
@@ -25,12 +23,12 @@ func TestConnectivity(proxyId string, proxyConfig string, proxies []config.Brows
}
}
if src == "" {
- return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "tcp", Error: "代理配置为空"}
}
endpoint, err := proxyEndpoint(src)
if err != nil {
- return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("地址解析失败: %v", err)}
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: "tcp", Error: fmt.Sprintf("地址解析失败: %v", err)}
}
start := time.Now()
@@ -38,10 +36,10 @@ func TestConnectivity(proxyId string, proxyConfig string, proxies []config.Brows
latency := time.Since(start).Milliseconds()
if err != nil {
- return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Engine: "tcp", Error: err.Error()}
}
conn.Close()
- return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency, Engine: "tcp"}
}
// TestRealConnectivity 通过代理链路发起真实 HTTP 请求测量端到端延迟。
@@ -86,8 +84,9 @@ func TestRealConnectivityWithRuntimeConfig(
cfg *SpeedTestConfig,
) TestResult {
src := resolveProxyConfig("", proxies, proxyId)
+ engine := speedTestProbeEngine(src, proxies, proxyId, connectorType)
if src == "" {
- return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: engine, Error: "代理配置为空"}
}
targetURLs := defaultRealConnectivityTargets()
@@ -105,12 +104,12 @@ func TestRealConnectivityWithRuntimeConfig(
}
targetURLs = uniqueSpeedTestURLs(targetURLs)
if len(targetURLs) == 0 {
- return TestResult{ProxyId: proxyId, Ok: false, Error: "真实连通性测试目标 URL 为空"}
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: engine, Error: "真实连通性测试目标 URL 为空"}
}
client, err := buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, timeout)
if err != nil {
- return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()}
+ return TestResult{ProxyId: proxyId, Ok: false, Engine: engine, Error: err.Error()}
}
var lastErr error
@@ -122,27 +121,19 @@ func TestRealConnectivityWithRuntimeConfig(
lastLatency = latency
if err != nil {
lastErr = err
- if isTimeoutError(err) {
- if endpointResult := tcpPingFallback(proxyId, src, minPositiveDuration(timeout, 5*time.Second), nil); endpointResult.Ok {
- return endpointResult
- }
- }
continue
}
_ = resp.Body.Close()
if isSpeedTestSuccessStatus(resp.StatusCode) {
- return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
+ return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency, Engine: engine}
}
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
}
- if endpointResult := tcpPingFallback(proxyId, src, minPositiveDuration(timeout, 5*time.Second), nil); endpointResult.Ok {
- return endpointResult
- }
if lastErr != nil {
- return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Error: lastErr.Error()}
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Engine: engine, Error: "真实访问失败: " + lastErr.Error()}
}
- return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Error: "真实连通性测试失败"}
+ return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Engine: engine, Error: "真实连通性测试失败"}
}
func defaultRealConnectivityTargets() []string {
@@ -179,26 +170,5 @@ func uniqueSpeedTestURLs(urls []string) []string {
}
func isSpeedTestSuccessStatus(statusCode int) bool {
- return statusCode == http.StatusNoContent || (statusCode >= 200 && statusCode < 400)
-}
-
-func minPositiveDuration(a time.Duration, b time.Duration) time.Duration {
- if a <= 0 {
- return b
- }
- if b <= 0 || a < b {
- return a
- }
- return b
-}
-
-func isTimeoutError(err error) bool {
- if err == nil {
- return false
- }
- if os.IsTimeout(err) {
- return true
- }
- var netErr net.Error
- return errors.As(err, &netErr) && netErr.Timeout()
+ return statusCode == http.StatusNoContent || (statusCode >= 200 && statusCode < 300)
}
diff --git a/backend/internal/proxy/utils_types.go b/backend/internal/proxy/utils_types.go
index b219202f..4b505f78 100644
--- a/backend/internal/proxy/utils_types.go
+++ b/backend/internal/proxy/utils_types.go
@@ -5,5 +5,6 @@ type TestResult struct {
ProxyId string
Ok bool
LatencyMs int64
+ Engine string
Error string
}
diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go
index 600cf531..cf700dab 100644
--- a/backend/internal/proxy/xray.go
+++ b/backend/internal/proxy/xray.go
@@ -42,11 +42,13 @@ 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)
+ preferredKernel := ""
if proxyId != "" {
found := false
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
+ preferredKernel = strings.TrimSpace(item.PreferredKernel)
found = true
break
}
@@ -57,6 +59,11 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox
}
}
}
+ if resolution, err := ResolveProxyKernel(src, proxies, "", preferredKernel); err != nil {
+ return false, fmt.Sprintf("代理配置解析失败: %v", err)
+ } else if len(resolution.SupportedKernels) == 0 {
+ return false, "代理配置无效"
+ }
if src == "" {
return true, ""
}
@@ -79,6 +86,12 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox
}
return true, ""
}
+ if IsMihomoOnlyProtocol(src) {
+ if err := validateMihomoOnlyProtocol(src); err != nil {
+ return false, fmt.Sprintf("代理配置解析失败: %v", err)
+ }
+ return true, ""
+ }
standardProxy, outbound, err := ParseProxyNode(src)
if err != nil {
@@ -108,6 +121,9 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s
if IsSingBoxProtocol(src) {
return false
}
+ if IsMihomoOnlyProtocol(src) {
+ return false
+ }
if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") {
return false
}
diff --git a/backend/internal/proxy/xray_bridge_launch.go b/backend/internal/proxy/xray_bridge_launch.go
index 97d58012..6c7ec99f 100644
--- a/backend/internal/proxy/xray_bridge_launch.go
+++ b/backend/internal/proxy/xray_bridge_launch.go
@@ -129,6 +129,9 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
break
}
}
+ if !isRetryableXrayLaunchError(lastErr) {
+ return "", "", lastErr
+ }
return "", "", fmt.Errorf("xray 启动失败(已尝试 %d 次): %w", attemptsUsed, lastErr)
}
@@ -358,7 +361,7 @@ func (m *XrayManager) testRuntimeConfig(binaryPath string, cfgPath string, stder
_, _ = stderrFile.Write(output)
}
return &xrayLaunchError{
- err: fmt.Errorf("xray 配置预检失败: %w;%s", err, m.describeBridgeReadyError(err, cfgPath, stderrPath)),
+ err: fmt.Errorf("Xray 配置错误:%s", m.describeBridgeReadyError(err, cfgPath, stderrPath)),
retryable: false,
}
}
@@ -390,22 +393,57 @@ 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
+ return time.Duration(defaultBridgeStartTimeoutMs) * time.Millisecond
}
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)
+ return summarizeXrayError(tail)
} else if cfgPath != "" {
if tail := readLogTail(filepath.Join(filepath.Dir(cfgPath), "xray-error.log"), 1200); tail != "" {
- parts = append(parts, "error.log: "+tail)
+ return summarizeXrayError(tail)
}
}
- return strings.Join(parts, ";")
+ if err != nil {
+ return summarizeXrayError(err.Error())
+ }
+ return "未知错误"
+}
+
+func summarizeXrayError(raw string) string {
+ text := strings.TrimSpace(raw)
+ if text == "" {
+ return "未知错误"
+ }
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ lines := strings.Split(text, "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ line := strings.TrimSpace(lines[i])
+ if line != "" {
+ text = line
+ break
+ }
+ }
+ text = strings.TrimSpace(strings.TrimPrefix(text, "Failed to start:"))
+ if idx := strings.LastIndex(text, " > "); idx >= 0 && idx+3 < len(text) {
+ text = strings.TrimSpace(text[idx+3:])
+ }
+ text = strings.TrimPrefix(text, "infra/conf: ")
+ text = strings.TrimPrefix(text, "main: ")
+ if idx := strings.Index(text, "Try "); idx > 0 {
+ text = strings.TrimSpace(text[:idx])
+ }
+ if idx := strings.Index(text, "请检查"); idx > 0 {
+ text = strings.TrimSpace(text[:idx])
+ }
+ text = strings.Trim(text, " .;。")
+ if strings.Contains(text, "allowInsecure") && strings.Contains(strings.ToLower(text), "removed") {
+ return "字段 allowInsecure 已被当前 Xray 移除"
+ }
+ if text == "" {
+ return "未知错误"
+ }
+ return text
}
func readLogTail(path string, max int) string {
diff --git a/backend/internal/proxy/xray_runtime_config.go b/backend/internal/proxy/xray_runtime_config.go
index f7c08a49..b95324c1 100644
--- a/backend/internal/proxy/xray_runtime_config.go
+++ b/backend/internal/proxy/xray_runtime_config.go
@@ -2,12 +2,24 @@ package proxy
import (
"ant-chrome/backend/internal/apppath"
+ "context"
+ "crypto/sha256"
+ "crypto/tls"
+ "encoding/hex"
"encoding/json"
+ "net"
"os"
"path/filepath"
+ "strconv"
"strings"
+ "sync"
+ "time"
)
+const xrayTLSInsecurePinKey = "_antInsecureSkipVerify"
+
+var xrayTLSPinCache sync.Map
+
func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) {
return m.buildRuntimeConfigWithRoute(
key,
@@ -29,6 +41,8 @@ func (m *XrayManager) buildRuntimeConfigWithRoute(key string, outbounds []interf
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return "", err
}
+ outbounds = sanitizeXrayOutbounds(outbounds)
+ outbounds = pinXrayInsecureTLSOutbounds(outbounds)
cfgPath := filepath.Join(baseDir, "xray-config.json")
cfg := map[string]interface{}{
"log": map[string]interface{}{
@@ -63,6 +77,8 @@ func (m *XrayManager) buildRuntimeConfigWithRoute(key string, outbounds []interf
}
if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil {
cfg["dns"] = dnsCfg
+ } else {
+ cfg["dns"] = defaultXrayDNSConfig()
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
@@ -74,6 +90,168 @@ func (m *XrayManager) buildRuntimeConfigWithRoute(key string, outbounds []interf
return cfgPath, nil
}
+func defaultXrayDNSConfig() map[string]interface{} {
+ return map[string]interface{}{
+ "servers": []interface{}{"223.5.5.5", "119.29.29.29"},
+ }
+}
+
+func sanitizeXrayOutbounds(outbounds []interface{}) []interface{} {
+ for _, outbound := range outbounds {
+ removeXrayDeprecatedFields(outbound)
+ }
+ return outbounds
+}
+
+func pinXrayInsecureTLSOutbounds(outbounds []interface{}) []interface{} {
+ for _, outbound := range outbounds {
+ pinXrayInsecureTLSOutbound(outbound)
+ }
+ return outbounds
+}
+
+func pinXrayInsecureTLSOutbound(outbound interface{}) {
+ item, ok := outbound.(map[string]interface{})
+ if !ok {
+ return
+ }
+ stream, _ := item["streamSettings"].(map[string]interface{})
+ tlsSettings, _ := stream["tlsSettings"].(map[string]interface{})
+ if tlsSettings == nil || !truthy(tlsSettings[xrayTLSInsecurePinKey]) {
+ return
+ }
+ delete(tlsSettings, xrayTLSInsecurePinKey)
+ serverName := getStringAny(tlsSettings["serverName"])
+ host, port := firstXrayOutboundEndpoint(item)
+ if host == "" || port == "" {
+ return
+ }
+ if serverName == "" {
+ serverName = host
+ }
+ if fingerprints := fetchTLSPeerCertPins(host, port, serverName); len(fingerprints) > 0 {
+ tlsSettings["pinnedPeerCertSha256"] = strings.Join(fingerprints, ",")
+ }
+}
+
+func firstXrayOutboundEndpoint(outbound map[string]interface{}) (string, string) {
+ protocol := strings.ToLower(getStringAny(outbound["protocol"]))
+ settings, _ := outbound["settings"].(map[string]interface{})
+ if settings == nil {
+ return "", ""
+ }
+ switch protocol {
+ case "trojan":
+ servers, _ := settings["servers"].([]interface{})
+ if len(servers) == 0 {
+ return "", ""
+ }
+ server, _ := servers[0].(map[string]interface{})
+ if server == nil {
+ return "", ""
+ }
+ return getStringAny(server["address"]), getPortString(server["port"])
+ case "vless", "vmess":
+ vnext, _ := settings["vnext"].([]interface{})
+ if len(vnext) == 0 {
+ return "", ""
+ }
+ server, _ := vnext[0].(map[string]interface{})
+ if server == nil {
+ return "", ""
+ }
+ return getStringAny(server["address"]), getPortString(server["port"])
+ }
+ return "", ""
+}
+
+func fetchTLSPeerCertPins(host string, port string, serverName string) []string {
+ cacheKey := strings.ToLower(strings.TrimSpace(host)) + ":" + strings.TrimSpace(port) + "|" + strings.ToLower(strings.TrimSpace(serverName))
+ if cached, ok := xrayTLSPinCache.Load(cacheKey); ok {
+ if pins, ok := cached.([]string); ok && len(pins) > 0 {
+ return append([]string(nil), pins...)
+ }
+ }
+ fingerprint, err := fetchTLSPeerCertPin(host, port, serverName)
+ if err != nil || fingerprint == "" {
+ return nil
+ }
+ fingerprints := []string{fingerprint}
+ if len(fingerprints) > 0 {
+ xrayTLSPinCache.Store(cacheKey, append([]string(nil), fingerprints...))
+ }
+ return fingerprints
+}
+
+func fetchTLSPeerCertPin(host string, port string, serverName string) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
+ defer cancel()
+ dialer := &tls.Dialer{
+ NetDialer: &net.Dialer{Timeout: time.Second},
+ Config: &tls.Config{ServerName: serverName, InsecureSkipVerify: true},
+ }
+ conn, err := dialer.DialContext(ctx, "tcp", host+":"+port)
+ if err != nil {
+ return "", err
+ }
+ defer conn.Close()
+ tlsConn, ok := conn.(*tls.Conn)
+ if !ok {
+ return "", nil
+ }
+ state := tlsConn.ConnectionState()
+ if len(state.PeerCertificates) == 0 {
+ return "", nil
+ }
+ sum := sha256.Sum256(state.PeerCertificates[0].Raw)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+func getPortString(value interface{}) string {
+ switch v := value.(type) {
+ case int:
+ return strconv.Itoa(v)
+ case int64:
+ return strconv.FormatInt(v, 10)
+ case float64:
+ return strconv.Itoa(int(v))
+ case string:
+ return strings.TrimSpace(v)
+ default:
+ return ""
+ }
+}
+
+func getStringAny(value interface{}) string {
+ s, _ := value.(string)
+ return strings.TrimSpace(s)
+}
+
+func truthy(value interface{}) bool {
+ switch v := value.(type) {
+ case bool:
+ return v
+ case string:
+ return strings.EqualFold(v, "true") || v == "1"
+ default:
+ return false
+ }
+}
+
+func removeXrayDeprecatedFields(value interface{}) {
+ switch item := value.(type) {
+ case map[string]interface{}:
+ delete(item, "allowInsecure")
+ for _, nested := range item {
+ removeXrayDeprecatedFields(nested)
+ }
+ case []interface{}:
+ for _, nested := range item {
+ removeXrayDeprecatedFields(nested)
+ }
+ }
+}
+
func (m *XrayManager) resolveWorkdir(key string) string {
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
if root == "" {
diff --git a/backend/test/launchcode/dao_prop_test.go b/backend/test/launchcode/dao_prop_test.go
index ceee10cc..be4a8052 100644
--- a/backend/test/launchcode/dao_prop_test.go
+++ b/backend/test/launchcode/dao_prop_test.go
@@ -7,6 +7,7 @@ import (
"database/sql"
"fmt"
"os"
+ "sync/atomic"
"testing"
"ant-chrome/backend/internal/launchcode"
@@ -17,14 +18,11 @@ import (
_ "modernc.org/sqlite"
)
-// newTestDB 创建内存 SQLite 数据库并执行建表迁移
-func newTestDB(t *testing.T) *sql.DB {
+var testDBSequence uint64
+
+func setupLaunchCodeSchema(t *testing.T, db *sql.DB) {
t.Helper()
- db, err := sql.Open("sqlite", "file::memory:?cache=shared&_journal_mode=WAL")
- if err != nil {
- t.Fatalf("打开测试数据库失败: %v", err)
- }
- _, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
+ _, err := db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
profile_id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -37,10 +35,30 @@ func newTestDB(t *testing.T) *sql.DB {
if err != nil {
t.Fatalf("建索引失败: %v", err)
}
+}
+
+// newTestDB 创建内存 SQLite 数据库并执行建表迁移
+func newTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:launchcode_test_%d?mode=memory&cache=shared", atomic.AddUint64(&testDBSequence, 1)))
+ if err != nil {
+ t.Fatalf("打开测试数据库失败: %v", err)
+ }
+ setupLaunchCodeSchema(t, db)
t.Cleanup(func() { db.Close() })
return db
}
+func newIsolatedTestDB(t *testing.T) (*sql.DB, func()) {
+ t.Helper()
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:launchcode_prop_%d?mode=memory&cache=shared", atomic.AddUint64(&testDBSequence, 1)))
+ if err != nil {
+ t.Fatalf("打开测试数据库失败: %v", err)
+ }
+ setupLaunchCodeSchema(t, db)
+ return db, func() { _ = db.Close() }
+}
+
// newFileTestDB 创建基于文件的 SQLite 数据库(用于需要独立隔离的测试)
func newFileTestDB(t *testing.T) *sql.DB {
t.Helper()
@@ -85,7 +103,8 @@ func TestProperty2_PersistenceRoundTrip(t *testing.T) {
properties.Property("Upsert 后 FindProfileId 返回正确 profileId", prop.ForAll(
func(profileId, code string) bool {
- db := newFileTestDB(t)
+ db, cleanup := newIsolatedTestDB(t)
+ defer cleanup()
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
if err := dao.Upsert(profileId, code); err != nil {
@@ -100,7 +119,8 @@ func TestProperty2_PersistenceRoundTrip(t *testing.T) {
properties.Property("Upsert 后 FindCode 返回正确 code", prop.ForAll(
func(profileId, code string) bool {
- db := newFileTestDB(t)
+ db, cleanup := newIsolatedTestDB(t)
+ defer cleanup()
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
if err := dao.Upsert(profileId, code); err != nil {
@@ -118,7 +138,8 @@ func TestProperty2_PersistenceRoundTrip(t *testing.T) {
if code1 == code2 {
return true // 跳过相同 code 的情况
}
- db := newFileTestDB(t)
+ db, cleanup := newIsolatedTestDB(t)
+ defer cleanup()
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
if err := dao.Upsert(profileId, code1); err != nil {
@@ -145,7 +166,8 @@ func TestProperty2_DeleteRemovesMapping(t *testing.T) {
properties.Property("Delete 后 FindCode 返回错误", prop.ForAll(
func(profileId, code string) bool {
- db := newFileTestDB(t)
+ db, cleanup := newIsolatedTestDB(t)
+ defer cleanup()
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
if err := dao.Upsert(profileId, code); err != nil {
diff --git a/backend/test/proxy/trojan_test.go b/backend/test/proxy/trojan_test.go
index 19a6541b..56437ff0 100644
--- a/backend/test/proxy/trojan_test.go
+++ b/backend/test/proxy/trojan_test.go
@@ -33,19 +33,21 @@ func TestTrojanClashYAML(t *testing.T) {
t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"])
}
settings := outbound["settings"].(map[string]interface{})
- if settings["address"] != "trojan.example.com" {
- t.Errorf("address 不匹配: %v", settings["address"])
+ servers := settings["servers"].([]interface{})
+ server := servers[0].(map[string]interface{})
+ if server["address"] != "trojan.example.com" {
+ t.Errorf("address 不匹配: %v", server["address"])
}
- if settings["password"] != "example-password" {
- t.Errorf("password 不匹配: %v", settings["password"])
+ if server["password"] != "example-password" {
+ t.Errorf("password 不匹配: %v", server["password"])
}
stream := outbound["streamSettings"].(map[string]interface{})
if stream["security"] != "tls" {
t.Errorf("security 期望 tls,得到 %v", stream["security"])
}
tls := stream["tlsSettings"].(map[string]interface{})
- if tls["allowInsecure"] != true {
- t.Errorf("allowInsecure 期望 true,得到 %v", tls["allowInsecure"])
+ if _, ok := tls["allowInsecure"]; ok {
+ t.Errorf("tlsSettings 不应包含已废弃的 allowInsecure: %#v", tls)
}
}
@@ -59,11 +61,53 @@ func TestTrojanURI(t *testing.T) {
t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"])
}
settings := outbound["settings"].(map[string]interface{})
- if settings["address"] != "example.com" {
- t.Errorf("address 不匹配: %v", settings["address"])
+ servers := settings["servers"].([]interface{})
+ server := servers[0].(map[string]interface{})
+ if server["address"] != "example.com" {
+ t.Errorf("address 不匹配: %v", server["address"])
}
- if settings["password"] != "mypassword" {
- t.Errorf("password 不匹配: %v", settings["password"])
+ if server["password"] != "mypassword" {
+ t.Errorf("password 不匹配: %v", server["password"])
+ }
+ stream := outbound["streamSettings"].(map[string]interface{})
+ tls := stream["tlsSettings"].(map[string]interface{})
+ if _, ok := tls["allowInsecure"]; ok {
+ t.Errorf("tlsSettings 不应包含已废弃的 allowInsecure: %#v", tls)
+ }
+ if tls["_antInsecureSkipVerify"] != true {
+ t.Errorf("_antInsecureSkipVerify 期望 true,得到 %v", tls["_antInsecureSkipVerify"])
+ }
+}
+
+func TestTrojanURIKeepsFingerprintAlias(t *testing.T) {
+ node := "trojan://mypassword@example.com:443?sni=example.com&fp=chrome"
+ _, outbound, err := proxy.ParseProxyNode(node)
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ stream := outbound["streamSettings"].(map[string]interface{})
+ tls := stream["tlsSettings"].(map[string]interface{})
+ if tls["fingerprint"] != "chrome" {
+ t.Fatalf("fingerprint = %v, want chrome", tls["fingerprint"])
+ }
+}
+
+func TestVlessURIKeepsFingerprintAlias(t *testing.T) {
+ node := "vless://00000000-0000-0000-0000-000000000001@example.com:443?type=tcp&security=tls&flow=xtls-rprx-vision&fp=chrome&sni=d1.awsstatic.com&insecure=1"
+ _, outbound, err := proxy.ParseProxyNode(node)
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ stream := outbound["streamSettings"].(map[string]interface{})
+ tls := stream["tlsSettings"].(map[string]interface{})
+ if tls["serverName"] != "d1.awsstatic.com" {
+ t.Fatalf("serverName = %v, want d1.awsstatic.com", tls["serverName"])
+ }
+ if tls["fingerprint"] != "chrome" {
+ t.Fatalf("fingerprint = %v, want chrome", tls["fingerprint"])
+ }
+ if tls["_antInsecureSkipVerify"] != true {
+ t.Fatalf("_antInsecureSkipVerify = %v, want true", tls["_antInsecureSkipVerify"])
}
}
@@ -177,6 +221,24 @@ func TestHysteria2URI(t *testing.T) {
}
}
+func TestHysteria2URIWithPortHopUsesServerPorts(t *testing.T) {
+ node := "hysteria2://mypassword@example.com:20000?sni=example.com&insecure=1&mport=20000-50000"
+ outbound, err := proxy.BuildSingBoxOutbound(node)
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ serverPorts, ok := outbound["server_ports"].(string)
+ if !ok {
+ t.Fatalf("server_ports is %T, want string", outbound["server_ports"])
+ }
+ if serverPorts != "20000:50000" {
+ t.Fatalf("server_ports = %#v, want 20000:50000", serverPorts)
+ }
+ if _, ok := outbound["server_port"]; ok {
+ t.Fatalf("server_port should be omitted when mport is set: %#v", outbound)
+ }
+}
+
func TestHysteria2ClashYAML(t *testing.T) {
node := `- name: HY2节点
type: hysteria2
diff --git a/config.yaml b/config.yaml
index cedf96f1..3870733f 100644
--- a/config.yaml
+++ b/config.yaml
@@ -9,8 +9,9 @@ app:
height: 1000
min_width: 1200
min_height: 700
- max_profile_limit: 20
- used_cd_keys: []
+ max_profile_limit: 70
+ used_cd_keys:
+ - GITHUB_STAR_REWARD
runtime:
max_memory_mb: 0
gc_percent: 100
@@ -45,6 +46,7 @@ browser:
- --disable-sync
- --no-first-run
default_start_urls: []
+ light_start_enabled: true
restore_last_session: false
start_ready_timeout_ms: 3000
start_stable_window_ms: 1200
@@ -61,10 +63,11 @@ launch_server:
api_key: ""
header: X-Ant-Api-Key
automation:
- enabled: false
+ enabled: true
install_policy: on_demand
runtime_version: node-22.15.1-playwright-core-1.59.0
keep_runtime_on_disable: true
+ artifacts_dir: data/automation/artifacts
node_source: auto
node_version: 22.15.1
playwright_core_version: 1.59.0
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 774be3c0..b1dd3367 100644
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-7b7cd01deb4f6d205b686dee62014883
\ No newline at end of file
+80f733bd69eeb89b54b5de1ecc207d1a
\ No newline at end of file
diff --git a/frontend/src/modules/browser/api/profiles.ts b/frontend/src/modules/browser/api/profiles.ts
index caf628f1..d361ddb6 100644
--- a/frontend/src/modules/browser/api/profiles.ts
+++ b/frontend/src/modules/browser/api/profiles.ts
@@ -8,7 +8,15 @@ export async function fetchBrowserProfiles(): Promise- {importMode === 'clash' - ? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)' - : importMode === 'direct' - ? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接' - : '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'} -
{importMode === 'clash' && ( <>