mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
V1.2.0 功能发布
This commit is contained in:
@@ -51,6 +51,15 @@ Ant Browser 适合以下场景:
|
||||
|
||||
## 近期更新
|
||||
|
||||
### 1.2.0 · 2026-05-09
|
||||
|
||||
- 重点升级接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力
|
||||
- 完善自动化接口链路:脚本执行支持 selector / params 覆盖和 `timeoutMs` 超时控制,双实例 runtime 流程支持超时取消与错误返回
|
||||
- 增强代理池:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入
|
||||
- 优化代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测
|
||||
- 改进实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点
|
||||
- 升级书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例
|
||||
|
||||
### 1.1.0 · 2026-03-19
|
||||
|
||||
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题
|
||||
|
||||
@@ -258,6 +258,30 @@ func backupSrcTableExists(tx *sql.Tx, table string) (bool, error) {
|
||||
return cnt > 0, nil
|
||||
}
|
||||
|
||||
func backupSrcColumnExists(tx *sql.Tx, table string, column string) (bool, error) {
|
||||
rows, err := tx.Query("PRAGMA src.table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name string
|
||||
var columnType string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.EqualFold(name, column) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func backupCountRows(tx *sql.Tx, tableName string) (int, error) {
|
||||
var cnt int
|
||||
row := tx.QueryRow("SELECT COUNT(1) FROM " + tableName)
|
||||
|
||||
@@ -195,6 +195,25 @@ WHERE NOT EXISTS (
|
||||
if !resetFirst {
|
||||
sqlText = item.insertSafe
|
||||
}
|
||||
if item.name == "browser_bookmarks" {
|
||||
hasOpenOnStart, err := backupSrcColumnExists(tx, item.name, "open_on_start")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasOpenOnStart {
|
||||
if resetFirst {
|
||||
sqlText = `INSERT INTO browser_bookmarks (name, url, open_on_start, sort_order)
|
||||
SELECT name, url, COALESCE(open_on_start,0), sort_order FROM src.browser_bookmarks`
|
||||
} else {
|
||||
sqlText = `INSERT INTO browser_bookmarks (name, url, open_on_start, sort_order)
|
||||
SELECT s.name, s.url, COALESCE(s.open_on_start,0), s.sort_order
|
||||
FROM src.browser_bookmarks s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM browser_bookmarks t WHERE lower(t.url) = lower(s.url)
|
||||
)`
|
||||
}
|
||||
}
|
||||
}
|
||||
res, err := tx.Exec(sqlText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("导入数据表失败(%s): %w", item.name, err)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestBackupMergeConfigDedup(t *testing.T) {
|
||||
{Name: "Google", URL: "https://www.google.com/"},
|
||||
}
|
||||
current.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://proxy.invalid:8080"},
|
||||
}
|
||||
current.Browser.Cores = []config.BrowserCore{
|
||||
{CoreId: "c1", CoreName: "C1", CorePath: "chrome/c1"},
|
||||
@@ -40,7 +40,7 @@ func TestBackupMergeConfigDedup(t *testing.T) {
|
||||
{Name: "ChatGPT", URL: "https://chatgpt.com/"},
|
||||
}
|
||||
incoming.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://proxy.invalid:8080"},
|
||||
{ProxyId: "p2", ProxyName: "P2", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
}
|
||||
incoming.Browser.Cores = []config.BrowserCore{
|
||||
|
||||
+92
-4
@@ -1,18 +1,38 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BrowserBookmark = config.BrowserBookmark
|
||||
|
||||
type BookmarkSyncResult struct {
|
||||
Total int `json:"total"`
|
||||
Synced int `json:"synced"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
SkippedList []string `json:"skippedList"`
|
||||
FailedList []string `json:"failedList"`
|
||||
}
|
||||
|
||||
var defaultBookmarkList = []BrowserBookmark{
|
||||
{Name: "Google", URL: "https://www.google.com/"},
|
||||
{Name: "Gmail", URL: "https://mail.google.com/"},
|
||||
{Name: "Claude", URL: "https://claude.ai/"},
|
||||
{Name: "ChatGPT", URL: "https://chatgpt.com/"},
|
||||
{Name: "YouTube", URL: "https://www.youtube.com/"},
|
||||
{Name: "IPPure", URL: "https://ippure.com/"},
|
||||
{Name: "IPLark", URL: "https://iplark.com/"},
|
||||
{Name: "Ping0", URL: "https://ping0.cc/"},
|
||||
}
|
||||
|
||||
var verificationBookmarkList = []BrowserBookmark{
|
||||
{Name: "IPPure", URL: "https://ippure.com/"},
|
||||
{Name: "IPLark", URL: "https://iplark.com/"},
|
||||
{Name: "Ping0", URL: "https://ping0.cc/"},
|
||||
}
|
||||
|
||||
// BookmarkList 获取默认书签列表(优先 SQLite,降级 config.yaml)
|
||||
@@ -20,11 +40,11 @@ func (a *App) BookmarkList() []BrowserBookmark {
|
||||
if a.browserMgr.BookmarkDAO != nil {
|
||||
list, err := a.browserMgr.BookmarkDAO.List()
|
||||
if err == nil && len(list) > 0 {
|
||||
return list
|
||||
return mergeBookmarksByURL(list, verificationBookmarkList)
|
||||
}
|
||||
}
|
||||
if len(a.config.Browser.DefaultBookmarks) > 0 {
|
||||
return append([]BrowserBookmark{}, a.config.Browser.DefaultBookmarks...)
|
||||
return mergeBookmarksByURL(a.config.Browser.DefaultBookmarks, verificationBookmarkList)
|
||||
}
|
||||
return append([]BrowserBookmark{}, defaultBookmarkList...)
|
||||
}
|
||||
@@ -34,10 +54,13 @@ func (a *App) BookmarkSave(items []BrowserBookmark) error {
|
||||
log := logger.New("Bookmark")
|
||||
valid := make([]BrowserBookmark, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Name != "" && item.URL != "" {
|
||||
valid = append(valid, item)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
url := strings.TrimSpace(item.URL)
|
||||
if name != "" && url != "" {
|
||||
valid = append(valid, BrowserBookmark{Name: name, URL: url, OpenOnStart: item.OpenOnStart})
|
||||
}
|
||||
}
|
||||
valid = mergeBookmarksByURL(valid, verificationBookmarkList)
|
||||
|
||||
if a.browserMgr.BookmarkDAO != nil {
|
||||
if err := a.browserMgr.BookmarkDAO.ReplaceAll(valid); err != nil {
|
||||
@@ -62,3 +85,68 @@ func (a *App) BookmarkSave(items []BrowserBookmark) error {
|
||||
func (a *App) BookmarkReset() error {
|
||||
return a.BookmarkSave(append([]BrowserBookmark{}, defaultBookmarkList...))
|
||||
}
|
||||
|
||||
func mergeBookmarksByURL(items []BrowserBookmark, required []BrowserBookmark) []BrowserBookmark {
|
||||
merged := make([]BrowserBookmark, 0, len(items)+len(required))
|
||||
seen := make(map[string]struct{}, len(items)+len(required))
|
||||
appendOne := func(item BrowserBookmark) {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
url := strings.TrimSpace(item.URL)
|
||||
if name == "" || url == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(url)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
merged = append(merged, BrowserBookmark{Name: name, URL: url, OpenOnStart: item.OpenOnStart})
|
||||
}
|
||||
for _, item := range items {
|
||||
appendOne(item)
|
||||
}
|
||||
for _, item := range required {
|
||||
appendOne(item)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// BookmarkSyncToProfiles 将当前默认书签增量同步到已有未运行实例。
|
||||
func (a *App) BookmarkSyncToProfiles() BookmarkSyncResult {
|
||||
result := BookmarkSyncResult{}
|
||||
log := logger.New("Bookmark")
|
||||
bookmarks := a.BookmarkList()
|
||||
if len(bookmarks) == 0 || a.browserMgr == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
a.browserMgr.InitData()
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
result.Total = len(a.browserMgr.Profiles)
|
||||
for _, profile := range a.browserMgr.Profiles {
|
||||
if profile == nil {
|
||||
continue
|
||||
}
|
||||
if isBrowserProfileLive(profile, a.browserMgr.BrowserProcesses[profile.ProfileId]) {
|
||||
result.Skipped++
|
||||
result.SkippedList = append(result.SkippedList, profile.ProfileName)
|
||||
continue
|
||||
}
|
||||
|
||||
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
|
||||
if err := browser.EnsureDefaultBookmarks(userDataDir, bookmarks); err != nil {
|
||||
result.Failed++
|
||||
name := profile.ProfileName
|
||||
if name == "" {
|
||||
name = profile.ProfileId
|
||||
}
|
||||
result.FailedList = append(result.FailedList, name)
|
||||
log.Error("同步默认书签到实例失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err.Error()))
|
||||
continue
|
||||
}
|
||||
result.Synced++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
internalbrowser "ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/config"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBookmarkSyncToProfilesAppliesCurrentDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appRoot := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.UserDataRoot = "data"
|
||||
cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{
|
||||
{Name: "默认书签", URL: "https://default.example/"},
|
||||
}
|
||||
|
||||
app := NewApp(appRoot)
|
||||
app.config = cfg
|
||||
app.browserMgr = internalbrowser.NewManager(cfg, appRoot)
|
||||
app.browserMgr.Profiles["profile-1"] = &internalbrowser.Profile{
|
||||
ProfileId: "profile-1",
|
||||
ProfileName: "实例 1",
|
||||
UserDataDir: "profile-1",
|
||||
}
|
||||
|
||||
result := app.BookmarkSyncToProfiles()
|
||||
if result.Total != 1 || result.Synced != 1 || result.Skipped != 0 || result.Failed != 0 {
|
||||
t.Fatalf("unexpected sync result: %+v", result)
|
||||
}
|
||||
|
||||
bookmarksPath := filepath.Join(appRoot, "data", "profile-1", "Default", "Bookmarks")
|
||||
data, err := os.ReadFile(bookmarksPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read bookmarks: %v", err)
|
||||
}
|
||||
var root map[string]interface{}
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatalf("unmarshal bookmarks: %v", err)
|
||||
}
|
||||
if countBookmarkURLInRoot(root, "https://default.example/") != 1 {
|
||||
t.Fatalf("default bookmark was not synced once: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookmarkListAlwaysIncludesVerificationBookmarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appRoot := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{
|
||||
{Name: "用户默认书签", URL: "https://user.example/"},
|
||||
}
|
||||
|
||||
app := NewApp(appRoot)
|
||||
app.config = cfg
|
||||
app.browserMgr = internalbrowser.NewManager(cfg, appRoot)
|
||||
|
||||
bookmarks := app.BookmarkList()
|
||||
for _, url := range []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} {
|
||||
if countBookmarkItemsByURL(bookmarks, url) != 1 {
|
||||
t.Fatalf("expected verification bookmark %s exactly once, got %+v", url, bookmarks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookmarkSavePersistsVerificationBookmarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appRoot := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
app := NewApp(appRoot)
|
||||
app.config = cfg
|
||||
app.browserMgr = internalbrowser.NewManager(cfg, appRoot)
|
||||
|
||||
if err := app.BookmarkSave([]config.BrowserBookmark{{Name: "用户默认书签", URL: "https://user.example/", OpenOnStart: true}}); err != nil {
|
||||
t.Fatalf("BookmarkSave returned error: %v", err)
|
||||
}
|
||||
if item, ok := findBookmarkItemByURL(app.config.Browser.DefaultBookmarks, "https://user.example/"); !ok || !item.OpenOnStart {
|
||||
t.Fatalf("expected open_on_start to be preserved, got %+v", app.config.Browser.DefaultBookmarks)
|
||||
}
|
||||
for _, url := range []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} {
|
||||
if countBookmarkItemsByURL(app.config.Browser.DefaultBookmarks, url) != 1 {
|
||||
t.Fatalf("expected saved verification bookmark %s exactly once, got %+v", url, app.config.Browser.DefaultBookmarks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDefaultStartURLsIncludesOpenOnStartBookmarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appRoot := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.DefaultStartURLs = []string{"https://home.example/"}
|
||||
cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{
|
||||
{Name: "启动打开", URL: "https://open.example/", OpenOnStart: true},
|
||||
{Name: "普通书签", URL: "https://closed.example/"},
|
||||
{Name: "重复启动页", URL: "https://home.example/", OpenOnStart: true},
|
||||
}
|
||||
|
||||
app := NewApp(appRoot)
|
||||
app.config = cfg
|
||||
app.browserMgr = internalbrowser.NewManager(cfg, appRoot)
|
||||
|
||||
got := app.browserDefaultStartURLs()
|
||||
want := []string{"https://home.example/", "https://open.example/"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("default start urls mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func countBookmarkURLInRoot(root map[string]interface{}, url string) int {
|
||||
count := 0
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return count
|
||||
}
|
||||
for _, item := range roots {
|
||||
folder, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if children, ok := folder["children"].([]interface{}); ok {
|
||||
count += countBookmarkURLInNodes(children, url)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countBookmarkURLInNodes(nodes []interface{}, url string) int {
|
||||
count := 0
|
||||
for _, item := range nodes {
|
||||
node, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if node["type"] == "url" && node["url"] == url {
|
||||
count++
|
||||
}
|
||||
if children, ok := node["children"].([]interface{}); ok {
|
||||
count += countBookmarkURLInNodes(children, url)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func findBookmarkItemByURL(items []config.BrowserBookmark, url string) (config.BrowserBookmark, bool) {
|
||||
for _, item := range items {
|
||||
if item.URL == url {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return config.BrowserBookmark{}, false
|
||||
}
|
||||
|
||||
func countBookmarkItemsByURL(items []config.BrowserBookmark, url string) int {
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if item.URL == url {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -78,7 +78,6 @@ func (a *App) migrateToSQLite() {
|
||||
} else {
|
||||
srcProxies = []browser.Proxy{
|
||||
{ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
|
||||
{ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
log.Info("代理表为空,初始化默认代理")
|
||||
}
|
||||
|
||||
@@ -58,6 +58,39 @@ func browserDefaultStartURLs(cfg *config.Config) []string {
|
||||
return config.DefaultBrowserStartURLs()
|
||||
}
|
||||
|
||||
func (a *App) browserDefaultStartURLs() []string {
|
||||
return mergeStartURLs(browserDefaultStartURLs(a.config), bookmarkStartURLs(a.BookmarkList()))
|
||||
}
|
||||
|
||||
func bookmarkStartURLs(bookmarks []BrowserBookmark) []string {
|
||||
if len(bookmarks) == 0 {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(bookmarks))
|
||||
for _, bookmark := range bookmarks {
|
||||
if bookmark.OpenOnStart {
|
||||
urls = append(urls, bookmark.URL)
|
||||
}
|
||||
}
|
||||
return normalizeNonEmptyStrings(urls)
|
||||
}
|
||||
|
||||
func mergeStartURLs(groups ...[]string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
for _, group := range groups {
|
||||
for _, item := range normalizeNonEmptyStrings(group) {
|
||||
key := strings.ToLower(item)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func browserRestoreLastSession(cfg *config.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
package backend
|
||||
|
||||
func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) {
|
||||
return a.browserInstanceStartInternal(profileId, nil, nil, false, false)
|
||||
return a.browserInstanceStartInternal(profileId, nil, nil, false, false, false)
|
||||
}
|
||||
|
||||
func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool {
|
||||
return len(normalizeNonEmptyStrings(startURLs)) > 0
|
||||
}
|
||||
|
||||
// BrowserInstanceStartDirect 仅本次启动走直连,不落库修改实例代理配置。
|
||||
func (a *App) BrowserInstanceStartDirect(profileId string) (*BrowserProfile, error) {
|
||||
return a.browserInstanceStartInternal(profileId, nil, nil, false, false, true)
|
||||
}
|
||||
|
||||
// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库)
|
||||
func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) {
|
||||
preferVisibleWindow := shouldPreferVisibleWindowForStartWithParams(startURLs)
|
||||
return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow)
|
||||
return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, false)
|
||||
}
|
||||
|
||||
func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) (*BrowserProfile, error) {
|
||||
input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow)
|
||||
func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) (*BrowserProfile, error) {
|
||||
input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, forceDirectProxy)
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type browserStartInput struct {
|
||||
StartURLs []string
|
||||
SkipDefaultStartURLs bool
|
||||
PreferVisibleWindow bool
|
||||
ForceDirectProxy bool
|
||||
}
|
||||
|
||||
type browserStartPlan struct {
|
||||
@@ -33,7 +34,7 @@ type browserStartPlan struct {
|
||||
totalReadyTimeout time.Duration
|
||||
}
|
||||
|
||||
func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) browserStartInput {
|
||||
func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) browserStartInput {
|
||||
normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs)
|
||||
if preferVisibleWindow {
|
||||
normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs)
|
||||
@@ -45,6 +46,7 @@ func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs
|
||||
StartURLs: normalizeNonEmptyStrings(startURLs),
|
||||
SkipDefaultStartURLs: skipDefaultStartURLs,
|
||||
PreferVisibleWindow: preferVisibleWindow,
|
||||
ForceDirectProxy: forceDirectProxy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +110,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
|
||||
return nil, err
|
||||
}
|
||||
|
||||
effectiveProxy, acquiredXrayBridgeKey, releaseXrayBridge, err := a.resolveBrowserStartProxy(input.ProfileID, profile)
|
||||
effectiveProxy, acquiredXrayBridgeKey, releaseXrayBridge, err := a.resolveBrowserStartProxy(input, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -133,7 +135,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
|
||||
profile: profile,
|
||||
chromeBinaryPath: chromeBinaryPath,
|
||||
userDataDir: userDataDir,
|
||||
args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, browserDefaultStartURLs(a.config), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)),
|
||||
args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, a.browserDefaultStartURLs(), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)),
|
||||
effectiveProxy: effectiveProxy,
|
||||
acquiredXrayBridgeKey: acquiredXrayBridgeKey,
|
||||
releaseXrayBridge: releaseXrayBridge,
|
||||
|
||||
@@ -5,13 +5,12 @@ import (
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile) (string, string, bool, error) {
|
||||
func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *BrowserProfile) (string, string, bool, error) {
|
||||
log := logger.New("Browser")
|
||||
proxies := a.getLatestProxies()
|
||||
profileID := input.ProfileID
|
||||
|
||||
resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig)
|
||||
if profile.ProxyId != "" {
|
||||
@@ -29,6 +28,13 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile
|
||||
logger.F("profile_proxy_config", profile.ProxyConfig),
|
||||
logger.F("resolved_proxy_config", resolvedProxyConfig),
|
||||
)
|
||||
if input.ForceDirectProxy {
|
||||
log.Warn("按请求直连启动实例",
|
||||
logger.F("profile_id", profileID),
|
||||
logger.F("proxy_id", profile.ProxyId),
|
||||
)
|
||||
return "direct://", "", false, nil
|
||||
}
|
||||
if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported {
|
||||
startErr := fmt.Errorf("实例启动失败:%s", errorMsg)
|
||||
profile.LastError = startErr.Error()
|
||||
@@ -50,14 +56,13 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile
|
||||
logger.F("reason", startErr.Error()),
|
||||
)
|
||||
profile.LastError = startErr.Error()
|
||||
a.emitBrowserStartBridgeFailure(profileID, profile.ProfileName, startErr.Error())
|
||||
return "", "", false, startErr
|
||||
}
|
||||
log.Info("sing-box 桥接成功", logger.F("socks_url", socksURL))
|
||||
return socksURL, "", false, nil
|
||||
}
|
||||
|
||||
if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) {
|
||||
if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) {
|
||||
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId)
|
||||
if bridgeErr != nil {
|
||||
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
|
||||
@@ -66,7 +71,6 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile
|
||||
logger.F("reason", startErr.Error()),
|
||||
)
|
||||
profile.LastError = startErr.Error()
|
||||
a.emitBrowserStartBridgeFailure(profileID, profile.ProfileName, startErr.Error())
|
||||
return "", "", false, startErr
|
||||
}
|
||||
log.Info("xray 桥接成功", logger.F("socks_url", socksURL))
|
||||
@@ -75,14 +79,3 @@ func (a *App) resolveBrowserStartProxy(profileID string, profile *BrowserProfile
|
||||
|
||||
return resolvedProxyConfig, "", false, nil
|
||||
}
|
||||
|
||||
func (a *App) emitBrowserStartBridgeFailure(profileID string, profileName string, errorText string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{
|
||||
"profileId": profileID,
|
||||
"profileName": profileName,
|
||||
"error": errorText,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
)
|
||||
|
||||
type ProxyCheckSettings = config.ProxyCheckConfig
|
||||
type ProxyCheckTarget = config.ProxyCheckTarget
|
||||
|
||||
func (a *App) GetProxyCheckSettings() ProxyCheckSettings {
|
||||
if a.config == nil {
|
||||
return config.DefaultConfig().ProxyCheck
|
||||
}
|
||||
settings := a.config.ProxyCheck
|
||||
settings.Targets = append([]config.ProxyCheckTarget{}, settings.Targets...)
|
||||
return settings
|
||||
}
|
||||
|
||||
func (a *App) SaveProxyCheckSettings(settings ProxyCheckSettings) error {
|
||||
if a.config == nil {
|
||||
return nil
|
||||
}
|
||||
settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, 15000)
|
||||
settings.SpeedTargetID = strings.TrimSpace(settings.SpeedTargetID)
|
||||
settings.IPHealthTargetID = strings.TrimSpace(settings.IPHealthTargetID)
|
||||
settings.Targets = normalizeProxyCheckTargets(settings.Targets)
|
||||
if len(settings.Targets) == 0 {
|
||||
settings.Targets = config.DefaultConfig().ProxyCheck.Targets
|
||||
}
|
||||
if settings.SpeedTargetID == "" {
|
||||
settings.SpeedTargetID = firstProxyCheckTargetID(settings.Targets, "speed", "")
|
||||
}
|
||||
if settings.IPHealthTargetID == "" {
|
||||
settings.IPHealthTargetID = firstProxyCheckTargetID(settings.Targets, "ip_health", "")
|
||||
}
|
||||
a.config.ProxyCheck = settings
|
||||
return a.config.Save(a.resolveAppPath("config.yaml"))
|
||||
}
|
||||
|
||||
func (a *App) proxySpeedTestConfig() *proxy.SpeedTestConfig {
|
||||
cfg := proxy.DefaultSpeedTestConfig
|
||||
if a == nil || a.config == nil {
|
||||
return &cfg
|
||||
}
|
||||
target := a.proxyCheckTarget(a.config.ProxyCheck.SpeedTargetID, "speed")
|
||||
if strings.TrimSpace(target.URL) != "" {
|
||||
cfg.URLs = []string{strings.TrimSpace(target.URL)}
|
||||
}
|
||||
if target.TimeoutMs > 0 {
|
||||
cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
|
||||
}
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func (a *App) proxyIPHealthConfig() *proxy.IPHealthConfig {
|
||||
cfg := &proxy.IPHealthConfig{Source: "ip_health"}
|
||||
if a == nil || a.config == nil {
|
||||
return cfg
|
||||
}
|
||||
target := a.proxyCheckTarget(a.config.ProxyCheck.IPHealthTargetID, "ip_health")
|
||||
if strings.TrimSpace(target.URL) != "" {
|
||||
cfg.URL = strings.TrimSpace(target.URL)
|
||||
}
|
||||
if strings.TrimSpace(target.ID) != "" {
|
||||
cfg.Source = strings.TrimSpace(target.ID)
|
||||
}
|
||||
if strings.TrimSpace(target.Parser) != "" {
|
||||
cfg.Parser = strings.TrimSpace(target.Parser)
|
||||
}
|
||||
if target.TimeoutMs > 0 {
|
||||
cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *App) proxyCheckTarget(id string, targetType string) config.ProxyCheckTarget {
|
||||
if a == nil || a.config == nil {
|
||||
return config.ProxyCheckTarget{}
|
||||
}
|
||||
normalizedID := strings.TrimSpace(id)
|
||||
normalizedType := strings.TrimSpace(targetType)
|
||||
for _, target := range a.config.ProxyCheck.Targets {
|
||||
if normalizedID != "" && strings.EqualFold(strings.TrimSpace(target.ID), normalizedID) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
for _, target := range a.config.ProxyCheck.Targets {
|
||||
if normalizedType != "" && strings.EqualFold(strings.TrimSpace(target.Type), normalizedType) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return config.ProxyCheckTarget{}
|
||||
}
|
||||
|
||||
func normalizeProxyCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyCheckTarget {
|
||||
result := make([]config.ProxyCheckTarget, 0, len(targets))
|
||||
seen := map[string]struct{}{}
|
||||
for _, target := range targets {
|
||||
target.ID = strings.TrimSpace(target.ID)
|
||||
target.Name = strings.TrimSpace(target.Name)
|
||||
target.Type = strings.TrimSpace(target.Type)
|
||||
target.URL = strings.TrimSpace(target.URL)
|
||||
target.Parser = strings.TrimSpace(target.Parser)
|
||||
if target.ID == "" || target.URL == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(target.ID)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if target.Name == "" {
|
||||
target.Name = target.ID
|
||||
}
|
||||
if target.Type == "" {
|
||||
target.Type = "speed"
|
||||
}
|
||||
if target.TimeoutMs <= 0 {
|
||||
target.TimeoutMs = 10000
|
||||
}
|
||||
result = append(result, target)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstProxyCheckTargetID(targets []config.ProxyCheckTarget, targetType string, fallback string) string {
|
||||
for _, target := range targets {
|
||||
if strings.EqualFold(strings.TrimSpace(target.Type), targetType) {
|
||||
return strings.TrimSpace(target.ID)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func normalizePositiveInt(value int, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
+21
-12
@@ -15,7 +15,7 @@ import (
|
||||
// BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果
|
||||
func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult {
|
||||
proxies := a.getLatestProxies()
|
||||
result := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil)
|
||||
result := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig())
|
||||
if a.browserMgr.ProxyDAO != nil {
|
||||
testedAt := time.Now().Format(time.RFC3339)
|
||||
_ = a.browserMgr.ProxyDAO.UpdateSpeedResult(proxyId, result.Ok, result.LatencyMs, testedAt)
|
||||
@@ -49,7 +49,7 @@ func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []P
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for job := range jobs {
|
||||
result := proxy.SpeedTest(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, nil)
|
||||
result := proxy.SpeedTest(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig())
|
||||
if a.browserMgr.ProxyDAO != nil {
|
||||
testedAt := time.Now().Format(time.RFC3339)
|
||||
_ = a.browserMgr.ProxyDAO.UpdateSpeedResult(job.ProxyId, result.Ok, result.LatencyMs, testedAt)
|
||||
@@ -73,10 +73,10 @@ func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []P
|
||||
return results
|
||||
}
|
||||
|
||||
// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息(通过 IPPure 接口)
|
||||
// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息
|
||||
func (a *App) BrowserProxyCheckIPHealth(proxyId string) ProxyIPHealthResult {
|
||||
proxies := a.getLatestProxies()
|
||||
data, err := proxy.FetchIPPureInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr)
|
||||
data, err := proxy.FetchIPHealthInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxyIPHealthConfig())
|
||||
result := buildProxyIPHealthResult(proxyId, data, err)
|
||||
a.persistProxyIPHealthResult(result)
|
||||
if a.ctx != nil {
|
||||
@@ -111,7 +111,7 @@ func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for job := range jobs {
|
||||
data, err := proxy.FetchIPPureInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr)
|
||||
data, err := proxy.FetchIPHealthInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxyIPHealthConfig())
|
||||
result := buildProxyIPHealthResult(job.ProxyId, data, err)
|
||||
a.persistProxyIPHealthResult(result)
|
||||
results[job.Idx] = result
|
||||
@@ -132,25 +132,26 @@ func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int)
|
||||
}
|
||||
|
||||
func buildProxyIPHealthResult(proxyId string, data map[string]interface{}, err error) ProxyIPHealthResult {
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
data["error"] = err.Error()
|
||||
return ProxyIPHealthResult{
|
||||
ProxyId: proxyId,
|
||||
Ok: false,
|
||||
Source: "ippure",
|
||||
Source: mapStringDefault(data, "_source", "ip_health"),
|
||||
Error: err.Error(),
|
||||
RawData: map[string]interface{}{},
|
||||
RawData: data,
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
|
||||
return ProxyIPHealthResult{
|
||||
ProxyId: proxyId,
|
||||
Ok: true,
|
||||
Source: "ippure",
|
||||
Source: mapStringDefault(data, "_source", "ip_health"),
|
||||
Error: "",
|
||||
IP: mapString(data, "ip"),
|
||||
FraudScore: mapInt64(data, "fraudScore"),
|
||||
@@ -165,6 +166,14 @@ func buildProxyIPHealthResult(proxyId string, data map[string]interface{}, err e
|
||||
}
|
||||
}
|
||||
|
||||
func mapStringDefault(data map[string]interface{}, key string, fallback string) string {
|
||||
value := strings.TrimSpace(mapString(data, key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (a *App) persistProxyIPHealthResult(result ProxyIPHealthResult) {
|
||||
if a.browserMgr.ProxyDAO == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildProxyIPHealthResultPreservesErrorSourceMetadata(t *testing.T) {
|
||||
result := buildProxyIPHealthResult("proxy-1", map[string]interface{}{
|
||||
"_source": "trace",
|
||||
"_targetUrl": "https://example.invalid/trace",
|
||||
"_parser": "cloudflare_trace",
|
||||
}, errors.New("request failed"))
|
||||
|
||||
if result.Source != "trace" {
|
||||
t.Fatalf("source = %q, want trace", result.Source)
|
||||
}
|
||||
if result.Error != "request failed" {
|
||||
t.Fatalf("error = %q, want request failed", result.Error)
|
||||
}
|
||||
rawError, _ := result.RawData["error"].(string)
|
||||
if rawError != "request failed" {
|
||||
t.Fatalf("raw error = %q, want request failed", rawError)
|
||||
}
|
||||
if got, _ := result.RawData["_targetUrl"].(string); got != "https://example.invalid/trace" {
|
||||
t.Fatalf("target url = %q, want trace url", got)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,6 @@ func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error {
|
||||
|
||||
builtins := []BrowserProxy{
|
||||
{ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
|
||||
{ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
for _, builtin := range builtins {
|
||||
found := false
|
||||
|
||||
@@ -179,7 +179,6 @@ func (a *App) loadProxies() {
|
||||
|
||||
builtins := []browser.Proxy{
|
||||
{ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
|
||||
{ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
ensureBuiltins := func(list []browser.Proxy) []browser.Proxy {
|
||||
|
||||
@@ -116,13 +116,20 @@ func (a *App) AutomationDemoDeleteProfile(profileId string) (map[string]interfac
|
||||
}
|
||||
|
||||
func (a *App) automationDemoRequest(method string, apiPath string, body any) (int, map[string]interface{}, error) {
|
||||
return a.automationDemoRequestWithContext(context.Background(), method, apiPath, body)
|
||||
}
|
||||
|
||||
func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
baseURL, authHeader, authValue, err := a.automationDemoEndpoint()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
requestURL := strings.TrimRight(baseURL, "/") + apiPath
|
||||
ctx, cancel := context.WithTimeout(context.Background(), automationDemoTimeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, automationDemoTimeout)
|
||||
defer cancel()
|
||||
|
||||
var reader io.Reader
|
||||
@@ -147,6 +154,9 @@ func (a *App) automationDemoRequest(method string, apiPath string, body any) (in
|
||||
|
||||
resp, err := (&http.Client{Timeout: automationDemoTimeout}).Do(req)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return 0, nil, fmt.Errorf("call launch api failed: %w", ctxErr)
|
||||
}
|
||||
return 0, nil, fmt.Errorf("call launch api failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -9,6 +10,12 @@ import (
|
||||
"ant-chrome/backend/internal/automation"
|
||||
)
|
||||
|
||||
const (
|
||||
automationScriptRunDefaultTimeout = 5 * time.Minute
|
||||
automationScriptRunMinTimeout = 1 * time.Second
|
||||
automationScriptRunMaxTimeout = 30 * time.Minute
|
||||
)
|
||||
|
||||
func (a *App) automationScriptRunStore() *automation.ScriptRunStore {
|
||||
return automation.NewScriptRunStore(a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "runs"))))
|
||||
}
|
||||
@@ -43,9 +50,16 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest)
|
||||
run.ScriptName = script.Name
|
||||
run.ScriptType = script.Type
|
||||
|
||||
runCtx := a.ctx
|
||||
if runCtx == nil {
|
||||
runCtx = context.Background()
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(runCtx, automationScriptRunTimeout(input))
|
||||
defer cancel()
|
||||
|
||||
switch script.Type {
|
||||
case "launch-api":
|
||||
resultText, summary, errText := a.runLaunchAPIScript(script, input)
|
||||
resultText, summary, errText := a.runLaunchAPIScript(runCtx, script, input)
|
||||
run.ResultText = resultText
|
||||
run.Summary = summary
|
||||
run.Error = errText
|
||||
@@ -53,7 +67,7 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest)
|
||||
run.Status = "success"
|
||||
}
|
||||
case "playwright-cdp":
|
||||
resultText, summary, errText := a.runPlaywrightScript(script, input)
|
||||
resultText, summary, errText := a.runPlaywrightScript(runCtx, script, input)
|
||||
run.ResultText = resultText
|
||||
run.Summary = summary
|
||||
run.Error = errText
|
||||
@@ -68,6 +82,30 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest)
|
||||
return a.finalizeAutomationScriptRun(run, startedAt)
|
||||
}
|
||||
|
||||
func automationScriptRunTimeout(input automation.ScriptRunRequest) time.Duration {
|
||||
if input.TimeoutMs <= 0 {
|
||||
return automationScriptRunDefaultTimeout
|
||||
}
|
||||
timeout := time.Duration(input.TimeoutMs) * time.Millisecond
|
||||
if timeout < automationScriptRunMinTimeout {
|
||||
return automationScriptRunMinTimeout
|
||||
}
|
||||
if timeout > automationScriptRunMaxTimeout {
|
||||
return automationScriptRunMaxTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func automationRunContextErrorMessage(err error) string {
|
||||
if err == context.DeadlineExceeded {
|
||||
return "自动化任务超时,已终止"
|
||||
}
|
||||
if err == context.Canceled {
|
||||
return "自动化任务已取消"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (a *App) finalizeAutomationScriptRun(run automation.ScriptRunRecord, startedAt time.Time) (*automation.ScriptRunRecord, error) {
|
||||
run.FinishedAt = time.Now().Format(time.RFC3339)
|
||||
run.DurationMs = time.Since(startedAt).Milliseconds()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -34,10 +35,16 @@ type dualInstanceRuntimeBrowser struct {
|
||||
LaunchArgs []string
|
||||
}
|
||||
|
||||
func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) {
|
||||
func (a *App) runLaunchAPIScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "脚本执行失败", automationRunContextErrorMessage(err)
|
||||
}
|
||||
paramsText := resolveAutomationRunJSONText(input.ParamsText, script.ParamsText, input.UseScriptParams)
|
||||
if script.ID == automation.DualInstanceRuntimeScriptID {
|
||||
return a.runDualInstanceRuntimeLaunchAPIScript(paramsText)
|
||||
return a.runDualInstanceRuntimeLaunchAPIScript(ctx, paramsText)
|
||||
}
|
||||
|
||||
selector, targetSummary, err := a.resolveAutomationEffectiveSelector(script, input, true)
|
||||
@@ -55,8 +62,11 @@ func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automatio
|
||||
body[key] = value
|
||||
}
|
||||
|
||||
status, payload, reqErr := a.automationDemoRequest(http.MethodPost, automationDemoLaunchPath, body)
|
||||
status, payload, reqErr := a.automationDemoRequestWithContext(ctx, http.MethodPost, automationDemoLaunchPath, body)
|
||||
if reqErr != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "Launch API 请求失败", automationRunContextErrorMessage(err)
|
||||
}
|
||||
return "", "Launch API 请求失败", reqErr.Error()
|
||||
}
|
||||
|
||||
@@ -80,7 +90,10 @@ func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automatio
|
||||
return responseText, summary, errorText
|
||||
}
|
||||
|
||||
func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, string, string) {
|
||||
func (a *App) runDualInstanceRuntimeLaunchAPIScript(ctx context.Context, paramsText string) (string, string, string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
browsers, timeoutMs, err := parseDualInstanceRuntimeParams(paramsText)
|
||||
if err != nil {
|
||||
return "", "脚本执行失败", err.Error()
|
||||
@@ -90,7 +103,16 @@ func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string,
|
||||
browserCodes := make([]string, 0, len(browsers))
|
||||
|
||||
for _, browser := range browsers {
|
||||
sessionStatus, sessionPayload, reqErr := a.automationDemoRequest(
|
||||
if err := ctx.Err(); err != nil {
|
||||
return buildDualInstanceRuntimeFailureResult(
|
||||
sessions,
|
||||
browserCodes,
|
||||
"双实例流程超时",
|
||||
automationRunContextErrorMessage(err),
|
||||
)
|
||||
}
|
||||
sessionStatus, sessionPayload, reqErr := a.automationDemoRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
automationDemoRuntimeSessionPath,
|
||||
map[string]any{
|
||||
@@ -107,6 +129,14 @@ func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string,
|
||||
sessionPayload = ensureAutomationPayload(sessionPayload, browser.Code)
|
||||
sessions = append(sessions, sessionPayload)
|
||||
if reqErr != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return buildDualInstanceRuntimeFailureResult(
|
||||
sessions,
|
||||
browserCodes,
|
||||
"双实例流程超时",
|
||||
automationRunContextErrorMessage(err),
|
||||
)
|
||||
}
|
||||
return buildDualInstanceRuntimeFailureResult(
|
||||
sessions,
|
||||
browserCodes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -28,16 +29,25 @@ func (a *App) ensurePlaywrightTargetReady(selector map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) {
|
||||
func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if a.automationMgr == nil {
|
||||
return "", "脚本执行失败", "automation runtime manager is not initialized"
|
||||
}
|
||||
if a.config == nil || !a.config.Automation.Enabled {
|
||||
return "", "脚本执行失败", "自动化支持尚未启用"
|
||||
}
|
||||
if err := a.automationMgr.EnsureInstalled(a.ctx); err != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "脚本执行失败", automationRunContextErrorMessage(err)
|
||||
}
|
||||
if err := a.automationMgr.EnsureInstalled(ctx); err != nil {
|
||||
return "", "脚本执行失败", err.Error()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "脚本执行失败", automationRunContextErrorMessage(err)
|
||||
}
|
||||
|
||||
state := a.automationMgr.CurrentState()
|
||||
if !state.Ready {
|
||||
@@ -53,6 +63,9 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati
|
||||
if err := a.ensurePlaywrightTargetReady(selector); err != nil {
|
||||
return "", "脚本执行失败", err.Error()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "脚本执行失败", automationRunContextErrorMessage(err)
|
||||
}
|
||||
params, err := parseAutomationJSONObject(paramsText, false)
|
||||
if err != nil {
|
||||
return "", "脚本执行失败", err.Error()
|
||||
@@ -68,8 +81,11 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati
|
||||
return "", "脚本执行失败", err.Error()
|
||||
}
|
||||
defer cleanup()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "脚本执行失败", automationRunContextErrorMessage(err)
|
||||
}
|
||||
|
||||
taskResult, err := a.automationMgr.RunScriptTask(a.ctx, automation.ScriptTaskRequest{
|
||||
taskResult, err := a.automationMgr.RunScriptTask(ctx, automation.ScriptTaskRequest{
|
||||
TaskKey: "script:" + script.ID,
|
||||
ScriptPath: scriptPath,
|
||||
Selector: selector,
|
||||
@@ -78,6 +94,7 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati
|
||||
LaunchAuthHeader: authHeader,
|
||||
LaunchAuthValue: authValue,
|
||||
ArtifactDir: artifactDir,
|
||||
Timeout: automationScriptRunTimeout(input),
|
||||
})
|
||||
if err != nil {
|
||||
return "", "脚本执行失败", err.Error()
|
||||
|
||||
@@ -32,6 +32,7 @@ type ScriptRunRequest struct {
|
||||
ParamsText string `json:"paramsText"`
|
||||
UseScriptSelector bool `json:"useScriptSelector"`
|
||||
UseScriptParams bool `json:"useScriptParams"`
|
||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
type ScriptRunStore struct {
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
|
||||
package automation
|
||||
|
||||
import "os/exec"
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
}
|
||||
|
||||
func prepareTaskCommand(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,7 @@ import (
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
|
||||
func prepareTaskCommand(cmd *exec.Cmd) {
|
||||
hideWindow(cmd)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timeoutLimit := req.Timeout
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
} else if deadline, ok := ctx.Deadline(); ok {
|
||||
timeoutLimit = time.Until(deadline)
|
||||
}
|
||||
|
||||
state := m.CurrentState()
|
||||
if !state.Ready {
|
||||
@@ -58,6 +66,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr
|
||||
payload,
|
||||
"自动化 script task 已启动",
|
||||
"自动化 script task 已完成",
|
||||
timeoutLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return ScriptTaskResult{}, err
|
||||
@@ -87,7 +96,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string) (string, taskRunnerResponse, string, int64, error) {
|
||||
func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string, timeoutLimit time.Duration) (string, taskRunnerResponse, string, int64, error) {
|
||||
taskID, err := m.registerTask(taskKey)
|
||||
if err != nil {
|
||||
return "", taskRunnerResponse{}, "", 0, err
|
||||
@@ -103,7 +112,11 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR
|
||||
state := m.CurrentState()
|
||||
cmd := exec.CommandContext(ctx, state.NodePath, state.RunnerPath, payloadPath)
|
||||
cmd.Dir = state.RuntimeDir
|
||||
hideWindow(cmd)
|
||||
prepareTaskCommand(cmd)
|
||||
cmd.Cancel = func() error {
|
||||
return stopTaskProcess(cmd)
|
||||
}
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
|
||||
startedAt := time.Now()
|
||||
m.attachTaskCommand(taskID, cmd)
|
||||
@@ -118,6 +131,21 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
durationMs := time.Since(startedAt).Milliseconds()
|
||||
if runErr != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
_ = stopTaskProcess(cmd)
|
||||
message := taskContextErrorMessage(ctxErr, timeoutLimit)
|
||||
m.emitTaskEvent(TaskEvent{
|
||||
TaskID: taskID,
|
||||
ProfileID: taskKey,
|
||||
Phase: "failed",
|
||||
Message: message,
|
||||
StartedAt: startedAt.Format(time.RFC3339),
|
||||
FinishedAt: time.Now().Format(time.RFC3339),
|
||||
DurationMs: durationMs,
|
||||
})
|
||||
return "", taskRunnerResponse{}, "", durationMs, fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = runErr.Error()
|
||||
@@ -152,6 +180,32 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR
|
||||
return taskID, runnerResp, string(output), durationMs, nil
|
||||
}
|
||||
|
||||
func taskContextErrorMessage(err error, timeoutLimit time.Duration) string {
|
||||
if err == context.DeadlineExceeded {
|
||||
if timeoutText := formatTaskTimeout(timeoutLimit); timeoutText != "" {
|
||||
return fmt.Sprintf("自动化任务超时,已终止(上限 %s)", timeoutText)
|
||||
}
|
||||
return "自动化任务超时,已终止"
|
||||
}
|
||||
if err == context.Canceled {
|
||||
return "自动化任务已取消"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func formatTaskTimeout(timeout time.Duration) string {
|
||||
if timeout <= 0 {
|
||||
return ""
|
||||
}
|
||||
if timeout >= time.Minute && timeout%time.Minute == 0 {
|
||||
return fmt.Sprintf("%d 分钟", int64(timeout/time.Minute))
|
||||
}
|
||||
if timeout >= time.Second && timeout%time.Second == 0 {
|
||||
return fmt.Sprintf("%d 秒", int64(timeout/time.Second))
|
||||
}
|
||||
return fmt.Sprintf("%d 毫秒", timeout.Milliseconds())
|
||||
}
|
||||
|
||||
func (m *Manager) writeTaskPayload(payload taskRunnerPayload) (string, error) {
|
||||
tempDir := filepath.Join(m.runtimeRoot(), "tmp")
|
||||
if err := os.MkdirAll(tempDir, 0o755); err != nil {
|
||||
|
||||
@@ -80,6 +80,10 @@ func stopTaskProcess(cmd *exec.Cmd) error {
|
||||
if err := killCmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
} else if cmd.Process.Pid > 0 {
|
||||
if err := killProcessGroup(cmd.Process.Pid); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
err := cmd.Process.Kill()
|
||||
if err == nil {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package automation
|
||||
|
||||
import "syscall"
|
||||
|
||||
func killProcessGroup(pid int) error {
|
||||
return syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package automation
|
||||
|
||||
func killProcessGroup(pid int) error {
|
||||
return nil
|
||||
}
|
||||
@@ -395,6 +395,66 @@ func TestRunScriptTaskClosesBrowserConnections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) {
|
||||
nodeExecPath := lookupNodeExecutable(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Automation.Enabled = true
|
||||
cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
|
||||
cfg.Automation.SystemNodePath = nodeExecPath
|
||||
cfg.Automation.NodeVersion = "test-node"
|
||||
cfg.Automation.PlaywrightCoreVersion = "1.59.0"
|
||||
cfg.Automation.RuntimeVersion = "test-runtime"
|
||||
|
||||
manager := NewManager(t.TempDir(), cfg, nil, Options{})
|
||||
|
||||
state := manager.CurrentState()
|
||||
if err := writeRunnerScript(state.RunnerPath); err != nil {
|
||||
t.Fatalf("write runner script failed: %v", err)
|
||||
}
|
||||
if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil {
|
||||
t.Fatalf("write mock playwright module failed: %v", err)
|
||||
}
|
||||
|
||||
scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
|
||||
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
|
||||
t.Fatalf("create script dir failed: %v", err)
|
||||
}
|
||||
scriptPath := filepath.Join(scriptDir, "script-timeout.cjs")
|
||||
scriptSource := `module.exports.run = async () => {
|
||||
await new Promise(() => setInterval(() => {}, 1000))
|
||||
}`
|
||||
if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
|
||||
t.Fatalf("write script failed: %v", err)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
_, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
|
||||
TaskKey: "script:timeout",
|
||||
ScriptPath: scriptPath,
|
||||
LaunchBaseURL: "http://127.0.0.1",
|
||||
Timeout: 150 * time.Millisecond,
|
||||
})
|
||||
elapsed := time.Since(startedAt)
|
||||
if err == nil {
|
||||
t.Fatalf("expected RunScriptTask to fail on timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("expected timeout to terminate quickly, took %s", elapsed)
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
activeTaskCount := len(manager.activeTasks)
|
||||
profileTaskCount := len(manager.profileTask)
|
||||
manager.mu.Unlock()
|
||||
if activeTaskCount != 0 || profileTaskCount != 0 {
|
||||
t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupNodeExecutable(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package automation
|
||||
|
||||
import "time"
|
||||
|
||||
type ScriptTaskRequest struct {
|
||||
TaskKey string `json:"taskKey"`
|
||||
ScriptPath string `json:"scriptPath"`
|
||||
@@ -9,6 +11,7 @@ type ScriptTaskRequest struct {
|
||||
LaunchAuthHeader string `json:"launchAuthHeader,omitempty"`
|
||||
LaunchAuthValue string `json:"launchAuthValue,omitempty"`
|
||||
ArtifactDir string `json:"artifactDir,omitempty"`
|
||||
Timeout time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
type ScriptTaskResult struct {
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewSQLiteBookmarkDAO(db *sql.DB) *SQLiteBookmarkDAO {
|
||||
// List 查询所有默认书签,按 sort_order 升序
|
||||
func (d *SQLiteBookmarkDAO) List() ([]config.BrowserBookmark, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT name, url FROM browser_bookmarks ORDER BY sort_order ASC, id ASC`)
|
||||
SELECT name, url, COALESCE(open_on_start, 0) FROM browser_bookmarks ORDER BY sort_order ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询书签列表失败: %w", err)
|
||||
}
|
||||
@@ -35,9 +35,11 @@ func (d *SQLiteBookmarkDAO) List() ([]config.BrowserBookmark, error) {
|
||||
var list []config.BrowserBookmark
|
||||
for rows.Next() {
|
||||
var b config.BrowserBookmark
|
||||
if err := rows.Scan(&b.Name, &b.URL); err != nil {
|
||||
var openOnStart int
|
||||
if err := rows.Scan(&b.Name, &b.URL, &openOnStart); err != nil {
|
||||
return nil, fmt.Errorf("读取书签行失败: %w", err)
|
||||
}
|
||||
b.OpenOnStart = openOnStart != 0
|
||||
list = append(list, b)
|
||||
}
|
||||
return list, rows.Err()
|
||||
@@ -59,11 +61,18 @@ func (d *SQLiteBookmarkDAO) ReplaceAll(bookmarks []config.BrowserBookmark) error
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO browser_bookmarks (name, url, sort_order) VALUES (?, ?, ?)`,
|
||||
b.Name, b.URL, i,
|
||||
`INSERT INTO browser_bookmarks (name, url, open_on_start, sort_order) VALUES (?, ?, ?, ?)`,
|
||||
b.Name, b.URL, boolToInt(b.OpenOnStart), i,
|
||||
); err != nil {
|
||||
return fmt.Errorf("插入书签失败: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -42,14 +42,19 @@ func EnsureDefaultBookmarks(userDataDir string, bookmarks []config.BrowserBookma
|
||||
root = newEmptyBookmarkRoot(now)
|
||||
}
|
||||
|
||||
// 取出 bookmark_bar children,收集已有 URL 集合
|
||||
barChildren, existingURLs := extractBarChildren(root)
|
||||
// 取出 bookmark_bar children,按整个书签树收集已有 URL 集合
|
||||
barChildren := extractBarChildren(root)
|
||||
existingURLs := collectRootURLs(root)
|
||||
|
||||
// 计算当前最大 id,用于分配新 id
|
||||
maxID := findMaxID(root)
|
||||
|
||||
// 把不存在的默认书签追加进去
|
||||
added := false
|
||||
for _, b := range bookmarks {
|
||||
if b.Name == "" || b.URL == "" {
|
||||
continue
|
||||
}
|
||||
if existingURLs[b.URL] {
|
||||
continue
|
||||
}
|
||||
@@ -64,6 +69,12 @@ func EnsureDefaultBookmarks(userDataDir string, bookmarks []config.BrowserBookma
|
||||
"type": "url",
|
||||
"url": b.URL,
|
||||
})
|
||||
existingURLs[b.URL] = true
|
||||
added = true
|
||||
}
|
||||
|
||||
if !added {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 写回
|
||||
@@ -121,9 +132,8 @@ func newEmptyBookmarkRoot(now string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// extractBarChildren 从根结构中提取书签栏 children 和已有 URL 集合
|
||||
func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string]bool) {
|
||||
existing := map[string]bool{}
|
||||
// extractBarChildren 从根结构中提取书签栏 children
|
||||
func extractBarChildren(root map[string]interface{}) []interface{} {
|
||||
var children []interface{}
|
||||
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
@@ -135,7 +145,7 @@ func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string]
|
||||
"name": "书签栏",
|
||||
},
|
||||
}
|
||||
return children, existing
|
||||
return children
|
||||
}
|
||||
|
||||
bar, ok := roots["bookmark_bar"].(map[string]interface{})
|
||||
@@ -146,14 +156,31 @@ func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string]
|
||||
"name": "书签栏",
|
||||
}
|
||||
root["roots"] = roots
|
||||
return children, existing
|
||||
return children
|
||||
}
|
||||
|
||||
if c, ok := bar["children"].([]interface{}); ok {
|
||||
children = c
|
||||
collectURLs(c, existing)
|
||||
}
|
||||
return children, existing
|
||||
return children
|
||||
}
|
||||
|
||||
func collectRootURLs(root map[string]interface{}) map[string]bool {
|
||||
existing := map[string]bool{}
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return existing
|
||||
}
|
||||
for _, item := range roots {
|
||||
folder, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if children, ok := folder["children"].([]interface{}); ok {
|
||||
collectURLs(children, existing)
|
||||
}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
// collectURLs 递归收集所有书签 URL
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureDefaultBookmarksOnlyAppendsMissingItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userDataDir := t.TempDir()
|
||||
profileDir := filepath.Join(userDataDir, "Default")
|
||||
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||
t.Fatalf("create profile dir: %v", err)
|
||||
}
|
||||
|
||||
root := newEmptyBookmarkRoot("0")
|
||||
roots := root["roots"].(map[string]interface{})
|
||||
bar := roots["bookmark_bar"].(map[string]interface{})
|
||||
bar["children"] = []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "4",
|
||||
"name": "用户自己的书签",
|
||||
"type": "url",
|
||||
"url": "https://user.example/",
|
||||
},
|
||||
}
|
||||
other := roots["other"].(map[string]interface{})
|
||||
other["children"] = []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "5",
|
||||
"name": "其他文件夹已有默认书签",
|
||||
"type": "url",
|
||||
"url": "https://existing.example/",
|
||||
},
|
||||
}
|
||||
writeBookmarkRoot(t, profileDir, root)
|
||||
|
||||
err := EnsureDefaultBookmarks(userDataDir, []config.BrowserBookmark{
|
||||
{Name: "已存在默认书签", URL: "https://existing.example/"},
|
||||
{Name: "新增默认书签", URL: "https://new.example/"},
|
||||
{Name: "", URL: "https://ignored-name.example/"},
|
||||
{Name: "忽略空 URL", URL: ""},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefaultBookmarks returned error: %v", err)
|
||||
}
|
||||
|
||||
updated := readBookmarkRoot(t, profileDir)
|
||||
if got := countBookmarkURL(updated, "https://user.example/"); got != 1 {
|
||||
t.Fatalf("用户自己的书签不应被改动: count=%d", got)
|
||||
}
|
||||
if got := countBookmarkURL(updated, "https://existing.example/"); got != 1 {
|
||||
t.Fatalf("已存在 URL 不应跨文件夹重复添加: count=%d", got)
|
||||
}
|
||||
if got := countBookmarkURL(updated, "https://new.example/"); got != 1 {
|
||||
t.Fatalf("新增默认书签应追加一次: count=%d", got)
|
||||
}
|
||||
if got := countBookmarkURL(updated, "https://ignored-name.example/"); got != 0 {
|
||||
t.Fatalf("空名称书签不应写入: count=%d", got)
|
||||
}
|
||||
if !bookmarkBarHasURL(updated, "https://user.example/") {
|
||||
t.Fatalf("用户自己的书签应保留在书签栏")
|
||||
}
|
||||
if !bookmarkBarHasURL(updated, "https://new.example/") {
|
||||
t.Fatalf("新增默认书签应追加到书签栏")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultBookmarksDoesNotRewriteWhenNothingMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userDataDir := t.TempDir()
|
||||
profileDir := filepath.Join(userDataDir, "Default")
|
||||
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||
t.Fatalf("create profile dir: %v", err)
|
||||
}
|
||||
|
||||
root := newEmptyBookmarkRoot("0")
|
||||
roots := root["roots"].(map[string]interface{})
|
||||
bar := roots["bookmark_bar"].(map[string]interface{})
|
||||
bar["date_modified"] = "unchanged"
|
||||
bar["children"] = []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "4",
|
||||
"name": "已有默认书签",
|
||||
"type": "url",
|
||||
"url": "https://existing.example/",
|
||||
},
|
||||
}
|
||||
writeBookmarkRoot(t, profileDir, root)
|
||||
before, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks"))
|
||||
if err != nil {
|
||||
t.Fatalf("read before: %v", err)
|
||||
}
|
||||
|
||||
err = EnsureDefaultBookmarks(userDataDir, []config.BrowserBookmark{
|
||||
{Name: "已有默认书签", URL: "https://existing.example/"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefaultBookmarks returned error: %v", err)
|
||||
}
|
||||
after, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks"))
|
||||
if err != nil {
|
||||
t.Fatalf("read after: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("没有新增项时不应重写用户书签文件")
|
||||
}
|
||||
}
|
||||
|
||||
func writeBookmarkRoot(t *testing.T, profileDir string, root map[string]interface{}) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal bookmarks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(profileDir, "Bookmarks"), data, 0o644); err != nil {
|
||||
t.Fatalf("write bookmarks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readBookmarkRoot(t *testing.T, profileDir string) map[string]interface{} {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks"))
|
||||
if err != nil {
|
||||
t.Fatalf("read bookmarks: %v", err)
|
||||
}
|
||||
var root map[string]interface{}
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatalf("unmarshal bookmarks: %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func countBookmarkURL(root map[string]interface{}, url string) int {
|
||||
count := 0
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return count
|
||||
}
|
||||
for _, item := range roots {
|
||||
folder, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if children, ok := folder["children"].([]interface{}); ok {
|
||||
count += countURLInNodes(children, url)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countURLInNodes(nodes []interface{}, url string) int {
|
||||
count := 0
|
||||
for _, item := range nodes {
|
||||
node, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if node["type"] == "url" && node["url"] == url {
|
||||
count++
|
||||
}
|
||||
if children, ok := node["children"].([]interface{}); ok {
|
||||
count += countURLInNodes(children, url)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func bookmarkBarHasURL(root map[string]interface{}, url string) bool {
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
bar, ok := roots["bookmark_bar"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
children, ok := bar["children"].([]interface{})
|
||||
return ok && countURLInNodes(children, url) > 0
|
||||
}
|
||||
@@ -9,16 +9,9 @@ func TestBuildLaunchArgsAppendsDefaultVerificationURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseArgs := []string{"--disable-sync"}
|
||||
got := BuildLaunchArgs(append([]string{}, baseArgs...), []string{
|
||||
"https://ippure.com/",
|
||||
"https://iplark.com/",
|
||||
"https://ping0.cc/",
|
||||
})
|
||||
got := BuildLaunchArgs(append([]string{}, baseArgs...), []string{})
|
||||
want := []string{
|
||||
"--disable-sync",
|
||||
"https://ippure.com/",
|
||||
"https://iplark.com/",
|
||||
"https://ping0.cc/",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// readSystemProxy 从 Windows 注册表读取当前系统代理(WinINet,Clash 会写这里)。
|
||||
// 返回格式如 "http://127.0.0.1:7890" 或 "socks5://127.0.0.1:7891"。
|
||||
// 返回格式如 "http://host:port" 或 "socks5://host:port"。
|
||||
func readSystemProxy() (string, error) {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
|
||||
@@ -30,7 +30,7 @@ func readSystemProxy() (string, error) {
|
||||
return "", fmt.Errorf("代理地址为空")
|
||||
}
|
||||
|
||||
// proxyServer 可能是 "127.0.0.1:7890" 或 "http=..;https=.." 多协议格式
|
||||
// proxyServer 可能是 "host:port" 或 "http=..;https=.." 多协议格式
|
||||
// 不含协议前缀时默认补 http://
|
||||
if !strings.Contains(proxyServer, ":") {
|
||||
return "", fmt.Errorf("无效的代理格式: %s", proxyServer)
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestGetProxyConfigByIdPreferDAO(t *testing.T) {
|
||||
func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "pool-2", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "pool-2", ProxyConfig: "http://proxy.invalid:8080"},
|
||||
}
|
||||
|
||||
mgr := NewManager(cfg, "")
|
||||
@@ -63,7 +63,7 @@ func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected proxy to be found in config fallback")
|
||||
}
|
||||
if got != "http://127.0.0.1:7890" {
|
||||
if got != "http://proxy.invalid:8080" {
|
||||
t.Fatalf("unexpected proxy config: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestApplyDefaultsDoesNotFallbackToDirectAfterPoolBindByProxyConfig(t *testi
|
||||
mgr.ProxyDAO = &proxyDAOStub{
|
||||
list: []Proxy{
|
||||
{ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
|
||||
{ProxyId: "pool-1", ProxyName: "香港-01", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
{ProxyId: "pool-1", ProxyName: "节点-01", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestResolveProfileProxyBindingBySourceAndName(t *testing.T) {
|
||||
list: []Proxy{
|
||||
{
|
||||
ProxyId: "new-p1",
|
||||
ProxyName: "香港-01",
|
||||
ProxyName: "节点-01",
|
||||
ProxyConfig: "socks5://127.0.0.1:1080",
|
||||
SourceID: "src-hk",
|
||||
SourceURL: "https://example.com/sub",
|
||||
@@ -25,7 +25,7 @@ func TestResolveProfileProxyBindingBySourceAndName(t *testing.T) {
|
||||
ProxyId: "old-missing-id",
|
||||
ProxyConfig: "socks5://127.0.0.1:2080",
|
||||
ProxyBindSourceID: "src-hk",
|
||||
ProxyBindName: "香港-01",
|
||||
ProxyBindName: "节点-01",
|
||||
}
|
||||
|
||||
changed, boundInPool, mode := mgr.ResolveProfileProxyBinding(profile)
|
||||
|
||||
@@ -84,10 +84,28 @@ type Config struct {
|
||||
Runtime RuntimeConfig `yaml:"runtime"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Browser BrowserConfig `yaml:"browser"`
|
||||
ProxyCheck ProxyCheckConfig `yaml:"proxy_check"`
|
||||
LaunchServer LaunchServerConfig `yaml:"launch_server"`
|
||||
Automation AutomationConfig `yaml:"automation"`
|
||||
}
|
||||
|
||||
type ProxyCheckConfig struct {
|
||||
BridgeStartTimeoutMs int `yaml:"bridge_start_timeout_ms" json:"bridgeStartTimeoutMs"`
|
||||
SpeedTargetID string `yaml:"speed_target_id" json:"speedTargetId"`
|
||||
IPHealthTargetID string `yaml:"ip_health_target_id" json:"ipHealthTargetId"`
|
||||
Targets []ProxyCheckTarget `yaml:"targets" json:"targets"`
|
||||
}
|
||||
|
||||
type ProxyCheckTarget struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Type string `yaml:"type" json:"type"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
Parser string `yaml:"parser,omitempty" json:"parser,omitempty"`
|
||||
TimeoutMs int `yaml:"timeout_ms,omitempty" json:"timeoutMs,omitempty"`
|
||||
ExpectedStatus []int `yaml:"expected_status,omitempty" json:"expectedStatus,omitempty"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
SQLite SQLiteConfig `yaml:"sqlite"`
|
||||
@@ -117,8 +135,9 @@ type RuntimeConfig struct {
|
||||
}
|
||||
|
||||
type BrowserBookmark struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
OpenOnStart bool `yaml:"open_on_start,omitempty" json:"openOnStart"`
|
||||
}
|
||||
|
||||
type BrowserConfig struct {
|
||||
|
||||
@@ -7,11 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var defaultBrowserStartURLs = []string{
|
||||
"https://ippure.com/",
|
||||
"https://iplark.com/",
|
||||
"https://ping0.cc/",
|
||||
}
|
||||
var defaultBrowserStartURLs = []string{}
|
||||
|
||||
func DefaultBrowserStartURLs() []string {
|
||||
return append([]string{}, defaultBrowserStartURLs...)
|
||||
@@ -111,6 +107,8 @@ func normalizeConfig(config *Config) {
|
||||
}
|
||||
if config.Browser.DefaultStartURLs == nil {
|
||||
config.Browser.DefaultStartURLs = append([]string{}, defaultConfig.Browser.DefaultStartURLs...)
|
||||
} else if isLegacyVerificationStartURLs(config.Browser.DefaultStartURLs) {
|
||||
config.Browser.DefaultStartURLs = []string{}
|
||||
}
|
||||
if config.Browser.StartReadyTimeoutMs <= 0 {
|
||||
config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs
|
||||
@@ -130,6 +128,18 @@ func normalizeConfig(config *Config) {
|
||||
if config.Browser.Profiles == nil {
|
||||
config.Browser.Profiles = []BrowserProfileConfig{}
|
||||
}
|
||||
if config.ProxyCheck.BridgeStartTimeoutMs <= 0 {
|
||||
config.ProxyCheck.BridgeStartTimeoutMs = defaultConfig.ProxyCheck.BridgeStartTimeoutMs
|
||||
}
|
||||
if strings.TrimSpace(config.ProxyCheck.SpeedTargetID) == "" {
|
||||
config.ProxyCheck.SpeedTargetID = defaultConfig.ProxyCheck.SpeedTargetID
|
||||
}
|
||||
if strings.TrimSpace(config.ProxyCheck.IPHealthTargetID) == "" {
|
||||
config.ProxyCheck.IPHealthTargetID = defaultConfig.ProxyCheck.IPHealthTargetID
|
||||
}
|
||||
if len(config.ProxyCheck.Targets) == 0 {
|
||||
config.ProxyCheck.Targets = append([]ProxyCheckTarget{}, defaultConfig.ProxyCheck.Targets...)
|
||||
}
|
||||
|
||||
if config.LaunchServer.Port <= 0 {
|
||||
config.LaunchServer.Port = defaultConfig.LaunchServer.Port
|
||||
@@ -181,6 +191,19 @@ func isLegacyDefaultLogPath(path string) bool {
|
||||
return strings.EqualFold(filepath.ToSlash(strings.TrimSpace(path)), "logs/app.log")
|
||||
}
|
||||
|
||||
func isLegacyVerificationStartURLs(urls []string) bool {
|
||||
legacy := []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"}
|
||||
if len(urls) != len(legacy) {
|
||||
return false
|
||||
}
|
||||
for i, url := range urls {
|
||||
if !strings.EqualFold(strings.TrimSpace(url), legacy[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DefaultConfig 返回默认配置
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
@@ -214,6 +237,12 @@ func DefaultConfig() *Config {
|
||||
StartReadyTimeoutMs: 3000,
|
||||
StartStableWindowMs: 1200,
|
||||
},
|
||||
ProxyCheck: ProxyCheckConfig{
|
||||
BridgeStartTimeoutMs: 15000,
|
||||
SpeedTargetID: "",
|
||||
IPHealthTargetID: "",
|
||||
Targets: []ProxyCheckTarget{},
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
FileEnabled: false,
|
||||
|
||||
@@ -58,8 +58,8 @@ browser: {}
|
||||
if len(cfg.Browser.DefaultFingerprintArgs) == 0 || len(cfg.Browser.DefaultLaunchArgs) == 0 {
|
||||
t.Fatalf("Browser 默认启动参数未补齐")
|
||||
}
|
||||
if len(cfg.Browser.DefaultStartURLs) != 3 {
|
||||
t.Fatalf("Browser 默认启动页面未补齐: got=%v", cfg.Browser.DefaultStartURLs)
|
||||
if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 {
|
||||
t.Fatalf("Browser 默认启动页面应初始化为空切片: got=%v", cfg.Browser.DefaultStartURLs)
|
||||
}
|
||||
if cfg.Browser.RestoreLastSession {
|
||||
t.Fatalf("Browser.RestoreLastSession 默认应为 false")
|
||||
@@ -141,6 +141,31 @@ func TestDefaultConfigUsesCurrentOSFingerprintPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClearsLegacyVerificationStartURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
legacyConfig := `
|
||||
browser:
|
||||
default_start_urls:
|
||||
- https://ippure.com/
|
||||
- https://iplark.com/
|
||||
- https://ping0.cc/
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 {
|
||||
t.Fatalf("旧默认检测页应迁移为空: got=%v", cfg.Browser.DefaultStartURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPreservesExplicitConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -135,6 +135,13 @@ var migrations = []migration{
|
||||
`ALTER TABLE browser_profiles ADD COLUMN proxy_bind_updated_at TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 7,
|
||||
desc: "书签表添加启动时打开字段",
|
||||
stmts: []string{
|
||||
`ALTER TABLE browser_bookmarks ADD COLUMN open_on_start INTEGER NOT NULL DEFAULT 0`,
|
||||
},
|
||||
},
|
||||
// ── 新版本在此追加,格式:
|
||||
// {
|
||||
// version: 4,
|
||||
|
||||
@@ -17,6 +17,7 @@ type automationScriptRunAPIRequest struct {
|
||||
Params json.RawMessage `json:"params"`
|
||||
UseScriptSelector *bool `json:"useScriptSelector"`
|
||||
UseScriptParams *bool `json:"useScriptParams"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
type automationScriptSummary struct {
|
||||
@@ -324,6 +325,7 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
|
||||
ParamsText: paramsText,
|
||||
UseScriptSelector: useScriptSelector,
|
||||
UseScriptParams: useScriptParams,
|
||||
TimeoutMs: req.TimeoutMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type directProxyBridgeSpec struct {
|
||||
Scheme string
|
||||
Server string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func RequiresLocalProxyBridgeForBrowser(src string) bool {
|
||||
spec, err := parseDirectProxyBridgeSpec(src)
|
||||
return err == nil && spec != nil
|
||||
}
|
||||
|
||||
func buildDirectProxyBridgeOutbound(src string) (map[string]interface{}, bool, error) {
|
||||
spec, err := parseDirectProxyBridgeSpec(src)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if spec == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if spec.Scheme == "socks5" || spec.Scheme == "http" {
|
||||
return chainSocks5Outbound(chainSocks5Hop{
|
||||
Protocol: spec.Scheme,
|
||||
Server: spec.Server,
|
||||
Port: spec.Port,
|
||||
Username: spec.Username,
|
||||
Password: spec.Password,
|
||||
}, "proxy-out", ""), true, nil
|
||||
}
|
||||
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func parseDirectProxyBridgeSpec(src string) (*directProxyBridgeSpec, error) {
|
||||
raw := strings.TrimSpace(src)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "socks5://") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("代理地址解析失败: %w", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
||||
switch scheme {
|
||||
case "socks5", "http":
|
||||
if parsed.User == nil {
|
||||
return nil, nil
|
||||
}
|
||||
username := strings.TrimSpace(parsed.User.Username())
|
||||
if username == "" {
|
||||
return nil, nil
|
||||
}
|
||||
server := strings.TrimSpace(parsed.Hostname())
|
||||
if server == "" {
|
||||
return nil, fmt.Errorf("代理地址缺少主机名")
|
||||
}
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, fmt.Errorf("代理端口无效")
|
||||
}
|
||||
password, _ := parsed.User.Password()
|
||||
return &directProxyBridgeSpec{
|
||||
Scheme: scheme,
|
||||
Server: server,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,23 @@ func buildProxyHTTPClient(
|
||||
singboxMgr *SingBoxManager,
|
||||
timeout time.Duration,
|
||||
) (*http.Client, error) {
|
||||
src = resolveProxyConfig(src, proxies, proxyId)
|
||||
l := strings.ToLower(strings.TrimSpace(src))
|
||||
if l == "" || l == "direct://" {
|
||||
return &http.Client{Timeout: timeout}, nil
|
||||
}
|
||||
|
||||
if IsChainSocks5Proxy(src) {
|
||||
if xrayMgr == nil {
|
||||
return nil, fmt.Errorf("xray 管理器未初始化")
|
||||
}
|
||||
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xray 桥接启动失败: %w", err)
|
||||
}
|
||||
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
|
||||
}
|
||||
|
||||
if IsSingBoxProtocol(src) {
|
||||
if singboxMgr == nil {
|
||||
return nil, fmt.Errorf("sing-box 管理器未初始化")
|
||||
|
||||
@@ -5,64 +5,153 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
const defaultIPPureInfoURL = "https://my.ippure.com/v1/info"
|
||||
const DefaultIPHealthURL = "https://my.ippure.com/v1/info"
|
||||
|
||||
// FetchIPPureInfo 通过指定代理链路查询 IPPure 的出口 IP 健康信息。
|
||||
type IPHealthConfig struct {
|
||||
URL string
|
||||
Source string
|
||||
Parser string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// FetchDefaultIPHealthInfo 使用传入的检测目标查询出口 IP 健康信息。
|
||||
// 返回值为第三方接口原始 JSON(map 形式),不做本地评分计算。
|
||||
func FetchIPPureInfo(
|
||||
func FetchDefaultIPHealthInfo(
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
) (map[string]interface{}, error) {
|
||||
src := ""
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
return FetchIPHealthInfo(proxyId, proxies, xrayMgr, singboxMgr, nil)
|
||||
}
|
||||
|
||||
func FetchIPHealthInfo(
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
cfg *IPHealthConfig,
|
||||
) (map[string]interface{}, error) {
|
||||
if cfg == nil {
|
||||
cfg = &IPHealthConfig{}
|
||||
}
|
||||
targetURL := strings.TrimSpace(cfg.URL)
|
||||
if targetURL == "" {
|
||||
targetURL = DefaultIPHealthURL
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
source := resolveIPHealthSource(cfg, targetURL)
|
||||
parser := resolveIPHealthParser(cfg.Parser)
|
||||
meta := map[string]interface{}{
|
||||
"_source": source,
|
||||
"_targetUrl": targetURL,
|
||||
"_parser": parser,
|
||||
}
|
||||
if targetURL == "" {
|
||||
meta["error"] = "IP 健康检测目标 URL 为空"
|
||||
return meta, fmt.Errorf("IP 健康检测目标 URL 为空")
|
||||
}
|
||||
|
||||
src := resolveProxyConfig("", proxies, proxyId)
|
||||
if src == "" {
|
||||
return nil, fmt.Errorf("未找到代理配置")
|
||||
meta["error"] = "未找到代理配置"
|
||||
return meta, fmt.Errorf("未找到代理配置")
|
||||
}
|
||||
|
||||
client, err := buildIPPureHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, 20*time.Second)
|
||||
client, err := buildIPHealthHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
meta["error"] = err.Error()
|
||||
return meta, fmt.Errorf("创建 IP 健康检测客户端失败(source=%s): %w", source, err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, defaultIPPureInfoURL, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
meta["error"] = err.Error()
|
||||
return meta, fmt.Errorf("创建 IP 健康检测请求失败(source=%s): %w", source, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "AntChrome/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("调用 IPPure 接口失败: %w", err)
|
||||
meta["error"] = err.Error()
|
||||
return meta, fmt.Errorf("调用 IP 健康检测接口失败(source=%s): %w", source, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 IPPure 响应失败: %w", err)
|
||||
meta["error"] = err.Error()
|
||||
return meta, fmt.Errorf("读取 IP 健康检测响应失败(source=%s): %w", source, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("IPPure HTTP %d: %s", resp.StatusCode, bodySnippet(body, 180))
|
||||
snippet := bodySnippet(body, 180)
|
||||
meta["error"] = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
meta["_statusCode"] = resp.StatusCode
|
||||
if snippet != "" {
|
||||
meta["_bodySnippet"] = snippet
|
||||
}
|
||||
return meta, fmt.Errorf("IP 健康检测 HTTP %d(source=%s): %s", resp.StatusCode, source, snippet)
|
||||
}
|
||||
|
||||
result, err := parseIPHealthBody(body, cfg.Parser)
|
||||
if err != nil {
|
||||
snippet := bodySnippet(body, 180)
|
||||
meta["error"] = err.Error()
|
||||
if snippet != "" {
|
||||
meta["_bodySnippet"] = snippet
|
||||
}
|
||||
return meta, fmt.Errorf("IP 健康检测响应解析失败(source=%s, parser=%s): %w", source, parser, err)
|
||||
}
|
||||
result["_source"] = source
|
||||
result["_targetUrl"] = targetURL
|
||||
result["_parser"] = parser
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseIPHealthBody(body []byte, parser string) (map[string]interface{}, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(parser), "cloudflare_trace") {
|
||||
result := map[string]interface{}{}
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if ok && strings.TrimSpace(key) != "" {
|
||||
result[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if ip := mapString(result, "ip"); ip != "" {
|
||||
result["ip"] = ip
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("IPPure JSON 解析失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildIPPureHTTPClient(
|
||||
func mapString(data map[string]interface{}, key string) string {
|
||||
value, ok := data[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func buildIPHealthHTTPClient(
|
||||
src string,
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
@@ -73,6 +162,34 @@ func buildIPPureHTTPClient(
|
||||
return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
|
||||
}
|
||||
|
||||
func resolveIPHealthSource(cfg *IPHealthConfig, targetURL string) string {
|
||||
if cfg != nil {
|
||||
if source := strings.TrimSpace(cfg.Source); source != "" {
|
||||
return source
|
||||
}
|
||||
if parser := strings.TrimSpace(cfg.Parser); parser != "" {
|
||||
return parser
|
||||
}
|
||||
}
|
||||
if DefaultIPHealthURL != "" && strings.EqualFold(strings.TrimSpace(targetURL), DefaultIPHealthURL) {
|
||||
return "ip_health"
|
||||
}
|
||||
if parsed, err := url.Parse(strings.TrimSpace(targetURL)); err == nil {
|
||||
if host := strings.ToLower(strings.TrimSpace(parsed.Hostname())); host != "" {
|
||||
return host
|
||||
}
|
||||
}
|
||||
return "ip_health"
|
||||
}
|
||||
|
||||
func resolveIPHealthParser(parser string) string {
|
||||
normalized := strings.TrimSpace(parser)
|
||||
if normalized == "" {
|
||||
return "json"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func bodySnippet(body []byte, max int) string {
|
||||
s := strings.TrimSpace(string(body))
|
||||
if len(s) <= max {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestFetchIPHealthInfoReturnsSourceMetadataOnParseError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
data, err := FetchIPHealthInfo(
|
||||
"proxy-1",
|
||||
[]config.BrowserProxy{{ProxyId: "proxy-1", ProxyConfig: "direct://"}},
|
||||
nil,
|
||||
nil,
|
||||
&IPHealthConfig{
|
||||
URL: server.URL,
|
||||
Source: "json",
|
||||
Parser: "json",
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "source=json") {
|
||||
t.Fatalf("expected source in error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parser=json") {
|
||||
t.Fatalf("expected parser in error, got %v", err)
|
||||
}
|
||||
if got := mapString(data, "_source"); got != "json" {
|
||||
t.Fatalf("source metadata = %q, want json", got)
|
||||
}
|
||||
if got := mapString(data, "_targetUrl"); got != server.URL {
|
||||
t.Fatalf("target url metadata = %q, want %q", got, server.URL)
|
||||
}
|
||||
if got := mapString(data, "_parser"); got != "json" {
|
||||
t.Fatalf("parser metadata = %q, want json", got)
|
||||
}
|
||||
if got := mapString(data, "_bodySnippet"); got != "not-json" {
|
||||
t.Fatalf("body snippet = %q, want not-json", got)
|
||||
}
|
||||
}
|
||||
@@ -56,15 +56,19 @@ func ParseChainSocks5Config(src string) (*chainSocks5Config, error) {
|
||||
if cfg.LocalPort < 0 || cfg.LocalPort > 65535 {
|
||||
return nil, fmt.Errorf("本地监听端口必须在 1-65535 之间")
|
||||
}
|
||||
if cfg.First.Protocol == "" {
|
||||
cfg.First.Protocol = "socks5"
|
||||
}
|
||||
if cfg.Second.Protocol == "" {
|
||||
cfg.Second.Protocol = "socks5"
|
||||
}
|
||||
cfg.First.Protocol = normalizeChainHopProtocol(cfg.First.Protocol)
|
||||
cfg.Second.Protocol = normalizeChainHopProtocol(cfg.Second.Protocol)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func normalizeChainHopProtocol(protocol string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(protocol))
|
||||
if normalized == "" {
|
||||
return "socks5"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateChainSocks5Hop(label string, hop chainSocks5Hop) error {
|
||||
if strings.TrimSpace(hop.Server) == "" {
|
||||
return fmt.Errorf("%s代理地址不能为空", label)
|
||||
@@ -73,8 +77,8 @@ func validateChainSocks5Hop(label string, hop chainSocks5Hop) error {
|
||||
return fmt.Errorf("%s代理端口必须在 1-65535 之间", label)
|
||||
}
|
||||
protocol := strings.ToLower(strings.TrimSpace(hop.Protocol))
|
||||
if protocol != "" && protocol != "socks5" {
|
||||
return fmt.Errorf("%s协议仅支持 socks5", label)
|
||||
if protocol != "" && protocol != "socks5" && protocol != "http" {
|
||||
return fmt.Errorf("%s协议仅支持 http 或 socks5", label)
|
||||
}
|
||||
if strings.TrimSpace(hop.Password) != "" && strings.TrimSpace(hop.Username) == "" {
|
||||
return fmt.Errorf("%s填写密码时请同时填写账号", label)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
func resolveProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) string {
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
if proxyId == "" {
|
||||
return src
|
||||
}
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
return strings.TrimSpace(item.ProxyConfig)
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
@@ -7,22 +7,13 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EnsureBridge 确保 sing-box 桥接进程运行,返回 socks5://127.0.0.1:port
|
||||
func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
|
||||
log := logger.New("SingBox")
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
src := resolveProxyConfig(proxyConfig, proxies, proxyId)
|
||||
if src == "" {
|
||||
return "", fmt.Errorf("未找到代理节点")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// ─── Clash 标准测速 URL ───
|
||||
// 使用 HTTP 与 Clash 客户端保持一致
|
||||
|
||||
const defaultTestURL = "http://www.gstatic.com/generate_204"
|
||||
const DefaultSpeedTestURL = "http://www.gstatic.com/generate_204"
|
||||
|
||||
// SpeedTestConfig 测速参数
|
||||
type SpeedTestConfig struct {
|
||||
@@ -46,13 +46,7 @@ func SpeedTest(
|
||||
cfg = &c
|
||||
}
|
||||
|
||||
src := ""
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
src := resolveProxyConfig("", proxies, proxyId)
|
||||
if src == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
|
||||
}
|
||||
@@ -61,18 +55,40 @@ func SpeedTest(
|
||||
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0}
|
||||
}
|
||||
|
||||
testURL := defaultTestURL
|
||||
testURL := strings.TrimSpace(DefaultSpeedTestURL)
|
||||
if len(cfg.URLs) > 0 {
|
||||
testURL = cfg.URLs[0]
|
||||
testURL = strings.TrimSpace(cfg.URLs[0])
|
||||
}
|
||||
if testURL == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "测速目标 URL 为空"}
|
||||
}
|
||||
|
||||
mapping, err := proxyConfigToMapping(src)
|
||||
resolvedSrc := src
|
||||
if IsChainSocks5Proxy(src) {
|
||||
if xrayMgr == nil {
|
||||
log.Warn("链式代理测速缺少 Xray 管理器,降级到 TCP ping",
|
||||
logger.F("proxy_id", proxyId),
|
||||
)
|
||||
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
|
||||
}
|
||||
bridgeSocksURL, bridgeErr := xrayMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if bridgeErr != nil {
|
||||
log.Warn("链式代理桥接失败,降级到 TCP ping",
|
||||
logger.F("proxy_id", proxyId),
|
||||
logger.F("error", bridgeErr.Error()),
|
||||
)
|
||||
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
|
||||
}
|
||||
resolvedSrc = strings.TrimSpace(bridgeSocksURL)
|
||||
}
|
||||
|
||||
mapping, err := proxyConfigToMapping(resolvedSrc)
|
||||
if err != nil {
|
||||
log.Warn("代理配置解析失败,降级到 TCP ping",
|
||||
logger.F("proxy_id", proxyId),
|
||||
logger.F("error", err.Error()),
|
||||
)
|
||||
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
|
||||
return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log)
|
||||
}
|
||||
|
||||
proxyInstance, err := adapter.ParseProxy(mapping)
|
||||
@@ -82,7 +98,7 @@ func SpeedTest(
|
||||
logger.F("error", err.Error()),
|
||||
logger.F("type", mapping["type"]),
|
||||
)
|
||||
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
|
||||
return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log)
|
||||
}
|
||||
|
||||
return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout)
|
||||
|
||||
@@ -2,6 +2,8 @@ package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -26,26 +28,24 @@ func proxyConfigToMapping(src string) (map[string]any, error) {
|
||||
}
|
||||
|
||||
func parseStandardProxy(src string, proxyType string) (map[string]any, error) {
|
||||
rest := src[strings.Index(src, "://")+3:]
|
||||
|
||||
var username, password, hostport string
|
||||
if atIdx := strings.LastIndex(rest, "@"); atIdx >= 0 {
|
||||
userInfo := rest[:atIdx]
|
||||
hostport = rest[atIdx+1:]
|
||||
parts := strings.SplitN(userInfo, ":", 2)
|
||||
username = parts[0]
|
||||
if len(parts) > 1 {
|
||||
password = parts[1]
|
||||
}
|
||||
} else {
|
||||
hostport = rest
|
||||
parsed, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("代理地址解析失败: %w", err)
|
||||
}
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil {
|
||||
port = 0
|
||||
}
|
||||
hostport = strings.SplitN(hostport, "/", 2)[0]
|
||||
|
||||
host, port := splitHostPort(hostport)
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("无法解析地址: %s", src)
|
||||
}
|
||||
username := ""
|
||||
password := ""
|
||||
if parsed.User != nil {
|
||||
username = parsed.User.Username()
|
||||
password, _ = parsed.User.Password()
|
||||
}
|
||||
|
||||
mapping := map[string]any{
|
||||
"name": "speedtest-proxy",
|
||||
@@ -78,26 +78,6 @@ func parseClashYAMLToMapping(src string) (map[string]any, error) {
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func splitHostPort(hostport string) (string, int) {
|
||||
if strings.HasPrefix(hostport, "[") {
|
||||
if idx := strings.LastIndex(hostport, "]:"); idx >= 0 {
|
||||
host := hostport[1:idx]
|
||||
port := 0
|
||||
fmt.Sscanf(hostport[idx+2:], "%d", &port)
|
||||
return host, port
|
||||
}
|
||||
return strings.Trim(hostport, "[]"), 0
|
||||
}
|
||||
idx := strings.LastIndex(hostport, ":")
|
||||
if idx < 0 {
|
||||
return hostport, 0
|
||||
}
|
||||
host := hostport[:idx]
|
||||
port := 0
|
||||
fmt.Sscanf(hostport[idx+1:], "%d", &port)
|
||||
return host, port
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package proxy
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyConfigToMappingStandardProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -27,6 +30,33 @@ func TestProxyConfigToMappingStandardProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyConfigToMappingEscapedCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mapping, err := proxyConfigToMapping("http://user%40mail:p%40ss%3Aword@example.com:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("proxyConfigToMapping returned error: %v", err)
|
||||
}
|
||||
if got := mapping["username"]; got != "user@mail" {
|
||||
t.Fatalf("username = %v, want user@mail", got)
|
||||
}
|
||||
if got := mapping["password"]; got != "p@ss:word" {
|
||||
t.Fatalf("password = %v, want p@ss:word", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyEndpointDropsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint, err := proxyEndpoint("http://user:pass@example.com:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("proxyEndpoint returned error: %v", err)
|
||||
}
|
||||
if endpoint != "example.com:8080" {
|
||||
t.Fatalf("endpoint = %q, want example.com:8080", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyConfigToMappingClashYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -76,3 +106,14 @@ func TestURLToMeta(t *testing.T) {
|
||||
t.Fatalf("DstIP = %v, want 1.2.3.4", meta.DstIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultProxyCheckURLsAreConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if strings.TrimSpace(DefaultSpeedTestURL) == "" {
|
||||
t.Fatalf("DefaultSpeedTestURL must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(DefaultIPHealthURL) == "" {
|
||||
t.Fatalf("DefaultIPHealthURL must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// TestConnectivity 通过 TCP 握手测试代理服务器的可达性和延迟
|
||||
@@ -63,67 +61,20 @@ func TestRealConnectivityWithSingBox(
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
) TestResult {
|
||||
src := ""
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
src := resolveProxyConfig("", proxies, proxyId)
|
||||
if src == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
|
||||
}
|
||||
|
||||
const targetURL = "http://www.gstatic.com/generate_204"
|
||||
targetURL := strings.TrimSpace(DefaultSpeedTestURL)
|
||||
if targetURL == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "真实连通性测试目标 URL 为空"}
|
||||
}
|
||||
const timeout = 15 * time.Second
|
||||
|
||||
var client *http.Client
|
||||
|
||||
if IsSingBoxProtocol(src) {
|
||||
if singboxMgr == nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"}
|
||||
}
|
||||
socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("sing-box 桥接启动失败: %v", err)}
|
||||
}
|
||||
socks5Host := strings.TrimPrefix(socks5Addr, "socks5://")
|
||||
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)}
|
||||
}
|
||||
contextDialer, ok := dialer.(xproxy.ContextDialer)
|
||||
if !ok {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"}
|
||||
}
|
||||
transport := &http.Transport{DialContext: contextDialer.DialContext}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
} else if RequiresBridge(src, proxies, proxyId) {
|
||||
if xrayMgr == nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "xray 管理器未初始化"}
|
||||
}
|
||||
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("桥接启动失败: %v", err)}
|
||||
}
|
||||
socks5Host := strings.TrimPrefix(socks5Addr, "socks5://")
|
||||
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)}
|
||||
}
|
||||
contextDialer, ok := dialer.(xproxy.ContextDialer)
|
||||
if !ok {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"}
|
||||
}
|
||||
transport := &http.Transport{DialContext: contextDialer.DialContext}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
} else {
|
||||
proxyURL, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理地址解析失败: %v", err)}
|
||||
}
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
client, err := buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -16,9 +17,14 @@ func proxyEndpoint(src string) (string, error) {
|
||||
l := strings.ToLower(src)
|
||||
|
||||
if strings.HasPrefix(l, "socks5://") || strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
|
||||
hostport := src[strings.Index(src, "//")+2:]
|
||||
hostport = strings.SplitN(hostport, "/", 2)[0]
|
||||
return hostport, nil
|
||||
parsed, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("缺少代理地址")
|
||||
}
|
||||
return parsed.Host, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(l, "vmess://") {
|
||||
|
||||
@@ -20,6 +20,7 @@ type XrayManager struct {
|
||||
Bridges map[string]*XrayBridge
|
||||
OnBridgeDied func(key string, err error) // 桥接进程意外退出回调
|
||||
mu sync.Mutex
|
||||
launchMu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -40,8 +41,8 @@ func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager {
|
||||
// 返回: supported bool, errorMsg string
|
||||
func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (bool, string) {
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
found := false
|
||||
if proxyId != "" {
|
||||
found := false
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
@@ -65,6 +66,12 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox
|
||||
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
|
||||
return true, ""
|
||||
}
|
||||
if IsChainSocks5Proxy(src) {
|
||||
if _, err := ParseChainSocks5Config(src); err != nil {
|
||||
return false, fmt.Sprintf("链式代理配置解析失败: %v", err)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
if IsSingBoxProtocol(src) {
|
||||
if _, err := BuildSingBoxOutbound(src); err != nil {
|
||||
return false, fmt.Sprintf("代理配置解析失败: %v", err)
|
||||
@@ -86,15 +93,7 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox
|
||||
// 注意: Xray 仅支持 vless/vmess/trojan/shadowsocks 等协议
|
||||
// hysteria2 不支持,需要使用 Hysteria 客户端或 sing-box
|
||||
func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) bool {
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
src := resolveProxyConfig(proxyConfig, proxies, proxyId)
|
||||
if src == "" {
|
||||
return false
|
||||
}
|
||||
@@ -102,6 +101,9 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s
|
||||
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
|
||||
return false
|
||||
}
|
||||
if IsChainSocks5Proxy(src) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -13,12 +13,11 @@ import (
|
||||
|
||||
func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) {
|
||||
log := logger.New("Xray")
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
src := resolveProxyConfig(proxyConfig, proxies, proxyId)
|
||||
dnsServers := ""
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
dnsServers = item.DnsServers
|
||||
break
|
||||
}
|
||||
@@ -28,19 +27,76 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
|
||||
return "", "", fmt.Errorf("未找到代理节点")
|
||||
}
|
||||
src = normalizeNodeScheme(src)
|
||||
standardProxy, outbound, err := ParseProxyNode(src)
|
||||
if err != nil {
|
||||
log.Error("节点解析失败", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
if standardProxy != "" {
|
||||
return standardProxy, "", nil
|
||||
}
|
||||
if outbound == nil {
|
||||
return "", "", fmt.Errorf("节点解析失败")
|
||||
|
||||
var (
|
||||
outbounds []interface{}
|
||||
routes []interface{}
|
||||
preferredPort int
|
||||
)
|
||||
|
||||
if IsChainSocks5Proxy(src) {
|
||||
chainCfg, err := ParseChainSocks5Config(src)
|
||||
if err != nil {
|
||||
log.Error("链式节点解析失败", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
outbounds = []interface{}{
|
||||
chainSocks5Outbound(chainCfg.First, "first-hop", ""),
|
||||
chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"),
|
||||
}
|
||||
routes = []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "second-hop",
|
||||
},
|
||||
}
|
||||
preferredPort = chainCfg.LocalPort
|
||||
} else {
|
||||
directOutbound, shouldBridgeDirectProxy, err := buildDirectProxyBridgeOutbound(src)
|
||||
if err != nil {
|
||||
log.Error("直连代理桥接配置解析失败", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
if shouldBridgeDirectProxy {
|
||||
outbounds = []interface{}{directOutbound}
|
||||
routes = []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "proxy-out",
|
||||
},
|
||||
}
|
||||
} else {
|
||||
standardProxy, outbound, err := ParseProxyNode(src)
|
||||
if err != nil {
|
||||
log.Error("节点解析失败", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
if standardProxy != "" {
|
||||
return standardProxy, "", nil
|
||||
}
|
||||
if outbound == nil {
|
||||
return "", "", fmt.Errorf("节点解析失败")
|
||||
}
|
||||
outbounds = []interface{}{outbound}
|
||||
routes = []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "proxy-out",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
key := computeNodeKey(src + "\x00" + dnsServers)
|
||||
|
||||
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
|
||||
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
|
||||
return socksURL, key, nil
|
||||
}
|
||||
m.launchMu.Lock()
|
||||
defer m.launchMu.Unlock()
|
||||
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
|
||||
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
|
||||
return socksURL, key, nil
|
||||
@@ -52,10 +108,13 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
const maxLaunchRetries = 3
|
||||
maxLaunchRetries := 3
|
||||
if preferredPort > 0 {
|
||||
maxLaunchRetries = 1
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxLaunchRetries; attempt++ {
|
||||
socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbound, dnsServers, pin, attempt)
|
||||
socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbounds, routes, preferredPort, dnsServers, pin, attempt)
|
||||
if err == nil {
|
||||
return socksURL, key, nil
|
||||
}
|
||||
@@ -67,13 +126,17 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
|
||||
return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr)
|
||||
}
|
||||
|
||||
func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binaryPath string, outbound map[string]interface{}, dnsServers string, pin bool, attempt int) (string, *XrayBridge, error) {
|
||||
port, err := nextAvailablePort()
|
||||
if err != nil {
|
||||
log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt))
|
||||
return "", nil, err
|
||||
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) {
|
||||
port := preferredPort
|
||||
if port <= 0 {
|
||||
var err error
|
||||
port, err = nextAvailablePort()
|
||||
if err != nil {
|
||||
log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt))
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers)
|
||||
cfgPath, err := m.buildRuntimeConfigWithRoute(key, outbounds, routes, port, dnsServers)
|
||||
if err != nil {
|
||||
log.Error("xray 配置生成失败", logger.F("error", err))
|
||||
return "", nil, err
|
||||
@@ -121,8 +184,77 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary
|
||||
return fmt.Sprintf("socks5://127.0.0.1:%d", port), bridge, nil
|
||||
}
|
||||
|
||||
func chainSocks5Outbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} {
|
||||
protocol := normalizeChainHopProtocol(hop.Protocol)
|
||||
if protocol == "http" {
|
||||
return chainHTTPOutbound(hop, tag, nextTag)
|
||||
}
|
||||
|
||||
user := map[string]interface{}{}
|
||||
if strings.TrimSpace(hop.Username) != "" {
|
||||
user["user"] = strings.TrimSpace(hop.Username)
|
||||
if strings.TrimSpace(hop.Password) != "" {
|
||||
user["pass"] = hop.Password
|
||||
}
|
||||
}
|
||||
|
||||
server := map[string]interface{}{
|
||||
"address": strings.TrimSpace(hop.Server),
|
||||
"port": hop.Port,
|
||||
}
|
||||
if len(user) > 0 {
|
||||
server["users"] = []interface{}{user}
|
||||
}
|
||||
|
||||
outbound := map[string]interface{}{
|
||||
"protocol": "socks",
|
||||
"tag": tag,
|
||||
"settings": map[string]interface{}{
|
||||
"servers": []interface{}{server},
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(nextTag) != "" {
|
||||
outbound["proxySettings"] = map[string]interface{}{
|
||||
"tag": strings.TrimSpace(nextTag),
|
||||
}
|
||||
}
|
||||
return outbound
|
||||
}
|
||||
|
||||
func chainHTTPOutbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} {
|
||||
user := map[string]interface{}{}
|
||||
if strings.TrimSpace(hop.Username) != "" {
|
||||
user["user"] = strings.TrimSpace(hop.Username)
|
||||
if strings.TrimSpace(hop.Password) != "" {
|
||||
user["pass"] = hop.Password
|
||||
}
|
||||
}
|
||||
|
||||
server := map[string]interface{}{
|
||||
"address": strings.TrimSpace(hop.Server),
|
||||
"port": hop.Port,
|
||||
}
|
||||
if len(user) > 0 {
|
||||
server["users"] = []interface{}{user}
|
||||
}
|
||||
|
||||
outbound := map[string]interface{}{
|
||||
"protocol": "http",
|
||||
"tag": tag,
|
||||
"settings": map[string]interface{}{
|
||||
"servers": []interface{}{server},
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(nextTag) != "" {
|
||||
outbound["proxySettings"] = map[string]interface{}{
|
||||
"tag": strings.TrimSpace(nextTag),
|
||||
}
|
||||
}
|
||||
return outbound
|
||||
}
|
||||
|
||||
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, 10*time.Second); err != nil {
|
||||
if err := waitPortReady("127.0.0.1", bridge.Port, m.bridgeStartTimeout()); err != nil {
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
@@ -131,10 +263,10 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf
|
||||
m.stopBridgeProcess(bridge)
|
||||
bridge.Running = false
|
||||
bridge.Pid = 0
|
||||
bridge.LastError = err.Error()
|
||||
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))
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
return err
|
||||
return fmt.Errorf("%s", bridge.LastError)
|
||||
}
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
@@ -142,6 +274,43 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *XrayManager) bridgeStartTimeout() time.Duration {
|
||||
if m != nil && m.Config != nil && m.Config.ProxyCheck.BridgeStartTimeoutMs > 0 {
|
||||
return time.Duration(m.Config.ProxyCheck.BridgeStartTimeoutMs) * time.Millisecond
|
||||
}
|
||||
return 15 * time.Second
|
||||
}
|
||||
|
||||
func (m *XrayManager) describeBridgeReadyError(err error, cfgPath string, stderrPath string) string {
|
||||
parts := []string{err.Error()}
|
||||
if strings.TrimSpace(cfgPath) != "" {
|
||||
parts = append(parts, "配置文件: "+cfgPath)
|
||||
}
|
||||
if tail := readLogTail(stderrPath, 1200); tail != "" {
|
||||
parts = append(parts, "stderr: "+tail)
|
||||
} else if cfgPath != "" {
|
||||
if tail := readLogTail(filepath.Join(filepath.Dir(cfgPath), "xray-error.log"), 1200); tail != "" {
|
||||
parts = append(parts, "error.log: "+tail)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func readLogTail(path string, max int) string {
|
||||
if strings.TrimSpace(path) == "" || max <= 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if len(text) <= max {
|
||||
return text
|
||||
}
|
||||
return text[len(text)-max:]
|
||||
}
|
||||
|
||||
func (m *XrayManager) logBridgeStartupError(log *logger.Logger, cfgPath string, stderrPath string) {
|
||||
if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 {
|
||||
log.Error("xray stderr", logger.F("output", string(stderrContent)))
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestChainSocks5RuntimeConfigRoutesThroughSecondHop(t *testing.T) {
|
||||
chainConfig := buildTestChainSocks5Config(t, 19090)
|
||||
chainCfg, err := ParseChainSocks5Config(chainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseChainSocks5Config returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.UserDataRoot = t.TempDir()
|
||||
manager := &XrayManager{
|
||||
Config: cfg,
|
||||
AppRoot: t.TempDir(),
|
||||
}
|
||||
|
||||
cfgPath, err := manager.buildRuntimeConfigWithRoute(
|
||||
"chain-test",
|
||||
[]interface{}{
|
||||
chainSocks5Outbound(chainCfg.First, "first-hop", ""),
|
||||
chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"),
|
||||
},
|
||||
[]interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "second-hop",
|
||||
},
|
||||
},
|
||||
chainCfg.LocalPort,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime config failed: %v", err)
|
||||
}
|
||||
var runtimeConfig map[string]interface{}
|
||||
if err := json.Unmarshal(data, &runtimeConfig); err != nil {
|
||||
t.Fatalf("unmarshal runtime config failed: %v", err)
|
||||
}
|
||||
|
||||
inbounds := runtimeConfig["inbounds"].([]interface{})
|
||||
inbound := inbounds[0].(map[string]interface{})
|
||||
if got := int(inbound["port"].(float64)); got != 19090 {
|
||||
t.Fatalf("inbound port = %d, want 19090", got)
|
||||
}
|
||||
|
||||
outbounds := runtimeConfig["outbounds"].([]interface{})
|
||||
byTag := map[string]map[string]interface{}{}
|
||||
for _, item := range outbounds {
|
||||
outbound := item.(map[string]interface{})
|
||||
if tag, ok := outbound["tag"].(string); ok {
|
||||
byTag[tag] = outbound
|
||||
}
|
||||
}
|
||||
secondHop := byTag["second-hop"]
|
||||
if secondHop == nil {
|
||||
t.Fatalf("second-hop outbound is missing: %+v", byTag)
|
||||
}
|
||||
proxySettings, ok := secondHop["proxySettings"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("second-hop proxySettings is missing: %+v", secondHop)
|
||||
}
|
||||
if got := proxySettings["tag"]; got != "first-hop" {
|
||||
t.Fatalf("second-hop proxy tag = %v, want first-hop", got)
|
||||
}
|
||||
|
||||
routing := runtimeConfig["routing"].(map[string]interface{})
|
||||
rules := routing["rules"].([]interface{})
|
||||
rule := rules[0].(map[string]interface{})
|
||||
if got := rule["outboundTag"]; got != "second-hop" {
|
||||
t.Fatalf("route outboundTag = %v, want second-hop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatedSocks5RuntimeConfigUsesLocalBridgeOutbound(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.UserDataRoot = t.TempDir()
|
||||
manager := &XrayManager{
|
||||
Config: cfg,
|
||||
AppRoot: t.TempDir(),
|
||||
}
|
||||
|
||||
outbound, ok, err := buildDirectProxyBridgeOutbound("socks5://user:pass@first-hop.invalid:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected bridge outbound for authenticated socks5 proxy")
|
||||
}
|
||||
|
||||
cfgPath, err := manager.buildRuntimeConfigWithRoute(
|
||||
"direct-auth-socks-test",
|
||||
[]interface{}{outbound},
|
||||
[]interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "proxy-out",
|
||||
},
|
||||
},
|
||||
19091,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime config failed: %v", err)
|
||||
}
|
||||
var runtimeConfig map[string]interface{}
|
||||
if err := json.Unmarshal(data, &runtimeConfig); err != nil {
|
||||
t.Fatalf("unmarshal runtime config failed: %v", err)
|
||||
}
|
||||
|
||||
outbounds := runtimeConfig["outbounds"].([]interface{})
|
||||
byTag := map[string]map[string]interface{}{}
|
||||
for _, item := range outbounds {
|
||||
current := item.(map[string]interface{})
|
||||
if tag, ok := current["tag"].(string); ok {
|
||||
byTag[tag] = current
|
||||
}
|
||||
}
|
||||
|
||||
proxyOut := byTag["proxy-out"]
|
||||
if proxyOut == nil {
|
||||
t.Fatalf("proxy-out outbound is missing: %+v", byTag)
|
||||
}
|
||||
if proxyOut["protocol"] != "socks" {
|
||||
t.Fatalf("proxy-out protocol = %v, want socks", proxyOut["protocol"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainHTTPOutboundUsesAuthenticatedHTTPServer(t *testing.T) {
|
||||
outbound := chainSocks5Outbound(chainSocks5Hop{
|
||||
Protocol: "http",
|
||||
Server: "first-hop.invalid",
|
||||
Port: 1080,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}, "first-hop", "")
|
||||
|
||||
if outbound["protocol"] != "http" {
|
||||
t.Fatalf("protocol = %v, want http", outbound["protocol"])
|
||||
}
|
||||
settings, ok := outbound["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("settings missing: %+v", outbound)
|
||||
}
|
||||
servers, ok := settings["servers"].([]interface{})
|
||||
if !ok || len(servers) != 1 {
|
||||
t.Fatalf("servers invalid: %+v", settings["servers"])
|
||||
}
|
||||
server, ok := servers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("server invalid: %+v", servers[0])
|
||||
}
|
||||
if server["address"] != "first-hop.invalid" {
|
||||
t.Fatalf("address = %v, want first-hop.invalid", server["address"])
|
||||
}
|
||||
if server["port"] != 1080 {
|
||||
t.Fatalf("port = %v, want 1080", server["port"])
|
||||
}
|
||||
users, ok := server["users"].([]interface{})
|
||||
if !ok || len(users) != 1 {
|
||||
t.Fatalf("users invalid: %+v", server["users"])
|
||||
}
|
||||
user, ok := users[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("user invalid: %+v", users[0])
|
||||
}
|
||||
if user["user"] != "user" || user["pass"] != "pass" {
|
||||
t.Fatalf("unexpected user payload: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainMixedHTTPAndSocksRuntimeConfig(t *testing.T) {
|
||||
chainConfig := "chain+socks5://%7B%22first%22%3A%7B%22protocol%22%3A%22http%22%2C%22server%22%3A%22127.0.0.1%22%2C%22port%22%3A8080%2C%22username%22%3A%22u1%22%2C%22password%22%3A%22p1%22%7D%2C%22second%22%3A%7B%22protocol%22%3A%22socks5%22%2C%22server%22%3A%22127.0.0.2%22%2C%22port%22%3A1080%2C%22username%22%3A%22u2%22%2C%22password%22%3A%22p2%22%7D%7D"
|
||||
chainCfg, err := ParseChainSocks5Config(chainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseChainSocks5Config returned error: %v", err)
|
||||
}
|
||||
|
||||
first := chainSocks5Outbound(chainCfg.First, "first-hop", "")
|
||||
second := chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop")
|
||||
if first["protocol"] != "http" {
|
||||
t.Fatalf("first protocol = %v, want http", first["protocol"])
|
||||
}
|
||||
if second["protocol"] != "socks" {
|
||||
t.Fatalf("second protocol = %v, want socks", second["protocol"])
|
||||
}
|
||||
proxySettings, ok := second["proxySettings"].(map[string]interface{})
|
||||
if !ok || proxySettings["tag"] != "first-hop" {
|
||||
t.Fatalf("second proxySettings invalid: %+v", second["proxySettings"])
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,22 @@ import (
|
||||
)
|
||||
|
||||
func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) {
|
||||
return m.buildRuntimeConfigWithRoute(
|
||||
key,
|
||||
[]interface{}{outbound},
|
||||
[]interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "proxy-out",
|
||||
},
|
||||
},
|
||||
port,
|
||||
dnsServers,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *XrayManager) buildRuntimeConfigWithRoute(key string, outbounds []interface{}, rules []interface{}, port int, dnsServers string) (string, error) {
|
||||
baseDir := m.resolveWorkdir(key)
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return "", err
|
||||
@@ -33,8 +49,7 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa
|
||||
},
|
||||
},
|
||||
},
|
||||
"outbounds": []interface{}{
|
||||
outbound,
|
||||
"outbounds": append(outbounds,
|
||||
map[string]interface{}{
|
||||
"protocol": "direct",
|
||||
"tag": "direct",
|
||||
@@ -43,15 +58,9 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa
|
||||
"protocol": "blackhole",
|
||||
"tag": "block",
|
||||
},
|
||||
},
|
||||
),
|
||||
"routing": map[string]interface{}{
|
||||
"rules": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "proxy-out",
|
||||
},
|
||||
},
|
||||
"rules": rules,
|
||||
},
|
||||
}
|
||||
if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package proxy
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -18,7 +20,7 @@ func TestValidateProxyConfigInvalidRawString(t *testing.T) {
|
||||
|
||||
func TestValidateProxyConfigMissingProxyId(t *testing.T) {
|
||||
ok, msg := ValidateProxyConfig("", []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "p1", ProxyConfig: "http://proxy.invalid:8080"},
|
||||
}, "missing-proxy")
|
||||
if ok {
|
||||
t.Fatalf("expected missing proxyId to fail validation")
|
||||
@@ -30,7 +32,7 @@ func TestValidateProxyConfigMissingProxyId(t *testing.T) {
|
||||
|
||||
func TestValidateProxyConfigMissingProxyIdFallbackToRawConfig(t *testing.T) {
|
||||
ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "p1", ProxyConfig: "http://proxy.invalid:8080"},
|
||||
}, "missing-proxy")
|
||||
if !ok {
|
||||
t.Fatalf("expected fallback proxyConfig to pass, msg=%s", msg)
|
||||
@@ -43,3 +45,172 @@ func TestValidateProxyConfigStandardProxy(t *testing.T) {
|
||||
t.Fatalf("expected standard proxy to pass: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyConfigChainSocks5Proxy(t *testing.T) {
|
||||
chainConfig := buildTestChainSocks5Config(t, 0)
|
||||
ok, msg := ValidateProxyConfig(chainConfig, nil, "")
|
||||
if !ok {
|
||||
t.Fatalf("expected chain proxy to pass: %s", msg)
|
||||
}
|
||||
if !RequiresBridge(chainConfig, nil, "") {
|
||||
t.Fatalf("expected chain proxy to require bridge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyConfigChainHTTPProxy(t *testing.T) {
|
||||
chainConfig := buildTestChainHTTPConfig(t)
|
||||
ok, msg := ValidateProxyConfig(chainConfig, nil, "")
|
||||
if !ok {
|
||||
t.Fatalf("expected chain http proxy to pass: %s", msg)
|
||||
}
|
||||
if !RequiresBridge(chainConfig, nil, "") {
|
||||
t.Fatalf("expected chain http proxy to require bridge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresLocalProxyBridgeForBrowserAuthenticatedSocks5(t *testing.T) {
|
||||
if !RequiresLocalProxyBridgeForBrowser("socks5://user:pass@127.0.0.1:1080") {
|
||||
t.Fatal("expected authenticated socks5 proxy to require browser bridge")
|
||||
}
|
||||
if !RequiresLocalProxyBridgeForBrowser("http://user:pass@127.0.0.1:8080") {
|
||||
t.Fatal("expected authenticated http proxy to require browser bridge")
|
||||
}
|
||||
if RequiresLocalProxyBridgeForBrowser("socks5://127.0.0.1:1080") {
|
||||
t.Fatal("expected unauthenticated socks5 proxy not to require browser bridge")
|
||||
}
|
||||
if RequiresLocalProxyBridgeForBrowser("http://127.0.0.1:8080") {
|
||||
t.Fatal("expected unauthenticated http proxy not to require browser bridge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectProxyBridgeOutboundAuthenticatedSocks5(t *testing.T) {
|
||||
outbound, ok, err := buildDirectProxyBridgeOutbound("socks5://user:pass@127.0.0.1:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected authenticated socks5 proxy to build bridge outbound")
|
||||
}
|
||||
|
||||
if outbound["protocol"] != "socks" {
|
||||
t.Fatalf("protocol = %v, want socks", outbound["protocol"])
|
||||
}
|
||||
settings, ok := outbound["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("settings missing: %+v", outbound)
|
||||
}
|
||||
servers, ok := settings["servers"].([]interface{})
|
||||
if !ok || len(servers) != 1 {
|
||||
t.Fatalf("servers invalid: %+v", settings["servers"])
|
||||
}
|
||||
server, ok := servers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("server invalid: %+v", servers[0])
|
||||
}
|
||||
if server["address"] != "127.0.0.1" {
|
||||
t.Fatalf("address = %v, want 127.0.0.1", server["address"])
|
||||
}
|
||||
if got := int(server["port"].(int)); got != 1080 {
|
||||
t.Fatalf("port = %d, want 1080", got)
|
||||
}
|
||||
users, ok := server["users"].([]interface{})
|
||||
if !ok || len(users) != 1 {
|
||||
t.Fatalf("users invalid: %+v", server["users"])
|
||||
}
|
||||
user, ok := users[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("user invalid: %+v", users[0])
|
||||
}
|
||||
if user["user"] != "user" || user["pass"] != "pass" {
|
||||
t.Fatalf("unexpected user payload: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectProxyBridgeOutboundAuthenticatedHTTP(t *testing.T) {
|
||||
outbound, ok, err := buildDirectProxyBridgeOutbound("http://user:pass@127.0.0.1:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected bridge outbound for authenticated http proxy")
|
||||
}
|
||||
if outbound["protocol"] != "http" {
|
||||
t.Fatalf("protocol = %v, want http", outbound["protocol"])
|
||||
}
|
||||
settings, ok := outbound["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("settings missing: %+v", outbound)
|
||||
}
|
||||
servers, ok := settings["servers"].([]interface{})
|
||||
if !ok || len(servers) != 1 {
|
||||
t.Fatalf("servers invalid: %+v", settings["servers"])
|
||||
}
|
||||
server, ok := servers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("server invalid: %+v", servers[0])
|
||||
}
|
||||
users, ok := server["users"].([]interface{})
|
||||
if !ok || len(users) != 1 {
|
||||
t.Fatalf("users invalid: %+v", server["users"])
|
||||
}
|
||||
user, ok := users[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("user invalid: %+v", users[0])
|
||||
}
|
||||
if user["user"] != "user" || user["pass"] != "pass" {
|
||||
t.Fatalf("unexpected user payload: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestChainSocks5Config(t *testing.T, localPort int) string {
|
||||
t.Helper()
|
||||
localPortField := ""
|
||||
if localPort > 0 {
|
||||
localPortField = fmt.Sprintf(`,"localPort":%d`, localPort)
|
||||
}
|
||||
raw := fmt.Sprintf(`{"first":{"protocol":"socks5","server":"127.0.0.1","port":1081,"username":"u1","password":"p1"},"second":{"protocol":"socks5","server":"127.0.0.2","port":1082}%s}`, localPortField)
|
||||
return "chain+socks5://" + url.QueryEscape(raw)
|
||||
}
|
||||
|
||||
func buildTestChainHTTPConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw := `{"first":{"protocol":"http","server":"first-hop.invalid","port":8080,"username":"u1","password":"p1"},"second":{"protocol":"http","server":"second-hop.invalid","port":8081,"username":"u2","password":"p2"}}`
|
||||
return "chain+socks5://" + url.QueryEscape(raw)
|
||||
}
|
||||
|
||||
func TestDirectProxyBridgeIgnoresClashYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
src := "name: JP-Vmess(Dmit) 1.5x\n" +
|
||||
"type: vmess\n" +
|
||||
"server: example.com\n" +
|
||||
"port: 10091\n" +
|
||||
"uuid: 5ef4299f-a5eb-4aaa-9bf5-60b541b19294\n" +
|
||||
"alterId: 0\n" +
|
||||
"cipher: auto\n" +
|
||||
"tls: true\n" +
|
||||
"network: ws\n" +
|
||||
"ws-opts:\n" +
|
||||
" path: /\n" +
|
||||
" headers:\n" +
|
||||
" Host: example.com\n"
|
||||
|
||||
outbound, ok, err := buildDirectProxyBridgeOutbound(src)
|
||||
if err != nil {
|
||||
t.Fatalf("buildDirectProxyBridgeOutbound returned error: %v", err)
|
||||
}
|
||||
if ok || outbound != nil {
|
||||
t.Fatalf("clash yaml must not be treated as direct proxy bridge: ok=%v outbound=%v", ok, outbound)
|
||||
}
|
||||
|
||||
standard, parsedOutbound, err := ParseProxyNode(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseProxyNode returned error: %v", err)
|
||||
}
|
||||
if standard != "" {
|
||||
t.Fatalf("expected xray outbound, got standard proxy %q", standard)
|
||||
}
|
||||
if parsedOutbound == nil || parsedOutbound["protocol"] != "vmess" {
|
||||
t.Fatalf("expected vmess outbound, got %#v", parsedOutbound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// 模拟数据库中实际存储的 Clash YAML 格式代理配置
|
||||
var testTrojanConfig = `- name: HK01|香港|x1.0
|
||||
var testTrojanConfig = `- name: Node01
|
||||
type: trojan
|
||||
server: trojan.example.com
|
||||
port: 443
|
||||
@@ -21,7 +21,7 @@ var testTrojanConfig = `- name: HK01|香港|x1.0
|
||||
|
||||
var testVmessConfig = `- name: DE-Vmess(NL1) 1x
|
||||
type: vmess
|
||||
server: 203.0.113.55
|
||||
server: proxy.invalid
|
||||
port: 443
|
||||
uuid: 11111111-1111-4111-8111-111111111111
|
||||
alterId: 0
|
||||
@@ -56,7 +56,7 @@ func TestProtocolDetection(t *testing.T) {
|
||||
{"vmess-clash", testVmessConfig},
|
||||
{"hysteria2-clash", testHysteria2Config},
|
||||
{"socks5-direct", "socks5://127.0.0.1:1080"},
|
||||
{"http-direct", "http://127.0.0.1:7890"},
|
||||
{"http-direct", "http://proxy.invalid:8080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -96,7 +96,7 @@ func TestSpeedTestWithMockProxies(t *testing.T) {
|
||||
{ProxyId: "test-trojan", ProxyName: "测试trojan", ProxyConfig: testTrojanConfig},
|
||||
{ProxyId: "test-vmess", ProxyName: "测试vmess", ProxyConfig: testVmessConfig},
|
||||
{ProxyId: "test-hysteria2", ProxyName: "测试hysteria2", ProxyConfig: testHysteria2Config},
|
||||
{ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://proxy.invalid:8080"},
|
||||
}
|
||||
|
||||
for _, p := range proxies {
|
||||
|
||||
+62
-55
@@ -1,62 +1,69 @@
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: data/app.db
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: data/app.db
|
||||
app:
|
||||
name: Ant Browser
|
||||
window:
|
||||
width: 1750
|
||||
height: 1000
|
||||
min_width: 1200
|
||||
min_height: 700
|
||||
max_profile_limit: 20
|
||||
used_cd_keys: []
|
||||
name: Ant Browser
|
||||
window:
|
||||
width: 1750
|
||||
height: 1000
|
||||
min_width: 1200
|
||||
min_height: 700
|
||||
max_profile_limit: 20
|
||||
used_cd_keys: []
|
||||
runtime:
|
||||
max_memory_mb: 0
|
||||
gc_percent: 100
|
||||
max_memory_mb: 0
|
||||
gc_percent: 100
|
||||
logging:
|
||||
level: info
|
||||
file_enabled: false
|
||||
file_path: data/logs/app.log
|
||||
format: text
|
||||
buffer_size: 4
|
||||
async_queue_size: 1000
|
||||
flush_interval_ms: 1000
|
||||
rotation:
|
||||
enabled: false
|
||||
max_size_mb: 100
|
||||
max_age: 7
|
||||
max_backups: 5
|
||||
time_interval: daily
|
||||
interceptor:
|
||||
enabled: true
|
||||
log_parameters: true
|
||||
log_results: true
|
||||
sensitive_fields:
|
||||
- password
|
||||
- token
|
||||
- secret
|
||||
level: info
|
||||
file_enabled: false
|
||||
file_path: data/logs/app.log
|
||||
format: text
|
||||
buffer_size: 4
|
||||
async_queue_size: 1000
|
||||
flush_interval_ms: 1000
|
||||
rotation:
|
||||
enabled: false
|
||||
max_size_mb: 100
|
||||
max_age: 7
|
||||
max_backups: 5
|
||||
time_interval: daily
|
||||
interceptor:
|
||||
enabled: true
|
||||
log_parameters: true
|
||||
log_results: true
|
||||
sensitive_fields:
|
||||
- password
|
||||
- token
|
||||
- secret
|
||||
browser:
|
||||
user_data_root: data
|
||||
default_fingerprint_args:
|
||||
- --fingerprint-brand=Chrome
|
||||
- --fingerprint-platform=windows
|
||||
default_launch_args:
|
||||
- --disable-sync
|
||||
- --no-first-run
|
||||
default_bookmarks: []
|
||||
cores: []
|
||||
proxies: []
|
||||
profiles: []
|
||||
user_data_root: data
|
||||
default_fingerprint_args:
|
||||
- --fingerprint-brand=Chrome
|
||||
- --fingerprint-platform=windows
|
||||
default_launch_args:
|
||||
- --disable-sync
|
||||
- --no-first-run
|
||||
default_start_urls: []
|
||||
restore_last_session: false
|
||||
start_ready_timeout_ms: 3000
|
||||
start_stable_window_ms: 1200
|
||||
proxy_check:
|
||||
bridge_start_timeout_ms: 15000
|
||||
speed_target_id: ""
|
||||
ip_health_target_id: ""
|
||||
targets: []
|
||||
launch_server:
|
||||
port: 19876
|
||||
port: 19876
|
||||
auth:
|
||||
enabled: false
|
||||
api_key: ""
|
||||
header: X-Ant-Api-Key
|
||||
automation:
|
||||
enabled: false
|
||||
install_policy: on_demand
|
||||
runtime_version: node-22.15.1-playwright-core-1.59.0
|
||||
headless_default: false
|
||||
keep_runtime_on_disable: true
|
||||
node_source: auto
|
||||
system_node_path: ""
|
||||
node_version: 22.15.1
|
||||
playwright_core_version: 1.59.0
|
||||
enabled: false
|
||||
install_policy: on_demand
|
||||
runtime_version: node-22.15.1-playwright-core-1.59.0
|
||||
keep_runtime_on_disable: true
|
||||
node_source: auto
|
||||
node_version: 22.15.1
|
||||
playwright_core_version: 1.59.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
bf6dd2f2f453474c0fc4b1cf2c98596b
|
||||
7b7cd01deb4f6d205b686dee62014883
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import { ThemeProvider } from "./shared/theme";
|
||||
import { Layout } from "./shared/layout";
|
||||
import { ToastContainer, Modal, Button, Loading } from "./shared/components";
|
||||
import { ToastContainer, Modal, Button, Loading, toast } from "./shared/components";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useNotificationStore } from "./store/notificationStore";
|
||||
import { useBackupStore } from "./store/backupStore";
|
||||
@@ -171,10 +171,11 @@ function useWailsNotifications() {
|
||||
"proxy:bridge:failed",
|
||||
(data: { profileId: string; profileName: string; error: string }) => {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "代理连接失败",
|
||||
message: `「${data.profileName || data.profileId}」代理桥接启动失败:${data.error}`,
|
||||
type: "warning",
|
||||
title: "代理已降级直连",
|
||||
message: `「${data.profileName || data.profileId}」${data.error}`,
|
||||
});
|
||||
toast.warning(`「${data.profileName || data.profileId}」代理桥接失败,已直连启动`, 6000);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from './api/instances'
|
||||
export * from './api/settings'
|
||||
export * from './api/cores'
|
||||
export * from './api/proxies'
|
||||
export * from './api/proxyCheck'
|
||||
export * from './api/cookies'
|
||||
export * from './api/snapshots'
|
||||
export * from './api/bookmarks'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserBookmark } from '../types'
|
||||
import type { BookmarkSyncResult, BrowserBookmark } from '../types'
|
||||
import { getBindings } from './runtime'
|
||||
|
||||
export async function fetchBookmarks(): Promise<BrowserBookmark[]> {
|
||||
@@ -7,11 +7,14 @@ export async function fetchBookmarks(): Promise<BrowserBookmark[]> {
|
||||
return (await bindings.BookmarkList()) || []
|
||||
}
|
||||
return [
|
||||
{ name: 'Google', url: 'https://www.google.com/' },
|
||||
{ name: 'Gmail', url: 'https://mail.google.com/' },
|
||||
{ name: 'Claude', url: 'https://claude.ai/' },
|
||||
{ name: 'ChatGPT', url: 'https://chatgpt.com/' },
|
||||
{ name: 'YouTube', url: 'https://www.youtube.com/' },
|
||||
{ name: 'Google', url: 'https://www.google.com/', openOnStart: false },
|
||||
{ name: 'Gmail', url: 'https://mail.google.com/', openOnStart: false },
|
||||
{ name: 'Claude', url: 'https://claude.ai/', openOnStart: false },
|
||||
{ name: 'ChatGPT', url: 'https://chatgpt.com/', openOnStart: false },
|
||||
{ name: 'YouTube', url: 'https://www.youtube.com/', openOnStart: false },
|
||||
{ name: 'IPPure', url: 'https://ippure.com/', openOnStart: false },
|
||||
{ name: 'IPLark', url: 'https://iplark.com/', openOnStart: false },
|
||||
{ name: 'Ping0', url: 'https://ping0.cc/', openOnStart: false },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,3 +35,18 @@ export async function resetBookmarks(): Promise<boolean> {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function syncBookmarksToProfiles(): Promise<BookmarkSyncResult> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.BookmarkSyncToProfiles) {
|
||||
return await bindings.BookmarkSyncToProfiles()
|
||||
}
|
||||
return {
|
||||
total: 0,
|
||||
synced: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
skippedList: [],
|
||||
failedList: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ export async function startBrowserInstance(profileId: string): Promise<BrowserPr
|
||||
return nextProfiles.find((item) => item.profileId === profileId) || null
|
||||
}
|
||||
|
||||
export async function startBrowserInstanceDirect(profileId: string): Promise<BrowserProfile | null> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.BrowserInstanceStartDirect) {
|
||||
return (await bindings.BrowserInstanceStartDirect(profileId)) || null
|
||||
}
|
||||
return startBrowserInstance(profileId)
|
||||
}
|
||||
|
||||
export async function startBrowserInstanceByCode(code: string): Promise<BrowserProfile | null> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.BrowserInstanceStartByCode) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
|
||||
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
|
||||
import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime'
|
||||
|
||||
export interface ClashImportURLResult {
|
||||
@@ -124,7 +124,7 @@ export async function browserProxyCheckIPHealth(proxyId: string): Promise<ProxyI
|
||||
(await bindings.BrowserProxyCheckIPHealth(proxyId)) || {
|
||||
proxyId,
|
||||
ok: false,
|
||||
source: 'ippure',
|
||||
source: 'ip_health',
|
||||
error: '调用失败',
|
||||
ip: '',
|
||||
fraudScore: 0,
|
||||
@@ -144,7 +144,7 @@ export async function browserProxyCheckIPHealth(proxyId: string): Promise<ProxyI
|
||||
return {
|
||||
proxyId,
|
||||
ok: true,
|
||||
source: 'ippure',
|
||||
source: 'ip_health',
|
||||
error: '',
|
||||
ip: '127.0.0.1',
|
||||
fraudScore: Math.floor(Math.random() * 100),
|
||||
@@ -169,7 +169,7 @@ export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurr
|
||||
return proxyIds.map((proxyId) => ({
|
||||
proxyId,
|
||||
ok: true,
|
||||
source: 'ippure',
|
||||
source: 'ip_health',
|
||||
error: '',
|
||||
ip: '127.0.0.1',
|
||||
fraudScore: Math.floor(Math.random() * 100),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ProxyCheckSettings } from '../types'
|
||||
import { getBindings } from './runtime'
|
||||
|
||||
export function createDefaultProxyCheckSettings(): ProxyCheckSettings {
|
||||
return {
|
||||
bridgeStartTimeoutMs: 15000,
|
||||
speedTargetId: '',
|
||||
ipHealthTargetId: '',
|
||||
targets: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProxyCheckSettings(): Promise<ProxyCheckSettings> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.GetProxyCheckSettings) {
|
||||
return (await bindings.GetProxyCheckSettings()) || createDefaultProxyCheckSettings()
|
||||
}
|
||||
return createDefaultProxyCheckSettings()
|
||||
}
|
||||
|
||||
export async function saveProxyCheckSettings(settings: ProxyCheckSettings): Promise<boolean> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.SaveProxyCheckSettings) {
|
||||
await bindings.SaveProxyCheckSettings(settings)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserCore, BrowserProfile, BrowserProxy, BrowserSettings } from '../types'
|
||||
import type { BrowserCore, BrowserProfile, BrowserProxy, BrowserSettings } from '../types'
|
||||
|
||||
export async function getBindings() {
|
||||
try {
|
||||
@@ -21,11 +21,7 @@ export function createDefaultBrowserSettings(): BrowserSettings {
|
||||
userDataRoot: 'data',
|
||||
defaultFingerprintArgs: [],
|
||||
defaultLaunchArgs: [],
|
||||
defaultStartUrls: [
|
||||
'https://ippure.com/',
|
||||
'https://iplark.com/',
|
||||
'https://ping0.cc/',
|
||||
],
|
||||
defaultStartUrls: [],
|
||||
restoreLastSession: false,
|
||||
startReadyTimeoutMs: 3000,
|
||||
startStableWindowMs: 1200,
|
||||
|
||||
@@ -107,6 +107,7 @@ function normalizeAutomationScriptRunInput(
|
||||
paramsText: "",
|
||||
useScriptSelector: true,
|
||||
useScriptParams: true,
|
||||
timeoutMs: 0,
|
||||
launchCode: "",
|
||||
startByCodeBeforeRun: false,
|
||||
};
|
||||
@@ -118,6 +119,9 @@ function normalizeAutomationScriptRunInput(
|
||||
paramsText: String(input?.paramsText || ""),
|
||||
useScriptSelector: input?.useScriptSelector !== false,
|
||||
useScriptParams: input?.useScriptParams !== false,
|
||||
timeoutMs: Number.isFinite(Number(input?.timeoutMs))
|
||||
? Math.round(Number(input?.timeoutMs))
|
||||
: 0,
|
||||
launchCode: String(input?.launchCode || "")
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AutomationScriptRunInput {
|
||||
paramsText?: string;
|
||||
useScriptSelector?: boolean;
|
||||
useScriptParams?: boolean;
|
||||
timeoutMs?: number;
|
||||
launchCode?: string;
|
||||
startByCodeBeforeRun?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Play } from "lucide-react";
|
||||
import { Copy, FileText, FolderOpen, Play } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import {
|
||||
copyBrowserProfile,
|
||||
fetchBrowserProfiles,
|
||||
openCorePath,
|
||||
} from "../api";
|
||||
import { runAutomationScript } from "../automationScriptApi";
|
||||
import {
|
||||
@@ -39,6 +41,12 @@ interface DemoCreateDraft {
|
||||
templateProfileId: string;
|
||||
}
|
||||
|
||||
interface ResultOutputEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface AutomationScriptRunModalProps {
|
||||
open: boolean;
|
||||
script: AutomationScriptRecord | null;
|
||||
@@ -95,6 +103,105 @@ function formatDuration(durationMs?: number): string {
|
||||
return `${(durationMs / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
function parseRunResultOutputs(resultText?: string): ResultOutputEntry[] {
|
||||
const normalized = String(resultText || "").trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const outputs: ResultOutputEntry[] = [];
|
||||
|
||||
const addOutput = (key: string, value: string) => {
|
||||
const path = value.trim();
|
||||
if (!path || seen.has(path)) {
|
||||
return;
|
||||
}
|
||||
seen.add(path);
|
||||
outputs.push({
|
||||
key,
|
||||
label: formatRunResultOutputLabel(key),
|
||||
path,
|
||||
});
|
||||
};
|
||||
|
||||
const collectOutputs = (value: unknown, keyHint = "") => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (/path$/i.test(keyHint)) {
|
||||
addOutput(keyHint, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (keyHint === "artifacts") {
|
||||
value.forEach((item) => {
|
||||
if (typeof item === "string") {
|
||||
addOutput(keyHint, item);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
value.forEach((item) => collectOutputs(item, keyHint));
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [nestedKey, nestedValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
collectOutputs(nestedValue, nestedKey);
|
||||
}
|
||||
};
|
||||
|
||||
collectOutputs(parsed);
|
||||
return outputs;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatRunResultOutputLabel(key: string): string {
|
||||
switch (key) {
|
||||
case "outputPath":
|
||||
return "输出文件";
|
||||
case "screenshotPath":
|
||||
return "截图文件";
|
||||
case "artifacts":
|
||||
return "导出文件";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRunResultOutputName(path: string): string {
|
||||
const segments = path.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
function formatRunResultText(resultText?: string): string {
|
||||
const normalized = String(resultText || "").trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(normalized), null, 2);
|
||||
} catch {
|
||||
return resultText || "";
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, successMessage: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -134,7 +241,7 @@ function isPlaceholderSelectorText(text: string): boolean {
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
: "";
|
||||
return !code || code === "BUYER_001";
|
||||
return !code || code === "BUYER_001" || code === "DEMO_ABC123";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -251,10 +358,20 @@ function resolvePreferredProfileId(
|
||||
function buildSelectableProfileOptions(profiles: SelectableProfile[]) {
|
||||
return profiles.map((profile) => ({
|
||||
value: profile.profileId,
|
||||
label: `${profile.launchCode} · ${profile.profileName} · ${profile.running ? "运行中" : "已停止"}`,
|
||||
label: `${profile.launchCode} · ${profile.profileName} · ${formatSelectableProfileStatus(profile)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatSelectableProfileStatus(profile: SelectableProfile): string {
|
||||
if (profile.running && profile.debugReady && profile.debugPort > 0) {
|
||||
return "可连接";
|
||||
}
|
||||
if (profile.running) {
|
||||
return "启动中";
|
||||
}
|
||||
return "未启动,执行时自动启动";
|
||||
}
|
||||
|
||||
function sortTemplateProfiles(profiles: BrowserProfile[]) {
|
||||
return [...profiles].sort((left, right) =>
|
||||
left.profileName.localeCompare(right.profileName, "zh-CN"),
|
||||
@@ -276,6 +393,7 @@ export function AutomationScriptRunModal({
|
||||
dirty = false,
|
||||
onClose,
|
||||
}: AutomationScriptRunModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const [selectorText, setSelectorText] = useState("");
|
||||
const [paramsText, setParamsText] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
@@ -356,22 +474,35 @@ export function AutomationScriptRunModal({
|
||||
try {
|
||||
const allProfiles = await fetchBrowserProfiles();
|
||||
const profiles = filterSelectableProfiles(allProfiles);
|
||||
setAvailableProfiles(profiles);
|
||||
setTemplateProfiles(sortTemplateProfiles(allProfiles));
|
||||
setSelectedProfileId((current) => {
|
||||
const preferredProfile = resolvePreferredProfileId(
|
||||
const nextSelectedProfileId =
|
||||
resolvePreferredProfileId(
|
||||
profiles,
|
||||
preferredProfileId,
|
||||
preferredLaunchCode,
|
||||
) ||
|
||||
(selectedProfileId &&
|
||||
profiles.some((profile) => profile.profileId === selectedProfileId)
|
||||
? selectedProfileId
|
||||
: profiles[0]?.profileId || "");
|
||||
const nextSelectedProfile =
|
||||
profiles.find((profile) => profile.profileId === nextSelectedProfileId) ||
|
||||
null;
|
||||
|
||||
setAvailableProfiles(profiles);
|
||||
setTemplateProfiles(sortTemplateProfiles(allProfiles));
|
||||
setSelectedProfileId(nextSelectedProfileId);
|
||||
if (demoMode === "select" && nextSelectedProfile) {
|
||||
const nextSelectorText = buildDemoSelectorText(
|
||||
nextSelectedProfile.launchCode,
|
||||
);
|
||||
if (preferredProfile) {
|
||||
return preferredProfile;
|
||||
if (
|
||||
resolveSelectorLaunchCode(selectorText) !==
|
||||
nextSelectedProfile.launchCode
|
||||
) {
|
||||
setSelectorText(nextSelectorText);
|
||||
}
|
||||
if (current && profiles.some((profile) => profile.profileId === current)) {
|
||||
return current;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
syncDemoSessionFromProfile(nextSelectedProfile, "选择已有实例");
|
||||
}
|
||||
setCreateDraft((current) => {
|
||||
if (
|
||||
current.templateProfileId &&
|
||||
@@ -436,7 +567,7 @@ export function AutomationScriptRunModal({
|
||||
resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode,
|
||||
false,
|
||||
);
|
||||
}, [open, reloadDemoSession, script, usesStoredTargetConfig]);
|
||||
}, [open, script, usesStoredTargetConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !script || script.type !== "playwright-cdp") {
|
||||
@@ -585,6 +716,14 @@ export function AutomationScriptRunModal({
|
||||
selectorText,
|
||||
demoSession,
|
||||
);
|
||||
if (
|
||||
script.type === "playwright-cdp" &&
|
||||
!usesStoredTargetConfig &&
|
||||
demoMode === "select" &&
|
||||
selectedProfile
|
||||
) {
|
||||
nextSelectorText = buildDemoSelectorText(selectedProfile.launchCode);
|
||||
}
|
||||
const selectorError = usesStoredTargetConfig
|
||||
? ""
|
||||
: validateJsonObjectText(
|
||||
@@ -628,6 +767,14 @@ export function AutomationScriptRunModal({
|
||||
if (nextSelectorText !== selectorText) {
|
||||
setSelectorText(nextSelectorText);
|
||||
}
|
||||
if (
|
||||
script.type === "playwright-cdp" &&
|
||||
!usesStoredTargetConfig &&
|
||||
demoMode === "select" &&
|
||||
selectedProfile
|
||||
) {
|
||||
syncDemoSessionFromProfile(selectedProfile, "选择已有实例");
|
||||
}
|
||||
|
||||
await executeRun(nextSelectorText, paramsText);
|
||||
};
|
||||
@@ -649,6 +796,23 @@ export function AutomationScriptRunModal({
|
||||
await handleRun();
|
||||
};
|
||||
|
||||
const handleOpenScriptDetail = () => {
|
||||
if (!script || running || demoBusy) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
navigate(`/browser/automation/${script.id}`);
|
||||
};
|
||||
|
||||
const handleOpenOutputPath = async (path: string) => {
|
||||
try {
|
||||
await openCorePath(path);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "打开目录失败";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!script) {
|
||||
return null;
|
||||
}
|
||||
@@ -658,6 +822,8 @@ export function AutomationScriptRunModal({
|
||||
script.type === "playwright-cdp" && !usesStoredTargetConfig;
|
||||
const selectableProfileOptions = buildSelectableProfileOptions(availableProfiles);
|
||||
const templateProfileOptions = buildTemplateProfileOptions(templateProfiles);
|
||||
const resultOutputs = parseRunResultOutputs(lastRun?.resultText);
|
||||
const formattedResultText = formatRunResultText(lastRun?.resultText);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -687,36 +853,49 @@ export function AutomationScriptRunModal({
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)] px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={script.type === "launch-api" ? "info" : "default"}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={script.type === "launch-api" ? "info" : "default"}
|
||||
size="sm"
|
||||
>
|
||||
{getAutomationScriptTypeLabel(script.type)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
script.status === "ready"
|
||||
? "success"
|
||||
: script.status === "disabled"
|
||||
? "default"
|
||||
: "warning"
|
||||
}
|
||||
size="sm"
|
||||
dot
|
||||
>
|
||||
{script.status === "ready"
|
||||
? "可用"
|
||||
: script.status === "disabled"
|
||||
? "停用"
|
||||
: "草稿"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-[var(--color-text-primary)]">
|
||||
{script.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
最近更新 {formatDateTime(script.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleOpenScriptDetail}
|
||||
disabled={running || demoBusy}
|
||||
>
|
||||
{getAutomationScriptTypeLabel(script.type)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
script.status === "ready"
|
||||
? "success"
|
||||
: script.status === "disabled"
|
||||
? "default"
|
||||
: "warning"
|
||||
}
|
||||
size="sm"
|
||||
dot
|
||||
>
|
||||
{script.status === "ready"
|
||||
? "可用"
|
||||
: script.status === "disabled"
|
||||
? "停用"
|
||||
: "草稿"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-[var(--color-text-primary)]">
|
||||
{script.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
最近更新 {formatDateTime(script.updatedAt)}
|
||||
<FileText className="h-4 w-4" />
|
||||
查看脚本详情
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -794,7 +973,6 @@ export function AutomationScriptRunModal({
|
||||
disabled={
|
||||
running ||
|
||||
demoBusy ||
|
||||
profilesLoading ||
|
||||
selectableProfileOptions.length === 0
|
||||
}
|
||||
/>
|
||||
@@ -914,7 +1092,7 @@ export function AutomationScriptRunModal({
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
void copyToClipboard(lastRun.resultText, "执行结果已复制")
|
||||
void copyToClipboard(formattedResultText, "执行结果已复制")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -923,10 +1101,39 @@ export function AutomationScriptRunModal({
|
||||
</div>
|
||||
<Textarea
|
||||
rows={10}
|
||||
value={lastRun.resultText}
|
||||
value={formattedResultText}
|
||||
readOnly
|
||||
className="font-mono"
|
||||
/>
|
||||
{resultOutputs.length > 0 && (
|
||||
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
{resultOutputs.map((output) => (
|
||||
<div
|
||||
key={`${output.key}-${output.path}`}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-[var(--color-text-primary)]">
|
||||
{output.label} · {formatRunResultOutputName(output.path)}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-xs text-[var(--color-text-muted)]">
|
||||
{output.path}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleOpenOutputPath(output.path)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
打开文件夹
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, XCircle } from 'lucide-react'
|
||||
|
||||
import { Button, Card, FormItem, Input, Modal, StatCard, Switch, Table, Textarea } from '../../../shared/components'
|
||||
@@ -250,7 +250,7 @@ export function BrowserListSettingsModal({
|
||||
value={startUrlsText}
|
||||
onChange={(event) => onStartUrlsTextChange(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="https://ippure.com/"
|
||||
placeholder="启动 URL"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开默认启动页或空白页">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle, Edit2, Plus, Star, Trash2, XCircle } from 'lucide-react'
|
||||
import { Button, Card, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components'
|
||||
import type { TableColumn } from '../../../shared/components/Table'
|
||||
@@ -132,7 +132,7 @@ export function BrowserSettingsModal({ open, onClose, settings: initSettings, co
|
||||
<Textarea value={launchText} onChange={e => setLaunchText(e.target.value)} rows={3} placeholder="--disable-sync" />
|
||||
</FormItem>
|
||||
<FormItem label="默认启动页面(每行一个 URL)">
|
||||
<Textarea value={startUrlsText} onChange={e => setStartUrlsText(e.target.value)} rows={4} placeholder="https://ippure.com/" />
|
||||
<Textarea value={startUrlsText} onChange={e => setStartUrlsText(e.target.value)} rows={4} placeholder="启动 URL" />
|
||||
</FormItem>
|
||||
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开上面配置的默认页面或空白页">
|
||||
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border-default)] px-3 py-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, RefreshCw, Wand2 } from 'lucide-react'
|
||||
import { ConfirmModal, FormItem, Input, Select, Textarea } from '../../../shared/components'
|
||||
import {
|
||||
@@ -60,7 +60,7 @@ const TIMEZONE_OPTIONS = [
|
||||
{ value: 'America/Denver', label: 'America/Denver (UTC-7)' },
|
||||
{ value: 'America/Toronto', label: 'America/Toronto (UTC-5)' },
|
||||
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (UTC-3)' },
|
||||
// 欧洲
|
||||
// EMEA
|
||||
{ value: 'Europe/London', label: 'Europe/London (UTC+0)' },
|
||||
{ value: 'Europe/Paris', label: 'Europe/Paris (UTC+1)' },
|
||||
{ value: 'Europe/Berlin', label: 'Europe/Berlin (UTC+1)' },
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import yaml from 'js-yaml'
|
||||
import { Button, FormItem, Input, Modal, Select, Table, Textarea, toast } from '../../../shared/components'
|
||||
import type { TableColumn } from '../../../shared/components/Table'
|
||||
import type { BrowserProxy } from '../types'
|
||||
import { fetchClashImportFromURL, saveBrowserProxies } from '../api'
|
||||
import { DIRECT_QUICK_IMPORT_TEMPLATE, buildDirectImportCandidatesFromText, parseDirectImportText } from '../pages/proxyPool/helpers'
|
||||
|
||||
interface ProxyImportModalProps {
|
||||
open: boolean
|
||||
@@ -35,6 +36,7 @@ interface DirectImportForm {
|
||||
}
|
||||
|
||||
interface ChainHopForm {
|
||||
protocol: 'http' | 'socks5'
|
||||
server: string
|
||||
port: string
|
||||
username: string
|
||||
@@ -67,12 +69,14 @@ const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
|
||||
proxyName: '',
|
||||
localPort: '',
|
||||
first: {
|
||||
protocol: 'http',
|
||||
server: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
second: {
|
||||
protocol: 'http',
|
||||
server: '',
|
||||
port: '',
|
||||
username: '',
|
||||
@@ -83,6 +87,7 @@ const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
|
||||
interface ImportCandidate {
|
||||
proxyName: string
|
||||
proxyConfig: string
|
||||
groupName?: string
|
||||
}
|
||||
|
||||
interface ProxyDisplayInfo {
|
||||
@@ -98,7 +103,7 @@ interface ProxyDisplayInfo {
|
||||
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
|
||||
|
||||
interface ChainSocks5HopConfig {
|
||||
protocol: 'socks5'
|
||||
protocol: 'http' | 'socks5'
|
||||
server: string
|
||||
port: number
|
||||
username?: string
|
||||
@@ -125,7 +130,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const hop = raw as Record<string, unknown>
|
||||
const protocol = String(hop.protocol || '').trim().toLowerCase()
|
||||
if (protocol && protocol !== 'socks5') return null
|
||||
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
|
||||
|
||||
const server = String(hop.server || '').trim()
|
||||
if (!server) return null
|
||||
@@ -138,7 +143,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
|
||||
if (password && !username) return null
|
||||
|
||||
return {
|
||||
protocol: 'socks5',
|
||||
protocol: protocol === 'http' ? 'http' : 'socks5',
|
||||
server,
|
||||
port: portVal,
|
||||
username: username || undefined,
|
||||
@@ -380,6 +385,7 @@ function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
|
||||
|
||||
function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
|
||||
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
|
||||
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
|
||||
const server = hop.server.trim()
|
||||
if (!server) {
|
||||
throw new Error(`请输入${label}代理地址`)
|
||||
@@ -408,7 +414,7 @@ function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: 'socks5',
|
||||
protocol,
|
||||
server,
|
||||
port,
|
||||
username: username || undefined,
|
||||
@@ -459,7 +465,7 @@ function buildImportPreview(candidates: ImportCandidate[], groupName: string): P
|
||||
proxyId: `preview-${index}`,
|
||||
proxyName: candidate.proxyName,
|
||||
proxyConfig: candidate.proxyConfig,
|
||||
groupName,
|
||||
groupName: candidate.groupName || groupName,
|
||||
type: info.type || '-',
|
||||
server: info.server || '-',
|
||||
port: info.port || 0,
|
||||
@@ -563,6 +569,7 @@ export function ProxyImportModal({
|
||||
const [importDnsServers, setImportDnsServers] = useState('')
|
||||
const [importNamePrefix, setImportNamePrefix] = useState('')
|
||||
const [importGroupName, setImportGroupName] = useState('')
|
||||
const [directImportText, setDirectImportText] = useState('')
|
||||
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
|
||||
const [chainImportForm, setChainImportForm] = useState<ChainImportForm>(() => ({ ...INITIAL_CHAIN_IMPORT_FORM }))
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
@@ -583,6 +590,7 @@ export function ProxyImportModal({
|
||||
setImportDnsServers('')
|
||||
setImportNamePrefix('')
|
||||
setImportGroupName('')
|
||||
setDirectImportText('')
|
||||
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
|
||||
setChainImportForm({ ...INITIAL_CHAIN_IMPORT_FORM })
|
||||
setPreviewList([])
|
||||
@@ -644,16 +652,28 @@ export function ProxyImportModal({
|
||||
const handleParseImport = () => {
|
||||
try {
|
||||
const prefix = importNamePrefix.trim()
|
||||
const candidates = importMode === 'clash'
|
||||
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
|
||||
: importMode === 'direct'
|
||||
? [buildDirectImportCandidate(directImportForm)]
|
||||
: [buildChainImportCandidate(chainImportForm)]
|
||||
let candidates
|
||||
let previewGroupName = importGroupName.trim()
|
||||
if (importMode === 'clash') {
|
||||
candidates = buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
|
||||
} else if (importMode === 'direct') {
|
||||
if (directImportText.trim()) {
|
||||
const parsed = buildDirectImportCandidatesFromText(directImportText)
|
||||
candidates = parsed.candidates
|
||||
if (!previewGroupName) {
|
||||
previewGroupName = parsed.defaultGroupName
|
||||
}
|
||||
} else {
|
||||
candidates = [buildDirectImportCandidate(directImportForm)]
|
||||
}
|
||||
} else {
|
||||
candidates = [buildChainImportCandidate(chainImportForm)]
|
||||
}
|
||||
if (!candidates.length) {
|
||||
toast.error('未解析到可导入代理')
|
||||
return
|
||||
}
|
||||
const preview = buildImportPreview(candidates, importGroupName.trim())
|
||||
const preview = buildImportPreview(candidates, previewGroupName)
|
||||
setPreviewList(preview)
|
||||
setPreviewModalOpen(true)
|
||||
} catch (error: any) {
|
||||
@@ -661,6 +681,36 @@ export function ProxyImportModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleFillDirectTemplate = () => {
|
||||
setDirectImportText(DIRECT_QUICK_IMPORT_TEMPLATE)
|
||||
}
|
||||
|
||||
const handleCopyDirectTemplate = async () => {
|
||||
try {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
throw new Error('当前环境不支持剪贴板')
|
||||
}
|
||||
await navigator.clipboard.writeText(DIRECT_QUICK_IMPORT_TEMPLATE)
|
||||
toast.success('JSON 模板已复制')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '复制模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyDirectText = () => {
|
||||
try {
|
||||
const { form, groupName } = parseDirectImportText(directImportText)
|
||||
setDirectImportForm(form)
|
||||
if (groupName) {
|
||||
setImportGroupName(groupName)
|
||||
}
|
||||
setDirectImportText('')
|
||||
toast.success('文本已应用')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '文本应用失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmImport = async () => {
|
||||
if (previewList.length === 0) {
|
||||
toast.error('请至少保留 1 个代理后再导入')
|
||||
@@ -687,7 +737,7 @@ export function ProxyImportModal({
|
||||
proxyName: p.proxyName,
|
||||
proxyConfig: p.proxyConfig,
|
||||
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
|
||||
groupName: importGroupName.trim() || undefined,
|
||||
groupName: p.groupName.trim() || undefined,
|
||||
sourceId: sourceID || undefined,
|
||||
sourceUrl: sourceURL || undefined,
|
||||
sourceNamePrefix: sourceNamePrefix || undefined,
|
||||
@@ -719,8 +769,11 @@ export function ProxyImportModal({
|
||||
const canParseImport = importMode === 'clash'
|
||||
? !!importText.trim()
|
||||
: importMode === 'direct'
|
||||
? !!directImportForm.server.trim() && !!directImportForm.port.trim()
|
||||
: !!chainImportForm.first.server.trim() && !!chainImportForm.first.port.trim() && !!chainImportForm.second.server.trim() && !!chainImportForm.second.port.trim()
|
||||
? !!directImportText.trim() || (!!directImportForm.server.trim() && !!directImportForm.port.trim())
|
||||
: !!chainImportForm.first.server.trim()
|
||||
&& !!chainImportForm.first.port.trim()
|
||||
&& !!chainImportForm.second.server.trim()
|
||||
&& !!chainImportForm.second.port.trim()
|
||||
|
||||
const previewColumns = useMemo<TableColumn<ProxyDisplayInfo>[]>(() => [
|
||||
{ key: 'proxyName', title: '代理名称', width: '200px' },
|
||||
@@ -769,7 +822,7 @@ export function ProxyImportModal({
|
||||
variant={importMode === 'direct' ? undefined : 'secondary'}
|
||||
onClick={() => handleImportModeChange('direct')}
|
||||
>
|
||||
HTTP / SOCKS5(测试中)
|
||||
HTTP / SOCKS5
|
||||
</Button>
|
||||
<Button
|
||||
variant={importMode === 'chain' ? undefined : 'secondary'}
|
||||
@@ -782,7 +835,7 @@ export function ProxyImportModal({
|
||||
{importMode === 'clash'
|
||||
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)'
|
||||
: importMode === 'direct'
|
||||
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'
|
||||
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
|
||||
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
|
||||
</p>
|
||||
{importMode === 'clash' && (
|
||||
@@ -798,7 +851,7 @@ export function ProxyImportModal({
|
||||
setImportResolvedUrl('')
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com/clash/subscription"
|
||||
placeholder="订阅 URL"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
@@ -826,52 +879,76 @@ export function ProxyImportModal({
|
||||
</>
|
||||
)}
|
||||
{importMode === 'direct' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理协议" required>
|
||||
<Select
|
||||
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
|
||||
value={directImportForm.protocol}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理名称(可选)">
|
||||
<Input
|
||||
value={directImportForm.proxyName}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
|
||||
placeholder="例如:香港节点"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={directImportForm.server}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
|
||||
placeholder="例如:127.0.0.1 或 hk.example.com"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={directImportForm.port}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
|
||||
placeholder="例如:1080"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={directImportForm.username}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="留空则不使用认证"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={directImportForm.password}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="留空则不使用密码"
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理协议" required>
|
||||
<Select
|
||||
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
|
||||
value={directImportForm.protocol}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理名称(可选)">
|
||||
<Input
|
||||
value={directImportForm.proxyName}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
|
||||
placeholder="节点名称"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={directImportForm.server}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
|
||||
placeholder="例如:127.0.0.1 或 hk.example.com"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={directImportForm.port}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
|
||||
placeholder="例如:1080"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={directImportForm.username}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="留空则不使用认证"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={directImportForm.password}
|
||||
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="留空则不使用密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="文本辅助(可选)" hint="支持单个 JSON、JSON 数组,或多行 http:// / https:// / socks5://,每行一个">
|
||||
<Textarea
|
||||
value={directImportText}
|
||||
onChange={e => setDirectImportText(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={DIRECT_QUICK_IMPORT_TEMPLATE}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleFillDirectTemplate}>
|
||||
填入模板
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => void handleCopyDirectTemplate()}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleApplyDirectText} disabled={!directImportText.trim()}>
|
||||
应用文本
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
留空则按上方表单导入;有内容则点击“解析”按文本直接导入,可批量。
|
||||
</p>
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
@@ -882,7 +959,7 @@ export function ProxyImportModal({
|
||||
<Input
|
||||
value={chainImportForm.proxyName}
|
||||
onChange={e => setChainImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
|
||||
placeholder="例如:双层香港链路"
|
||||
placeholder="链路名称"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="本地监听端口(可选)">
|
||||
@@ -898,8 +975,18 @@ export function ProxyImportModal({
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层 SOCKS5</h4>
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainImportForm.first.protocol}
|
||||
onChange={e => updateChainHop('first', 'protocol', e.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainImportForm.first.server}
|
||||
@@ -936,8 +1023,18 @@ export function ProxyImportModal({
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层 SOCKS5</h4>
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainImportForm.second.protocol}
|
||||
onChange={e => updateChainHop('second', 'protocol', e.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainImportForm.second.server}
|
||||
@@ -979,7 +1076,7 @@ export function ProxyImportModal({
|
||||
<Input
|
||||
value={importGroupName}
|
||||
onChange={e => setImportGroupName(e.target.value)}
|
||||
placeholder="例如:香港、美国、机场A"
|
||||
placeholder="分组名称"
|
||||
list="proxy-groups-datalist"
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, Loader2, Pencil, Plus, Search, Trash2, Wifi, X } from 'lucide-react'
|
||||
import { Button, ConfirmModal, FormItem, Input, Modal, Textarea, toast } from '../../../shared/components'
|
||||
import { Button, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components'
|
||||
import type { BrowserProxy } from '../types'
|
||||
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies } from '../api'
|
||||
import { EventsOn } from '../../../wailsjs/runtime/runtime'
|
||||
@@ -19,7 +19,7 @@ interface ProxyPickerModalProps {
|
||||
type SpeedResult = { ok: boolean; latencyMs: number; error: string }
|
||||
|
||||
type ChainSocksHop = {
|
||||
protocol?: string
|
||||
protocol?: 'http' | 'socks5'
|
||||
server?: string
|
||||
port?: number
|
||||
username?: string
|
||||
@@ -33,6 +33,7 @@ type ChainSocksConfig = {
|
||||
}
|
||||
|
||||
interface ChainHopForm {
|
||||
protocol: 'http' | 'socks5'
|
||||
server: string
|
||||
port: string
|
||||
username: string
|
||||
@@ -49,12 +50,10 @@ interface ChainEditForm {
|
||||
const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
|
||||
proxyName: '',
|
||||
localPort: '',
|
||||
first: { server: '', port: '', username: '', password: '' },
|
||||
second: { server: '', port: '', username: '', password: '' },
|
||||
first: { protocol: 'http', server: '', port: '', username: '', password: '' },
|
||||
second: { protocol: 'http', server: '', port: '', username: '', password: '' },
|
||||
}
|
||||
|
||||
const LOCAL_PROXY_ID = '__local__'
|
||||
|
||||
function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
|
||||
const cfg = proxyConfig.trim()
|
||||
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
|
||||
@@ -69,7 +68,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const hop = raw as Record<string, unknown>
|
||||
const protocol = String(hop.protocol || '').trim().toLowerCase()
|
||||
if (protocol && protocol !== 'socks5') return null
|
||||
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
|
||||
|
||||
const server = String(hop.server || '').trim()
|
||||
if (!server) return null
|
||||
@@ -82,7 +81,7 @@ function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
|
||||
if (password && !username) return null
|
||||
|
||||
return {
|
||||
protocol: 'socks5',
|
||||
protocol: protocol === 'http' ? 'http' : 'socks5',
|
||||
server,
|
||||
port: portVal,
|
||||
username: username || undefined,
|
||||
@@ -118,12 +117,14 @@ function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditFor
|
||||
proxyName,
|
||||
localPort: cfg.localPort ? String(cfg.localPort) : '',
|
||||
first: {
|
||||
protocol: cfg.first?.protocol || 'socks5',
|
||||
server: cfg.first?.server || '',
|
||||
port: cfg.first?.port ? String(cfg.first.port) : '',
|
||||
username: cfg.first?.username || '',
|
||||
password: cfg.first?.password || '',
|
||||
},
|
||||
second: {
|
||||
protocol: cfg.second?.protocol || 'socks5',
|
||||
server: cfg.second?.server || '',
|
||||
port: cfg.second?.port ? String(cfg.second.port) : '',
|
||||
username: cfg.second?.username || '',
|
||||
@@ -134,6 +135,7 @@ function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditFor
|
||||
|
||||
function buildChainProxyConfig(form: ChainEditForm): string {
|
||||
const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
|
||||
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
|
||||
const server = hop.server.trim()
|
||||
if (!server) {
|
||||
throw new Error(`请输入${label}代理地址`)
|
||||
@@ -162,7 +164,7 @@ function buildChainProxyConfig(form: ChainEditForm): string {
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: 'socks5',
|
||||
protocol,
|
||||
server,
|
||||
port,
|
||||
username: username || undefined,
|
||||
@@ -499,7 +501,7 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
|
||||
|
||||
const handleDeleteClick = (proxy: BrowserProxy, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (proxy.proxyId === DIRECT_PROXY_ID || proxy.proxyId === LOCAL_PROXY_ID) return
|
||||
if (proxy.proxyId === DIRECT_PROXY_ID) return
|
||||
setDeleteCandidate(proxy)
|
||||
}
|
||||
|
||||
@@ -639,12 +641,12 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
|
||||
setEditName(e.target.value)
|
||||
}
|
||||
}}
|
||||
placeholder="例如:香港节点"
|
||||
placeholder="节点名称"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="分组名称(可选)">
|
||||
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="例如:香港、美国" />
|
||||
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="分组名称" />
|
||||
</FormItem>
|
||||
|
||||
{chainEditMode ? (
|
||||
@@ -661,8 +663,18 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
|
||||
</FormItem>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层 SOCKS5</h4>
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainEditForm.first.protocol}
|
||||
onChange={e => updateChainHop('first', 'protocol', e.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input value={chainEditForm.first.server} onChange={e => updateChainHop('first', 'server', e.target.value)} />
|
||||
</FormItem>
|
||||
@@ -679,8 +691,18 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层 SOCKS5</h4>
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainEditForm.second.protocol}
|
||||
onChange={e => updateChainHop('second', 'protocol', e.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input value={chainEditForm.second.server} onChange={e => updateChainHop('second', 'server', e.target.value)} />
|
||||
</FormItem>
|
||||
@@ -771,8 +793,7 @@ function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResul
|
||||
|
||||
function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
|
||||
const isDirect = proxy.proxyId === DIRECT_PROXY_ID
|
||||
const isLocal = proxy.proxyId === LOCAL_PROXY_ID
|
||||
const disableDelete = isDirect || isLocal
|
||||
const disableDelete = isDirect
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -810,7 +831,7 @@ function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSele
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={disableDelete}
|
||||
title={isDirect ? '直连不可删除' : isLocal ? '本地代理不可删除' : '删除代理'}
|
||||
title={isDirect ? '直连不可删除' : '删除代理'}
|
||||
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, RotateCcw, GripVertical } from 'lucide-react'
|
||||
import { Plus, Trash2, RotateCcw, GripVertical, RefreshCw } from 'lucide-react'
|
||||
import { Button, Card, ConfirmModal, Input, toast } from '../../../shared/components'
|
||||
import type { BrowserBookmark } from '../types'
|
||||
import { fetchBookmarks, resetBookmarks, saveBookmarks } from '../api'
|
||||
import { fetchBookmarks, resetBookmarks, saveBookmarks, syncBookmarksToProfiles } from '../api'
|
||||
|
||||
export function BookmarkSettingsPage() {
|
||||
const [items, setItems] = useState<BrowserBookmark[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const [syncOpen, setSyncOpen] = useState(false)
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,13 +21,17 @@ export function BookmarkSettingsPage() {
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setItems(prev => [...prev, { name: '', url: '' }])
|
||||
setItems(prev => [...prev, { name: '', url: '', openOnStart: false }])
|
||||
}
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
setItems(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleOpenOnStartChange = (index: number, checked: boolean) => {
|
||||
setItems(prev => prev.map((item, i) => i === index ? { ...item, openOnStart: checked } : item))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const valid = items.filter(i => i.name.trim() && i.url.trim())
|
||||
if (valid.length !== items.length) {
|
||||
@@ -35,7 +41,17 @@ export function BookmarkSettingsPage() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveBookmarks(items)
|
||||
toast.success('书签已保存,下次新建实例时生效')
|
||||
const result = await syncBookmarksToProfiles()
|
||||
const parts = ['书签已保存']
|
||||
if (result.synced > 0) parts.push(`已同步 ${result.synced} 个已有实例`)
|
||||
if (result.skipped > 0) parts.push(`跳过运行中 ${result.skipped} 个,停止后再同步`)
|
||||
if (result.failed > 0) parts.push(`失败 ${result.failed} 个`)
|
||||
const message = parts.join(',')
|
||||
if (result.failed > 0 || result.skipped > 0) {
|
||||
toast.warning(message)
|
||||
} else {
|
||||
toast.success(message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -48,6 +64,27 @@ export function BookmarkSettingsPage() {
|
||||
toast.success('已恢复默认书签')
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const result = await syncBookmarksToProfiles()
|
||||
const parts = [`已同步 ${result.synced} 个实例`]
|
||||
if (result.skipped > 0) parts.push(`跳过运行中 ${result.skipped} 个,停止后再同步`)
|
||||
if (result.failed > 0) parts.push(`失败 ${result.failed} 个`)
|
||||
const message = parts.join(',')
|
||||
if (result.failed > 0 || result.skipped > 0) {
|
||||
toast.warning(message)
|
||||
} else {
|
||||
toast.success(message)
|
||||
}
|
||||
setSyncOpen(false)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '同步失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const handleDragStart = (index: number) => setDragIndex(index)
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
@@ -71,6 +108,10 @@ export function BookmarkSettingsPage() {
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">新建实例首次启动时自动写入书签栏,已有书签不受影响</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setSyncOpen(true)} loading={syncing}>
|
||||
<RefreshCw className="w-4 h-4 mr-1.5" />
|
||||
手动同步
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setResetOpen(true)}>
|
||||
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||
恢复默认
|
||||
@@ -107,6 +148,15 @@ export function BookmarkSettingsPage() {
|
||||
placeholder="https://..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 px-2 text-xs text-[var(--color-text-secondary)] whitespace-nowrap select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(item.openOnStart)}
|
||||
onChange={e => handleOpenOnStartChange(index, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[var(--color-border-default)] accent-[var(--color-accent)]"
|
||||
/>
|
||||
启动打开
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(index)}
|
||||
@@ -143,6 +193,15 @@ export function BookmarkSettingsPage() {
|
||||
confirmText="确定恢复"
|
||||
danger
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={syncOpen}
|
||||
onClose={() => setSyncOpen(false)}
|
||||
onConfirm={handleSync}
|
||||
title="手动同步已有实例"
|
||||
content="只会增量追加缺失的默认书签,不会删除、改名或移动用户已有书签。运行中的实例会跳过。"
|
||||
confirmText="开始同步"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,6 +183,25 @@ export function BrowserEditPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleProxyListUpdated = (nextProxies: BrowserProxy[]) => {
|
||||
setProxies(nextProxies)
|
||||
}
|
||||
|
||||
const handleProxyDeleted = (deletedProxyId: string, nextProxies: BrowserProxy[]) => {
|
||||
setProxies(nextProxies)
|
||||
if (formData.proxyId !== deletedProxyId) {
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackProxy = nextProxies.find((proxy) => proxy.proxyId === directProxyID)
|
||||
if (fallbackProxy) {
|
||||
handleChange('proxyId', fallbackProxy.proxyId)
|
||||
return
|
||||
}
|
||||
|
||||
handleChange('proxyId', '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -281,6 +300,8 @@ export function BrowserEditPage() {
|
||||
open={proxyPickerOpen}
|
||||
currentProxyId={formData.proxyId}
|
||||
onSelect={proxy => handleChange('proxyId', proxy.proxyId)}
|
||||
onProxyListUpdated={handleProxyListUpdated}
|
||||
onProxyDeleted={handleProxyDeleted}
|
||||
onClose={() => setProxyPickerOpen(false)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
saveBrowserSettings,
|
||||
setDefaultBrowserCore,
|
||||
startBrowserInstance,
|
||||
startBrowserInstanceDirect,
|
||||
stopBrowserInstance,
|
||||
validateBrowserCorePath,
|
||||
validateProxyConfig,
|
||||
@@ -417,6 +418,34 @@ export function BrowserListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartDirect = async (profileId: string) => {
|
||||
updatePendingIds(setStartingIds, profileId, true)
|
||||
try {
|
||||
const startedProfile = await startBrowserInstanceDirect(profileId)
|
||||
mergeProfileState(startedProfile)
|
||||
setProxyErrorModal(false)
|
||||
setPendingStartId(null)
|
||||
if (startedProfile?.running && !startedProfile.debugReady && startedProfile.runtimeWarning) {
|
||||
toast.warning(startedProfile.runtimeWarning)
|
||||
} else {
|
||||
toast.success(`实例已直连启动${startedProfile?.profileName ? `:${startedProfile.profileName}` : ''}`)
|
||||
}
|
||||
await loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
} catch (error: any) {
|
||||
setProxyErrorModal(false)
|
||||
setPendingStartId(null)
|
||||
const feedback = resolveActionFeedback(error, '实例直连启动失败')
|
||||
if (feedback.tone === 'warning') {
|
||||
toast.warning(feedback.message)
|
||||
} else {
|
||||
toast.error(feedback.message)
|
||||
}
|
||||
await loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
} finally {
|
||||
updatePendingIds(setStartingIds, profileId, false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async (profileId: string) => {
|
||||
updatePendingIds(setStoppingIds, profileId, true)
|
||||
try {
|
||||
@@ -792,6 +821,12 @@ export function BrowserListPage() {
|
||||
setProxyErrorModal(false)
|
||||
setPendingStartId(null)
|
||||
}}
|
||||
onStartDirect={() => {
|
||||
if (pendingStartId) {
|
||||
void handleStartDirect(pendingStartId)
|
||||
}
|
||||
}}
|
||||
startingDirect={pendingStartId ? startingIds.has(pendingStartId) : false}
|
||||
kwModal={kwModal}
|
||||
onCloseKeywords={closeKwModal}
|
||||
onKeywordsSaved={(keywords) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { FolderOpen, Settings, Edit2 } from 'lucide-react'
|
||||
import { Badge, Button, Card, ConfirmModal, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components'
|
||||
import type { TableColumn } from '../../../shared/components/Table'
|
||||
@@ -517,7 +517,7 @@ export function CoreManagementPage() {
|
||||
value={settingsForm.defaultStartUrls}
|
||||
onChange={e => setSettingsForm(prev => ({ ...prev, defaultStartUrls: e.target.value }))}
|
||||
rows={4}
|
||||
placeholder="https://ippure.com/"
|
||||
placeholder="启动 URL"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开默认启动页或空白页">
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ConfirmModal, toast } from '../../../shared/components'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, ConfirmModal, FormItem, Input, Modal, Textarea, toast } from '../../../shared/components'
|
||||
import type { SortOrder } from '../../../shared/components/Table'
|
||||
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
|
||||
import { fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL } from '../api'
|
||||
import type { BrowserProxy, ProxyCheckSettings, ProxyIPHealthResult } from '../types'
|
||||
import { createDefaultProxyCheckSettings, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL, fetchProxyCheckSettings, saveProxyCheckSettings } from '../api'
|
||||
import { EventsOn } from '../../../wailsjs/runtime/runtime'
|
||||
import {
|
||||
BUILTIN_PROXY_IDS,
|
||||
CHAIN_QUICK_IMPORT_TEMPLATE,
|
||||
DIRECT_QUICK_IMPORT_TEMPLATE,
|
||||
INITIAL_CHAIN_IMPORT_FORM,
|
||||
INITIAL_DIRECT_IMPORT_FORM,
|
||||
buildChainImportCandidate,
|
||||
buildDirectImportCandidate,
|
||||
buildDirectImportCandidatesFromText,
|
||||
buildImportCandidatesFromClash,
|
||||
buildImportPreview,
|
||||
buildRefreshedSourceProxies,
|
||||
@@ -16,10 +21,14 @@ import {
|
||||
ensureBuiltinProxies,
|
||||
normalizeRefreshIntervalM,
|
||||
parseClashImportText,
|
||||
parseChainImportJSON,
|
||||
parseDirectImportText,
|
||||
parseTimestampMs,
|
||||
nextProxyID,
|
||||
resolveImportSourceID,
|
||||
toChainImportForm,
|
||||
toDisplayList,
|
||||
type ChainImportForm,
|
||||
type DirectImportForm,
|
||||
type ProxyDisplayInfo,
|
||||
type ProxyImportMode,
|
||||
@@ -48,6 +57,12 @@ import { ProxyPoolHeader } from './proxyPool/ProxyPoolHeader'
|
||||
import { ProxyPoolTableCard } from './proxyPool/ProxyPoolTableCard'
|
||||
|
||||
export function ProxyPoolPage() {
|
||||
const createInitialChainImportForm = (): ChainImportForm => ({
|
||||
...INITIAL_CHAIN_IMPORT_FORM,
|
||||
first: { ...INITIAL_CHAIN_IMPORT_FORM.first },
|
||||
second: { ...INITIAL_CHAIN_IMPORT_FORM.second },
|
||||
})
|
||||
|
||||
const [proxies, setProxies] = useState<BrowserProxy[]>([])
|
||||
const [displayList, setDisplayList] = useState<ProxyDisplayInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -64,6 +79,10 @@ export function ProxyPoolPage() {
|
||||
const [ipHealthMap, setIPHealthMap] = useState<Record<string, ProxyIPHealthResult>>({})
|
||||
const [checkingIPHealthIds, setCheckingIPHealthIds] = useState<Set<string>>(new Set())
|
||||
const [checkingAllIPHealth, setCheckingAllIPHealth] = useState(false)
|
||||
const [checkSettingsOpen, setCheckSettingsOpen] = useState(false)
|
||||
const [checkSettings, setCheckSettings] = useState<ProxyCheckSettings>(() => createDefaultProxyCheckSettings())
|
||||
const [checkTargetsText, setCheckTargetsText] = useState('')
|
||||
const [savingCheckSettings, setSavingCheckSettings] = useState(false)
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false)
|
||||
@@ -76,6 +95,9 @@ export function ProxyPoolPage() {
|
||||
const [importDnsServers, setImportDnsServers] = useState('')
|
||||
const [importNamePrefix, setImportNamePrefix] = useState('')
|
||||
const [importGroupName, setImportGroupName] = useState('')
|
||||
const [chainImportText, setChainImportText] = useState('')
|
||||
const [directImportText, setDirectImportText] = useState('')
|
||||
const [chainImportForm, setChainImportForm] = useState<ChainImportForm>(() => createInitialChainImportForm())
|
||||
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [previewList, setPreviewList] = useState<ProxyDisplayInfo[]>([])
|
||||
@@ -89,6 +111,8 @@ export function ProxyPoolPage() {
|
||||
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editingProxy, setEditingProxy] = useState<BrowserProxy | null>(null)
|
||||
const [chainEditMode, setChainEditMode] = useState(false)
|
||||
const [chainEditForm, setChainEditForm] = useState<ChainImportForm>(() => createInitialChainImportForm())
|
||||
const [editForm, setEditForm] = useState<ProxyEditFormValue>({
|
||||
proxyName: '',
|
||||
proxyConfig: '',
|
||||
@@ -204,6 +228,27 @@ export function ProxyPoolPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openCheckSettings = async () => {
|
||||
const settings = await fetchProxyCheckSettings()
|
||||
setCheckSettings(settings)
|
||||
setCheckTargetsText(JSON.stringify(settings.targets || [], null, 2))
|
||||
setCheckSettingsOpen(true)
|
||||
}
|
||||
|
||||
const saveCheckSettings = async () => {
|
||||
setSavingCheckSettings(true)
|
||||
try {
|
||||
const targets = JSON.parse(checkTargetsText || '[]')
|
||||
await saveProxyCheckSettings({ ...checkSettings, targets })
|
||||
toast.success('检测设置已保存')
|
||||
setCheckSettingsOpen(false)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '检测设置保存失败')
|
||||
} finally {
|
||||
setSavingCheckSettings(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 直接保存完整列表,内置代理保护由后端负责
|
||||
const saveProxies = useCallback(async (list: BrowserProxy[]) => {
|
||||
await saveBrowserProxies(list)
|
||||
@@ -347,10 +392,11 @@ export function ProxyPoolPage() {
|
||||
|
||||
const getLatencySortTuple = (proxyId: string): [number, number] => {
|
||||
const v = latencyMap[proxyId]
|
||||
if (v === undefined) return [4, Number.MAX_SAFE_INTEGER]
|
||||
if (v === undefined) return [5, Number.MAX_SAFE_INTEGER]
|
||||
if (v === -1) return [1, Number.MAX_SAFE_INTEGER] // 测试中
|
||||
if (v === -2) return [2, Number.MAX_SAFE_INTEGER] // 超时
|
||||
if (v === -3) return [3, Number.MAX_SAFE_INTEGER] // 不支持
|
||||
if (v === -4) return [4, Number.MAX_SAFE_INTEGER] // 失败
|
||||
return [0, v] // 正常延迟
|
||||
}
|
||||
|
||||
@@ -560,23 +606,73 @@ export function ProxyPoolPage() {
|
||||
setRemovedPreviewProxyNames(prev => [...prev, target.proxyName])
|
||||
}
|
||||
|
||||
const updateChainImportHop = (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => {
|
||||
setChainImportForm(prev => ({
|
||||
...prev,
|
||||
[hop]: {
|
||||
...prev[hop],
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const updateChainEditHop = (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => {
|
||||
setChainEditForm(prev => ({
|
||||
...prev,
|
||||
[hop]: {
|
||||
...prev[hop],
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleEdit = (record: ProxyDisplayInfo) => {
|
||||
const proxy = proxies.find(p => p.proxyId === record.proxyId)
|
||||
if (proxy) {
|
||||
setEditingProxy(proxy)
|
||||
setEditForm({ proxyName: proxy.proxyName, proxyConfig: proxy.proxyConfig, dnsServers: proxy.dnsServers || '', groupName: proxy.groupName || '' })
|
||||
const nextChainForm = toChainImportForm(proxy.proxyName, proxy.proxyConfig)
|
||||
if (nextChainForm) {
|
||||
setChainEditMode(true)
|
||||
setChainEditForm(nextChainForm)
|
||||
} else {
|
||||
setChainEditMode(false)
|
||||
setChainEditForm(createInitialChainImportForm())
|
||||
}
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProxy = async () => {
|
||||
if (!editForm.proxyName.trim()) { toast.error('请输入代理名称'); return }
|
||||
if (!editingProxy) return
|
||||
|
||||
let nextProxyName = editForm.proxyName.trim()
|
||||
let nextProxyConfig = editForm.proxyConfig
|
||||
if (chainEditMode) {
|
||||
try {
|
||||
const candidate = buildChainImportCandidate(chainEditForm)
|
||||
nextProxyName = candidate.proxyName
|
||||
nextProxyConfig = candidate.proxyConfig
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '链式代理配置无效')
|
||||
return
|
||||
}
|
||||
} else if (!nextProxyName) {
|
||||
toast.error('请输入代理名称')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const newProxies = proxies.map(p =>
|
||||
p.proxyId === editingProxy.proxyId
|
||||
? { ...p, proxyName: editForm.proxyName, proxyConfig: editForm.proxyConfig, dnsServers: editForm.dnsServers, groupName: editForm.groupName }
|
||||
? {
|
||||
...p,
|
||||
proxyName: nextProxyName,
|
||||
proxyConfig: nextProxyConfig,
|
||||
dnsServers: editForm.dnsServers.trim() || undefined,
|
||||
groupName: editForm.groupName.trim() || undefined,
|
||||
}
|
||||
: p
|
||||
)
|
||||
await saveProxies(newProxies)
|
||||
@@ -616,6 +712,63 @@ export function ProxyPoolPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFillChainTemplate = () => {
|
||||
setChainImportText(CHAIN_QUICK_IMPORT_TEMPLATE)
|
||||
}
|
||||
|
||||
const handleFillDirectTemplate = () => {
|
||||
setDirectImportText(DIRECT_QUICK_IMPORT_TEMPLATE)
|
||||
}
|
||||
|
||||
const handleCopyChainTemplate = async () => {
|
||||
try {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
throw new Error('当前环境不支持剪贴板')
|
||||
}
|
||||
await navigator.clipboard.writeText(CHAIN_QUICK_IMPORT_TEMPLATE)
|
||||
toast.success('JSON 模板已复制')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '复制模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyDirectTemplate = async () => {
|
||||
try {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
throw new Error('当前环境不支持剪贴板')
|
||||
}
|
||||
await navigator.clipboard.writeText(DIRECT_QUICK_IMPORT_TEMPLATE)
|
||||
toast.success('JSON 模板已复制')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '复制模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyChainJSON = () => {
|
||||
try {
|
||||
const { form, groupName } = parseChainImportJSON(chainImportText)
|
||||
setChainImportForm(form)
|
||||
setImportGroupName(groupName)
|
||||
toast.success('JSON 已应用')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || 'JSON 应用失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyDirectText = () => {
|
||||
try {
|
||||
const { form, groupName } = parseDirectImportText(directImportText)
|
||||
setDirectImportForm(form)
|
||||
if (groupName) {
|
||||
setImportGroupName(groupName)
|
||||
}
|
||||
setDirectImportText('')
|
||||
toast.success('文本已应用')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '文本应用失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportUrlChange = (nextValue: string) => {
|
||||
setImportUrl(nextValue)
|
||||
if (importResolvedUrl.trim() && nextValue.trim() !== importResolvedUrl.trim()) {
|
||||
@@ -660,14 +813,28 @@ export function ProxyPoolPage() {
|
||||
const handleParseImport = () => {
|
||||
try {
|
||||
const prefix = importNamePrefix.trim()
|
||||
const candidates = importMode === 'clash'
|
||||
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
|
||||
: [buildDirectImportCandidate(directImportForm)]
|
||||
let candidates
|
||||
let previewGroupName = importGroupName.trim()
|
||||
if (importMode === 'clash') {
|
||||
candidates = buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
|
||||
} else if (importMode === 'direct') {
|
||||
if (directImportText.trim()) {
|
||||
const parsed = buildDirectImportCandidatesFromText(directImportText)
|
||||
candidates = parsed.candidates
|
||||
if (!previewGroupName) {
|
||||
previewGroupName = parsed.defaultGroupName
|
||||
}
|
||||
} else {
|
||||
candidates = [buildDirectImportCandidate(directImportForm)]
|
||||
}
|
||||
} else {
|
||||
candidates = [buildChainImportCandidate(chainImportForm)]
|
||||
}
|
||||
if (!candidates.length) {
|
||||
toast.error('未解析到可导入代理')
|
||||
return
|
||||
}
|
||||
const preview = buildImportPreview(candidates, importGroupName.trim())
|
||||
const preview = buildImportPreview(candidates, previewGroupName)
|
||||
setRemovedPreviewProxyNames([])
|
||||
setPreviewList(preview)
|
||||
setImportModalOpen(false)
|
||||
@@ -701,7 +868,7 @@ export function ProxyPoolPage() {
|
||||
proxyName: p.proxyName,
|
||||
proxyConfig: p.proxyConfig,
|
||||
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
|
||||
groupName: importGroupName.trim() || undefined,
|
||||
groupName: p.groupName.trim() || undefined,
|
||||
sourceId: sourceID || undefined,
|
||||
sourceUrl: sourceURL || undefined,
|
||||
sourceNamePrefix: sourceNamePrefix || undefined,
|
||||
@@ -723,6 +890,9 @@ export function ProxyPoolPage() {
|
||||
setImportDnsServers('')
|
||||
setImportNamePrefix('')
|
||||
setImportGroupName('')
|
||||
setChainImportText('')
|
||||
setDirectImportText('')
|
||||
setChainImportForm(createInitialChainImportForm())
|
||||
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
|
||||
setPreviewList([])
|
||||
setRemovedPreviewProxyNames([])
|
||||
@@ -737,7 +907,12 @@ export function ProxyPoolPage() {
|
||||
const selectedCount = selectedIds.size
|
||||
const canParseImport = importMode === 'clash'
|
||||
? !!importText.trim()
|
||||
: !!directImportForm.server.trim() && !!directImportForm.port.trim()
|
||||
: importMode === 'direct'
|
||||
? !!directImportText.trim() || (!!directImportForm.server.trim() && !!directImportForm.port.trim())
|
||||
: !!chainImportForm.first.server.trim()
|
||||
&& !!chainImportForm.first.port.trim()
|
||||
&& !!chainImportForm.second.server.trim()
|
||||
&& !!chainImportForm.second.port.trim()
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
@@ -745,6 +920,7 @@ export function ProxyPoolPage() {
|
||||
checkingAllIPHealth={checkingAllIPHealth}
|
||||
hasURLImportSources={hasURLImportSources}
|
||||
onCheckAllIPHealth={handleCheckAllIPHealth}
|
||||
onOpenSettings={() => void openCheckSettings()}
|
||||
onOpenImport={() => setImportModalOpen(true)}
|
||||
onRefreshAllSources={() => void handleRefreshAllSources(false)}
|
||||
onTestAll={() => void handleTestAll()}
|
||||
@@ -809,6 +985,9 @@ export function ProxyPoolPage() {
|
||||
importDnsServers={importDnsServers}
|
||||
importNamePrefix={importNamePrefix}
|
||||
importGroupName={importGroupName}
|
||||
chainImportText={chainImportText}
|
||||
directImportText={directImportText}
|
||||
chainImportForm={chainImportForm}
|
||||
directImportForm={directImportForm}
|
||||
fetchingImportUrl={fetchingImportUrl}
|
||||
canParseImport={canParseImport}
|
||||
@@ -821,6 +1000,16 @@ export function ProxyPoolPage() {
|
||||
onImportDnsServersChange={setImportDnsServers}
|
||||
onImportNamePrefixChange={setImportNamePrefix}
|
||||
onImportGroupNameChange={setImportGroupName}
|
||||
onChainImportTextChange={setChainImportText}
|
||||
onDirectImportTextChange={setDirectImportText}
|
||||
onApplyChainJSON={handleApplyChainJSON}
|
||||
onApplyDirectText={handleApplyDirectText}
|
||||
onChainImportFormChange={(patch) => setChainImportForm((prev) => ({ ...prev, ...patch }))}
|
||||
onChainImportHopChange={updateChainImportHop}
|
||||
onFillChainTemplate={handleFillChainTemplate}
|
||||
onCopyChainTemplate={() => void handleCopyChainTemplate()}
|
||||
onFillDirectTemplate={handleFillDirectTemplate}
|
||||
onCopyDirectTemplate={() => void handleCopyDirectTemplate()}
|
||||
onDirectImportFormChange={(patch) => setDirectImportForm((prev) => ({ ...prev, ...patch }))}
|
||||
/>
|
||||
|
||||
@@ -845,9 +1034,13 @@ export function ProxyPoolPage() {
|
||||
saving={saving}
|
||||
groups={groups}
|
||||
editForm={editForm}
|
||||
chainEditMode={chainEditMode}
|
||||
chainEditForm={chainEditForm}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSave={handleSaveProxy}
|
||||
onChange={(patch) => setEditForm((prev) => ({ ...prev, ...patch }))}
|
||||
onChainEditFormChange={(patch) => setChainEditForm((prev) => ({ ...prev, ...patch }))}
|
||||
onChainEditHopChange={updateChainEditHop}
|
||||
/>
|
||||
|
||||
<ProxyPoolIPHealthDetailModal
|
||||
@@ -856,6 +1049,48 @@ export function ProxyPoolPage() {
|
||||
onClose={() => setIPHealthDetailOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={checkSettingsOpen}
|
||||
onClose={() => setCheckSettingsOpen(false)}
|
||||
title="检测设置"
|
||||
width="760px"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setCheckSettingsOpen(false)}>取消</Button>
|
||||
<Button onClick={saveCheckSettings} loading={savingCheckSettings}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormItem label="桥接启动等待" hint="毫秒" >
|
||||
<Input
|
||||
type="number"
|
||||
value={checkSettings.bridgeStartTimeoutMs}
|
||||
onChange={(e) => setCheckSettings(prev => ({ ...prev, bridgeStartTimeoutMs: Number(e.target.value) || 15000 }))}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="测速目标 ID">
|
||||
<Input
|
||||
value={checkSettings.speedTargetId}
|
||||
onChange={(e) => setCheckSettings(prev => ({ ...prev, speedTargetId: e.target.value }))}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="IP 健康目标 ID">
|
||||
<Input
|
||||
value={checkSettings.ipHealthTargetId}
|
||||
onChange={(e) => setCheckSettings(prev => ({ ...prev, ipHealthTargetId: e.target.value }))}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="检测目标列表(JSON,每项一个)" hint="可直接编辑 URL、超时、期望状态码">
|
||||
<Textarea
|
||||
value={checkTargetsText}
|
||||
onChange={(e) => setCheckTargetsText(e.target.value)}
|
||||
rows={14}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)} onConfirm={handleDeleteConfirm}
|
||||
title="确认删除" content="确定要删除这个代理吗?此操作不可恢复。" confirmText="删除" danger />
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ interface BrowserListDialogsProps {
|
||||
pendingStartId: string | null
|
||||
proxyErrorMsg: string
|
||||
onCloseProxyError: () => void
|
||||
onStartDirect: () => void
|
||||
startingDirect: boolean
|
||||
kwModal: { open: boolean; profile: BrowserProfile | null }
|
||||
onCloseKeywords: () => void
|
||||
onKeywordsSaved: (keywords: string[]) => void
|
||||
@@ -36,6 +38,8 @@ export function BrowserListDialogs({
|
||||
pendingStartId,
|
||||
proxyErrorMsg,
|
||||
onCloseProxyError,
|
||||
onStartDirect,
|
||||
startingDirect,
|
||||
kwModal,
|
||||
onCloseKeywords,
|
||||
onKeywordsSaved,
|
||||
@@ -66,10 +70,15 @@ export function BrowserListDialogs({
|
||||
width="420px"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onCloseProxyError}>取消</Button>
|
||||
<Button variant="secondary" onClick={onCloseProxyError} disabled={startingDirect}>取消</Button>
|
||||
{pendingStartId && (
|
||||
<Button variant="secondary" onClick={onStartDirect} loading={startingDirect}>
|
||||
直连启动
|
||||
</Button>
|
||||
)}
|
||||
{pendingStartId && (
|
||||
<Link to={`/browser/edit/${pendingStartId}`}>
|
||||
<Button onClick={onCloseProxyError}>去修改代理</Button>
|
||||
<Button onClick={onCloseProxyError} disabled={startingDirect}>去修改代理</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function LaunchDocsFlowPage({ baseUrl }: LaunchDocsFlowPageProps) {
|
||||
summary: '先准备浏览器内核,并在应用里确认已识别。',
|
||||
path: '指纹浏览器 -> 内核管理 -> 下载内核 -> 设为默认',
|
||||
example: `chrome/
|
||||
chrome142/
|
||||
chrome-<version>/
|
||||
chrome.exe`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DOC_SKILL_USAGE,
|
||||
DOC_TUTORIAL,
|
||||
} from './contentIntro'
|
||||
import { DOC_CHANGELOG } from './contentChangelog'
|
||||
import {
|
||||
DOC_API_PROFILES_LAUNCH,
|
||||
DOC_API_RUNTIME,
|
||||
@@ -60,6 +61,18 @@ export const DOC_GROUPS: LaunchDocGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changelog',
|
||||
label: '更新日志',
|
||||
items: [
|
||||
{
|
||||
id: 'changelog-versions',
|
||||
label: '版本更新',
|
||||
summary: '按大版本查看主要变化。',
|
||||
content: DOC_CHANGELOG,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'core',
|
||||
label: '内核介绍',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const DOC_CHANGELOG = `# 更新日志
|
||||
|
||||
## 1.2.0 - 2026-05-05
|
||||
|
||||
- 文档中心改版:把使用教程、内核、代理、接口和排障内容收进同一个入口,接口详情改为按章节逐步查看。
|
||||
- Launch API 完整化:补齐实例增删改查、按 code / selector 启动、runtime session、runtime status、runtime stop 和统一 CDP 入口。
|
||||
- 自动化脚本中心:支持脚本列表、详情、执行和运行记录;脚本可复用默认 selector / params,也可在执行时覆盖。
|
||||
- OpenClaw 对接:新增 ant-chrome-openclaw skill、安装脚本、HTTP 调用参考和同机远程 CDP 接管流程。
|
||||
- 代理能力增强:代理池支持更多导入与检测场景,补齐链式代理编辑、IP 健康检查和测速链路。
|
||||
- 工程拆分:实例启动、备份恢复、自动化运行时、发布脚本等模块拆分,减少单文件堆叠,便于后续维护。
|
||||
|
||||
## 1.1.0 - 2026-03-19
|
||||
|
||||
- Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并修复安装版启动与退出稳定性问题。
|
||||
- macOS unsigned 内测:支持原生 macOS 主机打包 app / zip,用户状态目录迁移到 Application Support。
|
||||
- SOCKS 代理测试:SOCKS 代理能力进入测试阶段,继续验证稳定性与兼容性。
|
||||
- 接口触发浏览器:实验性支持通过接口启动浏览器实例,为自动化流程接入做准备。
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- 实例隔离管理:支持创建、编辑、启动、停止、重启、克隆和删除浏览器实例。
|
||||
- 代理池配置:支持统一维护代理节点,将代理绑定到具体实例,并导入 Clash 配置。
|
||||
- 内核管理:支持维护多个 Chrome 内核,设置默认内核,并在实例中选择使用。
|
||||
- 快捷启动:支持通过实例 Code 和快捷入口打开目标实例。
|
||||
- 标签与检索:支持按标签、关键字、状态、代理、内核、分组筛选实例。
|
||||
- 本地化存储:配置和实例数据保存在本地,适合长期使用和备份。
|
||||
`
|
||||
@@ -1,4 +1,4 @@
|
||||
export const DOC_TUTORIAL = `# 使用教程
|
||||
export const DOC_TUTORIAL = `# 使用教程
|
||||
|
||||
## 只在应用内使用
|
||||
|
||||
@@ -178,7 +178,7 @@ export const DOC_CORE_INTRO = `# 内核介绍
|
||||
|
||||
\`\`\`text
|
||||
chrome/
|
||||
chrome142/
|
||||
chrome-<version>/
|
||||
chrome.exe
|
||||
...
|
||||
\`\`\`
|
||||
@@ -190,7 +190,7 @@ chrome/
|
||||
指纹浏览器 -> 内核管理 -> 下载内核
|
||||
|
||||
方式 B:手动下载
|
||||
下载 ZIP -> 解压到 chrome/ -> 回到内核管理确认识别
|
||||
下载 ZIP -> 解压到 chrome/<version>/ -> 回到内核管理确认识别
|
||||
\`\`\`
|
||||
|
||||
## 下载渠道
|
||||
@@ -219,9 +219,9 @@ export const DOC_PROXY_INTRO = `# 代理介绍
|
||||
## 直接录入示例
|
||||
|
||||
\`\`\`text
|
||||
http://127.0.0.1:7890
|
||||
socks5://127.0.0.1:1080
|
||||
http://user:pass@127.0.0.1:7890
|
||||
<proxy-url>
|
||||
<proxy-url>
|
||||
<proxy-url>
|
||||
\`\`\`
|
||||
|
||||
## Clash YAML 导入示例
|
||||
@@ -327,6 +327,8 @@ curl -H "X-Ant-Api-Key: <your-api-key>" http://127.0.0.1:19876/api/health
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
也可以把 \`code / profileId / profileName / keyword / tags / groupId / matchMode\` 放在请求体顶层;新接入建议统一放进 \`selector\`。
|
||||
|
||||
## 怎么选接口
|
||||
|
||||
| 场景 | 用哪个 |
|
||||
|
||||
@@ -440,10 +440,17 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
label: '按 selector 启动',
|
||||
method: 'POST',
|
||||
path: '/api/launch',
|
||||
purpose: '按 selector 和启动参数启动实例。',
|
||||
description: '更灵活的启动入口,支持 selector、launchArgs、startUrls 和 skipDefaultStartUrls 等临时参数。',
|
||||
purpose: '按 selector 或兼容顶层字段启动实例。',
|
||||
description: '更灵活的启动入口,支持 selector、兼容顶层选择字段、launchArgs、startUrls 和 skipDefaultStartUrls 等临时参数。',
|
||||
fields: [
|
||||
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
|
||||
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
|
||||
{ name: 'code', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.code。' },
|
||||
{ name: 'profileId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileId。' },
|
||||
{ name: 'profileName', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileName。' },
|
||||
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.keyword / selector.keywords。' },
|
||||
{ name: 'tag / tags', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.tag / selector.tags。' },
|
||||
{ name: 'groupId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.groupId。' },
|
||||
{ name: 'matchMode', type: 'unique | first | all', required: false, location: 'Body', description: '兼容写法:等价于 selector.matchMode。' },
|
||||
{ name: 'launchArgs', type: 'string[]', required: false, location: 'Body', description: '本次启动的临时附加参数。' },
|
||||
{ name: 'startUrls', type: 'string[]', required: false, location: 'Body', description: '本次启动后额外打开的网址。' },
|
||||
{ name: 'skipDefaultStartUrls', type: 'boolean', required: false, location: 'Body', description: '是否跳过实例默认启动 URL。' },
|
||||
@@ -479,6 +486,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
{ code: '409', description: 'selector 命中多个实例。' },
|
||||
],
|
||||
notes: [
|
||||
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
|
||||
'matchMode=all 只在这个接口可用。',
|
||||
],
|
||||
},
|
||||
@@ -526,7 +534,14 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
purpose: '准备一个可 attach 的运行时会话。',
|
||||
description: '按 selector 命中实例,必要时自动启动,并在给定超时时间内等待 debugReady=true。',
|
||||
fields: [
|
||||
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
|
||||
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
|
||||
{ name: 'code', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.code。' },
|
||||
{ name: 'profileId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileId。' },
|
||||
{ name: 'profileName', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.profileName。' },
|
||||
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.keyword / selector.keywords。' },
|
||||
{ name: 'tag / tags', type: 'string / string[]', required: false, location: 'Body', description: '兼容写法:等价于 selector.tag / selector.tags。' },
|
||||
{ name: 'groupId', type: 'string', required: false, location: 'Body', description: '兼容写法:等价于 selector.groupId。' },
|
||||
{ name: 'matchMode', type: 'unique | first', required: false, location: 'Body', description: '兼容写法:等价于 selector.matchMode。' },
|
||||
{ name: 'timeoutMs', type: 'integer', required: false, location: 'Body', description: '等待 debugReady 的超时时间。' },
|
||||
{ name: 'startUrls', type: 'string[]', required: false, location: 'Body', description: '本次启动时额外打开的网址。' },
|
||||
{ name: 'skipDefaultStartUrls', type: 'boolean', required: false, location: 'Body', description: '是否跳过实例默认启动 URL。' },
|
||||
@@ -567,6 +582,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
{ code: '404', description: '目标实例不存在。' },
|
||||
],
|
||||
notes: [
|
||||
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
|
||||
'200 表示 ready,可直接接管。',
|
||||
'202 表示未 ready,需要重试。',
|
||||
],
|
||||
@@ -580,7 +596,11 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
purpose: '按 selector 查询实例当前运行态。',
|
||||
description: '不启动新实例,不等待 ready,只看当前 selector 命中的实例状态。',
|
||||
fields: [
|
||||
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
|
||||
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
|
||||
{ name: 'code / profileId / profileName', type: 'string', required: false, location: 'Body', description: '兼容顶层选择字段。' },
|
||||
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
|
||||
{ name: 'tag / tags / groupId', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
|
||||
{ name: 'matchMode', type: 'unique | first', required: false, location: 'Body', description: '运行态控制不支持 all。' },
|
||||
],
|
||||
requestExample: {
|
||||
language: 'bash',
|
||||
@@ -610,6 +630,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
],
|
||||
notes: [
|
||||
'不会启动实例。',
|
||||
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -621,7 +642,11 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
purpose: '按 selector 停止实例。',
|
||||
description: '和 runtime/status 一样使用 selector,但动作改为停止实例,适合编排侧做统一回收。',
|
||||
fields: [
|
||||
{ name: 'selector', type: 'object', required: true, location: 'Body', description: '目标实例选择条件。' },
|
||||
{ name: 'selector', type: 'object', required: false, location: 'Body', description: '目标实例选择条件;新接入推荐使用。' },
|
||||
{ name: 'code / profileId / profileName', type: 'string', required: false, location: 'Body', description: '兼容顶层选择字段。' },
|
||||
{ name: 'keyword / keywords', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
|
||||
{ name: 'tag / tags / groupId', type: 'string / string[]', required: false, location: 'Body', description: '兼容顶层选择字段。' },
|
||||
{ name: 'matchMode', type: 'unique | first', required: false, location: 'Body', description: '运行态控制不支持 all。' },
|
||||
],
|
||||
requestExample: {
|
||||
language: 'bash',
|
||||
@@ -650,6 +675,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
{ code: '404', description: '目标实例不存在。' },
|
||||
],
|
||||
notes: [
|
||||
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
|
||||
'不支持 matchMode=all。',
|
||||
],
|
||||
},
|
||||
@@ -670,7 +696,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
responseExample: {
|
||||
language: 'json',
|
||||
code: () => `{
|
||||
"Browser": "Chrome/142.0.0.0",
|
||||
"Browser": "Chrome/<version>",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"webSocketDebuggerUrl": "ws://127.0.0.1:19876/devtools/browser/active"
|
||||
@@ -848,6 +874,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
|
||||
{ name: 'params', type: 'object', required: false, location: 'Body', description: '覆盖脚本默认 params。' },
|
||||
{ name: 'useScriptSelector', type: 'boolean', required: false, location: 'Body', description: '显式指定是否沿用脚本默认 selector。' },
|
||||
{ name: 'useScriptParams', type: 'boolean', required: false, location: 'Body', description: '显式指定是否沿用脚本默认 params。' },
|
||||
{ name: 'timeoutMs', type: 'integer', required: false, location: 'Body', description: '本次脚本执行超时时间。' },
|
||||
],
|
||||
requestExample: {
|
||||
language: 'bash',
|
||||
|
||||
@@ -5,6 +5,7 @@ interface ProxyPoolHeaderProps {
|
||||
hasURLImportSources: boolean
|
||||
onCheckAllIPHealth: () => void
|
||||
onOpenImport: () => void
|
||||
onOpenSettings: () => void
|
||||
onRefreshAllSources: () => void
|
||||
onTestAll: () => void
|
||||
refreshingAllSources: boolean
|
||||
@@ -17,6 +18,7 @@ export function ProxyPoolHeader({
|
||||
hasURLImportSources,
|
||||
onCheckAllIPHealth,
|
||||
onOpenImport,
|
||||
onOpenSettings,
|
||||
onRefreshAllSources,
|
||||
onTestAll,
|
||||
refreshingAllSources,
|
||||
@@ -30,6 +32,13 @@ export function ProxyPoolHeader({
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">管理代理配置,支持 Clash 订阅、HTTP、HTTPS、SOCKS5</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
检测设置
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../../shared/components'
|
||||
import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../../shared/components'
|
||||
import type { TableColumn } from '../../../../shared/components/Table'
|
||||
import type { ProxyIPHealthResult } from '../../types'
|
||||
|
||||
import {
|
||||
CHAIN_QUICK_IMPORT_TEMPLATE,
|
||||
DIRECT_QUICK_IMPORT_TEMPLATE,
|
||||
DIRECT_PROXY_PROTOCOL_OPTIONS,
|
||||
type ChainImportForm,
|
||||
type DirectImportForm,
|
||||
type ProxyDisplayInfo,
|
||||
type ProxyImportMode,
|
||||
@@ -26,6 +29,9 @@ interface ProxyPoolImportModalProps {
|
||||
importDnsServers: string
|
||||
importNamePrefix: string
|
||||
importGroupName: string
|
||||
chainImportText: string
|
||||
directImportText: string
|
||||
chainImportForm: ChainImportForm
|
||||
directImportForm: DirectImportForm
|
||||
fetchingImportUrl: boolean
|
||||
canParseImport: boolean
|
||||
@@ -38,6 +44,16 @@ interface ProxyPoolImportModalProps {
|
||||
onImportDnsServersChange: (nextValue: string) => void
|
||||
onImportNamePrefixChange: (nextValue: string) => void
|
||||
onImportGroupNameChange: (nextValue: string) => void
|
||||
onChainImportTextChange: (nextValue: string) => void
|
||||
onDirectImportTextChange: (nextValue: string) => void
|
||||
onApplyChainJSON: () => void
|
||||
onApplyDirectText: () => void
|
||||
onChainImportFormChange: (patch: Partial<ChainImportForm>) => void
|
||||
onChainImportHopChange: (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => void
|
||||
onFillChainTemplate: () => void
|
||||
onCopyChainTemplate: () => void
|
||||
onFillDirectTemplate: () => void
|
||||
onCopyDirectTemplate: () => void
|
||||
onDirectImportFormChange: (patch: Partial<DirectImportForm>) => void
|
||||
}
|
||||
|
||||
@@ -51,6 +67,9 @@ export function ProxyPoolImportModal({
|
||||
importDnsServers,
|
||||
importNamePrefix,
|
||||
importGroupName,
|
||||
chainImportText,
|
||||
directImportText,
|
||||
chainImportForm,
|
||||
directImportForm,
|
||||
fetchingImportUrl,
|
||||
canParseImport,
|
||||
@@ -63,6 +82,16 @@ export function ProxyPoolImportModal({
|
||||
onImportDnsServersChange,
|
||||
onImportNamePrefixChange,
|
||||
onImportGroupNameChange,
|
||||
onChainImportTextChange,
|
||||
onDirectImportTextChange,
|
||||
onApplyChainJSON,
|
||||
onApplyDirectText,
|
||||
onChainImportFormChange,
|
||||
onChainImportHopChange,
|
||||
onFillChainTemplate,
|
||||
onCopyChainTemplate,
|
||||
onFillDirectTemplate,
|
||||
onCopyDirectTemplate,
|
||||
onDirectImportFormChange,
|
||||
}: ProxyPoolImportModalProps) {
|
||||
return (
|
||||
@@ -83,7 +112,7 @@ export function ProxyPoolImportModal({
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={importMode === 'clash' ? undefined : 'secondary'}
|
||||
onClick={() => onImportModeChange('clash')}
|
||||
@@ -94,13 +123,21 @@ export function ProxyPoolImportModal({
|
||||
variant={importMode === 'direct' ? undefined : 'secondary'}
|
||||
onClick={() => onImportModeChange('direct')}
|
||||
>
|
||||
HTTP / SOCKS5(测试中)
|
||||
HTTP / SOCKS5
|
||||
</Button>
|
||||
<Button
|
||||
variant={importMode === 'chain' ? undefined : 'secondary'}
|
||||
onClick={() => onImportModeChange('chain')}
|
||||
>
|
||||
链式代理
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{importMode === 'clash'
|
||||
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)'
|
||||
: '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'}
|
||||
: importMode === 'direct'
|
||||
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
|
||||
: '支持两层 SOCKS5 链式代理,使用 JSON 导入,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
|
||||
</p>
|
||||
{importMode === 'clash' && (
|
||||
<>
|
||||
@@ -109,7 +146,7 @@ export function ProxyPoolImportModal({
|
||||
<Input
|
||||
value={importUrl}
|
||||
onChange={(event) => onImportUrlChange(event.target.value)}
|
||||
placeholder="https://example.com/clash/subscription"
|
||||
placeholder="订阅 URL"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
@@ -139,54 +176,210 @@ export function ProxyPoolImportModal({
|
||||
</>
|
||||
)}
|
||||
{importMode === 'direct' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理协议" required>
|
||||
<Select
|
||||
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
|
||||
value={directImportForm.protocol}
|
||||
onChange={(event) =>
|
||||
onDirectImportFormChange({ protocol: event.target.value as DirectImportForm['protocol'] })
|
||||
}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理协议" required>
|
||||
<Select
|
||||
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
|
||||
value={directImportForm.protocol}
|
||||
onChange={(event) =>
|
||||
onDirectImportFormChange({ protocol: event.target.value as DirectImportForm['protocol'] })
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理名称(可选)">
|
||||
<Input
|
||||
value={directImportForm.proxyName}
|
||||
onChange={(event) => onDirectImportFormChange({ proxyName: event.target.value })}
|
||||
placeholder="节点名称"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={directImportForm.server}
|
||||
onChange={(event) => onDirectImportFormChange({ server: event.target.value })}
|
||||
placeholder="例如:127.0.0.1 或 hk.example.com"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={directImportForm.port}
|
||||
onChange={(event) => onDirectImportFormChange({ port: event.target.value })}
|
||||
placeholder="例如:1080"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={directImportForm.username}
|
||||
onChange={(event) => onDirectImportFormChange({ username: event.target.value })}
|
||||
placeholder="留空则不使用认证"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={directImportForm.password}
|
||||
onChange={(event) => onDirectImportFormChange({ password: event.target.value })}
|
||||
placeholder="留空则不使用密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="文本辅助(可选)" hint="支持单个 JSON、JSON 数组,或多行 http:// / https:// / socks5://,每行一个">
|
||||
<Textarea
|
||||
value={directImportText}
|
||||
onChange={(event) => onDirectImportTextChange(event.target.value)}
|
||||
rows={8}
|
||||
placeholder={DIRECT_QUICK_IMPORT_TEMPLATE}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onFillDirectTemplate}>
|
||||
填入模板
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onCopyDirectTemplate}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onApplyDirectText} disabled={!directImportText.trim()}>
|
||||
应用文本
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
留空则按上方表单导入;有内容则点击“解析”按文本直接导入,可批量。
|
||||
</p>
|
||||
</FormItem>
|
||||
<FormItem label="代理名称(可选)">
|
||||
<Input
|
||||
value={directImportForm.proxyName}
|
||||
onChange={(event) => onDirectImportFormChange({ proxyName: event.target.value })}
|
||||
placeholder="例如:香港节点"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={directImportForm.server}
|
||||
onChange={(event) => onDirectImportFormChange({ server: event.target.value })}
|
||||
placeholder="例如:127.0.0.1 或 hk.example.com"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={directImportForm.port}
|
||||
onChange={(event) => onDirectImportFormChange({ port: event.target.value })}
|
||||
placeholder="例如:1080"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={directImportForm.username}
|
||||
onChange={(event) => onDirectImportFormChange({ username: event.target.value })}
|
||||
placeholder="留空则不使用认证"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={directImportForm.password}
|
||||
onChange={(event) => onDirectImportFormChange({ password: event.target.value })}
|
||||
placeholder="留空则不使用密码"
|
||||
</div>
|
||||
)}
|
||||
{importMode === 'chain' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理名称(可选)">
|
||||
<Input
|
||||
value={chainImportForm.proxyName}
|
||||
onChange={(event) => onChainImportFormChange({ proxyName: event.target.value })}
|
||||
placeholder="例如:双层英国链路"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="本地监听端口(可选)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.localPort}
|
||||
onChange={(event) => onChainImportFormChange({ localPort: event.target.value })}
|
||||
placeholder="留空自动分配"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainImportForm.first.protocol}
|
||||
onChange={(event) => onChainImportHopChange('first', 'protocol', event.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainImportForm.first.server}
|
||||
onChange={(event) => onChainImportHopChange('first', 'server', event.target.value)}
|
||||
placeholder="代理地址"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.first.port}
|
||||
onChange={(event) => onChainImportHopChange('first', 'port', event.target.value)}
|
||||
placeholder="端口"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={chainImportForm.first.username}
|
||||
onChange={(event) => onChainImportHopChange('first', 'username', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={chainImportForm.first.password}
|
||||
onChange={(event) => onChainImportHopChange('first', 'password', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainImportForm.second.protocol}
|
||||
onChange={(event) => onChainImportHopChange('second', 'protocol', event.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainImportForm.second.server}
|
||||
onChange={(event) => onChainImportHopChange('second', 'server', event.target.value)}
|
||||
placeholder="代理地址"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.second.port}
|
||||
onChange={(event) => onChainImportHopChange('second', 'port', event.target.value)}
|
||||
placeholder="端口"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={chainImportForm.second.username}
|
||||
onChange={(event) => onChainImportHopChange('second', 'username', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={chainImportForm.second.password}
|
||||
onChange={(event) => onChainImportHopChange('second', 'password', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<FormItem label="JSON 辅助(可选)">
|
||||
<Textarea
|
||||
value={chainImportText}
|
||||
onChange={(event) => onChainImportTextChange(event.target.value)}
|
||||
rows={10}
|
||||
placeholder={CHAIN_QUICK_IMPORT_TEMPLATE}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onFillChainTemplate}>
|
||||
填入模板
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onCopyChainTemplate}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onApplyChainJSON} disabled={!chainImportText.trim()}>
|
||||
应用 JSON
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
@@ -194,7 +387,7 @@ export function ProxyPoolImportModal({
|
||||
<Input
|
||||
value={importGroupName}
|
||||
onChange={(event) => onImportGroupNameChange(event.target.value)}
|
||||
placeholder="例如:香港、美国、机场A"
|
||||
placeholder="分组名称"
|
||||
list="proxy-groups-datalist"
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
@@ -317,9 +510,13 @@ interface ProxyPoolEditModalProps {
|
||||
saving: boolean
|
||||
groups: string[]
|
||||
editForm: ProxyEditFormValue
|
||||
chainEditMode: boolean
|
||||
chainEditForm: ChainImportForm
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onChange: (patch: Partial<ProxyEditFormValue>) => void
|
||||
onChainEditFormChange: (patch: Partial<ChainImportForm>) => void
|
||||
onChainEditHopChange: (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => void
|
||||
}
|
||||
|
||||
export function ProxyPoolEditModal({
|
||||
@@ -327,9 +524,13 @@ export function ProxyPoolEditModal({
|
||||
saving,
|
||||
groups,
|
||||
editForm,
|
||||
chainEditMode,
|
||||
chainEditForm,
|
||||
onClose,
|
||||
onSave,
|
||||
onChange,
|
||||
onChainEditFormChange,
|
||||
onChainEditHopChange,
|
||||
}: ProxyPoolEditModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
@@ -351,16 +552,22 @@ export function ProxyPoolEditModal({
|
||||
<div className="space-y-4">
|
||||
<FormItem label="代理名称" required>
|
||||
<Input
|
||||
value={editForm.proxyName}
|
||||
onChange={(event) => onChange({ proxyName: event.target.value })}
|
||||
placeholder="例如:香港节点"
|
||||
value={chainEditMode ? chainEditForm.proxyName : editForm.proxyName}
|
||||
onChange={(event) => {
|
||||
if (chainEditMode) {
|
||||
onChainEditFormChange({ proxyName: event.target.value })
|
||||
return
|
||||
}
|
||||
onChange({ proxyName: event.target.value })
|
||||
}}
|
||||
placeholder="节点名称"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="分组名称(可选)">
|
||||
<Input
|
||||
value={editForm.groupName}
|
||||
onChange={(event) => onChange({ groupName: event.target.value })}
|
||||
placeholder="例如:香港、美国"
|
||||
placeholder="分组名称"
|
||||
list="edit-proxy-groups-datalist"
|
||||
/>
|
||||
<datalist id="edit-proxy-groups-datalist">
|
||||
@@ -369,14 +576,115 @@ export function ProxyPoolEditModal({
|
||||
))}
|
||||
</datalist>
|
||||
</FormItem>
|
||||
<FormItem label="代理配置">
|
||||
<Textarea
|
||||
value={editForm.proxyConfig}
|
||||
onChange={(event) => onChange({ proxyConfig: event.target.value })}
|
||||
rows={10}
|
||||
placeholder="支持 Clash YAML、http://、https://、socks5:// 代理配置"
|
||||
/>
|
||||
</FormItem>
|
||||
{chainEditMode ? (
|
||||
<div className="space-y-4">
|
||||
<FormItem label="本地监听端口(可选)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainEditForm.localPort}
|
||||
onChange={(event) => onChainEditFormChange({ localPort: event.target.value })}
|
||||
placeholder="留空自动分配"
|
||||
/>
|
||||
</FormItem>
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainEditForm.first.protocol}
|
||||
onChange={(event) => onChainEditHopChange('first', 'protocol', event.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainEditForm.first.server}
|
||||
onChange={(event) => onChainEditHopChange('first', 'server', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainEditForm.first.port}
|
||||
onChange={(event) => onChainEditHopChange('first', 'port', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={chainEditForm.first.username}
|
||||
onChange={(event) => onChainEditHopChange('first', 'username', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={chainEditForm.first.password}
|
||||
onChange={(event) => onChainEditHopChange('first', 'password', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层代理</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="协议">
|
||||
<Select
|
||||
value={chainEditForm.second.protocol}
|
||||
onChange={(event) => onChainEditHopChange('second', 'protocol', event.target.value)}
|
||||
options={[
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainEditForm.second.server}
|
||||
onChange={(event) => onChainEditHopChange('second', 'server', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainEditForm.second.port}
|
||||
onChange={(event) => onChainEditHopChange('second', 'port', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={chainEditForm.second.username}
|
||||
onChange={(event) => onChainEditHopChange('second', 'username', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={chainEditForm.second.password}
|
||||
onChange={(event) => onChainEditHopChange('second', 'password', event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormItem label="代理配置">
|
||||
<Textarea
|
||||
value={editForm.proxyConfig}
|
||||
onChange={(event) => onChange({ proxyConfig: event.target.value })}
|
||||
rows={10}
|
||||
placeholder="支持 Clash YAML、http://、https://、socks5://、chain+socks5://"
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormItem label="DNS 服务器(可选)">
|
||||
<Textarea
|
||||
value={editForm.dnsServers}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function ProxyPoolTableCard({
|
||||
if (value === -1) return <span className="text-[var(--color-text-muted)] text-xs animate-pulse">测试中...</span>
|
||||
if (value === -2) return <span className="text-red-500 text-xs">超时</span>
|
||||
if (value === -3) return <span className="text-gray-400 text-xs">不支持</span>
|
||||
if (value === -4) return <span className="text-red-500 text-xs">失败</span>
|
||||
const color = value < 200 ? 'text-green-500' : value < 500 ? 'text-yellow-500' : 'text-red-500'
|
||||
return <span className={`text-xs font-medium ${color}`}>{value} ms</span>
|
||||
}
|
||||
@@ -184,7 +185,14 @@ export function ProxyPoolTableCard({
|
||||
},
|
||||
{
|
||||
key: 'ipHealth',
|
||||
title: 'IP健康',
|
||||
title: (
|
||||
<div className="leading-tight">
|
||||
<div>IP健康</div>
|
||||
<div className="mt-0.5 text-[10px] font-normal text-[var(--color-text-muted)]">
|
||||
仅供参考
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
width: '280px',
|
||||
render: (_, record) => renderIPHealth(record),
|
||||
},
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import yaml from 'js-yaml'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
import type { BrowserProxy } from '../../types'
|
||||
|
||||
export const BUILTIN_PROXY_IDS = new Set(['__direct__', '__local__'])
|
||||
export const BUILTIN_PROXY_IDS = new Set(['__direct__'])
|
||||
|
||||
const BUILTIN_PROXIES: BrowserProxy[] = [
|
||||
{ proxyId: '__direct__', proxyName: '直连(不走代理)', proxyConfig: 'direct://' },
|
||||
{ proxyId: '__local__', proxyName: '本地代理', proxyConfig: 'http://127.0.0.1:7890' },
|
||||
]
|
||||
|
||||
export interface ClashProxy {
|
||||
@@ -17,7 +16,7 @@ export interface ClashProxy {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ProxyImportMode = 'clash' | 'direct'
|
||||
export type ProxyImportMode = 'clash' | 'direct' | 'chain'
|
||||
|
||||
export interface DirectImportForm {
|
||||
proxyName: string
|
||||
@@ -28,6 +27,67 @@ export interface DirectImportForm {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ChainHopForm {
|
||||
protocol: 'http' | 'socks5'
|
||||
server: string
|
||||
port: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ChainImportForm {
|
||||
proxyName: string
|
||||
localPort: string
|
||||
first: ChainHopForm
|
||||
second: ChainHopForm
|
||||
}
|
||||
|
||||
interface ChainSocks5HopConfig {
|
||||
protocol: 'http' | 'socks5'
|
||||
server: string
|
||||
port: number
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
interface ChainSocks5Config {
|
||||
localPort?: number
|
||||
first: ChainSocks5HopConfig
|
||||
second: ChainSocks5HopConfig
|
||||
}
|
||||
|
||||
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
|
||||
|
||||
export const CHAIN_QUICK_IMPORT_TEMPLATE = `{
|
||||
"name": "",
|
||||
"group": "",
|
||||
"localPort": "",
|
||||
"first": {
|
||||
"protocol": "http",
|
||||
"server": "",
|
||||
"port": "",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"second": {
|
||||
"protocol": "http",
|
||||
"server": "",
|
||||
"port": "",
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
}`
|
||||
|
||||
export const DIRECT_QUICK_IMPORT_TEMPLATE = `{
|
||||
"name": "",
|
||||
"group": "",
|
||||
"protocol": "http",
|
||||
"server": "",
|
||||
"port": "",
|
||||
"username": "",
|
||||
"password": ""
|
||||
}`
|
||||
|
||||
export const DIRECT_PROXY_PROTOCOL_OPTIONS = [
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
@@ -43,9 +103,114 @@ export const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
|
||||
password: '',
|
||||
}
|
||||
|
||||
export const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
|
||||
proxyName: '',
|
||||
localPort: '',
|
||||
first: {
|
||||
protocol: 'http',
|
||||
server: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
second: {
|
||||
protocol: 'http',
|
||||
server: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
}
|
||||
|
||||
function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
|
||||
const cfg = proxyConfig.trim()
|
||||
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
|
||||
if (!encoded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const hop = raw as Record<string, unknown>
|
||||
const protocol = String(hop.protocol || '').trim().toLowerCase()
|
||||
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
|
||||
|
||||
const server = String(hop.server || '').trim()
|
||||
if (!server) return null
|
||||
|
||||
const portVal = Number(hop.port || 0)
|
||||
if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
|
||||
|
||||
const username = String(hop.username || '').trim()
|
||||
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
|
||||
if (password && !username) return null
|
||||
|
||||
return {
|
||||
protocol: protocol === 'http' ? 'http' : 'socks5',
|
||||
server,
|
||||
port: portVal,
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(encoded)
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>
|
||||
const first = normalizeHop(parsed.first)
|
||||
const second = normalizeHop(parsed.second)
|
||||
if (!first || !second) return null
|
||||
|
||||
const localPortRaw = parsed.localPort
|
||||
const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
|
||||
? 0
|
||||
: Number(localPortRaw)
|
||||
if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
|
||||
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
localPort: localPortNum > 0 ? localPortNum : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function toChainImportForm(proxyName: string, proxyConfig: string): ChainImportForm | null {
|
||||
const cfg = parseChainSocks5Config(proxyConfig)
|
||||
if (!cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
proxyName,
|
||||
localPort: cfg.localPort ? String(cfg.localPort) : '',
|
||||
first: {
|
||||
protocol: cfg.first.protocol,
|
||||
server: cfg.first.server,
|
||||
port: String(cfg.first.port),
|
||||
username: cfg.first.username || '',
|
||||
password: cfg.first.password || '',
|
||||
},
|
||||
second: {
|
||||
protocol: cfg.second.protocol,
|
||||
server: cfg.second.server,
|
||||
port: String(cfg.second.port),
|
||||
username: cfg.second.username || '',
|
||||
password: cfg.second.password || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportCandidate {
|
||||
proxyName: string
|
||||
proxyConfig: string
|
||||
groupName?: string
|
||||
}
|
||||
|
||||
export interface ProxyDisplayInfo {
|
||||
@@ -89,6 +254,11 @@ export function parseProxyInfo(proxyConfig: string): { type: string; server: str
|
||||
const cfg = proxyConfig.trim()
|
||||
if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 }
|
||||
|
||||
const chain = parseChainSocks5Config(cfg)
|
||||
if (chain) {
|
||||
return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 }
|
||||
}
|
||||
|
||||
const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//)
|
||||
if (urlMatch) {
|
||||
const scheme = urlMatch[1].toLowerCase()
|
||||
@@ -258,6 +428,54 @@ function formatDirectProxyHost(raw: string): string {
|
||||
return host.includes(':') ? `[${host}]` : host
|
||||
}
|
||||
|
||||
function normalizeDirectProtocol(raw: unknown): DirectImportForm['protocol'] {
|
||||
const protocol = String(raw || '').trim().toLowerCase()
|
||||
if (protocol === 'http' || protocol === 'https' || protocol === 'socks5') {
|
||||
return protocol
|
||||
}
|
||||
if (protocol === 'socks' || protocol === 'socket') {
|
||||
return 'socks5'
|
||||
}
|
||||
throw new Error('protocol 仅支持 http / https / socks5')
|
||||
}
|
||||
|
||||
function parseDirectProxyURL(raw: string): DirectImportForm {
|
||||
const normalized = normalizeDirectProxyConfig(raw)
|
||||
if (!normalized) {
|
||||
throw new Error('请输入标准代理地址')
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(normalized)) {
|
||||
throw new Error('单行文本需要包含协议头,需要包含协议头')
|
||||
}
|
||||
|
||||
let parsedURL: URL
|
||||
try {
|
||||
parsedURL = new URL(normalized)
|
||||
} catch {
|
||||
throw new Error('单行代理文本格式无效')
|
||||
}
|
||||
|
||||
const protocol = normalizeDirectProtocol(parsedURL.protocol.replace(/:$/, ''))
|
||||
const server = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1').trim()
|
||||
if (!server) {
|
||||
throw new Error('代理地址缺少主机名')
|
||||
}
|
||||
|
||||
const port = Number(parsedURL.port)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('代理地址缺少有效端口')
|
||||
}
|
||||
|
||||
return {
|
||||
proxyName: '',
|
||||
protocol,
|
||||
server,
|
||||
port: String(port),
|
||||
username: parsedURL.username ? decodeURIComponent(parsedURL.username) : '',
|
||||
password: parsedURL.password ? decodeURIComponent(parsedURL.password) : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
|
||||
const serverInput = form.server.trim()
|
||||
if (!serverInput) {
|
||||
@@ -311,6 +529,298 @@ export function buildDirectImportCandidate(form: DirectImportForm): ImportCandid
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedDirectImportItem {
|
||||
form: DirectImportForm
|
||||
groupName: string
|
||||
}
|
||||
|
||||
function parseDirectImportObject(payload: Record<string, unknown>, fallbackGroupName: string): ParsedDirectImportItem {
|
||||
const proxyName = String(payload.name ?? payload.proxyName ?? '').trim()
|
||||
const groupName = String(payload.group ?? payload.groupName ?? fallbackGroupName).trim()
|
||||
const proxyURL = String(payload.url ?? payload.proxyUrl ?? payload.proxy ?? payload.proxyConfig ?? '').trim()
|
||||
if (proxyURL) {
|
||||
const parsedForm = parseDirectProxyURL(proxyURL)
|
||||
return {
|
||||
form: {
|
||||
...parsedForm,
|
||||
proxyName: proxyName || parsedForm.proxyName,
|
||||
},
|
||||
groupName,
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = normalizeDirectProtocol(payload.protocol ?? payload.scheme)
|
||||
const server = String(payload.server ?? payload.host ?? '').trim()
|
||||
if (!server) {
|
||||
throw new Error('JSON 缺少 server')
|
||||
}
|
||||
|
||||
const portValue = Number(payload.port)
|
||||
if (!Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
|
||||
throw new Error('JSON 缺少有效 port')
|
||||
}
|
||||
|
||||
const username = String(payload.username ?? payload.user ?? '').trim()
|
||||
const password = payload.password === undefined || payload.password === null ? '' : String(payload.password)
|
||||
if (password && !username) {
|
||||
throw new Error('填写 password 时请同时填写 username')
|
||||
}
|
||||
|
||||
return {
|
||||
form: {
|
||||
proxyName,
|
||||
protocol,
|
||||
server,
|
||||
port: String(portValue),
|
||||
username,
|
||||
password,
|
||||
},
|
||||
groupName,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDirectImportItems(raw: string): { items: ParsedDirectImportItem[]; defaultGroupName: string } {
|
||||
const text = raw.trim()
|
||||
if (!text) {
|
||||
throw new Error('请输入 HTTP / SOCKS5 文本')
|
||||
}
|
||||
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('JSON 格式无效')
|
||||
}
|
||||
|
||||
let defaultGroupName = ''
|
||||
let sources: unknown[] = []
|
||||
if (Array.isArray(payload)) {
|
||||
sources = payload
|
||||
} else if (payload && typeof payload === 'object') {
|
||||
const record = payload as Record<string, unknown>
|
||||
defaultGroupName = String(record.group ?? record.groupName ?? '').trim()
|
||||
if (Array.isArray(record.proxies)) {
|
||||
sources = record.proxies
|
||||
} else if (Array.isArray(record.items)) {
|
||||
sources = record.items
|
||||
} else if (Array.isArray(record.list)) {
|
||||
sources = record.list
|
||||
} else {
|
||||
sources = [record]
|
||||
}
|
||||
} else {
|
||||
throw new Error('JSON 根节点必须是对象或数组')
|
||||
}
|
||||
|
||||
const items = sources.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
form: parseDirectProxyURL(item),
|
||||
groupName: defaultGroupName,
|
||||
}
|
||||
}
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new Error(`第 ${index + 1} 项格式无效`)
|
||||
}
|
||||
return parseDirectImportObject(item as Record<string, unknown>, defaultGroupName)
|
||||
})
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('JSON 未解析到可导入代理')
|
||||
}
|
||||
return { items, defaultGroupName }
|
||||
}
|
||||
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#') && !line.startsWith('//'))
|
||||
if (lines.length === 0) {
|
||||
throw new Error('请输入标准代理地址')
|
||||
}
|
||||
|
||||
return {
|
||||
items: lines.map((line) => ({
|
||||
form: parseDirectProxyURL(line),
|
||||
groupName: '',
|
||||
})),
|
||||
defaultGroupName: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDirectImportText(raw: string): { form: DirectImportForm; groupName: string } {
|
||||
const { items } = parseDirectImportItems(raw)
|
||||
if (items.length !== 1) {
|
||||
throw new Error('检测到多条代理,请直接点击解析进行批量导入')
|
||||
}
|
||||
return items[0]
|
||||
}
|
||||
|
||||
export function buildDirectImportCandidatesFromText(raw: string): { candidates: ImportCandidate[]; defaultGroupName: string } {
|
||||
const { items, defaultGroupName } = parseDirectImportItems(raw)
|
||||
return {
|
||||
candidates: items.map((item) => ({
|
||||
...buildDirectImportCandidate(item.form),
|
||||
groupName: item.groupName,
|
||||
})),
|
||||
defaultGroupName,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
|
||||
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
|
||||
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
|
||||
const server = hop.server.trim()
|
||||
if (!server) {
|
||||
throw new Error(`请输入${label}代理地址`)
|
||||
}
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
|
||||
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
|
||||
}
|
||||
|
||||
const portInput = hop.port.trim()
|
||||
if (!portInput) {
|
||||
throw new Error(`请输入${label}代理端口`)
|
||||
}
|
||||
if (!/^\d+$/.test(portInput)) {
|
||||
throw new Error(`${label}代理端口必须为数字`)
|
||||
}
|
||||
|
||||
const port = Number(portInput)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${label}代理端口必须在 1-65535 之间`)
|
||||
}
|
||||
|
||||
const username = hop.username.trim()
|
||||
const password = hop.password
|
||||
if (password && !username) {
|
||||
throw new Error(`${label}填写密码时请同时填写账号`)
|
||||
}
|
||||
|
||||
return {
|
||||
protocol,
|
||||
server,
|
||||
port,
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const localPortInput = form.localPort.trim()
|
||||
if (localPortInput && !/^\d+$/.test(localPortInput)) {
|
||||
throw new Error('本地监听端口必须为数字')
|
||||
}
|
||||
const localPort = localPortInput ? Number(localPortInput) : 0
|
||||
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
|
||||
throw new Error('本地监听端口必须在 1-65535 之间')
|
||||
}
|
||||
|
||||
const payload: ChainSocks5Config = {
|
||||
first: parseHop('第一层', form.first),
|
||||
second: parseHop('第二层', form.second),
|
||||
localPort: localPort > 0 ? localPort : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
|
||||
proxyConfig: `${CHAIN_SOCKS5_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`,
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalChainPort(raw: unknown, label: string): number | undefined {
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
const value = Number(raw)
|
||||
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
||||
throw new Error(`${label}必须在 1-65535 之间`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseChainQuickImportHop(raw: unknown, label: string): ChainSocks5HopConfig {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error(`${label}缺少配置`)
|
||||
}
|
||||
|
||||
const hop = raw as Record<string, unknown>
|
||||
const protocol = String(hop.protocol || 'socks5').trim().toLowerCase()
|
||||
if (protocol !== 'socks5' && protocol !== 'http') {
|
||||
throw new Error(`${label}仅支持 http / socks5`)
|
||||
}
|
||||
|
||||
const server = String(hop.server || '').trim()
|
||||
if (!server) {
|
||||
throw new Error(`${label}缺少 server`)
|
||||
}
|
||||
|
||||
const portValue = Number(hop.port)
|
||||
if (!Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
|
||||
throw new Error(`${label}缺少有效 port`)
|
||||
}
|
||||
|
||||
const username = String(hop.username || '').trim()
|
||||
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
|
||||
if (password && !username) {
|
||||
throw new Error(`${label}填写 password 时请同时填写 username`)
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: protocol === 'http' ? 'http' : 'socks5',
|
||||
server,
|
||||
port: portValue,
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseChainImportJSON(raw: string): { form: ChainImportForm; groupName: string } {
|
||||
const text = raw.trim()
|
||||
if (!text) {
|
||||
throw new Error('请输入链式代理 JSON')
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error('JSON 格式无效')
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('JSON 根节点必须是对象')
|
||||
}
|
||||
|
||||
const first = parseChainQuickImportHop(payload.first, '第一层')
|
||||
const second = parseChainQuickImportHop(payload.second, '第二层')
|
||||
const localPort = parseOptionalChainPort(payload.localPort, 'localPort')
|
||||
const proxyName = String(payload.name ?? payload.proxyName ?? '').trim()
|
||||
const groupName = String(payload.group ?? payload.groupName ?? '').trim()
|
||||
|
||||
return {
|
||||
form: {
|
||||
proxyName,
|
||||
localPort: localPort ? String(localPort) : '',
|
||||
first: {
|
||||
protocol: first.protocol,
|
||||
server: first.server,
|
||||
port: String(first.port),
|
||||
username: first.username || '',
|
||||
password: first.password || '',
|
||||
},
|
||||
second: {
|
||||
protocol: second.protocol,
|
||||
server: second.server,
|
||||
port: String(second.port),
|
||||
username: second.username || '',
|
||||
password: second.password || '',
|
||||
},
|
||||
},
|
||||
groupName,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
|
||||
return parsedProxies.map((proxy, index) => ({
|
||||
proxyName: resolveImportedProxyName(proxy, index, prefix),
|
||||
@@ -325,7 +835,7 @@ export function buildImportPreview(candidates: ImportCandidate[], groupName: str
|
||||
proxyId: `preview-${index}`,
|
||||
proxyName: candidate.proxyName,
|
||||
proxyConfig: candidate.proxyConfig,
|
||||
groupName,
|
||||
groupName: candidate.groupName || groupName,
|
||||
sourceId: '',
|
||||
sourceUrl: '',
|
||||
sourceAutoRefresh: false,
|
||||
|
||||
@@ -116,7 +116,10 @@ export function writeGlobalRefreshConfig(enabled: boolean, intervalM: number) {
|
||||
|
||||
export function toLatencyValue(ok: boolean, latencyMs: number, error?: string): number {
|
||||
if (ok) return latencyMs
|
||||
return error?.includes('不支持') ? -3 : -2
|
||||
const message = (error || '').toLowerCase()
|
||||
if (message.includes('不支持')) return -3
|
||||
if (message.includes('timeout') || message.includes('超时') || message.includes('deadline exceeded') || message.includes('i/o timeout')) return -2
|
||||
return -4
|
||||
}
|
||||
|
||||
export function readLatencyCache(): Record<string, number> {
|
||||
|
||||
@@ -57,6 +57,23 @@ export interface BrowserSettings {
|
||||
startStableWindowMs: number
|
||||
}
|
||||
|
||||
export interface ProxyCheckTarget {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
parser?: string
|
||||
timeoutMs?: number
|
||||
expectedStatus?: number[]
|
||||
}
|
||||
|
||||
export interface ProxyCheckSettings {
|
||||
bridgeStartTimeoutMs: number
|
||||
speedTargetId: string
|
||||
ipHealthTargetId: string
|
||||
targets: ProxyCheckTarget[]
|
||||
}
|
||||
|
||||
export interface BrowserCore {
|
||||
coreId: string
|
||||
coreName: string
|
||||
@@ -139,6 +156,16 @@ export interface SnapshotInfo {
|
||||
export interface BrowserBookmark {
|
||||
name: string
|
||||
url: string
|
||||
openOnStart?: boolean
|
||||
}
|
||||
|
||||
export interface BookmarkSyncResult {
|
||||
total: number
|
||||
synced: number
|
||||
skipped: number
|
||||
failed: number
|
||||
skippedList: string[]
|
||||
failedList: string[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
+9
-1
@@ -3,8 +3,8 @@
|
||||
import {automation} from '../models';
|
||||
import {backup} from '../models';
|
||||
import {config} from '../models';
|
||||
import {browser} from '../models';
|
||||
import {backend} from '../models';
|
||||
import {browser} from '../models';
|
||||
import {logger} from '../models';
|
||||
import {launchcode} from '../models';
|
||||
import {time} from '../models';
|
||||
@@ -71,6 +71,8 @@ export function BookmarkReset():Promise<void>;
|
||||
|
||||
export function BookmarkSave(arg1:Array<config.BrowserBookmark>):Promise<void>;
|
||||
|
||||
export function BookmarkSyncToProfiles():Promise<backend.BookmarkSyncResult>;
|
||||
|
||||
export function BrowserClearCookies(arg1:string):Promise<void>;
|
||||
|
||||
export function BrowserCoreDelete(arg1:string):Promise<void>;
|
||||
@@ -105,6 +107,8 @@ export function BrowserInstanceStart(arg1:string):Promise<browser.Profile>;
|
||||
|
||||
export function BrowserInstanceStartByCode(arg1:string):Promise<browser.Profile>;
|
||||
|
||||
export function BrowserInstanceStartDirect(arg1:string):Promise<browser.Profile>;
|
||||
|
||||
export function BrowserInstanceStartWithParams(arg1:string,arg2:Array<string>,arg3:Array<string>,arg4:boolean):Promise<browser.Profile>;
|
||||
|
||||
export function BrowserInstanceStatus(arg1:string):Promise<browser.Profile>;
|
||||
@@ -193,6 +197,8 @@ export function GetLogLevel():Promise<string>;
|
||||
|
||||
export function GetMemoryStats():Promise<Record<string, any>>;
|
||||
|
||||
export function GetProxyCheckSettings():Promise<config.ProxyCheckConfig>;
|
||||
|
||||
export function GetRunningInstances():Promise<Array<browser.Profile>>;
|
||||
|
||||
export function InstallAutomationRuntime():Promise<Record<string, any>>;
|
||||
@@ -225,6 +231,8 @@ export function SaveBrowserProxies(arg1:Array<config.BrowserProxy>):Promise<void
|
||||
|
||||
export function SaveBrowserSettings(arg1:browser.Settings):Promise<void>;
|
||||
|
||||
export function SaveProxyCheckSettings(arg1:config.ProxyCheckConfig):Promise<void>;
|
||||
|
||||
export function SetLogLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function StartInstance(arg1:string):Promise<browser.Profile>;
|
||||
|
||||
@@ -126,6 +126,10 @@ export function BookmarkSave(arg1) {
|
||||
return window['go']['main']['App']['BookmarkSave'](arg1);
|
||||
}
|
||||
|
||||
export function BookmarkSyncToProfiles() {
|
||||
return window['go']['main']['App']['BookmarkSyncToProfiles']();
|
||||
}
|
||||
|
||||
export function BrowserClearCookies(arg1) {
|
||||
return window['go']['main']['App']['BrowserClearCookies'](arg1);
|
||||
}
|
||||
@@ -194,6 +198,10 @@ export function BrowserInstanceStartByCode(arg1) {
|
||||
return window['go']['main']['App']['BrowserInstanceStartByCode'](arg1);
|
||||
}
|
||||
|
||||
export function BrowserInstanceStartDirect(arg1) {
|
||||
return window['go']['main']['App']['BrowserInstanceStartDirect'](arg1);
|
||||
}
|
||||
|
||||
export function BrowserInstanceStartWithParams(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['BrowserInstanceStartWithParams'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -370,6 +378,10 @@ export function GetMemoryStats() {
|
||||
return window['go']['main']['App']['GetMemoryStats']();
|
||||
}
|
||||
|
||||
export function GetProxyCheckSettings() {
|
||||
return window['go']['main']['App']['GetProxyCheckSettings']();
|
||||
}
|
||||
|
||||
export function GetRunningInstances() {
|
||||
return window['go']['main']['App']['GetRunningInstances']();
|
||||
}
|
||||
@@ -434,6 +446,10 @@ export function SaveBrowserSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveBrowserSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveProxyCheckSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveProxyCheckSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SetLogLevel(arg1) {
|
||||
return window['go']['main']['App']['SetLogLevel'](arg1);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ export namespace automation {
|
||||
paramsText: string;
|
||||
useScriptSelector: boolean;
|
||||
useScriptParams: boolean;
|
||||
timeoutMs?: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScriptRunRequest(source);
|
||||
@@ -190,6 +191,7 @@ export namespace automation {
|
||||
this.paramsText = source["paramsText"];
|
||||
this.useScriptSelector = source["useScriptSelector"];
|
||||
this.useScriptParams = source["useScriptParams"];
|
||||
this.timeoutMs = source["timeoutMs"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +201,28 @@ export namespace automation {
|
||||
|
||||
export namespace backend {
|
||||
|
||||
export class BookmarkSyncResult {
|
||||
total: number;
|
||||
synced: number;
|
||||
skipped: number;
|
||||
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"];
|
||||
this.synced = source["synced"];
|
||||
this.skipped = source["skipped"];
|
||||
this.failed = source["failed"];
|
||||
this.skippedList = source["skippedList"];
|
||||
this.failedList = source["failedList"];
|
||||
}
|
||||
}
|
||||
export class CookieInfo {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -733,6 +757,7 @@ export namespace config {
|
||||
export class BrowserBookmark {
|
||||
name: string;
|
||||
url: string;
|
||||
openOnStart: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BrowserBookmark(source);
|
||||
@@ -742,6 +767,7 @@ export namespace config {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.url = source["url"];
|
||||
this.openOnStart = source["openOnStart"];
|
||||
}
|
||||
}
|
||||
export class BrowserCore {
|
||||
@@ -804,6 +830,66 @@ export namespace config {
|
||||
this.lastIPHealthJson = source["lastIPHealthJson"];
|
||||
}
|
||||
}
|
||||
export class ProxyCheckTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
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"];
|
||||
this.name = source["name"];
|
||||
this.type = source["type"];
|
||||
this.url = source["url"];
|
||||
this.parser = source["parser"];
|
||||
this.timeoutMs = source["timeoutMs"];
|
||||
this.expectedStatus = source["expectedStatus"];
|
||||
}
|
||||
}
|
||||
export class ProxyCheckConfig {
|
||||
bridgeStartTimeoutMs: number;
|
||||
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"];
|
||||
this.speedTargetId = source["speedTargetId"];
|
||||
this.ipHealthTargetId = source["ipHealthTargetId"];
|
||||
this.targets = this.convertValues(source["targets"], ProxyCheckTarget);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
|
||||
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
|
||||
@@ -467,8 +467,6 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
|
||||
|
||||
+13
-1
@@ -122,6 +122,14 @@ FunctionEnd
|
||||
Function WarnInstallDir
|
||||
StrCpy $0 "$INSTDIR"
|
||||
|
||||
StrCpy $1 "$0" 2
|
||||
${If} "$1" == "C:"
|
||||
Goto warn_dir
|
||||
${EndIf}
|
||||
${If} "$1" == "c:"
|
||||
Goto warn_dir
|
||||
${EndIf}
|
||||
|
||||
StrLen $1 "$PROGRAMFILES64"
|
||||
${If} $1 > 0
|
||||
StrCpy $2 "$0" $1
|
||||
@@ -149,7 +157,7 @@ Function WarnInstallDir
|
||||
Return
|
||||
|
||||
warn_dir:
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OKCANCEL|MB_DEFBUTTON2 "当前安装目录位于 Program Files。$\r$\n$\r$\n在部分机器上,普通权限运行可能无法写入 data 目录,导致再次启动闪退或浏览器实例启动失败。$\r$\n$\r$\n建议改为可写目录(如 D:\AntBrowser)。$\r$\n$\r$\n点击“确定”继续安装到当前目录,点击“取消”返回修改安装路径。" IDOK continue_install IDCANCEL cancel_install
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OKCANCEL|MB_DEFBUTTON2 "不建议安装到 C 盘、Program Files 或其他受权限保护的目录。$\r$\n$\r$\nAnt Browser 会在安装目录写入 data、浏览器实例和运行时文件;普通权限运行时可能写入失败,导致再次启动闪退或浏览器实例启动失败。$\r$\n$\r$\n建议改为非 C 盘可写目录,例如 D:\software\Ant Browser 或 E:\software\Ant Browser。$\r$\n$\r$\n点击“确定”继续安装到当前目录,点击“取消”返回修改安装路径。" IDOK continue_install IDCANCEL cancel_install
|
||||
|
||||
continue_install:
|
||||
Return
|
||||
@@ -173,9 +181,13 @@ RequestExecutionLevel admin
|
||||
!define MUI_UNICON "..\build\windows\icon.ico"
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!define MUI_DIRECTORYPAGE_TEXT_TOP "请选择 Ant Browser 的安装目录。建议安装到非 C 盘的可写目录,例如 D:\software\Ant Browser 或 E:\software\Ant Browser;不要安装到 C 盘、Program Files 或其他受权限保护的目录。"
|
||||
!define MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装目录(建议非 C 盘)"
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE WarnInstallDir
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!undef MUI_PAGE_CUSTOMFUNCTION_LEAVE
|
||||
!undef MUI_DIRECTORYPAGE_TEXT_TOP
|
||||
!undef MUI_DIRECTORYPAGE_TEXT_DESTINATION
|
||||
!define MUI_COMPONENTSPAGE_SMALLDESC
|
||||
!insertmacro MUI_PAGE_COMPONENTS
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
Reference in New Issue
Block a user