diff --git a/AGENTS.md b/AGENTS.md index 570aa8f3..73f78cae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Core expectations: - Use modal/drawer for short low-risk forms; use a dedicated page or wizard for complex flows. - Remove filler copy, repeated headings, decorative cards, and meaningless whitespace. - Keep the next action obvious and preserve predictable back/cancel/save behavior. +- 代理连接必须遵守两套连接栈规则,详见 `docs/proxy-connector-stacks.md`:`browser.default_connector_type=xray` 表示 Xray + sing-box 组合栈,Xray 负责 vmess/vless/trojan/shadowsocks/链式代理等,sing-box 负责 hysteria2/tuic/anytls 等协议;`browser.default_connector_type=mihomo` 表示独立 Mihomo 栈。实例启动、测速、真实连通性、IP 健康、预热和代理下载都必须按当前连接栈执行,不允许在 `xray` 组合栈和 `mihomo` 栈之间自动混用;不要把 sing-box 协议误判成“xray 不支持”。 - For detailed UI checks, selectively read `D:\code\open_source\ant-ready-start\skills\page-style-linear-flow\references\checklist.md`. These shared skill instructions supplement project-specific rules in this `AGENTS.md`; keep more specific project rules authoritative for this repository. diff --git a/README.md b/README.md index 8969cf04..c1c77b70 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,14 @@ Ant Browser 适合以下场景: - 支持手动维护代理和导入 Clash - 支持查看延迟、IP 健康并挑选可用节点 +代理连接栈规则: + +- `default_connector_type` 只有两套连接栈:`xray` 和 `mihomo`。 +- `xray` 表示 Xray + sing-box 组合栈:Xray 负责 vmess/vless/trojan/shadowsocks/链式代理等,sing-box 负责 hysteria2/tuic/anytls 等协议。 +- `mihomo` 表示独立 Mihomo 栈:需要桥接的代理统一走 mihomo。 +- 实例启动、代理测速、真实连通性、IP 健康、预热和插件下载代理必须按当前连接栈执行;不得在 `xray` 组合栈和 `mihomo` 栈之间自动混用。 +- 详细约束见 `docs/proxy-connector-stacks.md`。 + ### 4. 代理生效验证 代理生效验证 @@ -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 { if (bindings?.BrowserProfileList) { return (await bindings.BrowserProfileList()) || [] } - return getMockProfiles() + return getMockProfiles().filter((profile) => !profile.deletedAt) +} + +export async function fetchBrowserProfileTrash(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileTrashList) { + return (await bindings.BrowserProfileTrashList()) || [] + } + return getMockProfiles().filter((profile) => !!profile.deletedAt) } export async function fetchBrowserProfilesByTag(tag: string): Promise { @@ -78,10 +86,57 @@ export async function deleteBrowserProfile(profileId: string): Promise return true } + const deletedAt = nowISOString() + setMockProfiles(getMockProfiles().map((item) => ( + item.profileId === profileId ? { ...item, deletedAt, updatedAt: deletedAt, running: false } : item + ))) + return true +} + +export async function restoreBrowserProfile(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileRestore) { + return (await bindings.BrowserProfileRestore(profileId)) || null + } + + const updatedAt = nowISOString() + let restored: BrowserProfile | null = null + const nextProfiles = getMockProfiles().map((item) => { + if (item.profileId !== profileId) return item + restored = { ...item, deletedAt: '', updatedAt } + return restored + }) + setMockProfiles(nextProfiles) + return restored +} + +export async function permanentlyDeleteBrowserProfile(profileId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfilePermanentlyDelete) { + await bindings.BrowserProfilePermanentlyDelete(profileId) + return true + } + setMockProfiles(getMockProfiles().filter((item) => item.profileId !== profileId)) return true } +export async function cleanupBrowserProfileTrash(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileTrashCleanup) { + await bindings.BrowserProfileTrashCleanup() + return true + } + + const expiredBefore = Date.now() - 3 * 24 * 60 * 60 * 1000 + setMockProfiles(getMockProfiles().filter((item) => { + if (!item.deletedAt) return true + const deletedAt = new Date(item.deletedAt).getTime() + return Number.isNaN(deletedAt) || deletedAt > expiredBefore + })) + return true +} + export async function copyBrowserProfile( profileId: string, newName: string, diff --git a/frontend/src/modules/browser/api/proxies.ts b/frontend/src/modules/browser/api/proxies.ts index 178b5d47..898e269e 100644 --- a/frontend/src/modules/browser/api/proxies.ts +++ b/frontend/src/modules/browser/api/proxies.ts @@ -1,4 +1,4 @@ -import type { BrowserProxy, ProxyBridgeWarmupResult, ProxyCoreDownloadInfoResult, ProxyCoreStatusResult, ProxyIPHealthResult, ProxyLocationResolveResult } from '../types' +import type { BrowserProxy, ProxyBridgeWarmupResult, ProxyCoreDownloadInfoResult, ProxyCoreStatusResult, ProxyIPHealthResult, ProxyLocationResolveResult, ProxySpeedTestResult } from '../types' import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime' export interface ClashImportURLResult { @@ -37,8 +37,18 @@ export async function fetchBrowserProxiesByGroup(groupName: string): Promise proxy.groupName === groupName) } -export async function fetchClashImportFromURL(targetURL: string): Promise { +export async function fetchClashImportFromURL(targetURL: string, proxyId = ''): Promise { const bindings: any = await getBindings() + const trimmedProxyId = proxyId.trim() + if (trimmedProxyId && bindings?.BrowserProxyFetchClashByURLWithProxy) { + return ( + (await bindings.BrowserProxyFetchClashByURLWithProxy(targetURL, trimmedProxyId)) || { + url: targetURL, + content: '', + proxyCount: 0, + } + ) + } if (bindings?.BrowserProxyFetchClashByURL) { return ( (await bindings.BrowserProxyFetchClashByURL(targetURL)) || { @@ -50,6 +60,15 @@ export async function fetchClashImportFromURL(targetURL: string): Promise { +export async function testProxyConnectivity(proxyId: string, proxyConfig: string): Promise { const bindings: any = await getBindings() if (bindings?.TestProxyConnectivity) { - return (await bindings.TestProxyConnectivity(proxyId, proxyConfig)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' } + return (await bindings.TestProxyConnectivity(proxyId, proxyConfig)) || { proxyId, ok: false, latencyMs: 0, engine: 'unknown', error: '调用失败' } } await sleep(300 + Math.random() * 500) - return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 200), error: '' } + return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 200), engine: 'mock', error: '' } } -export async function testProxyRealConnectivity(proxyId: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> { +export async function testProxyRealConnectivity(proxyId: string): Promise { const bindings: any = await getBindings() if (bindings?.TestProxyRealConnectivity) { - return (await bindings.TestProxyRealConnectivity(proxyId)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' } + return (await bindings.TestProxyRealConnectivity(proxyId)) || { proxyId, ok: false, latencyMs: 0, engine: 'unknown', error: '调用失败' } } await sleep(300 + Math.random() * 500) - return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' } + return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), engine: 'mock', error: '' } } -export async function browserProxyTestSpeed(proxyId: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> { +export async function browserProxyTestSpeed(proxyId: string): Promise { const bindings: any = await getBindings() if (bindings?.BrowserProxyTestSpeed) { - return (await bindings.BrowserProxyTestSpeed(proxyId)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' } + return (await bindings.BrowserProxyTestSpeed(proxyId)) || { proxyId, ok: false, latencyMs: 0, engine: 'unknown', error: '调用失败' } } await sleep(300 + Math.random() * 500) - return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' } + return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), engine: 'mock', error: '' } } -export async function browserProxyBatchTestSpeed(proxyIds: string[], concurrency: number = 20): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }[]> { +export async function browserProxyBatchTestSpeed(proxyIds: string[], concurrency: number = 20): Promise { const bindings: any = await getBindings() if (bindings?.BrowserProxyBatchTestSpeed) { return (await bindings.BrowserProxyBatchTestSpeed(proxyIds, concurrency)) || [] } await sleep(1000) - return proxyIds.map((proxyId) => ({ proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' })) + return proxyIds.map((proxyId) => ({ proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), engine: 'mock', error: '' })) } export async function browserProxyWarmupBridge(proxyId: string): Promise { diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx index 58d614f9..f10f39e5 100644 --- a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx +++ b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx @@ -118,7 +118,6 @@ export function AutomationScriptRunModal({ usesStoredTargetConfig, selectorText, setSelectorText, - demoSession, setDemoSession, reloadDemoSession, }); diff --git a/frontend/src/modules/browser/components/BrowserListLayout.tsx b/frontend/src/modules/browser/components/BrowserListLayout.tsx index 764696d6..66eb44e5 100644 --- a/frontend/src/modules/browser/components/BrowserListLayout.tsx +++ b/frontend/src/modules/browser/components/BrowserListLayout.tsx @@ -26,6 +26,7 @@ interface BrowserListHeaderProps { onRefresh: () => void onOpenSettings: () => void onOpenExpandModal: () => void + onOpenTrash: () => void onViewModeChange: (next: BrowserViewMode) => void } @@ -45,6 +46,7 @@ export function BrowserListHeader({ onRefresh, onOpenSettings, onOpenExpandModal, + onOpenTrash, onViewModeChange, }: BrowserListHeaderProps) { return ( @@ -70,6 +72,9 @@ export function BrowserListHeader({ + ))} {isOverflowing && ( diff --git a/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx b/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx index 057af7dd..7e2f4929 100644 --- a/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx +++ b/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx @@ -1,10 +1,13 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Link } from 'react-router-dom' -import { Copy, Key, Play, Puzzle, RotateCcw, Settings, Square, Trash2 } from 'lucide-react' +import { Copy, Key, Loader2, MoreHorizontal, Play, Puzzle, Repeat2, RotateCcw, Settings, Square, Trash2, Wifi } from 'lucide-react' import { Badge, Button, Card, Table } from '../../../shared/components' import type { TableColumn } from '../../../shared/components/Table' -import type { BrowserCore, BrowserProfile, BrowserProxy } from '../types' +import type { BrowserCore, BrowserProfile, BrowserProxy, ProxySpeedTestResult } from '../types' +import { browserProxyTestSpeed, testProxyConnectivity } from '../api' import type { BrowserViewMode } from './BrowserListLayout' import { KeywordInlineRow, LaunchCodeCell } from './BrowserListWidgets' @@ -37,6 +40,7 @@ interface BrowserProfilesPanelProps { onOpenKeywords: (profile: BrowserProfile) => void onOpenExtensions: (profile: BrowserProfile) => void onOpenCopy: (profile: BrowserProfile) => void + onOpenProxyPicker: (profile: BrowserProfile) => void onDelete: (profileId: string) => void } @@ -60,6 +64,195 @@ function formatProxyLabel(profile: BrowserProfile, proxy?: BrowserProxy): string return '-' } +function ProxyLatency({ result }: { result?: ProxySpeedTestResult | null }) { + if (!result) return null + if (!result.ok) return 失败 + const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500' + return {result.latencyMs}ms +} + +function ProxyInlineActions({ + profile, + proxy, + isBusy, + onOpenProxyPicker, + maxWidthClass = 'max-w-[220px]', +}: { + profile: BrowserProfile + proxy?: BrowserProxy + isBusy: boolean + onOpenProxyPicker: (profile: BrowserProfile) => void + maxWidthClass?: string +}) { + const [testing, setTesting] = useState(false) + const [speedResult, setSpeedResult] = useState(null) + const historyResult = proxy?.lastTestedAt + ? { + proxyId: proxy.proxyId, + ok: proxy.lastTestOk ?? false, + latencyMs: proxy.lastLatencyMs ?? -1, + error: '', + } + : null + const displayResult = speedResult || historyResult + const canTest = !!profile.proxyId || !!profile.proxyConfig.trim() + + const handleTest = async () => { + if (testing || !canTest) return + setTesting(true) + try { + const result = profile.proxyId + ? await browserProxyTestSpeed(profile.proxyId) + : await testProxyConnectivity(profile.profileId, profile.proxyConfig) + setSpeedResult(result) + } catch (error: any) { + setSpeedResult({ + proxyId: profile.proxyId || profile.profileId, + ok: false, + latencyMs: -1, + error: error?.message || '测速失败', + }) + } finally { + setTesting(false) + } + } + + return ( +
+ {formatProxyLabel(profile, proxy)} + + + +
+ ) +} + +function ProfileMoreActions({ + open, + disabled, + onToggle, + onClose, + onRestart, + onOpenKeywords, + onOpenExtensions, +}: { + open: boolean + disabled: boolean + onToggle: () => void + onClose: () => void + onRestart: () => void + onOpenKeywords: () => void + onOpenExtensions: () => void +}) { + const triggerRef = useRef(null) + const menuRef = useRef(null) + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }) + + useEffect(() => { + if (!open) return + const updateMenuPosition = () => { + const rect = triggerRef.current?.getBoundingClientRect() + if (!rect) return + const menuWidth = 128 + const menuHeight = 128 + const gap = 8 + const left = Math.max(8, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8)) + const belowTop = rect.bottom + gap + const top = belowTop + menuHeight > window.innerHeight + ? Math.max(8, rect.top - menuHeight - gap) + : belowTop + setMenuPosition({ top, left }) + } + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) { + onClose() + } + } + updateMenuPosition() + document.addEventListener('mousedown', handlePointerDown) + window.addEventListener('resize', updateMenuPosition) + window.addEventListener('scroll', updateMenuPosition, true) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + window.removeEventListener('resize', updateMenuPosition) + window.removeEventListener('scroll', updateMenuPosition, true) + } + }, [open, onClose]) + + const runAndClose = (handler: () => void) => { + handler() + onClose() + } + + return ( + <> +
+ +
+ {open && createPortal( +
+ + + +
, + document.body + )} + + ) +} + function BrowserProfileCard({ profile, proxy, @@ -77,6 +270,7 @@ function BrowserProfileCard({ onOpenKeywords, onOpenExtensions, onOpenCopy, + onOpenProxyPicker, onDelete, }: { profile: BrowserProfile @@ -95,6 +289,7 @@ function BrowserProfileCard({ onOpenKeywords: (profile: BrowserProfile) => void onOpenExtensions: (profile: BrowserProfile) => void onOpenCopy: (profile: BrowserProfile) => void + onOpenProxyPicker: (profile: BrowserProfile) => void onDelete: (profileId: string) => void }) { return ( @@ -156,9 +351,13 @@ function BrowserProfileCard({
代理配置 - - {formatProxyLabel(profile, proxy)} - +
快捷配置码 @@ -202,10 +401,12 @@ export function BrowserProfilesPanel({ onOpenKeywords, onOpenExtensions, onOpenCopy, + onOpenProxyPicker, onDelete, }: BrowserProfilesPanelProps) { const allSelected = profiles.length > 0 && selectedIds.size === profiles.length const partiallySelected = selectedIds.size > 0 && selectedIds.size < profiles.length + const [openMoreProfileId, setOpenMoreProfileId] = useState(null) const columns: TableColumn[] = [ { @@ -275,7 +476,8 @@ export function BrowserProfilesPanel({ title: '代理', render: (value, record) => { const proxy = proxies.find(item => item.proxyId === value) - return {formatProxyLabel(record, proxy)} + const isBusy = isProfileBusy(record.profileId) + return }, }, { @@ -297,15 +499,16 @@ export function BrowserProfilesPanel({ { key: 'actions', title: '操作', - width: 292, + width: 248, align: 'right', render: (_, record) => { const isStarting = isProfileStarting(record.profileId) const isStopping = isProfileStopping(record.profileId) const isBusy = isProfileBusy(record.profileId) + const isMoreOpen = openMoreProfileId === record.profileId return ( -
+
{record.running ? ( )} - - - + setOpenMoreProfileId(isMoreOpen ? null : record.profileId)} + onClose={() => setOpenMoreProfileId(null)} + onRestart={() => onRestart(record.profileId)} + onOpenKeywords={() => onOpenKeywords(record)} + onOpenExtensions={() => onOpenExtensions(record)} + />
) @@ -361,6 +570,7 @@ export function BrowserProfilesPanel({ onOpenKeywords={onOpenKeywords} onOpenExtensions={onOpenExtensions} onOpenCopy={onOpenCopy} + onOpenProxyPicker={onOpenProxyPicker} onDelete={onDelete} />
diff --git a/frontend/src/modules/browser/components/BrowserSettingsModal.tsx b/frontend/src/modules/browser/components/BrowserSettingsModal.tsx index 7deadd00..c667d8ec 100644 --- a/frontend/src/modules/browser/components/BrowserSettingsModal.tsx +++ b/frontend/src/modules/browser/components/BrowserSettingsModal.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { CheckCircle, Edit2, Plus, Star, Trash2, XCircle } from 'lucide-react' -import { Button, Card, FormItem, Input, Modal, Select, Switch, Table, Textarea, toast } from '../../../shared/components' +import { Button, Card, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components' import type { TableColumn } from '../../../shared/components/Table' import type { BrowserCore, BrowserCoreInput, BrowserSettings } from '../types' import { @@ -122,16 +122,6 @@ export function BrowserSettingsModal({ open, onClose, settings: initSettings, co
- - setSettings(p => ({ ...p, userDataRoot: e.target.value }))} placeholder="data" /> diff --git a/frontend/src/modules/browser/components/InstanceFilterBar.tsx b/frontend/src/modules/browser/components/InstanceFilterBar.tsx index eaff3a63..fb9c0d5a 100644 --- a/frontend/src/modules/browser/components/InstanceFilterBar.tsx +++ b/frontend/src/modules/browser/components/InstanceFilterBar.tsx @@ -44,7 +44,8 @@ export function InstanceFilterBar({ filters, onChange, proxies, cores, allTags, onChange({ ...filters, [key]: value }) const hasFilter = !isFiltersEmpty(filters) - const activeCount = [filters.keyword, filters.status, filters.proxyId, filters.coreId, filters.kwSearch, filters.groupId].filter(Boolean).length + filters.tags.size + const searchValue = filters.keyword || filters.kwSearch + const activeCount = [searchValue, filters.status, filters.proxyId, filters.coreId, filters.groupId].filter(Boolean).length + filters.tags.size return (
@@ -66,10 +67,10 @@ export function InstanceFilterBar({ filters, onChange, proxies, cores, allTags, <>
set('keyword', e.target.value)} - placeholder="搜索名称..." - style={{ width: '180px' }} + value={searchValue} + onChange={e => onChange({ ...filters, keyword: e.target.value, kwSearch: '' })} + placeholder="搜索名称/快捷码/关键字..." + className="flex-1 min-w-[220px]" /> set('kwSearch', e.target.value)} - placeholder="搜索关键字值..." - className="flex-1 min-w-[160px]" - /> {hasFilter && (
-

- {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' && ( <> -
+
{ @@ -147,6 +147,18 @@ export function ProxyImportModalView({ placeholder="订阅 URL" className="flex-1" /> +