feat: improve proxy bridge warmup stability

This commit is contained in:
ant-black
2026-06-10 20:11:46 +08:00
parent ceb97de816
commit 2aca34e82d
39 changed files with 1932 additions and 203 deletions
+193
View File
@@ -0,0 +1,193 @@
package backend
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type ProxyLocationResolveResult struct {
ProxyId string `json:"proxyId"`
Ok bool `json:"ok"`
Auto bool `json:"auto"`
Source string `json:"source"`
Error string `json:"error"`
IP string `json:"ip"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
Timezone string `json:"timezone"`
Lang string `json:"lang"`
Health *ProxyIPHealthResult `json:"health,omitempty"`
Alternates []ProxyLocationOption `json:"alternates,omitempty"`
ResolvedAt string `json:"resolvedAt"`
}
type ProxyLocationOption struct {
Label string `json:"label"`
Timezone string `json:"timezone"`
Lang string `json:"lang"`
}
var countryLocaleDefaults = map[string]ProxyLocationOption{
"CN": {Label: "中国", Timezone: "Asia/Shanghai", Lang: "zh-CN"},
"HK": {Label: "中国香港", Timezone: "Asia/Hong_Kong", Lang: "zh-HK"},
"TW": {Label: "中国台湾", Timezone: "Asia/Taipei", Lang: "zh-TW"},
"US": {Label: "美国", Timezone: "America/New_York", Lang: "en-US"},
"GB": {Label: "英国", Timezone: "Europe/London", Lang: "en-GB"},
"JP": {Label: "日本", Timezone: "Asia/Tokyo", Lang: "ja-JP"},
"KR": {Label: "韩国", Timezone: "Asia/Seoul", Lang: "ko-KR"},
"SG": {Label: "新加坡", Timezone: "Asia/Singapore", Lang: "en-SG"},
"DE": {Label: "德国", Timezone: "Europe/Berlin", Lang: "de-DE"},
"FR": {Label: "法国", Timezone: "Europe/Paris", Lang: "fr-FR"},
"NL": {Label: "荷兰", Timezone: "Europe/Amsterdam", Lang: "nl-NL"},
"CA": {Label: "加拿大", Timezone: "America/Toronto", Lang: "en-CA"},
"AU": {Label: "澳大利亚", Timezone: "Australia/Sydney", Lang: "en-AU"},
"RU": {Label: "俄罗斯", Timezone: "Europe/Moscow", Lang: "ru-RU"},
"BR": {Label: "巴西", Timezone: "America/Sao_Paulo", Lang: "pt-BR"},
"IN": {Label: "印度", Timezone: "Asia/Kolkata", Lang: "en-IN"},
}
var cityTimezoneDefaults = map[string]string{
"US|new york": "America/New_York",
"US|los angeles": "America/Los_Angeles",
"US|san francisco": "America/Los_Angeles",
"US|chicago": "America/Chicago",
"US|denver": "America/Denver",
"US|phoenix": "America/Phoenix",
"CA|toronto": "America/Toronto",
"CA|vancouver": "America/Vancouver",
"AU|sydney": "Australia/Sydney",
"AU|melbourne": "Australia/Melbourne",
"AU|perth": "Australia/Perth",
}
func (a *App) BrowserProxyResolveLocation(proxyId string) ProxyLocationResolveResult {
proxyId = strings.TrimSpace(proxyId)
resolvedAt := time.Now().Format(time.RFC3339)
if proxyId == "" || strings.EqualFold(proxyId, "__direct__") {
return ProxyLocationResolveResult{ProxyId: proxyId, Ok: false, Auto: false, Source: "manual", Error: "直连或未选择代理,请手动选择定位", ResolvedAt: resolvedAt}
}
if cached, ok := a.cachedProxyIPHealthResult(proxyId); ok && cached.Ok {
return buildProxyLocationResolveResult(proxyId, cached, "cache", resolvedAt)
}
health := a.BrowserProxyCheckIPHealth(proxyId)
if !health.Ok {
return ProxyLocationResolveResult{ProxyId: proxyId, Ok: false, Auto: false, Source: health.Source, Error: health.Error, Health: &health, ResolvedAt: resolvedAt}
}
return buildProxyLocationResolveResult(proxyId, health, "ip_health", resolvedAt)
}
func (a *App) cachedProxyIPHealthResult(proxyId string) (ProxyIPHealthResult, bool) {
if a == nil || a.browserMgr == nil || a.browserMgr.ProxyDAO == nil {
return ProxyIPHealthResult{}, false
}
proxies, err := a.browserMgr.ProxyDAO.List()
if err != nil {
return ProxyIPHealthResult{}, false
}
for _, item := range proxies {
if !strings.EqualFold(strings.TrimSpace(item.ProxyId), proxyId) || strings.TrimSpace(item.LastIPHealthJSON) == "" {
continue
}
var result ProxyIPHealthResult
if err := json.Unmarshal([]byte(item.LastIPHealthJSON), &result); err == nil && result.ProxyId != "" {
return result, true
}
}
return ProxyIPHealthResult{}, false
}
func buildProxyLocationResolveResult(proxyId string, health ProxyIPHealthResult, source string, resolvedAt string) ProxyLocationResolveResult {
option := resolveProxyLocationOption(health.Country, health.City)
ok := health.Ok && option.Timezone != "" && option.Lang != ""
result := ProxyLocationResolveResult{
ProxyId: proxyId,
Ok: ok,
Auto: ok,
Source: source,
IP: health.IP,
Country: health.Country,
Region: health.Region,
City: health.City,
Timezone: option.Timezone,
Lang: option.Lang,
Health: &health,
ResolvedAt: resolvedAt,
}
if !ok {
result.Error = fmt.Sprintf("无法根据地区自动匹配定位:%s %s", strings.TrimSpace(health.Country), strings.TrimSpace(health.City))
result.Alternates = defaultProxyLocationOptions()
}
return result
}
func resolveProxyLocationOption(country string, city string) ProxyLocationOption {
countryCode := normalizeCountryCode(country)
option := countryLocaleDefaults[countryCode]
if option.Timezone == "" {
return ProxyLocationOption{}
}
cityKey := countryCode + "|" + strings.ToLower(strings.TrimSpace(city))
if timezone := cityTimezoneDefaults[cityKey]; timezone != "" {
option.Timezone = timezone
}
return option
}
func normalizeCountryCode(country string) string {
value := strings.TrimSpace(country)
upper := strings.ToUpper(value)
if len(upper) == 2 {
return upper
}
switch strings.ToLower(value) {
case "china", "中国", "mainland china":
return "CN"
case "hong kong", "香港":
return "HK"
case "taiwan", "台湾":
return "TW"
case "united states", "usa", "us", "美国":
return "US"
case "united kingdom", "uk", "great britain", "英国":
return "GB"
case "japan", "日本":
return "JP"
case "south korea", "korea", "韩国":
return "KR"
case "singapore", "新加坡":
return "SG"
case "germany", "德国":
return "DE"
case "france", "法国":
return "FR"
case "netherlands", "荷兰":
return "NL"
case "canada", "加拿大":
return "CA"
case "australia", "澳大利亚":
return "AU"
case "russia", "俄罗斯":
return "RU"
case "brazil", "巴西":
return "BR"
case "india", "印度":
return "IN"
default:
return upper
}
}
func defaultProxyLocationOptions() []ProxyLocationOption {
return []ProxyLocationOption{
countryLocaleDefaults["US"],
countryLocaleDefaults["GB"],
countryLocaleDefaults["JP"],
countryLocaleDefaults["SG"],
countryLocaleDefaults["CN"],
}
}
+38
View File
@@ -0,0 +1,38 @@
package backend
import "testing"
func TestResolveProxyLocationOptionUsesCityTimezone(t *testing.T) {
option := resolveProxyLocationOption("US", "Los Angeles")
if option.Timezone != "America/Los_Angeles" {
t.Fatalf("timezone = %q, want America/Los_Angeles", option.Timezone)
}
if option.Lang != "en-US" {
t.Fatalf("lang = %q, want en-US", option.Lang)
}
}
func TestResolveProxyLocationOptionNormalizesCountryName(t *testing.T) {
option := resolveProxyLocationOption("Japan", "Tokyo")
if option.Timezone != "Asia/Tokyo" {
t.Fatalf("timezone = %q, want Asia/Tokyo", option.Timezone)
}
if option.Lang != "ja-JP" {
t.Fatalf("lang = %q, want ja-JP", option.Lang)
}
}
func TestBuildProxyLocationResolveResultUnknownCountryFallsBackToManual(t *testing.T) {
result := buildProxyLocationResolveResult("proxy-1", ProxyIPHealthResult{
ProxyId: "proxy-1",
Ok: true,
Country: "Unknownland",
City: "Nowhere",
}, "cache", "2026-06-09T00:00:00Z")
if result.Ok {
t.Fatalf("expected unknown country to fail automatic resolution")
}
if len(result.Alternates) == 0 {
t.Fatalf("expected manual alternates")
}
}
+115
View File
@@ -3,6 +3,9 @@ package backend
import (
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/proxy"
"strings"
"sync"
"time"
)
func (a *App) BrowserProxyList() []BrowserProxy {
@@ -44,6 +47,118 @@ func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult {
return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error}
}
// BrowserProxyWarmupBridge 只预热本地代理桥接,不执行外网测速。
func (a *App) BrowserProxyWarmupBridge(proxyId string) ProxyBridgeWarmupResult {
proxies := a.getLatestProxies()
return a.warmupProxyBridge(proxyId, "", proxies)
}
// BrowserProxyWarmupBridgeWithConfig 预热指定代理配置,proxyConfig 仅本次预热生效。
func (a *App) BrowserProxyWarmupBridgeWithConfig(proxyId string, proxyConfig string) ProxyBridgeWarmupResult {
proxies := a.getLatestProxies()
return a.warmupProxyBridge(proxyId, proxyConfig, proxies)
}
// BrowserProxyBatchWarmupBridge 批量预热代理桥接,concurrency 控制并发数(默认 5)。
func (a *App) BrowserProxyBatchWarmupBridge(proxyIds []string, concurrency int) []ProxyBridgeWarmupResult {
if len(proxyIds) == 0 {
return []ProxyBridgeWarmupResult{}
}
if concurrency <= 0 {
concurrency = 5
}
if concurrency > len(proxyIds) {
concurrency = len(proxyIds)
}
proxies := a.getLatestProxies()
results := make([]ProxyBridgeWarmupResult, len(proxyIds))
type warmupJob struct {
idx int
proxyId string
}
jobs := make(chan warmupJob, len(proxyIds))
var wg sync.WaitGroup
for worker := 0; worker < concurrency; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results[job.idx] = a.warmupProxyBridge(job.proxyId, "", proxies)
}
}()
}
for i, proxyID := range proxyIds {
jobs <- warmupJob{idx: i, proxyId: proxyID}
}
close(jobs)
wg.Wait()
return results
}
func (a *App) warmupProxyBridge(proxyId string, proxyConfig string, proxies []BrowserProxy) ProxyBridgeWarmupResult {
startedAt := time.Now()
proxyId = strings.TrimSpace(proxyId)
result := ProxyBridgeWarmupResult{ProxyId: proxyId}
src := strings.TrimSpace(resolveProxyConfigForApp(proxyConfig, proxies, proxyId))
if src == "" {
result.Error = "代理配置为空"
return result
}
if strings.EqualFold(src, "direct://") {
result.Ok = true
result.Engine = "direct"
result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
if !proxy.RequiresBridge(src, proxies, proxyId) && !proxy.RequiresLocalProxyBridgeForBrowser(src) && !proxy.IsSingBoxProtocol(src) {
result.Ok = true
result.Engine = "none"
result.LatencyMs = time.Since(startedAt).Milliseconds()
return result
}
var socksURL string
var err error
if proxy.IsSingBoxProtocol(src) {
result.Engine = "sing-box"
if a.singboxMgr == nil {
result.Error = "sing-box 管理器不可用"
return result
}
socksURL, err = a.singboxMgr.EnsureBridge(src, proxies, proxyId)
} else {
result.Engine = "xray"
if a.xrayMgr == nil {
result.Error = "xray 管理器不可用"
return result
}
socksURL, err = a.xrayMgr.EnsureBridge(src, proxies, proxyId)
}
result.LatencyMs = time.Since(startedAt).Milliseconds()
if err != nil {
result.Error = err.Error()
return result
}
result.Ok = true
result.SocksURL = socksURL
return result
}
func resolveProxyConfigForApp(proxyConfig string, proxies []BrowserProxy, proxyId string) string {
proxyConfig = strings.TrimSpace(proxyConfig)
proxyId = strings.TrimSpace(proxyId)
if proxyId == "" {
return proxyConfig
}
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
return strings.TrimSpace(item.ProxyConfig)
}
}
return proxyConfig
}
// getLatestProxies 获取最新的代理列表,优先从数据库读取
func (a *App) getLatestProxies() []BrowserProxy {
return browser.LatestProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
+10
View File
@@ -14,6 +14,16 @@ type ProxyTestResult struct {
Error string `json:"error"`
}
// ProxyBridgeWarmupResult 代理桥接预热结果。
type ProxyBridgeWarmupResult struct {
ProxyId string `json:"proxyId"`
Ok bool `json:"ok"`
Engine string `json:"engine"`
SocksURL string `json:"socksUrl"`
LatencyMs int64 `json:"latencyMs"`
Error string `json:"error"`
}
// ProxyIPHealthResult 代理出口 IP 健康信息(透传第三方接口结果)
type ProxyIPHealthResult struct {
ProxyId string `json:"proxyId"`
+60
View File
@@ -0,0 +1,60 @@
package backend
import (
"ant-chrome/backend/internal/config"
"testing"
)
func TestWarmupProxyBridgeDirectProxy(t *testing.T) {
t.Parallel()
app := &App{}
result := app.warmupProxyBridge("direct", "", []BrowserProxy{{ProxyId: "direct", ProxyConfig: "direct://"}})
if !result.Ok {
t.Fatalf("direct warmup failed: %s", result.Error)
}
if result.Engine != "direct" {
t.Fatalf("engine = %q, want direct", result.Engine)
}
if result.SocksURL != "" {
t.Fatalf("direct warmup socks url = %q, want empty", result.SocksURL)
}
}
func TestWarmupProxyBridgeStandardProxyDoesNotRequireBridge(t *testing.T) {
t.Parallel()
app := &App{}
result := app.warmupProxyBridge("http", "", []BrowserProxy{{ProxyId: "http", ProxyConfig: "http://127.0.0.1:8080"}})
if !result.Ok {
t.Fatalf("standard proxy warmup failed: %s", result.Error)
}
if result.Engine != "none" {
t.Fatalf("engine = %q, want none", result.Engine)
}
}
func TestWarmupProxyBridgeMissingProxyConfig(t *testing.T) {
t.Parallel()
app := &App{}
result := app.warmupProxyBridge("missing", "", []config.BrowserProxy{{ProxyId: "other", ProxyConfig: "direct://"}})
if result.Ok {
t.Fatalf("missing proxy config unexpectedly succeeded")
}
if result.Error == "" {
t.Fatalf("missing proxy config should return an error")
}
}
func TestResolveProxyConfigForApp(t *testing.T) {
t.Parallel()
proxies := []BrowserProxy{{ProxyId: "p1", ProxyConfig: "direct://"}}
if got := resolveProxyConfigForApp("", proxies, "p1"); got != "direct://" {
t.Fatalf("resolveProxyConfigForApp() = %q", got)
}
if got := resolveProxyConfigForApp("http://127.0.0.1:8080", proxies, "missing"); got != "http://127.0.0.1:8080" {
t.Fatalf("fallback config = %q", got)
}
}
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"path/filepath"
@@ -58,17 +59,60 @@ func resolveEnvPath(path string, appRoot string) string {
func waitPortReady(host string, port int, timeout time.Duration) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
lastErr = err
time.Sleep(100 * time.Millisecond)
}
if lastErr != nil {
return fmt.Errorf("端口 %d 不可用: %w", port, lastErr)
}
return fmt.Errorf("端口 %d 不可用", port)
}
func waitSocks5Ready(host string, port int, timeout time.Duration) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
if err := checkSocks5Handshake(addr, 300*time.Millisecond); err == nil {
return nil
} else {
lastErr = err
}
time.Sleep(100 * time.Millisecond)
}
if lastErr != nil {
return fmt.Errorf("socks5 端口 %d 未就绪: %w", port, lastErr)
}
return fmt.Errorf("socks5 端口 %d 未就绪", port)
}
func checkSocks5Handshake(addr string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil {
return err
}
buf := make([]byte, 2)
if _, err := io.ReadFull(conn, buf); err != nil {
return err
}
if buf[0] != 0x05 || buf[1] != 0x00 {
return fmt.Errorf("socks5 握手响应异常: %v", buf)
}
return nil
}
// nextAvailablePort 分配一个可用端口。
// 采用二次验证策略:分配后立即再次绑定确认未被其他进程抢占,
// 并在 EnsureBridge 层面加重试,彻底消除 TOCTOU 竞争窗口。
@@ -140,3 +140,15 @@ password: test-password
t.Fatalf("expected missing server and port to fail")
}
}
func TestBuildSingBoxAnyTLSRequiresPassword(t *testing.T) {
src := `
type: anytls
server: anytls.example.com
port: 443
`
if _, err := BuildSingBoxOutbound(src); err == nil {
t.Fatalf("expected missing password to fail")
}
}
@@ -0,0 +1,40 @@
package proxy
func (bridge *SingBoxBridge) startExitWatcher() {
if bridge == nil || bridge.Cmd == nil {
return
}
if bridge.ExitDone == nil {
bridge.ExitDone = make(chan struct{})
}
bridge.waitOnce.Do(func() {
go func() {
err := bridge.Cmd.Wait()
bridge.exitMu.Lock()
bridge.ExitErr = err
bridge.exitMu.Unlock()
close(bridge.ExitDone)
}()
})
}
func (bridge *SingBoxBridge) waitExit() error {
if bridge == nil || bridge.Cmd == nil {
return nil
}
bridge.startExitWatcher()
if bridge.ExitDone == nil {
return nil
}
<-bridge.ExitDone
return bridge.exitErr()
}
func (bridge *SingBoxBridge) exitErr() error {
if bridge == nil {
return nil
}
bridge.exitMu.Lock()
defer bridge.exitMu.Unlock()
return bridge.ExitErr
}
@@ -0,0 +1,70 @@
package proxy
import (
"ant-chrome/backend/internal/logger"
"errors"
"fmt"
"time"
)
var errSingBoxBridgeRestartNotNeeded = errors.New("sing-box 桥接已无须恢复")
func cloneStringInterfaceMap(items map[string]interface{}) map[string]interface{} {
if len(items) == 0 {
return nil
}
cloned := make(map[string]interface{}, len(items))
for key, value := range items {
cloned[key] = value
}
return cloned
}
func (m *SingBoxManager) restartBridgeOnSamePort(log *logger.Logger, key string, bridge *SingBoxBridge) error {
if bridge == nil {
return fmt.Errorf("sing-box 桥接不存在")
}
m.mu.Lock()
current := m.Bridges[key]
if current != bridge || bridge.Stopping || bridge.Restarting {
m.mu.Unlock()
return errSingBoxBridgeRestartNotNeeded
}
if len(bridge.Outbound) == 0 {
m.mu.Unlock()
return fmt.Errorf("sing-box 桥接缺少重启上下文")
}
bridge.Restarting = true
m.mu.Unlock()
binaryPath, err := m.resolveBinary()
if err != nil {
return err
}
log.Warn("sing-box 桥接进程退出,尝试同端口恢复",
logger.F("key", key[:8]),
logger.F("port", bridge.Port),
)
restarted, err := m.launchBridgeOnPort(log, key, binaryPath, cloneStringInterfaceMap(bridge.Outbound), bridge.Port, 1)
if err != nil {
return err
}
restarted.RestartCount = bridge.RestartCount + 1
restarted.LastUsedAt = time.Now()
m.mu.Lock()
if current := m.Bridges[key]; current != bridge {
m.mu.Unlock()
restarted.Stopping = true
m.stopBridgeProcess(restarted)
return errSingBoxBridgeRestartNotNeeded
}
m.Bridges[key] = restarted
m.mu.Unlock()
log.Info("sing-box 桥接已同端口恢复",
logger.F("key", key[:8]),
logger.F("port", restarted.Port),
logger.F("pid", restarted.Pid),
)
go m.watchBridge(restarted, key)
return nil
}
+209 -54
View File
@@ -3,10 +3,12 @@ package proxy
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
@@ -39,69 +41,26 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows
}
log.Debug("sing-box binary", logger.F("path", binaryPath))
const maxRetries = 3
const maxRetries = 2
var lastErr error
attemptsUsed := 0
for attempt := 1; attempt <= maxRetries; attempt++ {
attemptsUsed = attempt
port, err := nextAvailablePort()
if err != nil {
lastErr = err
continue
}
cfgPath, err := m.buildConfig(key, outbound, port)
bridge, err := m.launchBridgeOnPort(log, key, binaryPath, outbound, port, attempt)
if err != nil {
return "", fmt.Errorf("sing-box 配置生成失败: %w", err)
}
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrPath := filepath.Join(filepath.Dir(cfgPath), "singbox-stderr.log")
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
cmd.Stderr = stderrFile
}
if err := cmd.Start(); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
log.Error("sing-box 启动失败", logger.F("error", err), logger.F("attempt", attempt))
lastErr = err
continue
}
bridge := &SingBoxBridge{
NodeKey: key,
Port: port,
Cmd: cmd,
Pid: cmd.Process.Pid,
Running: true,
}
log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil {
if stderrFile != nil {
stderrFile.Close()
if !isRetryableSingBoxLaunchError(err) {
break
}
if content, readErr := os.ReadFile(stderrPath); readErr == nil && len(content) > 0 {
log.Error("sing-box stderr", logger.F("output", string(content)))
}
bridge.Stopping = true
m.stopBridgeProcess(bridge)
bridge.Running = false
bridge.Pid = 0
bridge.LastError = err.Error()
log.Error("sing-box 端口不可用,重试", logger.F("error", err), logger.F("attempt", attempt))
lastErr = err
time.Sleep(200 * time.Millisecond)
continue
}
if stderrFile != nil {
stderrFile.Close()
}
if socksURL, reused := m.registerBridge(key, bridge); reused {
log.Info("复用已就绪 sing-box 桥接", logger.F("key", key[:8]), logger.F("socks_url", socksURL))
bridge.Stopping = true
@@ -113,7 +72,182 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows
return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil
}
return "", fmt.Errorf("sing-box 启动失败(已试 %d 次): %w", maxRetries, 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) {
cfgPath, err := m.buildConfig(key, outbound, port)
if err != nil {
return nil, fmt.Errorf("sing-box 配置生成失败: %w", err)
}
stderrPath := filepath.Join(filepath.Dir(cfgPath), "singbox-stderr.log")
if err := m.testRuntimeConfig(binaryPath, cfgPath, stderrPath); err != nil {
log.Error("sing-box 配置预检失败", logger.F("error", err), logger.F("attempt", attempt), logger.F("config", cfgPath))
return nil, err
}
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
cmd.Stderr = stderrFile
}
if err := cmd.Start(); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
log.Error("sing-box 启动失败", logger.F("error", err), logger.F("attempt", attempt))
return nil, &singBoxLaunchError{err: err, retryable: false}
}
bridge := &SingBoxBridge{
NodeKey: key,
Port: port,
Cmd: cmd,
Pid: cmd.Process.Pid,
Running: true,
Outbound: cloneStringInterfaceMap(outbound),
LastUsedAt: time.Now(),
}
bridge.startExitWatcher()
log.Info("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 stderrFile != nil {
stderrFile.Close()
}
m.logBridgeStartupError(log, cfgPath, stderrPath)
bridge.Stopping = true
m.stopBridgeProcess(bridge)
bridge.Running = false
bridge.Pid = 0
bridge.LastError = m.describeBridgeReadyError(err, cfgPath, stderrPath)
retryable := m.isRetryableBridgeReadyError(err, cfgPath, stderrPath)
message := "sing-box 桥接未就绪"
if retryable {
message = "sing-box 桥接未就绪,重试"
}
log.Error(message, logger.F("error", err), logger.F("attempt", attempt), logger.F("port", port), logger.F("retryable", retryable))
time.Sleep(200 * time.Millisecond)
return nil, &singBoxLaunchError{err: fmt.Errorf("%s", bridge.LastError), retryable: retryable}
}
if stderrFile != nil {
stderrFile.Close()
}
return bridge, nil
}
type singBoxLaunchError struct {
err error
retryable bool
}
func (e *singBoxLaunchError) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *singBoxLaunchError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func isRetryableSingBoxLaunchError(err error) bool {
if err == nil {
return false
}
var launchErr *singBoxLaunchError
if errors.As(err, &launchErr) {
return launchErr.retryable
}
return true
}
func (m *SingBoxManager) testRuntimeConfig(binaryPath string, cfgPath string, stderrPath string) error {
cmd := exec.Command(binaryPath, "check", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
defer stderrFile.Close()
cmd.Stderr = stderrFile
}
output, err := cmd.Output()
if err == nil {
return nil
}
if len(output) > 0 && stderrFile != nil {
_, _ = stderrFile.Write(output)
}
return &singBoxLaunchError{
err: fmt.Errorf("sing-box 配置预检失败: %w%s", err, m.describeBridgeReadyError(err, cfgPath, stderrPath)),
retryable: false,
}
}
func (m *SingBoxManager) waitBridgeSocksReady(bridge *SingBoxBridge, timeout time.Duration) error {
if bridge == nil {
return fmt.Errorf("sing-box 桥接进程不存在")
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ready := make(chan error, 1)
go func() {
ready <- waitSocks5Ready("127.0.0.1", bridge.Port, timeout)
}()
select {
case err := <-ready:
return err
case <-bridge.ExitDone:
if err := bridge.exitErr(); err != nil {
return fmt.Errorf("sing-box 进程提前退出: %w", err)
}
return fmt.Errorf("sing-box 进程提前退出")
case <-deadline.C:
return fmt.Errorf("sing-box socks5 端口 %d 启动超时", bridge.Port)
}
}
func (m *SingBoxManager) isRetryableBridgeReadyError(err error, cfgPath string, stderrPath string) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
if !strings.Contains(message, "提前退出") {
return true
}
tail := strings.ToLower(readLogTail(stderrPath, 1200))
if tail == "" && strings.TrimSpace(cfgPath) != "" {
tail = strings.ToLower(readLogTail(filepath.Join(filepath.Dir(cfgPath), "singbox-error.log"), 1200))
}
return strings.Contains(tail, "address already in use") ||
strings.Contains(tail, "only one usage of each socket") ||
strings.Contains(tail, "bind:") ||
strings.Contains(tail, "bind ")
}
func (m *SingBoxManager) 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 strings.Join(parts, "")
}
func (m *SingBoxManager) logBridgeStartupError(log *logger.Logger, cfgPath string, stderrPath string) {
if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 {
log.Error("sing-box stderr", logger.F("output", string(stderrContent)))
}
}
// StopAll 关闭所有 sing-box 桥接进程
@@ -140,7 +274,7 @@ func (m *SingBoxManager) tryReuseBridge(key string) (string, bool) {
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 && waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
if alive && waitSocks5Ready("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port)
m.mu.Unlock()
return socksURL, true
@@ -169,7 +303,7 @@ 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 && waitPortReady("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
if alive && waitSocks5Ready("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
duplicate = bridge
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port)
m.mu.Unlock()
@@ -197,16 +331,37 @@ func (m *SingBoxManager) watchBridge(bridge *SingBoxBridge, key string) {
if bridge == nil || bridge.Cmd == nil {
return
}
_ = bridge.Cmd.Wait()
_ = bridge.waitExit()
var shouldRestart bool
m.mu.Lock()
if current, ok := m.Bridges[key]; ok && current == bridge {
delete(m.Bridges, key)
if !bridge.Stopping && !bridge.Restarting && bridge.RestartCount < 1 {
shouldRestart = true
} else {
delete(m.Bridges, key)
}
}
bridge.Running = false
stopping := bridge.Stopping
m.mu.Unlock()
if shouldRestart {
log := logger.New("SingBox")
if err := m.restartBridgeOnSamePort(log, key, bridge); err == nil {
return
} else if errors.Is(err, errSingBoxBridgeRestartNotNeeded) {
return
} else {
log.Error("sing-box 桥接同端口恢复失败", logger.F("key", key[:8]), logger.F("port", bridge.Port), logger.F("error", err.Error()))
m.mu.Lock()
if current, ok := m.Bridges[key]; ok && current == bridge {
delete(m.Bridges, key)
}
m.mu.Unlock()
}
}
if !stopping && m.OnBridgeDied != nil {
m.OnBridgeDied(key, fmt.Errorf("sing-box 桥接进程意外退出"))
}
+1 -1
View File
@@ -138,7 +138,7 @@ func buildSingBoxAnyTLSFromClash(node map[string]interface{}) (map[string]interf
}
skipVerify := getMapBool(node, "skip-cert-verify")
if host == "" || port == 0 {
if host == "" || port == 0 || password == "" {
return nil, fmt.Errorf("anytls node info incomplete")
}
+72 -1
View File
@@ -1,6 +1,12 @@
package proxy
import "testing"
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
)
func TestSingBoxRegisterBridgeStoresNewBridge(t *testing.T) {
manager := &SingBoxManager{
@@ -49,3 +55,68 @@ func TestSingBoxRegisterBridgeIgnoresSamePointer(t *testing.T) {
t.Fatalf("same bridge pointer should not be marked as stopping")
}
}
func TestSingBoxLaunchErrorRetryClassification(t *testing.T) {
t.Parallel()
if isRetryableSingBoxLaunchError(&singBoxLaunchError{err: fmt.Errorf("config invalid"), retryable: false}) {
t.Fatalf("non-retryable sing-box launch error was classified as retryable")
}
if !isRetryableSingBoxLaunchError(&singBoxLaunchError{err: fmt.Errorf("port race"), retryable: true}) {
t.Fatalf("retryable sing-box launch error was classified as non-retryable")
}
if !isRetryableSingBoxLaunchError(fmt.Errorf("legacy error")) {
t.Fatalf("plain errors should remain retryable for backward compatibility")
}
}
func TestSingBoxBridgeReadyErrorRetryPolicy(t *testing.T) {
t.Parallel()
manager := &SingBoxManager{}
dir := t.TempDir()
cfgPath := filepath.Join(dir, "singbox-config.json")
stderrPath := filepath.Join(dir, "singbox-stderr.log")
if err := os.WriteFile(cfgPath, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write config failed: %v", err)
}
if manager.isRetryableBridgeReadyError(fmt.Errorf("sing-box 进程提前退出: config invalid"), cfgPath, stderrPath) {
t.Fatalf("early process exit without bind evidence should not be retried")
}
if err := os.WriteFile(stderrPath, []byte("listen tcp 127.0.0.1:10001: bind: address already in use"), 0o644); err != nil {
t.Fatalf("write stderr failed: %v", err)
}
if !manager.isRetryableBridgeReadyError(fmt.Errorf("sing-box 进程提前退出"), cfgPath, stderrPath) {
t.Fatalf("bind conflict should be retried with another port")
}
}
func TestSingBoxRestartBridgeNotNeededWhenBridgeChanged(t *testing.T) {
t.Parallel()
manager := &SingBoxManager{Bridges: make(map[string]*SingBoxBridge)}
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)
if !errors.Is(err, errSingBoxBridgeRestartNotNeeded) {
t.Fatalf("restartBridgeOnSamePort() error = %v, want restart-not-needed", err)
}
}
func TestSingBoxRestartBridgeRequiresContext(t *testing.T) {
t.Parallel()
manager := &SingBoxManager{Bridges: make(map[string]*SingBoxBridge)}
bridge := &SingBoxBridge{NodeKey: "node-a", Port: 21001}
manager.Bridges["node-a"] = bridge
err := manager.restartBridgeOnSamePort(nil, "node-a", bridge)
if err == nil {
t.Fatalf("restartBridgeOnSamePort() returned nil, want missing context error")
}
if errors.Is(err, errSingBoxBridgeRestartNotNeeded) {
t.Fatalf("missing restart context should not be treated as restart-not-needed")
}
}
+16 -7
View File
@@ -4,17 +4,26 @@ import (
"ant-chrome/backend/internal/config"
"os/exec"
"sync"
"time"
)
// SingBoxBridge sing-box 桥接进程
type SingBoxBridge struct {
NodeKey string
Port int
Cmd *exec.Cmd
Pid int
Running bool
Stopping bool
LastError string
NodeKey string
Port int
Cmd *exec.Cmd
Pid int
Running bool
Stopping bool
LastError string
Outbound map[string]interface{}
LastUsedAt time.Time
Restarting bool
RestartCount int
ExitDone chan struct{}
ExitErr error
exitMu sync.Mutex
waitOnce sync.Once
}
// SingBoxManager sing-box 桥接管理器
+9
View File
@@ -2,6 +2,7 @@ package proxy
import (
"os/exec"
"sync"
"time"
)
@@ -16,6 +17,14 @@ type XrayBridge struct {
RefCount int
LastUsedAt time.Time
Stopping bool
Restarting bool
Outbounds []interface{}
Routes []interface{}
DNSServers string
ExitDone chan struct{}
ExitErr error
exitMu sync.Mutex
waitOnce sync.Once
}
// ProxyResult 代理解析结果
+6 -5
View File
@@ -20,7 +20,7 @@ type XrayManager struct {
Bridges map[string]*XrayBridge
OnBridgeDied func(key string, err error) // 桥接进程意外退出回调
mu sync.Mutex
launchMu sync.Mutex
launchLocks map[string]*xrayLaunchLock
stopCh chan struct{}
stopOnce sync.Once
}
@@ -28,10 +28,11 @@ type XrayManager struct {
// NewXrayManager 创建 Xray 管理器
func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager {
manager := &XrayManager{
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*XrayBridge),
stopCh: make(chan struct{}),
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*XrayBridge),
launchLocks: make(map[string]*xrayLaunchLock),
stopCh: make(chan struct{}),
}
go manager.cleanupLoop()
return manager
+121 -9
View File
@@ -3,6 +3,7 @@ package proxy
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"errors"
"fmt"
"os"
"os/exec"
@@ -95,8 +96,8 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
return socksURL, key, nil
}
m.launchMu.Lock()
defer m.launchMu.Unlock()
unlockLaunch := m.lockLaunchForKey(key)
defer unlockLaunch()
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
return socksURL, key, nil
@@ -108,12 +109,14 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
return "", "", err
}
maxLaunchRetries := 3
maxLaunchRetries := 2
if preferredPort > 0 {
maxLaunchRetries = 1
}
var lastErr error
attemptsUsed := 0
for attempt := 1; attempt <= maxLaunchRetries; attempt++ {
attemptsUsed = attempt
socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbounds, routes, preferredPort, dnsServers, pin, attempt)
if err == nil {
return socksURL, key, nil
@@ -122,8 +125,41 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
go m.watchBridge(bridge, key)
}
lastErr = err
if !isRetryableXrayLaunchError(err) {
break
}
}
return "", "", fmt.Errorf("xray 启动失败(已试 %d 次): %w", maxLaunchRetries, lastErr)
return "", "", fmt.Errorf("xray 启动失败(已试 %d 次): %w", attemptsUsed, lastErr)
}
type xrayLaunchError struct {
err error
retryable bool
}
func (e *xrayLaunchError) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *xrayLaunchError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func isRetryableXrayLaunchError(err error) bool {
if err == nil {
return false
}
var launchErr *xrayLaunchError
if errors.As(err, &launchErr) {
return launchErr.retryable
}
return true
}
func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binaryPath string, outbounds []interface{}, routes []interface{}, preferredPort int, dnsServers string, pin bool, attempt int) (string, *XrayBridge, error) {
@@ -141,11 +177,15 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary
log.Error("xray 配置生成失败", logger.F("error", err))
return "", nil, err
}
stderrPath := filepath.Join(filepath.Dir(cfgPath), "xray-stderr.log")
if err := m.testRuntimeConfig(binaryPath, cfgPath, stderrPath); err != nil {
log.Error("xray 配置预检失败", logger.F("error", err), logger.F("attempt", attempt), logger.F("config", cfgPath))
return "", nil, err
}
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrPath := filepath.Join(filepath.Dir(cfgPath), "xray-stderr.log")
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
cmd.Stderr = stderrFile
@@ -156,7 +196,7 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary
stderrFile.Close()
}
log.Error("xray 启动失败", logger.F("error", err), logger.F("attempt", attempt))
return "", nil, err
return "", nil, &xrayLaunchError{err: err, retryable: false}
}
bridge := &XrayBridge{
@@ -167,7 +207,11 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary
Running: true,
RefCount: 0,
LastUsedAt: time.Now(),
Outbounds: cloneInterfaceSlice(outbounds),
Routes: cloneInterfaceSlice(routes),
DNSServers: dnsServers,
}
bridge.startExitWatcher()
log.Info("xray 启动", logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt))
if err := m.waitBridgeReady(log, bridge, cfgPath, stderrPath, stderrFile, attempt); err != nil {
@@ -254,7 +298,7 @@ func chainHTTPOutbound(hop chainSocks5Hop, tag string, nextTag string) map[strin
}
func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cfgPath string, stderrPath string, stderrFile *os.File, attempt int) error {
if err := waitPortReady("127.0.0.1", bridge.Port, m.bridgeStartTimeout()); err != nil {
if err := m.waitBridgeSocksReady(bridge, m.bridgeStartTimeout()); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
@@ -264,9 +308,14 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf
bridge.Running = false
bridge.Pid = 0
bridge.LastError = m.describeBridgeReadyError(err, cfgPath, stderrPath)
log.Error("xray 端口不可用,重试", logger.F("key", bridge.NodeKey), logger.F("error", err), logger.F("port", bridge.Port), logger.F("attempt", attempt))
retryable := m.isRetryableBridgeReadyError(err, cfgPath, stderrPath)
message := "xray 桥接未就绪"
if retryable {
message = "xray 桥接未就绪,重试"
}
log.Error(message, logger.F("key", bridge.NodeKey), logger.F("error", err), logger.F("port", bridge.Port), logger.F("attempt", attempt), logger.F("retryable", retryable))
time.Sleep(200 * time.Millisecond)
return fmt.Errorf("%s", bridge.LastError)
return &xrayLaunchError{err: fmt.Errorf("%s", bridge.LastError), retryable: retryable}
}
if stderrFile != nil {
stderrFile.Close()
@@ -274,6 +323,69 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf
return nil
}
func (m *XrayManager) isRetryableBridgeReadyError(err error, cfgPath string, stderrPath string) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
if !strings.Contains(message, "提前退出") {
return true
}
tail := strings.ToLower(readLogTail(stderrPath, 1200))
if tail == "" && strings.TrimSpace(cfgPath) != "" {
tail = strings.ToLower(readLogTail(filepath.Join(filepath.Dir(cfgPath), "xray-error.log"), 1200))
}
return strings.Contains(tail, "address already in use") ||
strings.Contains(tail, "only one usage of each socket") ||
strings.Contains(tail, "bind:") ||
strings.Contains(tail, "bind ")
}
func (m *XrayManager) testRuntimeConfig(binaryPath string, cfgPath string, stderrPath string) error {
cmd := exec.Command(binaryPath, "run", "-test", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
defer stderrFile.Close()
cmd.Stderr = stderrFile
}
output, err := cmd.Output()
if err == nil {
return nil
}
if len(output) > 0 && stderrFile != nil {
_, _ = stderrFile.Write(output)
}
return &xrayLaunchError{
err: fmt.Errorf("xray 配置预检失败: %w%s", err, m.describeBridgeReadyError(err, cfgPath, stderrPath)),
retryable: false,
}
}
func (m *XrayManager) waitBridgeSocksReady(bridge *XrayBridge, timeout time.Duration) error {
if bridge == nil {
return fmt.Errorf("xray 桥接进程不存在")
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ready := make(chan error, 1)
go func() {
ready <- waitSocks5Ready("127.0.0.1", bridge.Port, timeout)
}()
select {
case err := <-ready:
return err
case <-bridge.ExitDone:
if err := bridge.exitErr(); err != nil {
return fmt.Errorf("xray 进程提前退出: %w", err)
}
return fmt.Errorf("xray 进程提前退出")
case <-deadline.C:
return fmt.Errorf("xray socks5 端口 %d 启动超时", bridge.Port)
}
}
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
@@ -0,0 +1,40 @@
package proxy
func (bridge *XrayBridge) startExitWatcher() {
if bridge == nil || bridge.Cmd == nil {
return
}
if bridge.ExitDone == nil {
bridge.ExitDone = make(chan struct{})
}
bridge.waitOnce.Do(func() {
go func() {
err := bridge.Cmd.Wait()
bridge.exitMu.Lock()
bridge.ExitErr = err
bridge.exitMu.Unlock()
close(bridge.ExitDone)
}()
})
}
func (bridge *XrayBridge) waitExit() error {
if bridge == nil || bridge.Cmd == nil {
return nil
}
bridge.startExitWatcher()
if bridge.ExitDone == nil {
return nil
}
<-bridge.ExitDone
return bridge.exitErr()
}
func (bridge *XrayBridge) exitErr() error {
if bridge == nil {
return nil
}
bridge.exitMu.Lock()
defer bridge.exitMu.Unlock()
return bridge.ExitErr
}
@@ -0,0 +1,80 @@
package proxy
import (
"ant-chrome/backend/internal/logger"
"errors"
"fmt"
"time"
)
var errXrayBridgeRestartNotNeeded = errors.New("xray 桥接已无须恢复")
func cloneInterfaceSlice(items []interface{}) []interface{} {
if len(items) == 0 {
return nil
}
cloned := make([]interface{}, len(items))
copy(cloned, items)
return cloned
}
func (m *XrayManager) restartPinnedBridge(log *logger.Logger, key string, bridge *XrayBridge, refCount int) error {
if bridge == nil {
return fmt.Errorf("xray 桥接不存在")
}
unlockLaunch := m.lockLaunchForKey(key)
defer unlockLaunch()
m.mu.Lock()
current := m.Bridges[key]
if current != bridge || bridge.Stopping || bridge.RefCount <= 0 {
m.mu.Unlock()
return errXrayBridgeRestartNotNeeded
}
refCount = bridge.RefCount
m.mu.Unlock()
if refCount <= 0 {
return fmt.Errorf("xray 桥接无活动引用")
}
if len(bridge.Outbounds) == 0 || len(bridge.Routes) == 0 {
return fmt.Errorf("xray 桥接缺少重启上下文")
}
log.Warn("xray 桥接进程退出,尝试同端口恢复",
logger.F("key", key),
logger.F("port", bridge.Port),
logger.F("ref_count", refCount),
)
binaryPath, err := m.resolveBinary()
if err != nil {
return err
}
socksURL, restarted, err := m.launchBridgeAttempt(
log,
key,
binaryPath,
cloneInterfaceSlice(bridge.Outbounds),
cloneInterfaceSlice(bridge.Routes),
bridge.Port,
bridge.DNSServers,
false,
1,
)
if err != nil {
return err
}
if restarted == nil {
return fmt.Errorf("xray 桥接恢复异常: 未返回新进程")
}
restarted.LastUsedAt = time.Now()
log.Info("xray 桥接已同端口恢复",
logger.F("key", key),
logger.F("port", restarted.Port),
logger.F("pid", restarted.Pid),
logger.F("socks_url", socksURL),
logger.F("ref_count", refCount),
)
go m.watchBridge(restarted, key)
return nil
}
+37 -4
View File
@@ -1,6 +1,8 @@
package proxy
import (
"ant-chrome/backend/internal/logger"
"errors"
"fmt"
"time"
)
@@ -11,7 +13,7 @@ func (m *XrayManager) tryReuseBridge(key string, pin bool) (string, bool) {
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 && waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
if alive && waitSocks5Ready("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
if pin {
bridge.RefCount++
}
@@ -44,7 +46,7 @@ func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) (
}
alive := existing.Running && existing.Cmd != nil && existing.Cmd.Process != nil && existing.Cmd.ProcessState == nil
if alive && waitPortReady("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
if alive && waitSocks5Ready("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
if pin {
existing.RefCount++
}
@@ -59,9 +61,16 @@ func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) (
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 {
@@ -81,16 +90,40 @@ func (m *XrayManager) watchBridge(bridge *XrayBridge, key string) {
if bridge == nil || bridge.Cmd == nil {
return
}
_ = bridge.Cmd.Wait()
_ = bridge.waitExit()
var shouldRestart bool
var refCount int
m.mu.Lock()
if current, ok := m.Bridges[key]; ok && current == bridge {
delete(m.Bridges, key)
refCount = bridge.RefCount
if !bridge.Stopping && refCount > 0 && !bridge.Restarting {
bridge.Restarting = true
shouldRestart = true
} else {
delete(m.Bridges, key)
}
}
bridge.Running = false
stopping := bridge.Stopping
m.mu.Unlock()
if shouldRestart {
log := logger.New("Xray")
if err := m.restartPinnedBridge(log, key, bridge, refCount); err == nil {
return
} else if errors.Is(err, errXrayBridgeRestartNotNeeded) {
return
} else {
log.Error("xray 桥接同端口恢复失败", logger.F("key", key), logger.F("port", bridge.Port), logger.F("error", err.Error()))
m.mu.Lock()
if current, ok := m.Bridges[key]; ok && current == bridge {
delete(m.Bridges, key)
}
m.mu.Unlock()
}
}
if !stopping && m.OnBridgeDied != nil {
m.OnBridgeDied(key, fmt.Errorf("xray 桥接进程意外退出"))
}
@@ -0,0 +1,36 @@
package proxy
import "sync"
type xrayLaunchLock struct {
mu sync.Mutex
refs int
}
func (m *XrayManager) lockLaunchForKey(key string) func() {
if m == nil {
return func() {}
}
m.mu.Lock()
if m.launchLocks == nil {
m.launchLocks = make(map[string]*xrayLaunchLock)
}
lock := m.launchLocks[key]
if lock == nil {
lock = &xrayLaunchLock{}
m.launchLocks[key] = lock
}
lock.refs++
m.mu.Unlock()
lock.mu.Lock()
return func() {
lock.mu.Unlock()
m.mu.Lock()
lock.refs--
if lock.refs <= 0 && m.launchLocks[key] == lock {
delete(m.launchLocks, key)
}
m.mu.Unlock()
}
}
@@ -0,0 +1,58 @@
package proxy
import (
"sync/atomic"
"testing"
"time"
)
func TestXrayLaunchLockSerializesSameKey(t *testing.T) {
t.Parallel()
manager := &XrayManager{}
unlockFirst := manager.lockLaunchForKey("node-a")
acquiredSecond := make(chan struct{})
go func() {
unlockSecond := manager.lockLaunchForKey("node-a")
defer unlockSecond()
close(acquiredSecond)
}()
select {
case <-acquiredSecond:
t.Fatalf("same-key launch lock was not serialized")
case <-time.After(30 * time.Millisecond):
}
unlockFirst()
select {
case <-acquiredSecond:
case <-time.After(time.Second):
t.Fatalf("same-key launch lock did not release")
}
}
func TestXrayLaunchLockAllowsDifferentKeys(t *testing.T) {
t.Parallel()
manager := &XrayManager{}
unlockFirst := manager.lockLaunchForKey("node-a")
defer unlockFirst()
var acquired int32
done := make(chan struct{})
go func() {
unlockSecond := manager.lockLaunchForKey("node-b")
defer unlockSecond()
atomic.StoreInt32(&acquired, 1)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatalf("different-key launch lock was unexpectedly blocked")
}
if atomic.LoadInt32(&acquired) != 1 {
t.Fatalf("different-key launch lock was not acquired")
}
}
@@ -1,8 +1,11 @@
package proxy
import (
"io"
"net"
"reflect"
"testing"
"time"
)
func TestParseDnsConfigFromClashYAML(t *testing.T) {
@@ -49,3 +52,50 @@ func TestNormalizeNodeScheme(t *testing.T) {
t.Fatalf("normalizeNodeScheme() unexpectedly changed vmess: %q", got)
}
}
func TestWaitPortReadyIncludesLastDialError(t *testing.T) {
t.Parallel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen failed: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
err = waitPortReady("127.0.0.1", port, 50*time.Millisecond)
if err == nil {
t.Fatalf("expected waitPortReady error")
}
if got := err.Error(); got == "" || got == "端口 0 不可用" {
t.Fatalf("expected detailed port error, got %q", got)
}
}
func TestWaitSocks5ReadyRequiresHandshake(t *testing.T) {
t.Parallel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen failed: %v", err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
done := make(chan struct{})
go func() {
defer close(done)
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 3)
_, _ = io.ReadFull(conn, buf)
_, _ = conn.Write([]byte{0x05, 0x00})
}()
if err := waitSocks5Ready("127.0.0.1", port, time.Second); err != nil {
t.Fatalf("waitSocks5Ready() error = %v", err)
}
<-done
}
+85 -1
View File
@@ -1,6 +1,12 @@
package proxy
import "testing"
import (
"fmt"
"net"
"os"
"path/filepath"
"testing"
)
func TestXrayRegisterBridgeStoresNewBridge(t *testing.T) {
t.Parallel()
@@ -53,3 +59,81 @@ func TestXrayRegisterBridgeIgnoresSamePointer(t *testing.T) {
t.Fatalf("same bridge pointer should not be marked as stopping")
}
}
func TestXrayLaunchErrorRetryClassification(t *testing.T) {
t.Parallel()
if isRetryableXrayLaunchError(&xrayLaunchError{err: fmt.Errorf("config invalid"), retryable: false}) {
t.Fatalf("non-retryable xray launch error was classified as retryable")
}
if !isRetryableXrayLaunchError(&xrayLaunchError{err: fmt.Errorf("port race"), retryable: true}) {
t.Fatalf("retryable xray launch error was classified as non-retryable")
}
if !isRetryableXrayLaunchError(fmt.Errorf("legacy error")) {
t.Fatalf("plain errors should remain retryable for backward compatibility")
}
}
func TestXrayBridgeReadyErrorRetryPolicy(t *testing.T) {
t.Parallel()
manager := &XrayManager{}
dir := t.TempDir()
cfgPath := filepath.Join(dir, "xray-config.json")
stderrPath := filepath.Join(dir, "xray-stderr.log")
if err := os.WriteFile(cfgPath, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write config failed: %v", err)
}
if manager.isRetryableBridgeReadyError(fmt.Errorf("xray 进程提前退出: config invalid"), cfgPath, stderrPath) {
t.Fatalf("early process exit without bind evidence should not be retried")
}
if err := os.WriteFile(stderrPath, []byte("listen tcp 127.0.0.1:10001: bind: address already in use"), 0o644); err != nil {
t.Fatalf("write stderr failed: %v", err)
}
if !manager.isRetryableBridgeReadyError(fmt.Errorf("xray 进程提前退出"), cfgPath, stderrPath) {
t.Fatalf("bind conflict should be retried with another port")
}
}
func TestXrayRegisterBridgeTransfersRestartingRefCount(t *testing.T) {
t.Parallel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen failed: %v", err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
manager := &XrayManager{Bridges: make(map[string]*XrayBridge)}
oldBridge := &XrayBridge{
NodeKey: "node-a",
Port: 21001,
Running: false,
RefCount: 2,
Restarting: true,
}
manager.Bridges["node-a"] = oldBridge
newBridge := &XrayBridge{NodeKey: "node-a", Port: port, Running: true}
socksURL, reused := manager.registerBridge("node-a", newBridge, false)
if reused {
t.Fatalf("expected new restarted bridge registration, got reused %q", socksURL)
}
if manager.Bridges["node-a"] != newBridge {
t.Fatalf("new bridge was not registered")
}
if newBridge.RefCount != 2 {
t.Fatalf("restart refcount = %d, want 2", newBridge.RefCount)
}
}
+77 -1
View File
@@ -1,4 +1,4 @@
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
import type { BrowserProxy, ProxyBridgeWarmupResult, ProxyIPHealthResult, ProxyLocationResolveResult } from '../types'
import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime'
export interface ClashImportURLResult {
@@ -117,6 +117,46 @@ export async function browserProxyBatchTestSpeed(proxyIds: string[], concurrency
return proxyIds.map((proxyId) => ({ proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' }))
}
export async function browserProxyWarmupBridge(proxyId: string): Promise<ProxyBridgeWarmupResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyWarmupBridge) {
return (await bindings.BrowserProxyWarmupBridge(proxyId)) || {
proxyId,
ok: false,
engine: '',
socksUrl: '',
latencyMs: 0,
error: '调用失败',
}
}
await sleep(200)
return { proxyId, ok: true, engine: 'mock', socksUrl: '', latencyMs: 0, error: '' }
}
export async function browserProxyWarmupBridgeWithConfig(proxyId: string, proxyConfig: string): Promise<ProxyBridgeWarmupResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyWarmupBridgeWithConfig) {
return (await bindings.BrowserProxyWarmupBridgeWithConfig(proxyId, proxyConfig)) || {
proxyId,
ok: false,
engine: '',
socksUrl: '',
latencyMs: 0,
error: '调用失败',
}
}
return browserProxyWarmupBridge(proxyId)
}
export async function browserProxyBatchWarmupBridge(proxyIds: string[], concurrency: number = 5): Promise<ProxyBridgeWarmupResult[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyBatchWarmupBridge) {
return (await bindings.BrowserProxyBatchWarmupBridge(proxyIds, concurrency)) || []
}
await sleep(400)
return proxyIds.map((proxyId) => ({ proxyId, ok: true, engine: 'mock', socksUrl: '', latencyMs: 0, error: '' }))
}
export async function browserProxyCheckIPHealth(proxyId: string): Promise<ProxyIPHealthResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyCheckIPHealth) {
@@ -159,6 +199,42 @@ export async function browserProxyCheckIPHealth(proxyId: string): Promise<ProxyI
}
}
export async function browserProxyResolveLocation(proxyId: string): Promise<ProxyLocationResolveResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyResolveLocation) {
return (await bindings.BrowserProxyResolveLocation(proxyId)) || {
proxyId,
ok: false,
auto: false,
source: 'location',
error: '调用失败',
ip: '',
country: '',
region: '',
city: '',
timezone: '',
lang: '',
resolvedAt: nowISOString(),
}
}
await sleep(400)
return {
proxyId,
ok: true,
auto: true,
source: 'mock',
error: '',
ip: '127.0.0.1',
country: 'US',
region: 'New York',
city: 'New York',
timezone: 'America/New_York',
lang: 'en-US',
resolvedAt: nowISOString(),
}
}
export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurrency: number = 10): Promise<ProxyIPHealthResult[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyBatchCheckIPHealth) {
@@ -34,12 +34,21 @@ const PLATFORM_OPTIONS = [
const LANG_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'zh-CN', label: '中文 (zh-CN)' },
{ value: 'en-US', label: 'English (en-US)' },
{ value: 'en-GB', label: 'English (en-GB)' },
{ value: 'zh-HK', label: '繁體中文香港 (zh-HK)' },
{ value: 'zh-TW', label: '繁體中文台灣 (zh-TW)' },
{ value: 'en-US', label: 'English US (en-US)' },
{ value: 'en-GB', label: 'English UK (en-GB)' },
{ value: 'en-CA', label: 'English Canada (en-CA)' },
{ value: 'en-AU', label: 'English Australia (en-AU)' },
{ value: 'en-SG', label: 'English Singapore (en-SG)' },
{ value: 'en-IN', label: 'English India (en-IN)' },
{ value: 'ja-JP', label: '日本語 (ja-JP)' },
{ value: 'ko-KR', label: '한국어 (ko-KR)' },
{ value: 'fr-FR', label: 'Français (fr-FR)' },
{ value: 'de-DE', label: 'Deutsch (de-DE)' },
{ value: 'nl-NL', label: 'Nederlands (nl-NL)' },
{ value: 'ru-RU', label: 'Русский (ru-RU)' },
{ value: 'pt-BR', label: 'Português Brasil (pt-BR)' },
]
const TIMEZONE_OPTIONS = [
@@ -51,6 +60,7 @@ const TIMEZONE_OPTIONS = [
{ value: 'Asia/Seoul', label: 'Asia/Seoul (UTC+9)' },
{ value: 'Asia/Singapore', label: 'Asia/Singapore (UTC+8)' },
{ value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (UTC+8)' },
{ value: 'Asia/Taipei', label: 'Asia/Taipei (UTC+8)' },
{ value: 'Asia/Dubai', label: 'Asia/Dubai (UTC+4)' },
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (UTC+5:30)' },
// 美洲
@@ -59,6 +69,8 @@ const TIMEZONE_OPTIONS = [
{ value: 'America/Chicago', label: 'America/Chicago (UTC-6)' },
{ value: 'America/Denver', label: 'America/Denver (UTC-7)' },
{ value: 'America/Toronto', label: 'America/Toronto (UTC-5)' },
{ value: 'America/Vancouver', label: 'America/Vancouver (UTC-8)' },
{ value: 'America/Phoenix', label: 'America/Phoenix (UTC-7)' },
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (UTC-3)' },
// EMEA
{ value: 'Europe/London', label: 'Europe/London (UTC+0)' },
@@ -67,6 +79,8 @@ const TIMEZONE_OPTIONS = [
{ value: 'Europe/Moscow', label: 'Europe/Moscow (UTC+3)' },
// 大洋洲
{ value: 'Australia/Sydney', label: 'Australia/Sydney (UTC+10)' },
{ value: 'Australia/Melbourne', label: 'Australia/Melbourne (UTC+10)' },
{ value: 'Australia/Perth', label: 'Australia/Perth (UTC+8)' },
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (UTC+12)' },
]
@@ -17,6 +17,7 @@ import {
import { CookieManagerCard } from '../components/CookieManagerCard'
import { SnapshotTab } from '../components/SnapshotTab'
import { resolveActionErrorMessage, resolveActionFeedback } from '../utils/actionErrors'
import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup'
const resolveRuntimeStatus = (running: boolean, debugReady: boolean) => {
if (!running) return { variant: 'warning' as const, label: '已停止' }
@@ -128,6 +129,7 @@ export function BrowserDetailPage() {
const handleStart = async () => {
setPendingAction('starting')
try {
await warmupProfileProxyBeforeStart(profile)
const startedProfile = await startBrowserInstance(profile.profileId)
if (startedProfile) {
setProfile(startedProfile)
@@ -169,6 +171,7 @@ export function BrowserDetailPage() {
const handleRestart = async () => {
setPendingAction('restarting')
try {
await warmupProfileProxyBeforeStart(profile)
const restartedProfile = await restartBrowserInstance(profile.profileId)
if (restartedProfile) {
setProfile(restartedProfile)
@@ -2,9 +2,10 @@ import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { FolderOpen, Layers } from 'lucide-react'
import { Button, Card, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components'
import type { BrowserCore, BrowserProfileInput, BrowserProxy, BrowserGroup } from '../types'
import { createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchBrowserSettings, fetchGroups, openUserDataDir, updateBrowserProfile, validateProxyConfig } from '../api'
import type { BrowserCore, BrowserProfileInput, BrowserProxy, BrowserGroup, ProxyLocationResolveResult } from '../types'
import { browserProxyResolveLocation, createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchBrowserSettings, fetchGroups, openUserDataDir, updateBrowserProfile, validateProxyConfig } from '../api'
import { FingerprintPanel } from '../components/FingerprintPanel'
import { applyLocaleToFingerprintArgs } from '../utils/fingerprintSerializer'
import { TagInput } from '../components/TagInput'
import { GroupSelector } from '../components/GroupSelector'
import { ProxyPickerModal } from '../components/ProxyPickerModal'
@@ -76,6 +77,8 @@ export function BrowserEditPage() {
const [isDirty, setIsDirty] = useState(false)
const [leaveConfirm, setLeaveConfirm] = useState(false)
const [saveError, setSaveError] = useState('')
const [locationResolving, setLocationResolving] = useState(false)
const [locationResult, setLocationResult] = useState<ProxyLocationResolveResult | null>(null)
useEffect(() => {
const loadData = async () => {
@@ -198,6 +201,30 @@ export function BrowserEditPage() {
if (isDirty) { setLeaveConfirm(true) } else { navigate('/browser/list') }
}
const handleApplyProxyLocation = async () => {
if (proxyMode !== 'pool' || !formData.proxyId || formData.proxyId === directProxyID) {
toast.error('请选择代理池中的非直连节点')
return
}
setLocationResolving(true)
setLocationResult(null)
try {
const result = await browserProxyResolveLocation(formData.proxyId)
setLocationResult(result)
if (!result.ok || !result.lang || !result.timezone) {
toast.error(result.error || '无法根据代理 IP 匹配定位')
return
}
const nextArgs = applyLocaleToFingerprintArgs(formData.fingerprintArgs, result.lang, result.timezone)
handleChange('fingerprintArgs', nextArgs)
toast.success(`已设置 ${result.lang} / ${result.timezone}`)
} catch (error: unknown) {
toast.error((error as Error)?.message || '代理定位失败')
} finally {
setLocationResolving(false)
}
}
const defaultCore = cores.find(c => c.isDefault)
const selectedPoolProxy = proxies.find((proxy) => proxy.proxyId === formData.proxyId)
@@ -314,7 +341,7 @@ export function BrowserEditPage() {
<div className="flex gap-2">
<Select
value={formData.proxyId}
onChange={e => handleChange('proxyId', e.target.value)}
onChange={e => { handleChange('proxyId', e.target.value); setLocationResult(null) }}
options={
proxies.length > 0
? proxies.map(p => ({ value: p.proxyId, label: p.proxyName || p.proxyId }))
@@ -325,7 +352,23 @@ export function BrowserEditPage() {
<Button variant="secondary" size="sm" onClick={() => setProxyPickerOpen(true)} title="按分组选择代理">
<Layers className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleApplyProxyLocation}
loading={locationResolving}
disabled={!formData.proxyId || formData.proxyId === directProxyID}
>
</Button>
</div>
{locationResult && (
<div className="mt-2 text-xs text-[var(--color-text-muted)]">
{locationResult.ok
? `出口 ${locationResult.ip || '-'} · ${[locationResult.country, locationResult.region, locationResult.city].filter(Boolean).join(' / ') || '-'} · ${locationResult.lang} · ${locationResult.timezone}`
: locationResult.error || '未匹配到定位'}
</div>
)}
</FormItem>
) : (
<FormItem label="本地代理地址" hint="支持 http://、https://、socks5://">
@@ -347,7 +390,7 @@ export function BrowserEditPage() {
<ProxyPickerModal
open={proxyPickerOpen}
currentProxyId={formData.proxyId}
onSelect={proxy => handleChange('proxyId', proxy.proxyId)}
onSelect={proxy => { handleChange('proxyId', proxy.proxyId); setLocationResult(null) }}
onProxyListUpdated={handleProxyListUpdated}
onProxyDeleted={handleProxyDeleted}
onClose={() => setProxyPickerOpen(false)}
@@ -12,6 +12,7 @@ import { useBrowserListDerived, useBrowserListViewState } from './browserList/us
import { useBrowserListSettings } from './browserList/useBrowserListSettings'
import { useBrowserListData } from './browserList/useBrowserListData'
import { useBrowserProfileActions } from './browserList/useBrowserProfileActions'
import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup'
import {
copyBrowserProfile,
deleteBrowserProfile,
@@ -172,6 +173,7 @@ export function BrowserListPage() {
if (!profile || profile.running) continue
updatePendingIds(setStartingIds, id, true)
try {
await warmupProfileProxyBeforeStart(profile)
const startedProfile = await startBrowserInstance(id)
mergeProfileState(startedProfile)
success++
@@ -162,6 +162,8 @@ export function ProxyPoolPage() {
ipHealthMap,
checkingIPHealthIds,
checkingAllIPHealth,
warmingBridgeIds,
warmingAllBridges,
ipHealthDetailOpen,
setIPHealthDetailOpen,
currentIPHealthDetail,
@@ -169,6 +171,8 @@ export function ProxyPoolPage() {
setIPHealthMap,
handleTestOne,
handleTestAll,
handleWarmupOne,
handleWarmupAll,
handleCheckOneIPHealth,
handleCheckAllIPHealth,
openIPHealthDetail,
@@ -336,9 +340,11 @@ export function ProxyPoolPage() {
onOpenImport={() => setImportModalOpen(true)}
onRefreshAllSources={() => void handleRefreshAllSources(false)}
onTestAll={() => void handleTestAll(filteredList)}
onWarmupAll={() => void handleWarmupAll(filteredList)}
refreshingAllSources={refreshingAllSources}
testingAll={testingAll}
totalCount={filteredList.length}
warmingAllBridges={warmingAllBridges}
/>
<ProxyPoolTableCard
@@ -378,6 +384,8 @@ export function ProxyPoolPage() {
onTestOne={(record) => void handleTestOne(record)}
onToggleAll={handleToggleAll}
onToggleOne={handleToggleOne}
onWarmupOne={(record) => void handleWarmupOne(record)}
onWarmupSelected={() => void handleWarmupAll(filteredList.filter(item => selectedIds.has(item.proxyId)))}
protocolOptions={protocolOptions}
refreshingSourceIds={refreshingSourceIds}
selectedCount={selectedCount}
@@ -385,6 +393,8 @@ export function ProxyPoolPage() {
someFilteredSelected={someFilteredSelected}
sortColumn={sortColumn}
sortOrder={sortOrder}
warmingAllBridges={warmingAllBridges}
warmingBridgeIds={warmingBridgeIds}
/>
<ProxyPoolImportModal
@@ -10,6 +10,7 @@ import {
} from '../../api'
import type { BrowserProfile } from '../../types'
import { resolveActionErrorMessage, resolveActionFeedback } from '../../utils/actionErrors'
import { warmupProfileProxyBeforeStart } from '../../utils/proxyWarmup'
interface UseBrowserProfileActionsOptions {
profiles: BrowserProfile[]
@@ -54,6 +55,7 @@ export function useBrowserProfileActions({
}
}
await warmupProfileProxyBeforeStart(profile)
const startedProfile = await startBrowserInstance(profileId)
mergeProfileState(startedProfile)
if (startedProfile?.runtimeWarning) {
@@ -119,8 +121,10 @@ export function useBrowserProfileActions({
}
const handleRestart = async (profileId: string) => {
const profile = profiles.find(p => p.profileId === profileId)
updatePendingIds(setStoppingIds, profileId, true)
try {
await warmupProfileProxyBeforeStart(profile)
const restartedProfile = await restartBrowserInstance(profileId)
mergeProfileState(restartedProfile)
toast.success(`实例已重启${restartedProfile?.profileName ? `${restartedProfile.profileName}` : ''}`)
@@ -151,4 +155,4 @@ export function useBrowserProfileActions({
handleRestart,
handleDelete,
}
}
}
@@ -8,9 +8,11 @@ interface ProxyPoolHeaderProps {
onOpenSettings: () => void
onRefreshAllSources: () => void
onTestAll: () => void
onWarmupAll: () => void
refreshingAllSources: boolean
testingAll: boolean
totalCount: number
warmingAllBridges: boolean
}
export function ProxyPoolHeader({
@@ -21,9 +23,11 @@ export function ProxyPoolHeader({
onOpenSettings,
onRefreshAllSources,
onTestAll,
onWarmupAll,
refreshingAllSources,
testingAll,
totalCount,
warmingAllBridges,
}: ProxyPoolHeaderProps) {
return (
<div className="flex items-center justify-between">
@@ -57,6 +61,15 @@ export function ProxyPoolHeader({
>
IP健康
</Button>
<Button
size="sm"
variant="secondary"
onClick={onWarmupAll}
loading={warmingAllBridges}
disabled={totalCount === 0}
>
</Button>
<Button
size="sm"
variant="secondary"
@@ -35,6 +35,8 @@ interface ProxyPoolTableCardProps {
onTestOne: (record: ProxyDisplayInfo) => void
onToggleAll: () => void
onToggleOne: (proxyId: string) => void
onWarmupOne: (record: ProxyDisplayInfo) => void
onWarmupSelected: () => void
protocolOptions: string[]
refreshingSourceIds: Set<string>
selectedCount: number
@@ -43,6 +45,8 @@ interface ProxyPoolTableCardProps {
sortColumn: string
sortOrder: SortOrder
latencyMap: Record<string, number>
warmingBridgeIds: Set<string>
warmingAllBridges: boolean
}
export function ProxyPoolTableCard({
@@ -74,6 +78,8 @@ export function ProxyPoolTableCard({
onTestOne,
onToggleAll,
onToggleOne,
onWarmupOne,
onWarmupSelected,
protocolOptions,
refreshingSourceIds,
selectedCount,
@@ -82,6 +88,8 @@ export function ProxyPoolTableCard({
sortColumn,
sortOrder,
latencyMap,
warmingBridgeIds,
warmingAllBridges,
}: ProxyPoolTableCardProps) {
const hasActiveFilters = filterProtocol !== 'all' || !!filterKeyword || filterGroup !== 'all'
@@ -199,7 +207,7 @@ export function ProxyPoolTableCard({
{
key: 'actions',
title: '操作',
width: '320px',
width: '380px',
render: (_, record) => {
const isBuiltin = BUILTIN_PROXY_IDS.has(record.proxyId)
const sourceId = record.sourceId || ''
@@ -216,6 +224,15 @@ export function ProxyPoolTableCard({
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={(event) => { event.stopPropagation(); onWarmupOne(record) }}
loading={warmingBridgeIds.has(record.proxyId)}
disabled={record.proxyConfig === 'direct://'}
>
</Button>
<Button
size="sm"
variant="ghost"
@@ -275,8 +292,10 @@ export function ProxyPoolTableCard({
onRefreshSingleSource,
onTestOne,
onToggleOne,
onWarmupOne,
refreshingSourceIds,
selectedIds,
warmingBridgeIds,
])
return (
@@ -343,9 +362,14 @@ export function ProxyPoolTableCard({
</label>
)}
{selectedCount > 0 && (
<Button size="sm" variant="danger" onClick={onOpenBatchDelete}>
({selectedCount})
</Button>
<>
<Button size="sm" variant="secondary" onClick={onWarmupSelected} loading={warmingAllBridges}>
({selectedCount})
</Button>
<Button size="sm" variant="danger" onClick={onOpenBatchDelete}>
({selectedCount})
</Button>
</>
)}
</div>
<Table
@@ -4,8 +4,10 @@ import { EventsOn } from '../../../../wailsjs/runtime/runtime'
import {
browserProxyBatchCheckIPHealth,
browserProxyBatchTestSpeed,
browserProxyBatchWarmupBridge,
browserProxyCheckIPHealth,
browserProxyTestSpeed,
browserProxyWarmupBridge,
} from '../../api'
import type { ProxyIPHealthResult } from '../../types'
import type { ProxyDisplayInfo } from './helpers'
@@ -22,6 +24,8 @@ export function useProxyChecks({ proxies }: UseProxyChecksOptions) {
const [ipHealthMap, setIPHealthMap] = useState<Record<string, ProxyIPHealthResult>>({})
const [checkingIPHealthIds, setCheckingIPHealthIds] = useState<Set<string>>(new Set())
const [checkingAllIPHealth, setCheckingAllIPHealth] = useState(false)
const [warmingBridgeIds, setWarmingBridgeIds] = useState<Set<string>>(new Set())
const [warmingAllBridges, setWarmingAllBridges] = useState(false)
const [ipHealthDetailOpen, setIPHealthDetailOpen] = useState(false)
const [currentIPHealthDetail, setCurrentIPHealthDetail] = useState<ProxyIPHealthResult | null>(null)
@@ -102,6 +106,48 @@ export function useProxyChecks({ proxies }: UseProxyChecksOptions) {
}
}
const handleWarmupOne = async (record: ProxyDisplayInfo) => {
if (record.proxyConfig === 'direct://') {
toast.info('直连模式无需预热')
return
}
if (warmingBridgeIds.has(record.proxyId)) return
setWarmingBridgeIds(prev => new Set(prev).add(record.proxyId))
try {
const result = await browserProxyWarmupBridge(record.proxyId)
if (result.ok) toast.success(`${record.proxyName} 已预热`)
else toast.error(result.error || `${record.proxyName} 预热失败`)
} finally {
setWarmingBridgeIds(prev => {
const next = new Set(prev)
next.delete(record.proxyId)
return next
})
}
}
const handleWarmupAll = async (items: ProxyDisplayInfo[]) => {
const testable = items.filter(p => p.proxyConfig !== 'direct://')
if (testable.length === 0) return
setWarmingAllBridges(true)
const ids = testable.map(p => p.proxyId)
setWarmingBridgeIds(prev => new Set([...Array.from(prev), ...ids]))
try {
const results = await browserProxyBatchWarmupBridge(ids, 5)
const failed = results.filter(r => !r.ok).length
if (failed > 0) toast.info(`预热完成:成功 ${results.length - failed},失败 ${failed}`)
else toast.success(`预热完成:共 ${results.length}`)
} finally {
setWarmingBridgeIds(prev => {
const next = new Set(prev)
ids.forEach(id => next.delete(id))
return next
})
setWarmingAllBridges(false)
}
}
const handleCheckOneIPHealth = async (record: ProxyDisplayInfo) => {
if (record.proxyConfig === 'direct://') {
toast.info('直连模式无需检测')
@@ -178,6 +224,8 @@ export function useProxyChecks({ proxies }: UseProxyChecksOptions) {
ipHealthMap,
checkingIPHealthIds,
checkingAllIPHealth,
warmingBridgeIds,
warmingAllBridges,
ipHealthDetailOpen,
setIPHealthDetailOpen,
currentIPHealthDetail,
@@ -185,6 +233,8 @@ export function useProxyChecks({ proxies }: UseProxyChecksOptions) {
setIPHealthMap,
handleTestOne,
handleTestAll,
handleWarmupOne,
handleWarmupAll,
handleCheckOneIPHealth,
handleCheckAllIPHealth,
openIPHealthDetail,
+33
View File
@@ -147,6 +147,39 @@ export interface ProxyIPHealthResult {
updatedAt: string
}
export interface ProxyBridgeWarmupResult {
proxyId: string
ok: boolean
engine: string
socksUrl: string
latencyMs: number
error: string
}
export interface ProxyLocationOption {
label: string
timezone: string
lang: string
}
export interface ProxyLocationResolveResult {
proxyId: string
ok: boolean
auto: boolean
source: string
error: string
ip: string
country: string
region: string
city: string
timezone: string
lang: string
health?: ProxyIPHealthResult
alternates?: ProxyLocationOption[]
resolvedAt: string
}
export interface BrowserCoreExtended {
coreId: string
chromeVersion: string
@@ -348,3 +348,10 @@ export const FINGERPRINT_PRESETS: FingerprintPreset[] = [
},
},
]
export function applyLocaleToFingerprintArgs(args: string[], lang: string, timezone: string): string[] {
const nextConfig = deserialize(args || [])
if (lang) nextConfig.lang = lang
if (timezone) nextConfig.timezone = timezone
return serialize(nextConfig)
}
@@ -0,0 +1,12 @@
import { browserProxyWarmupBridgeWithConfig } from '../api'
import type { BrowserProfile } from '../types'
export async function warmupProfileProxyBeforeStart(profile: BrowserProfile | null | undefined): Promise<void> {
if (!profile || profile.running || (!profile.proxyId && !profile.proxyConfig)) {
return
}
try {
await browserProxyWarmupBridgeWithConfig(profile.proxyId || '', profile.proxyConfig || '')
} catch {
}
}
+8
View File
@@ -151,8 +151,12 @@ export function BrowserProxyBatchCheckIPHealth(arg1:Array<string>,arg2:number):P
export function BrowserProxyBatchTestSpeed(arg1:Array<string>,arg2:number):Promise<Array<backend.ProxyTestResult>>;
export function BrowserProxyBatchWarmupBridge(arg1:Array<string>,arg2:number):Promise<Array<backend.ProxyBridgeWarmupResult>>;
export function BrowserProxyCheckIPHealth(arg1:string):Promise<backend.ProxyIPHealthResult>;
export function BrowserProxyResolveLocation(arg1:string):Promise<backend.ProxyLocationResolveResult>;
export function BrowserProxyFetchClashByURL(arg1:string):Promise<Record<string, any>>;
export function BrowserProxyList():Promise<Array<config.BrowserProxy>>;
@@ -161,6 +165,10 @@ export function BrowserProxyListByGroup(arg1:string):Promise<Array<config.Browse
export function BrowserProxyListGroups():Promise<Array<string>>;
export function BrowserProxyWarmupBridge(arg1:string):Promise<backend.ProxyBridgeWarmupResult>;
export function BrowserProxyWarmupBridgeWithConfig(arg1:string,arg2:string):Promise<backend.ProxyBridgeWarmupResult>;
export function BrowserProxyTestSpeed(arg1:string):Promise<backend.ProxyTestResult>;
export function BrowserRenameTag(arg1:string,arg2:string):Promise<void>;
+16
View File
@@ -286,10 +286,18 @@ export function BrowserProxyBatchTestSpeed(arg1, arg2) {
return window['go']['main']['App']['BrowserProxyBatchTestSpeed'](arg1, arg2);
}
export function BrowserProxyBatchWarmupBridge(arg1, arg2) {
return window['go']['main']['App']['BrowserProxyBatchWarmupBridge'](arg1, arg2);
}
export function BrowserProxyCheckIPHealth(arg1) {
return window['go']['main']['App']['BrowserProxyCheckIPHealth'](arg1);
}
export function BrowserProxyResolveLocation(arg1) {
return window['go']['main']['App']['BrowserProxyResolveLocation'](arg1);
}
export function BrowserProxyFetchClashByURL(arg1) {
return window['go']['main']['App']['BrowserProxyFetchClashByURL'](arg1);
}
@@ -306,6 +314,14 @@ export function BrowserProxyListGroups() {
return window['go']['main']['App']['BrowserProxyListGroups']();
}
export function BrowserProxyWarmupBridge(arg1) {
return window['go']['main']['App']['BrowserProxyWarmupBridge'](arg1);
}
export function BrowserProxyWarmupBridgeWithConfig(arg1, arg2) {
return window['go']['main']['App']['BrowserProxyWarmupBridgeWithConfig'](arg1, arg2);
}
export function BrowserProxyTestSpeed(arg1) {
return window['go']['main']['App']['BrowserProxyTestSpeed'](arg1);
}
+203 -109
View File
@@ -1,15 +1,15 @@
export namespace automation {
export namespace automation {
export class ScriptPublicAPIVariable {
name: string;
defaultValue: string;
description: string;
required: boolean;
static createFrom(source: any = {}) {
return new ScriptPublicAPIVariable(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -28,11 +28,11 @@ export namespace automation {
requestBodyText: string;
responseBodyText: string;
variables: ScriptPublicAPIVariable[];
static createFrom(source: any = {}) {
return new ScriptPublicAPIConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
@@ -45,7 +45,7 @@ export namespace automation {
this.responseBodyText = source["responseBodyText"];
this.variables = this.convertValues(source["variables"], ScriptPublicAPIVariable);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -64,18 +64,18 @@ export namespace automation {
return a;
}
}
export class ScriptSource {
type: string;
uri: string;
ref: string;
path: string;
importedAt: string;
static createFrom(source: any = {}) {
return new ScriptSource(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
@@ -92,11 +92,11 @@ export namespace automation {
groupId: string;
keywords: string[];
tags: string[];
static createFrom(source: any = {}) {
return new ScriptTargetSelector(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
@@ -112,11 +112,11 @@ export namespace automation {
selector: ScriptTargetSelector;
templateSelector: ScriptTargetSelector;
createNameTemplate: string;
static createFrom(source: any = {}) {
return new ScriptTargetConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mode = source["mode"];
@@ -124,7 +124,7 @@ export namespace automation {
this.templateSelector = this.convertValues(source["templateSelector"], ScriptTargetSelector);
this.createNameTemplate = source["createNameTemplate"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -162,11 +162,11 @@ export namespace automation {
source: ScriptSource;
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new ScriptRecord(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.packageFormat = source["packageFormat"];
@@ -188,7 +188,7 @@ export namespace automation {
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -219,11 +219,11 @@ export namespace automation {
startedAt: string;
finishedAt: string;
durationMs: number;
static createFrom(source: any = {}) {
return new ScriptRunRecord(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -248,11 +248,11 @@ export namespace automation {
useScriptSelector: boolean;
useScriptParams: boolean;
timeoutMs?: number;
static createFrom(source: any = {}) {
return new ScriptRunRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.scriptId = source["scriptId"];
@@ -265,21 +265,21 @@ export namespace automation {
this.timeoutMs = source["timeoutMs"];
}
}
}
export namespace backend {
export class AutomationScriptImportIssue {
path: string;
message: string;
static createFrom(source: any = {}) {
return new AutomationScriptImportIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
@@ -290,18 +290,18 @@ export namespace backend {
imported: automation.ScriptRecord[];
failed: AutomationScriptImportIssue[];
scanned: number;
static createFrom(source: any = {}) {
return new AutomationScriptBatchImportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.imported = this.convertValues(source["imported"], automation.ScriptRecord);
this.failed = this.convertValues(source["failed"], AutomationScriptImportIssue);
this.scanned = source["scanned"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -320,7 +320,7 @@ export namespace backend {
return a;
}
}
export class AutomationScriptPublicAPIInvokeInput {
url: string;
method: string;
@@ -328,11 +328,11 @@ export namespace backend {
apiKey: string;
authHeader: string;
timeoutMs: number;
static createFrom(source: any = {}) {
return new AutomationScriptPublicAPIInvokeInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.url = source["url"];
@@ -349,11 +349,11 @@ export namespace backend {
statusText: string;
bodyText: string;
bodyJson: any;
static createFrom(source: any = {}) {
return new AutomationScriptPublicAPIInvokeResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ok = source["ok"];
@@ -370,11 +370,11 @@ export namespace backend {
failed: number;
skippedList: string[];
failedList: string[];
static createFrom(source: any = {}) {
return new BookmarkSyncResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
@@ -394,11 +394,11 @@ export namespace backend {
httpOnly: boolean;
secure: boolean;
sameSite: string;
static createFrom(source: any = {}) {
return new CookieInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -415,11 +415,11 @@ export namespace backend {
maxLimit: number;
usedCount: number;
usedKeys: string[];
static createFrom(source: any = {}) {
return new LicenseStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.maxLimit = source["maxLimit"];
@@ -427,6 +427,78 @@ export namespace backend {
this.usedKeys = source["usedKeys"];
}
}
export class ProxyLocationOption {
label: string;
timezone: string;
lang: string;
static createFrom(source: any = {}) {
return new ProxyLocationOption(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.timezone = source["timezone"];
this.lang = source["lang"];
}
}
export class ProxyLocationResolveResult {
proxyId: string;
ok: boolean;
auto: boolean;
source: string;
error: string;
ip: string;
country: string;
region: string;
city: string;
timezone: string;
lang: string;
health?: ProxyIPHealthResult;
alternates?: ProxyLocationOption[];
resolvedAt: string;
static createFrom(source: any = {}) {
return new ProxyLocationResolveResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.proxyId = source["proxyId"];
this.ok = source["ok"];
this.auto = source["auto"];
this.source = source["source"];
this.error = source["error"];
this.ip = source["ip"];
this.country = source["country"];
this.region = source["region"];
this.city = source["city"];
this.timezone = source["timezone"];
this.lang = source["lang"];
this.health = this.convertValues(source["health"], ProxyIPHealthResult);
this.alternates = this.convertValues(source["alternates"], ProxyLocationOption);
this.resolvedAt = source["resolvedAt"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProxyIPHealthResult {
proxyId: string;
ok: boolean;
@@ -442,11 +514,11 @@ export namespace backend {
asOrganization: string;
rawData: Record<string, any>;
updatedAt: string;
static createFrom(source: any = {}) {
return new ProxyIPHealthResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.proxyId = source["proxyId"];
@@ -470,11 +542,11 @@ export namespace backend {
ok: boolean;
latencyMs: number;
error: string;
static createFrom(source: any = {}) {
return new ProxyTestResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.proxyId = source["proxyId"];
@@ -483,14 +555,36 @@ export namespace backend {
this.error = source["error"];
}
}
export class ProxyBridgeWarmupResult {
proxyId: string;
ok: boolean;
engine: string;
socksUrl: string;
latencyMs: number;
error: string;
static createFrom(source: any = {}) {
return new ProxyBridgeWarmupResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.proxyId = source["proxyId"];
this.ok = source["ok"];
this.engine = source["engine"];
this.socksUrl = source["socksUrl"];
this.latencyMs = source["latencyMs"];
this.error = source["error"];
}
}
export class ProxyValidationResult {
supported: boolean;
errorMsg: string;
static createFrom(source: any = {}) {
return new ProxyValidationResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.supported = source["supported"];
@@ -504,11 +598,11 @@ export namespace backend {
sizeMB: number;
createdAt: string;
filePath?: string;
static createFrom(source: any = {}) {
return new SnapshotInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.snapshotId = source["snapshotId"];
@@ -523,7 +617,7 @@ export namespace backend {
}
export namespace backup {
export class ManifestEntry {
id: string;
category: string;
@@ -531,11 +625,11 @@ export namespace backup {
required: boolean;
archivePath: string;
description?: string;
static createFrom(source: any = {}) {
return new ManifestEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -549,11 +643,11 @@ export namespace backup {
export class ManifestAppInfo {
name: string;
version: string;
static createFrom(source: any = {}) {
return new ManifestAppInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -566,11 +660,11 @@ export namespace backup {
createdAt: string;
app: ManifestAppInfo;
entries: ManifestEntry[];
static createFrom(source: any = {}) {
return new Manifest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.format = source["format"];
@@ -579,7 +673,7 @@ export namespace backup {
this.app = this.convertValues(source["app"], ManifestAppInfo);
this.entries = this.convertValues(source["entries"], ManifestEntry);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -598,8 +692,8 @@ export namespace backup {
return a;
}
}
export class ScopeEntry {
id: string;
category: string;
@@ -609,11 +703,11 @@ export namespace backup {
archivePath: string;
exists: boolean;
description?: string;
static createFrom(source: any = {}) {
return new ScopeEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -631,11 +725,11 @@ export namespace backup {
manifestVersion: number;
appRoot: string;
entries: ScopeEntry[];
static createFrom(source: any = {}) {
return new Scope(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.format = source["format"];
@@ -643,7 +737,7 @@ export namespace backup {
this.appRoot = source["appRoot"];
this.entries = this.convertValues(source["entries"], ScopeEntry);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -666,16 +760,16 @@ export namespace backup {
}
export namespace browser {
export class CoreExtendedInfo {
coreId: string;
chromeVersion: string;
instanceCount: number;
static createFrom(source: any = {}) {
return new CoreExtendedInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.coreId = source["coreId"];
@@ -688,11 +782,11 @@ export namespace browser {
coreName: string;
corePath: string;
isDefault: boolean;
static createFrom(source: any = {}) {
return new CoreInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.coreId = source["coreId"];
@@ -704,11 +798,11 @@ export namespace browser {
export class CoreValidateResult {
valid: boolean;
message: string;
static createFrom(source: any = {}) {
return new CoreValidateResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.valid = source["valid"];
@@ -722,11 +816,11 @@ export namespace browser {
sortOrder: number;
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new Group(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.groupId = source["groupId"];
@@ -741,11 +835,11 @@ export namespace browser {
groupName: string;
parentId: string;
sortOrder: number;
static createFrom(source: any = {}) {
return new GroupInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.groupName = source["groupName"];
@@ -761,11 +855,11 @@ export namespace browser {
createdAt: string;
updatedAt: string;
instanceCount: number;
static createFrom(source: any = {}) {
return new GroupWithCount(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.groupId = source["groupId"];
@@ -804,11 +898,11 @@ export namespace browser {
updatedAt: string;
lastStartAt: string;
lastStopAt: string;
static createFrom(source: any = {}) {
return new Profile(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.profileId = source["profileId"];
@@ -842,11 +936,11 @@ export namespace browser {
export class ProfileCopyOptions {
mode: string;
automationTargets: string[];
static createFrom(source: any = {}) {
return new ProfileCopyOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mode = source["mode"];
@@ -864,11 +958,11 @@ export namespace browser {
tags: string[];
keywords: string[];
groupId: string;
static createFrom(source: any = {}) {
return new ProfileInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.profileName = source["profileName"];
@@ -892,11 +986,11 @@ export namespace browser {
restoreLastSession: boolean;
startReadyTimeoutMs: number;
startStableWindowMs: number;
static createFrom(source: any = {}) {
return new Settings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.userDataRoot = source["userDataRoot"];
@@ -914,11 +1008,11 @@ export namespace browser {
title: string;
url: string;
active: boolean;
static createFrom(source: any = {}) {
return new Tab(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.tabId = source["tabId"];
@@ -931,16 +1025,16 @@ export namespace browser {
}
export namespace config {
export class BrowserBookmark {
name: string;
url: string;
openOnStart: boolean;
static createFrom(source: any = {}) {
return new BrowserBookmark(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -953,11 +1047,11 @@ export namespace config {
coreName: string;
corePath: string;
isDefault: boolean;
static createFrom(source: any = {}) {
return new BrowserCore(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.coreId = source["coreId"];
@@ -983,11 +1077,11 @@ export namespace config {
lastTestOk: boolean;
lastTestedAt: string;
lastIPHealthJson?: string;
static createFrom(source: any = {}) {
return new BrowserProxy(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.proxyId = source["proxyId"];
@@ -1016,11 +1110,11 @@ export namespace config {
parser?: string;
timeoutMs?: number;
expectedStatus?: number[];
static createFrom(source: any = {}) {
return new ProxyCheckTarget(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -1037,11 +1131,11 @@ export namespace config {
speedTargetId: string;
ipHealthTargetId: string;
targets: ProxyCheckTarget[];
static createFrom(source: any = {}) {
return new ProxyCheckConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.bridgeStartTimeoutMs = source["bridgeStartTimeoutMs"];
@@ -1049,7 +1143,7 @@ export namespace config {
this.ipHealthTargetId = source["ipHealthTargetId"];
this.targets = this.convertValues(source["targets"], ProxyCheckTarget);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -1072,18 +1166,18 @@ export namespace config {
}
export namespace launchcode {
export class LaunchRequestParams {
launchArgs: string[];
startUrls: string[];
skipDefaultStartUrls: boolean;
proxyId: string;
proxyConfig: string;
static createFrom(source: any = {}) {
return new LaunchRequestParams(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.launchArgs = source["launchArgs"];
@@ -1097,18 +1191,18 @@ export namespace launchcode {
}
export namespace logger {
export class MemoryLogEntry {
time: string;
level: string;
component: string;
message: string;
fields?: Record<string, any>;
static createFrom(source: any = {}) {
return new MemoryLogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.time = source["time"];
@@ -1119,15 +1213,15 @@ export namespace logger {
}
}
export class MethodInterceptor {
static createFrom(source: any = {}) {
return new MethodInterceptor(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}