feat: improve browser automation and proxy management

This commit is contained in:
ant-black
2026-06-18 20:33:03 +08:00
parent 3257de014e
commit 59cbe36ca0
119 changed files with 8512 additions and 602 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ func (a *App) backupClearBusinessTables() error {
}
defer tx.Rollback()
tables := []string{"launch_codes", "browser_profiles", "browser_proxies", "browser_cores", "browser_bookmarks", "browser_groups"}
tables := []string{"launch_codes", "browser_profiles", "browser_proxies", "browser_cores", "browser_bookmarks", "browser_groups", "browser_extensions", "browser_profile_extension_settings", "browser_profile_extensions"}
for _, table := range tables {
if _, err := tx.Exec("DELETE FROM " + table); err != nil && !backupIsNoSuchTableError(err) {
return fmt.Errorf("清空数据表失败(%s): %w", table, err)
+33
View File
@@ -158,6 +158,39 @@ SELECT s.name, s.url, s.sort_order
FROM src.browser_bookmarks s
WHERE NOT EXISTS (
SELECT 1 FROM browser_bookmarks t WHERE lower(t.url) = lower(s.url)
)`,
},
{
name: "browser_extensions",
insertAll: `INSERT INTO browser_extensions (extension_id, name, version, description, manifest_json, source_url, install_dir, enabled, installed_at, updated_at)
SELECT extension_id, name, version, description, manifest_json, source_url, install_dir, enabled, installed_at, updated_at FROM src.browser_extensions`,
insertSafe: `INSERT INTO browser_extensions (extension_id, name, version, description, manifest_json, source_url, install_dir, enabled, installed_at, updated_at)
SELECT s.extension_id, s.name, s.version, s.description, s.manifest_json, s.source_url, s.install_dir, s.enabled, s.installed_at, s.updated_at
FROM src.browser_extensions s
WHERE NOT EXISTS (
SELECT 1 FROM browser_extensions t WHERE t.extension_id = s.extension_id
)`,
},
{
name: "browser_profile_extension_settings",
insertAll: `INSERT INTO browser_profile_extension_settings (profile_id, configured, updated_at)
SELECT profile_id, configured, updated_at FROM src.browser_profile_extension_settings`,
insertSafe: `INSERT INTO browser_profile_extension_settings (profile_id, configured, updated_at)
SELECT s.profile_id, s.configured, s.updated_at
FROM src.browser_profile_extension_settings s
WHERE NOT EXISTS (
SELECT 1 FROM browser_profile_extension_settings t WHERE t.profile_id = s.profile_id
)`,
},
{
name: "browser_profile_extensions",
insertAll: `INSERT INTO browser_profile_extensions (profile_id, extension_id, enabled, created_at, updated_at)
SELECT profile_id, extension_id, enabled, created_at, updated_at FROM src.browser_profile_extensions`,
insertSafe: `INSERT INTO browser_profile_extensions (profile_id, extension_id, enabled, created_at, updated_at)
SELECT s.profile_id, s.extension_id, s.enabled, s.created_at, s.updated_at
FROM src.browser_profile_extensions s
WHERE NOT EXISTS (
SELECT 1 FROM browser_profile_extensions t WHERE t.profile_id = s.profile_id AND t.extension_id = s.extension_id
)`,
},
{
+2
View File
@@ -17,6 +17,7 @@ func (a *App) GetBrowserSettings() BrowserSettings {
RestoreLastSession: a.config.Browser.RestoreLastSession,
StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config),
StartStableWindowMs: browserStartStableWindowMillis(a.config),
DefaultConnectorType: config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType),
}
}
@@ -33,6 +34,7 @@ func (a *App) SaveBrowserSettings(settings BrowserSettings) error {
lightStartEnabled := settings.LightStartEnabled
a.config.Browser.LightStartEnabled = &lightStartEnabled
a.config.Browser.RestoreLastSession = settings.RestoreLastSession
a.config.Browser.DefaultConnectorType = config.NormalizeBrowserConnectorType(settings.DefaultConnectorType)
if settings.StartReadyTimeoutMs > 0 {
a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs
} else if a.config.Browser.StartReadyTimeoutMs <= 0 {
+359
View File
@@ -0,0 +1,359 @@
package backend
import (
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"ant-chrome/backend/internal/proxy"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type BrowserExtension = browser.Extension
type BrowserExtensionLookupResult = browser.ExtensionLookupResult
type BrowserProfileExtensionSettings = browser.ProfileExtensionSettings
type BrowserExtensionWebStoreRequest struct {
Query string `json:"query"`
UseProxy bool `json:"useProxy"`
ProxyConfig string `json:"proxyConfig"`
}
type BrowserExtensionManualInstallGuide struct {
ExtensionID string `json:"extensionId"`
StoreURL string `json:"storeUrl"`
DownloadURL string `json:"downloadUrl"`
DownloadDir string `json:"downloadDir"`
FileName string `json:"fileName"`
}
type BrowserExtensionManualDownloadFile struct {
FileName string `json:"fileName"`
FilePath string `json:"filePath"`
SizeBytes int64 `json:"sizeBytes"`
UpdatedAt string `json:"updatedAt"`
}
func (a *App) BrowserExtensionList() ([]BrowserExtension, error) {
if a.browserMgr == nil || a.browserMgr.ExtensionDAO == nil {
return []BrowserExtension{}, nil
}
return a.browserMgr.ExtensionDAO.List()
}
func (a *App) BrowserExtensionLookup(query string) (BrowserExtensionLookupResult, error) {
return a.BrowserExtensionLookupWithProxy(BrowserExtensionWebStoreRequest{Query: query})
}
func (a *App) BrowserExtensionLookupWithProxy(input BrowserExtensionWebStoreRequest) (BrowserExtensionLookupResult, error) {
if a.browserMgr == nil {
return BrowserExtensionLookupResult{}, fmt.Errorf("浏览器管理器未初始化")
}
client, err := a.extensionDownloadHTTPClient(input.UseProxy, input.ProxyConfig)
if err != nil {
return BrowserExtensionLookupResult{}, fmt.Errorf("下载代理配置错误: %w", err)
}
return a.browserMgr.LookupExtensionWithHTTPClient(input.Query, client)
}
func (a *App) BrowserExtensionInstall(query string) (BrowserExtension, error) {
return a.BrowserExtensionInstallWithProxy(BrowserExtensionWebStoreRequest{Query: query})
}
func (a *App) BrowserExtensionInstallWithProxy(input BrowserExtensionWebStoreRequest) (BrowserExtension, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.ctx == nil {
return BrowserExtension{}, fmt.Errorf("应用上下文未初始化")
}
if a.browserMgr == nil {
return BrowserExtension{}, fmt.Errorf("浏览器管理器未初始化")
}
client, err := a.extensionDownloadHTTPClient(input.UseProxy, input.ProxyConfig)
if err != nil {
return BrowserExtension{}, fmt.Errorf("下载代理配置错误: %w", err)
}
return a.browserMgr.InstallExtensionFromWebStoreWithHTTPClient(a.ctx, input.Query, client)
}
func (a *App) BrowserExtensionManualInstallGuide(query string) (BrowserExtensionManualInstallGuide, error) {
extensionID := browser.NormalizeExtensionID(query)
if extensionID == "" {
return BrowserExtensionManualInstallGuide{}, fmt.Errorf("请输入 Chrome 插件 ID 或 Chrome Web Store 链接")
}
downloadDir := a.extensionManualDownloadDir()
if err := os.MkdirAll(downloadDir, 0o755); err != nil {
return BrowserExtensionManualInstallGuide{}, fmt.Errorf("创建手动下载目录失败: %w", err)
}
return BrowserExtensionManualInstallGuide{
ExtensionID: extensionID,
StoreURL: browser.BuildChromeWebStoreURL(extensionID),
DownloadURL: browser.BuildChromeExtensionDownloadURL(extensionID),
DownloadDir: downloadDir,
FileName: extensionID + ".crx",
}, nil
}
func (a *App) BrowserExtensionOpenManualDownloadDir() error {
downloadDir := a.extensionManualDownloadDir()
if err := os.MkdirAll(downloadDir, 0o755); err != nil {
return fmt.Errorf("创建手动下载目录失败: %w", err)
}
absPath, err := filepath.Abs(downloadDir)
if err != nil {
return err
}
return openPathInFileManager(absPath)
}
func (a *App) BrowserExtensionListManualDownloadFiles() ([]BrowserExtensionManualDownloadFile, error) {
downloadDir := a.extensionManualDownloadDir()
if err := os.MkdirAll(downloadDir, 0o755); err != nil {
return nil, fmt.Errorf("创建手动下载目录失败: %w", err)
}
entries, err := os.ReadDir(downloadDir)
if err != nil {
return nil, fmt.Errorf("读取手动下载目录失败: %w", err)
}
files := make([]BrowserExtensionManualDownloadFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
lowerName := strings.ToLower(name)
if !strings.HasSuffix(lowerName, ".crx") && !strings.HasSuffix(lowerName, ".zip") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, BrowserExtensionManualDownloadFile{
FileName: name,
FilePath: filepath.Join(downloadDir, name),
SizeBytes: info.Size(),
UpdatedAt: info.ModTime().Format("2006-01-02 15:04:05"),
})
}
return files, nil
}
func (a *App) BrowserExtensionInstallManualDownloadFile(fileName string) (BrowserExtension, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.browserMgr == nil {
return BrowserExtension{}, fmt.Errorf("浏览器管理器未初始化")
}
path, err := a.resolveManualDownloadFile(fileName)
if err != nil {
return BrowserExtension{}, err
}
return a.browserMgr.InstallExtensionPackageFile(path)
}
func (a *App) extensionManualDownloadDir() string {
return a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "extensions", "manual-downloads")))
}
func (a *App) resolveManualDownloadFile(fileName string) (string, error) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return "", fmt.Errorf("请选择要导入的插件包")
}
baseDir, err := filepath.Abs(a.extensionManualDownloadDir())
if err != nil {
return "", err
}
cleanName := filepath.Clean(filepath.FromSlash(fileName))
if cleanName == "." || cleanName == "" || filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) || cleanName == ".." {
return "", fmt.Errorf("插件包路径无效")
}
lowerName := strings.ToLower(cleanName)
if !strings.HasSuffix(lowerName, ".crx") && !strings.HasSuffix(lowerName, ".zip") {
return "", fmt.Errorf("只支持导入 .crx 或 .zip 插件包")
}
fullPath, err := filepath.Abs(filepath.Join(baseDir, cleanName))
if err != nil {
return "", err
}
if fullPath != baseDir && !strings.HasPrefix(fullPath, baseDir+string(os.PathSeparator)) {
return "", fmt.Errorf("插件包路径越界")
}
info, err := os.Stat(fullPath)
if err != nil {
return "", fmt.Errorf("插件包不存在: %w", err)
}
if info.IsDir() {
return "", fmt.Errorf("请选择插件包文件,不是目录")
}
return fullPath, nil
}
func (a *App) extensionDownloadHTTPClient(useProxy bool, proxyConfig string) (*http.Client, error) {
proxyConfig = strings.TrimSpace(proxyConfig)
log := logger.New("Extension")
if useProxy && proxyConfig == "" {
return nil, fmt.Errorf("已启用下载代理,但代理配置为空,请重新选择代理节点")
}
if proxyConfig == "" || strings.EqualFold(proxyConfig, "direct://") {
log.Info("Chrome 插件下载使用直连")
client, _, err := proxyCoreHTTPClient(browser.ExtensionDownloadTimeout(), "")
return client, err
}
proxies := a.getLatestProxies()
connectorType := config.BrowserConnectorXray
if a != nil && a.config != nil {
connectorType = a.config.Browser.DefaultConnectorType
}
log.Info("Chrome 插件下载使用代理", logger.F("connector", connectorType), logger.F("proxy_prefix", proxyConfigLogPrefix(proxyConfig)))
return proxy.BuildProxyHTTPClient(proxyConfig, "", proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, browser.ExtensionDownloadTimeout())
}
func proxyConfigLogPrefix(proxyConfig string) string {
proxyConfig = strings.TrimSpace(proxyConfig)
if len(proxyConfig) <= 24 {
return proxyConfig
}
return proxyConfig[:24]
}
func (a *App) BrowserExtensionInstallLocalFile() (BrowserExtension, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.ctx == nil {
return BrowserExtension{}, fmt.Errorf("应用上下文未初始化")
}
if a.browserMgr == nil {
return BrowserExtension{}, fmt.Errorf("浏览器管理器未初始化")
}
path, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "选择 Chrome 插件包",
Filters: []wailsruntime.FileFilter{
{DisplayName: "Chrome 插件包 (*.crx;*.zip)", Pattern: "*.crx;*.zip"},
},
})
if err != nil {
return BrowserExtension{}, fmt.Errorf("打开文件选择框失败: %w", err)
}
if strings.TrimSpace(path) == "" {
return BrowserExtension{}, fmt.Errorf("已取消选择")
}
return a.browserMgr.InstallExtensionPackageFile(path)
}
func (a *App) BrowserExtensionInstallLocalDirectory() (BrowserExtension, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.ctx == nil {
return BrowserExtension{}, fmt.Errorf("应用上下文未初始化")
}
if a.browserMgr == nil {
return BrowserExtension{}, fmt.Errorf("浏览器管理器未初始化")
}
path, err := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{Title: "选择已解压插件目录"})
if err != nil {
return BrowserExtension{}, fmt.Errorf("打开目录选择框失败: %w", err)
}
if strings.TrimSpace(path) == "" {
return BrowserExtension{}, fmt.Errorf("已取消选择")
}
return a.browserMgr.InstallExtensionDirectory(path)
}
func (a *App) BrowserExtensionSetEnabled(extensionID string, enabled bool) (BrowserExtension, error) {
if a.browserMgr == nil || a.browserMgr.ExtensionDAO == nil {
return BrowserExtension{}, fmt.Errorf("插件管理器未初始化")
}
extensionID = strings.TrimSpace(extensionID)
if extensionID == "" {
return BrowserExtension{}, fmt.Errorf("插件 ID 不能为空")
}
if err := a.browserMgr.ExtensionDAO.SetEnabled(extensionID, enabled); err != nil {
return BrowserExtension{}, err
}
return a.browserMgr.ExtensionDAO.Get(extensionID)
}
func (a *App) BrowserExtensionDelete(extensionID string) error {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.browserMgr == nil || a.browserMgr.ExtensionDAO == nil {
return fmt.Errorf("插件管理器未初始化")
}
extensionID = strings.TrimSpace(extensionID)
if extensionID == "" {
return fmt.Errorf("插件 ID 不能为空")
}
extension, err := a.browserMgr.ExtensionDAO.Get(extensionID)
if err != nil {
return err
}
if _, err := a.resolveBrowserExtensionInstallDir(extension.InstallDir); err != nil {
return err
}
if err := a.browserMgr.ExtensionDAO.Delete(extensionID); err != nil {
return err
}
if err := a.removeBrowserExtensionInstallDir(extension.InstallDir); err != nil {
return err
}
return nil
}
func (a *App) resolveBrowserExtensionInstallDir(installDir string) (string, error) {
installDir = strings.TrimSpace(installDir)
if installDir == "" {
return "", nil
}
target, err := filepath.Abs(installDir)
if err != nil {
return "", fmt.Errorf("解析插件目录失败: %w", err)
}
root, err := filepath.Abs(a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "extensions"))))
if err != nil {
return "", fmt.Errorf("解析插件根目录失败: %w", err)
}
target = filepath.Clean(target)
root = filepath.Clean(root)
rel, err := filepath.Rel(root, target)
if err != nil || rel == "." || rel == "" || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("拒绝删除插件根目录外的路径: %s", installDir)
}
return target, nil
}
func (a *App) removeBrowserExtensionInstallDir(installDir string) error {
target, err := a.resolveBrowserExtensionInstallDir(installDir)
if err != nil || target == "" {
return err
}
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("删除插件目录失败: %w", err)
}
return nil
}
func (a *App) BrowserProfileExtensionGet(profileID string) (BrowserProfileExtensionSettings, error) {
if a.browserMgr == nil || a.browserMgr.ExtensionDAO == nil {
return BrowserProfileExtensionSettings{}, fmt.Errorf("插件管理器未初始化")
}
return a.browserMgr.ExtensionDAO.GetProfileSettings(profileID)
}
func (a *App) BrowserProfileExtensionSave(profileID string, extensionIDs []string, configured bool) (BrowserProfileExtensionSettings, error) {
if a.browserMgr == nil || a.browserMgr.ExtensionDAO == nil {
return BrowserProfileExtensionSettings{}, fmt.Errorf("插件管理器未初始化")
}
return a.browserMgr.ExtensionDAO.SetProfileSettings(profileID, extensionIDs, configured)
}
+10 -2
View File
@@ -26,6 +26,7 @@ type browserStartPlan struct {
chromeBinaryPath string
userDataDir string
args []string
extensionDirs []string
deferredStartTargets []string
effectiveProxy string
acquiredXrayBridgeKey string
@@ -132,6 +133,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
maxStartAttempts := browserStartAttemptCount()
totalReadyTimeout := time.Duration(maxStartAttempts) * startReadyTimeout
restoreLastSession := browserRestoreLastSession(a.config)
extensionDirs := a.browserMgr.EnabledExtensionDirsForProfile(input.ProfileID)
defaultStartURLs := mergeStartURLs(browserDefaultStartURLs(a.config), bookmarkStartURLs(bookmarks))
launchTargets, deferredStartTargets := buildBrowserLaunchTargets(
input.StartURLs,
@@ -157,7 +159,8 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
profile: profile,
chromeBinaryPath: chromeBinaryPath,
userDataDir: userDataDir,
args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, launchTargets),
extensionDirs: extensionDirs,
args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, extensionDirs, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, launchTargets),
deferredStartTargets: deferredStartTargets,
effectiveProxy: effectiveProxy,
acquiredXrayBridgeKey: acquiredXrayBridgeKey,
@@ -265,7 +268,7 @@ func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *Brow
return sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, nil
}
func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, launchTargets []string) []string {
func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, extensionDirs []string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, launchTargets []string) []string {
args := []string{
fmt.Sprintf("--user-data-dir=%s", userDataDir),
fmt.Sprintf("--remote-debugging-port=%d", debugPort),
@@ -296,6 +299,11 @@ func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPo
args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy))
}
if extensionArg := strings.Join(normalizeNonEmptyStrings(extensionDirs), ","); extensionArg != "" {
args = append(args, fmt.Sprintf("--disable-extensions-except=%s", extensionArg))
args = append(args, fmt.Sprintf("--load-extension=%s", extensionArg))
}
args = append(args, profile.FingerprintArgs...)
args = append(args, sanitizedProfileLaunchArgs...)
args = append(args, sanitizedExtraLaunchArgs...)
+23 -2
View File
@@ -1,6 +1,7 @@
package backend
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"ant-chrome/backend/internal/proxy"
"fmt"
@@ -70,7 +71,25 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
return "", "", false, startErr
}
connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
if connectorType == config.BrowserConnectorMihomo && (proxy.IsSingBoxProtocol(resolvedProxyConfig) || proxy.RequiresBridge(resolvedProxyConfig, proxies, resolvedProxyID) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig)) {
log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "mihomo"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
proxyURL, bridgeErr := a.clashMgr.EnsureNodeBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(mihomo)。原因:%v。请检查代理节点配置、mihomo 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
log.Error("代理桥接失败(mihomo)",
logger.F("error", bridgeErr.Error()),
logger.F("reason", startErr.Error()),
)
profile.LastError = startErr.Error()
return "", "", false, startErr
}
log.Info("mihomo 桥接成功", logger.F("engine", "mihomo"), logger.F("proxy_url", proxyURL))
return proxyURL, "", false, nil
}
if proxy.IsSingBoxProtocol(resolvedProxyConfig) {
log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "sing-box"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
@@ -81,11 +100,12 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
profile.LastError = startErr.Error()
return "", "", false, startErr
}
log.Info("sing-box 桥接成功", logger.F("socks_url", socksURL))
log.Info("sing-box 桥接成功", logger.F("engine", "sing-box"), logger.F("socks_url", socksURL))
return socksURL, "", false, nil
}
if proxy.RequiresBridge(resolvedProxyConfig, proxies, resolvedProxyID) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) {
log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "xray"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
@@ -96,10 +116,11 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
profile.LastError = startErr.Error()
return "", "", false, startErr
}
log.Info("xray 桥接成功", logger.F("socks_url", socksURL))
log.Info("xray 桥接成功", logger.F("engine", "xray"), logger.F("socks_url", socksURL))
return socksURL, bridgeKey, bridgeKey != "", nil
}
log.Info("实际代理内核", logger.F("profile_id", profileID), logger.F("engine", "native"), logger.F("connector", connectorType), logger.F("proxy_id", resolvedProxyID))
return resolvedProxyConfig, "", false, nil
}
+32
View File
@@ -196,6 +196,7 @@ func TestBuildBrowserLaunchArgsUsesNoProxyServerForDirectProxy(t *testing.T) {
"direct://",
nil,
nil,
nil,
[]string{"about:blank"},
)
@@ -212,3 +213,34 @@ func TestBuildBrowserLaunchArgsUsesNoProxyServerForDirectProxy(t *testing.T) {
t.Fatalf("expected direct proxy to use --no-proxy-server, got=%v", got)
}
}
func TestBuildBrowserLaunchArgsLoadsEnabledExtensions(t *testing.T) {
t.Parallel()
profile := &BrowserProfile{ProfileId: "profile-extension"}
got := buildBrowserLaunchArgs(
profile,
`D:\profiles\extensions`,
9333,
"",
[]string{`D:\extensions\a`, `D:\extensions\b`},
nil,
nil,
[]string{"about:blank"},
)
wantLoad := `--load-extension=D:\extensions\a,D:\extensions\b`
wantExcept := `--disable-extensions-except=D:\extensions\a,D:\extensions\b`
if !containsString(got, wantLoad) || !containsString(got, wantExcept) {
t.Fatalf("expected extension launch args, got=%v", got)
}
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
+342
View File
@@ -0,0 +1,342 @@
package backend
import (
"context"
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"strings"
"time"
"ant-chrome/backend/internal/apppath"
"ant-chrome/backend/internal/fsutil"
"ant-chrome/backend/internal/logger"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type ProxyCoreDownloadRequest struct {
Core string `json:"core"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
ProxyConfig string `json:"proxyConfig"`
Version string `json:"version"`
}
type ProxyCoreDownloadProgress struct {
Core string `json:"core"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Phase string `json:"phase"`
Progress int `json:"progress"`
Message string `json:"message"`
}
type ProxyCoreStatusResult struct {
Core string `json:"core"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Installed bool `json:"installed"`
Configured bool `json:"configured"`
Active bool `json:"active"`
BinaryPath string `json:"binaryPath"`
Source string `json:"source"`
Message string `json:"message"`
}
type ProxyCoreDownloadInfoResult struct {
Core string `json:"core"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Version string `json:"version"`
Repo string `json:"repo"`
ReleaseURL string `json:"releaseUrl"`
DownloadURL string `json:"downloadUrl"`
AssetName string `json:"assetName"`
InstallDir string `json:"installDir"`
BinaryName string `json:"binaryName"`
Message string `json:"message"`
}
type proxyCoreSpec struct {
Core string
Repo string
DisplayName string
BinaryBase string
ConfigKey string
Version string
}
func (a *App) BrowserProxyCoreDownload(input ProxyCoreDownloadRequest) error {
if a.ctx == nil {
return fmt.Errorf("app context is nil")
}
spec, err := normalizeProxyCoreSpec(input.Core)
if err != nil {
return err
}
target, err := normalizeProxyCoreTarget(input.GOOS, input.GOARCH)
if err != nil {
return err
}
version := normalizeProxyCoreVersion(input.Version, spec.Version)
go a.downloadProxyCore(a.ctx, spec, target, input.ProxyConfig, version)
return nil
}
func (a *App) BrowserProxyCoreStatus(input ProxyCoreDownloadRequest) ProxyCoreStatusResult {
spec, err := normalizeProxyCoreSpec(input.Core)
if err != nil {
return ProxyCoreStatusResult{Core: strings.TrimSpace(input.Core), Message: err.Error()}
}
target, err := normalizeProxyCoreTarget(input.GOOS, input.GOARCH)
if err != nil {
return ProxyCoreStatusResult{Core: spec.Core, Message: err.Error()}
}
return a.proxyCoreStatus(spec, target)
}
func (a *App) BrowserProxyCoreDownloadInfo(input ProxyCoreDownloadRequest) ProxyCoreDownloadInfoResult {
spec, err := normalizeProxyCoreSpec(input.Core)
if err != nil {
return ProxyCoreDownloadInfoResult{Core: strings.TrimSpace(input.Core), Message: err.Error()}
}
target, err := normalizeProxyCoreTarget(input.GOOS, input.GOARCH)
if err != nil {
return ProxyCoreDownloadInfoResult{Core: spec.Core, Message: err.Error()}
}
version := normalizeProxyCoreVersion(input.Version, spec.Version)
info := proxyCoreDownloadInfoBase(a, spec, target)
info.Version = version
info.ReleaseURL = proxyCoreReleaseURL(spec.Repo, version)
client, _, err := proxyCoreHTTPClient(30*time.Second, input.ProxyConfig)
if err != nil {
info.Message = manualProxyCoreDownloadMessage(spec, target, "下载代理配置错误: "+err.Error())
return info
}
release, err := fetchGitHubRelease(context.Background(), client, spec.Repo, version)
if err != nil {
info.Message = manualProxyCoreDownloadMessage(spec, target, "自动查询 Release 失败: "+err.Error())
return info
}
asset, err := selectProxyCoreAsset(spec, release.Assets, target.GOOS, target.GOARCH)
if err != nil {
info.Message = manualProxyCoreDownloadMessage(spec, target, err.Error())
return info
}
info.AssetName = asset.Name
info.DownloadURL = asset.BrowserDownloadURL
info.Message = "可打开远程地址手动下载,下载后解压到本地目录"
return info
}
func (a *App) BrowserProxyCoreOpenLocal(input ProxyCoreDownloadRequest) error {
spec, err := normalizeProxyCoreSpec(input.Core)
if err != nil {
return err
}
target, err := normalizeProxyCoreTarget(input.GOOS, input.GOARCH)
if err != nil {
return err
}
status := a.proxyCoreStatus(spec, target)
path := strings.TrimSpace(status.BinaryPath)
if path == "" {
path = proxyCoreInstallDir(a, spec, target)
if err := os.MkdirAll(path, 0o755); err != nil {
return fmt.Errorf("创建本地目录失败: %w", err)
}
}
if err := openPathInFileManager(path); err != nil {
return fmt.Errorf("打开本地路径失败: %w", err)
}
return nil
}
type proxyCoreTarget struct {
GOOS string
GOARCH string
}
func normalizeProxyCoreTarget(goos string, goarch string) (proxyCoreTarget, error) {
goos = strings.ToLower(strings.TrimSpace(goos))
goarch = strings.ToLower(strings.TrimSpace(goarch))
if goos == "" {
goos = goruntime.GOOS
}
if goarch == "" {
goarch = goruntime.GOARCH
}
switch goos {
case "win", "windows":
goos = "windows"
case "linux":
goos = "linux"
case "mac", "macos", "darwin":
goos = "darwin"
default:
return proxyCoreTarget{}, fmt.Errorf("不支持的目标系统: %s", goos)
}
switch goarch {
case "x64", "x86_64", "amd64":
goarch = "amd64"
case "aarch64", "arm64":
goarch = "arm64"
case "x86", "i386", "386":
goarch = "386"
default:
return proxyCoreTarget{}, fmt.Errorf("不支持的目标架构: %s", goarch)
}
return proxyCoreTarget{GOOS: goos, GOARCH: goarch}, nil
}
func normalizeProxyCoreSpec(core string) (proxyCoreSpec, error) {
switch strings.ToLower(strings.TrimSpace(core)) {
case "", "xray":
return proxyCoreSpec{Core: "xray", Repo: "XTLS/Xray-core", DisplayName: "Xray", BinaryBase: "xray", ConfigKey: "xray", Version: "v26.3.27"}, nil
case "mihomo", "clash", "clash-meta":
return proxyCoreSpec{Core: "mihomo", Repo: "MetaCubeX/mihomo", DisplayName: "Mihomo", BinaryBase: "mihomo", ConfigKey: "clash", Version: "v1.19.27"}, nil
case "sing-box", "singbox":
return proxyCoreSpec{Core: "sing-box", Repo: "SagerNet/sing-box", DisplayName: "sing-box", BinaryBase: "sing-box", ConfigKey: "sing-box", Version: "v1.13.13"}, nil
default:
return proxyCoreSpec{}, fmt.Errorf("不支持的代理内核: %s", core)
}
}
func (a *App) downloadProxyCore(ctx context.Context, spec proxyCoreSpec, target proxyCoreTarget, proxyConfig string, version string) {
log := logger.New("ProxyCore")
send := func(phase string, progress int, message string) {
wailsruntime.EventsEmit(ctx, "proxy-core:download:progress", ProxyCoreDownloadProgress{Core: spec.Core, GOOS: target.GOOS, GOARCH: target.GOARCH, Phase: phase, Progress: progress, Message: message})
}
client, proxyLabel, err := proxyCoreHTTPClient(90*time.Second, proxyConfig)
if err != nil {
send("error", 0, "下载代理配置错误: "+err.Error())
return
}
send("resolving", 0, fmt.Sprintf("正在查询官方 Release %s%s", version, proxyLabel))
release, err := fetchGitHubRelease(ctx, client, spec.Repo, version)
if err != nil {
send("error", 0, "查询 Release 失败: "+err.Error())
return
}
asset, err := selectProxyCoreAsset(spec, release.Assets, target.GOOS, target.GOARCH)
if err != nil {
send("error", 0, err.Error())
return
}
platformDir := fmt.Sprintf("%s-%s", target.GOOS, target.GOARCH)
installDir := proxyCoreInstallDir(a, spec, target)
if err := os.MkdirAll(installDir, 0o755); err != nil {
send("error", 0, "创建安装目录失败: "+err.Error())
return
}
tmp, err := os.CreateTemp(installDir, "proxy-core-*"+archiveExt(asset.Name))
if err != nil {
send("error", 0, "创建临时文件失败: "+err.Error())
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
send("downloading", 5, fmt.Sprintf("开始下载 %s %s%s", spec.DisplayName, release.TagName, proxyLabel))
if err := downloadProxyCoreAsset(ctx, client, asset.BrowserDownloadURL, tmp, asset.Size, send); err != nil {
_ = tmp.Close()
send("error", 0, "下载失败: "+err.Error())
return
}
_ = tmp.Close()
extractDir, err := os.MkdirTemp(installDir, "extract-*")
if err != nil {
send("error", 0, "创建解压目录失败: "+err.Error())
return
}
defer os.RemoveAll(extractDir)
send("extracting", 80, "下载完成,正在解压")
if err := extractProxyCoreArchive(tmpPath, extractDir, spec.BinaryBase, target.GOOS); err != nil {
send("error", 0, "解压失败: "+err.Error())
return
}
binaryPath, err := findProxyCoreBinary(extractDir, spec.BinaryBase, target.GOOS)
if err != nil {
send("error", 0, err.Error())
return
}
if err := replaceDirContents(extractDir, installDir); err != nil {
send("error", 0, "安装失败: "+err.Error())
return
}
installedBinary := filepath.Join(installDir, mustRelPath(extractDir, binaryPath))
installedBinary, err = normalizeInstalledProxyCoreBinary(installedBinary, installDir, spec.BinaryBase, target.GOOS)
if err != nil {
send("error", 0, "规范内核文件名失败: "+err.Error())
return
}
if target.GOOS == goruntime.GOOS && target.GOARCH == goruntime.GOARCH {
if err := fsutil.EnsureExecutable(installedBinary); err != nil {
send("error", 0, "设置可执行权限失败: "+err.Error())
return
}
if err := a.saveProxyCoreBinaryPath(spec, installedBinary); err != nil {
send("error", 0, "保存配置失败: "+err.Error())
return
}
send("done", 100, fmt.Sprintf("%s 已安装并启用: %s", spec.DisplayName, installedBinary))
} else {
send("done", 100, fmt.Sprintf("%s 已下载到 %s/%s: %s", spec.DisplayName, target.GOOS, target.GOARCH, installedBinary))
}
log.Info("代理内核安装完成", logger.F("core", spec.Core), logger.F("target", platformDir), logger.F("version", release.TagName), logger.F("binary", installedBinary))
}
func proxyCoreDownloadInfoBase(a *App, spec proxyCoreSpec, target proxyCoreTarget) ProxyCoreDownloadInfoResult {
return ProxyCoreDownloadInfoResult{
Core: spec.Core,
GOOS: target.GOOS,
GOARCH: target.GOARCH,
Version: spec.Version,
Repo: spec.Repo,
ReleaseURL: proxyCoreReleaseURL(spec.Repo, spec.Version),
InstallDir: proxyCoreInstallDir(a, spec, target),
BinaryName: proxyCoreBinaryName(spec.BinaryBase, target.GOOS),
}
}
func normalizeProxyCoreVersion(version string, fallback string) string {
version = strings.TrimSpace(version)
if version == "" || strings.EqualFold(version, "stable") {
version = strings.TrimSpace(fallback)
}
if version == "" || strings.EqualFold(version, "latest") {
return "latest"
}
if !strings.HasPrefix(strings.ToLower(version), "v") {
version = "v" + version
}
return version
}
func proxyCoreReleaseURL(repo string, version string) string {
if strings.EqualFold(strings.TrimSpace(version), "latest") {
return "https://github.com/" + repo + "/releases/latest"
}
return "https://github.com/" + repo + "/releases/tag/" + strings.TrimSpace(version)
}
func proxyCoreInstallDir(a *App, spec proxyCoreSpec, target proxyCoreTarget) string {
appRoot := ""
if a != nil {
appRoot = a.appRoot
}
return apppath.Resolve(appRoot, filepath.Join("bin", fmt.Sprintf("%s-%s", target.GOOS, target.GOARCH), spec.Core))
}
func manualProxyCoreDownloadMessage(spec proxyCoreSpec, target proxyCoreTarget, reason string) string {
return fmt.Sprintf("%s。请打开 Release 页面,下载 %s/%s 的 %s,解压后把 %s 放到本地目录。需要代理时,请在下载代理里填写 http://、https:// 或 socks5:// 地址。", reason, target.GOOS, target.GOARCH, spec.DisplayName, proxyCoreBinaryName(spec.BinaryBase, target.GOOS))
}
+229
View File
@@ -0,0 +1,229 @@
package backend
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
func extractProxyCoreArchive(archivePath string, targetDir string, binaryBase string, targetOS string) error {
lower := strings.ToLower(archivePath)
if strings.HasSuffix(lower, ".zip") {
return extractZipArchive(archivePath, targetDir)
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return extractTarGzArchive(archivePath, targetDir)
}
if strings.HasSuffix(lower, ".gz") {
return extractGzipBinary(archivePath, filepath.Join(targetDir, proxyCoreBinaryName(binaryBase, targetOS)))
}
return fmt.Errorf("不支持的压缩格式: %s", filepath.Base(archivePath))
}
func extractGzipBinary(archivePath string, targetPath string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gz.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, gz)
return err
}
func extractZipArchive(archivePath string, targetDir string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer reader.Close()
for _, file := range reader.File {
if err := writeArchiveFile(targetDir, file.Name, file.FileInfo().Mode(), file.FileInfo().IsDir(), func() (io.ReadCloser, error) { return file.Open() }); err != nil {
return err
}
}
return nil
}
func extractTarGzArchive(archivePath string, targetDir string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
mode := os.FileMode(header.Mode)
isDir := header.FileInfo().IsDir()
if header.Typeflag == tar.TypeDir {
isDir = true
}
if err := writeArchiveFile(targetDir, header.Name, mode, isDir, func() (io.ReadCloser, error) { return io.NopCloser(tr), nil }); err != nil {
return err
}
}
}
func writeArchiveFile(targetDir string, name string, mode os.FileMode, isDir bool, open func() (io.ReadCloser, error)) error {
cleanName := filepath.Clean(filepath.FromSlash(name))
if cleanName == "." || cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) || filepath.IsAbs(cleanName) {
return fmt.Errorf("压缩包包含非法路径: %s", name)
}
dest := filepath.Join(targetDir, cleanName)
if isDir {
return os.MkdirAll(dest, 0o755)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
src, err := open()
if err != nil {
return err
}
defer src.Close()
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode|0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}
func findProxyCoreBinary(root string, binaryBase string, targetOS string) (string, error) {
names := []string{proxyCoreBinaryName(binaryBase, targetOS), binaryBase}
var matches []string
_ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return nil
}
base := strings.ToLower(filepath.Base(path))
for _, name := range names {
if proxyCoreBinaryNameMatches(base, strings.ToLower(name), binaryBase, targetOS) {
matches = append(matches, path)
break
}
}
return nil
})
if len(matches) == 0 {
return "", fmt.Errorf("解压后未找到 %s 可执行文件", binaryBase)
}
sort.Strings(matches)
return matches[0], nil
}
func proxyCoreBinaryNameMatches(base string, expected string, binaryBase string, targetOS string) bool {
if base == expected {
return true
}
baseNoExt := strings.TrimSuffix(base, ".exe")
expectedNoExt := strings.TrimSuffix(expected, ".exe")
if baseNoExt == expectedNoExt {
return true
}
if binaryBase == "mihomo" && strings.HasPrefix(baseNoExt, "mihomo-") {
return targetOS != "windows" || strings.HasSuffix(base, ".exe")
}
return false
}
func normalizeInstalledProxyCoreBinary(binaryPath string, installDir string, binaryBase string, targetOS string) (string, error) {
standardPath := filepath.Join(installDir, proxyCoreBinaryName(binaryBase, targetOS))
if sameCleanPath(binaryPath, standardPath) {
return binaryPath, nil
}
if err := os.MkdirAll(filepath.Dir(standardPath), 0o755); err != nil {
return "", err
}
if _, err := os.Stat(standardPath); err == nil {
if err := os.Remove(standardPath); err != nil {
return "", err
}
}
if err := os.Rename(binaryPath, standardPath); err != nil {
return "", err
}
return standardPath, nil
}
func replaceDirContents(srcDir string, dstDir string) error {
entries, err := os.ReadDir(dstDir)
if err != nil {
return err
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "proxy-core-") || strings.HasPrefix(entry.Name(), "extract-") {
continue
}
if err := os.RemoveAll(filepath.Join(dstDir, entry.Name())); err != nil {
return err
}
}
return filepath.WalkDir(srcDir, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil || rel == "." {
return err
}
dest := filepath.Join(dstDir, rel)
if entry.IsDir() {
return os.MkdirAll(dest, 0o755)
}
info, err := entry.Info()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
return copyFile(path, dest, info.Mode())
})
}
func copyFile(src string, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode|0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+255
View File
@@ -0,0 +1,255 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
xproxy "golang.org/x/net/proxy"
)
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubReleaseAsset `json:"assets"`
}
type githubReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
func proxyCoreHTTPClient(timeout time.Duration, proxyConfig string) (*http.Client, string, error) {
proxyConfig = strings.TrimSpace(proxyConfig)
if proxyConfig == "" || strings.EqualFold(proxyConfig, "direct://") {
return &http.Client{Timeout: timeout, Transport: proxyCoreDirectTransport()}, "直连", nil
}
u, err := url.Parse(proxyConfig)
if err != nil {
return nil, "", fmt.Errorf("代理地址解析失败: %w", err)
}
if isBadLocalHTTPSProxy(u) {
return nil, "", fmt.Errorf("下载代理不能填 %s127.0.0.1:443 通常不是本机代理端口;请改成真实代理端口,如 socks5://127.0.0.1:7890,或留空直连", u.Host)
}
scheme := strings.ToLower(u.Scheme)
switch scheme {
case "http", "https":
return &http.Client{Timeout: timeout, Transport: &http.Transport{Proxy: http.ProxyURL(u)}}, "指定代理", nil
case "socks5":
var auth *xproxy.Auth
if u.User != nil {
password, _ := u.User.Password()
auth = &xproxy.Auth{User: u.User.Username(), Password: password}
}
dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct)
if err != nil {
return nil, "", fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
}
contextDialer, ok := dialer.(xproxy.ContextDialer)
if !ok {
return nil, "", fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
}
return &http.Client{Timeout: timeout, Transport: &http.Transport{DialContext: contextDialer.DialContext}}, "指定代理", nil
default:
return nil, "", fmt.Errorf("仅支持 http://、https://、socks5:// 或 direct://")
}
}
func proxyCoreDirectTransport() *http.Transport {
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err == nil && port == "443" && isLocalhostHost(host) {
return nil, fmt.Errorf("直连下载被解析到 %s:这通常是本机 hosts/DNS 污染或仍在运行旧版本。请重启应用;如果仍出现,请检查 hosts/DNS,或在下载代理中填写真实代理端口", address)
}
return dialer.DialContext(ctx, network, address)
},
}
}
func isBadLocalHTTPSProxy(u *url.URL) bool {
if u == nil {
return false
}
return isLocalhostHost(u.Hostname()) && u.Port() == "443"
}
func isLocalhostHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
return host == "127.0.0.1" || host == "localhost" || host == "::1"
}
func fetchGitHubRelease(ctx context.Context, client *http.Client, repo string, version string) (githubRelease, error) {
apiURL := "https://api.github.com/repos/" + repo + "/releases/latest"
if !strings.EqualFold(strings.TrimSpace(version), "latest") {
apiURL = "https://api.github.com/repos/" + repo + "/releases/tags/" + strings.TrimSpace(version)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ant-chrome-proxy-core-downloader")
resp, err := client.Do(req)
if err != nil {
return githubRelease{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return githubRelease{}, fmt.Errorf("GitHub API HTTP %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return githubRelease{}, err
}
if len(release.Assets) == 0 {
return githubRelease{}, fmt.Errorf("Release 没有可下载资产")
}
return release, nil
}
func selectProxyCoreAsset(spec proxyCoreSpec, assets []githubReleaseAsset, goos string, goarch string) (githubReleaseAsset, error) {
osTokens := map[string][]string{
"windows": {"windows", "win"},
"linux": {"linux"},
"darwin": {"darwin", "macos"},
}
archTokens := map[string][]string{
"amd64": {"amd64", "x86_64", "64"},
"arm64": {"arm64", "aarch64"},
"386": {"386", "i386", "x86"},
}
extTokens := []string{".zip", ".tar.gz", ".tgz"}
if spec.Core == "mihomo" {
extTokens = append(extTokens, ".gz")
}
if goos == "windows" {
extTokens = []string{".zip"}
}
badTokens := []string{"sha", "checksum", "dgst", ".sig", ".asc", "source", "geoip", "geosite"}
candidates := make([]githubReleaseAsset, 0)
for _, asset := range assets {
name := strings.ToLower(asset.Name)
if !hasAnySuffix(name, extTokens) || containsAny(name, badTokens) {
continue
}
if !containsAny(name, osTokens[goos]) || !matchesProxyAssetArch(name, goarch, archTokens[goarch]) {
continue
}
if spec.Core == "mihomo" && !strings.Contains(name, "compatible") {
continue
}
candidates = append(candidates, asset)
}
if len(candidates) == 0 && spec.Core == "mihomo" {
fallbackSpec := spec
fallbackSpec.Core = "mihomo-fallback"
for _, asset := range assets {
name := strings.ToLower(asset.Name)
if hasAnySuffix(name, extTokens) && !containsAny(name, badTokens) && containsAny(name, osTokens[goos]) && matchesProxyAssetArch(name, goarch, archTokens[goarch]) {
candidates = append(candidates, asset)
}
}
}
if len(candidates) == 0 {
return githubReleaseAsset{}, fmt.Errorf("官方 Release 未找到适配 %s/%s 的 %s 资产", goos, goarch, spec.DisplayName)
}
sort.SliceStable(candidates, func(i, j int) bool {
ai := assetScore(spec, candidates[i].Name)
aj := assetScore(spec, candidates[j].Name)
if ai != aj {
return ai > aj
}
return candidates[i].Name < candidates[j].Name
})
return candidates[0], nil
}
func proxyCoreBinaryName(binaryBase string, targetOS string) string {
if targetOS == "windows" {
return binaryBase + ".exe"
}
return binaryBase
}
func matchesProxyAssetArch(name string, goarch string, tokens []string) bool {
if goarch == "amd64" && strings.Contains(name, "arm64") {
return false
}
if goarch == "386" && (strings.Contains(name, "amd64") || strings.Contains(name, "arm64")) {
return false
}
return containsAny(name, tokens)
}
func assetScore(spec proxyCoreSpec, name string) int {
lower := strings.ToLower(name)
score := 0
if strings.HasSuffix(lower, ".zip") {
score += 3
}
if strings.Contains(lower, "compatible") {
score += 5
}
if strings.Contains(lower, spec.BinaryBase) || strings.Contains(lower, spec.Core) {
score += 2
}
if !strings.Contains(lower, "glibc") && !strings.Contains(lower, "musl") && !strings.Contains(lower, "softfloat") && !strings.Contains(lower, "legacy") {
score += 2
}
return score
}
func downloadProxyCoreAsset(ctx context.Context, client *http.Client, url string, file *os.File, totalSize int64, send func(string, int, string)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "ant-chrome-proxy-core-downloader")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
if totalSize <= 0 {
totalSize = resp.ContentLength
}
buf := make([]byte, 1024*1024)
var downloaded int64
lastTick := time.Now()
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, err := file.Write(buf[:n]); err != nil {
return err
}
downloaded += int64(n)
if totalSize > 0 && time.Since(lastTick) > 500*time.Millisecond {
progress := 5 + int(float64(downloaded)/float64(totalSize)*70)
if progress > 75 {
progress = 75
}
send("downloading", progress, fmt.Sprintf("下载中 %.1f MB / %.1f MB", float64(downloaded)/1024/1024, float64(totalSize)/1024/1024))
lastTick = time.Now()
}
}
if readErr == io.EOF {
return nil
}
if readErr != nil {
return readErr
}
}
}
+186
View File
@@ -0,0 +1,186 @@
package backend
import (
"fmt"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"ant-chrome/backend/internal/apppath"
"ant-chrome/backend/internal/fsutil"
)
func (a *App) proxyCoreStatus(spec proxyCoreSpec, target proxyCoreTarget) ProxyCoreStatusResult {
result := ProxyCoreStatusResult{Core: spec.Core, GOOS: target.GOOS, GOARCH: target.GOARCH}
if a == nil || a.config == nil {
result.Message = "配置未初始化"
return result
}
result.Active = proxyCoreIsActive(a, spec)
configuredPath := strings.TrimSpace(proxyCoreConfiguredPath(a, spec))
if configuredPath != "" && target.GOOS == goruntime.GOOS && target.GOARCH == goruntime.GOARCH {
if path, ok := existingProxyCoreFile(configuredPath, a.appRoot); ok {
result.Installed = true
result.Configured = true
result.BinaryPath = path
result.Source = "config"
result.Message = proxyCoreInstalledMessage(result.Active, true)
return result
}
}
if path, source, ok := findInstalledProxyCoreBinary(a.appRoot, spec, target); ok {
result.Installed = true
result.BinaryPath = path
result.Source = source
if target.GOOS == goruntime.GOOS && target.GOARCH == goruntime.GOARCH && configuredPath != "" {
if configured, ok := existingProxyCoreFile(configuredPath, a.appRoot); ok && sameCleanPath(configured, path) {
result.Configured = true
result.Source = "config"
}
}
result.Message = proxyCoreInstalledMessage(result.Active, result.Configured)
return result
}
if result.Active {
result.Message = "当前内核未找到"
return result
}
result.Message = "未下载"
return result
}
func proxyCoreIsActive(a *App, spec proxyCoreSpec) bool {
if a == nil || a.config == nil {
return false
}
current := strings.ToLower(strings.TrimSpace(a.config.Browser.DefaultConnectorType))
if current == "" {
current = "xray"
}
switch spec.Core {
case "xray":
return current == "xray"
case "mihomo":
return current == "mihomo" || current == "clash"
case "sing-box":
return current == "sing-box" || current == "singbox"
default:
return false
}
}
func proxyCoreInstalledMessage(active bool, configured bool) string {
if active {
return "已启用"
}
if configured {
return "已配置"
}
return "已下载"
}
func findInstalledProxyCoreBinary(appRoot string, spec proxyCoreSpec, target proxyCoreTarget) (string, string, bool) {
platformDir := fmt.Sprintf("%s-%s", target.GOOS, target.GOARCH)
searchDirs := []struct {
path string
source string
}{
{apppath.Resolve(appRoot, filepath.Join("bin", platformDir, spec.Core)), "downloaded"},
{apppath.Resolve(appRoot, filepath.Join("bin", platformDir)), "runtime"},
{apppath.Resolve(appRoot, "bin"), "runtime"},
}
if exePath, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exePath)
searchDirs = append(searchDirs,
struct {
path string
source string
}{filepath.Join(exeDir, "bin", platformDir, spec.Core), "downloaded"},
struct {
path string
source string
}{filepath.Join(exeDir, "bin", platformDir), "runtime"},
struct {
path string
source string
}{filepath.Join(exeDir, "bin"), "runtime"},
)
}
for _, dir := range searchDirs {
if strings.TrimSpace(dir.path) == "" {
continue
}
if path, err := findProxyCoreBinary(dir.path, spec.BinaryBase, target.GOOS); err == nil {
return path, dir.source, true
}
}
if target.GOOS == goruntime.GOOS && target.GOARCH == goruntime.GOARCH {
if path, err := exec.LookPath(proxyCoreBinaryName(spec.BinaryBase, target.GOOS)); err == nil {
return path, "path", true
}
}
return "", "", false
}
func proxyCoreConfiguredPath(a *App, spec proxyCoreSpec) string {
if a == nil || a.config == nil {
return ""
}
switch spec.ConfigKey {
case "xray":
return a.config.Browser.XrayBinaryPath
case "clash":
return a.config.Browser.ClashBinaryPath
case "sing-box":
return a.config.Browser.SingBoxBinaryPath
default:
return ""
}
}
func existingProxyCoreFile(path string, appRoot string) (string, bool) {
path = fsutil.NormalizePathInput(path)
if path == "" {
return "", false
}
if !filepath.IsAbs(path) && strings.TrimSpace(appRoot) != "" {
path = apppath.Resolve(appRoot, path)
}
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path, true
}
return "", false
}
func sameCleanPath(a string, b string) bool {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
func (a *App) saveProxyCoreBinaryPath(spec proxyCoreSpec, binaryPath string) error {
if a.config == nil {
return fmt.Errorf("config is nil")
}
clean := fsutil.NormalizePathInput(binaryPath)
switch spec.ConfigKey {
case "xray":
a.config.Browser.XrayBinaryPath = clean
case "clash":
a.config.Browser.ClashBinaryPath = clean
case "sing-box":
a.config.Browser.SingBoxBinaryPath = clean
default:
return fmt.Errorf("未知配置键: %s", spec.ConfigKey)
}
if a.xrayMgr != nil {
a.xrayMgr.Config = a.config
}
if a.clashMgr != nil {
a.clashMgr.Config = a.config
}
if a.singboxMgr != nil {
a.singboxMgr.Config = a.config
}
return a.config.Save(a.resolveAppPath("config.yaml"))
}
+216
View File
@@ -0,0 +1,216 @@
package backend
import (
"ant-chrome/backend/internal/config"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestWriteArchiveFileRejectsParentDirectoryEntry(t *testing.T) {
targetDir := t.TempDir()
err := writeArchiveFile(targetDir, "..", 0o644, false, func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("bad")), nil
})
if err == nil {
t.Fatalf("expected parent directory archive entry to be rejected")
}
}
func TestSelectProxyCoreAssetPrefersMihomoCompatibleWindows(t *testing.T) {
spec, err := normalizeProxyCoreSpec("mihomo")
if err != nil {
t.Fatalf("normalizeProxyCoreSpec returned error: %v", err)
}
asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{
{Name: "mihomo-windows-amd64-v1-v1.19.27.zip"},
{Name: "mihomo-windows-amd64-compatible-v1.19.27.zip"},
}, "windows", "amd64")
if err != nil {
t.Fatalf("selectProxyCoreAsset returned error: %v", err)
}
if asset.Name != "mihomo-windows-amd64-compatible-v1.19.27.zip" {
t.Fatalf("unexpected asset: %s", asset.Name)
}
}
func TestSelectProxyCoreAssetSupportsMihomoLinuxGzip(t *testing.T) {
spec, err := normalizeProxyCoreSpec("mihomo")
if err != nil {
t.Fatalf("normalizeProxyCoreSpec returned error: %v", err)
}
asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{
{Name: "mihomo-linux-amd64-compatible-v1.19.27.gz"},
{Name: "mihomo-linux-amd64-v1.19.27.gz"},
}, "linux", "amd64")
if err != nil {
t.Fatalf("selectProxyCoreAsset returned error: %v", err)
}
if asset.Name != "mihomo-linux-amd64-compatible-v1.19.27.gz" {
t.Fatalf("unexpected asset: %s", asset.Name)
}
}
func TestSelectProxyCoreAssetMatchesXrayLinux64(t *testing.T) {
spec, err := normalizeProxyCoreSpec("xray")
if err != nil {
t.Fatalf("normalizeProxyCoreSpec returned error: %v", err)
}
asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{
{Name: "Xray-linux-arm64-v8a.zip"},
{Name: "Xray-linux-64.zip"},
{Name: "Xray-linux-64.zip.dgst"},
}, "linux", "amd64")
if err != nil {
t.Fatalf("selectProxyCoreAsset returned error: %v", err)
}
if asset.Name != "Xray-linux-64.zip" {
t.Fatalf("unexpected asset: %s", asset.Name)
}
}
func TestNormalizeProxyCoreTargetAliases(t *testing.T) {
target, err := normalizeProxyCoreTarget("macos", "x64")
if err != nil {
t.Fatalf("normalizeProxyCoreTarget returned error: %v", err)
}
if target.GOOS != "darwin" || target.GOARCH != "amd64" {
t.Fatalf("unexpected target: %+v", target)
}
}
func TestNormalizeProxyCoreSpecPinsStableVersions(t *testing.T) {
cases := map[string]string{
"xray": "v26.3.27",
"mihomo": "v1.19.27",
"sing-box": "v1.13.13",
}
for core, want := range cases {
spec, err := normalizeProxyCoreSpec(core)
if err != nil {
t.Fatalf("normalizeProxyCoreSpec(%q) returned error: %v", core, err)
}
if spec.Version != want {
t.Fatalf("%s version = %q, want %q", core, spec.Version, want)
}
}
}
func TestProxyCoreReleaseURLUsesPinnedTag(t *testing.T) {
got := proxyCoreReleaseURL("MetaCubeX/mihomo", "v1.19.27")
want := "https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.27"
if got != want {
t.Fatalf("release URL = %q, want %q", got, want)
}
}
func TestSelectProxyCoreAssetMatchesDarwinAMD64(t *testing.T) {
spec, err := normalizeProxyCoreSpec("sing-box")
if err != nil {
t.Fatalf("normalizeProxyCoreSpec returned error: %v", err)
}
asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{
{Name: "sing-box-1.13.13-darwin-arm64.tar.gz"},
{Name: "sing-box-1.13.13-darwin-amd64.tar.gz"},
}, "darwin", "amd64")
if err != nil {
t.Fatalf("selectProxyCoreAsset returned error: %v", err)
}
if asset.Name != "sing-box-1.13.13-darwin-amd64.tar.gz" {
t.Fatalf("unexpected asset: %s", asset.Name)
}
}
func TestSelectProxyCoreAssetPrefersGenericSingBoxLinux(t *testing.T) {
spec, err := normalizeProxyCoreSpec("sing-box")
if err != nil {
t.Fatalf("normalizeProxyCoreSpec returned error: %v", err)
}
asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{
{Name: "sing-box-1.13.13-linux-amd64-glibc.tar.gz"},
{Name: "sing-box-1.13.13-linux-amd64-musl.tar.gz"},
{Name: "sing-box-1.13.13-linux-amd64.tar.gz"},
}, "linux", "amd64")
if err != nil {
t.Fatalf("selectProxyCoreAsset returned error: %v", err)
}
if asset.Name != "sing-box-1.13.13-linux-amd64.tar.gz" {
t.Fatalf("unexpected asset: %s", asset.Name)
}
}
func TestProxyCoreStatusFindsRuntimePlatformBin(t *testing.T) {
root := t.TempDir()
spec, err := normalizeProxyCoreSpec("xray")
if err != nil {
t.Fatalf("normalizeProxyCoreSpec returned error: %v", err)
}
target := proxyCoreTarget{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}
binaryName := "xray"
if runtime.GOOS == "windows" {
binaryName = "xray.exe"
}
binaryPath := filepath.Join(root, "bin", runtime.GOOS+"-"+runtime.GOARCH, binaryName)
if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := os.WriteFile(binaryPath, []byte("test"), 0o755); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app := &App{
appRoot: root,
config: &config.Config{},
}
app.config.Browser.DefaultConnectorType = "xray"
status := app.proxyCoreStatus(spec, target)
if !status.Installed || !status.Active {
t.Fatalf("status = %+v, want installed active", status)
}
if status.Message != "已启用" {
t.Fatalf("message = %q, want 已启用", status.Message)
}
if status.BinaryPath == "" {
t.Fatalf("expected binary path, got empty")
}
}
func TestFindProxyCoreBinaryMatchesVersionedMihomoWindowsExe(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "mihomo-windows-amd64-compatible.exe")
if err := os.WriteFile(binaryPath, []byte("test"), 0o755); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
got, err := findProxyCoreBinary(dir, "mihomo", "windows")
if err != nil {
t.Fatalf("findProxyCoreBinary returned error: %v", err)
}
if got != binaryPath {
t.Fatalf("binary path = %q, want %q", got, binaryPath)
}
}
func TestNormalizeInstalledProxyCoreBinaryRenamesMihomoExe(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "mihomo-windows-amd64-compatible.exe")
if err := os.WriteFile(binaryPath, []byte("test"), 0o755); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
got, err := normalizeInstalledProxyCoreBinary(binaryPath, dir, "mihomo", "windows")
if err != nil {
t.Fatalf("normalizeInstalledProxyCoreBinary returned error: %v", err)
}
want := filepath.Join(dir, "mihomo.exe")
if got != want {
t.Fatalf("normalized path = %q, want %q", got, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected normalized binary to exist: %v", err)
}
}
+48
View File
@@ -0,0 +1,48 @@
package backend
import (
"path/filepath"
"strings"
)
func hasAnySuffix(value string, suffixes []string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
func containsAny(value string, tokens []string) bool {
for _, token := range tokens {
if token != "" && strings.Contains(value, token) {
return true
}
}
return false
}
func archiveExt(name string) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".tar.gz"):
return ".tar.gz"
case strings.HasSuffix(lower, ".tgz"):
return ".tgz"
case strings.HasSuffix(lower, ".zip"):
return ".zip"
case strings.HasSuffix(lower, ".gz"):
return ".gz"
default:
return ".tmp"
}
}
func mustRelPath(base string, target string) string {
rel, err := filepath.Rel(base, target)
if err != nil {
return filepath.Base(target)
}
return rel
}
+51 -1
View File
@@ -1,6 +1,10 @@
package backend
import "ant-chrome/backend/internal/proxy"
import (
"ant-chrome/backend/internal/proxy"
"strings"
"time"
)
// BrowserProxyBuildDiagnostic 构建代理桥接诊断信息,不启动代理进程。
func (a *App) BrowserProxyBuildDiagnostic(proxyId string, proxyConfig string) ProxyBuildDiagnostic {
@@ -10,3 +14,49 @@ func (a *App) BrowserProxyBuildDiagnostic(proxyId string, proxyConfig string) Pr
SingBoxMgr: a.singboxMgr,
})
}
// BrowserProxyProbeBrowserPage 运行浏览器式并发探测,用于诊断真实页面并发访问效果。
func (a *App) BrowserProxyProbeBrowserPage(request ProxyBrowserProbeRequest) ProxyBrowserProbeResult {
request.ProxyId = strings.TrimSpace(request.ProxyId)
proxies := a.getLatestProxies()
cfg := buildProxyBrowserProbeConfig(request)
result := proxy.ProbeBrowserPageConnectivity(request.ProxyId, proxies, a.xrayMgr, a.singboxMgr, &cfg)
return ProxyBrowserProbeResult{
ProxyId: result.ProxyId,
Ok: result.Ok,
TotalMs: result.TotalMs,
AverageMs: result.AverageMs,
P95Ms: result.P95Ms,
Bytes: result.Bytes,
Completed: result.Completed,
Failed: result.Failed,
Concurrency: result.Concurrency,
Error: result.Error,
}
}
func buildProxyBrowserProbeConfig(request ProxyBrowserProbeRequest) proxy.BrowserPageProbeConfig {
cfg := proxy.DefaultBrowserPageProbeConfig
cfg.URLs = append([]string{}, proxy.DefaultBrowserPageProbeConfig.URLs...)
if len(request.URLs) > 0 {
urls := make([]string, 0, len(request.URLs))
for _, rawURL := range request.URLs {
if url := strings.TrimSpace(rawURL); url != "" {
urls = append(urls, url)
}
}
if len(urls) > 0 {
cfg.URLs = urls
}
}
if request.TimeoutMs > 0 {
cfg.Timeout = time.Duration(request.TimeoutMs) * time.Millisecond
}
if request.Concurrency > 0 {
cfg.Concurrency = request.Concurrency
}
if cfg.Concurrency > 16 {
cfg.Concurrency = 16
}
return cfg
}
+36
View File
@@ -0,0 +1,36 @@
package backend
import (
"testing"
"time"
)
func TestBuildProxyBrowserProbeConfigLimitsConcurrency(t *testing.T) {
cfg := buildProxyBrowserProbeConfig(ProxyBrowserProbeRequest{
URLs: []string{"https://example.com/a", " ", "https://example.com/b"},
Concurrency: 99,
TimeoutMs: 1234,
})
if cfg.Concurrency != 16 {
t.Fatalf("concurrency = %d, want 16", cfg.Concurrency)
}
if cfg.Timeout != 1234*time.Millisecond {
t.Fatalf("timeout = %v, want 1234ms", cfg.Timeout)
}
if len(cfg.URLs) != 2 || cfg.URLs[0] != "https://example.com/a" || cfg.URLs[1] != "https://example.com/b" {
t.Fatalf("urls = %#v, want trimmed request urls", cfg.URLs)
}
}
func TestBuildProxyBrowserProbeConfigUsesDefaults(t *testing.T) {
cfg := buildProxyBrowserProbeConfig(ProxyBrowserProbeRequest{})
if cfg.Concurrency <= 0 {
t.Fatalf("expected default concurrency, got %d", cfg.Concurrency)
}
if cfg.Timeout <= 0 {
t.Fatalf("expected default timeout, got %v", cfg.Timeout)
}
if len(cfg.URLs) == 0 {
t.Fatalf("expected default urls")
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ import (
// BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果
func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult {
proxies := a.getLatestProxies()
result := proxy.TestRealConnectivityWithConfig(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig())
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)
@@ -52,7 +52,7 @@ func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []P
go func() {
defer wg.Done()
for job := range jobs {
result := proxy.TestRealConnectivityWithConfig(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, a.proxySpeedTestConfig())
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)
+11 -2
View File
@@ -2,6 +2,7 @@ package backend
import (
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/proxy"
"strings"
"sync"
@@ -43,7 +44,7 @@ func (a *App) TestProxyConnectivity(proxyId string, proxyConfig string) ProxyTes
// 参考 Clash URLTest 策略:多 URL fallback + 复用桥接 + TCP ping 降级
func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult {
proxies := a.getLatestProxies()
result := proxy.TestRealConnectivityWithConfig(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil)
result := proxy.TestRealConnectivityWithRuntimeConfig(proxyId, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType), nil)
return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error}
}
@@ -120,7 +121,15 @@ func (a *App) warmupProxyBridge(proxyId string, proxyConfig string, proxies []Br
var socksURL string
var err error
if proxy.IsSingBoxProtocol(src) {
connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
if connectorType == config.BrowserConnectorMihomo {
result.Engine = "mihomo"
if a.clashMgr == nil {
result.Error = "mihomo 管理器不可用"
return result
}
socksURL, err = a.clashMgr.EnsureNodeBridge(src, proxies, proxyId)
} else if proxy.IsSingBoxProtocol(src) {
result.Engine = "sing-box"
if a.singboxMgr == nil {
result.Error = "sing-box 管理器不可用"
+20
View File
@@ -19,6 +19,26 @@ type ProxyTestResult struct {
Error string `json:"error"`
}
type ProxyBrowserProbeRequest struct {
ProxyId string `json:"proxyId"`
URLs []string `json:"urls"`
Concurrency int `json:"concurrency"`
TimeoutMs int `json:"timeoutMs"`
}
type ProxyBrowserProbeResult struct {
ProxyId string `json:"proxyId"`
Ok bool `json:"ok"`
TotalMs int64 `json:"totalMs"`
AverageMs int64 `json:"averageMs"`
P95Ms int64 `json:"p95Ms"`
Bytes int64 `json:"bytes"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Concurrency int `json:"concurrency"`
Error string `json:"error"`
}
// ProxyBridgeWarmupResult 代理桥接预热结果。
type ProxyBridgeWarmupResult struct {
ProxyId string `json:"proxyId"`
+1
View File
@@ -132,6 +132,7 @@ func (a *App) startupInitManagers(cfg *config.Config, db *database.DB) {
a.browserMgr.CoreDAO = browser.NewSQLiteCoreDAO(conn)
a.browserMgr.BookmarkDAO = browser.NewSQLiteBookmarkDAO(conn)
a.browserMgr.GroupDAO = browser.NewSQLiteGroupDAO(conn)
a.browserMgr.ExtensionDAO = browser.NewSQLiteExtensionDAO(conn)
a.migrateToSQLite()
+73
View File
@@ -105,6 +105,72 @@ func (a *App) AutomationScriptExportZip(scriptID string) (map[string]any, error)
}, nil
}
func (a *App) AutomationScriptExportBatchZip(scriptIDs []string) (map[string]any, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.ctx == nil {
return nil, fmt.Errorf("应用上下文未初始化")
}
normalizedIDs := make([]string, 0, len(scriptIDs))
seen := map[string]bool{}
for _, scriptID := range scriptIDs {
normalizedID := strings.TrimSpace(scriptID)
if normalizedID == "" || seen[normalizedID] {
continue
}
seen[normalizedID] = true
normalizedIDs = append(normalizedIDs, normalizedID)
}
if len(normalizedIDs) == 0 {
return nil, fmt.Errorf("请先勾选要导出的脚本")
}
store := a.automationScriptStore()
bundles := make([]automation.ImportedBundle, 0, len(normalizedIDs))
fileCount := 0
for _, scriptID := range normalizedIDs {
bundle, err := store.ExportBundle(scriptID)
if err != nil {
return nil, err
}
fileCount += len(bundle.Files)
bundles = append(bundles, bundle)
}
savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
Title: "导出脚本 ZIP",
DefaultFilename: buildAutomationScriptBatchZipFilename(len(bundles)),
Filters: []wailsruntime.FileFilter{
{DisplayName: "ZIP 脚本包 (*.zip)", Pattern: "*.zip"},
},
})
if err != nil {
return nil, fmt.Errorf("打开保存对话框失败: %w", err)
}
if strings.TrimSpace(savePath) == "" {
return map[string]any{
"cancelled": true,
"message": "已取消导出",
}, nil
}
savePath = ensureAutomationScriptZipSuffix(savePath)
if err := automation.WriteScriptPackagesZip(savePath, bundles); err != nil {
return nil, err
}
return map[string]any{
"cancelled": false,
"format": "zip",
"path": savePath,
"fileCount": fileCount,
"scriptCount": len(bundles),
"message": "脚本包已导出",
}, nil
}
func (a *App) AutomationScriptExportDirectory(scriptID string) (map[string]any, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
@@ -175,6 +241,13 @@ func buildAutomationScriptPackageZipFilename(scriptName string) string {
return fmt.Sprintf("%s-package-%s.zip", name, time.Now().Format("20060102-150405"))
}
func buildAutomationScriptBatchZipFilename(scriptCount int) string {
if scriptCount <= 1 {
return buildAutomationScriptPackageZipFilename("automation-script")
}
return fmt.Sprintf("automation-scripts-%d-package-%s.zip", scriptCount, time.Now().Format("20060102-150405"))
}
func ensureAutomationScriptZipSuffix(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
+2 -1
View File
@@ -74,8 +74,9 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest)
run.Status = "success"
}
case "playwright-cdp":
resultText, summary, errText := a.runPlaywrightScript(runCtx, script, input)
resultText, logText, summary, errText := a.runPlaywrightScript(runCtx, script, input)
run.ResultText = resultText
run.LogText = logText
run.Summary = summary
run.Error = errText
if errText == "" {
+17 -17
View File
@@ -87,61 +87,61 @@ func automationScriptTaskKey(scriptID string, selector map[string]any) string {
return "script:" + strings.TrimSpace(scriptID)
}
func (a *App) runPlaywrightScript(ctx context.Context, 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, string) {
if ctx == nil {
ctx = context.Background()
}
if a.automationMgr == nil {
return "", "脚本执行失败", "automation runtime manager is not initialized"
return "", "", "脚本执行失败", "automation runtime manager is not initialized"
}
if a.config == nil || !a.config.Automation.Enabled {
return "", "脚本执行失败", "自动化支持尚未启用"
return "", "", "脚本执行失败", "自动化支持尚未启用"
}
if err := ctx.Err(); err != nil {
return "", "脚本执行失败", automationRunContextErrorMessage(err)
return "", "", "脚本执行失败", automationRunContextErrorMessage(err)
}
if err := a.automationMgr.EnsureInstalled(ctx); err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
if err := ctx.Err(); err != nil {
return "", "脚本执行失败", automationRunContextErrorMessage(err)
return "", "", "脚本执行失败", automationRunContextErrorMessage(err)
}
state := a.automationMgr.CurrentState()
if !state.Ready {
return "", "脚本执行失败", "自动化运行时尚未就绪"
return "", "", "脚本执行失败", "自动化运行时尚未就绪"
}
paramsText := resolveAutomationRunJSONText(input.ParamsText, script.ParamsText, input.UseScriptParams)
selector, targetSummary, err := a.resolveAutomationEffectiveSelector(script, input, false)
if err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
selector, taskProfileID, err := a.ensurePlaywrightTargetReady(selector)
if err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
if err := ctx.Err(); err != nil {
return "", "脚本执行失败", automationRunContextErrorMessage(err)
return "", "", "脚本执行失败", automationRunContextErrorMessage(err)
}
params, err := parseAutomationJSONObject(paramsText, false)
if err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
baseURL, authHeader, authValue, err := a.automationDemoEndpoint()
if err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
scriptPath, artifactDir, cleanup, err := a.preparePlaywrightScriptWorkspace(state.RuntimeDir, script)
if err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
defer cleanup()
if err := ctx.Err(); err != nil {
return "", "脚本执行失败", automationRunContextErrorMessage(err)
return "", "", "脚本执行失败", automationRunContextErrorMessage(err)
}
taskResult, err := a.automationMgr.RunScriptTask(ctx, automation.ScriptTaskRequest{
@@ -156,7 +156,7 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR
Timeout: automationScriptRunTimeout(input),
})
if err != nil {
return "", "脚本执行失败", err.Error()
return "", "", "脚本执行失败", err.Error()
}
if taskResult.TaskKey == "" && taskProfileID != "" {
taskResult.TaskKey = taskProfileID
@@ -166,7 +166,7 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR
if errorText == "" {
errorText = "playwright script returned ok=false"
}
return taskResult.ResultText, appendAutomationRunSummary(taskResult.Summary, targetSummary), errorText
return taskResult.ResultText, taskResult.LogText, appendAutomationRunSummary(taskResult.Summary, targetSummary), errorText
}
return taskResult.ResultText, appendAutomationRunSummary(taskResult.Summary, targetSummary), ""
return taskResult.ResultText, taskResult.LogText, appendAutomationRunSummary(taskResult.Summary, targetSummary), ""
}
+2
View File
@@ -16,6 +16,8 @@ var managedLaunchArgSpecs = []managedLaunchArgSpec{
{prefix: "--remote-debugging-address", takesValue: true},
{prefix: "--remote-debugging-pipe", takesValue: false},
{prefix: "--proxy-server", takesValue: true},
{prefix: "--load-extension", takesValue: true},
{prefix: "--disable-extensions-except", takesValue: true},
}
func sanitizeManagedLaunchArgs(args []string) ([]string, []string) {
@@ -435,6 +435,20 @@ async function runScriptTask(payload, chromium) {
artifacts: Array.from(new Set(artifacts)),
result: normalizedResult,
};
} catch (error) {
return {
ok: false,
summary: '脚本执行失败',
error: error && error.message ? error.message : String(error),
title: '',
url: '',
startedAt,
finishedAt: new Date().toISOString(),
isolatedPage: false,
logs,
artifacts: Array.from(new Set(artifacts)),
result: null,
};
} finally {
await Promise.all(Array.from(connectedBrowsers, (browser) => closeBrowserConnection(browser)));
}
@@ -135,14 +135,28 @@ async function submitPrompt(page, selector, timeoutMs) {
return { step: 'submit_prompt', selector: '', submitMode: 'keyboard-enter' }
}
async function waitForGeneratedImage(page, selector, timeoutMs) {
async function waitForGeneratedImage(page, selector, timeoutMs, onProgress) {
const locator = page.locator(selector)
const deadline = Date.now() + timeoutMs
const startedAt = Date.now()
let lastProgressAt = 0
const loginRequiredPattern = /requires you to be logged in|需要登录|登录以获取|登录以|log in|sign in/i
while (Date.now() < deadline) {
if ((await locator.count().catch(() => 0)) > 0 && await locator.first().isVisible().catch(() => false)) {
const matchedCount = await locator.count().catch(() => 0)
const firstVisible = matchedCount > 0 && await locator.first().isVisible().catch(() => false)
if (firstVisible) {
break
}
const now = Date.now()
if (typeof onProgress === 'function' && now - lastProgressAt >= 10000) {
lastProgressAt = now
onProgress({
elapsedMs: now - startedAt,
remainingMs: Math.max(0, deadline - now),
matchedCount,
firstVisible,
})
}
const bodyText = await page.locator('body').innerText({ timeout: 1000 }).catch(() => '')
if (loginRequiredPattern.test(bodyText)) {
return {
@@ -169,6 +183,14 @@ async function waitForGeneratedImage(page, selector, timeoutMs) {
return { step: 'wait_image', selector, imageInfo }
}
function fileSize(pathname) {
try {
return fs.statSync(pathname).size
} catch {
return 0
}
}
async function downloadByButton(page, selector, timeoutMs) {
const downloadPromise = page.waitForEvent('download', { timeout: timeoutMs })
await page.locator(selector).first().click({ timeout: timeoutMs })
@@ -237,9 +259,15 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
? artifact(outputFileName)
: resolveOutputPath(artifactsDir, outputFileName)
const steps = []
const writeLog = (event, details = {}) => {
if (typeof log === 'function') {
log(`web-image-generate-download:${event}`, details)
}
}
const missing = buildMissingSetup(selectors, pageUrl)
if (missing.length > 0) {
writeLog('setup-missing', { missing, pageUrl })
return {
ok: false,
status: 'needs_page_info',
@@ -255,16 +283,29 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
}
}
log && log('web-image-generate-download:start', {
writeLog('start', {
pageUrl,
promptLength: prompt.length,
outputFileName,
timeoutMs,
waitAfterLoadMs,
settleMs,
captureScreenshot,
hasDownloadButton: Boolean(normalizeText(selectors.downloadButton)),
selectors: {
hasNewSessionButton: Boolean(normalizeText(selectors.newSessionButton)),
hasPromptInput: Boolean(normalizeText(selectors.promptInput)),
hasSendButton: Boolean(normalizeText(selectors.sendButton)),
hasGeneratedImage: Boolean(normalizeText(selectors.generatedImage)),
hasDownloadButton: Boolean(normalizeText(selectors.downloadButton)),
},
})
if (typeof useBrowser !== 'function') {
throw new Error('automation runtime does not provide useBrowser')
}
writeLog('open-page:start', { pageUrl, waitUntil: 'domcontentloaded', reuseCurrentPage: true })
const { page } = await useBrowser({
url: pageUrl,
waitUntil: 'domcontentloaded',
@@ -273,13 +314,18 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
bringToFront: true,
})
if (waitAfterLoadMs > 0) {
writeLog('open-page:wait-after-load', { waitAfterLoadMs })
await page.waitForTimeout(waitAfterLoadMs)
}
steps.push({ step: 'open_page', url: pageUrl })
steps.push({ step: 'open_page', url: pageUrl, currentUrl: page.url() })
writeLog('open-page:done', { requestedUrl: pageUrl, currentUrl: page.url() })
writeLog('login-check:start', { currentUrl: page.url() })
const loginRequired = await detectLoginRequired(page)
if (loginRequired) {
writeLog('login-check:failed', loginRequired)
const screenshotPath = await captureScreenshotIfNeeded(page, true, path.dirname(outputPath), 'needs-login')
writeLog('screenshot:capture', { reason: 'needs_login', screenshotPath })
return {
ok: false,
status: 'needs_login',
@@ -288,18 +334,39 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
steps: [...steps, { step: 'check_login', ok: false, ...loginRequired }],
}
}
writeLog('login-check:passed', { currentUrl: page.url() })
if (normalizeText(selectors.newSessionButton)) {
steps.push(await clickWhenReady(page, selectors.newSessionButton, timeoutMs, 'create_new_session'))
writeLog('new-session:start', { selector: selectors.newSessionButton })
const step = await clickWhenReady(page, selectors.newSessionButton, timeoutMs, 'create_new_session')
steps.push(step)
writeLog('new-session:done', step)
} else {
steps.push({ step: 'create_new_session', skipped: true, reason: 'selectors.newSessionButton is empty' })
const step = { step: 'create_new_session', skipped: true, reason: 'selectors.newSessionButton is empty' }
steps.push(step)
writeLog('new-session:skipped', step)
}
steps.push(await fillPrompt(page, selectors.promptInput, prompt, timeoutMs))
steps.push(await submitPrompt(page, selectors.sendButton, timeoutMs))
const generatedImageStep = await waitForGeneratedImage(page, selectors.generatedImage, timeoutMs)
writeLog('prompt-input:start', { selector: selectors.promptInput, promptLength: prompt.length })
const promptStep = await fillPrompt(page, selectors.promptInput, prompt, timeoutMs)
steps.push(promptStep)
writeLog('prompt-input:done', promptStep)
writeLog('submit:start', { selector: selectors.sendButton || '', mode: normalizeText(selectors.sendButton) ? 'button' : 'keyboard-enter' })
const submitStep = await submitPrompt(page, selectors.sendButton, timeoutMs)
steps.push(submitStep)
writeLog('submit:done', submitStep)
writeLog('wait-image:start', { selector: selectors.generatedImage, timeoutMs })
const generatedImageStep = await waitForGeneratedImage(page, selectors.generatedImage, timeoutMs, (progress) => {
writeLog('wait-image:progress', progress)
})
steps.push(generatedImageStep)
writeLog('wait-image:done', generatedImageStep)
if (generatedImageStep && generatedImageStep.ok === false) {
writeLog('wait-image:failed', generatedImageStep)
const screenshotPath = await captureScreenshotIfNeeded(page, true, path.dirname(outputPath), 'needs-login')
writeLog('screenshot:capture', { reason: generatedImageStep.status || 'failed', screenshotPath })
return {
ok: false,
status: generatedImageStep.status || 'failed',
@@ -310,11 +377,14 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
}
if (settleMs > 0) {
writeLog('settle:start', { settleMs })
await page.waitForTimeout(settleMs)
writeLog('settle:done', { settleMs })
}
let downloadInfo
if (normalizeText(selectors.downloadButton)) {
writeLog('download:start', { mode: 'download-button', selector: selectors.downloadButton, outputPath })
const download = await downloadByButton(page, selectors.downloadButton, timeoutMs)
await download.saveAs(outputPath)
downloadInfo = {
@@ -322,11 +392,17 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
suggestedFilename: download.suggestedFilename(),
}
} else {
writeLog('download:start', { mode: 'image-url', selector: selectors.generatedImage, outputPath })
downloadInfo = await downloadByImageURL(page, selectors.generatedImage, timeoutMs, outputPath)
}
steps.push({ step: 'download_image', outputPath, ...downloadInfo })
const downloadStep = { step: 'download_image', outputPath, bytes: fileSize(outputPath), ...downloadInfo }
steps.push(downloadStep)
writeLog('download:done', downloadStep)
writeLog('screenshot:optional-start', { enabled: captureScreenshot })
const screenshotPath = await captureScreenshotIfNeeded(page, captureScreenshot, path.dirname(outputPath), 'done')
writeLog('screenshot:optional-done', { enabled: captureScreenshot, screenshotPath })
writeLog('completed', { outputPath, bytes: fileSize(outputPath), screenshotPath, stepCount: steps.length })
return {
ok: true,
@@ -76,6 +76,98 @@ func WriteScriptPackageZip(zipPath string, bundle ImportedBundle) error {
return nil
}
func WriteScriptPackagesZip(zipPath string, bundles []ImportedBundle) error {
normalizedPath := strings.TrimSpace(zipPath)
if normalizedPath == "" {
return fmt.Errorf("script zip path is required")
}
if len(bundles) == 0 {
return fmt.Errorf("script bundles are required")
}
type packageFiles struct {
Root string
Record ScriptRecord
ManifestData []byte
Files []ImportedBundleFile
}
packages := make([]packageFiles, 0, len(bundles))
usedRoots := map[string]int{}
for _, bundle := range bundles {
record, files, err := collectScriptPackageExportFiles(bundle)
if err != nil {
return err
}
manifestData, err := MarshalScriptPackageManifest(record)
if err != nil {
return fmt.Errorf("marshal script package manifest failed: %w", err)
}
root := buildScriptPackageZipRoot(record)
usedRoots[root]++
if usedRoots[root] > 1 {
root = fmt.Sprintf("%s-%d", root, usedRoots[root])
}
packages = append(packages, packageFiles{
Root: root,
Record: record,
ManifestData: manifestData,
Files: files,
})
}
if err := os.MkdirAll(filepath.Dir(normalizedPath), 0o755); err != nil {
return fmt.Errorf("create script zip dir failed: %w", err)
}
tmpPath := normalizedPath + ".tmp"
_ = os.Remove(tmpPath)
file, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("create script zip failed: %w", err)
}
success := false
defer func() {
_ = file.Close()
if !success {
_ = os.Remove(tmpPath)
}
}()
writer := zip.NewWriter(file)
for _, item := range packages {
manifestPath := filepath.ToSlash(filepath.Join(item.Root, scriptPackageManifestName))
if err := writeScriptZipEntry(writer, manifestPath, item.ManifestData); err != nil {
_ = writer.Close()
return fmt.Errorf("write script package manifest failed: %w", err)
}
for _, bundleFile := range item.Files {
archivePath := filepath.ToSlash(filepath.Join(item.Root, bundleFile.Path))
if err := writeScriptZipEntry(writer, archivePath, bundleFile.Content); err != nil {
_ = writer.Close()
return fmt.Errorf("write script package file %s failed: %w", bundleFile.Path, err)
}
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("finalize script zip failed: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close script zip failed: %w", err)
}
if err := replaceFile(tmpPath, normalizedPath); err != nil {
return fmt.Errorf("move script zip failed: %w", err)
}
success = true
return nil
}
func collectScriptPackageExportFiles(bundle ImportedBundle) (ScriptRecord, []ImportedBundleFile, error) {
record, err := normalizeScriptRecord(bundle.Record, ScriptRecord{})
if err != nil {
@@ -129,6 +221,34 @@ func writeScriptZipEntry(writer *zip.Writer, archivePath string, content []byte)
return err
}
func buildScriptPackageZipRoot(record ScriptRecord) string {
name := strings.TrimSpace(record.Name)
if name == "" {
name = strings.TrimSpace(record.ID)
}
if name == "" {
return "automation-script"
}
replacer := strings.NewReplacer(
"\\", "-",
"/", "-",
":", "-",
"*", "-",
"?", "-",
"\"", "-",
"<", "-",
">", "-",
"|", "-",
)
cleaned := strings.Trim(replacer.Replace(name), ". ")
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return "automation-script"
}
return cleaned
}
func replaceFile(sourcePath string, targetPath string) error {
if err := os.Rename(sourcePath, targetPath); err == nil {
return nil
@@ -21,6 +21,7 @@ type ScriptRunRecord struct {
Summary string `json:"summary"`
Error string `json:"error"`
ResultText string `json:"resultText"`
LogText string `json:"logText"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt"`
DurationMs int64 `json:"durationMs"`
@@ -141,6 +142,7 @@ func normalizeScriptRunRecord(input ScriptRunRecord) ScriptRunRecord {
Summary: strings.TrimSpace(input.Summary),
Error: strings.TrimSpace(input.Error),
ResultText: strings.TrimSpace(input.ResultText),
LogText: strings.TrimSpace(input.LogText),
StartedAt: startedAt,
FinishedAt: finishedAt,
DurationMs: durationMs,
@@ -14,6 +14,7 @@ func TestScriptRunStoreSaveAndList(t *testing.T) {
ScriptName: "脚本 1",
Status: "success",
Summary: "ok",
LogText: "2026-04-02T09:00:00Z 打开页面",
StartedAt: "2026-04-02T09:00:00Z",
FinishedAt: "2026-04-02T09:00:01Z",
DurationMs: 1000,
@@ -24,6 +25,9 @@ func TestScriptRunStoreSaveAndList(t *testing.T) {
if first.ID != "run-1" {
t.Fatalf("expected run id run-1, got %q", first.ID)
}
if first.LogText != "2026-04-02T09:00:00Z 打开页面" {
t.Fatalf("expected run log text to be persisted, got %q", first.LogText)
}
if _, err := store.Save(ScriptRunRecord{
ID: "run-2",
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"time"
)
@@ -79,6 +80,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr
Summary: strings.TrimSpace(runnerResp.Summary),
Error: strings.TrimSpace(runnerResp.Error),
ResultText: rawOutput,
LogText: formatTaskRunnerLogs(runnerResp.Logs),
DurationMs: durationMs,
StartedAt: runnerResp.StartedAt,
FinishedAt: runnerResp.FinishedAt,
@@ -96,6 +98,49 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr
return result, nil
}
func formatTaskRunnerLogs(logs []taskRunnerLogEntry) string {
if len(logs) == 0 {
return ""
}
lines := make([]string, 0, len(logs))
for _, entry := range logs {
valueText := formatTaskRunnerLogValues(entry.Values)
if valueText == "" {
continue
}
timeText := strings.TrimSpace(entry.Time)
if timeText == "" {
lines = append(lines, valueText)
continue
}
lines = append(lines, fmt.Sprintf("%s %s", timeText, valueText))
}
return strings.Join(lines, "\n")
}
func formatTaskRunnerLogValues(values []any) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, formatTaskRunnerLogValue(value))
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func formatTaskRunnerLogValue(value any) string {
if value == nil {
return "null"
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text)
}
if reflect.TypeOf(value).Kind() == reflect.Map || reflect.TypeOf(value).Kind() == reflect.Slice {
if data, err := json.Marshal(value); err == nil {
return string(data)
}
}
return strings.TrimSpace(fmt.Sprint(value))
}
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 {
@@ -14,6 +14,23 @@ import (
"ant-chrome/backend/internal/config"
)
func TestFormatTaskRunnerLogs(t *testing.T) {
logText := formatTaskRunnerLogs([]taskRunnerLogEntry{
{Time: "2026-04-02T09:00:00Z", Values: []any{"打开页面", map[string]any{"url": "https://example.test"}}},
{Time: "2026-04-02T09:00:01Z", Values: []any{"点击按钮"}},
})
if !strings.Contains(logText, "2026-04-02T09:00:00Z 打开页面") {
t.Fatalf("expected first log line, got %q", logText)
}
if !strings.Contains(logText, `{"url":"https://example.test"}`) {
t.Fatalf("expected structured log value, got %q", logText)
}
if !strings.Contains(logText, "2026-04-02T09:00:01Z 点击按钮") {
t.Fatalf("expected second log line, got %q", logText)
}
}
func TestRunScriptTaskExecutesCustomRunner(t *testing.T) {
nodeExecPath := lookupNodeExecutable(t)
@@ -21,6 +21,7 @@ type ScriptTaskResult struct {
Summary string `json:"summary"`
Error string `json:"error"`
ResultText string `json:"resultText"`
LogText string `json:"logText"`
DurationMs int64 `json:"durationMs"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt"`
@@ -42,15 +43,21 @@ type taskRunnerPayload struct {
}
type taskRunnerResponse struct {
OK bool `json:"ok"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
Title string `json:"title"`
URL string `json:"url"`
ScreenshotPath string `json:"screenshotPath,omitempty"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt"`
IsolatedPage bool `json:"isolatedPage"`
OK bool `json:"ok"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
Title string `json:"title"`
URL string `json:"url"`
ScreenshotPath string `json:"screenshotPath,omitempty"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt"`
IsolatedPage bool `json:"isolatedPage"`
Logs []taskRunnerLogEntry `json:"logs,omitempty"`
}
type taskRunnerLogEntry struct {
Time string `json:"time"`
Values []any `json:"values"`
}
type TaskEvent struct {
+240
View File
@@ -0,0 +1,240 @@
package browser
import (
"database/sql"
"fmt"
"strings"
"time"
)
type ExtensionDAO interface {
List() ([]Extension, error)
ListEnabled() ([]Extension, error)
ListByIDs(extensionIDs []string) ([]Extension, error)
Get(extensionID string) (Extension, error)
Upsert(extension Extension) error
SetEnabled(extensionID string, enabled bool) error
Delete(extensionID string) error
GetProfileSettings(profileID string) (ProfileExtensionSettings, error)
SetProfileSettings(profileID string, extensionIDs []string, configured bool) (ProfileExtensionSettings, error)
}
type SQLiteExtensionDAO struct {
db *sql.DB
}
func NewSQLiteExtensionDAO(db *sql.DB) *SQLiteExtensionDAO {
return &SQLiteExtensionDAO{db: db}
}
func (d *SQLiteExtensionDAO) List() ([]Extension, error) {
return d.listWhere("", nil)
}
func (d *SQLiteExtensionDAO) ListEnabled() ([]Extension, error) {
return d.listWhere("WHERE enabled = ?", []any{1})
}
func (d *SQLiteExtensionDAO) ListByIDs(extensionIDs []string) ([]Extension, error) {
ids := normalizeExtensionIDs(extensionIDs)
if len(ids) == 0 {
return []Extension{}, nil
}
placeholders := make([]string, 0, len(ids))
args := make([]any, 0, len(ids))
for _, id := range ids {
placeholders = append(placeholders, "?")
args = append(args, id)
}
return d.listWhere("WHERE enabled = 1 AND extension_id IN ("+strings.Join(placeholders, ",")+")", args)
}
func (d *SQLiteExtensionDAO) Get(extensionID string) (Extension, error) {
row := d.db.QueryRow(`
SELECT extension_id, name, version, description, icon_data_url, manifest_json, source_url, install_dir, enabled, installed_at, updated_at
FROM browser_extensions WHERE extension_id = ?`, strings.TrimSpace(extensionID))
return scanExtension(row)
}
func (d *SQLiteExtensionDAO) Upsert(extension Extension) error {
now := time.Now().Format(time.RFC3339)
if strings.TrimSpace(extension.InstalledAt) == "" {
extension.InstalledAt = now
}
extension.UpdatedAt = now
_, err := d.db.Exec(`
INSERT INTO browser_extensions (extension_id, name, version, description, icon_data_url, manifest_json, source_url, install_dir, enabled, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(extension_id) DO UPDATE SET
name = excluded.name,
version = excluded.version,
description = excluded.description,
icon_data_url = excluded.icon_data_url,
manifest_json = excluded.manifest_json,
source_url = excluded.source_url,
install_dir = excluded.install_dir,
enabled = excluded.enabled,
updated_at = excluded.updated_at`,
extension.ExtensionID,
extension.Name,
extension.Version,
extension.Description,
extension.IconDataURL,
extension.ManifestJSON,
extension.SourceURL,
extension.InstallDir,
boolToInt(extension.Enabled),
extension.InstalledAt,
extension.UpdatedAt,
)
if err != nil {
return fmt.Errorf("保存插件失败: %w", err)
}
return nil
}
func (d *SQLiteExtensionDAO) SetEnabled(extensionID string, enabled bool) error {
result, err := d.db.Exec(
`UPDATE browser_extensions SET enabled = ?, updated_at = ? WHERE extension_id = ?`,
boolToInt(enabled), time.Now().Format(time.RFC3339), strings.TrimSpace(extensionID),
)
if err != nil {
return fmt.Errorf("更新插件状态失败: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (d *SQLiteExtensionDAO) Delete(extensionID string) error {
_, err := d.db.Exec(`DELETE FROM browser_extensions WHERE extension_id = ?`, strings.TrimSpace(extensionID))
if err != nil {
return fmt.Errorf("删除插件失败: %w", err)
}
_, _ = d.db.Exec(`DELETE FROM browser_profile_extensions WHERE extension_id = ?`, strings.TrimSpace(extensionID))
return nil
}
func (d *SQLiteExtensionDAO) GetProfileSettings(profileID string) (ProfileExtensionSettings, error) {
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return ProfileExtensionSettings{}, fmt.Errorf("实例 ID 不能为空")
}
settings := ProfileExtensionSettings{ProfileID: profileID}
var configured int
row := d.db.QueryRow(`SELECT configured, updated_at FROM browser_profile_extension_settings WHERE profile_id = ?`, profileID)
if err := row.Scan(&configured, &settings.UpdatedAt); err != nil && err != sql.ErrNoRows {
return ProfileExtensionSettings{}, err
} else if err == nil {
settings.Configured = configured != 0
}
rows, err := d.db.Query(`SELECT extension_id FROM browser_profile_extensions WHERE profile_id = ? AND enabled = 1 ORDER BY created_at ASC`, profileID)
if err != nil {
return ProfileExtensionSettings{}, err
}
defer rows.Close()
for rows.Next() {
var extensionID string
if err := rows.Scan(&extensionID); err != nil {
return ProfileExtensionSettings{}, err
}
settings.ExtensionIDs = append(settings.ExtensionIDs, extensionID)
}
return settings, rows.Err()
}
func (d *SQLiteExtensionDAO) SetProfileSettings(profileID string, extensionIDs []string, configured bool) (ProfileExtensionSettings, error) {
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return ProfileExtensionSettings{}, fmt.Errorf("实例 ID 不能为空")
}
ids := normalizeExtensionIDs(extensionIDs)
now := time.Now().Format(time.RFC3339)
tx, err := d.db.Begin()
if err != nil {
return ProfileExtensionSettings{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(`INSERT INTO browser_profile_extension_settings (profile_id, configured, updated_at) VALUES (?, ?, ?)
ON CONFLICT(profile_id) DO UPDATE SET configured = excluded.configured, updated_at = excluded.updated_at`, profileID, boolToInt(configured), now); err != nil {
return ProfileExtensionSettings{}, err
}
if _, err := tx.Exec(`DELETE FROM browser_profile_extensions WHERE profile_id = ?`, profileID); err != nil {
return ProfileExtensionSettings{}, err
}
for _, extensionID := range ids {
if _, err := tx.Exec(`INSERT INTO browser_profile_extensions (profile_id, extension_id, enabled, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`, profileID, extensionID, now, now); err != nil {
return ProfileExtensionSettings{}, err
}
}
if err := tx.Commit(); err != nil {
return ProfileExtensionSettings{}, err
}
return d.GetProfileSettings(profileID)
}
func (d *SQLiteExtensionDAO) listWhere(where string, args []any) ([]Extension, error) {
query := `
SELECT extension_id, name, version, description, icon_data_url, manifest_json, source_url, install_dir, enabled, installed_at, updated_at
FROM browser_extensions ` + where + ` ORDER BY installed_at DESC, name ASC`
rows, err := d.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询插件列表失败: %w", err)
}
defer rows.Close()
items := []Extension{}
for rows.Next() {
extension, err := scanExtension(rows)
if err != nil {
return nil, err
}
items = append(items, extension)
}
return items, rows.Err()
}
type extensionScanner interface {
Scan(dest ...any) error
}
func scanExtension(scanner extensionScanner) (Extension, error) {
var extension Extension
var enabled int
if err := scanner.Scan(
&extension.ExtensionID,
&extension.Name,
&extension.Version,
&extension.Description,
&extension.IconDataURL,
&extension.ManifestJSON,
&extension.SourceURL,
&extension.InstallDir,
&enabled,
&extension.InstalledAt,
&extension.UpdatedAt,
); err != nil {
return Extension{}, err
}
extension.Enabled = enabled != 0
return extension, nil
}
func normalizeExtensionIDs(extensionIDs []string) []string {
seen := map[string]struct{}{}
ids := make([]string, 0, len(extensionIDs))
for _, extensionID := range extensionIDs {
id := strings.TrimSpace(extensionID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids
}
@@ -0,0 +1,289 @@
package browser
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
extensionDownloadTimeout = 90 * time.Second
extensionMaxPackageBytes = 128 << 20
extensionsRootDir = "extensions"
)
func ExtensionDownloadTimeout() time.Duration {
return extensionDownloadTimeout
}
var extensionIDPattern = regexp.MustCompile(`^[a-p]{32}$`)
type extensionManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
ShortName string `json:"short_name"`
DefaultLocale string `json:"default_locale"`
Icons map[string]string `json:"icons"`
Action map[string]any `json:"action"`
BrowserAction map[string]any `json:"browser_action"`
}
func NormalizeExtensionID(value string) string {
trimmed := strings.TrimSpace(value)
if parsed := extractExtensionIDFromURL(trimmed); parsed != "" {
trimmed = parsed
}
trimmed = strings.ToLower(strings.Trim(trimmed, "/#?& "))
if extensionIDPattern.MatchString(trimmed) {
return trimmed
}
return ""
}
func BuildChromeWebStoreURL(extensionID string) string {
normalizedID := NormalizeExtensionID(extensionID)
if normalizedID == "" {
return ""
}
return "https://chromewebstore.google.com/detail/" + normalizedID
}
func BuildChromeExtensionDownloadURL(extensionID string) string {
normalizedID := NormalizeExtensionID(extensionID)
if normalizedID == "" {
return ""
}
return "https://clients2.google.com/service/update2/crx?response=redirect&prodversion=120.0.0.0&acceptformat=crx2,crx3&x=id%3D" + normalizedID + "%26installsource%3Dondemand%26uc"
}
func (m *Manager) LookupExtension(query string) (ExtensionLookupResult, error) {
return m.LookupExtensionWithHTTPClient(query, nil)
}
func (m *Manager) LookupExtensionWithHTTPClient(query string, client *http.Client) (ExtensionLookupResult, error) {
extensionID := NormalizeExtensionID(query)
if extensionID == "" {
return ExtensionLookupResult{}, fmt.Errorf("请输入 Chrome 插件 ID 或 Chrome Web Store 链接")
}
result := ExtensionLookupResult{
ExtensionID: extensionID,
Name: extensionID,
StoreURL: BuildChromeWebStoreURL(extensionID),
Installable: true,
Message: "已识别插件 ID,可下载安装",
}
data, err := downloadChromeExtensionCRX(context.Background(), extensionID, client)
if err != nil {
result.Message = "已识别插件 ID,但暂时无法读取商店元信息: " + err.Error()
return result, nil
}
zipData, err := normalizeExtensionArchiveData(data)
if err != nil {
result.Message = "已识别插件 ID,但插件包格式无法解析: " + err.Error()
return result, nil
}
manifestData, err := readExtensionManifestFromZip(zipData)
if err != nil {
result.Message = "已识别插件 ID,但 manifest 无法解析: " + err.Error()
return result, nil
}
manifest, err := parseExtensionManifest(manifestData)
if err != nil {
result.Message = "已识别插件 ID,但 manifest 无法解析: " + err.Error()
return result, nil
}
localeMessages := readExtensionLocaleMessagesFromZip(zipData, manifest)
result.Name = resolveExtensionName(manifest, extensionID)
result.Name = resolveExtensionMessage(result.Name, localeMessages)
result.Version = strings.TrimSpace(manifest.Version)
result.Description = resolveExtensionDescription(manifest, localeMessages)
result.Message = "已读取插件信息,可下载安装"
return ExtensionLookupResult{
ExtensionID: result.ExtensionID,
Name: result.Name,
Version: result.Version,
Description: result.Description,
StoreURL: result.StoreURL,
Installable: result.Installable,
Message: result.Message,
}, nil
}
func (m *Manager) InstallExtensionFromWebStore(ctx context.Context, query string) (Extension, error) {
return m.InstallExtensionFromWebStoreWithHTTPClient(ctx, query, nil)
}
func (m *Manager) InstallExtensionFromWebStoreWithHTTPClient(ctx context.Context, query string, client *http.Client) (Extension, error) {
extensionID := NormalizeExtensionID(query)
if extensionID == "" {
return Extension{}, fmt.Errorf("请输入 Chrome 插件 ID 或 Chrome Web Store 链接")
}
data, err := downloadChromeExtensionCRX(ctx, extensionID, client)
if err != nil {
return Extension{}, err
}
return m.InstallExtensionPackageBytes(extensionID, BuildChromeWebStoreURL(extensionID), data)
}
func (m *Manager) InstallExtensionPackageBytes(extensionID string, sourceURL string, data []byte) (Extension, error) {
if len(data) == 0 {
return Extension{}, fmt.Errorf("插件包为空")
}
if len(data) > extensionMaxPackageBytes {
return Extension{}, fmt.Errorf("插件包超过限制")
}
zipData, err := normalizeExtensionArchiveData(data)
if err != nil {
return Extension{}, err
}
manifestData, err := readExtensionManifestFromZip(zipData)
if err != nil {
return Extension{}, err
}
manifest, err := parseExtensionManifest(manifestData)
if err != nil {
return Extension{}, err
}
resolvedID := NormalizeExtensionID(extensionID)
if resolvedID == "" {
resolvedID = extensionIDFromManifest(manifestData)
}
if resolvedID == "" {
return Extension{}, fmt.Errorf("无法识别插件 ID")
}
installDir := filepath.Join(m.ResolveRelativePath(filepath.Join("data", extensionsRootDir)), resolvedID)
if err := replaceExtensionDirFromZip(zipData, installDir); err != nil {
return Extension{}, err
}
localeMessages := readExtensionLocaleMessagesFromZip(zipData, manifest)
manifestJSON := string(manifestData)
extension := Extension{
ExtensionID: resolvedID,
Name: resolveExtensionMessage(resolveExtensionName(manifest, resolvedID), localeMessages),
Version: strings.TrimSpace(manifest.Version),
Description: resolveExtensionDescription(manifest, localeMessages),
IconDataURL: readExtensionIconDataURLFromZip(zipData, manifest),
ManifestJSON: manifestJSON,
SourceURL: strings.TrimSpace(sourceURL),
InstallDir: installDir,
Enabled: true,
}
if m.ExtensionDAO != nil {
if err := m.ExtensionDAO.Upsert(extension); err != nil {
return Extension{}, err
}
stored, err := m.ExtensionDAO.Get(resolvedID)
if err == nil {
return stored, nil
}
}
return extension, nil
}
func (m *Manager) InstallExtensionPackageFile(path string) (Extension, error) {
normalizedPath := strings.TrimSpace(path)
if normalizedPath == "" {
return Extension{}, fmt.Errorf("插件文件路径不能为空")
}
data, err := os.ReadFile(normalizedPath)
if err != nil {
return Extension{}, fmt.Errorf("读取插件文件失败: %w", err)
}
return m.InstallExtensionPackageBytes("", normalizedPath, data)
}
func (m *Manager) InstallExtensionDirectory(sourceDir string) (Extension, error) {
normalizedDir := strings.TrimSpace(sourceDir)
if normalizedDir == "" {
return Extension{}, fmt.Errorf("插件目录不能为空")
}
manifestPath := filepath.Join(normalizedDir, "manifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
return Extension{}, fmt.Errorf("插件目录缺少 manifest.json: %w", err)
}
manifest, err := parseExtensionManifest(manifestData)
if err != nil {
return Extension{}, err
}
extensionID := extensionIDFromManifest(manifestData)
installDir := filepath.Join(m.ResolveRelativePath(filepath.Join("data", extensionsRootDir)), extensionID)
if err := copyExtensionDirectory(normalizedDir, installDir); err != nil {
return Extension{}, err
}
localeMessages := readExtensionLocaleMessagesFromDir(normalizedDir, manifest)
extension := Extension{
ExtensionID: extensionID,
Name: resolveExtensionMessage(resolveExtensionName(manifest, extensionID), localeMessages),
Version: strings.TrimSpace(manifest.Version),
Description: resolveExtensionDescription(manifest, localeMessages),
IconDataURL: readExtensionIconDataURLFromDir(normalizedDir, manifest),
ManifestJSON: string(manifestData),
SourceURL: normalizedDir,
InstallDir: installDir,
Enabled: true,
}
if m.ExtensionDAO != nil {
if err := m.ExtensionDAO.Upsert(extension); err != nil {
return Extension{}, err
}
if stored, err := m.ExtensionDAO.Get(extensionID); err == nil {
return stored, nil
}
}
return extension, nil
}
func (m *Manager) EnabledExtensionDirs() []string {
if m == nil || m.ExtensionDAO == nil {
return nil
}
items, err := m.ExtensionDAO.ListEnabled()
if err != nil {
return nil
}
dirs := make([]string, 0, len(items))
for _, item := range items {
dir := strings.TrimSpace(item.InstallDir)
if dir == "" {
continue
}
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
dirs = append(dirs, dir)
}
}
return dirs
}
func (m *Manager) EnabledExtensionDirsForProfile(profileID string) []string {
if m == nil || m.ExtensionDAO == nil {
return nil
}
settings, err := m.ExtensionDAO.GetProfileSettings(profileID)
if err != nil || !settings.Configured {
return m.EnabledExtensionDirs()
}
items, err := m.ExtensionDAO.ListByIDs(settings.ExtensionIDs)
if err != nil {
return nil
}
dirs := make([]string, 0, len(items))
for _, item := range items {
dir := strings.TrimSpace(item.InstallDir)
if dir != "" {
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
dirs = append(dirs, dir)
}
}
}
return dirs
}
@@ -0,0 +1,505 @@
package browser
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
func extractExtensionIDFromURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Host == "" {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
candidate := strings.ToLower(strings.TrimSpace(parts[i]))
if extensionIDPattern.MatchString(candidate) {
return candidate
}
}
return ""
}
func downloadChromeExtensionCRX(ctx context.Context, extensionID string, client *http.Client) ([]byte, error) {
if client == nil {
client = &http.Client{Timeout: extensionDownloadTimeout}
}
downloadURL := BuildChromeExtensionDownloadURL(extensionID)
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
data, err := downloadChromeExtensionCRXOnce(ctx, client, downloadURL)
if err == nil {
return data, nil
}
lastErr = err
if !isRetryableExtensionDownloadError(err) {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
}
}
return nil, formatExtensionDownloadError(lastErr)
}
func downloadChromeExtensionCRXOnce(ctx context.Context, client *http.Client, downloadURL string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", "Mozilla/5.0 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
request.Header.Set("Accept", "*/*")
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("下载插件失败: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("下载插件失败: HTTP %d", response.StatusCode)
}
limited := io.LimitReader(response.Body, extensionMaxPackageBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("读取插件包失败: %w", err)
}
if len(data) > extensionMaxPackageBytes {
return nil, fmt.Errorf("插件包超过限制")
}
return data, nil
}
func isRetryableExtensionDownloadError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "eof") ||
strings.Contains(message, "connection reset") ||
strings.Contains(message, "connection refused") ||
strings.Contains(message, "timeout") ||
strings.Contains(message, "temporarily unavailable")
}
func formatExtensionDownloadError(err error) error {
if err == nil {
return nil
}
message := strings.ToLower(err.Error())
if strings.Contains(message, "eof") {
return fmt.Errorf("下载插件失败: 连接在下载过程中提前关闭(EOF),已重试 3 次仍失败;请换一个下载代理节点或稍后重试")
}
if strings.Contains(message, "connectex") || strings.Contains(message, "dial tcp") || strings.Contains(message, "i/o timeout") {
return fmt.Errorf("下载插件失败: 无法连接 Chrome 插件下载服务,请确认网络或下载代理可访问 clients2.google.com: %w", err)
}
return err
}
func normalizeExtensionArchiveData(data []byte) ([]byte, error) {
if bytes.HasPrefix(data, []byte("PK\x03\x04")) {
return data, nil
}
zipOffset := bytes.Index(data, []byte("PK\x03\x04"))
if zipOffset < 0 {
return nil, fmt.Errorf("插件包不是有效的 CRX/ZIP 文件")
}
return data[zipOffset:], nil
}
func readExtensionManifestFromZip(data []byte) ([]byte, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("打开插件包失败: %w", err)
}
for _, file := range reader.File {
if normalizeZipEntryPath(file.Name) == "manifest.json" {
return readZipFile(file, 2<<20)
}
}
return nil, fmt.Errorf("插件包缺少 manifest.json")
}
func parseExtensionManifest(data []byte) (extensionManifest, error) {
var manifest extensionManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, fmt.Errorf("解析 manifest.json 失败: %w", err)
}
if strings.TrimSpace(manifest.Version) == "" {
return manifest, fmt.Errorf("manifest.json 缺少 version")
}
return manifest, nil
}
func readExtensionLocaleMessagesFromZip(data []byte, manifest extensionManifest) map[string]string {
locale := resolveExtensionLocale(manifest)
if locale == "" {
return nil
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil
}
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
if strings.EqualFold(normalizeZipEntryPath(file.Name), "_locales/"+locale+"/messages.json") {
content, err := readZipFile(file, 1<<20)
if err != nil {
return nil
}
return parseExtensionLocaleMessages(content)
}
}
return nil
}
func readExtensionLocaleMessagesFromDir(sourceDir string, manifest extensionManifest) map[string]string {
locale := resolveExtensionLocale(manifest)
if locale == "" {
return nil
}
data, err := os.ReadFile(filepath.Join(sourceDir, "_locales", locale, "messages.json"))
if err != nil {
return nil
}
return parseExtensionLocaleMessages(data)
}
func parseExtensionLocaleMessages(data []byte) map[string]string {
var raw map[string]struct {
Message string `json:"message"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil
}
messages := make(map[string]string, len(raw))
for key, value := range raw {
if key = strings.TrimSpace(key); key != "" {
messages[key] = strings.TrimSpace(value.Message)
}
}
return messages
}
func resolveExtensionLocale(manifest extensionManifest) string {
locale := strings.TrimSpace(manifest.DefaultLocale)
locale = strings.Trim(locale, "/\\. ")
if locale == "" || strings.Contains(locale, "/") || strings.Contains(locale, "\\") {
return ""
}
return locale
}
func resolveExtensionDescription(manifest extensionManifest, messages map[string]string) string {
return resolveExtensionMessage(strings.TrimSpace(manifest.Description), messages)
}
func resolveExtensionMessage(value string, messages map[string]string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if !strings.HasPrefix(trimmed, "__MSG_") || !strings.HasSuffix(trimmed, "__") {
return trimmed
}
key := strings.TrimSuffix(strings.TrimPrefix(trimmed, "__MSG_"), "__")
if message := strings.TrimSpace(messages[key]); message != "" {
return message
}
return trimmed
}
func readExtensionIconDataURLFromZip(data []byte, manifest extensionManifest) string {
iconPath := resolveExtensionIconPath(manifest)
if iconPath == "" {
return ""
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return ""
}
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
if strings.EqualFold(normalizeZipEntryPath(file.Name), iconPath) {
content, err := readZipFile(file, 1<<20)
if err != nil {
return ""
}
return extensionIconDataURL(iconPath, content)
}
}
return ""
}
func readExtensionIconDataURLFromDir(sourceDir string, manifest extensionManifest) string {
iconPath := resolveExtensionIconPath(manifest)
if iconPath == "" {
return ""
}
fullPath := filepath.Join(sourceDir, filepath.FromSlash(iconPath))
content, err := os.ReadFile(fullPath)
if err != nil || len(content) > 1<<20 {
return ""
}
return extensionIconDataURL(iconPath, content)
}
func resolveExtensionIconPath(manifest extensionManifest) string {
for _, candidate := range []map[string]any{manifest.Action, manifest.BrowserAction} {
if path := mapStringValue(candidate, "default_icon"); path != "" {
return normalizeExtensionAssetPath(path)
}
}
bestSize := -1
bestPath := ""
for size, path := range manifest.Icons {
if normalizedPath := normalizeExtensionAssetPath(path); normalizedPath != "" {
parsedSize := parseExtensionIconSize(size)
if parsedSize > bestSize {
bestSize = parsedSize
bestPath = normalizedPath
}
}
}
return bestPath
}
func mapStringValue(values map[string]any, key string) string {
if values == nil {
return ""
}
if value, ok := values[key].(string); ok {
return value
}
if nested, ok := values[key].(map[string]any); ok {
bestSize := -1
bestPath := ""
for size, rawPath := range nested {
path, ok := rawPath.(string)
if !ok {
continue
}
parsedSize := parseExtensionIconSize(size)
if parsedSize > bestSize {
bestSize = parsedSize
bestPath = path
}
}
return bestPath
}
return ""
}
func normalizeExtensionAssetPath(value string) string {
path := strings.TrimSpace(filepath.ToSlash(value))
path = strings.TrimLeft(path, "/")
if path == "" || strings.Contains(path, "..") || filepath.IsAbs(path) {
return ""
}
return path
}
func parseExtensionIconSize(value string) int {
var size int
_, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &size)
return size
}
func extensionIconDataURL(path string, data []byte) string {
if len(data) == 0 || len(data) > 1<<20 {
return ""
}
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(path)))
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
if !strings.HasPrefix(mimeType, "image/") {
return ""
}
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
func replaceExtensionDirFromZip(data []byte, installDir string) error {
tmpDir := installDir + ".tmp"
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("创建插件目录失败: %w", err)
}
success := false
defer func() {
if !success {
_ = os.RemoveAll(tmpDir)
}
}()
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return fmt.Errorf("打开插件包失败: %w", err)
}
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
relativePath := normalizeZipEntryPath(file.Name)
if relativePath == "" {
continue
}
targetPath := filepath.Join(tmpDir, filepath.FromSlash(relativePath))
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(tmpDir)+string(os.PathSeparator)) {
return fmt.Errorf("插件包包含非法路径: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("创建插件文件目录失败: %w", err)
}
content, err := readZipFile(file, extensionMaxPackageBytes)
if err != nil {
return err
}
if err := os.WriteFile(targetPath, content, 0o644); err != nil {
return fmt.Errorf("写入插件文件失败: %w", err)
}
}
if err := os.RemoveAll(installDir); err != nil {
return fmt.Errorf("清理旧插件失败: %w", err)
}
if err := os.Rename(tmpDir, installDir); err != nil {
return fmt.Errorf("安装插件失败: %w", err)
}
success = true
return nil
}
func copyExtensionDirectory(sourceDir string, installDir string) error {
tmpDir := installDir + ".tmp"
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("创建插件目录失败: %w", err)
}
success := false
defer func() {
if !success {
_ = os.RemoveAll(tmpDir)
}
}()
sourceClean, err := filepath.Abs(sourceDir)
if err != nil {
return err
}
if err := filepath.WalkDir(sourceClean, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("插件目录包含符号链接: %s", path)
}
if entry.IsDir() {
name := entry.Name()
if name == ".git" || name == "node_modules" {
return filepath.SkipDir
}
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Size() > extensionMaxPackageBytes {
return fmt.Errorf("插件文件过大: %s", path)
}
relativePath, err := filepath.Rel(sourceClean, path)
if err != nil {
return err
}
targetPath := filepath.Join(tmpDir, relativePath)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(targetPath, data, 0o644)
}); err != nil {
return fmt.Errorf("复制插件目录失败: %w", err)
}
if err := os.RemoveAll(installDir); err != nil {
return fmt.Errorf("清理旧插件失败: %w", err)
}
if err := os.Rename(tmpDir, installDir); err != nil {
return fmt.Errorf("安装插件失败: %w", err)
}
success = true
return nil
}
func normalizeZipEntryPath(value string) string {
path := strings.TrimSpace(filepath.ToSlash(value))
path = strings.TrimLeft(path, "/")
if path == "" || strings.Contains(path, "..") || filepath.IsAbs(path) {
return ""
}
parts := strings.Split(path, "/")
if len(parts) > 1 && parts[0] != "" && parts[1] == "manifest.json" {
return strings.Join(parts[1:], "/")
}
return path
}
func readZipFile(file *zip.File, limit int64) ([]byte, error) {
reader, err := file.Open()
if err != nil {
return nil, fmt.Errorf("读取插件文件失败: %w", err)
}
defer reader.Close()
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
if err != nil {
return nil, fmt.Errorf("读取插件文件失败: %w", err)
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("插件文件过大: %s", file.Name)
}
return data, nil
}
func extensionIDFromManifest(manifestData []byte) string {
sum := sha256.Sum256(manifestData)
hexValue := hex.EncodeToString(sum[:16])
var builder strings.Builder
for _, char := range hexValue {
if char >= '0' && char <= '9' {
builder.WriteByte(byte('a' + char - '0'))
continue
}
builder.WriteByte(byte('k' + char - 'a'))
}
return builder.String()
}
func resolveExtensionName(manifest extensionManifest, fallback string) string {
for _, value := range []string{manifest.Name, manifest.ShortName, fallback} {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return "Chrome 插件"
}
@@ -0,0 +1,119 @@
package browser
import (
"os"
"path/filepath"
"reflect"
"testing"
"ant-chrome/backend/internal/database"
)
func newExtensionTestDAO(t *testing.T) *SQLiteExtensionDAO {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "extensions.db")
db, err := database.NewDB(dbPath)
if err != nil {
t.Fatalf("创建测试数据库失败: %v", err)
}
if err := db.Migrate(); err != nil {
t.Fatalf("迁移测试数据库失败: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return NewSQLiteExtensionDAO(db.GetConn())
}
func TestNormalizeExtensionID(t *testing.T) {
validID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
cases := []struct {
name string
in string
want string
}{
{name: "raw id", in: validID, want: validID},
{name: "upper id", in: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", want: validID},
{name: "store url", in: "https://chromewebstore.google.com/detail/example/" + validID, want: validID},
{name: "invalid", in: "not-an-extension", want: ""},
{name: "wrong alphabet", in: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", want: ""},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeExtensionID(tt.in); got != tt.want {
t.Fatalf("NormalizeExtensionID() = %q, want %q", got, tt.want)
}
})
}
}
func TestProfileExtensionSettingsRoundTrip(t *testing.T) {
dao := newExtensionTestDAO(t)
ids := []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
settings, err := dao.SetProfileSettings("profile-1", ids, true)
if err != nil {
t.Fatalf("SetProfileSettings failed: %v", err)
}
wantIDs := []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
if !settings.Configured || !reflect.DeepEqual(settings.ExtensionIDs, wantIDs) {
t.Fatalf("settings = %+v, want configured with %v", settings, wantIDs)
}
loaded, err := dao.GetProfileSettings("profile-1")
if err != nil {
t.Fatalf("GetProfileSettings failed: %v", err)
}
if !loaded.Configured || !reflect.DeepEqual(loaded.ExtensionIDs, wantIDs) {
t.Fatalf("loaded = %+v, want configured with %v", loaded, wantIDs)
}
}
func TestEnabledExtensionDirsForProfile(t *testing.T) {
dao := newExtensionTestDAO(t)
root := t.TempDir()
firstDir := writeExtensionManifest(t, root, "one")
secondDir := writeExtensionManifest(t, root, "two")
disabledDir := writeExtensionManifest(t, root, "disabled")
manager := NewManager(nil, root)
manager.ExtensionDAO = dao
items := []Extension{
{ExtensionID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "one", Version: "1.0.0", InstallDir: firstDir, Enabled: true},
{ExtensionID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "two", Version: "1.0.0", InstallDir: secondDir, Enabled: true},
{ExtensionID: "cccccccccccccccccccccccccccccccc", Name: "disabled", Version: "1.0.0", InstallDir: disabledDir, Enabled: false},
}
for _, item := range items {
if err := dao.Upsert(item); err != nil {
t.Fatalf("Upsert(%s) failed: %v", item.ExtensionID, err)
}
}
if got := manager.EnabledExtensionDirsForProfile("profile-inherit"); !reflect.DeepEqual(got, []string{firstDir, secondDir}) {
t.Fatalf("inherit dirs = %v", got)
}
_, err := dao.SetProfileSettings("profile-custom", []string{
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"cccccccccccccccccccccccccccccccc",
}, true)
if err != nil {
t.Fatalf("SetProfileSettings failed: %v", err)
}
if got := manager.EnabledExtensionDirsForProfile("profile-custom"); !reflect.DeepEqual(got, []string{firstDir}) {
t.Fatalf("custom dirs = %v, want only enabled selected dir", got)
}
}
func writeExtensionManifest(t *testing.T, root string, name string) string {
t.Helper()
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("创建插件目录失败: %v", err)
}
manifest := []byte(`{"manifest_version":3,"name":"` + name + `","version":"1.0.0"}`)
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), manifest, 0o644); err != nil {
t.Fatalf("写入 manifest 失败: %v", err)
}
return dir
}
@@ -0,0 +1,32 @@
package browser
type Extension struct {
ExtensionID string `json:"extensionId"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
IconDataURL string `json:"iconDataUrl"`
ManifestJSON string `json:"manifestJson"`
SourceURL string `json:"sourceUrl"`
InstallDir string `json:"installDir"`
Enabled bool `json:"enabled"`
InstalledAt string `json:"installedAt"`
UpdatedAt string `json:"updatedAt"`
}
type ExtensionLookupResult struct {
ExtensionID string `json:"extensionId"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
StoreURL string `json:"storeUrl"`
Installable bool `json:"installable"`
Message string `json:"message"`
}
type ProfileExtensionSettings struct {
ProfileID string `json:"profileId"`
Configured bool `json:"configured"`
ExtensionIDs []string `json:"extensionIds"`
UpdatedAt string `json:"updatedAt"`
}
+50 -1
View File
@@ -3,6 +3,9 @@ package browser
import (
"ant-chrome/backend/internal/logger"
"fmt"
"os"
"path/filepath"
"strings"
)
// Delete 删除配置
@@ -12,10 +15,12 @@ func (m *Manager) Delete(profileId string) error {
m.Mutex.Lock()
defer m.Mutex.Unlock()
if _, exists := m.Profiles[profileId]; !exists {
profile, exists := m.Profiles[profileId]
if !exists {
log.Error("浏览器配置不存在", logger.F("profile_id", profileId))
return fmt.Errorf("profile not found")
}
userDataDir := m.ResolveUserDataDir(profile)
delete(m.Profiles, profileId)
log.Info("浏览器配置删除", logger.F("profile_id", profileId))
@@ -33,5 +38,49 @@ func (m *Manager) Delete(profileId string) error {
if m.CodeProvider != nil {
_ = m.CodeProvider.Remove(profileId)
}
if err := m.deleteProfileUserDataDir(userDataDir); err != nil {
log.Error("删除实例数据目录失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err))
return err
}
return nil
}
func (m *Manager) deleteProfileUserDataDir(userDataDir string) error {
userDataDir = strings.TrimSpace(userDataDir)
if userDataDir == "" {
return nil
}
target, err := filepath.Abs(userDataDir)
if err != nil {
return fmt.Errorf("解析实例数据目录失败: %w", err)
}
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
if root == "" {
root = "data"
}
rootAbs, err := filepath.Abs(m.ResolveRelativePath(root))
if err != nil {
return fmt.Errorf("解析用户数据根目录失败: %w", err)
}
target = filepath.Clean(target)
rootAbs = filepath.Clean(rootAbs)
if samePath(target, rootAbs) || !isPathInside(target, rootAbs) {
return nil
}
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("删除实例数据目录失败: %w", err)
}
return nil
}
func samePath(a string, b string) bool {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
func isPathInside(path string, parent string) bool {
rel, err := filepath.Rel(parent, path)
if err != nil || rel == "." || rel == "" {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
@@ -0,0 +1,51 @@
package browser
import (
"ant-chrome/backend/internal/config"
"os"
"path/filepath"
"testing"
)
func TestDeleteRemovesProfileUserDataDir(t *testing.T) {
appRoot := t.TempDir()
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = "data"
mgr := NewManager(cfg, appRoot)
profile := &Profile{ProfileId: "profile-1", UserDataDir: "profile-1"}
mgr.Profiles[profile.ProfileId] = profile
profileDir := filepath.Join(appRoot, "data", "profile-1")
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := mgr.Delete(profile.ProfileId); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(profileDir); !os.IsNotExist(err) {
t.Fatalf("expected profile dir removed, stat err=%v", err)
}
}
func TestDeleteKeepsUserDataRootWhenProfileDirIsRoot(t *testing.T) {
appRoot := t.TempDir()
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = "data"
mgr := NewManager(cfg, appRoot)
profile := &Profile{ProfileId: "profile-root", UserDataDir: ""}
mgr.Profiles[profile.ProfileId] = profile
rootDir := filepath.Join(appRoot, "data")
if err := os.MkdirAll(rootDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
profile.UserDataDir = rootDir
if err := mgr.Delete(profile.ProfileId); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(rootDir); err != nil {
t.Fatalf("expected data root kept, stat err=%v", err)
}
}
+7 -5
View File
@@ -75,6 +75,7 @@ type Settings struct {
RestoreLastSession bool `json:"restoreLastSession"`
StartReadyTimeoutMs int `json:"startReadyTimeoutMs"`
StartStableWindowMs int `json:"startStableWindowMs"`
DefaultConnectorType string `json:"defaultConnectorType"`
}
// CoreInput 内核配置输入
@@ -144,11 +145,12 @@ type Manager struct {
CodeProvider CodeProvider
// DAO 层(注入后使用 SQLite 存储,未注入时降级到 config.yaml
ProfileDAO ProfileDAO
ProxyDAO ProxyDAO
CoreDAO CoreDAO
BookmarkDAO BookmarkDAO
GroupDAO GroupDAO
ProfileDAO ProfileDAO
ProxyDAO ProxyDAO
CoreDAO CoreDAO
BookmarkDAO BookmarkDAO
GroupDAO GroupDAO
ExtensionDAO ExtensionDAO
}
// XrayBridge Xray 桥接进程
@@ -9,6 +9,22 @@ import (
var defaultBrowserStartURLs = []string{}
const (
BrowserConnectorXray = "xray"
BrowserConnectorMihomo = "mihomo"
)
func NormalizeBrowserConnectorType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case BrowserConnectorMihomo, "clash", "clash-meta":
return BrowserConnectorMihomo
case BrowserConnectorXray, "sing-box", "singbox", "sing_box", "":
return BrowserConnectorXray
default:
return BrowserConnectorXray
}
}
func DefaultBrowserStartURLs() []string {
return append([]string{}, defaultBrowserStartURLs...)
}
@@ -119,6 +135,7 @@ func normalizeConfig(config *Config) {
if config.Browser.StartStableWindowMs <= 0 {
config.Browser.StartStableWindowMs = defaultConfig.Browser.StartStableWindowMs
}
config.Browser.DefaultConnectorType = NormalizeBrowserConnectorType(config.Browser.DefaultConnectorType)
if config.Browser.DefaultBookmarks == nil {
config.Browser.DefaultBookmarks = []BrowserBookmark{}
}
@@ -246,6 +263,7 @@ func DefaultConfig() *Config {
RestoreLastSession: false,
StartReadyTimeoutMs: 3000,
StartStableWindowMs: 1200,
DefaultConnectorType: BrowserConnectorXray,
},
ProxyCheck: ProxyCheckConfig{
BridgeStartTimeoutMs: 15000,
+20
View File
@@ -141,6 +141,26 @@ func TestDefaultConfigUsesCurrentOSFingerprintPlatform(t *testing.T) {
}
}
func TestNormalizeBrowserConnectorTypeAliases(t *testing.T) {
t.Parallel()
tests := map[string]string{
"": BrowserConnectorXray,
"xray": BrowserConnectorXray,
"sing-box": BrowserConnectorXray,
"singbox": BrowserConnectorXray,
"sing_box": BrowserConnectorXray,
"mihomo": BrowserConnectorMihomo,
"clash": BrowserConnectorMihomo,
"clash-meta": BrowserConnectorMihomo,
}
for input, want := range tests {
if got := NormalizeBrowserConnectorType(input); got != want {
t.Fatalf("NormalizeBrowserConnectorType(%q) = %q, want %q", input, got, want)
}
}
}
func TestLoadClearsLegacyVerificationStartURLs(t *testing.T) {
t.Parallel()
+47
View File
@@ -142,6 +142,53 @@ var migrations = []migration{
`ALTER TABLE browser_bookmarks ADD COLUMN open_on_start INTEGER NOT NULL DEFAULT 0`,
},
},
{
version: 8,
desc: "添加 Chrome 插件包管理表",
stmts: []string{
`CREATE TABLE IF NOT EXISTS browser_extensions (
extension_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
manifest_json TEXT NOT NULL DEFAULT '{}',
source_url TEXT NOT NULL DEFAULT '',
install_dir TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
installed_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_browser_extensions_enabled ON browser_extensions(enabled)`,
},
},
{
version: 9,
desc: "添加实例插件绑定表",
stmts: []string{
`CREATE TABLE IF NOT EXISTS browser_profile_extension_settings (
profile_id TEXT PRIMARY KEY,
configured INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT ''
)`,
`CREATE TABLE IF NOT EXISTS browser_profile_extensions (
profile_id TEXT NOT NULL,
extension_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (profile_id, extension_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_browser_profile_extensions_profile ON browser_profile_extensions(profile_id)`,
`CREATE INDEX IF NOT EXISTS idx_browser_profile_extensions_extension ON browser_profile_extensions(extension_id)`,
},
},
{
version: 10,
desc: "插件表添加图标缓存字段",
stmts: []string{
`ALTER TABLE browser_extensions ADD COLUMN icon_data_url TEXT NOT NULL DEFAULT ''`,
},
},
// ── 新版本在此追加,格式:
// {
// version: 4,
+41 -10
View File
@@ -7,21 +7,27 @@ import (
"os"
"os/exec"
"strings"
"sync"
)
// ClashManager Clash 进程管理器
type ClashManager struct {
Config *config.Config
AppRoot string // 应用根目录,所有相对路径基于此解析
Processes map[string]*exec.Cmd
Config *config.Config
AppRoot string // 应用根目录,所有相对路径基于此解析
Processes map[string]*exec.Cmd
NodeBridges map[string]*MihomoNodeBridge
mu sync.Mutex
launchLocks map[string]*bridgeLaunchLock
}
// NewClashManager 创建 Clash 管理器
func NewClashManager(cfg *config.Config, appRoot string) *ClashManager {
return &ClashManager{
Config: cfg,
AppRoot: appRoot,
Processes: make(map[string]*exec.Cmd),
Config: cfg,
AppRoot: appRoot,
Processes: make(map[string]*exec.Cmd),
NodeBridges: make(map[string]*MihomoNodeBridge),
launchLocks: make(map[string]*bridgeLaunchLock),
}
}
@@ -93,24 +99,28 @@ func (m *ClashManager) StartForProfile(profile ClashProfile, userDataDir string)
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
m.mu.Lock()
m.Processes[profile.GetProfileId()] = cmd
m.mu.Unlock()
profile.SetClashRunning(true)
profile.SetClashPid(cmd.Process.Pid)
profile.SetClashLastError("")
log.Info("Clash 启动成功", logger.F("profile_id", profile.GetProfileId()), logger.F("pid", cmd.Process.Pid), logger.F("port", port))
log.Info("Clash 内核进程已启动", logger.F("engine", "clash"), logger.F("profile_id", profile.GetProfileId()), logger.F("pid", cmd.Process.Pid), logger.F("port", port))
return nil
}
// StopForProfile 停止配置的 Clash 进程
func (m *ClashManager) StopForProfile(profile ClashProfile) {
log := logger.New("Clash")
m.mu.Lock()
cmd := m.Processes[profile.GetProfileId()]
delete(m.Processes, profile.GetProfileId())
m.mu.Unlock()
if cmd != nil && cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
log.Error("Clash 停止失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
}
}
delete(m.Processes, profile.GetProfileId())
profile.SetClashRunning(false)
profile.SetClashPid(0)
log.Info("Clash 已停止", logger.F("profile_id", profile.GetProfileId()))
@@ -118,10 +128,31 @@ func (m *ClashManager) StopForProfile(profile ClashProfile) {
// StopAll 停止所有 Clash 进程
func (m *ClashManager) StopAll() {
m.mu.Lock()
processes := make([]*exec.Cmd, 0, len(m.Processes))
for profileID, cmd := range m.Processes {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
if cmd != nil {
processes = append(processes, cmd)
}
delete(m.Processes, profileID)
}
bridges := make([]*MihomoNodeBridge, 0, len(m.NodeBridges))
for key, bridge := range m.NodeBridges {
if bridge != nil {
bridge.Running = false
bridges = append(bridges, bridge)
}
delete(m.NodeBridges, key)
}
m.mu.Unlock()
for _, cmd := range processes {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
for _, bridge := range bridges {
if bridge != nil && bridge.Cmd != nil && bridge.Cmd.Process != nil {
_ = bridge.Cmd.Process.Kill()
}
}
}
+16
View File
@@ -24,9 +24,11 @@ type ProxyBuildDiagnostic struct {
NodeKey string `json:"nodeKey"`
RawConfigMasked string `json:"rawConfigMasked"`
DnsServers string `json:"dnsServers"`
DnsSummary DnsDiagnosticSummary `json:"dnsSummary"`
StandardProxy string `json:"standardProxy"`
Outbounds []interface{} `json:"outbounds"`
Routes []interface{} `json:"routes"`
Inbound map[string]interface{} `json:"inbound"`
Outbound map[string]interface{} `json:"outbound"`
Runtime ProxyRuntimeDiagnostic `json:"runtime"`
Errors []string `json:"errors"`
@@ -59,6 +61,7 @@ func BuildProxyDiagnostic(proxyConfig string, proxies []config.BrowserProxy, pro
Found: found || proxyId == "",
RawConfigMasked: maskProxyConfig(src),
DnsServers: item.DnsServers,
DnsSummary: buildDnsDiagnosticSummary(item.DnsServers),
}
if found {
result.ProxyName = item.ProxyName
@@ -146,6 +149,7 @@ func buildXrayDiagnostic(src string, proxies []config.BrowserProxy, proxyId stri
}
result.Ok = true
result.Inbound = buildXrayDiagnosticInbound()
preferredKeySource = src + "\x00" + dnsServersForDiagnostic(proxies, proxyId)
result.NodeKey = computeNodeKey(preferredKeySource)
if manager != nil {
@@ -155,6 +159,18 @@ func buildXrayDiagnostic(src string, proxies []config.BrowserProxy, proxyId stri
}
}
func buildXrayDiagnosticInbound() map[string]interface{} {
return map[string]interface{}{
"tag": "socks-in",
"listen": "127.0.0.1",
"protocol": "socks",
"settings": map[string]interface{}{
"udp": true,
},
"sniffing": xrayBrowserSniffingConfig(),
}
}
func buildRuntimeDiagnostic(workDir string, configName string, stderrName string, logName string, errorName string) ProxyRuntimeDiagnostic {
runtime := ProxyRuntimeDiagnostic{WorkDir: workDir, RecentLogs: map[string]string{}}
if workDir == "" {
@@ -39,6 +39,9 @@ func TestBuildProxyDiagnosticAuthenticatedSocks5UsesXrayBridge(t *testing.T) {
if diag.Runtime.WorkDir == "" || diag.Runtime.ConfigPath != filepath.Join(diag.Runtime.WorkDir, "xray-config.json") {
t.Fatalf("unexpected runtime paths: %+v", diag.Runtime)
}
if !diag.DnsSummary.HasConfig || diag.DnsSummary.XrayServerCount != 1 {
t.Fatalf("unexpected dns summary: %+v", diag.DnsSummary)
}
}
func TestBuildProxyDiagnosticSingBoxMasksSecrets(t *testing.T) {
@@ -82,6 +85,35 @@ func TestBuildProxyDiagnosticStandardProxyDoesNotBridge(t *testing.T) {
}
}
func TestBuildProxyDiagnosticXrayShowsBrowserTuning(t *testing.T) {
src := `
name: vmess-ws
type: vmess
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000009
cipher: auto
network: ws
browser-mux: true
`
diag := BuildProxyDiagnostic(src, nil, "", BuildDiagnosticOptions{})
if !diag.Ok {
t.Fatalf("expected diagnostic ok, errors=%v", diag.Errors)
}
if diag.Inbound == nil {
t.Fatalf("expected xray inbound diagnostic")
}
sniffing := diag.Inbound["sniffing"].(map[string]interface{})
if sniffing["enabled"] != true {
t.Fatalf("sniffing.enabled = %v, want true", sniffing["enabled"])
}
outbound := diag.Outbounds[0].(map[string]interface{})
mux := outbound["mux"].(map[string]interface{})
if mux["enabled"] != true {
t.Fatalf("mux.enabled = %v, want true", mux["enabled"])
}
}
func TestBuildProxyDiagnosticMissingProxy(t *testing.T) {
diag := BuildProxyDiagnostic("", []config.BrowserProxy{{ProxyId: "p1", ProxyConfig: "direct://"}}, "missing", BuildDiagnosticOptions{})
if diag.Ok {
+35
View File
@@ -13,12 +13,27 @@ import (
)
// buildProxyHTTPClient 根据代理配置构建 HTTP 客户端,统一用于测速/健康检测场景。
func BuildProxyHTTPClient(
src string,
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
clashMgr *ClashManager,
connectorType string,
timeout time.Duration,
) (*http.Client, error) {
return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, timeout)
}
func buildProxyHTTPClient(
src string,
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
clashMgr *ClashManager,
connectorType string,
timeout time.Duration,
) (*http.Client, error) {
src = resolveProxyConfig(src, proxies, proxyId)
@@ -27,6 +42,17 @@ func buildProxyHTTPClient(
return &http.Client{Timeout: timeout}, nil
}
if config.NormalizeBrowserConnectorType(connectorType) == config.BrowserConnectorMihomo && (IsChainSocks5Proxy(src) || IsSingBoxProtocol(src) || RequiresBridge(src, proxies, proxyId) || RequiresLocalProxyBridgeForBrowser(src)) {
if clashMgr == nil {
return nil, fmt.Errorf("mihomo 管理器未初始化")
}
proxyAddr, err := clashMgr.EnsureNodeBridge(src, proxies, proxyId)
if err != nil {
return nil, fmt.Errorf("mihomo 桥接启动失败: %w", err)
}
return buildHTTPProxyClient(proxyAddr, timeout)
}
if IsChainSocks5Proxy(src) {
if xrayMgr == nil {
return nil, fmt.Errorf("xray 管理器未初始化")
@@ -93,6 +119,15 @@ func buildProxyHTTPClient(
return &http.Client{Transport: transport, Timeout: timeout}, nil
}
func buildHTTPProxyClient(proxyAddr string, timeout time.Duration) (*http.Client, error) {
proxyURL, err := url.Parse(proxyAddr)
if err != nil {
return nil, fmt.Errorf("HTTP 代理地址解析失败: %w", err)
}
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
return &http.Client{Transport: transport, Timeout: timeout}, nil
}
func buildSocks5HTTPClient(socks5Host string, timeout time.Duration) (*http.Client, error) {
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
if err != nil {
+1 -1
View File
@@ -159,7 +159,7 @@ func buildIPHealthHTTPClient(
singboxMgr *SingBoxManager,
timeout time.Duration,
) (*http.Client, error) {
return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, nil, config.BrowserConnectorXray, timeout)
}
func resolveIPHealthSource(cfg *IPHealthConfig, targetURL string) string {
+320
View File
@@ -0,0 +1,320 @@
package proxy
import (
"ant-chrome/backend/internal/apppath"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/fsutil"
"ant-chrome/backend/internal/logger"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type MihomoNodeBridge struct {
NodeKey string
Port int
Cmd *exec.Cmd
Pid int
ConfigPath string
Running bool
LastUsedAt time.Time
ExitDone chan struct{}
ExitErr error
}
func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
log := logger.New("Mihomo")
src := strings.TrimSpace(resolveProxyConfig(proxyConfig, proxies, proxyId))
if src == "" {
return "", fmt.Errorf("未找到代理节点")
}
if strings.EqualFold(src, "direct://") {
return "direct://", nil
}
key := computeNodeKey(src + "\x00mihomo")
unlock := m.lockLaunchForKey(key)
defer unlock()
if proxyURL, reused := m.tryReuseMihomoNodeBridge(key); reused {
log.Info("复用 mihomo 桥接", logger.F("engine", "mihomo"), logger.F("key", key[:8]), logger.F("proxy_url", proxyURL))
return proxyURL, nil
}
binaryPath, err := m.resolveMihomoBinary()
if err != nil {
return "", err
}
node, err := buildMihomoNode(src)
if err != nil {
return "", err
}
port, err := nextAvailablePort()
if err != nil {
return "", err
}
cfgPath, err := m.buildMihomoNodeConfig(key, node, port)
if err != nil {
return "", err
}
cmd := exec.Command(binaryPath, "-f", cfgPath, "-d", filepath.Dir(cfgPath))
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrPath := filepath.Join(filepath.Dir(cfgPath), "mihomo-stderr.log")
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
cmd.Stderr = stderrFile
}
if err := cmd.Start(); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
return "", fmt.Errorf("mihomo 启动失败: %w", err)
}
bridge := &MihomoNodeBridge{NodeKey: key, Port: port, Cmd: cmd, Pid: cmd.Process.Pid, ConfigPath: cfgPath, Running: true, LastUsedAt: time.Now(), ExitDone: make(chan struct{})}
m.watchMihomoNodeBridge(bridge)
if err := waitTCPPortReady("127.0.0.1", port, 10*time.Second); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
_ = cmd.Process.Kill()
return "", fmt.Errorf("mihomo mixed-port 未就绪: %w", err)
}
if stderrFile != nil {
stderrFile.Close()
}
m.registerMihomoNodeBridge(key, bridge)
log.Info("mihomo 内核进程已启动", logger.F("engine", "mihomo"), logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
return fmt.Sprintf("http://127.0.0.1:%d", port), nil
}
func (m *ClashManager) tryReuseMihomoNodeBridge(key string) (string, bool) {
if m == nil {
return "", false
}
m.mu.Lock()
if m.NodeBridges == nil {
m.NodeBridges = map[string]*MihomoNodeBridge{}
}
bridge := m.NodeBridges[key]
if bridge == nil || !bridge.Running || bridge.Cmd == nil || bridge.Cmd.Process == nil || bridge.Cmd.ProcessState != nil {
delete(m.NodeBridges, key)
m.mu.Unlock()
return "", false
}
m.mu.Unlock()
if waitTCPPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) != nil {
_ = bridge.Cmd.Process.Kill()
m.mu.Lock()
if m.NodeBridges[key] == bridge {
bridge.Running = false
delete(m.NodeBridges, key)
}
m.mu.Unlock()
return "", false
}
m.mu.Lock()
if m.NodeBridges[key] != bridge || !bridge.Running {
m.mu.Unlock()
return "", false
}
bridge.LastUsedAt = time.Now()
m.mu.Unlock()
return fmt.Sprintf("http://127.0.0.1:%d", bridge.Port), true
}
func (m *ClashManager) registerMihomoNodeBridge(key string, bridge *MihomoNodeBridge) {
m.mu.Lock()
defer m.mu.Unlock()
if m.NodeBridges == nil {
m.NodeBridges = map[string]*MihomoNodeBridge{}
}
if old := m.NodeBridges[key]; old != nil && old.Cmd != nil && old.Cmd.Process != nil {
old.Running = false
_ = old.Cmd.Process.Kill()
}
m.NodeBridges[key] = bridge
}
func (m *ClashManager) watchMihomoNodeBridge(bridge *MihomoNodeBridge) {
if m == nil || bridge == nil || bridge.Cmd == nil {
return
}
go func() {
err := bridge.Cmd.Wait()
m.mu.Lock()
bridge.Running = false
bridge.ExitErr = err
if current := m.NodeBridges[bridge.NodeKey]; current == bridge {
delete(m.NodeBridges, bridge.NodeKey)
}
m.mu.Unlock()
close(bridge.ExitDone)
}()
}
func (m *ClashManager) lockLaunchForKey(key string) func() {
if m == nil {
return func() {}
}
m.mu.Lock()
if m.launchLocks == nil {
m.launchLocks = make(map[string]*bridgeLaunchLock)
}
lock := m.launchLocks[key]
if lock == nil {
lock = &bridgeLaunchLock{}
m.launchLocks[key] = lock
}
lock.refs++
m.mu.Unlock()
lock.mu.Lock()
return func() {
lock.mu.Unlock()
m.mu.Lock()
lock.refs--
if lock.refs <= 0 && m.launchLocks[key] == lock {
delete(m.launchLocks, key)
}
m.mu.Unlock()
}
}
func (m *ClashManager) buildMihomoNodeConfig(key string, node map[string]interface{}, port int) (string, error) {
baseDir := m.resolveMihomoWorkdir(key)
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return "", err
}
name := strings.TrimSpace(getMapString(node, "name"))
if name == "" {
name = "node"
node["name"] = name
}
payload := map[string]interface{}{
"mixed-port": port,
"allow-lan": false,
"mode": "rule",
"log-level": "warning",
"ipv6": true,
"unified-delay": true,
"tcp-concurrent": false,
"proxies": []interface{}{node},
"proxy-groups": []interface{}{
map[string]interface{}{
"name": "proxy-out",
"type": "select",
"proxies": []interface{}{name},
},
},
"rules": []interface{}{"MATCH,proxy-out"},
}
cfgPath := filepath.Join(baseDir, "mihomo-config.yaml")
data, err := yaml.Marshal(payload)
if err != nil {
return "", err
}
if err := os.WriteFile(cfgPath, data, 0o644); err != nil {
return "", err
}
return cfgPath, nil
}
func buildMihomoNode(src string) (map[string]interface{}, error) {
var payload interface{}
if err := yaml.Unmarshal([]byte(src), &payload); err == nil {
if node := pickClashNode(payload); node != nil {
return cloneStringInterfaceMap(node), nil
}
}
mapping, err := proxyConfigToMapping(src)
if err != nil {
return nil, fmt.Errorf("mihomo 节点解析失败: %w", err)
}
out := map[string]interface{}{}
for key, value := range mapping {
out[key] = value
}
return out, nil
}
func (m *ClashManager) resolveMihomoBinary() (string, error) {
if m == nil || m.Config == nil {
return "", fmt.Errorf("mihomo 管理器未初始化")
}
candidates := []string{}
if configured := strings.TrimSpace(m.Config.Browser.ClashBinaryPath); configured != "" {
candidates = append(candidates, resolveEnvPath(configured, m.AppRoot))
}
for _, name := range []string{"mihomo.exe", "mihomo", "clash-meta.exe", "clash-meta", "clash.exe", "clash"} {
if path, err := exec.LookPath(name); err == nil {
candidates = append(candidates, path)
}
}
if runtime.GOOS == "windows" {
if appData := strings.TrimSpace(os.Getenv("APPDATA")); appData != "" {
candidates = append(candidates,
filepath.Join(appData, "mihomo-party", "mihomo.exe"),
filepath.Join(appData, "mihomo-party", "core", "mihomo.exe"),
filepath.Join(appData, "mihomo-party", "core", "mihomo-windows-amd64.exe"),
)
}
}
if m.AppRoot != "" {
candidates = append(candidates, filepath.Join(m.AppRoot, "bin", "mihomo.exe"), filepath.Join(m.AppRoot, "bin", "mihomo"))
}
for _, candidate := range candidates {
candidate = fsutil.NormalizePathInput(candidate)
if candidate == "" {
continue
}
if !filepath.IsAbs(candidate) && m.AppRoot != "" {
candidate = apppath.Resolve(m.AppRoot, candidate)
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
if err := fsutil.EnsureExecutable(candidate); err != nil {
return "", fmt.Errorf("mihomo 文件不可执行: %s: %w", candidate, err)
}
return candidate, nil
}
}
return "", fmt.Errorf("mihomo/clash 可执行文件未找到,请配置 browser.clash_binary_path")
}
func (m *ClashManager) resolveMihomoWorkdir(key string) string {
root := "data"
if m != nil && m.Config != nil {
root = strings.TrimSpace(m.Config.Browser.UserDataRoot)
if root == "" {
root = "data"
}
}
if !filepath.IsAbs(root) && m != nil {
root = apppath.Resolve(m.AppRoot, root)
}
return filepath.Join(root, "_mihomo", key)
}
func waitTCPPortReady(host string, port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
address := fmt.Sprintf("%s:%d", host, port)
for {
conn, err := net.DialTimeout("tcp", address, 200*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
if time.Now().After(deadline) {
return err
}
time.Sleep(100 * time.Millisecond)
}
}
+169
View File
@@ -0,0 +1,169 @@
package proxy
import (
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"ant-chrome/backend/internal/config"
)
const defaultBrowserPageProbeConcurrency = 8
var DefaultBrowserPageProbeConfig = BrowserPageProbeConfig{
URLs: []string{DefaultSpeedTestURL},
Timeout: 15 * time.Second,
Concurrency: defaultBrowserPageProbeConcurrency,
}
type BrowserPageProbeConfig struct {
URLs []string
Timeout time.Duration
Concurrency int
}
type BrowserPageProbeResult struct {
ProxyId string
Ok bool
TotalMs int64
AverageMs int64
P95Ms int64
Bytes int64
Completed int
Failed int
Concurrency int
Error string
}
func ProbeBrowserPageConnectivity(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
cfg *BrowserPageProbeConfig,
) BrowserPageProbeResult {
normalized := normalizeBrowserPageProbeConfig(cfg)
client, err := buildProxyHTTPClient("", proxyId, proxies, xrayMgr, singboxMgr, nil, config.BrowserConnectorXray, normalized.Timeout)
if err != nil {
return BrowserPageProbeResult{ProxyId: proxyId, Ok: false, Error: err.Error(), Concurrency: normalized.Concurrency}
}
return runBrowserPageProbe(proxyId, client, normalized)
}
func normalizeBrowserPageProbeConfig(cfg *BrowserPageProbeConfig) BrowserPageProbeConfig {
normalized := DefaultBrowserPageProbeConfig
normalized.URLs = append([]string{}, DefaultBrowserPageProbeConfig.URLs...)
if cfg == nil {
return normalized
}
urls := make([]string, 0, len(cfg.URLs))
for _, rawURL := range cfg.URLs {
if url := strings.TrimSpace(rawURL); url != "" {
urls = append(urls, url)
}
}
if len(urls) > 0 {
normalized.URLs = urls
}
if cfg.Timeout > 0 {
normalized.Timeout = cfg.Timeout
}
if cfg.Concurrency > 0 {
normalized.Concurrency = cfg.Concurrency
}
return normalized
}
func runBrowserPageProbe(proxyId string, client *http.Client, cfg BrowserPageProbeConfig) BrowserPageProbeResult {
startedAt := time.Now()
latencies := make([]int64, 0, cfg.Concurrency)
var totalBytes int64
var firstError string
var failed int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < cfg.Concurrency; i++ {
url := cfg.URLs[i%len(cfg.URLs)]
wg.Add(1)
go func(targetURL string) {
defer wg.Done()
requestStartedAt := time.Now()
resp, err := client.Get(targetURL)
latencyMs := time.Since(requestStartedAt).Milliseconds()
if err != nil {
mu.Lock()
failed++
if firstError == "" {
firstError = err.Error()
}
mu.Unlock()
return
}
defer resp.Body.Close()
bytesRead, readErr := io.Copy(io.Discard, resp.Body)
mu.Lock()
defer mu.Unlock()
if readErr != nil || resp.StatusCode >= http.StatusBadRequest {
failed++
if firstError == "" {
if readErr != nil {
firstError = readErr.Error()
} else {
firstError = resp.Status
}
}
return
}
latencies = append(latencies, latencyMs)
totalBytes += bytesRead
}(url)
}
wg.Wait()
completed := len(latencies)
result := BrowserPageProbeResult{
ProxyId: proxyId,
Ok: completed > 0 && failed == 0,
TotalMs: time.Since(startedAt).Milliseconds(),
Bytes: totalBytes,
Completed: completed,
Failed: failed,
Concurrency: cfg.Concurrency,
Error: firstError,
}
if completed == 0 {
if result.Error == "" {
result.Error = "并发探测全部失败"
}
return result
}
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
var sum int64
for _, latency := range latencies {
sum += latency
}
result.AverageMs = sum / int64(completed)
result.P95Ms = percentileLatency(latencies, 0.95)
return result
}
func percentileLatency(sortedLatencies []int64, percentile float64) int64 {
if len(sortedLatencies) == 0 {
return 0
}
if percentile <= 0 {
return sortedLatencies[0]
}
idx := int(float64(len(sortedLatencies))*percentile + 0.5)
if idx < 1 {
idx = 1
}
if idx > len(sortedLatencies) {
idx = len(sortedLatencies)
}
return sortedLatencies[idx-1]
}
+72
View File
@@ -0,0 +1,72 @@
package proxy
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestRunBrowserPageProbeCollectsConcurrentMetrics(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
result := runBrowserPageProbe("node-a", server.Client(), BrowserPageProbeConfig{
URLs: []string{server.URL},
Timeout: time.Second,
Concurrency: 4,
})
if !result.Ok {
t.Fatalf("probe Ok = false, error=%q", result.Error)
}
if result.Completed != 4 || result.Failed != 0 || result.Concurrency != 4 {
t.Fatalf("unexpected counts: completed=%d failed=%d concurrency=%d", result.Completed, result.Failed, result.Concurrency)
}
if result.Bytes != 8 {
t.Fatalf("bytes = %d, want 8", result.Bytes)
}
if result.P95Ms < 0 || result.AverageMs < 0 || result.TotalMs < 0 {
t.Fatalf("unexpected latency metrics: %#v", result)
}
}
func TestRunBrowserPageProbeReportsFailures(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad", http.StatusBadGateway)
}))
defer server.Close()
result := runBrowserPageProbe("node-a", server.Client(), BrowserPageProbeConfig{
URLs: []string{server.URL},
Timeout: time.Second,
Concurrency: 3,
})
if result.Ok {
t.Fatalf("probe Ok = true, want false")
}
if result.Completed != 0 || result.Failed != 3 {
t.Fatalf("unexpected counts: completed=%d failed=%d", result.Completed, result.Failed)
}
if result.Error != fmt.Sprintf("%d %s", http.StatusBadGateway, http.StatusText(http.StatusBadGateway)) {
t.Fatalf("error = %q", result.Error)
}
}
func TestPercentileLatency(t *testing.T) {
t.Parallel()
latencies := []int64{10, 20, 30, 40, 50}
if got := percentileLatency(latencies, 0.95); got != 50 {
t.Fatalf("p95 = %d, want 50", got)
}
if got := percentileLatency(latencies, 0.50); got != 30 {
t.Fatalf("p50 = %d, want 30", got)
}
}
@@ -63,10 +63,14 @@ func buildOutboundFromClashSS(node map[string]interface{}) (map[string]interface
"protocol": "shadowsocks",
"tag": "proxy-out",
"settings": map[string]interface{}{
"address": host,
"port": port,
"method": cipher,
"password": password,
"servers": []interface{}{
map[string]interface{}{
"address": host,
"port": port,
"method": cipher,
"password": password,
},
},
},
}
if plugin := getMapString(node, "plugin"); plugin != "" {
@@ -81,6 +81,7 @@ func buildOutboundFromClashVless(node map[string]interface{}) (map[string]interf
if len(stream) > 0 {
out["streamSettings"] = stream
}
applyXrayBrowserOutboundTuning(node, out)
return out, "", nil
}
@@ -139,5 +140,6 @@ func buildOutboundFromClashVmess(node map[string]interface{}) (map[string]interf
if len(stream) > 0 {
out["streamSettings"] = stream
}
applyXrayBrowserOutboundTuning(node, out)
return out, "", nil
}
@@ -68,6 +68,139 @@ serviceName: svc
}
}
func TestClashVmessWSDoesNotEnableBrowserMuxByDefault(t *testing.T) {
src := `
name: vmess-ws
type: vmess
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000004
cipher: auto
network: ws
ws-opts:
path: /ray
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
if _, ok := outbound["mux"]; ok {
t.Fatalf("mux should not be enabled by default after stability validation: %#v", outbound["mux"])
}
}
func TestClashVmessWSEnablesBrowserMuxWhenExplicit(t *testing.T) {
src := `
name: vmess-ws
type: vmess
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000004
cipher: auto
network: ws
browser-mux: true
ws-opts:
path: /ray
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
mux := outbound["mux"].(map[string]interface{})
if mux["enabled"] != true {
t.Fatalf("mux.enabled = %v, want true", mux["enabled"])
}
if mux["concurrency"] != defaultXrayMuxConcurrency {
t.Fatalf("mux.concurrency = %v, want %d", mux["concurrency"], defaultXrayMuxConcurrency)
}
}
func TestClashVlessWSEnablesBrowserMuxWhenExplicit(t *testing.T) {
src := `
name: vless-ws
type: vless
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000005
network: ws
browser-mux: true
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
if outbound["mux"] == nil {
t.Fatalf("expected mux for vless ws outbound")
}
}
func TestClashVmessGRPCDoesNotEnableBrowserMux(t *testing.T) {
src := `
name: vmess-grpc
type: vmess
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000006
cipher: auto
network: grpc
serviceName: svc
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
if _, ok := outbound["mux"]; ok {
t.Fatalf("grpc outbound must not enable browser mux by default: %#v", outbound["mux"])
}
}
func TestClashVmessWSAllowsExplicitMuxDisable(t *testing.T) {
src := `
name: vmess-ws
type: vmess
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000007
cipher: auto
network: ws
browser-mux: false
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
if _, ok := outbound["mux"]; ok {
t.Fatalf("explicit mux disable should be respected: %#v", outbound["mux"])
}
}
func TestClashVmessWSAllowsNestedMuxDisable(t *testing.T) {
src := `
name: vmess-ws
type: vmess
server: edge.example.com
port: 443
uuid: 00000000-0000-0000-0000-000000000008
cipher: auto
network: ws
mux:
enabled: false
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
if _, ok := outbound["mux"]; ok {
t.Fatalf("nested mux disable should be respected: %#v", outbound["mux"])
}
}
func TestSingBoxHysteria2ClashKeepsTLSFingerprintAndCongestion(t *testing.T) {
src := `
type: hysteria2
@@ -127,3 +260,50 @@ congestion-controller: cubic
t.Fatalf("utls fingerprint = %v, want chrome", utls["fingerprint"])
}
}
func TestClashSSBuildsXrayServersArray(t *testing.T) {
src := `
name: ss-node
type: ss
server: ss.example.com
port: 8388
cipher: aes-128-gcm
password: test-password
`
_, outbound, err := ParseProxyNode(src)
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
if outbound["protocol"] != "shadowsocks" {
t.Fatalf("protocol = %v, want shadowsocks", outbound["protocol"])
}
settings := outbound["settings"].(map[string]interface{})
servers, ok := settings["servers"].([]interface{})
if !ok || len(servers) != 1 {
t.Fatalf("servers invalid: %#v", settings["servers"])
}
server := servers[0].(map[string]interface{})
if server["address"] != "ss.example.com" || server["port"] != 8388 || server["method"] != "aes-128-gcm" || server["password"] != "test-password" {
t.Fatalf("server invalid: %#v", server)
}
if _, ok := settings["address"]; ok {
t.Fatalf("legacy flat shadowsocks settings should not be present: %#v", settings)
}
}
func TestSSURIBuildsXrayServersArray(t *testing.T) {
_, outbound, err := ParseProxyNode("ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@example.com:8388")
if err != nil {
t.Fatalf("ParseProxyNode returned error: %v", err)
}
settings := outbound["settings"].(map[string]interface{})
servers, ok := settings["servers"].([]interface{})
if !ok || len(servers) != 1 {
t.Fatalf("servers invalid: %#v", settings["servers"])
}
server := servers[0].(map[string]interface{})
if server["address"] != "example.com" || server["port"] != 8388 || server["method"] != "aes-128-gcm" || server["password"] != "password" {
t.Fatalf("server invalid: %#v", server)
}
}
+8 -4
View File
@@ -258,10 +258,14 @@ func buildOutboundSS(node string) (map[string]interface{}, error) {
"protocol": "shadowsocks",
"tag": "proxy-out",
"settings": map[string]interface{}{
"address": host,
"port": port,
"method": method,
"password": password,
"servers": []interface{}{
map[string]interface{}{
"address": host,
"port": port,
"method": method,
"password": password,
},
},
},
}, nil
}
@@ -31,6 +31,35 @@ func TestXrayRuntimeConfigUsesWarningLogLevel(t *testing.T) {
}
}
func TestXrayRuntimeConfigEnablesBrowserSniffing(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = t.TempDir()
manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()}
cfgPath, err := manager.buildRuntimeConfigWithRoute(
"sniffing-test",
[]interface{}{map[string]interface{}{"protocol": "freedom", "tag": "proxy-out"}},
[]interface{}{},
19094,
"",
)
if err != nil {
t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err)
}
runtimeConfig := readRuntimeConfigMap(t, cfgPath)
inbounds := runtimeConfig["inbounds"].([]interface{})
inbound := inbounds[0].(map[string]interface{})
sniffing := inbound["sniffing"].(map[string]interface{})
if sniffing["enabled"] != true {
t.Fatalf("sniffing.enabled = %v, want true", sniffing["enabled"])
}
destOverride := sniffing["destOverride"].([]interface{})
if len(destOverride) != 3 || destOverride[0] != "http" || destOverride[1] != "tls" || destOverride[2] != "quic" {
t.Fatalf("sniffing.destOverride = %#v, want [http tls quic]", destOverride)
}
}
func TestSingBoxRuntimeConfigUsesWarnLogLevel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = t.TempDir()
+81
View File
@@ -0,0 +1,81 @@
package proxy
import "strings"
const defaultXrayMuxConcurrency = 8
func applyXrayBrowserOutboundTuning(node map[string]interface{}, outbound map[string]interface{}) {
if node == nil || outbound == nil {
return
}
protocol := strings.ToLower(strings.TrimSpace(getMapString(node, "type")))
network := strings.ToLower(strings.TrimSpace(getMapString(node, "network")))
if !shouldEnableXrayMuxForBrowser(node, protocol, network) {
return
}
outbound["mux"] = map[string]interface{}{
"enabled": true,
"concurrency": defaultXrayMuxConcurrency,
}
}
func shouldEnableXrayMuxForBrowser(node map[string]interface{}, protocol string, network string) bool {
if protocol != "vmess" && protocol != "vless" {
return false
}
if network != "ws" {
return false
}
return hasExplicitMuxEnabled(node)
}
func hasExplicitMuxEnabled(node map[string]interface{}) bool {
for _, key := range []string{"mux", "xray-mux", "browser-mux"} {
value, ok := node[key]
if !ok {
continue
}
switch v := value.(type) {
case bool:
return v
case string:
text := strings.ToLower(strings.TrimSpace(v))
return text == "true" || text == "on" || text == "1" || text == "enabled" || text == "yes"
case map[string]interface{}:
if enabled, ok := v["enabled"]; ok {
return truthyValue(enabled)
}
case map[interface{}]interface{}:
settings := toStringMap(v)
if enabled, ok := settings["enabled"]; ok {
return truthyValue(enabled)
}
}
}
return false
}
func truthyValue(value interface{}) bool {
switch v := value.(type) {
case bool:
return v
case string:
text := strings.ToLower(strings.TrimSpace(v))
return text == "true" || text == "on" || text == "1" || text == "enabled" || text == "yes"
case int:
return v != 0
case int64:
return v != 0
case float64:
return v != 0
default:
return value != nil
}
}
func xrayBrowserSniffingConfig() map[string]interface{} {
return map[string]interface{}{
"enabled": true,
"destOverride": []string{"http", "tls", "quic"},
}
}
@@ -0,0 +1,49 @@
package proxy
import (
"ant-chrome/backend/internal/logger"
"time"
)
func (m *SingBoxManager) cleanupLoop() {
ticker := time.NewTicker(singBoxBridgeCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.recycleIdleBridges()
case <-m.stopCh:
return
}
}
}
func (m *SingBoxManager) recycleIdleBridges() {
now := time.Now()
var stale []*SingBoxBridge
m.mu.Lock()
for key, bridge := range m.Bridges {
if bridge == nil {
delete(m.Bridges, key)
continue
}
if now.Sub(bridge.LastUsedAt) < singBoxBridgeIdleTTL {
continue
}
bridge.Stopping = true
stale = append(stale, bridge)
delete(m.Bridges, key)
}
m.mu.Unlock()
if len(stale) == 0 {
return
}
log := logger.New("SingBox")
for _, bridge := range stale {
log.Info("回收空闲 sing-box 桥接进程", logger.F("key", bridge.NodeKey[:8]), logger.F("pid", bridge.Pid))
m.stopBridgeProcess(bridge)
}
}
@@ -30,7 +30,13 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows
key := computeNodeKey(src)
if socksURL, reused := m.tryReuseBridge(key); reused {
log.Info("复用 sing-box 桥接", logger.F("key", key[:8]), logger.F("socks_url", socksURL))
log.Info("复用 sing-box 桥接", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("socks_url", socksURL))
return socksURL, nil
}
unlockLaunch := m.lockLaunchForKey(key)
defer unlockLaunch()
if socksURL, reused := m.tryReuseBridge(key); reused {
log.Info("复用 sing-box 桥接", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("socks_url", socksURL))
return socksURL, nil
}
@@ -62,7 +68,7 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows
}
if socksURL, reused := m.registerBridge(key, bridge); reused {
log.Info("复用已就绪 sing-box 桥接", logger.F("key", key[:8]), logger.F("socks_url", socksURL))
log.Info("复用已就绪 sing-box 桥接", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("socks_url", socksURL))
bridge.Stopping = true
m.stopBridgeProcess(bridge)
return socksURL, nil
@@ -112,7 +118,7 @@ func (m *SingBoxManager) launchBridgeOnPort(log *logger.Logger, key string, bina
LastUsedAt: time.Now(),
}
bridge.startExitWatcher()
log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
log.Info("sing-box 内核进程已启动", logger.F("engine", "sing-box"), logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
if err := m.waitBridgeSocksReady(bridge, 10*time.Second); err != nil {
if stderrFile != nil {
@@ -252,6 +258,10 @@ func (m *SingBoxManager) logBridgeStartupError(log *logger.Logger, cfgPath strin
// StopAll 关闭所有 sing-box 桥接进程
func (m *SingBoxManager) StopAll() {
m.stopOnce.Do(func() {
close(m.stopCh)
})
m.mu.Lock()
bridges := make([]*SingBoxBridge, 0, len(m.Bridges))
for key, bridge := range m.Bridges {
@@ -0,0 +1,29 @@
package proxy
func (m *SingBoxManager) lockLaunchForKey(key string) func() {
if m == nil {
return func() {}
}
m.mu.Lock()
if m.launchLocks == nil {
m.launchLocks = make(map[string]*bridgeLaunchLock)
}
lock := m.launchLocks[key]
if lock == nil {
lock = &bridgeLaunchLock{}
m.launchLocks[key] = lock
}
lock.refs++
m.mu.Unlock()
lock.mu.Lock()
return func() {
lock.mu.Unlock()
m.mu.Lock()
lock.refs--
if lock.refs <= 0 && m.launchLocks[key] == lock {
delete(m.launchLocks, key)
}
m.mu.Unlock()
}
}
@@ -0,0 +1,43 @@
package proxy
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSingBoxLaunchLockSerializesSameKey(t *testing.T) {
manager := NewSingBoxManager(nil, "")
defer manager.StopAll()
const workers = 8
var active int32
var maxActive int32
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
unlock := manager.lockLaunchForKey("same-node")
current := atomic.AddInt32(&active, 1)
for {
old := atomic.LoadInt32(&maxActive)
if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) {
break
}
}
time.Sleep(5 * time.Millisecond)
atomic.AddInt32(&active, -1)
unlock()
}()
}
close(start)
wg.Wait()
if maxActive != 1 {
t.Fatalf("same-key launch lock allowed %d concurrent entries", maxActive)
}
}
+16 -4
View File
@@ -7,6 +7,11 @@ import (
"time"
)
const (
singBoxBridgeIdleTTL = 45 * time.Second
singBoxBridgeCleanupInterval = 15 * time.Second
)
// SingBoxBridge sing-box 桥接进程
type SingBoxBridge struct {
NodeKey string
@@ -33,13 +38,20 @@ type SingBoxManager struct {
Bridges map[string]*SingBoxBridge
OnBridgeDied func(key string, err error)
mu sync.Mutex
launchLocks map[string]*bridgeLaunchLock
stopCh chan struct{}
stopOnce sync.Once
}
// NewSingBoxManager 创建 sing-box 管理器
func NewSingBoxManager(cfg *config.Config, appRoot string) *SingBoxManager {
return &SingBoxManager{
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*SingBoxBridge),
manager := &SingBoxManager{
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*SingBoxBridge),
launchLocks: make(map[string]*bridgeLaunchLock),
stopCh: make(chan struct{}),
}
go manager.cleanupLoop()
return manager
}
+19 -3
View File
@@ -66,16 +66,32 @@ func SpeedTest(
enableMihomoIPv6()
resolvedSrc := src
if IsChainSocks5Proxy(src) {
if IsChainSocks5Proxy(src) || RequiresBridge(src, proxies, proxyId) {
if xrayMgr == nil {
log.Warn("链式代理测速缺少 Xray 管理器,降级到 TCP ping",
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",
log.Warn("代理桥接失败,降级到 TCP ping",
logger.F("proxy_id", proxyId),
logger.F("error", bridgeErr.Error()),
)
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
}
resolvedSrc = strings.TrimSpace(bridgeSocksURL)
} else if IsSingBoxProtocol(src) {
if singboxMgr == nil {
log.Warn("sing-box 协议测速缺少管理器,降级到 TCP ping",
logger.F("proxy_id", proxyId),
)
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
}
bridgeSocksURL, bridgeErr := singboxMgr.EnsureBridge(src, proxies, proxyId)
if bridgeErr != nil {
log.Warn("sing-box 协议桥接失败,降级到 TCP ping",
logger.F("proxy_id", proxyId),
logger.F("error", bridgeErr.Error()),
)
+20 -4
View File
@@ -72,17 +72,33 @@ func parseSSURIToMapping(src string) (map[string]any, error) {
if !ok {
return nil, fmt.Errorf("ss 节点缺少 settings")
}
server, err := firstShadowsocksServer(settings)
if err != nil {
return nil, err
}
mapping := map[string]any{
"name": "speedtest-proxy",
"type": "ss",
"server": settings["address"],
"port": settings["port"],
"cipher": settings["method"],
"password": settings["password"],
"server": server["address"],
"port": server["port"],
"cipher": server["method"],
"password": server["password"],
}
return mapping, nil
}
func firstShadowsocksServer(settings map[string]interface{}) (map[string]interface{}, error) {
servers, ok := settings["servers"].([]interface{})
if !ok || len(servers) == 0 {
return nil, fmt.Errorf("ss 节点缺少 servers")
}
server, ok := servers[0].(map[string]interface{})
if !ok || server == nil {
return nil, fmt.Errorf("ss server 格式无效")
}
return server, nil
}
func parseClashYAMLToMapping(src string) (map[string]any, error) {
var payload interface{}
if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
+108 -14
View File
@@ -1,9 +1,11 @@
package proxy
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
@@ -70,41 +72,133 @@ func TestRealConnectivityWithConfig(
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
cfg *SpeedTestConfig,
) TestResult {
return TestRealConnectivityWithRuntimeConfig(proxyId, proxies, xrayMgr, singboxMgr, nil, config.BrowserConnectorXray, cfg)
}
func TestRealConnectivityWithRuntimeConfig(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
clashMgr *ClashManager,
connectorType string,
cfg *SpeedTestConfig,
) TestResult {
src := resolveProxyConfig("", proxies, proxyId)
if src == "" {
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
}
targetURL := strings.TrimSpace(DefaultSpeedTestURL)
targetURLs := defaultRealConnectivityTargets()
timeout := 15 * time.Second
if cfg != nil {
if len(cfg.URLs) > 0 && strings.TrimSpace(cfg.URLs[0]) != "" {
targetURL = strings.TrimSpace(cfg.URLs[0])
if len(cfg.URLs) > 0 {
configuredURLs := normalizeSpeedTestURLs(cfg.URLs)
if len(configuredURLs) > 0 {
targetURLs = append(configuredURLs, targetURLs...)
}
}
if cfg.Timeout > 0 {
timeout = cfg.Timeout
}
}
if targetURL == "" {
targetURLs = uniqueSpeedTestURLs(targetURLs)
if len(targetURLs) == 0 {
return TestResult{ProxyId: proxyId, Ok: false, Error: "真实连通性测试目标 URL 为空"}
}
client, err := buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
client, err := buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, clashMgr, connectorType, timeout)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()}
}
start := time.Now()
resp, err := client.Get(targetURL)
latency := time.Since(start).Milliseconds()
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
var lastErr error
var lastLatency int64
for _, targetURL := range targetURLs {
start := time.Now()
resp, err := client.Get(targetURL)
latency := time.Since(start).Milliseconds()
lastLatency = latency
if err != nil {
lastErr = err
if isTimeoutError(err) {
if endpointResult := tcpPingFallback(proxyId, src, minPositiveDuration(timeout, 5*time.Second), nil); endpointResult.Ok {
return endpointResult
}
}
continue
}
_ = resp.Body.Close()
if isSpeedTestSuccessStatus(resp.StatusCode) {
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
}
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}
if endpointResult := tcpPingFallback(proxyId, src, minPositiveDuration(timeout, 5*time.Second), nil); endpointResult.Ok {
return endpointResult
}
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
if lastErr != nil {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Error: lastErr.Error()}
}
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: lastLatency, Error: "真实连通性测试失败"}
}
func defaultRealConnectivityTargets() []string {
return []string{
DefaultSpeedTestURL,
"https://cp.cloudflare.com/generate_204",
"https://www.cloudflare.com/cdn-cgi/trace",
"http://www.msftconnecttest.com/connecttest.txt",
}
}
func normalizeSpeedTestURLs(urls []string) []string {
result := make([]string, 0, len(urls))
for _, item := range urls {
if item = strings.TrimSpace(item); item != "" {
result = append(result, item)
}
}
return result
}
func uniqueSpeedTestURLs(urls []string) []string {
result := make([]string, 0, len(urls))
seen := map[string]struct{}{}
for _, item := range normalizeSpeedTestURLs(urls) {
key := strings.ToLower(item)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func isSpeedTestSuccessStatus(statusCode int) bool {
return statusCode == http.StatusNoContent || (statusCode >= 200 && statusCode < 400)
}
func minPositiveDuration(a time.Duration, b time.Duration) time.Duration {
if a <= 0 {
return b
}
if b <= 0 || a < b {
return a
}
return b
}
func isTimeoutError(err error) bool {
if err == nil {
return false
}
if os.IsTimeout(err) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
+6 -2
View File
@@ -64,8 +64,12 @@ func proxyEndpoint(src string) (string, error) {
if !ok {
return "", fmt.Errorf("ss 节点缺少 settings")
}
server := getMapString(settings, "address")
port := getMapInt(settings, "port")
serverConfig, err := firstShadowsocksServer(settings)
if err != nil {
return "", err
}
server := getMapString(serverConfig, "address")
port := getMapInt(serverConfig, "port")
if server == "" || port == 0 {
return "", fmt.Errorf("ss 节点信息不完整")
}
+2 -2
View File
@@ -20,7 +20,7 @@ type XrayManager struct {
Bridges map[string]*XrayBridge
OnBridgeDied func(key string, err error) // 桥接进程意外退出回调
mu sync.Mutex
launchLocks map[string]*xrayLaunchLock
launchLocks map[string]*bridgeLaunchLock
stopCh chan struct{}
stopOnce sync.Once
}
@@ -31,7 +31,7 @@ func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager {
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*XrayBridge),
launchLocks: make(map[string]*xrayLaunchLock),
launchLocks: make(map[string]*bridgeLaunchLock),
stopCh: make(chan struct{}),
}
go manager.cleanupLoop()
+4 -4
View File
@@ -93,13 +93,13 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP
key := computeNodeKey(src + "\x00" + dnsServers)
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
log.Info("复用 xray 桥接进程", logger.F("engine", "xray"), logger.F("key", key), logger.F("socks_url", socksURL))
return socksURL, key, nil
}
unlockLaunch := m.lockLaunchForKey(key)
defer unlockLaunch()
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
log.Info("复用 xray 桥接进程", logger.F("engine", "xray"), logger.F("key", key), logger.F("socks_url", socksURL))
return socksURL, key, nil
}
@@ -212,14 +212,14 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary
DNSServers: dnsServers,
}
bridge.startExitWatcher()
log.Info("xray 启动", logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt))
log.Info("xray 内核进程已启动", logger.F("engine", "xray"), logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt))
if err := m.waitBridgeReady(log, bridge, cfgPath, stderrPath, stderrFile, attempt); err != nil {
return "", nil, err
}
if socksURL, reused := m.registerBridge(key, bridge, pin); reused {
log.Info("复用已就绪桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
log.Info("复用已就绪 xray 桥接进程", logger.F("engine", "xray"), logger.F("key", key), logger.F("socks_url", socksURL))
bridge.Stopping = true
m.stopBridgeProcess(bridge)
return socksURL, nil, nil
+3 -3
View File
@@ -2,7 +2,7 @@ package proxy
import "sync"
type xrayLaunchLock struct {
type bridgeLaunchLock struct {
mu sync.Mutex
refs int
}
@@ -13,11 +13,11 @@ func (m *XrayManager) lockLaunchForKey(key string) func() {
}
m.mu.Lock()
if m.launchLocks == nil {
m.launchLocks = make(map[string]*xrayLaunchLock)
m.launchLocks = make(map[string]*bridgeLaunchLock)
}
lock := m.launchLocks[key]
if lock == nil {
lock = &xrayLaunchLock{}
lock = &bridgeLaunchLock{}
m.launchLocks[key] = lock
}
lock.refs++
@@ -44,9 +44,7 @@ func (m *XrayManager) buildRuntimeConfigWithRoute(key string, outbounds []interf
"settings": map[string]interface{}{
"udp": true,
},
"sniffing": map[string]interface{}{
"enabled": false,
},
"sniffing": xrayBrowserSniffingConfig(),
},
},
"outbounds": append(outbounds,
@@ -6,6 +6,16 @@ import (
"gopkg.in/yaml.v3"
)
type DnsDiagnosticSummary struct {
HasConfig bool `json:"hasConfig"`
SourceFormat string `json:"sourceFormat"`
EnhancedMode string `json:"enhancedMode"`
NameserverCount int `json:"nameserverCount"`
FallbackCount int `json:"fallbackCount"`
XrayServerCount int `json:"xrayServerCount"`
Unsupported []string `json:"unsupported"`
}
// parseDnsConfig 解析 DNS 配置,支持两种格式:
// 1. Clash dns: YAML 块(含 nameserver/fallback 等字段)
// 2. 逗号分隔的 IP 列表(兼容旧格式)
@@ -74,3 +84,52 @@ func isXrayDnsAddr(s string) bool {
}
return true
}
func buildDnsDiagnosticSummary(raw string) DnsDiagnosticSummary {
raw = strings.TrimSpace(raw)
if raw == "" {
return DnsDiagnosticSummary{}
}
summary := DnsDiagnosticSummary{HasConfig: true, SourceFormat: "list"}
type clashDns struct {
Enable bool `yaml:"enable"`
EnhancedMode string `yaml:"enhanced-mode"`
Nameserver []string `yaml:"nameserver"`
Fallback []string `yaml:"fallback"`
}
type clashDnsWrapper struct {
Dns clashDns `yaml:"dns"`
}
var wrapper clashDnsWrapper
if err := yaml.Unmarshal([]byte(raw), &wrapper); err == nil && (len(wrapper.Dns.Nameserver) > 0 || len(wrapper.Dns.Fallback) > 0 || wrapper.Dns.EnhancedMode != "") {
summary.SourceFormat = "clash"
summary.EnhancedMode = strings.TrimSpace(wrapper.Dns.EnhancedMode)
summary.NameserverCount = len(wrapper.Dns.Nameserver)
summary.FallbackCount = len(wrapper.Dns.Fallback)
for _, server := range append(append([]string{}, wrapper.Dns.Nameserver...), wrapper.Dns.Fallback...) {
server = strings.TrimSpace(server)
if server == "" {
continue
}
if isXrayDnsAddr(server) {
summary.XrayServerCount++
} else {
summary.Unsupported = append(summary.Unsupported, server)
}
}
return summary
}
for _, server := range strings.Split(raw, ",") {
server = strings.TrimSpace(server)
if server == "" {
continue
}
summary.NameserverCount++
if isXrayDnsAddr(server) {
summary.XrayServerCount++
} else {
summary.Unsupported = append(summary.Unsupported, server)
}
}
return summary
}
@@ -30,6 +30,44 @@ dns:
}
}
func TestBuildDnsDiagnosticSummaryFromClashYAML(t *testing.T) {
t.Parallel()
raw := `
dns:
enable: true
enhanced-mode: fake-ip
nameserver:
- 8.8.8.8
- tls://1.1.1.1
fallback:
- https://dns.google/dns-query
`
got := buildDnsDiagnosticSummary(raw)
if !got.HasConfig || got.SourceFormat != "clash" || got.EnhancedMode != "fake-ip" {
t.Fatalf("unexpected summary identity: %+v", got)
}
if got.NameserverCount != 2 || got.FallbackCount != 1 || got.XrayServerCount != 2 {
t.Fatalf("unexpected summary counts: %+v", got)
}
if len(got.Unsupported) != 1 || got.Unsupported[0] != "tls://1.1.1.1" {
t.Fatalf("unsupported = %#v, want tls://1.1.1.1", got.Unsupported)
}
}
func TestBuildDnsDiagnosticSummaryFromCommaList(t *testing.T) {
t.Parallel()
got := buildDnsDiagnosticSummary("8.8.8.8, tls://1.1.1.1, https://dns.google/dns-query")
if !got.HasConfig || got.SourceFormat != "list" {
t.Fatalf("unexpected summary identity: %+v", got)
}
if got.NameserverCount != 3 || got.XrayServerCount != 2 || len(got.Unsupported) != 1 {
t.Fatalf("unexpected summary counts: %+v", got)
}
}
func TestParseDnsConfigFromCommaList(t *testing.T) {
t.Parallel()
+22 -16
View File
@@ -86,14 +86,16 @@ func TestSSClashYAML(t *testing.T) {
t.Errorf("protocol 期望 shadowsocks,得到 %v", outbound["protocol"])
}
settings := outbound["settings"].(map[string]interface{})
if settings["address"] != "1.2.3.4" {
t.Errorf("address 不匹配: %v", settings["address"])
servers := settings["servers"].([]interface{})
server := servers[0].(map[string]interface{})
if server["address"] != "1.2.3.4" {
t.Errorf("address 不匹配: %v", server["address"])
}
if settings["method"] != "aes-256-gcm" {
t.Errorf("method 不匹配: %v", settings["method"])
if server["method"] != "aes-256-gcm" {
t.Errorf("method 不匹配: %v", server["method"])
}
if settings["password"] != "testpassword" {
t.Errorf("password 不匹配: %v", settings["password"])
if server["password"] != "testpassword" {
t.Errorf("password 不匹配: %v", server["password"])
}
}
@@ -109,14 +111,16 @@ func TestSSURI_SIP002(t *testing.T) {
t.Logf("SS SIP002 outbound:\n%s", string(data))
settings := outbound["settings"].(map[string]interface{})
if settings["method"] != "aes-256-gcm" {
t.Errorf("method 不匹配: %v", settings["method"])
servers := settings["servers"].([]interface{})
server := servers[0].(map[string]interface{})
if server["method"] != "aes-256-gcm" {
t.Errorf("method 不匹配: %v", server["method"])
}
if settings["password"] != "mypassword" {
t.Errorf("password 不匹配: %v", settings["password"])
if server["password"] != "mypassword" {
t.Errorf("password 不匹配: %v", server["password"])
}
if settings["address"] != "1.2.3.4" {
t.Errorf("address 不匹配: %v", settings["address"])
if server["address"] != "1.2.3.4" {
t.Errorf("address 不匹配: %v", server["address"])
}
}
@@ -129,11 +133,13 @@ func TestSSURI_Legacy(t *testing.T) {
t.Fatalf("解析失败: %v", err)
}
settings := outbound["settings"].(map[string]interface{})
if settings["method"] != "chacha20-ietf-poly1305" {
t.Errorf("method 不匹配: %v", settings["method"])
servers := settings["servers"].([]interface{})
server := servers[0].(map[string]interface{})
if server["method"] != "chacha20-ietf-poly1305" {
t.Errorf("method 不匹配: %v", server["method"])
}
if settings["address"] != "2.3.4.5" {
t.Errorf("address 不匹配: %v", settings["address"])
if server["address"] != "2.3.4.5" {
t.Errorf("address 不匹配: %v", server["address"])
}
t.Logf("SS legacy outbound OK: %v", settings)
}
+1
View File
@@ -48,6 +48,7 @@ browser:
restore_last_session: false
start_ready_timeout_ms: 3000
start_stable_window_ms: 1200
default_connector_type: xray
proxy_check:
bridge_start_timeout_ms: 15000
speed_target_id: ""
+20 -4
View File
@@ -17,6 +17,7 @@ function runPowerShell(command, cwd = repoRoot) {
return spawnSync('powershell.exe', ['-NoProfile', '-Command', command], {
cwd,
encoding: 'utf8',
timeout: 12000,
})
}
@@ -33,6 +34,10 @@ function normalizeProcessChain(proc) {
function collectProcessesByPowerShell(filterCommand) {
const result = runPowerShell(filterCommand)
if (result.error) {
throw new Error(result.error.message || 'failed to inspect processes')
}
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || 'failed to inspect processes')
}
@@ -58,9 +63,12 @@ export function listListeners(port) {
const items = collectProcessesByPowerShell(`
$port = ${port}
$items = @()
$allProcs = Get-CimInstance Win32_Process
$procMap = @{}
foreach ($item in $allProcs) { $procMap[[string]$item.ProcessId] = $item }
$conns = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
foreach ($conn in $conns) {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId = $($conn.OwningProcess)" | Select-Object -First 1
$proc = $procMap[[string]$conn.OwningProcess]
if (-not $proc) { continue }
$chain = @()
@@ -76,7 +84,7 @@ foreach ($conn in $conns) {
commandLine = [string]$current.CommandLine
}
if ($current.ParentProcessId -le 0) { break }
$current = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" | Select-Object -First 1
$current = $procMap[[string]$current.ParentProcessId]
$depth++
}
@@ -126,14 +134,22 @@ export function killProcessTree(pid) {
const killed = spawnSync('taskkill.exe', ['/F', '/T', '/PID', String(pid)], {
cwd: repoRoot,
stdio: 'ignore',
timeout: 8000,
})
if (killed.error) {
return false
}
return killed.status === 0
}
export function listProjectDevProcesses() {
return collectProcessesByPowerShell(`
$items = @()
$procs = Get-CimInstance Win32_Process -Filter "${processInspectionFilter}"
$allProcs = Get-CimInstance Win32_Process
$procMap = @{}
foreach ($item in $allProcs) { $procMap[[string]$item.ProcessId] = $item }
$targetNames = @('node.exe', 'cmd.exe', 'npm.exe', 'esbuild.exe', 'wails.exe')
$procs = $allProcs | Where-Object { $targetNames -contains $_.Name }
foreach ($proc in $procs) {
$chain = @()
$current = $proc
@@ -148,7 +164,7 @@ foreach ($proc in $procs) {
commandLine = [string]$current.CommandLine
}
if ($current.ParentProcessId -le 0) { break }
$current = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" | Select-Object -First 1
$current = $procMap[[string]$current.ParentProcessId]
$depth++
}
+1
View File
@@ -22,6 +22,7 @@ export const navigationConfig: NavSection[] = [
{ name: '实例列表', path: '/browser/list', icon: 'Monitor' },
{ name: '自动化脚本', path: '/browser/automation', icon: 'Bot' },
{ name: '内核管理', path: '/browser/cores', icon: 'Cpu' },
{ name: '插件包管理', path: '/browser/extensions', icon: 'Puzzle' },
{ name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' },
{ name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' },
{ name: '标签管理', path: '/browser/tags', icon: 'Tag' },
@@ -0,0 +1,207 @@
import type { BrowserExtension, BrowserExtensionLookupResult, BrowserProfileExtensionSettings } from '../types'
import { getBindings, getGoApp } from './runtime'
export interface BrowserExtensionManualInstallGuide {
extensionId: string
storeUrl: string
downloadUrl: string
downloadDir: string
fileName: string
}
export interface BrowserExtensionManualDownloadFile {
fileName: string
filePath: string
sizeBytes: number
updatedAt: string
}
function normalizeExtension(payload: any): BrowserExtension {
return {
extensionId: String(payload?.extensionId || ''),
name: String(payload?.name || ''),
version: String(payload?.version || ''),
description: String(payload?.description || ''),
iconDataUrl: String(payload?.iconDataUrl || ''),
manifestJson: String(payload?.manifestJson || ''),
sourceUrl: String(payload?.sourceUrl || ''),
installDir: String(payload?.installDir || ''),
enabled: payload?.enabled !== false,
installedAt: String(payload?.installedAt || ''),
updatedAt: String(payload?.updatedAt || ''),
}
}
function normalizeLookup(payload: any): BrowserExtensionLookupResult {
return {
extensionId: String(payload?.extensionId || ''),
name: String(payload?.name || ''),
version: String(payload?.version || ''),
description: String(payload?.description || ''),
storeUrl: String(payload?.storeUrl || ''),
installable: payload?.installable === true,
message: String(payload?.message || ''),
}
}
function normalizeManualInstallGuide(payload: any): BrowserExtensionManualInstallGuide {
return {
extensionId: String(payload?.extensionId || ''),
storeUrl: String(payload?.storeUrl || ''),
downloadUrl: String(payload?.downloadUrl || ''),
downloadDir: String(payload?.downloadDir || ''),
fileName: String(payload?.fileName || ''),
}
}
function normalizeManualDownloadFile(payload: any): BrowserExtensionManualDownloadFile {
return {
fileName: String(payload?.fileName || ''),
filePath: String(payload?.filePath || ''),
sizeBytes: Number(payload?.sizeBytes || 0),
updatedAt: String(payload?.updatedAt || ''),
}
}
export async function fetchBrowserExtensions(): Promise<BrowserExtension[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionList) {
const result = await bindings.BrowserExtensionList()
return Array.isArray(result) ? result.map(normalizeExtension) : []
}
return []
}
export async function lookupBrowserExtension(query: string, proxyConfig = '', useProxy = false): Promise<BrowserExtensionLookupResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionLookupWithProxy) {
return normalizeLookup(await bindings.BrowserExtensionLookupWithProxy({ query, useProxy, proxyConfig }))
}
if (useProxy) throw new Error('当前后端版本不支持插件下载代理,请重启或更新应用')
if (bindings?.BrowserExtensionLookup) {
return normalizeLookup(await bindings.BrowserExtensionLookup(query))
}
throw new Error('当前环境不支持插件查询')
}
export async function installBrowserExtension(query: string, proxyConfig = '', useProxy = false): Promise<BrowserExtension> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionInstallWithProxy) {
return normalizeExtension(await bindings.BrowserExtensionInstallWithProxy({ query, useProxy, proxyConfig }))
}
if (useProxy) throw new Error('当前后端版本不支持插件下载代理,请重启或更新应用')
if (bindings?.BrowserExtensionInstall) {
return normalizeExtension(await bindings.BrowserExtensionInstall(query))
}
throw new Error('当前环境不支持插件安装')
}
export async function getBrowserExtensionManualInstallGuide(query: string): Promise<BrowserExtensionManualInstallGuide> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionManualInstallGuide) {
return normalizeManualInstallGuide(await bindings.BrowserExtensionManualInstallGuide(query))
}
const goApp = getGoApp()
if (goApp?.BrowserExtensionManualInstallGuide) {
return normalizeManualInstallGuide(await goApp.BrowserExtensionManualInstallGuide(query))
}
throw new Error('当前环境不支持手动安装指南')
}
export async function openBrowserExtensionManualDownloadDir(): Promise<void> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionOpenManualDownloadDir) {
await bindings.BrowserExtensionOpenManualDownloadDir()
return
}
const goApp = getGoApp()
if (goApp?.BrowserExtensionOpenManualDownloadDir) {
await goApp.BrowserExtensionOpenManualDownloadDir()
return
}
throw new Error('当前环境不支持打开下载目录')
}
export async function listBrowserExtensionManualDownloadFiles(): Promise<BrowserExtensionManualDownloadFile[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionListManualDownloadFiles) {
const result = await bindings.BrowserExtensionListManualDownloadFiles()
return Array.isArray(result) ? result.map(normalizeManualDownloadFile) : []
}
const goApp = getGoApp()
if (goApp?.BrowserExtensionListManualDownloadFiles) {
const result = await goApp.BrowserExtensionListManualDownloadFiles()
return Array.isArray(result) ? result.map(normalizeManualDownloadFile) : []
}
return []
}
export async function installBrowserExtensionManualDownloadFile(fileName: string): Promise<BrowserExtension> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionInstallManualDownloadFile) {
return normalizeExtension(await bindings.BrowserExtensionInstallManualDownloadFile(fileName))
}
const goApp = getGoApp()
if (goApp?.BrowserExtensionInstallManualDownloadFile) {
return normalizeExtension(await goApp.BrowserExtensionInstallManualDownloadFile(fileName))
}
throw new Error('当前环境不支持导入手动下载插件包')
}
export async function installBrowserExtensionLocalFile(): Promise<BrowserExtension> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionInstallLocalFile) {
return normalizeExtension(await bindings.BrowserExtensionInstallLocalFile())
}
throw new Error('当前环境不支持本地插件包导入')
}
export async function installBrowserExtensionLocalDirectory(): Promise<BrowserExtension> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionInstallLocalDirectory) {
return normalizeExtension(await bindings.BrowserExtensionInstallLocalDirectory())
}
throw new Error('当前环境不支持本地插件目录导入')
}
export async function setBrowserExtensionEnabled(extensionId: string, enabled: boolean): Promise<BrowserExtension> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionSetEnabled) {
return normalizeExtension(await bindings.BrowserExtensionSetEnabled(extensionId, enabled))
}
throw new Error('当前环境不支持插件状态切换')
}
export async function deleteBrowserExtension(extensionId: string): Promise<void> {
const bindings: any = await getBindings()
if (bindings?.BrowserExtensionDelete) {
await bindings.BrowserExtensionDelete(extensionId)
return
}
throw new Error('当前环境不支持插件删除')
}
function normalizeProfileSettings(payload: any): BrowserProfileExtensionSettings {
return {
profileId: String(payload?.profileId || ''),
configured: payload?.configured === true,
extensionIds: Array.isArray(payload?.extensionIds) ? payload.extensionIds.map((item: unknown) => String(item || '')).filter(Boolean) : [],
updatedAt: String(payload?.updatedAt || ''),
}
}
export async function fetchBrowserProfileExtensionSettings(profileId: string): Promise<BrowserProfileExtensionSettings> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileExtensionGet) {
return normalizeProfileSettings(await bindings.BrowserProfileExtensionGet(profileId))
}
throw new Error('当前环境不支持实例插件配置')
}
export async function saveBrowserProfileExtensionSettings(profileId: string, extensionIds: string[], configured: boolean): Promise<BrowserProfileExtensionSettings> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileExtensionSave) {
return normalizeProfileSettings(await bindings.BrowserProfileExtensionSave(profileId, extensionIds, configured))
}
throw new Error('当前环境不支持保存实例插件配置')
}
+39 -1
View File
@@ -1,4 +1,4 @@
import type { BrowserProxy, ProxyBridgeWarmupResult, ProxyIPHealthResult, ProxyLocationResolveResult } from '../types'
import type { BrowserProxy, ProxyBridgeWarmupResult, ProxyCoreDownloadInfoResult, ProxyCoreStatusResult, ProxyIPHealthResult, ProxyLocationResolveResult } from '../types'
import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime'
export interface ClashImportURLResult {
@@ -259,3 +259,41 @@ export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurr
updatedAt: nowISOString(),
}))
}
export async function browserProxyCoreDownload(core: string, goos: string, goarch: string, proxyConfig = ''): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyCoreDownload) {
await bindings.BrowserProxyCoreDownload({ core, goos, goarch, proxyConfig })
return true
}
return false
}
export async function browserProxyCoreStatus(core: string, goos: string, goarch: string): Promise<ProxyCoreStatusResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyCoreStatus) {
return (await bindings.BrowserProxyCoreStatus({ core, goos, goarch })) || {
core, goos, goarch, installed: false, configured: false, active: false, binaryPath: '', source: '', message: '状态查询失败',
}
}
return { core, goos, goarch, installed: false, configured: false, active: false, binaryPath: '', source: '', message: '未连接后端' }
}
export async function browserProxyCoreDownloadInfo(core: string, goos: string, goarch: string, proxyConfig = ''): Promise<ProxyCoreDownloadInfoResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyCoreDownloadInfo) {
return (await bindings.BrowserProxyCoreDownloadInfo({ core, goos, goarch, proxyConfig })) || {
core, goos, goarch, version: '', repo: '', releaseUrl: '', downloadUrl: '', assetName: '', installDir: '', binaryName: '', message: '下载信息查询失败',
}
}
return { core, goos, goarch, version: '', repo: '', releaseUrl: '', downloadUrl: '', assetName: '', installDir: '', binaryName: '', message: '未连接后端' }
}
export async function browserProxyCoreOpenLocal(core: string, goos: string, goarch: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyCoreOpenLocal) {
await bindings.BrowserProxyCoreOpenLocal({ core, goos, goarch })
return true
}
return false
}
@@ -26,6 +26,7 @@ export function createDefaultBrowserSettings(): BrowserSettings {
restoreLastSession: false,
startReadyTimeoutMs: 3000,
startStableWindowMs: 1200,
defaultConnectorType: 'xray',
}
}
@@ -106,6 +106,41 @@ export async function exportAutomationScriptZip(
throw new Error("当前环境不支持 ZIP 脚本包导出");
}
export async function exportAutomationScriptsBatchZip(
scriptIds: string[],
): Promise<AutomationScriptExportResult> {
const normalizedScriptIds = Array.from(
new Set(
scriptIds
.map((scriptId) => String(scriptId || "").trim())
.filter(Boolean),
),
);
if (normalizedScriptIds.length === 0) {
throw new Error("请先勾选要导出的脚本");
}
const bindings: any = await getBindings();
if (bindings?.AutomationScriptExportBatchZip) {
return normalizeAutomationScriptExportResult(
await bindings.AutomationScriptExportBatchZip(normalizedScriptIds),
);
}
const goApp = (window as any).go?.main?.App;
if (typeof goApp?.AutomationScriptExportBatchZip === "function") {
return normalizeAutomationScriptExportResult(
await goApp.AutomationScriptExportBatchZip(normalizedScriptIds),
);
}
if (normalizedScriptIds.length === 1) {
return exportAutomationScriptZip(normalizedScriptIds[0]);
}
throw new Error("当前环境不支持批量 ZIP 脚本包导出");
}
export async function exportAutomationScriptDirectory(
scriptId: string,
): Promise<AutomationScriptExportResult> {
@@ -2,6 +2,20 @@
import { startBrowserInstanceByCode } from "./api/instances";
import { getBindings, normalizeAutomationScriptPublicApiInvokeResult, normalizeAutomationScriptRunInput, normalizeAutomationScriptRunRecord, type AutomationScriptPublicApiInvokeInput, type AutomationScriptPublicApiInvokeResult } from "./automationScriptApi.shared";
const AUTOMATION_RUN_LIST_TIMEOUT_MS = 8000;
function withAutomationRunListTimeout<T>(promise: Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
reject(new Error("调用记录加载超时"));
}, AUTOMATION_RUN_LIST_TIMEOUT_MS);
promise
.then(resolve, reject)
.finally(() => window.clearTimeout(timer));
});
}
export async function runAutomationScript(
input: string | AutomationScriptRunInput,
): Promise<AutomationScriptRunRecord> {
@@ -59,6 +73,7 @@ export async function runAutomationScript(
summary: "当前环境未接入自动化脚本执行",
error: "AutomationScriptRun binding is unavailable",
resultText: "",
logText: "",
startedAt: now,
finishedAt: now,
durationMs: 0,
@@ -70,7 +85,7 @@ export async function fetchAutomationScriptRuns(
): Promise<AutomationScriptRunRecord[]> {
const bindings: any = await getBindings();
if (bindings?.AutomationScriptRunList) {
const raw = (await bindings.AutomationScriptRunList(limit)) || [];
const raw = (await withAutomationRunListTimeout(bindings.AutomationScriptRunList(limit))) || [];
return Array.isArray(raw)
? raw.map(normalizeAutomationScriptRunRecord)
: [];
@@ -78,7 +93,7 @@ export async function fetchAutomationScriptRuns(
const goApp = (window as any).go?.main?.App;
if (typeof goApp?.AutomationScriptRunList === "function") {
const raw = (await goApp.AutomationScriptRunList(limit)) || [];
const raw = (await withAutomationRunListTimeout(goApp.AutomationScriptRunList(limit))) || [];
return Array.isArray(raw)
? raw.map(normalizeAutomationScriptRunRecord)
: [];
@@ -80,6 +80,7 @@ export function normalizeAutomationScriptRunRecord(
summary: String(payload?.summary || ""),
error: String(payload?.error || ""),
resultText: String(payload?.resultText || ""),
logText: String(payload?.logText || ""),
startedAt: String(payload?.startedAt || ""),
finishedAt: String(payload?.finishedAt || ""),
durationMs: Number(payload?.durationMs) || 0,
@@ -7,5 +7,5 @@
} from "./automationScriptApi.shared";
export { fetchAutomationScripts, saveAutomationScript, deleteAutomationScript } from "./automationScriptApi.scripts";
export { importAutomationScriptFromGit, importAutomationScriptFromLocalDirectory, importAutomationScriptFromLocalFile, importAutomationScriptFromLocalLibrary, importAutomationScriptFromRemote, importAutomationScriptFromText, refreshAutomationScript } from "./automationScriptApi.imports";
export { exportAutomationScriptDirectory, exportAutomationScriptTemplate, exportAutomationScriptZip } from "./automationScriptApi.exports";
export { exportAutomationScriptDirectory, exportAutomationScriptTemplate, exportAutomationScriptZip, exportAutomationScriptsBatchZip } from "./automationScriptApi.exports";
export { fetchAutomationScriptRuns, invokeAutomationScriptPublicApi, runAutomationScript } from "./automationScriptApi.runs";
@@ -89,6 +89,7 @@ export interface AutomationScriptRunRecord {
summary: string;
error: string;
resultText: string;
logText: string;
startedAt: string;
finishedAt: string;
durationMs: number;
@@ -1,41 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { BookOpen, Settings2 } from 'lucide-react'
import { Button } from '../../../shared/components'
interface AutomationEntryActionsProps {
onBeforeNavigate?: () => void
size?: 'sm' | 'md' | 'lg'
}
export function AutomationEntryActions({
onBeforeNavigate,
size = 'sm',
}: AutomationEntryActionsProps) {
const navigate = useNavigate()
const openRoute = (path: string) => {
onBeforeNavigate?.()
navigate(path)
}
return (
<div className="flex flex-wrap gap-2">
<Button
size={size}
variant="secondary"
onClick={() => openRoute('/system/docs')}
>
<BookOpen className="h-4 w-4" />
</Button>
<Button
size={size}
variant="secondary"
onClick={() => openRoute('/settings')}
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
)
}
@@ -4,7 +4,7 @@ import {
useState,
type KeyboardEvent,
} from "react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { ChevronDown, ChevronRight, FileText, RefreshCw } from "lucide-react";
import {
Badge,
Button,
@@ -117,6 +117,7 @@ export function AutomationScriptHistoryModal({
}: AutomationScriptHistoryModalProps) {
const [runs, setRuns] = useState<AutomationScriptRunRecord[]>([]);
const [expandedRunId, setExpandedRunId] = useState("");
const [selectedLogRunId, setSelectedLogRunId] = useState("");
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@@ -124,6 +125,7 @@ export function AutomationScriptHistoryModal({
if (!open) {
setRuns([]);
setExpandedRunId("");
setSelectedLogRunId("");
setLoading(false);
return;
}
@@ -197,6 +199,8 @@ export function AutomationScriptHistoryModal({
};
const latestRun = runs[0] || null;
const selectedLogRun =
runs.find((item) => item.id === selectedLogRunId) || null;
const successCount = runs.filter((item) => item.status === "success").length;
const failedCount = runs.filter((item) => item.status === "failed").length;
const scriptCount = new Set(
@@ -204,6 +208,7 @@ export function AutomationScriptHistoryModal({
).size;
return (
<>
<Modal
open={open}
onClose={onClose}
@@ -269,7 +274,7 @@ export function AutomationScriptHistoryModal({
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] shadow-[var(--shadow-sm)]">
<div className="max-h-[58vh] overflow-auto">
<table className="w-full min-w-[960px]">
<table className="w-full min-w-[1040px]">
<thead className="sticky top-0 z-10 bg-[var(--color-bg-muted)]">
<tr>
<th className="w-14 px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
@@ -293,6 +298,9 @@ export function AutomationScriptHistoryModal({
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border-muted)]">
@@ -358,10 +366,24 @@ export function AutomationScriptHistoryModal({
{run.summary || "未返回摘要"}
</div>
</td>
<td className="px-4 py-4 align-top text-sm">
<Button
size="sm"
variant="secondary"
disabled={!run.logText}
onClick={(event) => {
event.stopPropagation();
setSelectedLogRunId(run.id);
}}
>
<FileText className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
{expanded ? (
<tr className="bg-[var(--color-bg-muted)]/35">
<td colSpan={7} className="px-4 pb-4 pt-1">
<td colSpan={8} className="px-4 pb-4 pt-1">
<div className="rounded-2xl border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<HistoryDetailField
@@ -424,7 +446,7 @@ export function AutomationScriptHistoryModal({
</div>
) : null}
{!run.error && !run.resultText ? (
{!run.error && !run.resultText && !run.logText ? (
<div className="mt-3 rounded-xl border border-dashed border-[var(--color-border-muted)] bg-[var(--color-bg-muted)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
</div>
@@ -443,5 +465,31 @@ export function AutomationScriptHistoryModal({
)}
</div>
</Modal>
<Modal
open={Boolean(selectedLogRun)}
onClose={() => setSelectedLogRunId("")}
title="执行日志"
width="760px"
footer={
<Button variant="secondary" onClick={() => setSelectedLogRunId("")}>
</Button>
}
>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-muted)] px-4 py-3 text-sm">
<span className="font-medium text-[var(--color-text-primary)]">
{selectedLogRun?.scriptName || "未命名脚本"}
</span>
<span className="text-xs text-[var(--color-text-muted)]">
{formatDateTime(selectedLogRun?.startedAt)}
</span>
</div>
<pre className="max-h-[56vh] overflow-auto rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-muted)] px-4 py-3 font-mono text-xs leading-6 text-[var(--color-text-secondary)] whitespace-pre-wrap break-all">
{selectedLogRun?.logText || "暂无执行日志"}
</pre>
</div>
</Modal>
</>
);
}
@@ -1,221 +0,0 @@
import { Copy, PlusSquare, Rocket, ShieldCheck, Trash2 } from 'lucide-react'
import { Badge, Button, Modal, toast } from '../../../shared/components'
import {
automationDemoCreateProfile,
automationDemoDeleteProfile,
automationDemoHealthCheck,
automationDemoLaunchProfile,
} from '../api'
import { AutomationEntryActions } from './AutomationEntryActions'
import { LaunchServerStatusBlock } from './LaunchServerStatusBlock'
import { useAutomationDemoSession } from '../hooks/useAutomationDemoSession'
import { useLaunchContext } from '../hooks/useLaunchContext'
interface AutomationToolboxModalProps {
open: boolean
onClose: () => void
}
async function copyToClipboard(text: string, successMessage: string) {
try {
await navigator.clipboard.writeText(text)
toast.success(successMessage)
} catch {
toast.error('复制失败')
}
}
function JsonPreview({ text }: { text: string }) {
if (!text) {
return (
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
Demo
</div>
)
}
return (
<pre className="max-h-[240px] overflow-auto rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-3 text-xs leading-relaxed text-[var(--color-text-primary)]">
<code>{text}</code>
</pre>
)
}
export function AutomationToolboxModal({ open, onClose }: AutomationToolboxModalProps) {
const { launchBaseUrl, apiAuth, launchServerReady } = useLaunchContext({ enabled: open })
const {
demoSession,
demoBusyAction,
demoBusy,
demoResponseText,
runDemoAction,
} = useAutomationDemoSession({ enabled: open, baseUrl: launchBaseUrl })
return (
<Modal open={open} onClose={onClose} title="自动化工具箱" width="1100px">
<div className="space-y-5">
<section className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"></h3>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
Smoke
</p>
</div>
<div className="mt-4">
<LaunchServerStatusBlock
launchBaseUrl={launchBaseUrl}
launchServerReady={launchServerReady}
apiAuth={apiAuth}
>
<AutomationEntryActions
onBeforeNavigate={onClose}
/>
</LaunchServerStatusBlock>
</div>
</section>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[0.92fr_1.08fr]">
<div className="space-y-4">
<section className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">Demo </h3>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">便 LaunchServer CDP </p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => void runDemoAction({
actionKey: 'health',
actionLabel: '健康检查',
runner: () => automationDemoHealthCheck(),
successMessage: '健康检查已完成',
failureMessage: '健康检查失败',
})}
loading={demoBusyAction === 'health'}
disabled={demoBusy}
>
<ShieldCheck className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => void runDemoAction({
actionKey: 'create',
actionLabel: '创建演示实例',
runner: () => automationDemoCreateProfile(),
successMessage: '演示实例已创建',
failureMessage: '演示实例创建失败',
})}
loading={demoBusyAction === 'create'}
disabled={demoBusy}
>
<PlusSquare className="h-4 w-4" />
Demo
</Button>
<Button
size="sm"
onClick={() => void runDemoAction({
actionKey: 'launch',
actionLabel: '按 Code 唤起',
runner: () => automationDemoLaunchProfile(demoSession.launchCode),
successMessage: '演示实例已唤起',
failureMessage: '演示实例唤起失败',
})}
loading={demoBusyAction === 'launch'}
disabled={demoBusy || !demoSession.launchCode}
>
<Rocket className="h-4 w-4" />
Code
</Button>
<Button
size="sm"
variant="danger"
onClick={() => void runDemoAction({
actionKey: 'delete',
actionLabel: '清理演示实例',
runner: () => automationDemoDeleteProfile(demoSession.profileId),
successMessage: '演示实例已清理',
failureMessage: '演示实例清理失败',
})}
loading={demoBusyAction === 'delete'}
disabled={demoBusy || !demoSession.profileId}
>
<Trash2 className="h-4 w-4" />
Demo
</Button>
</div>
<div className="mt-4 rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-4 py-3 text-sm text-[var(--color-text-secondary)]">
<div><code>{demoSession.lastAction || '-'}</code></div>
<div>Profile ID<code>{demoSession.profileId || '-'}</code></div>
<div>Launch Code<code>{demoSession.launchCode || '-'}</code></div>
<div className="break-all">CDP URL<code>{demoSession.cdpUrl || '-'}</code></div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => void copyToClipboard(demoSession.launchCode, 'Launch Code 已复制')}
disabled={!demoSession.launchCode}
>
<Copy className="h-3.5 w-3.5" />
Launch Code
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void copyToClipboard(demoSession.cdpUrl, 'CDP URL 已复制')}
disabled={!demoSession.cdpUrl}
>
<Copy className="h-3.5 w-3.5" />
CDP URL
</Button>
</div>
</section>
</div>
<section className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"></h3>
<p className="mt-1 text-xs text-[var(--color-text-muted)]"></p>
</div>
{demoResponseText && (
<Button size="sm" variant="secondary" onClick={() => void copyToClipboard(demoResponseText, '响应 JSON 已复制')}>
<Copy className="h-3.5 w-3.5" />
JSON
</Button>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge variant={demoSession.lastResult?.ok ? 'success' : demoSession.lastResult ? 'error' : 'default'} size="sm" dot>
{demoSession.lastResult ? (demoSession.lastResult.ok ? '请求成功' : '请求失败') : '暂无请求'}
</Badge>
{demoSession.lastResult && (
<Badge variant="default" size="sm">
HTTP {demoSession.lastResult.status || '-'}
</Badge>
)}
{demoSession.lastResult?.method && demoSession.lastResult?.path && (
<Badge variant="default" size="sm">
{demoSession.lastResult.method} {demoSession.lastResult.path}
</Badge>
)}
</div>
{demoSession.lastResult?.error && (
<p className="mt-3 break-all text-sm text-[var(--color-error)]">{demoSession.lastResult.error}</p>
)}
<div className="mt-4">
<JsonPreview text={demoResponseText} />
</div>
</section>
</div>
</div>
</Modal>
)
}
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Copy, Key, Play, RotateCcw, Settings, Square, Trash2 } from 'lucide-react'
import { Copy, Key, Play, Puzzle, RotateCcw, Settings, Square, Trash2 } from 'lucide-react'
import { Badge, Button, Card, Table } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
@@ -35,6 +35,7 @@ interface BrowserProfilesPanelProps {
onStop: (profileId: string) => void
onRestart: (profileId: string) => void
onOpenKeywords: (profile: BrowserProfile) => void
onOpenExtensions: (profile: BrowserProfile) => void
onOpenCopy: (profile: BrowserProfile) => void
onDelete: (profileId: string) => void
}
@@ -74,6 +75,7 @@ function BrowserProfileCard({
onStop,
onRestart,
onOpenKeywords,
onOpenExtensions,
onOpenCopy,
onDelete,
}: {
@@ -91,6 +93,7 @@ function BrowserProfileCard({
onStop: (profileId: string) => void
onRestart: (profileId: string) => void
onOpenKeywords: (profile: BrowserProfile) => void
onOpenExtensions: (profile: BrowserProfile) => void
onOpenCopy: (profile: BrowserProfile) => void
onDelete: (profileId: string) => void
}) {
@@ -139,6 +142,7 @@ function BrowserProfileCard({
<span className="w-px h-4 bg-[var(--color-border-muted)] mx-1"></span>
<Button size="sm" variant="ghost" onClick={() => onRestart(profile.profileId)} title="重启" className="px-3" disabled={isBusy}><RotateCcw className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onOpenKeywords(profile)} title="关键字管理" className="px-3" disabled={isBusy}><Key className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onOpenExtensions(profile)} title="插件配置" className="px-3" disabled={isBusy}><Puzzle className="w-4 h-4 mr-1.5" /></Button>
<Link to={`/browser/edit/${profile.profileId}`}><Button size="sm" variant="ghost" title="配置" className="px-3" disabled={isBusy}><Settings className="w-4 h-4 mr-1.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => onOpenCopy(profile)} title="克隆" className="px-3" disabled={isBusy}><Copy className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(profile.profileId)} title="删除" className="px-3 text-red-500 hover:text-red-600 hover:bg-red-50" disabled={isBusy}><Trash2 className="w-4 h-4 mr-1.5" /></Button>
@@ -196,6 +200,7 @@ export function BrowserProfilesPanel({
onStop,
onRestart,
onOpenKeywords,
onOpenExtensions,
onOpenCopy,
onDelete,
}: BrowserProfilesPanelProps) {
@@ -292,7 +297,7 @@ export function BrowserProfilesPanel({
{
key: 'actions',
title: '操作',
width: 252,
width: 292,
align: 'right',
render: (_, record) => {
const isStarting = isProfileStarting(record.profileId)
@@ -312,6 +317,7 @@ export function BrowserProfilesPanel({
)}
<Button size="sm" variant="ghost" onClick={() => onRestart(record.profileId)} title="重启" disabled={isBusy}><RotateCcw className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onOpenKeywords(record)} title="关键字" disabled={isBusy}><Key className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onOpenExtensions(record)} title="插件" disabled={isBusy}><Puzzle className="w-3.5 h-3.5" /></Button>
<Link to={`/browser/edit/${record.profileId}`}><Button size="sm" variant="ghost" title="配置" disabled={isBusy}><Settings className="w-3.5 h-3.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => onOpenCopy(record)} title="克隆" disabled={isBusy}><Copy className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(record.profileId)} title="删除" disabled={isBusy}><Trash2 className="w-3.5 h-3.5 text-red-500" /></Button>
@@ -353,6 +359,7 @@ export function BrowserProfilesPanel({
onStop={onStop}
onRestart={onRestart}
onOpenKeywords={onOpenKeywords}
onOpenExtensions={onOpenExtensions}
onOpenCopy={onOpenCopy}
onDelete={onDelete}
/>
@@ -1,6 +1,6 @@
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 { Button, Card, FormItem, Input, Modal, Select, Switch, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserCore, BrowserCoreInput, BrowserSettings } from '../types'
import {
@@ -122,6 +122,16 @@ export function BrowserSettingsModal({ open, onClose, settings: initSettings, co
</div>
<Card padding="none"><Table columns={coreColumns} data={cores} rowKey="coreId" /></Card>
</div>
<FormItem label="代理内核">
<Select
value={settings.defaultConnectorType || 'xray'}
onChange={e => setSettings(p => ({ ...p, defaultConnectorType: e.target.value }))}
options={[
{ value: 'xray', label: 'Xray' },
{ value: 'mihomo', label: 'Mihomo' },
]}
/>
</FormItem>
<FormItem label="用户数据根目录">
<Input value={settings.userDataRoot} onChange={e => setSettings(p => ({ ...p, userDataRoot: e.target.value }))} placeholder="data" />
</FormItem>
@@ -0,0 +1,121 @@
import { useEffect, useMemo, useState } from 'react'
import { Button, Modal, toast } from '../../../shared/components'
import type { BrowserExtension, BrowserProfile } from '../types'
import {
fetchBrowserExtensions,
fetchBrowserProfileExtensionSettings,
saveBrowserProfileExtensionSettings,
} from '../api/extensions'
interface ProfileExtensionModalProps {
open: boolean
profile: BrowserProfile | null
onClose: () => void
}
export function ProfileExtensionModal({ open, profile, onClose }: ProfileExtensionModalProps) {
const [extensions, setExtensions] = useState<BrowserExtension[]>([])
const [configured, setConfigured] = useState(false)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
useEffect(() => {
if (!open || !profile) return
setLoading(true)
Promise.all([
fetchBrowserExtensions(),
fetchBrowserProfileExtensionSettings(profile.profileId),
]).then(([extensionItems, settings]) => {
setExtensions(extensionItems)
setConfigured(settings.configured)
setSelectedIds(settings.extensionIds)
}).catch((error: any) => {
toast.error(error?.message || '加载实例插件配置失败')
}).finally(() => setLoading(false))
}, [open, profile])
const toggleExtension = (extensionId: string, checked: boolean) => {
setSelectedIds((current) => {
if (checked) return current.includes(extensionId) ? current : [...current, extensionId]
return current.filter((item) => item !== extensionId)
})
}
const handleSave = async () => {
if (!profile) return
setSaving(true)
try {
await saveBrowserProfileExtensionSettings(profile.profileId, selectedIds, configured)
toast.success('实例插件配置已保存')
onClose()
} catch (error: any) {
toast.error(error?.message || '保存实例插件配置失败')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={profile ? `插件配置:${profile.profileName}` : '插件配置'}
width="640px"
footer={(
<>
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSave} loading={saving} disabled={loading}></Button>
</>
)}
>
<div className="space-y-3">
<label className="flex items-center justify-between rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-3 py-2">
<span className="text-sm text-[var(--color-text-primary)]"></span>
<input
type="checkbox"
checked={configured}
onChange={(event) => setConfigured(event.target.checked)}
className="h-4 w-4 rounded accent-[var(--color-accent)]"
/>
</label>
{!configured ? (
<div className="rounded-xl border border-dashed border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-4 py-5 text-sm text-[var(--color-text-muted)]">
</div>
) : null}
<div className="max-h-[360px] space-y-2 overflow-auto pr-1">
{extensions.map((extension) => (
<label key={extension.extensionId} className={`flex items-start gap-3 rounded-xl border border-[var(--color-border-default)] px-3 py-2 ${configured ? 'bg-[var(--color-bg-surface)]' : 'bg-[var(--color-bg-muted)] opacity-70'}`}>
<input
type="checkbox"
disabled={!configured}
checked={selectedSet.has(extension.extensionId)}
onChange={(event) => toggleExtension(extension.extensionId, event.target.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-[var(--color-text-primary)]">
<span>{extension.name || extension.extensionId}</span>
{extension.version ? <span className="text-xs font-normal text-[var(--color-text-muted)]">v{extension.version}</span> : null}
{!extension.enabled ? <span className="rounded bg-[var(--color-bg-muted)] px-1.5 py-0.5 text-xs text-[var(--color-text-muted)]"></span> : null}
</div>
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-muted)]">{extension.extensionId}</div>
</div>
</label>
))}
{extensions.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]">
</div>
) : null}
</div>
</div>
</Modal>
)
}
@@ -13,13 +13,15 @@ import { ALL_GROUP, BATCH_TEST_CONCURRENCY, DIRECT_PROXY_ID, INITIAL_CHAIN_EDIT_
interface ProxyPickerModalProps {
open: boolean
currentProxyId: string
title?: string
onSelect: (proxy: BrowserProxy) => void
onClose: () => void
onProxyListUpdated?: (proxies: BrowserProxy[]) => void
onProxyDeleted?: (deletedProxyId: string, nextProxies: BrowserProxy[]) => void
onProxyTested?: (proxyId: string, result: SpeedResult) => void
}
export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onProxyListUpdated, onProxyDeleted }: ProxyPickerModalProps) {
export function ProxyPickerModal({ open, currentProxyId, title = '从代理池选择', onSelect, onClose, onProxyListUpdated, onProxyDeleted, onProxyTested }: ProxyPickerModalProps) {
const [groups, setGroups] = useState<string[]>([])
const [allProxies, setAllProxies] = useState<BrowserProxy[]>([])
const [selectedGroup, setSelectedGroup] = useState<string>(ALL_GROUP)
@@ -141,10 +143,12 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
try {
const result = await browserProxyTestSpeed(proxyId)
if (!abortRef.current) {
const nextResult = { ok: result.ok, latencyMs: result.latencyMs, error: result.error }
setSpeedMap(prev => ({
...prev,
[proxyId]: { ok: result.ok, latencyMs: result.latencyMs, error: result.error },
[proxyId]: nextResult,
}))
onProxyTested?.(proxyId, nextResult)
}
} finally {
setTestingIds(prev => {
@@ -165,10 +169,12 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
const off = EventsOn(SPEED_RESULT_EVENT, (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
if (abortRef.current || !idSet.has(data.proxyId)) return
const nextResult = { ok: data.ok, latencyMs: data.latencyMs, error: data.error }
setSpeedMap(prev => ({
...prev,
[data.proxyId]: { ok: data.ok, latencyMs: data.latencyMs, error: data.error },
[data.proxyId]: nextResult,
}))
onProxyTested?.(data.proxyId, nextResult)
setTestingIds(prev => {
const next = new Set(prev)
next.delete(data.proxyId)
@@ -191,7 +197,9 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
current.latencyMs !== result.latencyMs ||
current.error !== result.error
) {
next[result.proxyId] = { ok: result.ok, latencyMs: result.latencyMs, error: result.error }
const nextResult = { ok: result.ok, latencyMs: result.latencyMs, error: result.error }
next[result.proxyId] = nextResult
onProxyTested?.(result.proxyId, nextResult)
changed = true
}
})
@@ -331,7 +339,7 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
<span className="font-semibold text-[var(--color-text-primary)]"></span>
<span className="font-semibold text-[var(--color-text-primary)]">{title}</span>
<button onClick={onClose} className="text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors">
<X className="w-4 h-4" />
</button>
@@ -8,8 +8,10 @@ interface AutomationCardsSectionProps {
loading: boolean;
cards: AutomationCardPresentation[];
scripts: AutomationScriptRecord[];
selectedScriptIds: string[];
onCreate: () => void;
onImport: () => void;
onToggleScriptSelection: (scriptId: string, selected: boolean) => void;
onOpenScript: (scriptId: string) => void;
onRunAutomationScript: (script: AutomationScriptRecord) => void;
onOpenPublicApi: (script: AutomationScriptRecord, options?: { focusTest?: boolean }) => void;
@@ -19,13 +21,16 @@ export function AutomationCardsSection({
loading,
cards,
scripts,
selectedScriptIds,
onCreate,
onImport,
onToggleScriptSelection,
onOpenScript,
onRunAutomationScript,
onOpenPublicApi,
}: AutomationCardsSectionProps) {
const scriptMap = new Map(scripts.map((script) => [script.id, script]));
const selectedScriptIdSet = new Set(selectedScriptIds);
return (
<section className="rounded-[28px] border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] p-3 shadow-[var(--shadow-sm)] md:p-4">
@@ -88,6 +93,12 @@ export function AutomationCardsSection({
onOpen={onOpen}
onRunScript={runScriptAction}
onRunAPI={onRunAPI}
selected={scriptId ? selectedScriptIdSet.has(scriptId) : false}
onSelectedChange={
scriptId
? (selected) => onToggleScriptSelection(scriptId, selected)
: undefined
}
/>
</div>
);
@@ -4,8 +4,9 @@ import { toast } from "../../../shared/components";
import { AutomationScriptHistoryModal } from "../components/AutomationScriptHistoryModal";
import { AutomationScriptPublicApiModal } from "../components/AutomationScriptPublicApiModal";
import { AutomationScriptRunModal } from "../components/AutomationScriptRunModal";
import { AutomationToolboxModal } from "../components/AutomationToolboxModal";
import {
exportAutomationScriptsBatchZip,
importAutomationScriptFromGit,
importAutomationScriptFromLocalDirectory,
importAutomationScriptFromLocalFile,
@@ -42,7 +43,6 @@ export function AutomationPage() {
const { launchBaseUrl, apiAuth } = useLaunchContext();
const { scripts, setScripts, profiles, loading, refreshing, handleRefresh } = useAutomationPageData();
const [historyOpen, setHistoryOpen] = useState(false);
const [toolboxOpen, setToolboxOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [runModalOpen, setRunModalOpen] = useState(false);
@@ -62,15 +62,51 @@ export function AutomationPage() {
const [gitURL, setGitURL] = useState("");
const [gitRef, setGitRef] = useState("");
const [gitScriptPath, setGitScriptPath] = useState("");
const [busyAction, setBusyAction] = useState<"none" | "create" | "import">(
const [busyAction, setBusyAction] = useState<"none" | "create" | "import" | "export">(
"none",
);
const [selectedScriptIds, setSelectedScriptIds] = useState<string[]>([]);
const openScript = (scriptId: string) => {
navigate(`/browser/automation/${scriptId}`);
};
const handleToggleScriptSelection = (scriptId: string, selected: boolean) => {
setSelectedScriptIds((current) => {
if (selected) {
return current.includes(scriptId) ? current : [...current, scriptId];
}
return current.filter((item) => item !== scriptId);
});
};
const handleExportSelectedScripts = async () => {
const exportScriptIds = selectedScriptIds.filter((scriptId) =>
scripts.some((script) => script.id === scriptId),
);
if (exportScriptIds.length === 0) {
toast.warning("请先勾选要导出的脚本");
return;
}
setBusyAction("export");
try {
const result = await exportAutomationScriptsBatchZip(exportScriptIds);
if (!result.cancelled) {
toast.success(`已导出 ${exportScriptIds.length} 个脚本`);
setSelectedScriptIds([]);
} else {
toast.warning("已取消导出");
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "脚本导出失败";
toast.error(message);
} finally {
setBusyAction("none");
}
};
const handleOpenRunModal = (script: AutomationScriptRecord) => {
setActiveRunScript(script);
setRunModalOpen(true);
@@ -327,23 +363,30 @@ export function AutomationPage() {
}),
...scriptCards,
];
const modalBusyAction =
busyAction === "export" ? "none" : busyAction;
return (
<div className="space-y-5 animate-fade-in">
<AutomationPageHeader
refreshing={refreshing}
exporting={busyAction === "export"}
selectedCount={selectedScriptIds.length}
onRefresh={() => void handleRefresh()}
onCreate={() => setCreateOpen(true)}
onExportSelected={() => void handleExportSelectedScripts()}
onImport={() => setImportOpen(true)}
onOpenHistory={() => setHistoryOpen(true)}
onOpenToolbox={() => setToolboxOpen(true)}
/>
<AutomationCardsSection
loading={loading}
cards={cards}
scripts={scripts}
selectedScriptIds={selectedScriptIds}
onCreate={() => setCreateOpen(true)}
onImport={() => setImportOpen(true)}
onToggleScriptSelection={handleToggleScriptSelection}
onOpenScript={openScript}
onRunAutomationScript={handleOpenRunModal}
onOpenPublicApi={handleOpenPublicApiModal}
@@ -351,7 +394,7 @@ export function AutomationPage() {
<CreateAutomationScriptModal
open={createOpen}
busyAction={busyAction}
busyAction={modalBusyAction}
createName={createName}
createType={createType}
onClose={closeCreateModal}
@@ -362,7 +405,7 @@ export function AutomationPage() {
<ImportAutomationScriptModal
open={importOpen}
busyAction={busyAction}
busyAction={modalBusyAction}
importMode={importMode}
importText={importText}
remoteURL={remoteURL}
@@ -379,10 +422,6 @@ export function AutomationPage() {
onGitScriptPathChange={setGitScriptPath}
/>
<AutomationToolboxModal
open={toolboxOpen}
onClose={() => setToolboxOpen(false)}
/>
<AutomationScriptRunModal
open={runModalOpen}
script={activeRunScript}
@@ -1,22 +1,26 @@
import { History, PlusSquare, RefreshCw, Upload, Wrench } from "lucide-react";
import { Download, History, PlusSquare, RefreshCw, Upload } from "lucide-react";
import { Button } from "../../../shared/components";
interface AutomationPageHeaderProps {
refreshing: boolean;
exporting: boolean;
selectedCount: number;
onRefresh: () => void;
onCreate: () => void;
onExportSelected: () => void;
onImport: () => void;
onOpenHistory: () => void;
onOpenToolbox: () => void;
}
export function AutomationPageHeader({
refreshing,
exporting,
selectedCount,
onRefresh,
onCreate,
onExportSelected,
onImport,
onOpenHistory,
onOpenToolbox,
}: AutomationPageHeaderProps) {
return (
<div className="flex items-center justify-between gap-3 flex-wrap">
@@ -37,6 +41,16 @@ export function AutomationPageHeader({
<PlusSquare className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={onExportSelected}
loading={exporting}
disabled={selectedCount === 0}
>
<Download className="h-4 w-4" />
{selectedCount > 0 ? `导出 ${selectedCount}` : "导出脚本"}
</Button>
<Button
size="sm"
variant="secondary"
@@ -53,14 +67,6 @@ export function AutomationPageHeader({
<History className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={onOpenToolbox}
>
<Wrench className="h-4 w-4" />
</Button>
</div>
</div>
@@ -28,13 +28,18 @@ export function AutomationScriptSummaryCard({
onOpen,
onRunScript,
onRunAPI,
selected = false,
onSelectedChange,
}: {
card: AutomationCardPresentation;
onOpen?: () => void;
onRunScript?: () => void;
onRunAPI?: () => void;
selected?: boolean;
onSelectedChange?: (selected: boolean) => void;
}) {
const interactive = typeof onOpen === "function";
const selectable = typeof onSelectedChange === "function";
const isInterfaceModeCard = card.scriptType === "launch-api";
const actionButtonClassName =
"!h-7 !w-full min-w-0 justify-center whitespace-nowrap !rounded-md !border !border-black !bg-black !px-2 !text-xs !font-medium !leading-none !text-white !shadow-none hover:!border-[#1f1f1f] hover:!bg-[#1f1f1f] focus-visible:!ring-black disabled:!border-[#6b7280] disabled:!bg-[#6b7280] disabled:!text-white";
@@ -49,35 +54,58 @@ export function AutomationScriptSummaryCard({
const editButtonClassName =
actionButtonClassName;
const cardClickable = selectable || interactive;
const handleCardClick = () => {
if (selectable) {
onSelectedChange?.(!selected);
return;
}
onOpen?.();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!interactive || !onOpen) {
if (!cardClickable) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
handleCardClick();
}
};
return (
<div
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? onOpen : undefined}
onKeyDown={interactive ? handleKeyDown : undefined}
role={selectable ? "checkbox" : interactive ? "button" : undefined}
aria-checked={selectable ? selected : undefined}
tabIndex={cardClickable ? 0 : undefined}
onClick={cardClickable ? handleCardClick : undefined}
onKeyDown={cardClickable ? handleKeyDown : undefined}
className={`group relative flex h-full flex-col rounded-[22px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] pb-3 pl-7 pr-3.5 pt-3 text-left shadow-[var(--shadow-xs)] transition-all duration-200 ${
interactive
cardClickable
? "cursor-pointer hover:border-[var(--color-border-strong)] hover:shadow-[var(--shadow-md)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2"
: ""
}`}
} ${selected ? "border-[var(--color-border-strong)] ring-2 ring-[var(--color-border-strong)] ring-offset-1" : ""}`}
>
<div
aria-hidden="true"
className={`absolute bottom-3 left-3 top-3 w-1 rounded-full ${card.railClassName}`}
/>
<div className="min-w-0 text-[16px] font-semibold leading-5 text-[var(--color-text-primary)]">
{card.title}
<div className="flex min-w-0 items-start gap-2">
{selectable ? (
<input
type="checkbox"
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) => onSelectedChange?.(event.currentTarget.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--color-border-strong)] text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
aria-label={`选择 ${card.title}`}
/>
) : null}
<div className="min-w-0 text-[16px] font-semibold leading-5 text-[var(--color-text-primary)]">
{card.title}
</div>
</div>
<div
@@ -4,6 +4,7 @@ import type { BrowserProfile, BrowserProfileCopyOptions } from '../types'
import { BrowserCoreEditorModal, BrowserListHeader, BrowserListSettingsModal } from '../components/BrowserListLayout'
import { BatchToolbar } from '../components/BrowserListWidgets'
import { BrowserProfilesPanel } from '../components/BrowserProfilesPanel'
import { ProfileExtensionModal } from '../components/ProfileExtensionModal'
import { createBrowserProfileCopyOptions, isBrowserProfileCopyOptionsValid } from '../copyOptions'
import { buildBrowserProfileCopyName } from '../copyName'
import { resolveActionFeedback } from '../utils/actionErrors'
@@ -45,6 +46,10 @@ export function BrowserListPage() {
const openKwModal = (profile: BrowserProfile) => setKwModal({ open: true, profile })
const closeKwModal = () => setKwModal({ open: false, profile: null })
const [extensionModal, setExtensionModal] = useState<{ open: boolean; profile: BrowserProfile | null }>({ open: false, profile: null })
const openExtensionModal = (profile: BrowserProfile) => setExtensionModal({ open: true, profile })
const closeExtensionModal = () => setExtensionModal({ open: false, profile: null })
// 复制弹窗
const [copyModal, setCopyModal] = useState<{ open: boolean; profile: BrowserProfile | null }>({ open: false, profile: null })
const [copyName, setCopyName] = useState('')
@@ -318,10 +323,17 @@ export function BrowserListPage() {
onStop={(profileId) => { void handleStop(profileId) }}
onRestart={(profileId) => { void handleRestart(profileId) }}
onOpenKeywords={openKwModal}
onOpenExtensions={openExtensionModal}
onOpenCopy={openCopyModal}
onDelete={(profileId) => { void handleDelete(profileId) }}
/>
<ProfileExtensionModal
open={extensionModal.open}
profile={extensionModal.profile}
onClose={closeExtensionModal}
/>
<BrowserListSettingsModal
open={settingsModalOpen}
settings={settings}
@@ -27,6 +27,7 @@ export function CoreManagementPage() {
restoreLastSession: false,
startReadyTimeoutMs: 3000,
startStableWindowMs: 1200,
defaultConnectorType: 'xray',
})
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
const [settingsForm, setSettingsForm] = useState<CoreSettingsForm>({
@@ -373,6 +374,7 @@ export function CoreManagementPage() {
restoreLastSession: settingsForm.restoreLastSession,
startReadyTimeoutMs: Math.max(1000, Number(settingsForm.startReadyTimeoutMs) || 3000),
startStableWindowMs: Math.max(0, Number(settingsForm.startStableWindowMs) || 1200),
defaultConnectorType: settings.defaultConnectorType || 'xray',
}
await saveBrowserSettings(newSettings)
setSettings(newSettings)
@@ -0,0 +1,301 @@
import { Download, ExternalLink, FolderOpen, History, Power, Puzzle, RefreshCw, RotateCw, Search, Settings, Trash2, Users } from 'lucide-react'
import { Button, Card, Input } from '../../../shared/components'
import type { BrowserExtension, BrowserExtensionLookupResult, BrowserProxy } from '../types'
import { extensionStoreURL, formatExtensionSource, formatExtensionTime, getExtensionManifestMeta, getProxySpeedState } from './extensionManagementUtils'
export function ProxyStatePill({ useProxy, proxy }: { useProxy: boolean; proxy?: BrowserProxy }) {
if (!useProxy) {
return <span className="rounded-full bg-[var(--color-bg-muted)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]"></span>
}
if (!proxy) {
return <span className="rounded-full bg-red-50 px-2 py-0.5 text-xs text-red-600"></span>
}
const state = getProxySpeedState(proxy)
const status = state?.ok ? `${state.latencyMs}ms` : state ? '不可用' : '未测试'
return (
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs text-green-700">
使{proxy.proxyName || proxy.proxyId} · {status}
</span>
)
}
export interface ExtensionManagementHeaderProps {
proxyButtonText: string
loading: boolean
importing: 'none' | 'file' | 'directory'
downloadDirectoryLoading: boolean
onOpenProxy: () => void
onOpenHistory: () => void
onImportFile: () => void
onImportDirectory: () => void
onOpenDownloadDirectory: () => void
onRefresh: () => void
}
export function ExtensionManagementHeader({
proxyButtonText,
loading,
importing,
downloadDirectoryLoading,
onOpenProxy,
onOpenHistory,
onImportFile,
onImportDirectory,
onOpenDownloadDirectory,
onRefresh,
}: ExtensionManagementHeaderProps) {
return (
<div className="flex items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onOpenProxy}>
<Settings className="h-4 w-4" />
{proxyButtonText}
</Button>
<Button size="sm" variant="secondary" onClick={onOpenHistory}>
<History className="h-4 w-4" />
</Button>
<Button size="sm" variant="secondary" onClick={onImportFile} loading={importing === 'file'}>
<Download className="h-4 w-4" />
</Button>
<Button size="sm" variant="secondary" onClick={onImportDirectory} loading={importing === 'directory'}>
<Download className="h-4 w-4" />
</Button>
<Button size="sm" variant="secondary" onClick={onOpenDownloadDirectory} loading={downloadDirectoryLoading}>
<FolderOpen className="h-4 w-4" />
</Button>
<Button size="sm" variant="secondary" onClick={onRefresh} loading={loading}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
)
}
export interface ExtensionInstallCardProps {
query: string
lookup: BrowserExtensionLookupResult | null
querying: boolean
installing: boolean
useProxy: boolean
selectedProxy?: BrowserProxy
installedIds: Set<string>
lastLookupProxyLabel: string
onQueryChange: (value: string) => void
onLookup: () => void
onOpenWebStoreQuery: () => void
onOpenManualInstall: () => void
onOpenProxy: () => void
onInstall: () => void
}
export function ExtensionInstallCard({
query,
lookup,
querying,
installing,
useProxy,
selectedProxy,
installedIds,
lastLookupProxyLabel,
onQueryChange,
onLookup,
onOpenWebStoreQuery,
onOpenManualInstall,
onOpenProxy,
onInstall,
}: ExtensionInstallCardProps) {
return (
<Card>
<div className="flex flex-col gap-3 md:flex-row">
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onLookup()
}}
placeholder="Chrome Web Store 链接或 32 位插件 ID"
className="flex-1"
/>
<Button type="button" variant="secondary" onClick={onLookup} loading={querying}>
<Search className="h-4 w-4" />
</Button>
<Button type="button" variant="secondary" onClick={onOpenWebStoreQuery}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button type="button" variant="secondary" onClick={onOpenManualInstall}>
<Download className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<ProxyStatePill useProxy={useProxy} proxy={selectedProxy} />
<Button type="button" size="sm" variant="ghost" onClick={onOpenProxy}>
</Button>
</div>
{lookup ? (
<div className="mt-3 flex flex-col gap-3 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-muted)] p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 font-medium text-[var(--color-text-primary)]">
<span>{lookup.name || lookup.extensionId}</span>
{lookup.version ? <span className="text-xs font-normal text-[var(--color-text-muted)]">v{lookup.version}</span> : null}
</div>
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-muted)]">{lookup.extensionId}</div>
{lastLookupProxyLabel ? <div className="mt-1 text-xs text-[var(--color-text-muted)]">{lastLookupProxyLabel}</div> : null}
{lookup.description ? <div className="mt-1 line-clamp-2 text-xs text-[var(--color-text-muted)]">{lookup.description}</div> : null}
{lookup.message ? <div className="mt-1 text-xs text-[var(--color-text-muted)]">{lookup.message}</div> : null}
</div>
<div className="flex shrink-0 gap-2">
{lookup.storeUrl ? (
<Button type="button" size="sm" variant="secondary" onClick={() => window.open(lookup.storeUrl, '_blank')}>
<ExternalLink className="h-4 w-4" />
</Button>
) : null}
<Button
type="button"
size="sm"
onClick={onInstall}
loading={installing}
disabled={!lookup.installable || installedIds.has(lookup.extensionId)}
>
<Download className="h-4 w-4" />
{installedIds.has(lookup.extensionId) ? '已安装' : '安装'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onOpenManualInstall}>
</Button>
</div>
</div>
) : null}
</Card>
)
}
export interface InstalledExtensionsListProps {
items: BrowserExtension[]
busyId: string
updatingId: string
onRestrictProfiles: (item: BrowserExtension) => void
onUpdate: (item: BrowserExtension) => void
onToggle: (item: BrowserExtension) => void
onDelete: (item: BrowserExtension) => void
}
export function InstalledExtensionsList({ items, busyId, updatingId, onRestrictProfiles, onUpdate, onToggle, onDelete }: InstalledExtensionsListProps) {
return (
<Card>
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{items.length}</div>
</div>
<div className="space-y-2">
{items.map((item) => (
<InstalledExtensionCard
key={item.extensionId}
item={item}
busy={busyId === item.extensionId}
updating={updatingId === item.extensionId}
onRestrictProfiles={onRestrictProfiles}
onUpdate={onUpdate}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
ID
</div>
) : null}
</div>
</Card>
)
}
export interface InstalledExtensionCardProps {
item: BrowserExtension
busy: boolean
updating: boolean
onRestrictProfiles: (item: BrowserExtension) => void
onUpdate: (item: BrowserExtension) => void
onToggle: (item: BrowserExtension) => void
onDelete: (item: BrowserExtension) => void
}
export function InstalledExtensionCard({ item, busy, updating, onRestrictProfiles, onUpdate, onToggle, onDelete }: InstalledExtensionCardProps) {
const meta = getExtensionManifestMeta(item)
const storeUrl = extensionStoreURL(item)
return (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-3 shadow-[var(--shadow-xs)]">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-muted)]">
{item.iconDataUrl ? (
<img src={item.iconDataUrl} alt="" className="h-9 w-9 object-contain" />
) : (
<Puzzle className="h-6 w-6 text-[var(--color-text-muted)]" />
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-[var(--color-text-primary)]">{item.name || item.extensionId}</span>
<span className="rounded-full bg-[var(--color-bg-muted)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]">{item.enabled ? '已启用' : '已停用'}</span>
{item.version ? <span className="text-xs text-[var(--color-text-muted)]">v{item.version}</span> : null}
{meta.manifestVersion ? <span className="text-xs text-[var(--color-text-muted)]">MV{meta.manifestVersion}</span> : null}
</div>
{item.description ? <div className="mt-1 line-clamp-2 text-sm text-[var(--color-text-secondary)]">{item.description}</div> : null}
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--color-text-muted)]">
<span className="break-all font-mono">{item.extensionId}</span>
<span>{formatExtensionSource(item.sourceUrl)}</span>
<span>{formatExtensionTime(item.installedAt)}</span>
{item.updatedAt ? <span>{formatExtensionTime(item.updatedAt)}</span> : null}
</div>
{meta.permissions.length > 0 || meta.hostPermissionCount > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{meta.permissions.map((permission) => (
<span key={permission} className="rounded-full bg-[var(--color-bg-muted)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]">{permission}</span>
))}
{meta.hostPermissionCount > 0 ? <span className="rounded-full bg-[var(--color-bg-muted)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]"> {meta.hostPermissionCount}</span> : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 gap-2">
{storeUrl ? (
<Button type="button" size="sm" variant="secondary" onClick={() => window.open(storeUrl, '_blank')}>
<ExternalLink className="h-4 w-4" />
</Button>
) : null}
<Button type="button" size="sm" variant="secondary" onClick={() => onRestrictProfiles(item)}>
<Users className="h-4 w-4" />
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => onUpdate(item)} loading={updating}>
<RotateCw className="h-4 w-4" />
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => onToggle(item)} loading={busy}>
<Power className="h-4 w-4" />
{item.enabled ? '停用' : '启用'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => onDelete(item)} loading={busy}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,338 @@
import { useEffect, useMemo, useState } from 'react'
import { ExternalLink, Search } from 'lucide-react'
import { Button, Input, Modal, toast } from '../../../shared/components'
import type { BrowserExtension, BrowserProfile, BrowserProfileExtensionSettings } from '../types'
import { fetchBrowserProfileExtensionSettings, saveBrowserProfileExtensionSettings, type BrowserExtensionManualDownloadFile, type BrowserExtensionManualInstallGuide } from '../api/extensions'
import { fetchBrowserProfiles } from '../api/profiles'
import { extensionHistoryActionLabel, formatExtensionTime, sameStringSet, type ExtensionHistoryRecord } from './extensionManagementUtils'
export interface ExtensionProfileLimitModalProps {
open: boolean
extension: BrowserExtension | null
allExtensions: BrowserExtension[]
onClose: () => void
}
export function ExtensionProfileLimitModal({ open, extension, allExtensions, onClose }: ExtensionProfileLimitModalProps) {
const [profiles, setProfiles] = useState<BrowserProfile[]>([])
const [settingsByProfile, setSettingsByProfile] = useState<Record<string, BrowserProfileExtensionSettings>>({})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const enabledExtensionIds = useMemo(
() => allExtensions.filter((item) => item.enabled).map((item) => item.extensionId),
[allExtensions],
)
useEffect(() => {
if (!open || !extension) return
setLoading(true)
fetchBrowserProfiles().then(async (profileItems) => {
const profileSettings = await Promise.all(profileItems.map(async (profile) => ({
profile,
settings: await fetchBrowserProfileExtensionSettings(profile.profileId),
})))
const settingsMap: Record<string, BrowserProfileExtensionSettings> = {}
profileSettings.forEach(({ profile, settings }) => {
settingsMap[profile.profileId] = settings
})
setProfiles(profileItems)
setSettingsByProfile(settingsMap)
setSelectedIds(profileItems
.filter((profile) => {
const settings = settingsMap[profile.profileId]
return settings?.configured ? settings.extensionIds.includes(extension.extensionId) : extension.enabled
})
.map((profile) => profile.profileId))
}).catch((error: any) => {
toast.error(error?.message || '加载实例限制失败')
}).finally(() => setLoading(false))
}, [open, extension])
const toggleProfile = (profileId: string, checked: boolean) => {
setSelectedIds((current) => {
if (checked) return current.includes(profileId) ? current : [...current, profileId]
return current.filter((item) => item !== profileId)
})
}
const handleSave = async () => {
if (!extension) return
setSaving(true)
try {
const selected = new Set(selectedIds)
const saveTasks = profiles.map((profile) => {
const current = settingsByProfile[profile.profileId]
const baseIds = current?.configured ? current.extensionIds : enabledExtensionIds
const nextIds = selected.has(profile.profileId)
? Array.from(new Set([...baseIds, extension.extensionId]))
: baseIds.filter((extensionId) => extensionId !== extension.extensionId)
if (!current?.configured && sameStringSet(baseIds, nextIds)) return null
if (current?.configured && sameStringSet(current.extensionIds, nextIds)) return null
return saveBrowserProfileExtensionSettings(profile.profileId, nextIds, true)
}).filter((task): task is Promise<BrowserProfileExtensionSettings> => task !== null)
if (saveTasks.length > 0) await Promise.all(saveTasks)
toast.success('实例限制已保存')
onClose()
} catch (error: any) {
toast.error(error?.message || '保存实例限制失败')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={extension ? `限制实例:${extension.name || extension.extensionId}` : '限制实例'}
width="680px"
footer={(
<>
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSave} loading={saving} disabled={loading || !extension}></Button>
</>
)}
>
{loading ? (
<div className="py-8 text-center text-sm text-[var(--color-text-muted)]">...</div>
) : (
<div className="space-y-3">
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-3 py-2 text-sm text-[var(--color-text-secondary)]">
</div>
<div className="max-h-[420px] space-y-2 overflow-auto pr-1">
{profiles.map((profile) => (
<label key={profile.profileId} className="flex items-start gap-3 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 py-2">
<input
type="checkbox"
checked={selectedSet.has(profile.profileId)}
onChange={(event) => toggleProfile(profile.profileId, event.target.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-[var(--color-text-primary)]">
<span>{profile.profileName || profile.profileId}</span>
{profile.running ? <span className="rounded bg-green-50 px-1.5 py-0.5 text-xs text-green-700"></span> : null}
{settingsByProfile[profile.profileId]?.configured ? <span className="rounded bg-[var(--color-bg-muted)] px-1.5 py-0.5 text-xs font-normal text-[var(--color-text-muted)]"></span> : null}
</div>
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-muted)]">{profile.profileId}</div>
</div>
</label>
))}
{profiles.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
</div>
) : null}
</div>
</div>
)}
</Modal>
)
}
export interface ExtensionHistoryModalProps {
open: boolean
records: ExtensionHistoryRecord[]
onClose: () => void
onPick: (record: ExtensionHistoryRecord) => void
onClear: () => void
}
export function ExtensionHistoryModal({ open, records, onClose, onPick, onClear }: ExtensionHistoryModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title="插件历史"
width="760px"
footer={(
<>
<Button variant="secondary" onClick={onClear} disabled={records.length === 0}></Button>
<Button onClick={onClose}></Button>
</>
)}
>
<div className="max-h-[520px] overflow-y-auto">
{records.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
</div>
) : (
<div className="space-y-2">
{records.map((record) => (
<div key={record.id} className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-[var(--color-bg-muted)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]">{extensionHistoryActionLabel(record.action)}</span>
<span className={record.ok ? 'text-xs text-green-600' : 'text-xs text-red-500'}>{record.ok ? '成功' : '失败'}</span>
<span className="text-xs text-[var(--color-text-muted)]">{formatExtensionTime(record.createdAt)}</span>
</div>
<div className="mt-1 truncate text-sm font-medium text-[var(--color-text-primary)]">{record.name || record.extensionId || record.query}</div>
{record.extensionId ? <div className="mt-1 break-all font-mono text-xs text-[var(--color-text-muted)]">{record.extensionId}</div> : null}
{record.proxyLabel ? <div className="mt-1 text-xs text-[var(--color-text-muted)]">{record.proxyLabel}</div> : null}
{record.message ? <div className="mt-1 line-clamp-2 text-xs text-[var(--color-text-muted)]">{record.message}</div> : null}
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => onPick(record)}>
<Search className="h-4 w-4" />
使
</Button>
{record.storeUrl ? (
<Button type="button" size="sm" variant="secondary" onClick={() => window.open(record.storeUrl, '_blank', 'noopener,noreferrer')}>
<ExternalLink className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
</div>
))}
</div>
)}
</div>
</Modal>
)
}
export interface ManualInstallModalProps {
open: boolean
guide: BrowserExtensionManualInstallGuide | null
files: BrowserExtensionManualDownloadFile[]
loading: boolean
fileLoading: boolean
importingFileName: string
onClose: () => void
onOpenDownloadDir: () => void
onRefreshFiles: () => void
onImportFile: (fileName: string) => void
onImportDirectory: () => void
}
function formatFileSize(sizeBytes: number): string {
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) return '0 B'
if (sizeBytes < 1024) return `${sizeBytes} B`
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`
}
export function ManualInstallModal({ open, guide, files, loading, fileLoading, importingFileName, onClose, onOpenDownloadDir, onRefreshFiles, onImportFile, onImportDirectory }: ManualInstallModalProps) {
const copyText = async (value: string, label: string) => {
if (!value) return
await navigator.clipboard?.writeText(value)
toast.success(`${label}已复制`)
}
return (
<Modal open={open} onClose={onClose} title="手动安装插件" width="720px">
{loading ? (
<div className="py-8 text-center text-sm text-[var(--color-text-muted)]">...</div>
) : guide ? (
<div className="space-y-4">
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-muted)] p-3">
<div className="text-xs text-[var(--color-text-muted)]"> ID</div>
<div className="mt-1 break-all font-mono text-sm text-[var(--color-text-primary)]">{guide.extensionId}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">1. CRX</div>
<div className="flex items-center gap-2">
<Input value={guide.downloadUrl} readOnly className="min-w-0 flex-1 font-mono text-xs" />
<Button type="button" variant="secondary" className="shrink-0 whitespace-nowrap px-3" onClick={() => copyText(guide.downloadUrl, '下载地址')}></Button>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">2. </div>
<div className="flex items-center gap-2">
<Input value={`${guide.downloadDir}\${guide.fileName}`} readOnly className="min-w-0 flex-1 font-mono text-xs" />
<Button type="button" variant="secondary" className="shrink-0 whitespace-nowrap px-3" onClick={() => copyText(guide.downloadDir, '文件夹路径')}></Button>
<Button type="button" variant="secondary" className="shrink-0 whitespace-nowrap px-3" onClick={onOpenDownloadDir}></Button>
</div>
</div>
<div className="space-y-2 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">3. </div>
<Button type="button" size="sm" variant="secondary" onClick={onRefreshFiles} loading={fileLoading}></Button>
</div>
<div className="text-xs text-[var(--color-text-muted)]"> `.crx` / `.zip` </div>
<div className="max-h-52 overflow-y-auto rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-muted)]">
{files.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-[var(--color-text-muted)]"> `.crx` / `.zip` </div>
) : files.map((file) => (
<div key={file.fileName} className="flex items-center justify-between gap-3 border-b border-[var(--color-border-default)] px-3 py-2 last:border-b-0">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-[var(--color-text-primary)]">{file.fileName}</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">{formatFileSize(file.sizeBytes)} · {file.updatedAt}</div>
</div>
<Button type="button" size="sm" onClick={() => onImportFile(file.fileName)} loading={importingFileName === file.fileName}></Button>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={onImportDirectory}></Button>
{guide.storeUrl ? (
<Button type="button" size="sm" variant="secondary" onClick={() => window.open(guide.storeUrl, '_blank', 'noopener,noreferrer')}></Button>
) : null}
</div>
</div>
</div>
) : (
<div className="py-8 text-center text-sm text-[var(--color-text-muted)]"> ID Chrome Web Store </div>
)}
</Modal>
)
}
export interface DownloadDirectoryInstallModalProps {
open: boolean
files: BrowserExtensionManualDownloadFile[]
fileLoading: boolean
importingFileName: string
onClose: () => void
onOpenDownloadDir: () => void
onRefreshFiles: () => void
onImportFile: (fileName: string) => void
}
export function DownloadDirectoryInstallModal({ open, files, fileLoading, importingFileName, onClose, onOpenDownloadDir, onRefreshFiles, onImportFile }: DownloadDirectoryInstallModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title="从下载目录安装"
width="680px"
footer={(
<>
<Button variant="secondary" onClick={onOpenDownloadDir}></Button>
<Button variant="secondary" onClick={onRefreshFiles} loading={fileLoading}></Button>
<Button onClick={onClose}></Button>
</>
)}
>
<div className="max-h-[420px] overflow-y-auto rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-muted)]">
{fileLoading ? (
<div className="px-3 py-8 text-center text-sm text-[var(--color-text-muted)]">...</div>
) : files.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-[var(--color-text-muted)]"> `.crx` / `.zip` </div>
) : files.map((file) => (
<div key={file.fileName} className="flex items-center justify-between gap-3 border-b border-[var(--color-border-default)] px-3 py-2 last:border-b-0">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-[var(--color-text-primary)]">{file.fileName}</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">{formatFileSize(file.sizeBytes)} · {file.updatedAt}</div>
</div>
<Button type="button" size="sm" onClick={() => onImportFile(file.fileName)} loading={importingFileName === file.fileName}></Button>
</div>
))}
</div>
</Modal>
)
}
@@ -0,0 +1,531 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from '../../../shared/components'
import type { BrowserExtension, BrowserExtensionLookupResult, BrowserProxy } from '../types'
import {
deleteBrowserExtension,
fetchBrowserExtensions,
getBrowserExtensionManualInstallGuide,
installBrowserExtension,
installBrowserExtensionLocalDirectory,
installBrowserExtensionLocalFile,
installBrowserExtensionManualDownloadFile,
listBrowserExtensionManualDownloadFiles,
lookupBrowserExtension,
openBrowserExtensionManualDownloadDir,
setBrowserExtensionEnabled,
type BrowserExtensionManualDownloadFile,
type BrowserExtensionManualInstallGuide,
} from '../api/extensions'
import { fetchBrowserProxies } from '../api/proxies'
import { ProxyPickerModal } from '../components/ProxyPickerModal'
import { ExtensionInstallCard, ExtensionManagementHeader, InstalledExtensionsList } from './ExtensionManagementCards'
import { DownloadDirectoryInstallModal, ExtensionHistoryModal, ExtensionProfileLimitModal, ManualInstallModal } from './ExtensionManagementModals'
import { EXTENSION_HISTORY_LIMIT, buildChromeWebStoreQueryURL, createExtensionHistoryRecord, extensionStoreURL, loadExtensionDownloadProxyPreference, loadExtensionHistory, saveExtensionDownloadProxyPreference, saveExtensionHistory, type ExtensionHistoryRecord } from './extensionManagementUtils'
export function ExtensionManagementPage() {
const [items, setItems] = useState<BrowserExtension[]>([])
const [query, setQuery] = useState('')
const [lookup, setLookup] = useState<BrowserExtensionLookupResult | null>(null)
const [loading, setLoading] = useState(false)
const [querying, setQuerying] = useState(false)
const [installing, setInstalling] = useState(false)
const [importing, setImporting] = useState<'none' | 'file' | 'directory'>('none')
const [updatingId, setUpdatingId] = useState('')
const [busyId, setBusyId] = useState('')
const [proxies, setProxies] = useState<BrowserProxy[]>([])
const [useProxy, setUseProxy] = useState(false)
const [selectedProxyId, setSelectedProxyId] = useState('')
const [proxyModalOpen, setProxyModalOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyRecords, setHistoryRecords] = useState<ExtensionHistoryRecord[]>([])
const [manualOpen, setManualOpen] = useState(false)
const [manualLoading, setManualLoading] = useState(false)
const [manualGuide, setManualGuide] = useState<BrowserExtensionManualInstallGuide | null>(null)
const [manualFiles, setManualFiles] = useState<BrowserExtensionManualDownloadFile[]>([])
const [manualFileLoading, setManualFileLoading] = useState(false)
const [manualImportingFileName, setManualImportingFileName] = useState('')
const [downloadDirOpen, setDownloadDirOpen] = useState(false)
const [lastLookupProxyLabel, setLastLookupProxyLabel] = useState('')
const [limitExtension, setLimitExtension] = useState<BrowserExtension | null>(null)
const installedIds = useMemo(() => new Set(items.map((item) => item.extensionId)), [items])
const selectedProxy = useMemo(
() => proxies.find((proxy) => proxy.proxyId === selectedProxyId),
[proxies, selectedProxyId],
)
const downloadProxyConfig = useProxy ? selectedProxy?.proxyConfig || '' : ''
const refresh = async () => {
setLoading(true)
try {
setItems(await fetchBrowserExtensions())
} catch (error: any) {
toast.error(error?.message || '加载插件失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void refresh()
setHistoryRecords(loadExtensionHistory())
}, [])
useEffect(() => {
fetchBrowserProxies().then((items) => {
const preference = loadExtensionDownloadProxyPreference()
const directProxyId = items.find((item) => item.proxyConfig === 'direct://')?.proxyId || ''
const restoredProxy = preference.proxyId ? items.find((item) => item.proxyId === preference.proxyId) : undefined
setProxies(items)
if (preference.useProxy && restoredProxy && restoredProxy.proxyConfig !== 'direct://') {
setUseProxy(true)
setSelectedProxyId(restoredProxy.proxyId)
} else {
setUseProxy(false)
setSelectedProxyId(directProxyId)
}
}).catch(() => {
setProxies([])
})
}, [])
const appendHistory = (input: Omit<ExtensionHistoryRecord, 'id' | 'createdAt'>) => {
const record = createExtensionHistoryRecord(input)
setHistoryRecords((current) => {
const next = [record, ...current].slice(0, EXTENSION_HISTORY_LIMIT)
saveExtensionHistory(next)
return next
})
}
const currentProxyLabel = () => (useProxy && selectedProxy ? `使用代理:${selectedProxy.proxyName || selectedProxy.proxyId}` : '直连')
const handleLookup = async () => {
const value = query.trim()
if (!value) {
toast.warning('请输入插件 ID 或 Chrome Web Store 链接')
return
}
if (useProxy && !downloadProxyConfig) {
toast.warning('请先选择可用的下载代理')
return
}
setQuerying(true)
try {
const result = await lookupBrowserExtension(value, downloadProxyConfig, useProxy)
setLookup(result)
const proxyLabel = currentProxyLabel()
setLastLookupProxyLabel(useProxy && selectedProxy ? proxyLabel : '直连查询')
appendHistory({
action: 'lookup',
query: value,
extensionId: result.extensionId || '',
name: result.name || '',
version: result.version || '',
storeUrl: result.storeUrl || '',
proxyLabel,
ok: result.installable,
message: result.message || '',
})
} catch (error: any) {
setLookup(null)
appendHistory({
action: 'lookup',
query: value,
extensionId: '',
name: '',
version: '',
storeUrl: buildChromeWebStoreQueryURL(value),
proxyLabel: currentProxyLabel(),
ok: false,
message: error?.message || '查询插件失败',
})
toast.error(error?.message || '查询插件失败')
} finally {
setQuerying(false)
}
}
const handleOpenWebStoreQuery = () => {
window.open(buildChromeWebStoreQueryURL(query), '_blank', 'noopener,noreferrer')
}
const refreshManualFiles = async () => {
setManualFileLoading(true)
try {
setManualFiles(await listBrowserExtensionManualDownloadFiles())
} catch (error: any) {
toast.error(error?.message || '扫描下载目录失败')
} finally {
setManualFileLoading(false)
}
}
const handleOpenManualInstall = async () => {
const target = lookup?.extensionId || query.trim()
if (!target) {
toast.warning('请输入插件 ID 或 Chrome Web Store 链接')
return
}
setManualOpen(true)
setManualLoading(true)
try {
setManualGuide(await getBrowserExtensionManualInstallGuide(target))
void refreshManualFiles()
} catch (error: any) {
setManualGuide(null)
toast.error(error?.message || '生成手动安装信息失败')
} finally {
setManualLoading(false)
}
}
const handleOpenManualDownloadDir = async () => {
try {
await openBrowserExtensionManualDownloadDir()
} catch (error: any) {
toast.error(error?.message || '打开下载目录失败')
}
}
const handleOpenDownloadDirectoryInstall = async () => {
setDownloadDirOpen(true)
try {
await openBrowserExtensionManualDownloadDir()
await refreshManualFiles()
} catch (error: any) {
toast.error(error?.message || '打开下载目录失败')
}
}
const handleImportManualFile = async (fileName: string) => {
setManualImportingFileName(fileName)
try {
const installed = await installBrowserExtensionManualDownloadFile(fileName)
appendHistory({
action: 'import',
query: fileName,
extensionId: installed.extensionId || '',
name: installed.name || '',
version: installed.version || '',
storeUrl: installed.sourceUrl || '',
proxyLabel: '手动下载目录',
ok: true,
message: '导入成功',
})
toast.success(`已导入 ${installed.name || installed.extensionId}`)
setManualOpen(false)
setDownloadDirOpen(false)
await refresh()
} catch (error: any) {
appendHistory({
action: 'import',
query: fileName,
extensionId: '',
name: '',
version: '',
storeUrl: '',
proxyLabel: '手动下载目录',
ok: false,
message: error?.message || '导入插件失败',
})
toast.error(error?.message || '导入插件失败')
} finally {
setManualImportingFileName('')
}
}
const handleInstall = async () => {
const target = lookup?.extensionId || query.trim()
if (!target) return
if (useProxy && !downloadProxyConfig) {
toast.warning('请先选择可用的下载代理')
return
}
setInstalling(true)
try {
const installed = await installBrowserExtension(target, downloadProxyConfig, useProxy)
appendHistory({
action: 'install',
query: target,
extensionId: installed.extensionId || target,
name: installed.name || '',
version: installed.version || '',
storeUrl: installed.sourceUrl || buildChromeWebStoreQueryURL(target),
proxyLabel: currentProxyLabel(),
ok: true,
message: '安装成功',
})
toast.success(`已安装 ${installed.name || installed.extensionId}`)
setLookup(null)
setLastLookupProxyLabel('')
setQuery('')
await refresh()
} catch (error: any) {
appendHistory({
action: 'install',
query: target,
extensionId: lookup?.extensionId || '',
name: lookup?.name || '',
version: lookup?.version || '',
storeUrl: lookup?.storeUrl || buildChromeWebStoreQueryURL(target),
proxyLabel: currentProxyLabel(),
ok: false,
message: error?.message || '安装插件失败',
})
toast.error(error?.message || '安装插件失败')
} finally {
setInstalling(false)
}
}
const handleImportLocal = async (mode: 'file' | 'directory') => {
setImporting(mode)
try {
const installed = mode === 'file'
? await installBrowserExtensionLocalFile()
: await installBrowserExtensionLocalDirectory()
appendHistory({
action: 'import',
query: mode === 'file' ? '本地插件包' : '本地插件目录',
extensionId: installed.extensionId || '',
name: installed.name || '',
version: installed.version || '',
storeUrl: installed.sourceUrl || '',
proxyLabel: '本地导入',
ok: true,
message: '导入成功',
})
toast.success(`已导入 ${installed.name || installed.extensionId}`)
await refresh()
} catch (error: any) {
const message = error?.message || '导入插件失败'
if (!message.includes('已取消')) {
appendHistory({
action: 'import',
query: mode === 'file' ? '本地插件包' : '本地插件目录',
extensionId: '',
name: '',
version: '',
storeUrl: '',
proxyLabel: '本地导入',
ok: false,
message,
})
}
if (!message.includes('已取消')) toast.error(message)
} finally {
setImporting('none')
}
}
const handleUpdateExtension = async (item: BrowserExtension) => {
if (useProxy && !downloadProxyConfig) {
toast.warning('请先选择可用的下载代理')
return
}
setUpdatingId(item.extensionId)
try {
const installed = await installBrowserExtension(item.extensionId, downloadProxyConfig, useProxy)
appendHistory({
action: 'install',
query: item.extensionId,
extensionId: installed.extensionId || item.extensionId,
name: installed.name || item.name || '',
version: installed.version || '',
storeUrl: installed.sourceUrl || buildChromeWebStoreQueryURL(item.extensionId),
proxyLabel: currentProxyLabel(),
ok: true,
message: item.version && installed.version && item.version !== installed.version ? `已更新 ${item.version}${installed.version}` : '更新完成',
})
toast.success(item.version && installed.version && item.version !== installed.version
? `已更新到 v${installed.version}`
: '插件已重新安装')
await refresh()
} catch (error: any) {
appendHistory({
action: 'install',
query: item.extensionId,
extensionId: item.extensionId,
name: item.name || '',
version: item.version || '',
storeUrl: extensionStoreURL(item) || buildChromeWebStoreQueryURL(item.extensionId),
proxyLabel: currentProxyLabel(),
ok: false,
message: error?.message || '更新插件失败',
})
toast.error(error?.message || '更新插件失败')
} finally {
setUpdatingId('')
}
}
const handleToggle = async (item: BrowserExtension) => {
setBusyId(item.extensionId)
try {
const updated = await setBrowserExtensionEnabled(item.extensionId, !item.enabled)
setItems((current) => current.map((entry) => entry.extensionId === updated.extensionId ? updated : entry))
} catch (error: any) {
toast.error(error?.message || '更新插件状态失败')
} finally {
setBusyId('')
}
}
const handleDelete = async (item: BrowserExtension) => {
if (!window.confirm(`删除插件「${item.name || item.extensionId}」?`)) return
setBusyId(item.extensionId)
try {
await deleteBrowserExtension(item.extensionId)
setItems((current) => current.filter((entry) => entry.extensionId !== item.extensionId))
toast.success('插件已删除')
} catch (error: any) {
toast.error(error?.message || '删除插件失败')
} finally {
setBusyId('')
}
}
const handlePickHistory = (record: ExtensionHistoryRecord) => {
setQuery(record.extensionId || record.query)
setLookup(null)
setLastLookupProxyLabel('')
setHistoryOpen(false)
}
const handleClearHistory = () => {
setHistoryRecords([])
saveExtensionHistory([])
toast.success('历史已清空')
}
const proxyButtonText = useProxy
? `下载代理:${selectedProxy?.proxyName || selectedProxyId || '未选择'}`
: '下载代理'
return (
<div className="space-y-4 animate-fade-in">
<ExtensionManagementHeader
proxyButtonText={proxyButtonText}
loading={loading}
importing={importing}
downloadDirectoryLoading={manualFileLoading && downloadDirOpen}
onOpenProxy={() => setProxyModalOpen(true)}
onOpenHistory={() => setHistoryOpen(true)}
onImportFile={() => void handleImportLocal('file')}
onImportDirectory={() => void handleImportLocal('directory')}
onOpenDownloadDirectory={() => void handleOpenDownloadDirectoryInstall()}
onRefresh={refresh}
/>
<ProxyPickerModal
open={proxyModalOpen}
title="选择下载代理"
currentProxyId={useProxy ? selectedProxyId : proxies.find((item) => item.proxyConfig === 'direct://')?.proxyId || ''}
onClose={() => setProxyModalOpen(false)}
onSelect={(proxy) => {
setProxies((current) => {
if (current.some((item) => item.proxyId === proxy.proxyId)) return current
return [...current, proxy]
})
setSelectedProxyId(proxy.proxyId)
const nextUseProxy = proxy.proxyConfig !== 'direct://'
setUseProxy(nextUseProxy)
saveExtensionDownloadProxyPreference({ useProxy: nextUseProxy, proxyId: proxy.proxyId })
}}
onProxyListUpdated={(nextProxies) => {
setProxies(nextProxies)
}}
onProxyDeleted={(deletedProxyId, nextProxies) => {
setProxies(nextProxies)
if (deletedProxyId === selectedProxyId) {
const directProxyId = nextProxies.find((item) => item.proxyConfig === 'direct://')?.proxyId || ''
setUseProxy(false)
setSelectedProxyId(directProxyId)
saveExtensionDownloadProxyPreference({ useProxy: false, proxyId: directProxyId })
}
}}
onProxyTested={(proxyId, result) => {
const testedAt = new Date().toISOString()
setProxies((current) => current.map((proxy) => (
proxy.proxyId === proxyId
? { ...proxy, lastTestOk: result.ok, lastLatencyMs: result.ok ? result.latencyMs : -1, lastTestedAt: testedAt }
: proxy
)))
}}
/>
<ExtensionHistoryModal
open={historyOpen}
records={historyRecords}
onClose={() => setHistoryOpen(false)}
onPick={handlePickHistory}
onClear={handleClearHistory}
/>
<ExtensionProfileLimitModal
open={!!limitExtension}
extension={limitExtension}
allExtensions={items}
onClose={() => setLimitExtension(null)}
/>
<ManualInstallModal
open={manualOpen}
guide={manualGuide}
files={manualFiles}
loading={manualLoading}
fileLoading={manualFileLoading}
importingFileName={manualImportingFileName}
onClose={() => setManualOpen(false)}
onOpenDownloadDir={handleOpenManualDownloadDir}
onRefreshFiles={() => void refreshManualFiles()}
onImportFile={(fileName) => void handleImportManualFile(fileName)}
onImportDirectory={() => {
setManualOpen(false)
void handleImportLocal('directory')
}}
/>
<DownloadDirectoryInstallModal
open={downloadDirOpen}
files={manualFiles}
fileLoading={manualFileLoading}
importingFileName={manualImportingFileName}
onClose={() => setDownloadDirOpen(false)}
onOpenDownloadDir={handleOpenManualDownloadDir}
onRefreshFiles={() => void refreshManualFiles()}
onImportFile={(fileName) => void handleImportManualFile(fileName)}
/>
<ExtensionInstallCard
query={query}
lookup={lookup}
querying={querying}
installing={installing}
useProxy={useProxy}
selectedProxy={selectedProxy}
installedIds={installedIds}
lastLookupProxyLabel={lastLookupProxyLabel}
onQueryChange={setQuery}
onLookup={() => void handleLookup()}
onOpenWebStoreQuery={handleOpenWebStoreQuery}
onOpenManualInstall={handleOpenManualInstall}
onOpenProxy={() => setProxyModalOpen(true)}
onInstall={() => void handleInstall()}
/>
<InstalledExtensionsList
items={items}
busyId={busyId}
updatingId={updatingId}
onRestrictProfiles={setLimitExtension}
onUpdate={(target) => void handleUpdateExtension(target)}
onToggle={(target) => void handleToggle(target)}
onDelete={(target) => void handleDelete(target)}
/>
</div>
)
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ConfirmModal, toast } from '../../../shared/components'
import type { SortOrder } from '../../../shared/components/Table'
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
@@ -22,6 +22,7 @@ import {
import { ProxyPoolHeader } from './proxyPool/ProxyPoolHeader'
import { ProxyPoolTableCard } from './proxyPool/ProxyPoolTableCard'
import { ProxyPoolCheckSettingsModal } from './proxyPool/ProxyPoolCheckSettingsModal'
import { ProxyCoreDownloadModal } from './proxyPool/ProxyCoreDownloadModal'
import { useProxySourceRefresh } from './proxyPool/useProxySourceRefresh'
import { useProxyImportFlow } from './proxyPool/useProxyImportFlow'
import { useProxyChecks } from './proxyPool/useProxyChecks'
@@ -29,16 +30,41 @@ import { useProxySelection } from './proxyPool/useProxySelection'
import { useProxyCheckSettingsModal } from './proxyPool/useProxyCheckSettingsModal'
import { useProxyGlobalRefreshConfig } from './proxyPool/useProxyGlobalRefreshConfig'
import { useProxyDeleteFlow } from './proxyPool/useProxyDeleteFlow'
import { useProxyCoreDownload } from './proxyPool/useProxyCoreDownload'
import { useProxyPoolFilter } from './proxyPool/useProxyPoolFilter'
export function ProxyPoolPage() {
const [proxies, setProxies] = useState<BrowserProxy[]>([])
const [displayList, setDisplayList] = useState<ProxyDisplayInfo[]>([])
const [loading, setLoading] = useState(true)
const {
browserSettings,
connectorSwitching,
coreDownloadOpen,
coreDownloadType,
setCoreDownloadType,
coreDownloadGOOS,
setCoreDownloadGOOS,
coreDownloadGOARCH,
setCoreDownloadGOARCH,
coreDownloadProxy,
setCoreDownloadProxy,
coreDownloadProgress,
currentCoreStatus,
downloadCoreStatus,
downloadCoreStatusLoading,
loadBrowserSettings,
handleSwitchConnector,
handleStartCoreDownload,
openCoreDownload,
closeCoreDownload,
} = useProxyCoreDownload()
const [groups, setGroups] = useState<string[]>([])
const [filterProtocol, setFilterProtocol] = useState<string>('all')
const [filterKeyword, setFilterKeyword] = useState('')
const [filterGroup, setFilterGroup] = useState<string>('all')
const [filterAvailableOnly, setFilterAvailableOnly] = useState(false)
const [sortColumn, setSortColumn] = useState<string>('') // 默认不排序
const [sortOrder, setSortOrder] = useState<SortOrder>(undefined)
@@ -73,55 +99,10 @@ export function ProxyPoolPage() {
groupName: '',
})
const [saving, setSaving] = useState(false)
useEffect(() => {
loadProxies()
}, [])
const loadProxies = async () => {
setLoading(true)
try {
const raw = await fetchBrowserProxies()
const proxyList = ensureBuiltinProxies(raw)
const persistedLatency: Record<string, number> = {}
const persistedIPHealth: Record<string, ProxyIPHealthResult> = {}
proxyList.forEach(proxy => {
if (proxy.lastTestedAt) {
persistedLatency[proxy.proxyId] = (proxy.lastTestOk ?? false)
? (proxy.lastLatencyMs ?? -2)
: -2
}
if (proxy.lastIPHealthJson) {
try {
const parsed = JSON.parse(proxy.lastIPHealthJson) as ProxyIPHealthResult
if (parsed && typeof parsed === 'object' && parsed.proxyId) {
persistedIPHealth[proxy.proxyId] = parsed
}
} catch {
// ignore bad historical json
}
}
})
setProxies(proxyList)
setDisplayList(toDisplayList(proxyList))
setLatencyMap(prev => ({ ...persistedLatency, ...prev }))
setIPHealthMap(prev => ({ ...persistedIPHealth, ...prev }))
const grps = await fetchBrowserProxyGroups()
setGroups(grps)
} finally {
setLoading(false)
}
}
// 直接保存完整列表,内置代理保护由后端负责
const saveProxies = useCallback(async (list: BrowserProxy[]) => {
await saveBrowserProxies(list)
setProxies(list)
setDisplayList(toDisplayList(list))
// 刷新分组列表(可能有新分组加入)
const grps = await fetchBrowserProxyGroups()
setGroups(grps)
}, [])
@@ -178,64 +159,58 @@ export function ProxyPoolPage() {
openIPHealthDetail,
} = useProxyChecks({ proxies })
const protocolOptions = useMemo(
() => ['all', ...Array.from(new Set(displayList.map(p => p.type).filter(t => t !== '-')))],
[displayList]
)
const loadProxies = useCallback(async () => {
setLoading(true)
try {
const [list, groupList] = await Promise.all([
fetchBrowserProxies(),
fetchBrowserProxyGroups(),
])
const finalList = await ensureBuiltinProxies(list)
setProxies(finalList)
setDisplayList(toDisplayList(finalList))
setGroups(groupList)
const getLatencySortTuple = (proxyId: string): [number, number] => {
const v = latencyMap[proxyId]
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] // 正常延迟
}
setLatencyMap(prev => {
const validIds = new Set(finalList.map(p => p.proxyId))
const next: Record<string, number> = {}
Object.entries(prev).forEach(([proxyId, latency]) => {
if (validIds.has(proxyId)) next[proxyId] = latency
})
return next
})
const compareText = (a: string, b: string) => a.localeCompare(b, 'zh-CN')
const compareByColumn = (a: ProxyDisplayInfo, b: ProxyDisplayInfo, column: string) => {
switch (column) {
case 'proxyName':
return compareText(a.proxyName || '', b.proxyName || '')
case 'groupName':
return compareText(a.groupName || '', b.groupName || '')
case 'type':
return compareText(a.type || '', b.type || '')
case 'server':
return compareText(a.server || '', b.server || '')
case 'port':
return (a.port || 0) - (b.port || 0)
case 'latency': {
const [rankA, valA] = getLatencySortTuple(a.proxyId)
const [rankB, valB] = getLatencySortTuple(b.proxyId)
if (rankA !== rankB) return rankA - rankB
if (valA !== valB) return valA - valB
return compareText(a.proxyName || '', b.proxyName || '')
}
default:
return 0
setIPHealthMap(prev => {
const validIds = new Set(finalList.map(p => p.proxyId))
const next: Record<string, ProxyIPHealthResult> = {}
Object.entries(prev).forEach(([proxyId, health]) => {
if (validIds.has(proxyId)) next[proxyId] = health
})
return next
})
} catch (error: any) {
toast.error(error?.message || '加载代理失败')
} finally {
setLoading(false)
}
}
}, [setIPHealthMap, setLatencyMap])
const filteredList = useMemo(() => {
const filtered = displayList.filter(p => {
const matchProtocol = filterProtocol === 'all' || p.type === filterProtocol
const matchKeyword = !filterKeyword || p.proxyName.toLowerCase().includes(filterKeyword.toLowerCase()) || p.server.toLowerCase().includes(filterKeyword.toLowerCase())
const matchGroup = filterGroup === 'all' || p.groupName === filterGroup
return matchProtocol && matchKeyword && matchGroup
})
useEffect(() => {
void loadProxies()
void loadBrowserSettings()
}, [loadProxies, loadBrowserSettings])
if (!sortColumn || !sortOrder) return filtered
return [...filtered].sort((a, b) => {
const cmp = compareByColumn(a, b, sortColumn)
return sortOrder === 'asc' ? cmp : -cmp
})
}, [displayList, filterProtocol, filterKeyword, filterGroup, sortColumn, sortOrder, latencyMap])
const { protocolOptions, filteredList } = useProxyPoolFilter({
displayList,
filterProtocol,
filterKeyword,
filterGroup,
filterAvailableOnly,
sortColumn,
sortOrder,
latencyMap,
ipHealthMap,
})
const {
selectedIds,
@@ -318,12 +293,6 @@ export function ProxyPoolPage() {
setSaving(false)
}
}
const {
deleteConfirmOpen,
setDeleteConfirmOpen,
@@ -334,17 +303,20 @@ export function ProxyPoolPage() {
<div className="space-y-5 animate-fade-in">
<ProxyPoolHeader
checkingAllIPHealth={checkingAllIPHealth}
connectorSwitching={connectorSwitching}
currentConnectorStatus={currentCoreStatus?.message || '未知'}
currentConnectorType={browserSettings?.defaultConnectorType || 'xray'}
hasURLImportSources={hasURLImportSources}
onCheckAllIPHealth={() => void handleCheckAllIPHealth(filteredList)}
onOpenSettings={() => void openCheckSettings()}
onSwitchConnector={() => void handleSwitchConnector()}
onOpenImport={() => setImportModalOpen(true)}
onOpenCoreDownload={openCoreDownload}
onRefreshAllSources={() => void handleRefreshAllSources(false)}
onTestAll={() => void handleTestAll(filteredList)}
onWarmupAll={() => void handleWarmupAll(filteredList)}
refreshingAllSources={refreshingAllSources}
testingAll={testingAll}
totalCount={filteredList.length}
warmingAllBridges={warmingAllBridges}
/>
<ProxyPoolTableCard
@@ -354,6 +326,7 @@ export function ProxyPoolPage() {
filterGroup={filterGroup}
filterKeyword={filterKeyword}
filterProtocol={filterProtocol}
filterAvailableOnly={filterAvailableOnly}
globalAutoRefreshEnabled={globalAutoRefreshEnabled}
globalRefreshInterval={globalRefreshInterval}
globalRefreshIntervalM={globalRefreshIntervalM}
@@ -366,12 +339,14 @@ export function ProxyPoolPage() {
setFilterProtocol('all')
setFilterKeyword('')
setFilterGroup('all')
setFilterAvailableOnly(false)
}}
onDelete={handleDeleteClick}
onEdit={handleEdit}
onFilterGroupChange={setFilterGroup}
onFilterKeywordChange={setFilterKeyword}
onFilterProtocolChange={setFilterProtocol}
onFilterAvailableOnlyChange={setFilterAvailableOnly}
onGlobalAutoRefreshEnabledChange={setGlobalAutoRefreshEnabled}
onGlobalRefreshIntervalMChange={setGlobalRefreshIntervalM}
onOpenBatchDelete={() => setBatchDeleteConfirmOpen(true)}
@@ -482,6 +457,23 @@ export function ProxyPoolPage() {
onCheckTargetsTextChange={setCheckTargetsText}
/>
<ProxyCoreDownloadModal
open={coreDownloadOpen}
core={coreDownloadType}
goos={coreDownloadGOOS}
goarch={coreDownloadGOARCH}
downloadProxy={coreDownloadProxy}
progress={coreDownloadProgress}
status={downloadCoreStatus}
statusLoading={downloadCoreStatusLoading}
onCoreChange={setCoreDownloadType}
onGOOSChange={setCoreDownloadGOOS}
onGOARCHChange={setCoreDownloadGOARCH}
onDownloadProxyChange={setCoreDownloadProxy}
onClose={closeCoreDownload}
onStart={handleStartCoreDownload}
/>
<ConfirmModal open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)} onConfirm={handleDeleteConfirm}
title="确认删除" content="确定要删除这个代理吗?此操作不可恢复。" confirmText="删除" danger />
@@ -490,5 +482,3 @@ export function ProxyPoolPage() {
</div>
)
}

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