mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: improve browser automation and proxy management
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("下载代理不能填 %s,127.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 管理器不可用"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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), ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 节点信息不完整")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
|
||||
@@ -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('当前环境不支持保存实例插件配置')
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user