mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
chore commit remaining workspace changes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("浏览器窗口已启动,但调试接口在等待窗口内未就绪,转入后台附着",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type SingBoxBridge struct {
|
||||
Stopping bool
|
||||
LastError string
|
||||
Outbound map[string]interface{}
|
||||
RefCount int
|
||||
LastUsedAt time.Time
|
||||
Restarting bool
|
||||
RestartCount int
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ type TestResult struct {
|
||||
ProxyId string
|
||||
Ok bool
|
||||
LatencyMs int64
|
||||
Engine string
|
||||
Error string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
7b7cd01deb4f6d205b686dee62014883
|
||||
80f733bd69eeb89b54b5de1ecc207d1a
|
||||
@@ -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,
|
||||
|
||||
@@ -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 订阅、HTTP、HTTPS、SOCKS5</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
Reference in New Issue
Block a user