mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: improve proxy bridge warmup stability
This commit is contained in:
@@ -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"],
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 桥接进程意外退出"))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 桥接管理器
|
||||
|
||||
@@ -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 代理解析结果
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user