diff --git a/backend/app_backup_config.go b/backend/app_backup_config.go index 1c32d765..8ef25e67 100644 --- a/backend/app_backup_config.go +++ b/backend/app_backup_config.go @@ -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) diff --git a/backend/app_backup_data_merge.go b/backend/app_backup_data_merge.go index f08c0d17..f6583423 100644 --- a/backend/app_backup_data_merge.go +++ b/backend/app_backup_data_merge.go @@ -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 )`, }, { diff --git a/backend/app_browser_config_api.go b/backend/app_browser_config_api.go index 21b8400f..9b2f75ca 100644 --- a/backend/app_browser_config_api.go +++ b/backend/app_browser_config_api.go @@ -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 { diff --git a/backend/app_extension_api.go b/backend/app_extension_api.go new file mode 100644 index 00000000..deb802b6 --- /dev/null +++ b/backend/app_extension_api.go @@ -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) +} diff --git a/backend/app_instance_start_prepare.go b/backend/app_instance_start_prepare.go index 96f2a2f7..6fb8f90c 100644 --- a/backend/app_instance_start_prepare.go +++ b/backend/app_instance_start_prepare.go @@ -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...) diff --git a/backend/app_instance_start_proxy.go b/backend/app_instance_start_proxy.go index 58531fa9..260a51d7 100644 --- a/backend/app_instance_start_proxy.go +++ b/backend/app_instance_start_proxy.go @@ -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 } diff --git a/backend/app_instance_start_ready_test.go b/backend/app_instance_start_ready_test.go index 664885b7..73fc291b 100644 --- a/backend/app_instance_start_ready_test.go +++ b/backend/app_instance_start_ready_test.go @@ -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 +} diff --git a/backend/app_proxy_core_download.go b/backend/app_proxy_core_download.go new file mode 100644 index 00000000..f660c33c --- /dev/null +++ b/backend/app_proxy_core_download.go @@ -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)) +} diff --git a/backend/app_proxy_core_download_archive.go b/backend/app_proxy_core_download_archive.go new file mode 100644 index 00000000..e4ea447e --- /dev/null +++ b/backend/app_proxy_core_download_archive.go @@ -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 +} diff --git a/backend/app_proxy_core_download_http.go b/backend/app_proxy_core_download_http.go new file mode 100644 index 00000000..5cedf2c9 --- /dev/null +++ b/backend/app_proxy_core_download_http.go @@ -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 + } + } +} diff --git a/backend/app_proxy_core_download_status.go b/backend/app_proxy_core_download_status.go new file mode 100644 index 00000000..4b5554b9 --- /dev/null +++ b/backend/app_proxy_core_download_status.go @@ -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")) +} diff --git a/backend/app_proxy_core_download_test.go b/backend/app_proxy_core_download_test.go new file mode 100644 index 00000000..e4b2beab --- /dev/null +++ b/backend/app_proxy_core_download_test.go @@ -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) + } +} diff --git a/backend/app_proxy_core_download_utils.go b/backend/app_proxy_core_download_utils.go new file mode 100644 index 00000000..a17dea28 --- /dev/null +++ b/backend/app_proxy_core_download_utils.go @@ -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 +} diff --git a/backend/app_proxy_diagnostics.go b/backend/app_proxy_diagnostics.go index 052729cf..15a56906 100644 --- a/backend/app_proxy_diagnostics.go +++ b/backend/app_proxy_diagnostics.go @@ -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 +} diff --git a/backend/app_proxy_diagnostics_test.go b/backend/app_proxy_diagnostics_test.go new file mode 100644 index 00000000..a8f01ecb --- /dev/null +++ b/backend/app_proxy_diagnostics_test.go @@ -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") + } +} diff --git a/backend/app_proxy_health.go b/backend/app_proxy_health.go index ada77392..65f40a06 100644 --- a/backend/app_proxy_health.go +++ b/backend/app_proxy_health.go @@ -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) diff --git a/backend/app_proxy_query.go b/backend/app_proxy_query.go index eb716314..ea2f9ba4 100644 --- a/backend/app_proxy_query.go +++ b/backend/app_proxy_query.go @@ -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 管理器不可用" diff --git a/backend/app_proxy_types.go b/backend/app_proxy_types.go index 4abb2f7b..380f8568 100644 --- a/backend/app_proxy_types.go +++ b/backend/app_proxy_types.go @@ -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"` diff --git a/backend/app_startup.go b/backend/app_startup.go index 55df0037..294367d8 100644 --- a/backend/app_startup.go +++ b/backend/app_startup.go @@ -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() diff --git a/backend/automation_script_export_api.go b/backend/automation_script_export_api.go index 47b7ee92..01dc9d76 100644 --- a/backend/automation_script_export_api.go +++ b/backend/automation_script_export_api.go @@ -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 == "" { diff --git a/backend/automation_script_run_entry.go b/backend/automation_script_run_entry.go index 67958425..07a2c895 100644 --- a/backend/automation_script_run_entry.go +++ b/backend/automation_script_run_entry.go @@ -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 == "" { diff --git a/backend/automation_script_run_playwright.go b/backend/automation_script_run_playwright.go index dbd85428..73c4f993 100644 --- a/backend/automation_script_run_playwright.go +++ b/backend/automation_script_run_playwright.go @@ -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), "" } diff --git a/backend/browser_launch_args.go b/backend/browser_launch_args.go index 2a671948..d233cabb 100644 --- a/backend/browser_launch_args.go +++ b/backend/browser_launch_args.go @@ -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) { diff --git a/backend/internal/automation/assets/runner.cjs b/backend/internal/automation/assets/runner.cjs index 835843db..53cbd6ac 100644 --- a/backend/internal/automation/assets/runner.cjs +++ b/backend/internal/automation/assets/runner.cjs @@ -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))); } diff --git a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs index 791ba808..0885bd2e 100644 --- a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs +++ b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs @@ -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, diff --git a/backend/internal/automation/script_package_zip_write.go b/backend/internal/automation/script_package_zip_write.go index d90dfc24..7d891242 100644 --- a/backend/internal/automation/script_package_zip_write.go +++ b/backend/internal/automation/script_package_zip_write.go @@ -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 diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go index f4564981..dd226220 100644 --- a/backend/internal/automation/script_run_store.go +++ b/backend/internal/automation/script_run_store.go @@ -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, diff --git a/backend/internal/automation/script_run_store_test.go b/backend/internal/automation/script_run_store_test.go index aa53b520..de330489 100644 --- a/backend/internal/automation/script_run_store_test.go +++ b/backend/internal/automation/script_run_store_test.go @@ -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", diff --git a/backend/internal/automation/task_runner_exec.go b/backend/internal/automation/task_runner_exec.go index e34f8862..de33ffe3 100644 --- a/backend/internal/automation/task_runner_exec.go +++ b/backend/internal/automation/task_runner_exec.go @@ -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 { diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go index 87da3d79..e1c9d71c 100644 --- a/backend/internal/automation/task_runner_test.go +++ b/backend/internal/automation/task_runner_test.go @@ -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) diff --git a/backend/internal/automation/task_runner_types.go b/backend/internal/automation/task_runner_types.go index ba7e0e3c..01e09a58 100644 --- a/backend/internal/automation/task_runner_types.go +++ b/backend/internal/automation/task_runner_types.go @@ -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 { diff --git a/backend/internal/browser/extension_dao.go b/backend/internal/browser/extension_dao.go new file mode 100644 index 00000000..0dcd99a0 --- /dev/null +++ b/backend/internal/browser/extension_dao.go @@ -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 +} diff --git a/backend/internal/browser/extension_manager.go b/backend/internal/browser/extension_manager.go new file mode 100644 index 00000000..9bdb9264 --- /dev/null +++ b/backend/internal/browser/extension_manager.go @@ -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 +} diff --git a/backend/internal/browser/extension_manager_helpers.go b/backend/internal/browser/extension_manager_helpers.go new file mode 100644 index 00000000..d45a4c72 --- /dev/null +++ b/backend/internal/browser/extension_manager_helpers.go @@ -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 插件" +} diff --git a/backend/internal/browser/extension_manager_test.go b/backend/internal/browser/extension_manager_test.go new file mode 100644 index 00000000..873be367 --- /dev/null +++ b/backend/internal/browser/extension_manager_test.go @@ -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 +} diff --git a/backend/internal/browser/extension_types.go b/backend/internal/browser/extension_types.go new file mode 100644 index 00000000..9b13834c --- /dev/null +++ b/backend/internal/browser/extension_types.go @@ -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"` +} diff --git a/backend/internal/browser/profile_delete.go b/backend/internal/browser/profile_delete.go index dd1157dc..bd845e98 100644 --- a/backend/internal/browser/profile_delete.go +++ b/backend/internal/browser/profile_delete.go @@ -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)) +} diff --git a/backend/internal/browser/profile_delete_test.go b/backend/internal/browser/profile_delete_test.go new file mode 100644 index 00000000..42e2e36b --- /dev/null +++ b/backend/internal/browser/profile_delete_test.go @@ -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) + } +} diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go index b5022b14..4da43e82 100644 --- a/backend/internal/browser/types.go +++ b/backend/internal/browser/types.go @@ -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 桥接进程 diff --git a/backend/internal/config/config_defaults.go b/backend/internal/config/config_defaults.go index 67111b24..0414fcc0 100644 --- a/backend/internal/config/config_defaults.go +++ b/backend/internal/config/config_defaults.go @@ -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, diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 355ab6f0..6abdd6de 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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() diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index ba6a8fd9..c3860c7f 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -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, diff --git a/backend/internal/proxy/clash.go b/backend/internal/proxy/clash.go index c9df6beb..a5f4840d 100644 --- a/backend/internal/proxy/clash.go +++ b/backend/internal/proxy/clash.go @@ -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() + } + } } diff --git a/backend/internal/proxy/diagnostics.go b/backend/internal/proxy/diagnostics.go index 43b0e495..f0d82b88 100644 --- a/backend/internal/proxy/diagnostics.go +++ b/backend/internal/proxy/diagnostics.go @@ -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 == "" { diff --git a/backend/internal/proxy/diagnostics_test.go b/backend/internal/proxy/diagnostics_test.go index 82938d0c..c3a5c9db 100644 --- a/backend/internal/proxy/diagnostics_test.go +++ b/backend/internal/proxy/diagnostics_test.go @@ -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 { diff --git a/backend/internal/proxy/http_client.go b/backend/internal/proxy/http_client.go index 04b565e7..0d26475b 100644 --- a/backend/internal/proxy/http_client.go +++ b/backend/internal/proxy/http_client.go @@ -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 { diff --git a/backend/internal/proxy/iphealth.go b/backend/internal/proxy/iphealth.go index 7a68ff9c..2eb57057 100644 --- a/backend/internal/proxy/iphealth.go +++ b/backend/internal/proxy/iphealth.go @@ -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 { diff --git a/backend/internal/proxy/mihomo_bridge.go b/backend/internal/proxy/mihomo_bridge.go new file mode 100644 index 00000000..61cd1676 --- /dev/null +++ b/backend/internal/proxy/mihomo_bridge.go @@ -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) + } +} diff --git a/backend/internal/proxy/page_probe.go b/backend/internal/proxy/page_probe.go new file mode 100644 index 00000000..bcd1dbbe --- /dev/null +++ b/backend/internal/proxy/page_probe.go @@ -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] +} diff --git a/backend/internal/proxy/page_probe_test.go b/backend/internal/proxy/page_probe_test.go new file mode 100644 index 00000000..e03f593c --- /dev/null +++ b/backend/internal/proxy/page_probe_test.go @@ -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) + } +} diff --git a/backend/internal/proxy/parser_clash_other_protocols.go b/backend/internal/proxy/parser_clash_other_protocols.go index 1c485895..7b979920 100644 --- a/backend/internal/proxy/parser_clash_other_protocols.go +++ b/backend/internal/proxy/parser_clash_other_protocols.go @@ -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 != "" { diff --git a/backend/internal/proxy/parser_clash_v_protocols.go b/backend/internal/proxy/parser_clash_v_protocols.go index d13a0d25..dd97446b 100644 --- a/backend/internal/proxy/parser_clash_v_protocols.go +++ b/backend/internal/proxy/parser_clash_v_protocols.go @@ -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 } diff --git a/backend/internal/proxy/parser_compat_test.go b/backend/internal/proxy/parser_compat_test.go index c731d394..2577d213 100644 --- a/backend/internal/proxy/parser_compat_test.go +++ b/backend/internal/proxy/parser_compat_test.go @@ -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) + } +} diff --git a/backend/internal/proxy/parser_uri.go b/backend/internal/proxy/parser_uri.go index 20887f86..d2f56acd 100644 --- a/backend/internal/proxy/parser_uri.go +++ b/backend/internal/proxy/parser_uri.go @@ -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 } diff --git a/backend/internal/proxy/runtime_config_test.go b/backend/internal/proxy/runtime_config_test.go index 4efcdafb..532948c8 100644 --- a/backend/internal/proxy/runtime_config_test.go +++ b/backend/internal/proxy/runtime_config_test.go @@ -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() diff --git a/backend/internal/proxy/runtime_tuning.go b/backend/internal/proxy/runtime_tuning.go new file mode 100644 index 00000000..f3e52b36 --- /dev/null +++ b/backend/internal/proxy/runtime_tuning.go @@ -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"}, + } +} diff --git a/backend/internal/proxy/singbox_bridge_cleanup.go b/backend/internal/proxy/singbox_bridge_cleanup.go new file mode 100644 index 00000000..27d1060d --- /dev/null +++ b/backend/internal/proxy/singbox_bridge_cleanup.go @@ -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) + } +} diff --git a/backend/internal/proxy/singbox_bridge_runtime.go b/backend/internal/proxy/singbox_bridge_runtime.go index 529cc41f..02e281f6 100644 --- a/backend/internal/proxy/singbox_bridge_runtime.go +++ b/backend/internal/proxy/singbox_bridge_runtime.go @@ -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 { diff --git a/backend/internal/proxy/singbox_launch_lock.go b/backend/internal/proxy/singbox_launch_lock.go new file mode 100644 index 00000000..9b5169c3 --- /dev/null +++ b/backend/internal/proxy/singbox_launch_lock.go @@ -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() + } +} diff --git a/backend/internal/proxy/singbox_launch_lock_test.go b/backend/internal/proxy/singbox_launch_lock_test.go new file mode 100644 index 00000000..3b514bc2 --- /dev/null +++ b/backend/internal/proxy/singbox_launch_lock_test.go @@ -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) + } +} diff --git a/backend/internal/proxy/singbox_types.go b/backend/internal/proxy/singbox_types.go index 36bcce78..444811ac 100644 --- a/backend/internal/proxy/singbox_types.go +++ b/backend/internal/proxy/singbox_types.go @@ -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 } diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go index 8bb02629..bc30390f 100644 --- a/backend/internal/proxy/speedtest.go +++ b/backend/internal/proxy/speedtest.go @@ -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()), ) diff --git a/backend/internal/proxy/speedtest_mapping.go b/backend/internal/proxy/speedtest_mapping.go index 6546fc41..f60d7258 100644 --- a/backend/internal/proxy/speedtest_mapping.go +++ b/backend/internal/proxy/speedtest_mapping.go @@ -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 { diff --git a/backend/internal/proxy/utils_connectivity.go b/backend/internal/proxy/utils_connectivity.go index eb8b00f7..3c78499c 100644 --- a/backend/internal/proxy/utils_connectivity.go +++ b/backend/internal/proxy/utils_connectivity.go @@ -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() } diff --git a/backend/internal/proxy/utils_parse.go b/backend/internal/proxy/utils_parse.go index ab04c2e6..194e52a1 100644 --- a/backend/internal/proxy/utils_parse.go +++ b/backend/internal/proxy/utils_parse.go @@ -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 节点信息不完整") } diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index 377e3ffe..600cf531 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -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() diff --git a/backend/internal/proxy/xray_bridge_launch.go b/backend/internal/proxy/xray_bridge_launch.go index 13d40034..97d58012 100644 --- a/backend/internal/proxy/xray_bridge_launch.go +++ b/backend/internal/proxy/xray_bridge_launch.go @@ -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 diff --git a/backend/internal/proxy/xray_launch_lock.go b/backend/internal/proxy/xray_launch_lock.go index 342f0d64..b9b5705f 100644 --- a/backend/internal/proxy/xray_launch_lock.go +++ b/backend/internal/proxy/xray_launch_lock.go @@ -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++ diff --git a/backend/internal/proxy/xray_runtime_config.go b/backend/internal/proxy/xray_runtime_config.go index 08fe3686..f7c08a49 100644 --- a/backend/internal/proxy/xray_runtime_config.go +++ b/backend/internal/proxy/xray_runtime_config.go @@ -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, diff --git a/backend/internal/proxy/xray_runtime_dns.go b/backend/internal/proxy/xray_runtime_dns.go index e13477ef..5703b6c3 100644 --- a/backend/internal/proxy/xray_runtime_dns.go +++ b/backend/internal/proxy/xray_runtime_dns.go @@ -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 +} diff --git a/backend/internal/proxy/xray_runtime_helpers_test.go b/backend/internal/proxy/xray_runtime_helpers_test.go index ca5d449f..8be331ef 100644 --- a/backend/internal/proxy/xray_runtime_helpers_test.go +++ b/backend/internal/proxy/xray_runtime_helpers_test.go @@ -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() diff --git a/backend/test/proxy/trojan_test.go b/backend/test/proxy/trojan_test.go index 75a1e814..19a6541b 100644 --- a/backend/test/proxy/trojan_test.go +++ b/backend/test/proxy/trojan_test.go @@ -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) } diff --git a/config.yaml b/config.yaml index 272d2778..cedf96f1 100644 --- a/config.yaml +++ b/config.yaml @@ -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: "" diff --git a/frontend/scripts/dev-port-helper.mjs b/frontend/scripts/dev-port-helper.mjs index 62fe9fbc..2d6df0fe 100644 --- a/frontend/scripts/dev-port-helper.mjs +++ b/frontend/scripts/dev-port-helper.mjs @@ -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++ } diff --git a/frontend/src/config/navigation.config.ts b/frontend/src/config/navigation.config.ts index aab7694a..71f1400b 100644 --- a/frontend/src/config/navigation.config.ts +++ b/frontend/src/config/navigation.config.ts @@ -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' }, diff --git a/frontend/src/modules/browser/api/extensions.ts b/frontend/src/modules/browser/api/extensions.ts new file mode 100644 index 00000000..235f23d0 --- /dev/null +++ b/frontend/src/modules/browser/api/extensions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const bindings: any = await getBindings() + if (bindings?.BrowserExtensionInstallLocalFile) { + return normalizeExtension(await bindings.BrowserExtensionInstallLocalFile()) + } + throw new Error('当前环境不支持本地插件包导入') +} + +export async function installBrowserExtensionLocalDirectory(): Promise { + 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 { + 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 { + 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 { + 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 { + const bindings: any = await getBindings() + if (bindings?.BrowserProfileExtensionSave) { + return normalizeProfileSettings(await bindings.BrowserProfileExtensionSave(profileId, extensionIds, configured)) + } + throw new Error('当前环境不支持保存实例插件配置') +} diff --git a/frontend/src/modules/browser/api/proxies.ts b/frontend/src/modules/browser/api/proxies.ts index afca7030..178b5d47 100644 --- a/frontend/src/modules/browser/api/proxies.ts +++ b/frontend/src/modules/browser/api/proxies.ts @@ -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 { + 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 { + 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 { + 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 { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyCoreOpenLocal) { + await bindings.BrowserProxyCoreOpenLocal({ core, goos, goarch }) + return true + } + return false +} diff --git a/frontend/src/modules/browser/api/runtime.ts b/frontend/src/modules/browser/api/runtime.ts index 50a49db7..31251d3f 100644 --- a/frontend/src/modules/browser/api/runtime.ts +++ b/frontend/src/modules/browser/api/runtime.ts @@ -26,6 +26,7 @@ export function createDefaultBrowserSettings(): BrowserSettings { restoreLastSession: false, startReadyTimeoutMs: 3000, startStableWindowMs: 1200, + defaultConnectorType: 'xray', } } diff --git a/frontend/src/modules/browser/automationScriptApi.exports.ts b/frontend/src/modules/browser/automationScriptApi.exports.ts index aa6d9a89..d198279e 100644 --- a/frontend/src/modules/browser/automationScriptApi.exports.ts +++ b/frontend/src/modules/browser/automationScriptApi.exports.ts @@ -106,6 +106,41 @@ export async function exportAutomationScriptZip( throw new Error("当前环境不支持 ZIP 脚本包导出"); } +export async function exportAutomationScriptsBatchZip( + scriptIds: string[], +): Promise { + 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 { diff --git a/frontend/src/modules/browser/automationScriptApi.runs.ts b/frontend/src/modules/browser/automationScriptApi.runs.ts index a90537f4..2feb8076 100644 --- a/frontend/src/modules/browser/automationScriptApi.runs.ts +++ b/frontend/src/modules/browser/automationScriptApi.runs.ts @@ -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(promise: Promise): Promise { + 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 { @@ -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 { 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) : []; diff --git a/frontend/src/modules/browser/automationScriptApi.shared.ts b/frontend/src/modules/browser/automationScriptApi.shared.ts index bcf4b967..d1becb6f 100644 --- a/frontend/src/modules/browser/automationScriptApi.shared.ts +++ b/frontend/src/modules/browser/automationScriptApi.shared.ts @@ -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, diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts index 69822105..058e2fe2 100644 --- a/frontend/src/modules/browser/automationScriptApi.ts +++ b/frontend/src/modules/browser/automationScriptApi.ts @@ -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"; diff --git a/frontend/src/modules/browser/automationScripts/definitions.ts b/frontend/src/modules/browser/automationScripts/definitions.ts index 8d4a3f4a..d7fdeba4 100644 --- a/frontend/src/modules/browser/automationScripts/definitions.ts +++ b/frontend/src/modules/browser/automationScripts/definitions.ts @@ -89,6 +89,7 @@ export interface AutomationScriptRunRecord { summary: string; error: string; resultText: string; + logText: string; startedAt: string; finishedAt: string; durationMs: number; diff --git a/frontend/src/modules/browser/components/AutomationEntryActions.tsx b/frontend/src/modules/browser/components/AutomationEntryActions.tsx deleted file mode 100644 index 094916b6..00000000 --- a/frontend/src/modules/browser/components/AutomationEntryActions.tsx +++ /dev/null @@ -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 ( -
- - -
- ) -} diff --git a/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx b/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx index e3f7d058..d563e6a3 100644 --- a/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx +++ b/frontend/src/modules/browser/components/AutomationScriptHistoryModal.tsx @@ -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([]); 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 ( + <>
- +
+ @@ -358,10 +366,24 @@ export function AutomationScriptHistoryModal({ {run.summary || "未返回摘要"} + {expanded ? ( -
@@ -293,6 +298,9 @@ export function AutomationScriptHistoryModal({ 摘要 + 操作 +
+ +
+
) : null} - {!run.error && !run.resultText ? ( + {!run.error && !run.resultText && !run.logText ? (
这条记录没有更多详情。
@@ -443,5 +465,31 @@ export function AutomationScriptHistoryModal({ )}
+ setSelectedLogRunId("")} + title="执行日志" + width="760px" + footer={ + + } + > +
+
+ + {selectedLogRun?.scriptName || "未命名脚本"} + + + {formatDateTime(selectedLogRun?.startedAt)} + +
+
+          {selectedLogRun?.logText || "暂无执行日志"}
+        
+
+
+ ); } diff --git a/frontend/src/modules/browser/components/AutomationToolboxModal.tsx b/frontend/src/modules/browser/components/AutomationToolboxModal.tsx deleted file mode 100644 index 4f476214..00000000 --- a/frontend/src/modules/browser/components/AutomationToolboxModal.tsx +++ /dev/null @@ -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 ( -
- 还没有最近响应。先执行一次健康检查或 Demo 创建。 -
- ) - } - - return ( -
-      {text}
-    
- ) -} - -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 ( - -
-
-
-

自动化入口

-

- 主页面只保留脚本管理,Smoke、文档和运行时入口统一从这里进入。 -

-
-
- - - -
-
- -
-
-
-
-

Demo 调试

-

保留真实请求链路,方便核对 LaunchServer、实例创建和 CDP 返回。

-
- -
- - - - -
- -
-
最近动作:{demoSession.lastAction || '-'}
-
Profile ID:{demoSession.profileId || '-'}
-
Launch Code:{demoSession.launchCode || '-'}
-
CDP URL:{demoSession.cdpUrl || '-'}
-
- -
- - -
-
-
- -
-
-
-

最近响应

-

调试响应保留在工具箱,主页面不再混入接口演示内容。

-
- {demoResponseText && ( - - )} -
- -
- - {demoSession.lastResult ? (demoSession.lastResult.ok ? '请求成功' : '请求失败') : '暂无请求'} - - {demoSession.lastResult && ( - - HTTP {demoSession.lastResult.status || '-'} - - )} - {demoSession.lastResult?.method && demoSession.lastResult?.path && ( - - {demoSession.lastResult.method} {demoSession.lastResult.path} - - )} -
- - {demoSession.lastResult?.error && ( -

{demoSession.lastResult.error}

- )} - -
- -
-
-
-
-
- ) -} diff --git a/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx b/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx index e35df6db..057af7dd 100644 --- a/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx +++ b/frontend/src/modules/browser/components/BrowserProfilesPanel.tsx @@ -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({ + @@ -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({ )} + @@ -353,6 +359,7 @@ export function BrowserProfilesPanel({ onStop={onStop} onRestart={onRestart} onOpenKeywords={onOpenKeywords} + onOpenExtensions={onOpenExtensions} onOpenCopy={onOpenCopy} onDelete={onDelete} /> diff --git a/frontend/src/modules/browser/components/BrowserSettingsModal.tsx b/frontend/src/modules/browser/components/BrowserSettingsModal.tsx index c667d8ec..7deadd00 100644 --- a/frontend/src/modules/browser/components/BrowserSettingsModal.tsx +++ b/frontend/src/modules/browser/components/BrowserSettingsModal.tsx @@ -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
+ + setSettings(p => ({ ...p, userDataRoot: e.target.value }))} placeholder="data" /> diff --git a/frontend/src/modules/browser/components/ProfileExtensionModal.tsx b/frontend/src/modules/browser/components/ProfileExtensionModal.tsx new file mode 100644 index 00000000..6c4d6060 --- /dev/null +++ b/frontend/src/modules/browser/components/ProfileExtensionModal.tsx @@ -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([]) + const [configured, setConfigured] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + 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 ( + + + + + )} + > +
+ + + {!configured ? ( +
+ 当前实例继承全局已启用插件。打开单独配置后,只加载下方勾选的插件。 +
+ ) : null} + +
+ {extensions.map((extension) => ( + + ))} + + {extensions.length === 0 ? ( +
+ 还没有安装插件,请先到插件包管理页面导入。 +
+ ) : null} +
+
+
+ ) +} diff --git a/frontend/src/modules/browser/components/ProxyPickerModal.tsx b/frontend/src/modules/browser/components/ProxyPickerModal.tsx index d5580403..fc39b87d 100644 --- a/frontend/src/modules/browser/components/ProxyPickerModal.tsx +++ b/frontend/src/modules/browser/components/ProxyPickerModal.tsx @@ -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([]) const [allProxies, setAllProxies] = useState([]) const [selectedGroup, setSelectedGroup] = useState(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()} >
- 从代理池选择 + {title} diff --git a/frontend/src/modules/browser/pages/AutomationCardsSection.tsx b/frontend/src/modules/browser/pages/AutomationCardsSection.tsx index a7b8ef8d..6ed1be39 100644 --- a/frontend/src/modules/browser/pages/AutomationCardsSection.tsx +++ b/frontend/src/modules/browser/pages/AutomationCardsSection.tsx @@ -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 (
@@ -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 + } />
); diff --git a/frontend/src/modules/browser/pages/AutomationPage.tsx b/frontend/src/modules/browser/pages/AutomationPage.tsx index 3c8374c0..c5f6984e 100644 --- a/frontend/src/modules/browser/pages/AutomationPage.tsx +++ b/frontend/src/modules/browser/pages/AutomationPage.tsx @@ -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([]); 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 (
void handleRefresh()} onCreate={() => setCreateOpen(true)} + onExportSelected={() => void handleExportSelectedScripts()} onImport={() => setImportOpen(true)} onOpenHistory={() => setHistoryOpen(true)} - onOpenToolbox={() => setToolboxOpen(true)} /> setCreateOpen(true)} onImport={() => setImportOpen(true)} + onToggleScriptSelection={handleToggleScriptSelection} onOpenScript={openScript} onRunAutomationScript={handleOpenRunModal} onOpenPublicApi={handleOpenPublicApiModal} @@ -351,7 +394,7 @@ export function AutomationPage() { - setToolboxOpen(false)} - /> 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 (
@@ -37,6 +41,16 @@ export function AutomationPageHeader({ 新建脚本 + -
diff --git a/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx b/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx index 2b4a7e40..7d914f55 100644 --- a/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx +++ b/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx @@ -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) => { - if (!interactive || !onOpen) { + if (!cardClickable) { return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - onOpen(); + handleCardClick(); } }; return (