chore commit remaining workspace changes

This commit is contained in:
ant-black
2026-06-23 09:35:24 +08:00
parent b260f7b0b8
commit 65232db7ec
117 changed files with 4709 additions and 890 deletions
+1
View File
@@ -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.
+10
View File
@@ -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. 代理生效验证
<img src="images/readme/004-自定义代理.png" alt="代理生效验证" width="100%" />
@@ -273,6 +281,8 @@ chrome/
先检查代理节点本身是否可用,再确认该实例已经正确绑定代理。建议启动后访问 IP 检测网站复核当前出口。
如果代理池里本地客户端可用节点很多,但 Ant Browser 中“只展示可用”数量明显偏少,先确认当前 `default_connector_type` 是否与本地客户端一致。Ant Browser 不会在 `xray` 组合栈和 `mihomo` 栈之间自动混用;切换连接栈后需要重新测速。
### 3. 实例太多,怎么快速找到目标实例?
可以在 `实例列表` 中按状态、代理、内核、分组、关键字筛选,也可以通过 `Ctrl + K` 使用实例 Code 或名称快速启动。
+2 -2
View File
@@ -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),
}
+4 -3
View File
@@ -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,
+48 -11
View File
@@ -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()
}
+16
View File
@@ -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)
+25 -1
View File
@@ -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
}
+6 -6
View File
@@ -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("浏览器窗口已启动,但调试接口在等待窗口内未就绪,转入后台附着",
+55 -51
View File
@@ -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
}
+56 -44
View File
@@ -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) {
+6 -6
View File
@@ -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)
+59
View File
@@ -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()
+31 -10
View File
@@ -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
+110
View File
@@ -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")
}
}
+174 -3
View File
@@ -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) {
+20
View File
@@ -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)
}
}
+28 -17
View File
@@ -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 {
+11
View File
@@ -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"`
+2 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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()
}
+5 -1
View File
@@ -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,
@@ -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)
+84 -10
View File
@@ -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,
@@ -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 () {})
@@ -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)
@@ -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)
@@ -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];
},
};
+20
View File
@@ -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
+104 -11
View File
@@ -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
+189 -11
View File
@@ -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
}
@@ -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
}
+7 -6
View File
@@ -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 {
+1
View File
@@ -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"`
}
+1
View File
@@ -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"`
+11 -1
View File
@@ -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":
+8
View File
@@ -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()
+15
View File
@@ -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,
+13 -2
View File
@@ -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)
}
+30 -3
View File
@@ -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 {
+64 -54
View File
@@ -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) {
+7 -3
View File
@@ -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 {
+2
View File
@@ -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",
+154
View File
@@ -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
}
@@ -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)
}
}
+110 -21
View File
@@ -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)
+57
View File
@@ -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")))
}
@@ -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)
}
}
+1
View File
@@ -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,
+10
View File
@@ -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)
}
}
@@ -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{}))
@@ -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
}
@@ -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
@@ -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" {
+34 -10
View File
@@ -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"
@@ -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()
@@ -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)
}
}
@@ -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
}
@@ -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 {
@@ -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
+89 -1
View File
@@ -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")
@@ -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 == "" {
@@ -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"])
}
}
+44 -4
View File
@@ -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")
}
+1
View File
@@ -22,6 +22,7 @@ type SingBoxBridge struct {
Stopping bool
LastError string
Outbound map[string]interface{}
RefCount int
LastUsedAt time.Time
Restarting bool
RestartCount int
+261 -68
View File
@@ -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)
}
+2 -2
View File
@@ -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"}
}
+184 -12
View File
@@ -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
}
}
}
}
@@ -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
}
+12 -42
View File
@@ -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)
}
+1
View File
@@ -5,5 +5,6 @@ type TestResult struct {
ProxyId string
Ok bool
LatencyMs int64
Engine string
Error string
}
+16
View File
@@ -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
}
+47 -9
View File
@@ -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 {
@@ -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 == "" {
+33 -11
View File
@@ -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 {
+72 -10
View File
@@ -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
+6 -3
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
7b7cd01deb4f6d205b686dee62014883
80f733bd69eeb89b54b5de1ecc207d1a
+56 -1
View File
@@ -8,7 +8,15 @@ export async function fetchBrowserProfiles(): Promise<BrowserProfile[]> {
if (bindings?.BrowserProfileList) {
return (await bindings.BrowserProfileList()) || []
}
return getMockProfiles()
return getMockProfiles().filter((profile) => !profile.deletedAt)
}
export async function fetchBrowserProfileTrash(): Promise<BrowserProfile[]> {
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<BrowserProfile[]> {
@@ -78,10 +86,57 @@ export async function deleteBrowserProfile(profileId: string): Promise<boolean>
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<BrowserProfile | null> {
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<boolean> {
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<boolean> {
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,
+32 -13
View File
@@ -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<Bro
return getMockProxies().filter((proxy) => proxy.groupName === groupName)
}
export async function fetchClashImportFromURL(targetURL: string): Promise<ClashImportURLResult> {
export async function fetchClashImportFromURL(targetURL: string, proxyId = ''): Promise<ClashImportURLResult> {
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<ClashI
}
const goApp = getGoApp()
if (trimmedProxyId && goApp?.BrowserProxyFetchClashByURLWithProxy) {
return (
(await goApp.BrowserProxyFetchClashByURLWithProxy(targetURL, trimmedProxyId)) || {
url: targetURL,
content: '',
proxyCount: 0,
}
)
}
if (goApp?.BrowserProxyFetchClashByURL) {
return (
(await goApp.BrowserProxyFetchClashByURL(targetURL)) || {
@@ -81,40 +100,40 @@ export async function validateProxyConfig(proxyConfig: string, proxyId: string):
return { supported: true, errorMsg: '' }
}
export async function testProxyConnectivity(proxyId: string, proxyConfig: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> {
export async function testProxyConnectivity(proxyId: string, proxyConfig: string): Promise<ProxySpeedTestResult> {
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<ProxySpeedTestResult> {
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<ProxySpeedTestResult> {
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<ProxySpeedTestResult[]> {
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<ProxyBridgeWarmupResult> {
@@ -118,7 +118,6 @@ export function AutomationScriptRunModal({
usesStoredTargetConfig,
selectorText,
setSelectorText,
demoSession,
setDemoSession,
reloadDemoSession,
});
@@ -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({
<Button variant="secondary" size="sm" onClick={onOpenSettings}>
<Sliders className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={onOpenTrash}>
<Trash2 className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
@@ -131,6 +131,15 @@ export function KeywordInlineRow({ keywords }: KeywordInlineRowProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [isOverflowing, setIsOverflowing] = useState(false)
const handleCopyKeyword = async (keyword: string) => {
try {
await navigator.clipboard.writeText(keyword)
toast.success('关键字已复制')
} catch {
toast.error('复制失败')
}
}
useEffect(() => {
if (containerRef.current) {
setIsOverflowing(containerRef.current.scrollHeight > 36)
@@ -148,14 +157,16 @@ export function KeywordInlineRow({ keywords }: KeywordInlineRowProps) {
className={`flex flex-wrap gap-2 flex-1 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
>
{keywords.map((keyword, index) => (
<span
<button
type="button"
key={index}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] text-[var(--color-text-secondary)] max-w-[200px]"
title={keyword}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-2.5 py-1 text-left text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)]"
title={`点击复制:${keyword}`}
onClick={() => { void handleCopyKeyword(keyword) }}
>
<span className="text-[var(--color-text-muted)] font-mono shrink-0">{index + 1}.</span>
<span className="truncate">{keyword}</span>
</span>
</button>
))}
</div>
{isOverflowing && (
@@ -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 <span className="text-xs text-red-500"></span>
const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium ${color}`}>{result.latencyMs}ms</span>
}
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<ProxySpeedTestResult | null>(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 (
<div className={`inline-flex ${maxWidthClass} items-center gap-1.5 text-xs`} title={formatProxyLabel(profile, proxy)}>
<span className="min-w-0 truncate text-[var(--color-text-primary)]">{formatProxyLabel(profile, proxy)}</span>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-40"
title={isBusy ? '实例操作中,暂不可切换代理' : '切换代理'}
disabled={isBusy}
onClick={() => onOpenProxyPicker(profile)}
>
<Repeat2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-40"
title={canTest ? '测速' : '无可测速代理'}
disabled={testing || !canTest}
onClick={handleTest}
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
</button>
<ProxyLatency result={displayResult} />
</div>
)
}
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<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(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 (
<>
<div ref={triggerRef} className="inline-flex">
<Button
size="sm"
variant="ghost"
onClick={onToggle}
title="更多"
disabled={disabled}
className="px-2"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</Button>
</div>
{open && createPortal(
<div
ref={menuRef}
className="fixed z-[9999] w-32 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-1.5 shadow-xl"
style={{ top: menuPosition.top, left: menuPosition.left }}
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)]"
onClick={() => runAndClose(onRestart)}
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)]"
onClick={() => runAndClose(onOpenKeywords)}
>
<Key className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)]"
onClick={() => runAndClose(onOpenExtensions)}
>
<Puzzle className="w-3.5 h-3.5" />
</button>
</div>,
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({
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-[var(--color-text-muted)] font-medium"></span>
<span className="text-xs text-[var(--color-text-primary)] truncate" title={formatProxyLabel(profile, proxy)}>
{formatProxyLabel(profile, proxy)}
</span>
<ProxyInlineActions
profile={profile}
proxy={proxy}
isBusy={isBusy}
onOpenProxyPicker={onOpenProxyPicker}
maxWidthClass="max-w-full"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-[var(--color-text-muted)] font-medium"></span>
@@ -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<string | null>(null)
const columns: TableColumn<BrowserProfile>[] = [
{
@@ -275,7 +476,8 @@ export function BrowserProfilesPanel({
title: '代理',
render: (value, record) => {
const proxy = proxies.find(item => item.proxyId === value)
return <span className="text-xs" title={formatProxyLabel(record, proxy)}>{formatProxyLabel(record, proxy)}</span>
const isBusy = isProfileBusy(record.profileId)
return <ProxyInlineActions profile={record} proxy={proxy} isBusy={isBusy} onOpenProxyPicker={onOpenProxyPicker} />
},
},
{
@@ -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 (
<div className="flex justify-end gap-1 whitespace-nowrap">
<div className="flex justify-end gap-1.5 whitespace-nowrap">
{record.running ? (
<Button size="sm" variant="secondary" onClick={() => onStop(record.profileId)} title="停止" loading={isStopping}>
{!isStopping && <Square className="w-3.5 h-3.5" />}
@@ -315,11 +518,17 @@ export function BrowserProfilesPanel({
{!isStarting && <Play className="w-3.5 h-3.5 fill-current" />}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => onRestart(record.profileId)} title="重启" disabled={isBusy}><RotateCcw className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onOpenKeywords(record)} title="关键字" disabled={isBusy}><Key className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onOpenExtensions(record)} title="插件" disabled={isBusy}><Puzzle className="w-3.5 h-3.5" /></Button>
<Link to={`/browser/edit/${record.profileId}`}><Button size="sm" variant="ghost" title="配置" disabled={isBusy}><Settings className="w-3.5 h-3.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => onOpenCopy(record)} title="克隆" disabled={isBusy}><Copy className="w-3.5 h-3.5" /></Button>
<ProfileMoreActions
open={isMoreOpen}
disabled={isBusy}
onToggle={() => setOpenMoreProfileId(isMoreOpen ? null : record.profileId)}
onClose={() => setOpenMoreProfileId(null)}
onRestart={() => onRestart(record.profileId)}
onOpenKeywords={() => onOpenKeywords(record)}
onOpenExtensions={() => onOpenExtensions(record)}
/>
<Button size="sm" variant="ghost" onClick={() => onDelete(record.profileId)} title="删除" disabled={isBusy}><Trash2 className="w-3.5 h-3.5 text-red-500" /></Button>
</div>
)
@@ -361,6 +570,7 @@ export function BrowserProfilesPanel({
onOpenKeywords={onOpenKeywords}
onOpenExtensions={onOpenExtensions}
onOpenCopy={onOpenCopy}
onOpenProxyPicker={onOpenProxyPicker}
onDelete={onDelete}
/>
</div>
@@ -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
</div>
<Card padding="none"><Table columns={coreColumns} data={cores} rowKey="coreId" /></Card>
</div>
<FormItem label="代理内核">
<Select
value={settings.defaultConnectorType || 'xray'}
onChange={e => setSettings(p => ({ ...p, defaultConnectorType: e.target.value }))}
options={[
{ value: 'xray', label: 'Xray' },
{ value: 'mihomo', label: 'Mihomo' },
]}
/>
</FormItem>
<FormItem label="用户数据根目录">
<Input value={settings.userDataRoot} onChange={e => setSettings(p => ({ ...p, userDataRoot: e.target.value }))} placeholder="data" />
</FormItem>
@@ -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 (
<div className="space-y-2">
@@ -66,10 +67,10 @@ export function InstanceFilterBar({ filters, onChange, proxies, cores, allTags,
<>
<div className="flex items-center gap-2 flex-wrap">
<Input
value={filters.keyword}
onChange={e => 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]"
/>
<Select
value={filters.status}
@@ -110,12 +111,6 @@ export function InstanceFilterBar({ filters, onChange, proxies, cores, allTags,
]}
style={{ width: '140px' }}
/>
<Input
value={filters.kwSearch}
onChange={e => set('kwSearch', e.target.value)}
placeholder="搜索关键字值..."
className="flex-1 min-w-[160px]"
/>
{hasFilter && (
<button
onClick={() => onChange({ ...EMPTY_FILTERS, tags: new Set() })}
@@ -407,7 +407,7 @@ export function nextProxyID(): string {
return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
export function createExistingProxyPicker(oldSourceProxies: BrowserProxy[]) {
const exactMap = new Map<string, BrowserProxy[]>()
const nameMap = new Map<string, BrowserProxy[]>()
oldSourceProxies.forEach(item => {
@@ -422,20 +422,25 @@ export function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
nameMap.set(nameKey, nameList)
})
return (name: string, configText: string): string | null => {
return (name: string, configText: string): BrowserProxy | null => {
const exactKey = `${name}|||${configText}`
const exactList = exactMap.get(exactKey)
if (exactList && exactList.length > 0) {
const item = exactList.shift()
if (item?.proxyId) return item.proxyId
if (item?.proxyId) return item
}
const nameList = nameMap.get(name)
if (nameList && nameList.length > 0) {
const item = nameList.shift()
if (item?.proxyId) return item.proxyId
if (item?.proxyId) return item
}
return null
}
}
export function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
const pickExisting = createExistingProxyPicker(oldSourceProxies)
return (name: string, configText: string): string | null => pickExisting(name, configText)?.proxyId || null
}
@@ -19,7 +19,7 @@ import {
buildDirectImportCandidate,
buildImportCandidatesFromClash,
buildImportPreview,
createExistingProxyIDPicker,
createExistingProxyPicker,
parseClashImportText,
nextProxyID,
normalizeRefreshIntervalM,
@@ -38,6 +38,7 @@ export function ProxyImportModal({
}: ProxyImportModalProps) {
const [importMode, setImportMode] = useState<ProxyImportMode>('clash')
const [importUrl, setImportUrl] = useState('')
const [importFetchProxyId, setImportFetchProxyId] = useState('')
const [importResolvedUrl, setImportResolvedUrl] = useState('')
const [importText, setImportText] = useState('')
const [importDnsServers, setImportDnsServers] = useState('')
@@ -59,6 +60,7 @@ export function ProxyImportModal({
const resetImportState = () => {
setImportMode('clash')
setImportUrl('')
setImportFetchProxyId('')
setImportResolvedUrl('')
setImportText('')
setImportDnsServers('')
@@ -75,6 +77,7 @@ export function ProxyImportModal({
setImportResolvedUrl('')
if (nextMode !== 'clash') {
setImportUrl('')
setImportFetchProxyId('')
setImportDnsServers('')
}
}
@@ -98,7 +101,7 @@ export function ProxyImportModal({
setFetchingImportUrl(true)
try {
const result = await fetchClashImportFromURL(targetURL)
const result = await fetchClashImportFromURL(targetURL, importFetchProxyId)
const content = (result?.content || '').trim()
if (!content) {
throw new Error('订阅内容为空')
@@ -204,12 +207,15 @@ export function ProxyImportModal({
const oldSourceProxies = isURLImport
? existingProxies.filter(item => (item.sourceId || '').trim() === sourceID)
: []
const pickExistingID = createExistingProxyIDPicker(oldSourceProxies)
const pickExisting = createExistingProxyPicker(oldSourceProxies)
const newProxies: BrowserProxy[] = previewList.map((p) => ({
proxyId: pickExistingID(p.proxyName, p.proxyConfig) || nextProxyID(),
const newProxies: BrowserProxy[] = previewList.map((p) => {
const existingProxy = pickExisting(p.proxyName, p.proxyConfig)
return {
proxyId: existingProxy?.proxyId || nextProxyID(),
proxyName: p.proxyName,
proxyConfig: p.proxyConfig,
preferredKernel: existingProxy?.preferredKernel || undefined,
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
groupName: p.groupName.trim() || undefined,
sourceId: sourceID || undefined,
@@ -218,7 +224,8 @@ export function ProxyImportModal({
sourceAutoRefresh,
sourceRefreshIntervalM,
sourceLastRefreshAt: sourceLastRefreshAt || undefined,
}))
}
})
const allProxies = isURLImport
? existingProxies.filter(item => (item.sourceId || '').trim() !== sourceID).concat(newProxies)
: [...existingProxies, ...newProxies]
@@ -278,6 +285,7 @@ export function ProxyImportModal({
canParseImport={canParseImport}
importMode={importMode}
importUrl={importUrl}
importFetchProxyId={importFetchProxyId}
importResolvedUrl={importResolvedUrl}
importText={importText}
importDnsServers={importDnsServers}
@@ -287,6 +295,7 @@ export function ProxyImportModal({
directImportForm={directImportForm}
chainImportForm={chainImportForm}
groups={groups}
fetchProxyOptions={existingProxies.filter(proxy => proxy.proxyConfig.trim() && !proxy.proxyConfig.trim().toLowerCase().startsWith('direct://'))}
previewModalOpen={previewModalOpen}
previewList={previewList}
importing={importing}
@@ -294,6 +303,7 @@ export function ProxyImportModal({
onParseImport={handleParseImport}
onImportModeChange={handleImportModeChange}
onImportUrlChange={setImportUrl}
onImportFetchProxyIdChange={setImportFetchProxyId}
onImportResolvedUrlChange={setImportResolvedUrl}
onFetchImportURL={handleFetchImportURL}
onImportTextChange={setImportText}
@@ -1,5 +1,6 @@
import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProxy } from '../types'
import { DIRECT_QUICK_IMPORT_TEMPLATE } from '../pages/proxyPool/helpers'
import {
DIRECT_PROXY_PROTOCOL_OPTIONS,
@@ -17,6 +18,7 @@ interface ProxyImportModalViewProps {
canParseImport: boolean
importMode: ProxyImportMode
importUrl: string
importFetchProxyId: string
importResolvedUrl: string
importText: string
importDnsServers: string
@@ -26,6 +28,7 @@ interface ProxyImportModalViewProps {
directImportForm: DirectImportForm
chainImportForm: ChainImportForm
groups: string[]
fetchProxyOptions: BrowserProxy[]
previewModalOpen: boolean
previewList: ProxyDisplayInfo[]
importing: boolean
@@ -33,6 +36,7 @@ interface ProxyImportModalViewProps {
onParseImport: () => void
onImportModeChange: (mode: ProxyImportMode) => void
onImportUrlChange: (value: string) => void
onImportFetchProxyIdChange: (value: string) => void
onImportResolvedUrlChange: (value: string) => void
onFetchImportURL: () => Promise<void>
onImportTextChange: (value: string) => void
@@ -57,6 +61,7 @@ export function ProxyImportModalView({
canParseImport,
importMode,
importUrl,
importFetchProxyId,
importResolvedUrl,
importText,
importDnsServers,
@@ -66,6 +71,7 @@ export function ProxyImportModalView({
directImportForm,
chainImportForm,
groups,
fetchProxyOptions,
previewModalOpen,
previewList,
importing,
@@ -73,6 +79,7 @@ export function ProxyImportModalView({
onParseImport,
onImportModeChange,
onImportUrlChange,
onImportFetchProxyIdChange,
onImportResolvedUrlChange,
onFetchImportURL,
onImportTextChange,
@@ -124,17 +131,10 @@ export function ProxyImportModalView({
</Button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
<>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<div className="grid grid-cols-[minmax(0,1fr)_150px_auto] gap-2">
<Input
value={importUrl}
onChange={e => {
@@ -147,6 +147,18 @@ export function ProxyImportModalView({
placeholder="订阅 URL"
className="flex-1"
/>
<Select
value={importFetchProxyId}
onChange={e => onImportFetchProxyIdChange(e.target.value)}
disabled={fetchingImportUrl}
options={[
{ value: '', label: '直连拉取' },
...fetchProxyOptions.map(proxy => ({
value: proxy.proxyId,
label: proxy.proxyName || proxy.proxyId,
})),
]}
/>
<Button
variant="secondary"
onClick={onFetchImportURL}
@@ -161,7 +173,6 @@ export function ProxyImportModalView({
{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"> YAML DNS </p>
</FormItem>
<Textarea
value={importText}
@@ -1,4 +1,4 @@
export type SpeedResult = { ok: boolean; latencyMs: number; error: string }
export type SpeedResult = { ok: boolean; latencyMs: number; engine?: string; error: string }
export type ChainSocksHop = {
protocol?: 'http' | 'socks5'
@@ -33,9 +33,10 @@ interface ProxyRowProps {
function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
if (testing) return <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--color-text-muted)] shrink-0" />
if (!result) return null
if (!result.ok) return <span className="text-xs text-red-500 shrink-0"></span>
const suffix = result.engine ? ` (${result.engine})` : ''
if (!result.ok) return <span className="text-xs text-red-500 shrink-0 whitespace-nowrap">{suffix}</span>
const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium shrink-0 ${color}`}>{result.latencyMs}ms</span>
return <span className={`text-xs font-medium shrink-0 whitespace-nowrap ${color}`}>{result.latencyMs}ms{suffix}</span>
}
export function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
@@ -1,4 +1,4 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { toast } from "../../../shared/components";
import { fetchBrowserProfiles, fetchGroups } from "../api";
import type { AutomationScriptRecord } from "../automationScripts";
@@ -29,7 +29,6 @@ interface UseAutomationScriptRunProfilesOptions {
usesStoredTargetConfig: boolean;
selectorText: string;
setSelectorText: (value: string) => void;
demoSession: AutomationDemoSession;
setDemoSession: Dispatch<SetStateAction<AutomationDemoSession>>;
reloadDemoSession: () => AutomationDemoSession;
}
@@ -41,7 +40,6 @@ export function useAutomationScriptRunProfiles({
usesStoredTargetConfig,
selectorText,
setSelectorText,
demoSession,
setDemoSession,
reloadDemoSession,
}: UseAutomationScriptRunProfilesOptions) {
@@ -57,6 +55,8 @@ export function useAutomationScriptRunProfiles({
const [createDraft, setCreateDraft] = useState<DemoCreateDraft>(
DEFAULT_DEMO_CREATE_DRAFT,
);
const lastRefreshKeyRef = useRef("");
const activeRefreshKeyRef = useRef("");
const selectedProfile =
availableProfiles.find((profile) => profile.profileId === selectedProfileId) ||
null;
@@ -216,31 +216,42 @@ export function useAutomationScriptRunProfiles({
if (!open || !script) {
setAvailableProfiles([]);
setSelectedProfileId("");
lastRefreshKeyRef.current = "";
activeRefreshKeyRef.current = "";
return;
}
const nextDemoSession = reloadDemoSession();
const nextSelectorText = resolveInitialSelectorText(script, nextDemoSession);
void refreshSelectableProfiles(
script.targetConfig.selector.profileId || nextDemoSession.profileId,
resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode,
false,
);
}, [open, script, usesStoredTargetConfig]);
const preferredProfileId =
script.targetConfig.selector.profileId || nextDemoSession.profileId;
const preferredLaunchCode =
resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode;
const refreshKey = [
script.id,
usesStoredTargetConfig ? "stored" : "runtime",
preferredProfileId,
preferredLaunchCode,
].join("|");
useEffect(() => {
if (!open || !script || script.type !== "playwright-cdp") {
return;
}
if (usesStoredTargetConfig) {
return;
}
if (demoMode !== "select") {
if (
lastRefreshKeyRef.current === refreshKey ||
activeRefreshKeyRef.current === refreshKey
) {
return;
}
void refreshSelectableProfiles("", demoSession.launchCode, false);
}, [demoMode, demoSession.launchCode, open, script, usesStoredTargetConfig]);
activeRefreshKeyRef.current = refreshKey;
void refreshSelectableProfiles(preferredProfileId, preferredLaunchCode, false)
.then(() => {
lastRefreshKeyRef.current = refreshKey;
})
.finally(() => {
if (activeRefreshKeyRef.current === refreshKey) {
activeRefreshKeyRef.current = "";
}
});
}, [open, script?.id, usesStoredTargetConfig]);
const handleSelectedProfileChange = (profileId: string) => {
setSelectedProfileId(profileId);
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { toast } from '../../../shared/components'
import type { BrowserProfile, BrowserProfileCopyOptions } from '../types'
import type { BrowserProfile, BrowserProfileCopyOptions, BrowserProxy } from '../types'
import { BrowserCoreEditorModal, BrowserListHeader, BrowserListSettingsModal } from '../components/BrowserListLayout'
import { BatchToolbar } from '../components/BrowserListWidgets'
import { BrowserProfilesPanel } from '../components/BrowserProfilesPanel'
import { ProxyPickerModal } from '../components/ProxyPickerModal'
import { ProfileExtensionModal } from '../components/ProfileExtensionModal'
import { createBrowserProfileCopyOptions, isBrowserProfileCopyOptionsValid } from '../copyOptions'
import { buildBrowserProfileCopyName } from '../copyName'
@@ -17,10 +18,16 @@ import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup'
import {
copyBrowserProfile,
deleteBrowserProfile,
fetchBrowserProfileTrash,
permanentlyDeleteBrowserProfile,
restoreBrowserProfile,
startBrowserInstance,
stopBrowserInstance,
updateBrowserProfile,
} from '../api'
const directProxyID = '__direct__'
export function BrowserListPage() {
const {
viewMode,
@@ -33,6 +40,13 @@ export function BrowserListPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchLoading, setBatchLoading] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<{
open: boolean
mode: 'single' | 'batch'
profileId?: string
profileName?: string
count: number
}>({ open: false, mode: 'single', count: 0 })
// 代理不支持弹窗
const [proxyErrorModal, setProxyErrorModal] = useState(false)
@@ -50,11 +64,22 @@ export function BrowserListPage() {
const openExtensionModal = (profile: BrowserProfile) => setExtensionModal({ open: true, profile })
const closeExtensionModal = () => setExtensionModal({ open: false, profile: null })
const [proxyPickerProfile, setProxyPickerProfile] = useState<BrowserProfile | null>(null)
// 复制弹窗
const [copyModal, setCopyModal] = useState<{ open: boolean; profile: BrowserProfile | null }>({ open: false, profile: null })
const [copyName, setCopyName] = useState('')
const [copyOptions, setCopyOptions] = useState<BrowserProfileCopyOptions>(() => createBrowserProfileCopyOptions())
const [copying, setCopying] = useState(false)
const [trashModalOpen, setTrashModalOpen] = useState(false)
const [trashProfiles, setTrashProfiles] = useState<BrowserProfile[]>([])
const [trashLoading, setTrashLoading] = useState(false)
const [restoringId, setRestoringId] = useState('')
const [permanentlyDeletingId, setPermanentlyDeletingId] = useState('')
const [permanentDeleteConfirm, setPermanentDeleteConfirm] = useState<{ open: boolean; profile: BrowserProfile | null }>({
open: false,
profile: null,
})
const openCopyModal = (profile: BrowserProfile) => {
setCopyName(buildBrowserProfileCopyName(profile.profileName))
@@ -113,6 +138,7 @@ export function BrowserListPage() {
updatePendingIds,
updateProfilesState,
mergeProfileState,
updateProxiesState,
loadProfiles,
} = useBrowserListData({ loadQuota, loadCores })
const {
@@ -131,7 +157,6 @@ export function BrowserListPage() {
handleStartDirect,
handleStop,
handleRestart,
handleDelete,
} = useBrowserProfileActions({
profiles,
setProxyErrorModal,
@@ -234,18 +259,97 @@ export function BrowserListPage() {
loadProfiles()
}
const handleBatchDelete = async () => {
const openDeleteConfirm = (profileId: string) => {
const profile = profiles.find(item => item.profileId === profileId)
setDeleteConfirm({
open: true,
mode: 'single',
profileId,
profileName: profile?.profileName,
count: 1,
})
}
const loadTrashProfiles = async () => {
setTrashLoading(true)
try {
setTrashProfiles(await fetchBrowserProfileTrash())
} catch (error: any) {
toast.error(error?.message || '加载回收站失败')
} finally {
setTrashLoading(false)
}
}
const openTrashModal = () => {
setTrashModalOpen(true)
void loadTrashProfiles()
}
const handleRestoreProfile = async (profileId: string) => {
setRestoringId(profileId)
try {
await restoreBrowserProfile(profileId)
toast.success('实例已恢复')
await loadTrashProfiles()
await loadProfiles()
} catch (error: any) {
toast.error(error?.message || '恢复失败')
} finally {
setRestoringId('')
}
}
const handleConfirmPermanentDelete = async () => {
const profile = permanentDeleteConfirm.profile
if (!profile) return
setPermanentlyDeletingId(profile.profileId)
try {
await permanentlyDeleteBrowserProfile(profile.profileId)
toast.success('实例已彻底删除')
setPermanentDeleteConfirm({ open: false, profile: null })
await loadTrashProfiles()
} catch (error: any) {
toast.error(error?.message || '彻底删除失败')
} finally {
setPermanentlyDeletingId('')
}
}
const openBatchDeleteConfirm = () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
if (!confirm(`确定删除选中的 ${ids.length} 个实例?`)) return
setDeleteConfirm({ open: true, mode: 'batch', count: ids.length })
}
const closeDeleteConfirm = () => {
if (batchLoading) return
setDeleteConfirm({ open: false, mode: 'single', count: 0 })
}
const handleConfirmDelete = async () => {
const ids = deleteConfirm.mode === 'batch'
? Array.from(selectedIds)
: deleteConfirm.profileId ? [deleteConfirm.profileId] : []
if (ids.length === 0) return
setBatchLoading(true)
for (const id of ids) {
await deleteBrowserProfile(id)
try {
for (const id of ids) {
await deleteBrowserProfile(id)
}
setSelectedIds(prev => {
const next = new Set(prev)
ids.forEach(id => next.delete(id))
return next
})
toast.success(ids.length > 1 ? `已删除 ${ids.length} 个实例` : '配置已删除')
setDeleteConfirm({ open: false, mode: 'single', count: 0 })
loadProfiles()
} catch (error: any) {
toast.error(error?.message || '删除失败')
} finally {
setBatchLoading(false)
}
setBatchLoading(false)
setSelectedIds(new Set())
toast.success(`已删除 ${ids.length} 个实例`)
loadProfiles()
}
const handleCopy = async (profileId: string) => {
@@ -266,6 +370,36 @@ export function BrowserListPage() {
const copyConfirmDisabled =
!copyName.trim() || !isBrowserProfileCopyOptionsValid(copyOptions)
const saveProfileProxy = async (profile: BrowserProfile, proxy: BrowserProxy) => {
try {
const updated = await updateBrowserProfile(profile.profileId, {
profileName: profile.profileName,
userDataDir: profile.userDataDir,
coreId: profile.coreId,
fingerprintArgs: profile.fingerprintArgs,
proxyId: proxy.proxyId,
proxyConfig: '',
launchArgs: profile.launchArgs,
tags: profile.tags,
keywords: profile.keywords || [],
groupId: profile.groupId || '',
})
mergeProfileState(updated || { ...profile, proxyId: proxy.proxyId, proxyConfig: '' })
toast.success('代理已切换')
} catch (error: any) {
toast.error(error?.message || '切换代理失败')
}
}
const handleProxyDeletedFromPicker = (deletedProxyId: string, nextProxies: BrowserProxy[]) => {
updateProxiesState(nextProxies)
if (!proxyPickerProfile || proxyPickerProfile.proxyId !== deletedProxyId) return
const fallbackProxy = nextProxies.find(proxy => proxy.proxyId === directProxyID || proxy.proxyConfig === 'direct://')
if (fallbackProxy) {
void saveProfileProxy(proxyPickerProfile, fallbackProxy)
}
}
return (
<div className="overflow-auto p-5 space-y-5 animate-fade-in h-full">
@@ -284,6 +418,7 @@ export function BrowserListPage() {
onToggleHeaderCollapsed={() => setHeaderCollapsed((prev) => !prev)}
onRefresh={() => { void loadProfiles() }}
onOpenSettings={handleOpenSettings}
onOpenTrash={openTrashModal}
onOpenExpandModal={() => {
setExpandModalOpen(true)
loadQuota()
@@ -299,7 +434,7 @@ export function BrowserListPage() {
onDeselectAll={handleDeselectAll}
onBatchStart={handleBatchStart}
onBatchStop={handleBatchStop}
onBatchDelete={handleBatchDelete}
onBatchDelete={openBatchDeleteConfirm}
batchLoading={batchLoading}
/>
@@ -325,7 +460,22 @@ export function BrowserListPage() {
onOpenKeywords={openKwModal}
onOpenExtensions={openExtensionModal}
onOpenCopy={openCopyModal}
onDelete={(profileId) => { void handleDelete(profileId) }}
onOpenProxyPicker={setProxyPickerProfile}
onDelete={openDeleteConfirm}
/>
<ProxyPickerModal
open={!!proxyPickerProfile}
currentProxyId={proxyPickerProfile?.proxyId || directProxyID}
title={proxyPickerProfile ? `切换代理:${proxyPickerProfile.profileName}` : '切换代理'}
onSelect={(proxy) => {
if (proxyPickerProfile) {
void saveProfileProxy(proxyPickerProfile, proxy)
}
}}
onProxyListUpdated={updateProxiesState}
onProxyDeleted={handleProxyDeletedFromPicker}
onClose={() => setProxyPickerProfile(null)}
/>
<ProfileExtensionModal
@@ -406,6 +556,21 @@ export function BrowserListPage() {
onConfirmCopy={() => copyModal.profile && handleCopy(copyModal.profile.profileId)}
copyConfirmDisabled={copyConfirmDisabled}
copying={copying}
deleteConfirm={deleteConfirm}
deleting={batchLoading}
onCloseDeleteConfirm={closeDeleteConfirm}
onConfirmDelete={() => { void handleConfirmDelete() }}
trashModalOpen={trashModalOpen}
trashProfiles={trashProfiles}
trashLoading={trashLoading}
restoringId={restoringId}
permanentlyDeletingId={permanentlyDeletingId}
permanentDeleteConfirm={permanentDeleteConfirm}
onCloseTrash={() => setTrashModalOpen(false)}
onRestoreProfile={(profileId) => { void handleRestoreProfile(profileId) }}
onOpenPermanentDelete={(profile) => setPermanentDeleteConfirm({ open: true, profile })}
onClosePermanentDelete={() => setPermanentDeleteConfirm({ open: false, profile: null })}
onConfirmPermanentDelete={() => { void handleConfirmPermanentDelete() }}
opError={opError}
onCloseOpError={() => setOpError('')}
/>
@@ -38,8 +38,6 @@ export function ProxyPoolPage() {
const [displayList, setDisplayList] = useState<ProxyDisplayInfo[]>([])
const [loading, setLoading] = useState(true)
const {
browserSettings,
connectorSwitching,
coreDownloadOpen,
coreDownloadType,
setCoreDownloadType,
@@ -54,7 +52,6 @@ export function ProxyPoolPage() {
downloadCoreStatus,
downloadCoreStatusLoading,
loadBrowserSettings,
handleSwitchConnector,
handleStartCoreDownload,
openCoreDownload,
closeCoreDownload,
@@ -95,6 +92,7 @@ export function ProxyPoolPage() {
const [editForm, setEditForm] = useState<ProxyEditFormValue>({
proxyName: '',
proxyConfig: '',
preferredKernel: 'auto',
dnsServers: '',
groupName: '',
})
@@ -108,11 +106,11 @@ export function ProxyPoolPage() {
}, [])
const {
importModalOpen, setImportModalOpen, importMode, importUrl, importResolvedUrl, importText,
importModalOpen, setImportModalOpen, importMode, importUrl, importFetchProxyId, importResolvedUrl, importText,
importDnsServers, importNamePrefix, importGroupName, chainImportText, directImportText,
chainImportForm, directImportForm, previewModalOpen, setPreviewModalOpen, previewList, removedPreviewProxyNames,
importing, fetchingImportUrl, canParseImport, setImportText, setImportDnsServers,
setImportNamePrefix, setImportGroupName, setChainImportText, setDirectImportText,
setImportNamePrefix, setImportGroupName, setImportFetchProxyId, setChainImportText, setDirectImportText,
setChainImportForm, setDirectImportForm, handleRemovePreviewProxy, updateChainImportHop,
handleImportModeChange, handleFillChainTemplate, handleFillDirectTemplate, handleCopyChainTemplate,
handleCopyDirectTemplate, handleApplyChainJSON, handleApplyDirectText, handleImportUrlChange,
@@ -139,6 +137,8 @@ export function ProxyPoolPage() {
const {
latencyMap,
latencyEngineMap,
latencyErrorMap,
testingAll,
ipHealthMap,
checkingIPHealthIds,
@@ -149,6 +149,7 @@ export function ProxyPoolPage() {
setIPHealthDetailOpen,
currentIPHealthDetail,
setLatencyMap,
setLatencyEngineMap,
setIPHealthMap,
handleTestOne,
handleTestAll,
@@ -180,6 +181,15 @@ export function ProxyPoolPage() {
return next
})
setLatencyEngineMap(prev => {
const validIds = new Set(finalList.map(p => p.proxyId))
const next: Record<string, string> = {}
Object.entries(prev).forEach(([proxyId, engine]) => {
if (validIds.has(proxyId)) next[proxyId] = engine
})
return next
})
setIPHealthMap(prev => {
const validIds = new Set(finalList.map(p => p.proxyId))
const next: Record<string, ProxyIPHealthResult> = {}
@@ -193,7 +203,7 @@ export function ProxyPoolPage() {
} finally {
setLoading(false)
}
}, [setIPHealthMap, setLatencyMap])
}, [setIPHealthMap, setLatencyEngineMap, setLatencyMap])
useEffect(() => {
void loadProxies()
@@ -239,7 +249,13 @@ export function ProxyPoolPage() {
const proxy = proxies.find(p => p.proxyId === record.proxyId)
if (proxy) {
setEditingProxy(proxy)
setEditForm({ proxyName: proxy.proxyName, proxyConfig: proxy.proxyConfig, dnsServers: proxy.dnsServers || '', groupName: proxy.groupName || '' })
setEditForm({
proxyName: proxy.proxyName,
proxyConfig: proxy.proxyConfig,
preferredKernel: proxy.preferredKernel || 'auto',
dnsServers: proxy.dnsServers || '',
groupName: proxy.groupName || '',
})
const nextChainForm = toChainImportForm(proxy.proxyName, proxy.proxyConfig)
if (nextChainForm) {
setChainEditMode(true)
@@ -279,6 +295,7 @@ export function ProxyPoolPage() {
...p,
proxyName: nextProxyName,
proxyConfig: nextProxyConfig,
preferredKernel: editForm.preferredKernel === 'auto' ? undefined : editForm.preferredKernel,
dnsServers: editForm.dnsServers.trim() || undefined,
groupName: editForm.groupName.trim() || undefined,
}
@@ -303,13 +320,10 @@ export function ProxyPoolPage() {
<div className="space-y-5 animate-fade-in">
<ProxyPoolHeader
checkingAllIPHealth={checkingAllIPHealth}
connectorSwitching={connectorSwitching}
currentConnectorStatus={currentCoreStatus?.message || '未知'}
currentConnectorType={browserSettings?.defaultConnectorType || 'xray'}
hasURLImportSources={hasURLImportSources}
onCheckAllIPHealth={() => void handleCheckAllIPHealth(filteredList)}
onOpenSettings={() => void openCheckSettings()}
onSwitchConnector={() => void handleSwitchConnector()}
onOpenImport={() => setImportModalOpen(true)}
onOpenCoreDownload={openCoreDownload}
onRefreshAllSources={() => void handleRefreshAllSources(false)}
@@ -333,6 +347,8 @@ export function ProxyPoolPage() {
groups={groups}
ipHealthMap={ipHealthMap}
latencyMap={latencyMap}
latencyEngineMap={latencyEngineMap}
latencyErrorMap={latencyErrorMap}
loading={loading}
onCheckOneIPHealth={(record) => void handleCheckOneIPHealth(record)}
onClearFilters={() => {
@@ -377,6 +393,7 @@ export function ProxyPoolPage() {
groups={groups}
importMode={importMode}
importUrl={importUrl}
importFetchProxyId={importFetchProxyId}
importResolvedUrl={importResolvedUrl}
importText={importText}
importDnsServers={importDnsServers}
@@ -387,12 +404,14 @@ export function ProxyPoolPage() {
chainImportForm={chainImportForm}
directImportForm={directImportForm}
fetchingImportUrl={fetchingImportUrl}
fetchProxyOptions={proxies.filter(proxy => proxy.proxyConfig.trim() && !proxy.proxyConfig.trim().toLowerCase().startsWith('direct://'))}
canParseImport={canParseImport}
onClose={() => setImportModalOpen(false)}
onParse={handleParseImport}
onFetchImportUrl={handleFetchImportURL}
onImportModeChange={handleImportModeChange}
onImportUrlChange={handleImportUrlChange}
onImportFetchProxyIdChange={setImportFetchProxyId}
onImportTextChange={setImportText}
onImportDnsServersChange={setImportDnsServers}
onImportNamePrefixChange={setImportNamePrefix}
@@ -30,6 +30,21 @@ interface BrowserListDialogsProps {
onConfirmCopy: () => void
copyConfirmDisabled: boolean
copying: boolean
deleteConfirm: { open: boolean; mode: 'single' | 'batch'; profileName?: string; count: number }
deleting: boolean
onCloseDeleteConfirm: () => void
onConfirmDelete: () => void
trashModalOpen: boolean
trashProfiles: BrowserProfile[]
trashLoading: boolean
restoringId: string
permanentlyDeletingId: string
permanentDeleteConfirm: { open: boolean; profile: BrowserProfile | null }
onCloseTrash: () => void
onRestoreProfile: (profileId: string) => void
onOpenPermanentDelete: (profile: BrowserProfile) => void
onClosePermanentDelete: () => void
onConfirmPermanentDelete: () => void
opError: string
onCloseOpError: () => void
}
@@ -59,9 +74,38 @@ export function BrowserListDialogs({
onConfirmCopy,
copyConfirmDisabled,
copying,
deleteConfirm,
deleting,
onCloseDeleteConfirm,
onConfirmDelete,
trashModalOpen,
trashProfiles,
trashLoading,
restoringId,
permanentlyDeletingId,
permanentDeleteConfirm,
onCloseTrash,
onRestoreProfile,
onOpenPermanentDelete,
onClosePermanentDelete,
onConfirmPermanentDelete,
opError,
onCloseOpError,
}: BrowserListDialogsProps) {
const formatTime = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN')
}
const formatExpireTime = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
date.setDate(date.getDate() + 3)
return date.toLocaleString('zh-CN')
}
return (
<>
<Modal
@@ -166,6 +210,101 @@ export function BrowserListDialogs({
/>
</Modal>
<Modal
open={deleteConfirm.open}
onClose={onCloseDeleteConfirm}
title="删除实例"
width="400px"
footer={
<>
<Button variant="secondary" onClick={onCloseDeleteConfirm} disabled={deleting}></Button>
<Button variant="danger" onClick={onConfirmDelete} loading={deleting}></Button>
</>
}
>
<div className="text-sm text-[var(--color-text-secondary)]">
{deleteConfirm.mode === 'batch'
? `确定将选中的 ${deleteConfirm.count} 个实例移入回收站?3 天内可恢复。`
: `确定将实例「${deleteConfirm.profileName || '未命名实例'}」移入回收站?3 天内可恢复。`}
</div>
</Modal>
<Modal
open={trashModalOpen}
onClose={onCloseTrash}
title="实例回收站"
width="720px"
footer={<Button variant="secondary" onClick={onCloseTrash}></Button>}
>
{trashLoading ? (
<div className="py-10 text-center text-sm text-[var(--color-text-muted)]">...</div>
) : trashProfiles.length === 0 ? (
<div className="py-10 text-center text-sm text-[var(--color-text-muted)]"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--color-border-default)] text-left text-xs text-[var(--color-text-muted)]">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{trashProfiles.map((profile) => (
<tr key={profile.profileId} className="border-b border-[var(--color-border-muted)] last:border-0">
<td className="py-3 pr-3 text-[var(--color-text-primary)]">{profile.profileName || '未命名实例'}</td>
<td className="py-3 pr-3 text-[var(--color-text-secondary)]">{formatTime(profile.deletedAt)}</td>
<td className="py-3 pr-3 text-[var(--color-text-secondary)]">{formatExpireTime(profile.deletedAt)}</td>
<td className="py-3 text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => onRestoreProfile(profile.profileId)}
loading={restoringId === profile.profileId}
disabled={!!permanentlyDeletingId}
>
</Button>
<Button
size="sm"
variant="danger"
onClick={() => onOpenPermanentDelete(profile)}
loading={permanentlyDeletingId === profile.profileId}
disabled={!!restoringId}
>
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Modal>
<Modal
open={permanentDeleteConfirm.open}
onClose={onClosePermanentDelete}
title="彻底删除实例"
width="420px"
footer={
<>
<Button variant="secondary" onClick={onClosePermanentDelete} disabled={!!permanentlyDeletingId}></Button>
<Button variant="danger" onClick={onConfirmPermanentDelete} loading={!!permanentlyDeletingId}></Button>
</>
}
>
<div className="space-y-2 text-sm text-[var(--color-text-secondary)]">
<p>{permanentDeleteConfirm.profile?.profileName || '未命名实例'}</p>
<p className="text-red-500"></p>
</div>
</Modal>
<Modal
open={!!opError}
onClose={onCloseOpError}
@@ -96,6 +96,10 @@ export function useBrowserListData({ loadQuota, loadCores }: UseBrowserListDataO
setGroups(await fetchGroups())
}
const updateProxiesState = (items: BrowserProxy[]) => {
setProxies(items)
}
useEffect(() => {
void loadProfiles()
loadGroups()
@@ -153,6 +157,7 @@ export function useBrowserListData({ loadQuota, loadCores }: UseBrowserListDataO
updatePendingIds,
updateProfilesState,
mergeProfileState,
updateProxiesState,
loadProfiles,
}
}
@@ -108,10 +108,11 @@ export function useBrowserListDerived(
)
const filteredProfiles = useMemo(() => {
const unifiedKeyword = filters.keyword || filters.kwSearch
return profiles.filter(profile => {
if (filters.groupId === '__ungrouped__' && profile.groupId) return false
if (filters.groupId && filters.groupId !== '__ungrouped__' && profile.groupId !== filters.groupId) return false
if (filters.keyword && !profile.profileName.toLowerCase().includes(filters.keyword.toLowerCase())) return false
if (unifiedKeyword && !matchesProfileKeyword(profile, unifiedKeyword)) return false
if (filters.status === 'running' && !profile.running) return false
if (filters.status === 'stopped' && profile.running) return false
if (filters.proxyId === '__none__' && (profile.proxyId || profile.proxyConfig)) return false
@@ -121,11 +122,6 @@ export function useBrowserListDerived(
if (!effectiveCore || effectiveCore.coreId !== filters.coreId) return false
}
if (filters.tags.size > 0 && !profile.tags?.some(tag => filters.tags.has(tag))) return false
if (filters.kwSearch) {
const query = filters.kwSearch.toLowerCase()
const hit = profile.keywords?.some(value => value.toLowerCase().includes(query))
if (!hit) return false
}
return true
}).sort((a, b) => naturalCompare(a.profileName, b.profileName))
}, [profiles, filters, defaultCore, cores])
@@ -143,6 +139,39 @@ export function useBrowserListDerived(
}
}
function normalizeSearchText(value: string): string {
return value.trim().toLowerCase()
}
function normalizeCompactSearchText(value: string): string {
return normalizeSearchText(value).replace(/[\s_-]+/g, '')
}
function matchesProfileKeyword(profile: BrowserProfile, keyword: string): boolean {
const query = normalizeSearchText(keyword)
if (!query) return true
const compactQuery = normalizeCompactSearchText(query)
const values = [
profile.profileName,
profile.launchCode,
profile.profileId,
profile.userDataDir,
profile.proxyId,
profile.proxyBindName,
profile.proxyBindSourceUrl,
profile.groupId,
...(profile.tags || []),
...(profile.keywords || []),
]
return values.some(value => {
const text = normalizeSearchText(String(value || ''))
if (!text) return false
return text.includes(query) || normalizeCompactSearchText(text).includes(compactQuery)
})
}
function naturalCompare(a: string, b: string): number {
const re = /(\d+)|(\D+)/g
const partsA = a.match(re) || []
@@ -7,6 +7,8 @@ interface CoreSettingsCardProps {
onEdit: () => void
}
const settingsValueClass = 'h-14 overflow-auto rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]'
export function CoreSettingsCard({ settings, onEdit }: CoreSettingsCardProps) {
return (
<Card>
@@ -38,7 +40,7 @@ function SettingsValue({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1">{label}</p>
<div className="min-h-9 rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]">
<div className={`${settingsValueClass} break-all`}>
{value}
</div>
</div>
@@ -50,11 +52,11 @@ function SettingsList({ label, values }: { label: string; values: string[] }) {
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1">{label}</p>
{values.length > 0 ? (
<pre className="min-h-9 max-h-20 overflow-auto rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]">
<pre className={settingsValueClass}>
{values.join('\n')}
</pre>
) : (
<div className="min-h-9 rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]">
<div className={settingsValueClass}>
-
</div>
)}
@@ -2,15 +2,12 @@ import { Button } from '../../../../shared/components'
interface ProxyPoolHeaderProps {
checkingAllIPHealth: boolean
connectorSwitching: boolean
currentConnectorStatus: string
currentConnectorType: string
hasURLImportSources: boolean
onCheckAllIPHealth: () => void
onOpenImport: () => void
onOpenCoreDownload: () => void
onOpenSettings: () => void
onSwitchConnector: () => void
onRefreshAllSources: () => void
onTestAll: () => void
refreshingAllSources: boolean
@@ -20,45 +17,28 @@ interface ProxyPoolHeaderProps {
export function ProxyPoolHeader({
checkingAllIPHealth,
connectorSwitching,
currentConnectorStatus,
currentConnectorType,
hasURLImportSources,
onCheckAllIPHealth,
onOpenImport,
onOpenCoreDownload,
onOpenSettings,
onSwitchConnector,
onRefreshAllSources,
onTestAll,
refreshingAllSources,
testingAll,
totalCount,
}: ProxyPoolHeaderProps) {
const normalizedConnector = currentConnectorType === 'mihomo' ? 'mihomo' : 'xray'
const connectorLabel = normalizedConnector === 'mihomo' ? 'Mihomo' : 'Xray'
const nextConnectorLabel = normalizedConnector === 'mihomo' ? 'Xray' : 'Mihomo'
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> Clash HTTPHTTPSSOCKS5</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-primary)] px-2 py-1 shadow-sm">
<span className="inline-flex items-center gap-1 whitespace-nowrap px-2 text-xs text-[var(--color-text-muted)]">
{connectorLabel}
{normalizedConnector === 'mihomo' && (
<span className="rounded-full border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700">
</span>
)}
<span>· {currentConnectorStatus || '未知'}</span>
{currentConnectorStatus || '未知'}
</span>
<Button size="sm" variant="secondary" onClick={onSwitchConnector} loading={connectorSwitching}>
{nextConnectorLabel}
</Button>
<Button size="sm" variant="secondary" onClick={onOpenCoreDownload}></Button>
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-primary)] px-2 py-1 shadow-sm">
@@ -1,4 +1,5 @@
import { Button, FormItem, Input, Modal, Select, Textarea } from '../../../../shared/components'
import type { BrowserProxy } from '../../types'
import {
CHAIN_QUICK_IMPORT_TEMPLATE,
DIRECT_QUICK_IMPORT_TEMPLATE,
@@ -13,6 +14,7 @@ interface ProxyPoolImportModalProps {
groups: string[]
importMode: ProxyImportMode
importUrl: string
importFetchProxyId: string
importResolvedUrl: string
importText: string
importDnsServers: string
@@ -23,12 +25,14 @@ interface ProxyPoolImportModalProps {
chainImportForm: ChainImportForm
directImportForm: DirectImportForm
fetchingImportUrl: boolean
fetchProxyOptions: BrowserProxy[]
canParseImport: boolean
onClose: () => void
onParse: () => void
onFetchImportUrl: () => void
onImportModeChange: (nextMode: ProxyImportMode) => void
onImportUrlChange: (nextValue: string) => void
onImportFetchProxyIdChange: (nextValue: string) => void
onImportTextChange: (nextValue: string) => void
onImportDnsServersChange: (nextValue: string) => void
onImportNamePrefixChange: (nextValue: string) => void
@@ -51,6 +55,7 @@ export function ProxyPoolImportModal({
groups,
importMode,
importUrl,
importFetchProxyId,
importResolvedUrl,
importText,
importDnsServers,
@@ -61,12 +66,14 @@ export function ProxyPoolImportModal({
chainImportForm,
directImportForm,
fetchingImportUrl,
fetchProxyOptions,
canParseImport,
onClose,
onParse,
onFetchImportUrl,
onImportModeChange,
onImportUrlChange,
onImportFetchProxyIdChange,
onImportTextChange,
onImportDnsServersChange,
onImportNamePrefixChange,
@@ -121,23 +128,28 @@ export function ProxyPoolImportModal({
</Button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,使用 JSON 导入,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
<>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<div className="grid grid-cols-[minmax(0,1fr)_150px_auto] gap-2">
<Input
value={importUrl}
onChange={(event) => onImportUrlChange(event.target.value)}
placeholder="订阅 URL"
className="flex-1"
/>
<Select
value={importFetchProxyId}
onChange={(event) => onImportFetchProxyIdChange(event.target.value)}
disabled={fetchingImportUrl}
options={[
{ value: '', label: '直连拉取' },
...fetchProxyOptions.map(proxy => ({
value: proxy.proxyId,
label: proxy.proxyName || proxy.proxyId,
})),
]}
/>
<Button
variant="secondary"
onClick={onFetchImportUrl}
@@ -152,9 +164,6 @@ export function ProxyPoolImportModal({
{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1">
YAML DNS
</p>
</FormItem>
<Textarea
value={importText}
@@ -11,6 +11,7 @@ import {
export interface ProxyEditFormValue {
proxyName: string
proxyConfig: string
preferredKernel: string
dnsServers: string
groupName: string
}
@@ -162,6 +163,18 @@ export function ProxyPoolEditModal({
))}
</datalist>
</FormItem>
<FormItem label="代理内核">
<Select
value={editForm.preferredKernel || 'auto'}
onChange={(event) => onChange({ preferredKernel: event.target.value })}
options={[
{ value: 'auto', label: '自动' },
{ value: 'xray', label: 'Xray' },
{ value: 'sing-box', label: 'sing-box' },
{ value: 'mihomo', label: 'Mihomo' },
]}
/>
</FormItem>
{chainEditMode ? (
<div className="space-y-4">
<FormItem label="本地监听端口(可选)">
@@ -278,9 +291,6 @@ export function ProxyPoolEditModal({
rows={6}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`}
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Clash dns: YAML Clash / HTTP/SOCKS5 使 DNS
</p>
</FormItem>
</div>
</Modal>

Some files were not shown because too many files have changed in this diff Show More