diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml new file mode 100644 index 00000000..131a6c80 --- /dev/null +++ b/.github/workflows/publish-linux.yml @@ -0,0 +1,68 @@ +name: Publish Linux Packages + +on: + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + dpkg-dev \ + fakeroot + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 + + - name: Add Go bin to PATH + run: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Build Linux packages + run: bash publish/linux/publish-linux.sh --arch ${{ matrix.arch }} + + - name: Validate deb package metadata + run: | + set -euo pipefail + deb_file="$(ls -1 publish/output/*_${{ matrix.arch }}.deb | head -n 1)" + echo "Validating: $deb_file" + dpkg-deb -I "$deb_file" + dpkg-deb -c "$deb_file" >/dev/null + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ant-browser-linux-${{ matrix.arch }} + path: | + publish/output/*_${{ matrix.arch }}.deb + publish/output/*-linux-${{ matrix.arch }}.tar.gz diff --git a/.gitignore b/.gitignore index 7d6ad73f..0c57f943 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,12 @@ pnpm-debug.log* .ant-license.json # Build outputs -bin/ -build/bin/ +/bin/ +build/bin/* +!build/bin/README.md build/dist/ -chrome/ +chrome/* +!chrome/README.md *.exe *.dll *.so @@ -34,8 +36,15 @@ chrome/ # Runtime binaries (must be versioned for release/dev convenience) !/bin/ +!/bin/README.md !/bin/xray.exe !/bin/sing-box.exe +!/bin/linux-amd64/ +!/bin/linux-amd64/xray +!/bin/linux-amd64/sing-box +!/bin/linux-arm64/ +!/bin/linux-arm64/xray +!/bin/linux-arm64/sing-box # Publish artifacts publish/staging/ @@ -48,9 +57,11 @@ plan.md pic/ # Runtime data -data/ +data/* +!data/README.md snapshots/ app.db +config.yaml.broken-* *.db *.sqlite *.sqlite3 @@ -62,6 +73,13 @@ frontend/node_modules/ frontend/dist/ frontend/.vite/ +# Python cache +__pycache__/ +*.pyc + +# Local runtime cache +.tmp/runtime-cache/ + # Test outputs / coverage coverage/ *.out diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b0a6c655 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 1.1.0 - 2026-03-19 + +- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题。 +- 新增 SOCKS 代理测试支持:SOCKS 代理能力已进入测试阶段,后续会继续验证稳定性与兼容性。 +- 实验性支持接口触发浏览器:支持通过接口启动浏览器实例,便于后续接入自动化流程。 diff --git a/README.md b/README.md index 5aa40410..453b838d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Ant Browser -> 面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。 +> 面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具(Windows / Linux)。 [![Release](https://img.shields.io/github/v/release/black-ant/Ant-Browser?sort=semver)](https://github.com/black-ant/Ant-Browser/releases) -[![Platform](https://img.shields.io/badge/Platform-Windows%2010%2F11-blue)](https://github.com/black-ant/Ant-Browser/releases) +[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-blue)](https://github.com/black-ant/Ant-Browser/releases) [![Issues](https://img.shields.io/github/issues/black-ant/Ant-Browser)](https://github.com/black-ant/Ant-Browser/issues) ## 推荐内核项目 @@ -16,11 +16,13 @@ Ant Browser 当前推荐配套使用的浏览器内核,来源于开源项目 [ 这个项目为 Ant Browser 的内核准备提供了直接可用的基础来源,这里先对原项目做明确推荐与致谢。 -Ant Browser 的目标很明确:在一台 Windows 设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。 +Ant Browser 的目标很明确:在一台桌面设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。 ## 目录 - [项目简介](#项目简介) +- [近期更新](#近期更新) +- [更新日志](CHANGELOG.md) - [核心特性](#核心特性) - [界面预览](#界面预览) - [快速开始](#快速开始) @@ -47,6 +49,16 @@ Ant Browser 适合以下场景: - 统一管理浏览器内核、标签、关键字和快捷打开码 - 在本地保存配置和运行数据,便于自主控制 +## 近期更新 + +### 1.1.0 · 2026-03-19 + +- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题 +- 新增 SOCKS 代理测试支持:SOCKS 代理能力已进入测试阶段,后续会继续验证稳定性与兼容性 +- 实验性支持接口触发浏览器:支持通过接口启动浏览器实例,便于后续接入自动化流程 + +完整历史版本记录见 [CHANGELOG.md](CHANGELOG.md)。 + ## 源码分支说明 - `master`:面向开发者的干净基线分支,不提交 `data/app.db`、实例目录或其他用户数据。首次启动时会自动初始化空数据库。 @@ -111,7 +123,9 @@ Ant Browser 适合以下场景: ### 环境要求 -- 操作系统:Windows 10 / Windows 11(64 位) +- 操作系统: + - Windows 10 / 11(64 位) + - Linux(amd64 / arm64) - 建议内存:8 GB 及以上 - 建议磁盘空间:2 GB 以上 @@ -120,13 +134,27 @@ Ant Browser 适合以下场景: 1. 前往 Releases 页面下载最新版本: 2. 安装版直接运行 `AntBrowser-Setup-*.exe` 3. 便携版解压后运行 `ant-chrome.exe` +4. Linux 包下载后可直接安装 `ant-browser__.deb`,或解压 `tar.gz` 后运行 `ant-chrome` ### 从源码运行 1. 开发默认使用 `master` 分支;该分支不带测试用户数据,适合作为日常开发基线。 2. 如需带测试库的演示环境,请切换到 `user_data` 分支。 -3. 执行 `bat\dev.bat` 或直接使用 `wails dev` 启动项目。 -4. 仓库已内置 `bin/xray.exe` 和 `bin/sing-box.exe`,不需要额外下载代理运行时。 +3. Windows 执行 `bat\dev.bat`;Linux 直接执行 `wails dev` 启动项目。 +4. Windows 运行时使用 `bin/xray.exe`、`bin/sing-box.exe`;Linux 运行时使用 `bin/linux-/xray`、`bin/linux-/sing-box`。 +5. 运行时文件采用“仓库固定 + 哈希校验”,校验清单在 `publish/runtime-manifest.json`,固定来源清单在 `publish/runtime-sources.json`。 +6. 如需刷新 Linux 运行时,执行 `python3 tools/runtime/sync-runtime.py`(会按固定来源下载、校验归档并更新 manifest)。 + +### Linux 发布打包(源码) + +Linux 发布脚本位于 `publish/linux/`。 + +```bash +bash publish/linux/publish-linux.sh --arch amd64 +bash publish/linux/publish-linux.sh --arch arm64 +``` + +详细说明见 [publish/linux/README.md](publish/linux/README.md)。 ### 准备浏览器内核 diff --git a/backend/app.go b/backend/app.go index 42da5891..3471d01d 100644 --- a/backend/app.go +++ b/backend/app.go @@ -1,6 +1,7 @@ package backend import ( + "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/database" @@ -37,6 +38,7 @@ type App struct { launchServer *launchcode.LaunchServer speedScheduler *browser.ProxySpeedScheduler appRoot string + version string forceQuit bool // 强制退出标志,用于跳过 OnBeforeClose 的拦截 maintenanceMu sync.Mutex // 维护类操作(初始化/导入/导出)互斥锁 @@ -47,16 +49,42 @@ type App struct { } // NewApp 创建新的应用实例 -func NewApp(appRoot string) *App { +func NewApp(appRoot string, appVersion ...string) *App { + version := "" + if len(appVersion) > 0 { + version = strings.TrimSpace(appVersion[0]) + } return &App{ appRoot: strings.TrimSpace(appRoot), + version: version, xrayBridgeRefs: make(map[string]string), } } +func (a *App) appName() string { + if a.config != nil { + if name := strings.TrimSpace(a.config.App.Name); name != "" { + return name + } + } + return "Ant Browser" +} + +func (a *App) appVersion() string { + version := strings.TrimSpace(a.version) + if version == "" { + return "unknown" + } + return version +} + // startup 应用启动时调用 func (a *App) startup(ctx context.Context) { a.ctx = ctx + if err := apppath.EnsureWritableLayout(a.appRoot); err != nil { + runtime.LogFatal(ctx, fmt.Sprintf("初始化 Linux 用户数据目录失败: %v", err)) + return + } cfg, err := LoadConfig(a.resolveAppPath("config.yaml")) if err != nil { cfg = config.DefaultConfig() @@ -84,10 +112,16 @@ func (a *App) startup(ctx context.Context) { log := logger.New("App") log.Info("应用启动中...", - logger.F("version", "1.0.0"), + logger.F("version", a.appVersion()), logger.F("max_memory_mb", cfg.Runtime.MaxMemoryMB), logger.F("gc_percent", cfg.Runtime.GCPercent), ) + if apppath.IsDetached(a.appRoot) { + log.Info("检测到安装目录需要只读运行,已切换到用户数据目录", + logger.F("install_root", apppath.InstallRoot(a.appRoot)), + logger.F("state_root", apppath.StateRoot(a.appRoot)), + ) + } // 确保 data 目录存在(存放数据库、用户数据、快照等) if err := os.MkdirAll(a.resolveAppPath("data"), 0755); err != nil { @@ -136,6 +170,7 @@ func (a *App) startup(ctx context.Context) { a.browserMgr.InitData() a.autoDetectCores() a.loadProxies() + a.reconcileProfileProxyBindings() // 初始化 LaunchCode 服务 launchCodeDAO := launchcode.NewSQLiteLaunchCodeDAO(a.db.GetConn()) @@ -161,8 +196,18 @@ func (a *App) startup(ctx context.Context) { a.xrayMgr.OnBridgeDied = func(key string, err error) { if a.ctx != nil { runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{ - "key": key[:8], - "error": err.Error(), + "engine": "xray", + "key": key[:8], + "error": err.Error(), + }) + } + } + a.singboxMgr.OnBridgeDied = func(key string, err error) { + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{ + "engine": "singbox", + "key": key[:8], + "error": err.Error(), }) } } @@ -196,7 +241,9 @@ func (a *App) ReloadConfig() error { // Update browser manager config reference if a.browserMgr != nil { a.browserMgr.Config = cfg + a.browserMgr.ListCores() a.loadProxies() + a.reconcileProfileProxyBindings() } if a.xrayMgr != nil { a.xrayMgr.Config = cfg @@ -219,7 +266,10 @@ func (a *App) applyRuntimeConfig(cfg config.RuntimeConfig) { if cfg.MaxMemoryMB > 0 { maxMemoryBytes := int64(cfg.MaxMemoryMB) * 1024 * 1024 debug.SetMemoryLimit(maxMemoryBytes) + return } + // 0 表示禁用自定义软限制,避免 ReloadConfig 后残留旧的 GOMEMLIMIT。 + debug.SetMemoryLimit(1 << 60) } func (a *App) shutdown(ctx context.Context) { @@ -250,10 +300,21 @@ func Stop(a *App, ctx context.Context) { a.shutdown(ctx) } +func platformSupportsTrayCloseFlow() bool { + return platformSupportsTrayCloseFlowForOS(goruntime.GOOS) +} + +func platformSupportsTrayCloseFlowForOS(goos string) bool { + return strings.EqualFold(strings.TrimSpace(goos), "windows") +} + func ShouldBlockClose(a *App, ctx context.Context) bool { if a.forceQuit { return false } + if !platformSupportsTrayCloseFlow() { + return false + } runtime.EventsEmit(ctx, "app:request-close") return true } @@ -318,11 +379,15 @@ func (a *App) GetDashboardStats() map[string]interface{} { "proxyCount": proxyCount, "coreCount": coreCount, "memUsedMB": int(memUsedMB), + "appVersion": a.appVersion(), } } func (a *App) GetAppConfig() map[string]interface{} { - return map[string]interface{}{"name": a.config.App.Name, "version": "1.0.0"} + return map[string]interface{}{ + "name": a.appName(), + "version": a.appVersion(), + } } func (a *App) GetMemoryStats() map[string]interface{} { @@ -925,6 +990,7 @@ func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error { } } log.Info("代理列表已保存到数据库", logger.F("count", len(normalized))) + a.reconcileProfileProxyBindings() return nil } @@ -933,6 +999,7 @@ func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error { log.Error("代理列表保存失败", logger.F("error", err)) return err } + a.reconcileProfileProxyBindings() return nil } @@ -1107,19 +1174,27 @@ func (a *App) migrateToSQLite() { if profiles, err := a.browserMgr.ProfileDAO.List(); err == nil && len(profiles) == 0 { if len(a.config.Browser.Profiles) > 0 { for _, pc := range a.config.Browser.Profiles { + coreId := strings.TrimSpace(pc.CoreId) + if strings.EqualFold(coreId, "default") { + coreId = "" + } p := &browser.Profile{ - ProfileId: pc.ProfileId, - ProfileName: pc.ProfileName, - UserDataDir: pc.UserDataDir, - CoreId: pc.CoreId, - FingerprintArgs: pc.FingerprintArgs, - ProxyId: pc.ProxyId, - ProxyConfig: pc.ProxyConfig, - LaunchArgs: pc.LaunchArgs, - Tags: pc.Tags, - Keywords: pc.Keywords, - CreatedAt: pc.CreatedAt, - UpdatedAt: pc.UpdatedAt, + ProfileId: pc.ProfileId, + ProfileName: pc.ProfileName, + UserDataDir: pc.UserDataDir, + CoreId: coreId, + FingerprintArgs: pc.FingerprintArgs, + ProxyId: pc.ProxyId, + ProxyConfig: pc.ProxyConfig, + ProxyBindSourceID: pc.ProxyBindSourceID, + ProxyBindSourceURL: pc.ProxyBindSourceURL, + ProxyBindName: pc.ProxyBindName, + ProxyBindUpdatedAt: pc.ProxyBindUpdatedAt, + LaunchArgs: pc.LaunchArgs, + Tags: pc.Tags, + Keywords: pc.Keywords, + CreatedAt: pc.CreatedAt, + UpdatedAt: pc.UpdatedAt, } if err := a.browserMgr.ProfileDAO.Upsert(p); err != nil { log.Error("实例迁移失败", logger.F("profile_id", pc.ProfileId), logger.F("error", err)) @@ -1132,7 +1207,7 @@ func (a *App) migrateToSQLite() { ProfileId: generateUUID(), ProfileName: "默认实例", UserDataDir: "default", - CoreId: "default", + CoreId: "", FingerprintArgs: a.config.Browser.DefaultFingerprintArgs, LaunchArgs: a.config.Browser.DefaultLaunchArgs, Tags: []string{"默认"}, diff --git a/backend/app_backup.go b/backend/app_backup.go index 5503b74a..f0125d2c 100644 --- a/backend/app_backup.go +++ b/backend/app_backup.go @@ -1,8 +1,8 @@ package backend import ( + "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/backup" - "strings" "time" ) @@ -12,7 +12,7 @@ type BackupManifest = backup.Manifest // BackupGetScopeDefinition 返回当前环境下的备份范围定义(第一阶段:范围与包格式)。 func (a *App) BackupGetScopeDefinition() (BackupScope, error) { return backup.BuildScope(backup.BuildOptions{ - AppRoot: a.appRoot, + AppRoot: apppath.StateRoot(a.appRoot), Config: a.config, }) } @@ -23,11 +23,5 @@ func (a *App) BackupGetManifestTemplate() (BackupManifest, error) { if err != nil { return BackupManifest{}, err } - appName := "Ant Browser" - if a.config != nil { - if name := strings.TrimSpace(a.config.App.Name); name != "" { - appName = name - } - } - return backup.BuildManifest(scope, appName, "1.0.0", time.Now()), nil + return backup.BuildManifest(scope, a.appName(), a.appVersion(), time.Now()), nil } diff --git a/backend/app_backup_ops.go b/backend/app_backup_ops.go index b5665379..f69f4201 100644 --- a/backend/app_backup_ops.go +++ b/backend/app_backup_ops.go @@ -68,11 +68,7 @@ func (a *App) BackupExportPackage() (map[string]interface{}, error) { a.backupEmitExportProgress("error", 100, fmt.Sprintf("导出失败: %v", err)) return nil, err } - appName := "Ant Browser" - if a.config != nil && strings.TrimSpace(a.config.App.Name) != "" { - appName = strings.TrimSpace(a.config.App.Name) - } - manifest := backup.BuildManifest(scope, appName, "1.0.0", time.Now()) + manifest := backup.BuildManifest(scope, a.appName(), a.appVersion(), time.Now()) a.backupEmitExportProgress("preparing", 15, "开始写入备份包...") includedEntries, skippedEntries, fileCount, err := backupWritePackageZip(savePath, scope, manifest, a.backupEmitExportProgressMeta) diff --git a/backend/app_close_behavior_test.go b/backend/app_close_behavior_test.go new file mode 100644 index 00000000..84ea3896 --- /dev/null +++ b/backend/app_close_behavior_test.go @@ -0,0 +1,27 @@ +package backend + +import ( + "context" + goruntime "runtime" + "testing" +) + +func TestPlatformSupportsTrayCloseFlowForOS(t *testing.T) { + if !platformSupportsTrayCloseFlowForOS("windows") { + t.Fatal("expected Windows to keep tray close flow enabled") + } + if platformSupportsTrayCloseFlowForOS("linux") { + t.Fatal("expected Linux to skip tray close flow") + } +} + +func TestShouldBlockClose_NonWindowsDoesNotIntercept(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("Windows keeps the tray-based close confirmation flow") + } + + app := NewApp("") + if ShouldBlockClose(app, context.Background()) { + t.Fatal("expected non-Windows close to proceed without interception") + } +} diff --git a/backend/app_cookie.go b/backend/app_cookie.go index a40e6120..08443e18 100644 --- a/backend/app_cookie.go +++ b/backend/app_cookie.go @@ -33,6 +33,10 @@ type cdpTarget struct { Type string `json:"type"` } +type cdpBrowserVersion struct { + WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"` +} + // cdpMessage 是 CDP 协议消息结构 type cdpMessage struct { Id int `json:"id"` @@ -103,6 +107,49 @@ func cdpCall(debugPort int, method string, params map[string]any) (map[string]an return cdpResp.Result, nil } +func cdpBrowserCall(debugPort int, method string, params map[string]any) error { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", debugPort)) + if err != nil { + return fmt.Errorf("CDP /json/version 请求失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var version cdpBrowserVersion + if err := json.Unmarshal(body, &version); err != nil { + return fmt.Errorf("CDP browser target 解析失败: %w", err) + } + wsURL := strings.TrimSpace(version.WebSocketDebuggerUrl) + if wsURL == "" { + return fmt.Errorf("未找到浏览器级 WebSocket 调试地址") + } + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return fmt.Errorf("浏览器级 WebSocket 连接失败: %w", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + + msg := cdpMessage{Id: 1, Method: method, Params: params} + if err := conn.WriteJSON(msg); err != nil { + return fmt.Errorf("浏览器级 CDP 命令发送失败: %w", err) + } + + var cdpResp cdpResponse + if err := conn.ReadJSON(&cdpResp); err != nil { + // Browser.close 可能会直接关闭 websocket,视为成功。 + if strings.EqualFold(method, "Browser.close") { + return nil + } + return fmt.Errorf("浏览器级 CDP 响应读取失败: %w", err) + } + if cdpResp.Error != nil { + return fmt.Errorf("浏览器级 CDP 错误: %s", cdpResp.Error.Message) + } + return nil +} + // getDebugPort 获取运行中实例的调试端口 func (a *App) getDebugPort(profileId string) (int, error) { a.browserMgr.Mutex.Lock() diff --git a/backend/app_instance.go b/backend/app_instance.go index 2cf2f487..d0c371c1 100644 --- a/backend/app_instance.go +++ b/backend/app_instance.go @@ -20,19 +20,25 @@ import ( // ============================================================================ func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) { - return a.browserInstanceStartInternal(profileId, nil, nil, false) + return a.browserInstanceStartInternal(profileId, nil, nil, false, false) } // BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库) func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { - return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs) + return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, true) } -func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { +func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) (*BrowserProfile, error) { log := logger.New("Browser") a.browserMgr.Mutex.Lock() defer a.browserMgr.Mutex.Unlock() + normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs) + normalizedStartURLs := normalizeNonEmptyStrings(startURLs) + if preferVisibleWindow { + normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs) + } + profile, exists := a.browserMgr.Profiles[profileId] if !exists { err := fmt.Errorf("实例启动失败:未找到实例配置(ID=%s)。请刷新列表后重试。", profileId) @@ -40,7 +46,41 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s return nil, err } if profile.Running { - return profile, nil + if !isBrowserProfileLive(profile) { + log.Info("检测到实例运行状态已失效,准备重新启动", + logger.F("profile_id", profileId), + logger.F("pid", profile.Pid), + logger.F("debug_port", profile.DebugPort), + ) + a.markProfileStoppedLocked(profileId, profile) + } else { + if preferVisibleWindow { + if err := a.openBrowserWindowForRunningProfile(profile, normalizedExtraLaunchArgs, normalizedStartURLs); err != nil { + startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err) + log.Error("运行中实例窗口唤起失败", + logger.F("profile_id", profileId), + logger.F("debug_port", profile.DebugPort), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + profile.LastError = startErr.Error() + return profile, startErr + } + } + if a.launchServer != nil { + a.launchServer.SetActiveProfile(profile) + } + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "browser:instance:started", map[string]interface{}{ + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "debugPort": profile.DebugPort, + "pid": profile.Pid, + "reused": true, + }) + } + return profile, nil + } } proxyChanged := a.browserMgr.ApplyDefaults(profile) @@ -105,7 +145,7 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s // hysteria2 / tuic → sing-box 桥接 socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId) if bridgeErr != nil { - startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box.exe 是否存在,以及本地端口是否被占用。", bridgeErr) + startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) log.Error("代理桥接失败(sing-box)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error())) profile.LastError = startErr.Error() if a.ctx != nil { @@ -123,7 +163,7 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s // vmess / vless / trojan / ss → xray 桥接 socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId) if bridgeErr != nil { - startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray.exe 是否存在,以及本地端口是否被占用。", bridgeErr) + startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) log.Error("代理桥接失败(xray)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error())) profile.LastError = startErr.Error() if a.ctx != nil { @@ -181,16 +221,10 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s } args = append(args, profile.FingerprintArgs...) args = append(args, profile.LaunchArgs...) - args = append(args, normalizeNonEmptyStrings(extraLaunchArgs)...) - - if normalizedURLs := normalizeNonEmptyStrings(startURLs); len(normalizedURLs) > 0 { - args = append(args, normalizedURLs...) - } else if !skipDefaultStartURLs { - args = browser.BuildLaunchArgs(args, profile) - } + args = append(args, normalizedExtraLaunchArgs...) + args = appendLaunchTargets(args, profile, normalizedStartURLs, skipDefaultStartURLs) cmd := exec.Command(chromeBinaryPath, args...) - hideWindow(cmd) cmd.Dir = filepath.Dir(chromeBinaryPath) if err := cmd.Start(); err != nil { startErr := fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) @@ -198,7 +232,7 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s profile.LastError = startErr.Error() return profile, startErr } - if err := waitBrowserDebugPortReady(debugPort, browserStartReadyTimeout); err != nil { + if err := waitBrowserDebugPortStable(debugPort, browserStartReadyTimeout, browserStartStableWindow); err != nil { startErr := fmt.Errorf("%s", describeBrowserReadyTimeout(debugPort, browserStartReadyTimeout)) log.Error("浏览器启动未就绪", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error())) _ = a.stopProcessCmd(cmd) @@ -219,10 +253,19 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) releaseXrayBridge = false } + if a.launchServer != nil { + a.launchServer.SetActiveProfile(profile) + } log.Info("实例启动", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("pid", profile.Pid), logger.F("proxy", effectiveProxy), logger.F("args", strings.Join(args, " "))) if a.ctx != nil { - runtime.EventsEmit(a.ctx, "browser:instance:started", profileId) + runtime.EventsEmit(a.ctx, "browser:instance:started", map[string]interface{}{ + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "debugPort": profile.DebugPort, + "pid": profile.Pid, + "reused": false, + }) } go a.waitBrowserProcess(profileId, cmd) @@ -240,6 +283,13 @@ func (a *App) BrowserInstanceStop(profileId string) (*BrowserProfile, error) { } cmd := a.browserMgr.BrowserProcesses[profileId] + debugPort := profile.DebugPort + if tryCloseBrowserViaCDP(debugPort, 5*time.Second) { + a.markProfileStoppedLocked(profileId, profile) + log.Info("实例停止", logger.F("profile_id", profileId), logger.F("method", "cdp"), logger.F("debug_port", debugPort)) + return profile, nil + } + if cmd != nil && cmd.Process != nil { if err := a.stopBrowserProcess(cmd); err != nil { log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("error", err)) @@ -248,11 +298,14 @@ func (a *App) BrowserInstanceStop(profileId string) (*BrowserProfile, error) { } } - profile.Running = false - profile.LastStopAt = time.Now().Format(time.RFC3339) - delete(a.browserMgr.BrowserProcesses, profileId) - a.releaseProfileXrayBridge(profileId) + if debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) { + err := fmt.Errorf("实例停止失败:浏览器仍在运行(调试端口 %d 仍可访问)", debugPort) + log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("reason", err.Error())) + profile.LastError = err.Error() + return profile, err + } + a.markProfileStoppedLocked(profileId, profile) log.Info("实例停止", logger.F("profile_id", profileId)) return profile, nil } @@ -425,16 +478,48 @@ func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab { func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) { err := cmd.Wait() + log := logger.New("Browser") + debugPort := 0 + profileName := profileId + shouldMonitorDetached := false + a.browserMgr.Mutex.Lock() profile, exists := a.browserMgr.Profiles[profileId] wasRunning := exists && profile.Running if exists { - profile.Running = false - profile.LastStopAt = time.Now().Format(time.RFC3339) + profileName = profile.ProfileName + debugPort = profile.DebugPort + } + a.browserMgr.Mutex.Unlock() + + if wasRunning && debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) { + a.browserMgr.Mutex.Lock() + profile, exists = a.browserMgr.Profiles[profileId] + if exists && profile.Running && profile.DebugPort == debugPort { + delete(a.browserMgr.BrowserProcesses, profileId) + profile.Pid = 0 + shouldMonitorDetached = true + } + a.browserMgr.Mutex.Unlock() + if shouldMonitorDetached { + log.Info("浏览器启动器进程已退出,切换为调试端口存活监控", + logger.F("profile_id", profileId), + logger.F("profile_name", profileName), + logger.F("debug_port", debugPort), + ) + a.waitDetachedBrowser(profileId, debugPort) + return + } + } + + a.browserMgr.Mutex.Lock() + profile, exists = a.browserMgr.Profiles[profileId] + wasRunning = exists && profile.Running + if exists { + profileName = profile.ProfileName + a.markProfileStoppedLocked(profileId, profile) } - delete(a.browserMgr.BrowserProcesses, profileId) a.browserMgr.Mutex.Unlock() - a.releaseProfileXrayBridge(profileId) if a.ctx == nil { return @@ -442,11 +527,8 @@ func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) { // 进程是正常退出(用户手动关闭)还是异常崩溃 if wasRunning && err != nil { - log := logger.New("Browser") // 异常退出,推送崩溃通知 - profileName := profileId - if exists { - profileName = profile.ProfileName + if exists && profile != nil { profile.LastError = fmt.Sprintf("实例运行异常退出:%s", err.Error()) } log.Error("浏览器进程异常退出", logger.F("profile_id", profileId), logger.F("profile_name", profileName), logger.F("error", err)) @@ -460,6 +542,66 @@ func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) { } } +func (a *App) waitDetachedBrowser(profileId string, debugPort int) { + const ( + pollInterval = 500 * time.Millisecond + maxMisses = 3 + ) + + log := logger.New("Browser") + misses := 0 + for { + if canConnectDebugPort(debugPort, 250*time.Millisecond) { + misses = 0 + time.Sleep(pollInterval) + continue + } + + misses++ + if misses < maxMisses { + time.Sleep(pollInterval) + continue + } + + profileName := profileId + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists || !profile.Running || profile.DebugPort != debugPort { + a.browserMgr.Mutex.Unlock() + return + } + profileName = profile.ProfileName + a.markProfileStoppedLocked(profileId, profile) + a.browserMgr.Mutex.Unlock() + + log.Info("检测到浏览器调试端口关闭,实例已停止", + logger.F("profile_id", profileId), + logger.F("profile_name", profileName), + logger.F("debug_port", debugPort), + ) + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId) + } + return + } +} + +func tryCloseBrowserViaCDP(debugPort int, timeout time.Duration) bool { + if debugPort <= 0 || !canConnectDebugPort(debugPort, 250*time.Millisecond) { + return false + } + + _ = cdpBrowserCall(debugPort, "Browser.close", nil) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !canConnectDebugPort(debugPort, 250*time.Millisecond) { + return true + } + time.Sleep(150 * time.Millisecond) + } + return false +} + func normalizeNonEmptyStrings(items []string) []string { if len(items) == 0 { return nil @@ -474,6 +616,80 @@ func normalizeNonEmptyStrings(items []string) []string { return out } +func ensureNewWindowLaunchArg(args []string) []string { + for _, arg := range args { + if strings.EqualFold(strings.TrimSpace(arg), "--new-window") { + return args + } + } + return append(args, "--new-window") +} + +func appendLaunchTargets(args []string, profile *BrowserProfile, startURLs []string, skipDefaultStartURLs bool) []string { + if len(startURLs) > 0 { + return append(args, startURLs...) + } + if !skipDefaultStartURLs { + return browser.BuildLaunchArgs(args, profile) + } + return args +} + +func isBrowserProfileLive(profile *BrowserProfile) bool { + if profile == nil || !profile.Running || profile.DebugPort <= 0 { + return false + } + return canConnectDebugPort(profile.DebugPort, 250*time.Millisecond) +} + +func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile) { + if profile == nil { + return + } + profile.Running = false + profile.Pid = 0 + profile.DebugPort = 0 + profile.LastStopAt = time.Now().Format(time.RFC3339) + delete(a.browserMgr.BrowserProcesses, profileId) + a.releaseProfileXrayBridge(profileId) + if a.launchServer != nil { + a.launchServer.ClearActiveProfile(profileId) + } +} + +func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraLaunchArgs []string, startURLs []string) error { + chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile) + if err != nil { + return err + } + + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if err := os.MkdirAll(userDataDir, 0755); err != nil { + return fmt.Errorf("无法创建用户数据目录 %s:%w", userDataDir, err) + } + + args := []string{ + fmt.Sprintf("--user-data-dir=%s", userDataDir), + } + args = append(args, extraLaunchArgs...) + if len(startURLs) > 0 { + args = append(args, startURLs...) + } else { + args = append(args, "about:blank") + } + + cmd := exec.Command(chromeBinaryPath, args...) + cmd.Dir = filepath.Dir(chromeBinaryPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) + } + + go func() { + _ = cmd.Wait() + }() + return nil +} + func (a *App) stopBrowserProcess(cmd *exec.Cmd) error { return a.stopProcessCmd(cmd) } diff --git a/backend/app_instance_errors.go b/backend/app_instance_errors.go index b7d88f44..5174b63a 100644 --- a/backend/app_instance_errors.go +++ b/backend/app_instance_errors.go @@ -8,15 +8,13 @@ import ( ) const browserStartReadyTimeout = 10 * time.Second +const browserStartStableWindow = 1200 * time.Millisecond func waitBrowserDebugPortReady(debugPort int, timeout time.Duration) error { - address := fmt.Sprintf("127.0.0.1:%d", debugPort) deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - conn, err := net.DialTimeout("tcp", address, 250*time.Millisecond) - if err == nil { - _ = conn.Close() + if canConnectDebugPort(debugPort, 250*time.Millisecond) { return nil } time.Sleep(150 * time.Millisecond) @@ -25,6 +23,38 @@ func waitBrowserDebugPortReady(debugPort int, timeout time.Duration) error { return fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort) } +func waitBrowserDebugPortStable(debugPort int, timeout time.Duration, stableFor time.Duration) error { + if err := waitBrowserDebugPortReady(debugPort, timeout); err != nil { + return err + } + if stableFor <= 0 { + return nil + } + + deadline := time.Now().Add(stableFor) + for time.Now().Before(deadline) { + if !canConnectDebugPort(debugPort, 250*time.Millisecond) { + return fmt.Errorf("浏览器调试端口 %d 短暂就绪后又失效", debugPort) + } + time.Sleep(150 * time.Millisecond) + } + return nil +} + +func canConnectDebugPort(debugPort int, dialTimeout time.Duration) bool { + if debugPort <= 0 { + return false + } + + address := fmt.Sprintf("127.0.0.1:%d", debugPort) + conn, err := net.DialTimeout("tcp", address, dialTimeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} + func describeChromeProcessStartError(chromeBinaryPath string, err error) string { raw := strings.TrimSpace(err.Error()) lower := strings.ToLower(raw) @@ -37,8 +67,10 @@ func describeChromeProcessStartError(chromeBinaryPath string, err error) string case strings.Contains(lower, "not a valid win32 application"), strings.Contains(raw, "不是有效的 win32 应用程序"), strings.Contains(raw, "不是有效的 Win32 应用程序"), - strings.Contains(raw, "bad exe format"): - return fmt.Sprintf("实例启动失败:当前浏览器内核与系统架构不兼容。可执行文件:%s。请更换为可用的 Windows 64 位 Chrome 内核。", chromeBinaryPath) + strings.Contains(raw, "bad exe format"), + strings.Contains(lower, "exec format error"), + strings.Contains(lower, "cannot execute binary file"): + return fmt.Sprintf("实例启动失败:当前浏览器内核与系统/架构不兼容。可执行文件:%s。请确认 Linux 环境使用的是对应架构的 Chrome 内核,而不是 Windows 可执行文件。", chromeBinaryPath) case strings.Contains(raw, "系统找不到指定的文件"), strings.Contains(lower, "file not found"), strings.Contains(lower, "no such file"), diff --git a/backend/app_instance_errors_test.go b/backend/app_instance_errors_test.go index 3407ebda..6616ee9d 100644 --- a/backend/app_instance_errors_test.go +++ b/backend/app_instance_errors_test.go @@ -26,7 +26,12 @@ func TestDescribeChromeProcessStartError(t *testing.T) { { name: "invalid win32", err: fmt.Errorf("%%1 is not a valid Win32 application"), - want: "与系统架构不兼容", + want: "与系统/架构不兼容", + }, + { + name: "linux exec format error", + err: fmt.Errorf("fork/exec /opt/chrome/chrome.exe: exec format error"), + want: "与系统/架构不兼容", }, } diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go new file mode 100644 index 00000000..c9353a55 --- /dev/null +++ b/backend/app_instance_start_test.go @@ -0,0 +1,179 @@ +package backend + +import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "net" + "os/exec" + "reflect" + goruntime "runtime" + "testing" + "time" +) + +func TestEnsureNewWindowLaunchArgAddsFlagOnce(t *testing.T) { + t.Parallel() + + got := ensureNewWindowLaunchArg([]string{"--lang=en-US"}) + want := []string{"--lang=en-US", "--new-window"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ensureNewWindowLaunchArg 结果错误: got=%v want=%v", got, want) + } + + got = ensureNewWindowLaunchArg([]string{"--new-window", "--lang=en-US"}) + want = []string{"--new-window", "--lang=en-US"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ensureNewWindowLaunchArg 不应重复追加: got=%v want=%v", got, want) + } +} + +func TestIsBrowserProfileLive(t *testing.T) { + t.Parallel() + + ln := mustListenLoopback(t) + defer ln.Close() + + profile := &BrowserProfile{ + Running: true, + DebugPort: listenerPort(t, ln), + } + if !isBrowserProfileLive(profile) { + t.Fatal("期望存活中的调试端口被识别为运行中实例") + } + + if isBrowserProfileLive(&BrowserProfile{Running: true, DebugPort: 0}) { + t.Fatal("debugPort=0 不应被识别为运行中实例") + } +} + +func TestWaitBrowserDebugPortStableKeepsListeningPort(t *testing.T) { + t.Parallel() + + ln := mustListenLoopback(t) + defer ln.Close() + + if err := waitBrowserDebugPortStable(listenerPort(t, ln), time.Second, 250*time.Millisecond); err != nil { + t.Fatalf("waitBrowserDebugPortStable 返回错误: %v", err) + } +} + +func TestWaitBrowserDebugPortStableRejectsEphemeralPort(t *testing.T) { + t.Parallel() + + ln := mustListenLoopback(t) + port := listenerPort(t, ln) + time.AfterFunc(120*time.Millisecond, func() { + _ = ln.Close() + }) + + err := waitBrowserDebugPortStable(port, time.Second, 400*time.Millisecond) + if err == nil { + t.Fatal("期望短暂就绪后关闭的端口被判定为失败") + } +} + +func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) { + ln := mustListenLoopback(t) + port := listenerPort(t, ln) + + app := NewApp("") + app.browserMgr = browser.NewManager(config.DefaultConfig(), "") + app.browserMgr.Profiles = map[string]*BrowserProfile{ + "profile-detached": { + ProfileId: "profile-detached", + ProfileName: "Detached Browser", + Running: true, + DebugPort: port, + Pid: 12345, + }, + } + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + + cmd := shortLivedCommand() + if err := cmd.Start(); err != nil { + t.Fatalf("启动短命测试进程失败: %v", err) + } + app.browserMgr.BrowserProcesses["profile-detached"] = cmd + + done := make(chan struct{}) + go func() { + app.waitBrowserProcess("profile-detached", cmd) + close(done) + }() + + waitForCondition(t, 3*time.Second, func() bool { + app.browserMgr.Mutex.Lock() + defer app.browserMgr.Mutex.Unlock() + + profile := app.browserMgr.Profiles["profile-detached"] + _, tracked := app.browserMgr.BrowserProcesses["profile-detached"] + return profile != nil && profile.Running && !tracked + }) + + _ = ln.Close() + + waitForCondition(t, 4*time.Second, func() bool { + app.browserMgr.Mutex.Lock() + defer app.browserMgr.Mutex.Unlock() + + profile := app.browserMgr.Profiles["profile-detached"] + return profile != nil && !profile.Running && profile.DebugPort == 0 && profile.Pid == 0 + }) + + select { + case <-done: + case <-time.After(4 * time.Second): + t.Fatal("waitBrowserProcess 未在调试端口关闭后结束") + } +} + +func mustListenLoopback(t *testing.T) net.Listener { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("监听测试端口失败: %v", err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + + return ln +} + +func listenerPort(t *testing.T, ln net.Listener) int { + t.Helper() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("解析监听地址失败: %T", ln.Addr()) + } + return tcpAddr.Port +} + +func shortLivedCommand() *exec.Cmd { + if goruntime.GOOS == "windows" { + return exec.Command("cmd", "/c", "exit", "0") + } + return exec.Command("sh", "-c", "exit 0") +} + +func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("等待条件成立超时") +} diff --git a/backend/app_launchcode.go b/backend/app_launchcode.go index 1f8db48b..b46b6228 100644 --- a/backend/app_launchcode.go +++ b/backend/app_launchcode.go @@ -72,8 +72,14 @@ func (a *App) GetLaunchServerInfo() map[string]interface{} { } if actualPort > 0 { info["baseUrl"] = fmt.Sprintf("http://127.0.0.1:%d", actualPort) + info["cdpUrl"] = fmt.Sprintf("http://127.0.0.1:%d", actualPort) + if a.launchServer != nil { + info["activeDebugPort"] = a.launchServer.ActiveDebugPort() + } } else { info["baseUrl"] = "" + info["cdpUrl"] = "" + info["activeDebugPort"] = 0 } return info } diff --git a/backend/app_license.go b/backend/app_license.go index 5c7d3079..42d749f9 100644 --- a/backend/app_license.go +++ b/backend/app_license.go @@ -1,6 +1,7 @@ package backend import ( + appconfig "ant-chrome/backend/internal/config" "crypto/sha256" "encoding/hex" "fmt" @@ -81,7 +82,7 @@ func (a *App) RedeemCDKey(cdkey string) error { } // 3. 兑现与本地保存 - a.config.App.MaxProfileLimit += 3 + a.config.App.MaxProfileLimit += appconfig.StandardCDKeyProfileBonus a.config.App.UsedCDKeys = append(a.config.App.UsedCDKeys, cdkey) configPath := a.resolveAppPath("config.yaml") @@ -107,7 +108,7 @@ func (a *App) RedeemGithubStar() error { if a.config == nil { a.config = DefaultConfig() } - cdkey := "GITHUB_STAR_REWARD" + cdkey := appconfig.GithubStarRewardKey // 防重复领取 for _, usedKey := range a.config.App.UsedCDKeys { if usedKey == cdkey { @@ -115,9 +116,11 @@ func (a *App) RedeemGithubStar() error { } } - // 兑现与本地保存: 增加 3 个 - a.config.App.MaxProfileLimit += 3 a.config.App.UsedCDKeys = append(a.config.App.UsedCDKeys, cdkey) + a.config.App.MaxProfileLimit += appconfig.GithubStarProfileBonus + if minLimit := appconfig.MinimumProfileLimitForUsedKeys(a.config.App.UsedCDKeys); a.config.App.MaxProfileLimit < minLimit { + a.config.App.MaxProfileLimit = minLimit + } configPath := a.resolveAppPath("config.yaml") if _, _, err := reconcileConfigWithLocalLicense(configPath, a.config); err != nil { diff --git a/backend/app_paths.go b/backend/app_paths.go index 65c0c019..cd0b9864 100644 --- a/backend/app_paths.go +++ b/backend/app_paths.go @@ -1,26 +1,17 @@ package backend import ( - "os" - "path/filepath" - "strings" + "ant-chrome/backend/internal/apppath" ) // appRootAbs 返回应用根目录的绝对路径,优先使用 App 注入的 appRoot。 func (a *App) appRootAbs() string { - root := strings.TrimSpace(a.appRoot) - if root == "" { - if cwd, err := os.Getwd(); err == nil { - root = cwd - } - } - if root == "" { - return "" - } - if abs, err := filepath.Abs(root); err == nil { - return abs - } - return root + return apppath.InstallRoot(a.appRoot) +} + +// appStateRootAbs 返回应用可写状态目录的绝对路径。 +func (a *App) appStateRootAbs() string { + return apppath.StateRoot(a.appRoot) } // appDataDir 返回 data 根目录绝对路径。 diff --git a/backend/app_proxy_binding.go b/backend/app_proxy_binding.go new file mode 100644 index 00000000..c82af8f4 --- /dev/null +++ b/backend/app_proxy_binding.go @@ -0,0 +1,51 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "time" +) + +// reconcileProfileProxyBindings 对实例代理绑定执行幂等修复: +// 1. 同步已存在 proxyId 的绑定快照; +// 2. 当 proxyId 失效时按绑定快照/配置执行自动重关联; +// 3. 仅在有变更时持久化。 +func (a *App) reconcileProfileProxyBindings() { + if a == nil || a.browserMgr == nil { + return + } + + log := logger.New("Browser") + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + changedCount := 0 + reboundCount := 0 + for _, profile := range a.browserMgr.Profiles { + changed, boundInPool, mode := a.browserMgr.ResolveProfileProxyBinding(profile) + if changed { + profile.UpdatedAt = time.Now().Format(time.RFC3339) + changedCount++ + } + if boundInPool && mode != "" && mode != "proxy_id" { + reboundCount++ + log.Info("实例代理重关联成功", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("proxy_id", profile.ProxyId), + logger.F("mode", mode), + ) + } + } + + if changedCount == 0 { + return + } + if err := a.browserMgr.SaveProfiles(); err != nil { + log.Error("实例代理绑定修复持久化失败", logger.F("error", err.Error())) + return + } + log.Info("实例代理绑定修复完成", + logger.F("changed", changedCount), + logger.F("rebound", reboundCount), + ) +} diff --git a/backend/app_reload_test.go b/backend/app_reload_test.go index afdd589d..ca223973 100644 --- a/backend/app_reload_test.go +++ b/backend/app_reload_test.go @@ -38,7 +38,7 @@ func TestReloadConfigKeepsLocalLicenseState(t *testing.T) { t.Fatalf("写入测试配置失败: %v", err) } if err := saveLocalLicenseState(filepath.Join(root, "config.yaml"), &localLicenseState{ - MaxProfileLimit: 9, + MaxProfileLimit: config.GithubStarProfileTotal + config.StandardCDKeyProfileBonus, UsedCDKeys: []string{"ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE", "GITHUB_STAR_REWARD"}, }); err != nil { t.Fatalf("写入本机额度状态失败: %v", err) @@ -51,7 +51,7 @@ func TestReloadConfigKeepsLocalLicenseState(t *testing.T) { t.Fatalf("ReloadConfig 失败: %v", err) } - if app.config.App.MaxProfileLimit != 9 { + if app.config.App.MaxProfileLimit != config.GithubStarProfileTotal+config.StandardCDKeyProfileBonus { t.Fatalf("ReloadConfig 未恢复本机额度状态: got=%d", app.config.App.MaxProfileLimit) } if len(app.config.App.UsedCDKeys) != 2 { diff --git a/backend/app_utils.go b/backend/app_utils.go index 6dcc137e..60f3a108 100644 --- a/backend/app_utils.go +++ b/backend/app_utils.go @@ -1,6 +1,7 @@ package backend import ( + "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" @@ -20,16 +21,7 @@ import ( // resolveAppPath 将相对路径解析为绝对路径(基于 appRoot)。 // 如果传入的已经是绝对路径则直接返回。 func (a *App) resolveAppPath(p string) string { - if filepath.IsAbs(p) { - return p - } - if a.appRoot != "" { - return filepath.Join(a.appRoot, p) - } - if cwd, err := os.Getwd(); err == nil { - return filepath.Join(cwd, p) - } - return p + return apppath.Resolve(a.appRoot, p) } func generateUUID() string { @@ -124,8 +116,8 @@ func (a *App) autoDetectCores() { } } -// scanChromeDir 扫描指定目录,将包含 chrome.exe 的子文件夹识别为内核。 -// 如果目录本身包含 chrome.exe(旧版单内核结构),则直接返回该目录作为内核。 +// scanChromeDir 扫描指定目录,将包含浏览器可执行文件的子文件夹识别为内核。 +// 如果目录本身包含可执行文件(旧版单内核结构),则直接返回该目录作为内核。 func (a *App) scanChromeDir(chromeRoot string) []browser.Core { log := logger.New("Browser") @@ -135,8 +127,8 @@ func (a *App) scanChromeDir(chromeRoot string) []browser.Core { return nil } - // 如果根目录本身就有 chrome.exe,视为单内核结构 - if _, err := os.Stat(filepath.Join(baseDir, "chrome.exe")); err == nil { + // 如果根目录本身就有浏览器可执行文件,视为单内核结构 + if _, _, ok := browser.FindCoreExecutable(baseDir); ok { return []browser.Core{ { CoreId: "default", @@ -160,9 +152,9 @@ func (a *App) scanChromeDir(chromeRoot string) []browser.Core { continue } subPath := filepath.Join(chromeRoot, entry.Name()) - absExe := filepath.Join(baseDir, entry.Name(), "chrome.exe") - if _, err := os.Stat(absExe); err != nil { - continue // 没有 chrome.exe,跳过 + absCoreDir := filepath.Join(baseDir, entry.Name()) + if _, _, ok := browser.FindCoreExecutable(absCoreDir); !ok { + continue // 没有浏览器可执行文件,跳过 } isDefault := len(cores) == 0 cores = append(cores, browser.Core{ diff --git a/backend/internal/apppath/apppath.go b/backend/internal/apppath/apppath.go new file mode 100644 index 00000000..f4577146 --- /dev/null +++ b/backend/internal/apppath/apppath.go @@ -0,0 +1,286 @@ +package apppath + +import ( + "ant-chrome/backend/internal/fsutil" + "io" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "sync" +) + +const appStateDirName = "ant-browser" + +type roots struct { + installRoot string + stateRoot string + detached bool +} + +var rootsCache sync.Map + +// InstallRoot 返回应用安装根目录的绝对路径。 +func InstallRoot(appRoot string) string { + return detect(appRoot).installRoot +} + +// StateRoot 返回应用可写状态目录的绝对路径。 +func StateRoot(appRoot string) string { + return detect(appRoot).stateRoot +} + +// IsDetached 返回当前是否启用了“安装目录只读、状态目录独立”的模式。 +func IsDetached(appRoot string) bool { + return detect(appRoot).detached +} + +// Resolve 将相对路径解析到安装目录或用户状态目录。 +// 已安装的 Linux / macOS 应用在启用 detached 模式后,除 bin/ 外的相对路径都会落到用户可写目录。 +func Resolve(appRoot, p string) string { + return resolveForOS(appRoot, p, goruntime.GOOS) +} + +func resolveForOS(appRoot, p, goos string) string { + p = fsutil.NormalizePathInput(p) + if p == "" { + return "" + } + if filepath.IsAbs(p) { + return filepath.Clean(p) + } + + root := detectForOS(appRoot, goos) + base := root.installRoot + if root.detached && useStateRoot(p) { + base = root.stateRoot + } + return filepath.Join(base, p) +} + +// EnsureWritableLayout 为需要 detached 状态目录的已安装应用准备首启所需的可写目录, +// 并把随包默认配置迁移到用户目录。 +func EnsureWritableLayout(appRoot string) error { + return ensureWritableLayoutForOS(appRoot, goruntime.GOOS) +} + +func ensureWritableLayoutForOS(appRoot, goos string) error { + root := detectForOS(appRoot, goos) + if !root.detached { + return nil + } + + if err := os.MkdirAll(root.stateRoot, 0755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(root.stateRoot, "data"), 0755); err != nil { + return err + } + + if err := copyFileIfMissing( + filepath.Join(root.installRoot, "config.yaml"), + filepath.Join(root.stateRoot, "config.yaml"), + ); err != nil { + return err + } + if err := copyFileIfMissing( + filepath.Join(root.installRoot, "proxies.yaml"), + filepath.Join(root.stateRoot, "proxies.yaml"), + ); err != nil { + return err + } + if err := copyDirIfMissing( + filepath.Join(root.installRoot, "chrome"), + filepath.Join(root.stateRoot, "chrome"), + ); err != nil { + return err + } + + return nil +} + +func detect(appRoot string) roots { + return detectForOS(appRoot, goruntime.GOOS) +} + +func detectForOS(appRoot, goos string) roots { + normalized := normalizeRoot(appRoot) + cacheKey := buildCacheKey(goos, normalized) + if cached, ok := rootsCache.Load(cacheKey); ok { + return cached.(roots) + } + + root := roots{ + installRoot: normalized, + stateRoot: normalized, + } + if shouldDetachStateRoot(goos, normalized) { + root.stateRoot = userStateRootForOS(goos, normalized) + root.detached = root.stateRoot != "" && root.stateRoot != normalized + } + + actual, _ := rootsCache.LoadOrStore(cacheKey, root) + return actual.(roots) +} + +func buildCacheKey(goos, root string) string { + return normalizeGOOS(goos) + "\x00" + root +} + +func normalizeGOOS(goos string) string { + return strings.ToLower(strings.TrimSpace(goos)) +} + +func shouldDetachStateRoot(goos, installRoot string) bool { + switch normalizeGOOS(goos) { + case "linux": + return !dirWritable(installRoot) + case "darwin": + return isMacAppBundleRoot(installRoot) || !dirWritable(installRoot) + default: + return false + } +} + +func normalizeRoot(appRoot string) string { + root := strings.TrimSpace(appRoot) + if root == "" { + if cwd, err := os.Getwd(); err == nil { + root = cwd + } + } + if root == "" { + root = "." + } + if abs, err := filepath.Abs(root); err == nil { + return abs + } + return filepath.Clean(root) +} + +func userStateRootForOS(goos, fallback string) string { + switch normalizeGOOS(goos) { + case "linux": + if base := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); base != "" { + return filepath.Join(base, appStateDirName) + } + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + return filepath.Join(home, ".local", "share", appStateDirName) + } + case "darwin": + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + return filepath.Join(home, "Library", "Application Support", appStateDirName) + } + } + if tmp := strings.TrimSpace(os.TempDir()); tmp != "" { + return filepath.Join(tmp, appStateDirName) + } + return fallback +} + +func isMacAppBundleRoot(dir string) bool { + clean := strings.TrimSuffix(filepath.ToSlash(filepath.Clean(dir)), "/") + lower := strings.ToLower(clean) + return strings.HasSuffix(lower, ".app/contents/macos") || strings.HasSuffix(lower, ".app/contents/resources") +} + +func dirWritable(dir string) bool { + file, err := os.CreateTemp(dir, ".ant-browser-write-test-*") + if err != nil { + return false + } + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + return true +} + +func useStateRoot(p string) bool { + clean := filepath.ToSlash(fsutil.NormalizePathInput(p)) + if clean == "" || clean == "." { + return false + } + return clean != "bin" && !strings.HasPrefix(clean, "bin/") +} + +func copyFileIfMissing(src, dst string) error { + if _, err := os.Stat(dst); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + + data, err := os.ReadFile(src) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} + +func copyDirIfMissing(src, dst string) error { + if _, err := os.Stat(dst); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + + info, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + + return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := dst + if rel != "." { + target = filepath.Join(dst, rel) + } + if info.IsDir() { + dirMode := info.Mode().Perm() | 0700 + return os.MkdirAll(target, dirMode) + } + return copyFile(path, target, info.Mode().Perm()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + 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) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} diff --git a/backend/internal/apppath/apppath_test.go b/backend/internal/apppath/apppath_test.go new file mode 100644 index 00000000..ec8a3108 --- /dev/null +++ b/backend/internal/apppath/apppath_test.go @@ -0,0 +1,149 @@ +package apppath + +import ( + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" +) + +func TestResolveReadOnlyLinuxInstallUsesUserDataRoot(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("linux-only path behavior") + } + + xdgDataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdgDataHome) + + installRoot := filepath.Join(t.TempDir(), "opt-app") + if err := os.MkdirAll(filepath.Join(installRoot, "bin"), 0755); err != nil { + t.Fatalf("创建 installRoot 失败: %v", err) + } + if err := os.Chmod(installRoot, 0555); err != nil { + t.Fatalf("设置 installRoot 权限失败: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(installRoot, 0755) + _ = os.Chmod(filepath.Join(installRoot, "bin"), 0755) + }) + + configPath := resolveForOS(installRoot, "config.yaml", "linux") + binPath := resolveForOS(installRoot, "bin/xray", "linux") + + expectedStateRoot := filepath.Join(xdgDataHome, appStateDirName) + if !strings.HasPrefix(configPath, expectedStateRoot+string(os.PathSeparator)) { + t.Fatalf("config path 应落到用户目录,got=%s want-prefix=%s", configPath, expectedStateRoot) + } + if binPath != filepath.Join(installRoot, "bin", "xray") { + t.Fatalf("bin path 不应迁移到用户目录,got=%s", binPath) + } +} + +func TestResolveDarwinAppBundleUsesApplicationSupportStateRoot(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + installRoot := filepath.Join(t.TempDir(), "Ant Browser.app", "Contents", "MacOS") + if err := os.MkdirAll(filepath.Join(installRoot, "bin"), 0755); err != nil { + t.Fatalf("创建 installRoot 失败: %v", err) + } + + configPath := resolveForOS(installRoot, "config.yaml", "darwin") + binPath := resolveForOS(installRoot, "bin/xray", "darwin") + expectedStateRoot := filepath.Join(homeDir, "Library", "Application Support", appStateDirName) + + if configPath != filepath.Join(expectedStateRoot, "config.yaml") { + t.Fatalf("darwin config path 应落到 Application Support,got=%s want=%s", configPath, filepath.Join(expectedStateRoot, "config.yaml")) + } + if binPath != filepath.Join(installRoot, "bin", "xray") { + t.Fatalf("darwin bin path 不应迁移到用户目录,got=%s", binPath) + } + + root := detectForOS(installRoot, "darwin") + if !root.detached { + t.Fatal("expected darwin .app bundle root to use detached state") + } + if root.stateRoot != expectedStateRoot { + t.Fatalf("unexpected darwin state root: got=%s want=%s", root.stateRoot, expectedStateRoot) + } +} + +func TestEnsureWritableLayoutSeedsConfigAndChrome(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("linux-only path behavior") + } + + xdgDataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdgDataHome) + + installRoot := filepath.Join(t.TempDir(), "opt-app") + if err := os.MkdirAll(filepath.Join(installRoot, "chrome"), 0755); err != nil { + t.Fatalf("创建 chrome 目录失败: %v", err) + } + if err := os.WriteFile(filepath.Join(installRoot, "config.yaml"), []byte("name: linux\n"), 0644); err != nil { + t.Fatalf("写入 config.yaml 失败: %v", err) + } + if err := os.WriteFile(filepath.Join(installRoot, "chrome", "README.md"), []byte("placeholder\n"), 0644); err != nil { + t.Fatalf("写入 README 失败: %v", err) + } + if err := os.Chmod(installRoot, 0555); err != nil { + t.Fatalf("设置 installRoot 权限失败: %v", err) + } + if err := os.Chmod(filepath.Join(installRoot, "chrome"), 0555); err != nil { + t.Fatalf("设置 chrome 目录权限失败: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(installRoot, 0755) + _ = os.Chmod(filepath.Join(installRoot, "chrome"), 0755) + }) + + if err := ensureWritableLayoutForOS(installRoot, "linux"); err != nil { + t.Fatalf("EnsureWritableLayout 返回错误: %v", err) + } + + stateRoot := filepath.Join(xdgDataHome, appStateDirName) + assertFileContent(t, filepath.Join(stateRoot, "config.yaml"), "name: linux\n") + assertFileContent(t, filepath.Join(stateRoot, "chrome", "README.md"), "placeholder\n") + if _, err := os.Stat(filepath.Join(stateRoot, "data")); err != nil { + t.Fatalf("data 目录未创建: %v", err) + } +} + +func TestEnsureWritableLayoutSeedsDarwinBundleStateRoot(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + installRoot := filepath.Join(t.TempDir(), "Ant Browser.app", "Contents", "MacOS") + if err := os.MkdirAll(filepath.Join(installRoot, "chrome"), 0755); err != nil { + t.Fatalf("创建 chrome 目录失败: %v", err) + } + if err := os.WriteFile(filepath.Join(installRoot, "config.yaml"), []byte("name: mac\n"), 0644); err != nil { + t.Fatalf("写入 config.yaml 失败: %v", err) + } + if err := os.WriteFile(filepath.Join(installRoot, "chrome", "README.md"), []byte("mac placeholder\n"), 0644); err != nil { + t.Fatalf("写入 README 失败: %v", err) + } + + if err := ensureWritableLayoutForOS(installRoot, "darwin"); err != nil { + t.Fatalf("ensureWritableLayoutForOS 返回错误: %v", err) + } + + stateRoot := filepath.Join(homeDir, "Library", "Application Support", appStateDirName) + assertFileContent(t, filepath.Join(stateRoot, "config.yaml"), "name: mac\n") + assertFileContent(t, filepath.Join(stateRoot, "chrome", "README.md"), "mac placeholder\n") + if _, err := os.Stat(filepath.Join(stateRoot, "data")); err != nil { + t.Fatalf("data 目录未创建: %v", err) + } +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读取文件失败 %s: %v", path, err) + } + if string(data) != want { + t.Fatalf("文件内容不符合预期 %s: got=%q want=%q", path, string(data), want) + } +} diff --git a/backend/internal/backup/spec.go b/backend/internal/backup/spec.go index c576f02d..6367729e 100644 --- a/backend/internal/backup/spec.go +++ b/backend/internal/backup/spec.go @@ -242,7 +242,7 @@ func BuildManifest(scope Scope, appName, appVersion string, createdAt time.Time) } version := strings.TrimSpace(appVersion) if version == "" { - version = "1.0.0" + version = "unknown" } entries := make([]ManifestEntry, 0, len(scope.Entries)) diff --git a/backend/internal/backup/spec_test.go b/backend/internal/backup/spec_test.go index 0e4ebe2d..9fbc683f 100644 --- a/backend/internal/backup/spec_test.go +++ b/backend/internal/backup/spec_test.go @@ -97,7 +97,7 @@ func TestBuildManifest_StripsSourcePath(t *testing.T) { } at := time.Date(2026, 3, 2, 12, 0, 0, 0, time.UTC) - manifest := BuildManifest(scope, "Ant Browser", "1.0.0", at) + manifest := BuildManifest(scope, "Ant Browser", "1.1.0", at) if manifest.CreatedAt != "2026-03-02T12:00:00Z" { t.Fatalf("CreatedAt 不匹配: %s", manifest.CreatedAt) @@ -105,7 +105,7 @@ func TestBuildManifest_StripsSourcePath(t *testing.T) { if manifest.App.Name != "Ant Browser" { t.Fatalf("manifest app name 不正确: %s", manifest.App.Name) } - if manifest.App.Version != "1.0.0" { + if manifest.App.Version != "1.1.0" { t.Fatalf("manifest app version 不正确: %s", manifest.App.Version) } diff --git a/backend/internal/browser/connector.go b/backend/internal/browser/connector.go index 8d29a389..b982e298 100644 --- a/backend/internal/browser/connector.go +++ b/backend/internal/browser/connector.go @@ -1,6 +1,13 @@ package browser +var defaultVerificationURLs = []string{ + "https://ippure.com/", + "https://iplark.com/", + "https://ping0.cc/", +} + // BuildLaunchArgs 构建启动参数 func BuildLaunchArgs(args []string, profile *Profile) []string { + args = append(args, defaultVerificationURLs...) return args } diff --git a/backend/internal/browser/connector_test.go b/backend/internal/browser/connector_test.go new file mode 100644 index 00000000..dc6ec393 --- /dev/null +++ b/backend/internal/browser/connector_test.go @@ -0,0 +1,23 @@ +package browser + +import ( + "reflect" + "testing" +) + +func TestBuildLaunchArgsAppendsDefaultVerificationURLs(t *testing.T) { + t.Parallel() + + baseArgs := []string{"--disable-sync"} + got := BuildLaunchArgs(append([]string{}, baseArgs...), &Profile{}) + want := []string{ + "--disable-sync", + "https://ippure.com/", + "https://iplark.com/", + "https://ping0.cc/", + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildLaunchArgs 结果错误:\n got=%v\nwant=%v", got, want) + } +} diff --git a/backend/internal/browser/core.go b/backend/internal/browser/core.go index ee8d83c5..6bac1e75 100644 --- a/backend/internal/browser/core.go +++ b/backend/internal/browser/core.go @@ -1,6 +1,7 @@ package browser import ( + "ant-chrome/backend/internal/fsutil" "ant-chrome/backend/internal/logger" "encoding/json" "fmt" @@ -11,13 +12,21 @@ import ( "github.com/google/uuid" ) +func normalizeProfileCoreID(coreId string) string { + coreId = strings.TrimSpace(coreId) + if strings.EqualFold(coreId, "default") { + return "" + } + return coreId +} + // GetCore 根据 coreId 获取内核配置 func (m *Manager) GetCore(coreId string) (Core, bool) { - coreId = strings.TrimSpace(coreId) + coreId = normalizeProfileCoreID(coreId) if coreId == "" { return Core{}, false } - for _, core := range m.Config.Browser.Cores { + for _, core := range m.ListCores() { if strings.EqualFold(core.CoreId, coreId) { return core, true } @@ -27,13 +36,14 @@ func (m *Manager) GetCore(coreId string) (Core, bool) { // GetDefaultCore 获取默认内核 func (m *Manager) GetDefaultCore() (Core, bool) { - for _, core := range m.Config.Browser.Cores { + cores := m.ListCores() + for _, core := range cores { if core.IsDefault { return core, true } } - if len(m.Config.Browser.Cores) > 0 { - return m.Config.Browser.Cores[0], true + if len(cores) > 0 { + return cores[0], true } return Core{}, false } @@ -46,13 +56,12 @@ func (m *Manager) ResolveCoreExecutable(core Core) (string, error) { } baseDir := m.ResolveRelativePath(corePath) - - exePath := filepath.Join(baseDir, "chrome.exe") - if _, err := os.Stat(exePath); err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("浏览器内核目录无效:未找到 chrome.exe(%s)。请检查内核目录是否完整或重新下载内核", exePath) - } - return "", fmt.Errorf("浏览器内核目录不可访问:%s。原因:%v", exePath, err) + exePath, _, ok := FindCoreExecutable(baseDir) + if !ok { + return "", fmt.Errorf("浏览器内核目录无效:未找到可执行文件(候选:%s)。请检查内核目录是否完整或重新下载内核", strings.Join(CoreExecutableCandidates(), ", ")) + } + if err := fsutil.EnsureExecutable(exePath); err != nil { + return "", fmt.Errorf("浏览器内核文件不可执行:%s。原因:%w。请检查文件权限或重新解压内核", exePath, err) } return exePath, nil @@ -70,10 +79,12 @@ func (m *Manager) ValidateCorePath(corePath string) CoreValidateResult { if _, err := os.Stat(baseDir); os.IsNotExist(err) { return CoreValidateResult{Valid: false, Message: fmt.Sprintf("目录不存在: %s", baseDir)} } - - exePath := filepath.Join(baseDir, "chrome.exe") - if _, err := os.Stat(exePath); os.IsNotExist(err) { - return CoreValidateResult{Valid: false, Message: fmt.Sprintf("chrome.exe 不存在: %s", exePath)} + exePath, _, ok := FindCoreExecutable(baseDir) + if !ok { + return CoreValidateResult{Valid: false, Message: fmt.Sprintf("未找到浏览器可执行文件(候选:%s)", strings.Join(CoreExecutableCandidates(), ", "))} + } + if err := fsutil.ValidateExecutable(exePath); err != nil { + return CoreValidateResult{Valid: false, Message: fmt.Sprintf("浏览器可执行文件不可用:%v", err)} } return CoreValidateResult{Valid: true, Message: fmt.Sprintf("路径有效: %s", exePath)} @@ -247,7 +258,7 @@ func (m *Manager) clearDefaultCore() { // ResolveChromeBinary 解析 Chrome 二进制路径(简化版) func (m *Manager) ResolveChromeBinary(profile *Profile) (string, error) { log := logger.New("Browser") - coreId := strings.TrimSpace(profile.CoreId) + coreId := normalizeProfileCoreID(profile.CoreId) var core Core var found bool @@ -313,8 +324,7 @@ func (m *Manager) GetChromeVersion(corePath string) string { func (m *Manager) CountInstancesByCore(coreId string) int { coreId = strings.TrimSpace(coreId) count := 0 - for _, profile := range m.Config.Browser.Profiles { - profileCoreId := strings.TrimSpace(profile.CoreId) + countByCoreID := func(profileCoreId string) { // 如果实例的 CoreId 为空,则使用默认内核 if profileCoreId == "" { defaultCore, found := m.GetDefaultCore() @@ -325,6 +335,17 @@ func (m *Manager) CountInstancesByCore(coreId string) int { count++ } } + + if len(m.Profiles) > 0 { + for _, profile := range m.Profiles { + countByCoreID(normalizeProfileCoreID(profile.CoreId)) + } + return count + } + + for _, profile := range m.Config.Browser.Profiles { + countByCoreID(normalizeProfileCoreID(profile.CoreId)) + } return count } diff --git a/backend/internal/browser/core_binary.go b/backend/internal/browser/core_binary.go new file mode 100644 index 00000000..102a68c5 --- /dev/null +++ b/backend/internal/browser/core_binary.go @@ -0,0 +1,41 @@ +package browser + +import ( + "os" + "path/filepath" + goruntime "runtime" + "strings" +) + +// CoreExecutableCandidates 返回当前平台可接受的浏览器可执行文件候选名。 +func CoreExecutableCandidates() []string { + switch goruntime.GOOS { + case "windows": + return []string{"chrome.exe"} + case "linux": + return []string{"chrome", "chrome-bin", "chrome.exe"} + case "darwin": + return []string{ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + "chrome", + } + default: + return []string{"chrome"} + } +} + +// FindCoreExecutable 在指定目录查找可执行文件,返回绝对路径和命中的候选名。 +func FindCoreExecutable(baseDir string) (string, string, bool) { + baseDir = strings.TrimSpace(baseDir) + if baseDir == "" { + return "", "", false + } + for _, candidate := range CoreExecutableCandidates() { + p := filepath.Join(baseDir, filepath.FromSlash(candidate)) + if _, err := os.Stat(p); err == nil { + return p, candidate, true + } + } + return "", "", false +} diff --git a/backend/internal/browser/core_test.go b/backend/internal/browser/core_test.go new file mode 100644 index 00000000..5de500b7 --- /dev/null +++ b/backend/internal/browser/core_test.go @@ -0,0 +1,131 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "os" + "path/filepath" + "testing" +) + +type coreDAOStub struct { + list []Core + err error +} + +func (s *coreDAOStub) List() ([]Core, error) { + if s.err != nil { + return nil, s.err + } + return append([]Core{}, s.list...), nil +} + +func (s *coreDAOStub) Upsert(Core) error { return nil } +func (s *coreDAOStub) Delete(string) error { return nil } +func (s *coreDAOStub) SetDefault(string) error { return nil } + +func TestResolveChromeBinaryUsesDefaultCoreFromDAO(t *testing.T) { + t.Parallel() + + root := t.TempDir() + coreDir := filepath.Join(root, "chrome142") + if err := os.MkdirAll(coreDir, 0o755); err != nil { + t.Fatalf("创建内核目录失败: %v", err) + } + + exePath := filepath.Join(coreDir, filepath.FromSlash(CoreExecutableCandidates()[0])) + if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { + t.Fatalf("创建可执行文件目录失败: %v", err) + } + if err := os.WriteFile(exePath, []byte("stub"), 0o755); err != nil { + t.Fatalf("写入可执行文件失败: %v", err) + } + + cfg := config.DefaultConfig() + cfg.Browser.Cores = nil // 模拟 ReloadConfig 后被 config.yaml 清空的场景 + + mgr := NewManager(cfg, root) + mgr.CoreDAO = &coreDAOStub{ + list: []Core{ + { + CoreId: "core-142", + CoreName: "Chrome 142", + CorePath: "chrome142", + IsDefault: true, + }, + }, + } + + got, err := mgr.ResolveChromeBinary(&Profile{CoreId: ""}) + if err != nil { + t.Fatalf("ResolveChromeBinary 返回错误: %v", err) + } + if got != exePath { + t.Fatalf("ResolveChromeBinary 路径错误: got=%q want=%q", got, exePath) + } +} + +func TestResolveChromeBinaryNormalizesWindowsStyleRelativeCorePath(t *testing.T) { + t.Parallel() + + root := t.TempDir() + coreDir := filepath.Join(root, "chrome", "Chrom-144") + if err := os.MkdirAll(coreDir, 0o755); err != nil { + t.Fatalf("创建内核目录失败: %v", err) + } + + exePath := filepath.Join(coreDir, filepath.FromSlash(CoreExecutableCandidates()[0])) + if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { + t.Fatalf("创建可执行文件目录失败: %v", err) + } + if err := os.WriteFile(exePath, []byte("stub"), 0o755); err != nil { + t.Fatalf("写入可执行文件失败: %v", err) + } + + cfg := config.DefaultConfig() + mgr := NewManager(cfg, root) + mgr.CoreDAO = &coreDAOStub{ + list: []Core{ + { + CoreId: "core-144", + CoreName: "Chrome 144", + CorePath: `chrome\Chrom-144`, + IsDefault: true, + }, + }, + } + + got, err := mgr.ResolveChromeBinary(&Profile{}) + if err != nil { + t.Fatalf("ResolveChromeBinary 返回错误: %v", err) + } + if got != exePath { + t.Fatalf("ResolveChromeBinary 路径错误: got=%q want=%q", got, exePath) + } +} + +func TestCountInstancesByCoreTreatsLegacyDefaultReferenceAsDefault(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.Browser.Profiles = []config.BrowserProfileConfig{ + {ProfileId: "p-empty", CoreId: ""}, + {ProfileId: "p-legacy", CoreId: "default"}, + {ProfileId: "p-explicit", CoreId: "core-142"}, + } + + mgr := NewManager(cfg, "") + mgr.CoreDAO = &coreDAOStub{ + list: []Core{ + { + CoreId: "core-142", + CoreName: "Chrome 142", + CorePath: "chrome142", + IsDefault: true, + }, + }, + } + + if got := mgr.CountInstancesByCore("core-142"); got != 3 { + t.Fatalf("默认内核实例计数错误: got=%d want=3", got) + } +} diff --git a/backend/internal/browser/download_core.go b/backend/internal/browser/download_core.go index 02bd9304..2791acce 100644 --- a/backend/internal/browser/download_core.go +++ b/backend/internal/browser/download_core.go @@ -16,7 +16,6 @@ import ( "ant-chrome/backend/internal/logger" "github.com/google/uuid" "github.com/wailsapp/wails/v2/pkg/runtime" - "golang.org/x/sys/windows/registry" ) // DownloadProgress 进度信息载体 @@ -40,38 +39,6 @@ func (cw *coreDownloadWriter) Write(p []byte) (int, error) { return cw.writeFunc(p) } -// readWindowsSystemProxy 从 Windows 注册表读取当前系统代理(WinINet,Clash 就是写这里) -// 返回格式如 "http://127.0.0.1:7890" 或 "socks5://127.0.0.1:7891" -func readWindowsSystemProxy() (string, error) { - k, err := registry.OpenKey(registry.CURRENT_USER, - `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, - registry.QUERY_VALUE) - if err != nil { - return "", err - } - defer k.Close() - - enabled, _, err := k.GetIntegerValue("ProxyEnable") - if err != nil || enabled == 0 { - return "", fmt.Errorf("系统代理未启用") - } - - proxyServer, _, err := k.GetStringValue("ProxyServer") - if err != nil || proxyServer == "" { - return "", fmt.Errorf("代理地址为空") - } - - // proxyServer 格式可能是 "127.0.0.1:7890" 或 "http=..;https=.." 多协议格式 - // 如果不含协议前缀,默认给 http:// - if !strings.Contains(proxyServer, ":") { - return "", fmt.Errorf("无效的代理格式: %s", proxyServer) - } - if !strings.HasPrefix(proxyServer, "http") && !strings.HasPrefix(proxyServer, "socks") { - return "http://" + proxyServer, nil - } - return proxyServer, nil -} - // DownloadAndExtractCore 执行异步下载解压并在过程中发送事件 func (m *Manager) DownloadAndExtractCore(ctx context.Context, coreName string, targetUrl string, proxyConfig string) { log := logger.New("Browser") @@ -113,7 +80,7 @@ func (m *Manager) DownloadAndExtractCore(ctx context.Context, coreName string, t if proxyConfig == "__system__" { // http.ProxyFromEnvironment 只读环境变量,而 Clash 的全局代理写在 Windows 注册表里 // 必须直接读取注册表才能拿到正确的代理地址 - if sysProxy, rErr := readWindowsSystemProxy(); rErr == nil && sysProxy != "" { + if sysProxy, rErr := readSystemProxy(); rErr == nil && sysProxy != "" { if proxyURL, pErr := url.Parse(sysProxy); pErr == nil { transport.Proxy = http.ProxyURL(proxyURL) sendEvent("downloading", 0, "已从系统注册表读取代理: "+sysProxy) @@ -189,7 +156,7 @@ func (m *Manager) DownloadAndExtractCore(ctx context.Context, coreName string, t log.Info("内核下载配置入库成功", logger.F("core_name", coreName)) } else { os.RemoveAll(targetDir) // 删除不正确的解压内容 - sendEvent("error", 0, "解压后在目录未找到 chrome.exe 执行文件,请检查压缩包内容!") + sendEvent("error", 0, fmt.Sprintf("解压后未找到浏览器可执行文件(候选:%s),请检查压缩包内容!", strings.Join(CoreExecutableCandidates(), ", "))) } } diff --git a/backend/internal/browser/download_core_proxy_other.go b/backend/internal/browser/download_core_proxy_other.go new file mode 100644 index 00000000..3b2697df --- /dev/null +++ b/backend/internal/browser/download_core_proxy_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package browser + +import "fmt" + +// readSystemProxy 非 Windows 平台不支持从注册表读取系统代理,直接返回未启用。 +func readSystemProxy() (string, error) { + return "", fmt.Errorf("当前平台不支持系统代理注册表读取") +} diff --git a/backend/internal/browser/download_core_proxy_windows.go b/backend/internal/browser/download_core_proxy_windows.go new file mode 100644 index 00000000..4c9adc2e --- /dev/null +++ b/backend/internal/browser/download_core_proxy_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package browser + +import ( + "fmt" + "strings" + + "golang.org/x/sys/windows/registry" +) + +// readSystemProxy 从 Windows 注册表读取当前系统代理(WinINet,Clash 会写这里)。 +// 返回格式如 "http://127.0.0.1:7890" 或 "socks5://127.0.0.1:7891"。 +func readSystemProxy() (string, error) { + k, err := registry.OpenKey(registry.CURRENT_USER, + `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + + enabled, _, err := k.GetIntegerValue("ProxyEnable") + if err != nil || enabled == 0 { + return "", fmt.Errorf("系统代理未启用") + } + + proxyServer, _, err := k.GetStringValue("ProxyServer") + if err != nil || proxyServer == "" { + return "", fmt.Errorf("代理地址为空") + } + + // proxyServer 可能是 "127.0.0.1:7890" 或 "http=..;https=.." 多协议格式 + // 不含协议前缀时默认补 http:// + if !strings.Contains(proxyServer, ":") { + return "", fmt.Errorf("无效的代理格式: %s", proxyServer) + } + if !strings.HasPrefix(proxyServer, "http") && !strings.HasPrefix(proxyServer, "socks") { + return "http://" + proxyServer, nil + } + return proxyServer, nil +} diff --git a/backend/internal/browser/environment.go b/backend/internal/browser/environment.go index af49414f..d8963f8c 100644 --- a/backend/internal/browser/environment.go +++ b/backend/internal/browser/environment.go @@ -8,23 +8,8 @@ import ( // GetProxyConfigById 根据代理 ID 获取代理配置 func (m *Manager) GetProxyConfigById(proxyId string) (string, bool) { - proxyId = strings.TrimSpace(proxyId) - if proxyId == "" { - return "", false - } - if m.ProxyDAO != nil { - if list, err := m.ProxyDAO.List(); err == nil { - for _, item := range list { - if strings.EqualFold(item.ProxyId, proxyId) { - return strings.TrimSpace(item.ProxyConfig), true - } - } - } - } - for _, item := range m.Config.Browser.Proxies { - if strings.EqualFold(item.ProxyId, proxyId) { - return strings.TrimSpace(item.ProxyConfig), true - } + if proxy, ok := m.GetProxyByID(proxyId); ok { + return strings.TrimSpace(proxy.ProxyConfig), true } return "", false } diff --git a/backend/internal/browser/profile.go b/backend/internal/browser/profile.go index 69173f20..9d5aad4c 100644 --- a/backend/internal/browser/profile.go +++ b/backend/internal/browser/profile.go @@ -43,6 +43,7 @@ func (m *Manager) loadProfiles() { } else { // SQLite 模式:无论是否为空都直接使用,不自动创建默认实例 for _, p := range profiles { + p.CoreId = normalizeProfileCoreID(p.CoreId) m.Profiles[p.ProfileId] = p } if len(profiles) > 0 { @@ -75,22 +76,26 @@ func (m *Manager) loadProfiles() { updatedAt = createdAt } m.Profiles[profileId] = &Profile{ - ProfileId: profileId, - ProfileName: item.ProfileName, - UserDataDir: item.UserDataDir, - CoreId: item.CoreId, - FingerprintArgs: append([]string{}, item.FingerprintArgs...), - ProxyId: item.ProxyId, - ProxyConfig: item.ProxyConfig, - LaunchArgs: append([]string{}, item.LaunchArgs...), - Tags: append([]string{}, item.Tags...), - Keywords: append([]string{}, item.Keywords...), - Running: false, - DebugPort: 0, - Pid: 0, - LastError: "", - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ProfileId: profileId, + ProfileName: item.ProfileName, + UserDataDir: item.UserDataDir, + CoreId: normalizeProfileCoreID(item.CoreId), + FingerprintArgs: append([]string{}, item.FingerprintArgs...), + ProxyId: item.ProxyId, + ProxyConfig: item.ProxyConfig, + ProxyBindSourceID: item.ProxyBindSourceID, + ProxyBindSourceURL: item.ProxyBindSourceURL, + ProxyBindName: item.ProxyBindName, + ProxyBindUpdatedAt: item.ProxyBindUpdatedAt, + LaunchArgs: append([]string{}, item.LaunchArgs...), + Tags: append([]string{}, item.Tags...), + Keywords: append([]string{}, item.Keywords...), + Running: false, + DebugPort: 0, + Pid: 0, + LastError: "", + CreatedAt: createdAt, + UpdatedAt: updatedAt, } } log.Info("浏览器配置从文件加载完成", logger.F("count", len(m.Profiles))) @@ -101,6 +106,7 @@ func (m *Manager) SaveProfiles() error { log := logger.New("Browser") if m.ProfileDAO != nil { for _, profile := range m.Profiles { + profile.CoreId = normalizeProfileCoreID(profile.CoreId) if err := m.ProfileDAO.Upsert(profile); err != nil { log.Error("实例配置持久化失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err)) return err @@ -114,18 +120,22 @@ func (m *Manager) SaveProfiles() error { profiles := make([]ProfileConfig, 0, len(m.Profiles)) for _, profile := range m.Profiles { profiles = append(profiles, ProfileConfig{ - ProfileId: profile.ProfileId, - ProfileName: profile.ProfileName, - UserDataDir: profile.UserDataDir, - CoreId: profile.CoreId, - FingerprintArgs: append([]string{}, profile.FingerprintArgs...), - ProxyId: profile.ProxyId, - ProxyConfig: profile.ProxyConfig, - LaunchArgs: append([]string{}, profile.LaunchArgs...), - Tags: append([]string{}, profile.Tags...), - Keywords: append([]string{}, profile.Keywords...), - CreatedAt: profile.CreatedAt, - UpdatedAt: profile.UpdatedAt, + ProfileId: profile.ProfileId, + ProfileName: profile.ProfileName, + UserDataDir: profile.UserDataDir, + CoreId: normalizeProfileCoreID(profile.CoreId), + FingerprintArgs: append([]string{}, profile.FingerprintArgs...), + ProxyId: profile.ProxyId, + ProxyConfig: profile.ProxyConfig, + ProxyBindSourceID: profile.ProxyBindSourceID, + ProxyBindSourceURL: profile.ProxyBindSourceURL, + ProxyBindName: profile.ProxyBindName, + ProxyBindUpdatedAt: profile.ProxyBindUpdatedAt, + LaunchArgs: append([]string{}, profile.LaunchArgs...), + Tags: append([]string{}, profile.Tags...), + Keywords: append([]string{}, profile.Keywords...), + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, }) } m.Config.Browser.Profiles = profiles @@ -222,14 +232,18 @@ func (m *Manager) Create(input ProfileInput) (*Profile, error) { } proxyConfig := strings.TrimSpace(input.ProxyConfig) proxyId := strings.TrimSpace(input.ProxyId) + selectedProxy := Proxy{} + hasSelectedProxy := false if proxyId != "" { - if resolved, ok := m.GetProxyConfigById(proxyId); ok { - proxyConfig = resolved + if proxyItem, ok := m.GetProxyByID(proxyId); ok { + proxyConfig = strings.TrimSpace(proxyItem.ProxyConfig) + selectedProxy = proxyItem + hasSelectedProxy = true } else { log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", proxyId)) } } - coreId := strings.TrimSpace(input.CoreId) + coreId := normalizeProfileCoreID(input.CoreId) if coreId == "" { if defaultCore, ok := m.GetDefaultCore(); ok { coreId = defaultCore.CoreId @@ -257,6 +271,9 @@ func (m *Manager) Create(input ProfileInput) (*Profile, error) { CreatedAt: now, UpdatedAt: now, } + if hasSelectedProxy { + _ = BindProfileToProxy(profile, selectedProxy, true) + } m.Profiles[profileId] = profile log.Info("浏览器配置创建", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName)) if err := m.SaveProfiles(); err != nil { @@ -283,18 +300,18 @@ func (m *Manager) Update(profileId string, input ProfileInput) (*Profile, error) } profile.ProfileName = input.ProfileName profile.UserDataDir = input.UserDataDir - profile.CoreId = input.CoreId + profile.CoreId = normalizeProfileCoreID(input.CoreId) profile.FingerprintArgs = input.FingerprintArgs profile.ProxyId = strings.TrimSpace(input.ProxyId) if profile.ProxyId != "" { - if resolved, ok := m.GetProxyConfigById(profile.ProxyId); ok { - profile.ProxyConfig = resolved + if proxyItem, ok := m.GetProxyByID(profile.ProxyId); ok { + _ = BindProfileToProxy(profile, proxyItem, true) } else { - profile.ProxyConfig = "" log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId)) } } else { profile.ProxyConfig = input.ProxyConfig + _ = ClearProfileProxyBinding(profile) } profile.LaunchArgs = input.LaunchArgs profile.Tags = input.Tags @@ -351,20 +368,29 @@ func (m *Manager) ApplyDefaults(profile *Profile) bool { if strings.TrimSpace(profile.UserDataDir) == "" { profile.UserDataDir = profile.ProfileId } - if strings.TrimSpace(profile.CoreId) == "" { + profile.CoreId = normalizeProfileCoreID(profile.CoreId) + if profile.CoreId == "" { if defaultCore, ok := m.GetDefaultCore(); ok { profile.CoreId = defaultCore.CoreId } } proxyChanged := false - if profile.ProxyId != "" { - if proxyConfig, ok := m.GetProxyConfigById(profile.ProxyId); ok { - if proxyConfig != "" && profile.ProxyConfig != proxyConfig { - profile.ProxyConfig = proxyConfig - proxyChanged = true - } - } else { + bindChanged, boundInPool, bindMode := m.ResolveProfileProxyBinding(profile) + if bindChanged { + proxyChanged = true + } + if bindMode != "" && bindMode != "proxy_id" { + log.Info("实例代理自动重关联", + logger.F("profile_id", profile.ProfileId), + logger.F("proxy_id", profile.ProxyId), + logger.F("mode", bindMode), + ) + } + if profile.ProxyId != "" && !boundInPool { + if strings.TrimSpace(profile.ProxyConfig) == "" { log.Error("实例代理未找到", logger.F("profile_id", profile.ProfileId), logger.F("proxy_id", profile.ProxyId)) + } else { + log.Warn("实例代理未找到,回退使用历史代理配置", logger.F("profile_id", profile.ProfileId), logger.F("proxy_id", profile.ProxyId)) } } if profile.ProxyConfig == "" && m.Config.Browser.DefaultProxy != "" { @@ -404,23 +430,27 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { // 复制配置,指纹参数使用默认值(新种子) profile := &Profile{ - ProfileId: newId, - ProfileName: profileName, - UserDataDir: newId, // 新的用户数据目录 - CoreId: src.CoreId, - FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), // 使用默认指纹(新种子) - ProxyId: src.ProxyId, - ProxyConfig: src.ProxyConfig, - LaunchArgs: append([]string{}, src.LaunchArgs...), - Tags: append([]string{}, src.Tags...), - Keywords: append([]string{}, src.Keywords...), - GroupId: src.GroupId, // 复制分组 - Running: false, - DebugPort: 0, - Pid: 0, - LastError: "", - CreatedAt: now, - UpdatedAt: now, + ProfileId: newId, + ProfileName: profileName, + UserDataDir: newId, // 新的用户数据目录 + CoreId: normalizeProfileCoreID(src.CoreId), + FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), // 使用默认指纹(新种子) + ProxyId: src.ProxyId, + ProxyConfig: src.ProxyConfig, + ProxyBindSourceID: src.ProxyBindSourceID, + ProxyBindSourceURL: src.ProxyBindSourceURL, + ProxyBindName: src.ProxyBindName, + ProxyBindUpdatedAt: src.ProxyBindUpdatedAt, + LaunchArgs: append([]string{}, src.LaunchArgs...), + Tags: append([]string{}, src.Tags...), + Keywords: append([]string{}, src.Keywords...), + GroupId: src.GroupId, // 复制分组 + Running: false, + DebugPort: 0, + Pid: 0, + LastError: "", + CreatedAt: now, + UpdatedAt: now, } m.Profiles[newId] = profile diff --git a/backend/internal/browser/profile_dao.go b/backend/internal/browser/profile_dao.go index e6f4cc50..a76d4a78 100644 --- a/backend/internal/browser/profile_dao.go +++ b/backend/internal/browser/profile_dao.go @@ -30,7 +30,10 @@ func NewSQLiteProfileDAO(db *sql.DB) *SQLiteProfileDAO { func (d *SQLiteProfileDAO) List() ([]*Profile, error) { rows, err := d.db.Query(` SELECT profile_id, profile_name, user_data_dir, core_id, - fingerprint_args, proxy_id, proxy_config, launch_args, + fingerprint_args, proxy_id, proxy_config, + COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''), + COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''), + launch_args, tags, keywords, group_id, created_at, updated_at FROM browser_profiles ORDER BY created_at ASC`) if err != nil { @@ -53,7 +56,10 @@ func (d *SQLiteProfileDAO) List() ([]*Profile, error) { func (d *SQLiteProfileDAO) GetById(profileId string) (*Profile, error) { row := d.db.QueryRow(` SELECT profile_id, profile_name, user_data_dir, core_id, - fingerprint_args, proxy_id, proxy_config, launch_args, + fingerprint_args, proxy_id, proxy_config, + COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''), + COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''), + launch_args, tags, keywords, group_id, created_at, updated_at FROM browser_profiles WHERE profile_id = ?`, profileId) p, err := scanProfile(row) @@ -81,8 +87,9 @@ func (d *SQLiteProfileDAO) Upsert(profile *Profile) error { _, err := d.db.Exec(` INSERT INTO browser_profiles (profile_id, profile_name, user_data_dir, core_id, fingerprint_args, - proxy_id, proxy_config, launch_args, tags, keywords, group_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + proxy_id, proxy_config, proxy_bind_source_id, proxy_bind_source_url, proxy_bind_name, proxy_bind_updated_at, + launch_args, tags, keywords, group_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(profile_id) DO UPDATE SET profile_name = excluded.profile_name, user_data_dir = excluded.user_data_dir, @@ -90,6 +97,10 @@ func (d *SQLiteProfileDAO) Upsert(profile *Profile) error { fingerprint_args = excluded.fingerprint_args, proxy_id = excluded.proxy_id, proxy_config = excluded.proxy_config, + proxy_bind_source_id = excluded.proxy_bind_source_id, + proxy_bind_source_url = excluded.proxy_bind_source_url, + proxy_bind_name = excluded.proxy_bind_name, + proxy_bind_updated_at = excluded.proxy_bind_updated_at, launch_args = excluded.launch_args, tags = excluded.tags, keywords = excluded.keywords, @@ -97,6 +108,7 @@ func (d *SQLiteProfileDAO) Upsert(profile *Profile) error { updated_at = excluded.updated_at`, profile.ProfileId, profile.ProfileName, profile.UserDataDir, profile.CoreId, string(fingerprintArgs), profile.ProxyId, profile.ProxyConfig, + profile.ProxyBindSourceID, profile.ProxyBindSourceURL, profile.ProxyBindName, profile.ProxyBindUpdatedAt, string(launchArgs), string(tags), string(keywords), profile.GroupId, profile.CreatedAt, profile.UpdatedAt, ) @@ -136,14 +148,20 @@ func (d *SQLiteProfileDAO) ListByGroup(groupId string, includeChildren bool, chi } rows, err = d.db.Query(fmt.Sprintf(` SELECT profile_id, profile_name, user_data_dir, core_id, - fingerprint_args, proxy_id, proxy_config, launch_args, + fingerprint_args, proxy_id, proxy_config, + COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''), + COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''), + launch_args, tags, keywords, group_id, created_at, updated_at FROM browser_profiles WHERE group_id IN (%s) ORDER BY created_at ASC`, inClause), args...) } else { // 仅查询指定分组 rows, err = d.db.Query(` SELECT profile_id, profile_name, user_data_dir, core_id, - fingerprint_args, proxy_id, proxy_config, launch_args, + fingerprint_args, proxy_id, proxy_config, + COALESCE(proxy_bind_source_id, ''), COALESCE(proxy_bind_source_url, ''), + COALESCE(proxy_bind_name, ''), COALESCE(proxy_bind_updated_at, ''), + launch_args, tags, keywords, group_id, created_at, updated_at FROM browser_profiles WHERE group_id = ? ORDER BY created_at ASC`, groupId) } @@ -199,6 +217,7 @@ func scanProfile(s scanner) (*Profile, error) { err := s.Scan( &p.ProfileId, &p.ProfileName, &p.UserDataDir, &p.CoreId, &fingerprintArgsJSON, &p.ProxyId, &p.ProxyConfig, + &p.ProxyBindSourceID, &p.ProxyBindSourceURL, &p.ProxyBindName, &p.ProxyBindUpdatedAt, &launchArgsJSON, &tagsJSON, &keywordsJSON, &p.GroupId, &p.CreatedAt, &p.UpdatedAt, ) diff --git a/backend/internal/browser/proxy_binding.go b/backend/internal/browser/proxy_binding.go new file mode 100644 index 00000000..63505d4a --- /dev/null +++ b/backend/internal/browser/proxy_binding.go @@ -0,0 +1,208 @@ +package browser + +import ( + "strings" + "time" +) + +func normalizeProxyBindValue(v string) string { + return strings.ToLower(strings.TrimSpace(v)) +} + +func (m *Manager) listProxyCatalog() []Proxy { + if m.ProxyDAO != nil { + if list, err := m.ProxyDAO.List(); err == nil && len(list) > 0 { + return append([]Proxy{}, list...) + } + } + return append([]Proxy{}, m.Config.Browser.Proxies...) +} + +func findProxyByID(list []Proxy, proxyID string) (Proxy, bool) { + target := normalizeProxyBindValue(proxyID) + if target == "" { + return Proxy{}, false + } + for _, item := range list { + if normalizeProxyBindValue(item.ProxyId) == target { + return item, true + } + } + return Proxy{}, false +} + +func uniqueProxyMatch(list []Proxy, match func(Proxy) bool) (Proxy, bool) { + var hit Proxy + matched := 0 + for _, item := range list { + if !match(item) { + continue + } + hit = item + matched++ + if matched > 1 { + return Proxy{}, false + } + } + return hit, matched == 1 +} + +// GetProxyByID 根据代理 ID 获取代理对象(优先 DAO)。 +func (m *Manager) GetProxyByID(proxyID string) (Proxy, bool) { + return findProxyByID(m.listProxyCatalog(), proxyID) +} + +// BindProfileToProxy 将实例绑定到指定代理并同步绑定快照。 +// syncProxyConfig=true 时会同步更新 profile.ProxyConfig。 +func BindProfileToProxy(profile *Profile, proxy Proxy, syncProxyConfig bool) bool { + if profile == nil { + return false + } + + changed := false + if profile.ProxyId != strings.TrimSpace(proxy.ProxyId) { + profile.ProxyId = strings.TrimSpace(proxy.ProxyId) + changed = true + } + if syncProxyConfig { + proxyConfig := strings.TrimSpace(proxy.ProxyConfig) + if proxyConfig != "" && profile.ProxyConfig != proxyConfig { + profile.ProxyConfig = proxyConfig + changed = true + } + } + + sourceID := strings.TrimSpace(proxy.SourceID) + sourceURL := strings.TrimSpace(proxy.SourceURL) + proxyName := strings.TrimSpace(proxy.ProxyName) + if profile.ProxyBindSourceID != sourceID { + profile.ProxyBindSourceID = sourceID + changed = true + } + if profile.ProxyBindSourceURL != sourceURL { + profile.ProxyBindSourceURL = sourceURL + changed = true + } + if profile.ProxyBindName != proxyName { + profile.ProxyBindName = proxyName + changed = true + } + if changed { + profile.ProxyBindUpdatedAt = time.Now().Format(time.RFC3339) + } + return changed +} + +// ClearProfileProxyBinding 清空实例的代理绑定快照。 +func ClearProfileProxyBinding(profile *Profile) bool { + if profile == nil { + return false + } + changed := false + if profile.ProxyBindSourceID != "" { + profile.ProxyBindSourceID = "" + changed = true + } + if profile.ProxyBindSourceURL != "" { + profile.ProxyBindSourceURL = "" + changed = true + } + if profile.ProxyBindName != "" { + profile.ProxyBindName = "" + changed = true + } + if changed { + profile.ProxyBindUpdatedAt = time.Now().Format(time.RFC3339) + } + return changed +} + +// ResolveProfileProxyBinding 尝试修复实例代理绑定。 +// 返回值: changed 是否修改实例, boundInPool 是否在代理池成功定位, mode 重关联命中模式 +func (m *Manager) ResolveProfileProxyBinding(profile *Profile) (bool, bool, string) { + if profile == nil { + return false, false, "" + } + proxies := m.listProxyCatalog() + if len(proxies) == 0 { + return false, false, "" + } + + if proxy, ok := findProxyByID(proxies, profile.ProxyId); ok { + changed := BindProfileToProxy(profile, proxy, true) + return changed, true, "proxy_id" + } + + allowConfigFallback := strings.TrimSpace(profile.ProxyId) != "" || + strings.TrimSpace(profile.ProxyBindSourceID) != "" || + strings.TrimSpace(profile.ProxyBindSourceURL) != "" || + strings.TrimSpace(profile.ProxyBindName) != "" + + if proxy, ok, mode := matchProxyBySnapshot(profile, proxies, allowConfigFallback); ok { + changed := BindProfileToProxy(profile, proxy, true) + return changed, true, mode + } + + return false, false, "" +} + +func matchProxyBySnapshot(profile *Profile, proxies []Proxy, allowConfigFallback bool) (Proxy, bool, string) { + nameKey := normalizeProxyBindValue(profile.ProxyBindName) + sourceIDKey := normalizeProxyBindValue(profile.ProxyBindSourceID) + sourceURLKey := normalizeProxyBindValue(profile.ProxyBindSourceURL) + cfgKey := normalizeProxyBindValue(profile.ProxyConfig) + + if sourceIDKey != "" && nameKey != "" { + if hit, ok := uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.SourceID) == sourceIDKey && + normalizeProxyBindValue(item.ProxyName) == nameKey + }); ok { + return hit, true, "source_id+name" + } + } + + if sourceURLKey != "" && nameKey != "" { + if hit, ok := uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.SourceURL) == sourceURLKey && + normalizeProxyBindValue(item.ProxyName) == nameKey + }); ok { + return hit, true, "source_url+name" + } + } + + if nameKey != "" { + if hit, ok := uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.ProxyName) == nameKey + }); ok { + return hit, true, "name" + } + } + + if sourceIDKey != "" && cfgKey != "" { + if hit, ok := uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.SourceID) == sourceIDKey && + normalizeProxyBindValue(item.ProxyConfig) == cfgKey + }); ok { + return hit, true, "source_id+config" + } + } + + if sourceURLKey != "" && cfgKey != "" { + if hit, ok := uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.SourceURL) == sourceURLKey && + normalizeProxyBindValue(item.ProxyConfig) == cfgKey + }); ok { + return hit, true, "source_url+config" + } + } + + if allowConfigFallback && cfgKey != "" { + if hit, ok := uniqueProxyMatch(proxies, func(item Proxy) bool { + return normalizeProxyBindValue(item.ProxyConfig) == cfgKey + }); ok { + return hit, true, "config" + } + } + + return Proxy{}, false, "" +} diff --git a/backend/internal/browser/proxy_binding_test.go b/backend/internal/browser/proxy_binding_test.go new file mode 100644 index 00000000..2351d38e --- /dev/null +++ b/backend/internal/browser/proxy_binding_test.go @@ -0,0 +1,81 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "testing" +) + +func TestResolveProfileProxyBindingBySourceAndName(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, "") + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + { + ProxyId: "new-p1", + ProxyName: "香港-01", + ProxyConfig: "socks5://127.0.0.1:1080", + SourceID: "src-hk", + SourceURL: "https://example.com/sub", + }, + }, + } + + profile := &Profile{ + ProfileId: "pf-1", + ProxyId: "old-missing-id", + ProxyConfig: "socks5://127.0.0.1:2080", + ProxyBindSourceID: "src-hk", + ProxyBindName: "香港-01", + } + + changed, boundInPool, mode := mgr.ResolveProfileProxyBinding(profile) + if !changed { + t.Fatalf("expected profile binding to change") + } + if !boundInPool { + t.Fatalf("expected profile to be rebound in pool") + } + if mode != "source_id+name" && mode != "proxy_id" { + t.Fatalf("unexpected bind mode: %s", mode) + } + if profile.ProxyId != "new-p1" { + t.Fatalf("unexpected rebound proxy id: %s", profile.ProxyId) + } + if profile.ProxyConfig != "socks5://127.0.0.1:1080" { + t.Fatalf("unexpected rebound proxy config: %s", profile.ProxyConfig) + } + if profile.ProxyBindUpdatedAt == "" { + t.Fatalf("expected bind updated time to be set") + } +} + +func TestResolveProfileProxyBindingAmbiguousNameNoRebind(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, "") + mgr.ProxyDAO = &proxyDAOStub{ + list: []Proxy{ + {ProxyId: "p1", ProxyName: "重复节点", ProxyConfig: "socks5://127.0.0.1:1080", SourceID: "src-a"}, + {ProxyId: "p2", ProxyName: "重复节点", ProxyConfig: "socks5://127.0.0.1:2080", SourceID: "src-b"}, + }, + } + + profile := &Profile{ + ProfileId: "pf-2", + ProxyId: "old-missing-id", + ProxyBindName: "重复节点", + } + + changed, boundInPool, mode := mgr.ResolveProfileProxyBinding(profile) + if changed { + t.Fatalf("did not expect binding to change") + } + if boundInPool { + t.Fatalf("did not expect ambiguous name to bind") + } + if mode != "" { + t.Fatalf("expected empty mode, got=%s", mode) + } + if profile.ProxyId != "old-missing-id" { + t.Fatalf("proxy id should remain unchanged, got=%s", profile.ProxyId) + } +} diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go index 6a39f1f1..41eae9bc 100644 --- a/backend/internal/browser/types.go +++ b/backend/internal/browser/types.go @@ -1,35 +1,38 @@ package browser import ( + "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/config" - "os" "os/exec" - "path/filepath" "sync" ) // Profile 浏览器配置文件 type Profile struct { - ProfileId string `json:"profileId"` - ProfileName string `json:"profileName"` - UserDataDir string `json:"userDataDir"` - CoreId string `json:"coreId"` - FingerprintArgs []string `json:"fingerprintArgs"` - ProxyId string `json:"proxyId"` - ProxyConfig string `json:"proxyConfig"` - LaunchArgs []string `json:"launchArgs"` - Tags []string `json:"tags"` - Keywords []string `json:"keywords"` - GroupId string `json:"groupId"` // 所属分组ID - LaunchCode string `json:"launchCode"` - Running bool `json:"running"` - DebugPort int `json:"debugPort"` - Pid int `json:"pid"` - LastError string `json:"lastError"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - LastStartAt string `json:"lastStartAt"` - LastStopAt string `json:"lastStopAt"` + ProfileId string `json:"profileId"` + ProfileName string `json:"profileName"` + UserDataDir string `json:"userDataDir"` + CoreId string `json:"coreId"` + FingerprintArgs []string `json:"fingerprintArgs"` + ProxyId string `json:"proxyId"` + ProxyConfig string `json:"proxyConfig"` + ProxyBindSourceID string `json:"proxyBindSourceId"` + ProxyBindSourceURL string `json:"proxyBindSourceUrl"` + ProxyBindName string `json:"proxyBindName"` + ProxyBindUpdatedAt string `json:"proxyBindUpdatedAt"` + LaunchArgs []string `json:"launchArgs"` + Tags []string `json:"tags"` + Keywords []string `json:"keywords"` + GroupId string `json:"groupId"` // 所属分组ID + LaunchCode string `json:"launchCode"` + Running bool `json:"running"` + DebugPort int `json:"debugPort"` + Pid int `json:"pid"` + LastError string `json:"lastError"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + LastStartAt string `json:"lastStartAt"` + LastStopAt string `json:"lastStopAt"` } // ProfileInput 创建/更新配置文件的输入 @@ -160,15 +163,5 @@ func NewManager(cfg *config.Config, appRoot string) *Manager { // ResolveRelativePath 将相对路径解析为绝对路径(基于 AppRoot)。 // 如果传入的已经是绝对路径则直接返回。 func (m *Manager) ResolveRelativePath(p string) string { - if filepath.IsAbs(p) { - return p - } - if m.AppRoot != "" { - return filepath.Join(m.AppRoot, p) - } - // 兜底:使用 CWD - if cwd, err := os.Getwd(); err == nil { - return filepath.Join(cwd, p) - } - return p + return apppath.Resolve(m.AppRoot, p) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 35078858..3935634d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,9 +9,49 @@ import ( "gopkg.in/yaml.v3" ) +const ( + DefaultMaxProfileLimit = 20 + StandardCDKeyProfileBonus = 10 + GithubStarRewardKey = "GITHUB_STAR_REWARD" + GithubStarProfileBonus = 50 + GithubStarProfileTotal = DefaultMaxProfileLimit + GithubStarProfileBonus + DefaultLaunchServerPort = 19876 +) + +// RewardForUsedKey 返回指定兑换记录对应的永久额度奖励。 +func RewardForUsedKey(key string) int { + normalized := strings.ToUpper(strings.TrimSpace(key)) + if normalized == "" { + return 0 + } + if normalized == GithubStarRewardKey { + return GithubStarProfileBonus + } + return StandardCDKeyProfileBonus +} + +// MinimumProfileLimitForUsedKeys 根据兑换记录计算最低应得实例额度。 +func MinimumProfileLimitForUsedKeys(keys []string) int { + limit := DefaultMaxProfileLimit + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + normalized := strings.ToUpper(strings.TrimSpace(key)) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + limit += RewardForUsedKey(normalized) + } + return limit +} + // LaunchServerConfig Launch HTTP 服务配置 type LaunchServerConfig struct { - // Port <= 0 时自动分配随机可用端口(推荐)。 + // Port 为对外暴露的固定入口端口。 + // Launch API 与 CDP 代理共用此端口,便于外部工具固定接入。 Port int `yaml:"port"` } @@ -54,7 +94,7 @@ type WindowConfig struct { // RuntimeConfig 运行时配置 type RuntimeConfig struct { - MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存限制(MB) + MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存软限制(MB),0 表示禁用 GCPercent int `yaml:"gc_percent"` // GC 触发百分比 } @@ -123,18 +163,22 @@ type BrowserEnvironment struct { } type BrowserProfileConfig struct { - ProfileId string `yaml:"profile_id" json:"profileId"` - ProfileName string `yaml:"profile_name" json:"profileName"` - UserDataDir string `yaml:"user_data_dir" json:"userDataDir"` - CoreId string `yaml:"core_id" json:"coreId"` - FingerprintArgs []string `yaml:"fingerprint_args" json:"fingerprintArgs"` - ProxyId string `yaml:"proxy_id" json:"proxyId"` - ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"` - LaunchArgs []string `yaml:"launch_args" json:"launchArgs"` - Tags []string `yaml:"tags" json:"tags"` - Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"` - CreatedAt string `yaml:"created_at" json:"createdAt"` - UpdatedAt string `yaml:"updated_at" json:"updatedAt"` + ProfileId string `yaml:"profile_id" json:"profileId"` + ProfileName string `yaml:"profile_name" json:"profileName"` + UserDataDir string `yaml:"user_data_dir" json:"userDataDir"` + CoreId string `yaml:"core_id" json:"coreId"` + FingerprintArgs []string `yaml:"fingerprint_args" json:"fingerprintArgs"` + ProxyId string `yaml:"proxy_id" json:"proxyId"` + ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"` + ProxyBindSourceID string `yaml:"proxy_bind_source_id,omitempty" json:"proxyBindSourceId,omitempty"` + ProxyBindSourceURL string `yaml:"proxy_bind_source_url,omitempty" json:"proxyBindSourceUrl,omitempty"` + ProxyBindName string `yaml:"proxy_bind_name,omitempty" json:"proxyBindName,omitempty"` + ProxyBindUpdatedAt string `yaml:"proxy_bind_updated_at,omitempty" json:"proxyBindUpdatedAt,omitempty"` + LaunchArgs []string `yaml:"launch_args" json:"launchArgs"` + Tags []string `yaml:"tags" json:"tags"` + Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"` + CreatedAt string `yaml:"created_at" json:"createdAt"` + UpdatedAt string `yaml:"updated_at" json:"updatedAt"` } // LoggingConfig 日志配置 @@ -223,17 +267,9 @@ func normalizeConfig(config *Config) { config.App.UsedCDKeys = []string{} } - // 兼容老版本配置: 如果之前没有 max_profile_limit,它会被解析成 0, - // 若用户在 0 状态下兑换了额度(比如 0+3=3),基础的 3 额度会被覆盖。 - // 这里通过统计兑换记录重新保底验证它的额度即可修复。 - expectedLimit := defaultConfig.App.MaxProfileLimit - for _, k := range config.App.UsedCDKeys { - if k == "GITHUB_STAR_REWARD" { - expectedLimit += 3 - } else { - expectedLimit += 3 - } - } + // 兼容老版本/损坏配置:若 max_profile_limit 缺失或被写成过小值, + // 通过兑换记录重新计算最低应得额度,避免基础额度或奖励额度丢失。 + expectedLimit := MinimumProfileLimitForUsedKeys(config.App.UsedCDKeys) if config.App.MaxProfileLimit < expectedLimit { config.App.MaxProfileLimit = expectedLimit } @@ -308,7 +344,7 @@ func normalizeConfig(config *Config) { config.Browser.Profiles = []BrowserProfileConfig{} } - if config.LaunchServer.Port < 0 { + if config.LaunchServer.Port <= 0 { config.LaunchServer.Port = defaultConfig.LaunchServer.Port } } @@ -340,12 +376,12 @@ func DefaultConfig() *Config { MinWidth: 1200, MinHeight: 700, }, - MaxProfileLimit: 3, + MaxProfileLimit: DefaultMaxProfileLimit, UsedCDKeys: []string{}, }, Runtime: RuntimeConfig{ - MaxMemoryMB: 1024, // 默认 1GB - GCPercent: 100, // 默认 100% + MaxMemoryMB: 0, // 默认禁用软限制,避免把运行中的前后端直接顶死 + GCPercent: 100, // 默认 100% }, Browser: BrowserConfig{ UserDataRoot: "data", @@ -376,7 +412,7 @@ func DefaultConfig() *Config { }, }, LaunchServer: LaunchServerConfig{ - Port: 0, + Port: DefaultLaunchServerPort, }, } } @@ -388,6 +424,9 @@ func (c *Config) Save(configPath string) error { return fmt.Errorf("序列化配置失败: %w", err) } + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return fmt.Errorf("创建配置目录失败: %w", err) + } if err := os.WriteFile(configPath, data, 0644); err != nil { return fmt.Errorf("写入配置文件失败: %w", err) } @@ -423,6 +462,9 @@ func SaveProxies(path string, proxies []BrowserProxy) error { if err != nil { return fmt.Errorf("序列化代理数据失败: %w", err) } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("创建代理目录失败: %w", err) + } if err := os.WriteFile(path, data, 0644); err != nil { return fmt.Errorf("写入代理文件失败: %w", err) } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 344bcb7a..bac5146f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -36,10 +36,10 @@ browser: {} if cfg.App.Name != "Ant Browser" { t.Fatalf("App.Name 未补齐: got=%q", cfg.App.Name) } - if cfg.App.MaxProfileLimit != 6 { - t.Fatalf("MaxProfileLimit 计算错误: got=%d want=6", cfg.App.MaxProfileLimit) + if cfg.App.MaxProfileLimit != GithubStarProfileTotal { + t.Fatalf("MaxProfileLimit 计算错误: got=%d want=%d", cfg.App.MaxProfileLimit, GithubStarProfileTotal) } - if cfg.Runtime.MaxMemoryMB != 1024 || cfg.Runtime.GCPercent != 100 { + if cfg.Runtime.MaxMemoryMB != 0 || cfg.Runtime.GCPercent != 100 { t.Fatalf("Runtime 未补齐: got=%+v", cfg.Runtime) } if cfg.Logging.Level != "info" || cfg.Logging.FilePath != "data/logs/app.log" { @@ -60,7 +60,7 @@ browser: {} if cfg.Browser.Cores == nil || cfg.Browser.Proxies == nil || cfg.Browser.Profiles == nil { t.Fatalf("Browser 列表字段应初始化为空切片") } - if cfg.LaunchServer.Port != 0 { + if cfg.LaunchServer.Port != DefaultLaunchServerPort { t.Fatalf("LaunchServer.Port 未补齐: got=%d", cfg.LaunchServer.Port) } } diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 7c5bbc32..7dadb3e4 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -125,6 +125,16 @@ var migrations = []migration{ `ALTER TABLE browser_proxies ADD COLUMN source_last_refresh_at TEXT NOT NULL DEFAULT ''`, }, }, + { + version: 6, + desc: "实例表添加代理绑定快照字段", + stmts: []string{ + `ALTER TABLE browser_profiles ADD COLUMN proxy_bind_source_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_profiles ADD COLUMN proxy_bind_source_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_profiles ADD COLUMN proxy_bind_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE browser_profiles ADD COLUMN proxy_bind_updated_at TEXT NOT NULL DEFAULT ''`, + }, + }, // ── 新版本在此追加,格式: // { // version: 4, diff --git a/backend/internal/fsutil/path.go b/backend/internal/fsutil/path.go new file mode 100644 index 00000000..dfb24caa --- /dev/null +++ b/backend/internal/fsutil/path.go @@ -0,0 +1,69 @@ +package fsutil + +import ( + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "strings" +) + +// NormalizePathInput standardizes separators from user/config input before +// path resolution so Windows-style relative paths still work on Linux/macOS. +func NormalizePathInput(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + + p = strings.ReplaceAll(p, `\`, string(filepath.Separator)) + p = strings.ReplaceAll(p, `/`, string(filepath.Separator)) + cleaned := filepath.Clean(p) + if cleaned == "." { + return "" + } + return cleaned +} + +// ValidateExecutable checks whether a file is runnable on the current platform. +func ValidateExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("目标是目录,不是可执行文件") + } + if goruntime.GOOS == "windows" { + return nil + } + if info.Mode()&0o111 == 0 { + return fmt.Errorf("文件缺少执行权限") + } + return nil +} + +// EnsureExecutable attempts to repair missing execute bits on non-Windows +// systems so repository-pinned runtime binaries can run from source checkout. +func EnsureExecutable(path string) error { + if goruntime.GOOS == "windows" { + return ValidateExecutable(path) + } + + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("目标是目录,不是可执行文件") + } + if info.Mode()&0o111 != 0 { + return nil + } + + nextMode := info.Mode() | 0o111 + if err := os.Chmod(path, nextMode); err != nil { + return fmt.Errorf("补充执行权限失败: %w", err) + } + return nil +} diff --git a/backend/internal/fsutil/path_test.go b/backend/internal/fsutil/path_test.go new file mode 100644 index 00000000..a68c9dd4 --- /dev/null +++ b/backend/internal/fsutil/path_test.go @@ -0,0 +1,43 @@ +package fsutil + +import ( + "os" + "path/filepath" + goruntime "runtime" + "testing" +) + +func TestNormalizePathInputConvertsWindowsSeparators(t *testing.T) { + t.Parallel() + + got := NormalizePathInput(`chrome\Chrom-144\chrome.exe`) + want := filepath.Join("chrome", "Chrom-144", "chrome.exe") + if got != want { + t.Fatalf("NormalizePathInput() = %q, want %q", got, want) + } +} + +func TestEnsureExecutableRepairsMissingExecBitsOnUnix(t *testing.T) { + t.Parallel() + + if goruntime.GOOS == "windows" { + t.Skip("Windows does not use POSIX execute bits") + } + + path := filepath.Join(t.TempDir(), "tool") + if err := os.WriteFile(path, []byte("stub"), 0o644); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + + if err := EnsureExecutable(path); err != nil { + t.Fatalf("EnsureExecutable() 返回错误: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("读取测试文件状态失败: %v", err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("EnsureExecutable() 未补充执行权限: mode=%#o", info.Mode().Perm()) + } +} diff --git a/backend/internal/launchcode/selector.go b/backend/internal/launchcode/selector.go new file mode 100644 index 00000000..159bea53 --- /dev/null +++ b/backend/internal/launchcode/selector.go @@ -0,0 +1,382 @@ +package launchcode + +import ( + "ant-chrome/backend/internal/browser" + "fmt" + "net/http" + "sort" + "strings" +) + +const ( + launchMatchModeUnique = "unique" + launchMatchModeFirst = "first" + launchMatchModeAll = "all" +) + +// LaunchSelector 定义实例选择条件。 +// 推荐在 POST /api/launch 中通过 selector 传入,兼容旧版 top-level code 用法。 +type LaunchSelector struct { + Code string `json:"code,omitempty"` + Key string `json:"key,omitempty"` + ProfileID string `json:"profileId,omitempty"` + ProfileName string `json:"profileName,omitempty"` + Keyword string `json:"keyword,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Tag string `json:"tag,omitempty"` + Tags []string `json:"tags,omitempty"` + GroupID string `json:"groupId,omitempty"` + MatchMode string `json:"matchMode,omitempty"` +} + +func mergeLaunchSelector(req LaunchRequest) LaunchSelector { + var nested LaunchSelector + if req.Selector != nil { + nested = *req.Selector + } + + return normalizeLaunchSelector(LaunchSelector{ + Code: firstNonEmpty(nested.Code, req.Code), + Key: firstNonEmpty(nested.Key, req.Key), + ProfileID: firstNonEmpty(nested.ProfileID, req.ProfileID), + ProfileName: firstNonEmpty(nested.ProfileName, req.ProfileName), + Keywords: appendSelectorTerms(nil, "", nested.Keywords, nested.Keyword, req.Keyword, req.Keywords), + Tags: appendSelectorTerms(nil, nested.Tag, nested.Tags, req.Tag, req.Tags), + GroupID: firstNonEmpty(nested.GroupID, req.GroupID), + MatchMode: firstNonEmpty(nested.MatchMode, req.MatchMode), + }) +} + +func normalizeLaunchSelector(selector LaunchSelector) LaunchSelector { + selector.Code = normalizeCode(selector.Code) + selector.Key = strings.TrimSpace(selector.Key) + selector.Keywords = normalizeSelectorTerms(appendSelectorTerms(nil, "", selector.Keywords, selector.Keyword)) + selector.Tags = normalizeSelectorTerms(appendSelectorTerms(nil, selector.Tag, selector.Tags)) + selector.ProfileID = strings.TrimSpace(selector.ProfileID) + selector.ProfileName = strings.TrimSpace(selector.ProfileName) + selector.GroupID = strings.TrimSpace(selector.GroupID) + selector.MatchMode = strings.ToLower(strings.TrimSpace(selector.MatchMode)) + if selector.MatchMode == "" { + selector.MatchMode = defaultLaunchMatchMode(selector) + } + selector.Keyword = "" + selector.Tag = "" + return selector +} + +func (selector LaunchSelector) IsEmpty() bool { + return selector.Code == "" && + selector.Key == "" && + selector.ProfileID == "" && + selector.ProfileName == "" && + selector.GroupID == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 +} + +func (selector LaunchSelector) OnlyCode() bool { + return selector.Code != "" && + selector.Key == "" && + selector.ProfileID == "" && + selector.ProfileName == "" && + selector.GroupID == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 +} + +func (selector LaunchSelector) Validate() error { + switch selector.MatchMode { + case "", launchMatchModeUnique, launchMatchModeFirst, launchMatchModeAll: + return nil + default: + return fmt.Errorf("matchMode must be unique, first or all") + } +} + +func defaultLaunchMatchMode(selector LaunchSelector) string { + if selector.Code != "" || selector.Key != "" || len(selector.Keywords) > 0 { + return launchMatchModeFirst + } + return launchMatchModeUnique +} + +func (s *LaunchServer) findProfilesBySelector(selector LaunchSelector) ([]browser.Profile, int, string) { + if selector.IsEmpty() { + return nil, http.StatusBadRequest, "selector is required" + } + if err := selector.Validate(); err != nil { + return nil, http.StatusBadRequest, err.Error() + } + if s.browserMgr == nil { + return nil, http.StatusInternalServerError, "advanced profile selector is not available" + } + + snapshots := s.profileSnapshots() + if len(snapshots) == 0 { + return nil, http.StatusNotFound, "profile selector matched no instance" + } + + if selector.Code != "" { + profileID, err := s.service.Resolve(selector.Code) + if err != nil { + return nil, http.StatusNotFound, "launch code not found" + } + filtered := make([]browser.Profile, 0, 1) + for _, item := range snapshots { + if item.ProfileId == profileID { + filtered = append(filtered, item) + break + } + } + snapshots = filtered + } + + if selector.ProfileID != "" { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return item.ProfileId == selector.ProfileID + }) + } + + if selector.ProfileName != "" { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return strings.EqualFold(strings.TrimSpace(item.ProfileName), selector.ProfileName) + }) + } + + if selector.GroupID != "" { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return strings.TrimSpace(item.GroupId) == selector.GroupID + }) + } + + if len(selector.Tags) > 0 { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return profileHasAllTags(item, selector.Tags) + }) + } + + fuzzyQueries := selector.Keywords + if selector.Key != "" { + exactMatches := filterProfiles(snapshots, func(item browser.Profile) bool { + return profileHasExactKeyword(item, selector.Key) + }) + if len(exactMatches) > 0 { + snapshots = exactMatches + } else { + fuzzyQueries = normalizeSelectorTerms(append([]string{selector.Key}, fuzzyQueries...)) + } + } + + if len(fuzzyQueries) > 0 { + snapshots = filterProfiles(snapshots, func(item browser.Profile) bool { + return profileMatchesAllKeywordQueries(item, fuzzyQueries) + }) + } + + if len(snapshots) == 0 { + if selector.OnlyCode() { + return nil, http.StatusNotFound, "launch code not found" + } + return nil, http.StatusNotFound, "profile selector matched no instance" + } + + sortProfilesForSelector(snapshots) + return snapshots, http.StatusOK, "" +} + +func (s *LaunchServer) findProfileBySelector(selector LaunchSelector) (browser.Profile, int, string) { + snapshots, status, errMsg := s.findProfilesBySelector(selector) + if errMsg != "" { + return browser.Profile{}, status, errMsg + } + if len(snapshots) > 1 && selector.MatchMode != launchMatchModeFirst { + return browser.Profile{}, http.StatusConflict, buildAmbiguousSelectorError(snapshots) + } + return snapshots[0], http.StatusOK, "" +} + +func (s *LaunchServer) profileSnapshots() []browser.Profile { + if s.browserMgr == nil { + return nil + } + + s.browserMgr.Mutex.Lock() + items := make([]browser.Profile, 0, len(s.browserMgr.Profiles)) + for _, profile := range s.browserMgr.Profiles { + if profile == nil { + continue + } + items = append(items, *profile) + } + s.browserMgr.Mutex.Unlock() + + if s.service != nil { + for i := range items { + if code, err := s.service.EnsureCode(items[i].ProfileId); err == nil { + items[i].LaunchCode = code + } + } + } + return items +} + +func filterProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile { + filtered := make([]browser.Profile, 0, len(items)) + for _, item := range items { + if keep(item) { + filtered = append(filtered, item) + } + } + return filtered +} + +func profileHasAllTags(profile browser.Profile, required []string) bool { + if len(required) == 0 { + return true + } + if len(profile.Tags) == 0 { + return false + } + + for _, want := range required { + found := false + for _, tag := range profile.Tags { + if strings.EqualFold(strings.TrimSpace(tag), want) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func profileHasExactKeyword(profile browser.Profile, expected string) bool { + expected = strings.TrimSpace(expected) + if expected == "" || len(profile.Keywords) == 0 { + return false + } + + for _, keyword := range profile.Keywords { + if strings.EqualFold(strings.TrimSpace(keyword), expected) { + return true + } + } + return false +} + +func profileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool { + if len(queries) == 0 { + return true + } + if len(profile.Keywords) == 0 { + return false + } + + for _, query := range queries { + queryLower := strings.ToLower(query) + found := false + for _, keyword := range profile.Keywords { + if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func sortProfilesForSelector(items []browser.Profile) { + sort.Slice(items, func(i, j int) bool { + leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName)) + rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName)) + if leftName != rightName { + return leftName < rightName + } + return items[i].ProfileId < items[j].ProfileId + }) +} + +func buildAmbiguousSelectorError(items []browser.Profile) string { + const maxPreview = 5 + parts := make([]string, 0, minInt(len(items), maxPreview)) + for i := 0; i < len(items) && i < maxPreview; i++ { + label := strings.TrimSpace(items[i].ProfileName) + if label == "" { + label = items[i].ProfileId + } + if items[i].LaunchCode != "" { + parts = append(parts, fmt.Sprintf("%s[id=%s, code=%s]", label, items[i].ProfileId, items[i].LaunchCode)) + continue + } + parts = append(parts, fmt.Sprintf("%s[id=%s]", label, items[i].ProfileId)) + } + suffix := "" + if len(items) > maxPreview { + suffix = fmt.Sprintf(" ... and %d more", len(items)-maxPreview) + } + return fmt.Sprintf("selector matched %d profiles: %s%s; use code/profileId or add groupId/tags/keywords, or set matchMode=first", len(items), strings.Join(parts, ", "), suffix) +} + +func appendSelectorTerms(dst []string, single string, many []string, moreSinglesAndSlices ...interface{}) []string { + if trimmed := strings.TrimSpace(single); trimmed != "" { + dst = append(dst, trimmed) + } + dst = append(dst, many...) + for _, item := range moreSinglesAndSlices { + switch v := item.(type) { + case string: + if trimmed := strings.TrimSpace(v); trimmed != "" { + dst = append(dst, trimmed) + } + case []string: + dst = append(dst, v...) + } + } + return dst +} + +func normalizeSelectorTerms(items []string) []string { + if len(items) == 0 { + return nil + } + seen := make(map[string]struct{}, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/launchcode/server.go b/backend/internal/launchcode/server.go index 22d37b19..cca55b80 100644 --- a/backend/internal/launchcode/server.go +++ b/backend/internal/launchcode/server.go @@ -7,6 +7,8 @@ import ( "io" "net" "net/http" + "net/http/httputil" + "net/url" "strconv" "strings" "sync" @@ -30,7 +32,17 @@ type LaunchRequestParams struct { // LaunchRequest POST /api/launch 的请求体 type LaunchRequest struct { - Code string `json:"code"` + Code string `json:"code"` + Key string `json:"key"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Keyword string `json:"keyword"` + Keywords []string `json:"keywords"` + Tag string `json:"tag"` + Tags []string `json:"tags"` + GroupID string `json:"groupId"` + MatchMode string `json:"matchMode"` + Selector *LaunchSelector `json:"selector"` LaunchRequestParams } @@ -46,6 +58,7 @@ type LaunchCallRecord struct { Path string `json:"path"` ClientIP string `json:"clientIp"` Code string `json:"code"` + Selector LaunchSelector `json:"selector,omitempty"` ProfileID string `json:"profileId"` ProfileName string `json:"profileName"` Params LaunchRequestParams `json:"params"` @@ -65,6 +78,10 @@ type LaunchServer struct { mu sync.Mutex logMu sync.Mutex callLogs []LaunchCallRecord + activeMu sync.RWMutex + activePort int + activeID string + activeName string } // NewLaunchServer 创建 LaunchServer @@ -79,19 +96,20 @@ func NewLaunchServer(service *LaunchCodeService, starter BrowserStarter, mgr *br // Start 非阻塞启动 HTTP 服务。 // 规则: -// - port <= 0:自动分配随机可用端口 -// - port > 0:优先使用指定端口;若被占用则回退到随机可用端口 +// - port <= 0:自动分配随机可用端口(仅内部测试/显式传 0 时) +// - port > 0:绑定指定固定端口;若被占用则直接返回错误 func (s *LaunchServer) Start() error { mux := http.NewServeMux() mux.HandleFunc("/api/health", s.handleHealth) mux.HandleFunc("/api/launch", s.handleLaunchWithBody) mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs) mux.HandleFunc("/api/launch/", s.handleLaunch) + mux.HandleFunc("/", s.handleCDPProxy) handler := s.localhostMiddleware(mux) preferredPort := s.port - ln, port, usedFallbackRandom, err := bindLaunchListener(preferredPort) + ln, port, err := bindLaunchListener(preferredPort) if err != nil { return err } @@ -104,11 +122,8 @@ func (s *LaunchServer) Start() error { log := logger.New("LaunchServer") if preferredPort <= 0 { log.Info("LaunchServer 使用随机端口", logger.F("port", port)) - } else if usedFallbackRandom { - log.Warn("LaunchServer 首选端口不可用,已切换随机端口", - logger.F("preferred_port", preferredPort), - logger.F("port", port), - ) + } else { + log.Info("LaunchServer 使用固定端口", logger.F("port", port)) } log.Info("LaunchServer 已启动", logger.F("port", port)) @@ -121,36 +136,26 @@ func (s *LaunchServer) Start() error { return nil } -func bindLaunchListener(preferredPort int) (net.Listener, int, bool, error) { +func bindLaunchListener(preferredPort int) (net.Listener, int, error) { if preferredPort <= 0 { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - return nil, 0, false, fmt.Errorf("自动分配端口失败: %w", err) + return nil, 0, fmt.Errorf("自动分配端口失败: %w", err) } port, err := listenerPort(ln) if err != nil { _ = ln.Close() - return nil, 0, false, err + return nil, 0, err } - return ln, port, false, nil + return ln, port, nil } addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(preferredPort)) ln, err := net.Listen("tcp", addr) if err == nil { - return ln, preferredPort, false, nil + return ln, preferredPort, nil } - - fallbackLn, fallbackErr := net.Listen("tcp", "127.0.0.1:0") - if fallbackErr != nil { - return nil, 0, false, fmt.Errorf("端口 %d 不可用且自动分配失败: %w", preferredPort, err) - } - port, portErr := listenerPort(fallbackLn) - if portErr != nil { - _ = fallbackLn.Close() - return nil, 0, false, portErr - } - return fallbackLn, port, true, nil + return nil, 0, fmt.Errorf("端口 %d 不可用: %w", preferredPort, err) } func listenerPort(ln net.Listener) (int, error) { @@ -194,6 +199,57 @@ func (s *LaunchServer) Port() int { return s.port } +// CDPURL 返回对外暴露的固定 CDP 入口地址。 +func (s *LaunchServer) CDPURL() string { + port := s.Port() + if port <= 0 { + return "" + } + return fmt.Sprintf("http://127.0.0.1:%d", port) +} + +// ActiveDebugPort 返回当前活动实例的内部调试端口。 +func (s *LaunchServer) ActiveDebugPort() int { + s.activeMu.RLock() + defer s.activeMu.RUnlock() + return s.activePort +} + +// SetActiveProfile 将统一入口切换到指定实例的调试端口。 +func (s *LaunchServer) SetActiveProfile(profile *browser.Profile) { + if profile == nil || profile.DebugPort <= 0 { + return + } + + s.activeMu.Lock() + s.activePort = profile.DebugPort + s.activeID = profile.ProfileId + s.activeName = profile.ProfileName + s.activeMu.Unlock() +} + +// ClearActiveProfile 在当前活动实例停止后清空统一入口。 +func (s *LaunchServer) ClearActiveProfile(profileID string) { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return + } + + s.activeMu.Lock() + if s.activeID == profileID { + s.activePort = 0 + s.activeID = "" + s.activeName = "" + } + s.activeMu.Unlock() +} + +func (s *LaunchServer) activeTarget() (int, string, string) { + s.activeMu.RLock() + defer s.activeMu.RUnlock() + return s.activePort, s.activeID, s.activeName +} + // localhostMiddleware 只允许 127.0.0.1 访问 func (s *LaunchServer) localhostMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -214,17 +270,44 @@ func (s *LaunchServer) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } +// handleCDPProxy 将统一端口上的非 /api 请求转发到当前活动实例的 CDP 端口。 +func (s *LaunchServer) handleCDPProxy(w http.ResponseWriter, r *http.Request) { + debugPort, profileID, profileName := s.activeTarget() + if debugPort <= 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ + "ok": false, + "error": "no active browser debug target", + "profileId": profileID, + "profileName": profileName, + }) + return + } + + target, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", debugPort)) + if err != nil { + http.Error(w, fmt.Sprintf("invalid cdp target: %v", err), http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, proxyErr error) { + http.Error(w, fmt.Sprintf("cdp proxy error: %v", proxyErr), http.StatusBadGateway) + } + proxy.ServeHTTP(w, r) +} + // handleLaunch GET /api/launch/{code} func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) { startAt := time.Now() clientIP := remoteIP(r.RemoteAddr) + selector := LaunchSelector{} if r.Method != http.MethodGet { msg := "method not allowed" writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ "ok": false, "error": msg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt) return } @@ -235,41 +318,58 @@ func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) { "ok": false, "error": msg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt) return } - profile, status, errMsg := s.launchByCode(code, LaunchRequestParams{}) + selector = normalizeLaunchSelector(LaunchSelector{Code: code}) + profile, launchCode, status, errMsg := s.launchByCode(code, LaunchRequestParams{}) if errMsg != "" { writeJSON(w, status, map[string]interface{}{ "ok": false, "error": errMsg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, LaunchRequestParams{}, false, status, errMsg, "", "", startAt) return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + s.SetActiveProfile(profile) + writeJSON(w, http.StatusOK, s.launchSuccessPayload(profile, launchCode)) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) +} + +func (s *LaunchServer) launchSuccessPayload(profile *browser.Profile, launchCode string) map[string]interface{} { + cdpURL := s.CDPURL() + cdpPort := s.Port() + if cdpURL == "" && profile != nil && profile.DebugPort > 0 { + cdpPort = profile.DebugPort + cdpURL = fmt.Sprintf("http://127.0.0.1:%d", profile.DebugPort) + } + + return map[string]interface{}{ "ok": true, "profileId": profile.ProfileId, "profileName": profile.ProfileName, + "launchCode": launchCode, "pid": profile.Pid, "debugPort": profile.DebugPort, - }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) + "cdpPort": cdpPort, + "cdpUrl": cdpURL, + } } // handleLaunchWithBody POST /api/launch func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Request) { startAt := time.Now() clientIP := remoteIP(r.RemoteAddr) + selector := LaunchSelector{} if r.Method != http.MethodPost { msg := "method not allowed" writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ "ok": false, "error": msg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt) return } @@ -282,39 +382,56 @@ func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Reque "ok": false, "error": msg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt) return } - if strings.TrimSpace(req.Code) == "" { - msg := "code is required" + + selector = mergeLaunchSelector(req) + if selector.IsEmpty() { + msg := "selector is required" writeJSON(w, http.StatusBadRequest, map[string]interface{}{ "ok": false, "error": msg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", selector, req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt) return } req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) req.StartURLs = normalizeStringSlice(req.StartURLs) - profile, status, errMsg := s.launchByCode(req.Code, req.LaunchRequestParams) + if selector.MatchMode == launchMatchModeAll { + profiles, status, errMsg := s.launchAllBySelector(selector, req.LaunchRequestParams) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) + return + } + + activeProfile, profileIDs, profileNames := summarizeLaunchedProfiles(profiles) + if activeProfile != nil { + s.SetActiveProfile(activeProfile) + } + writeJSON(w, http.StatusOK, s.launchBatchSuccessPayload(profiles)) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, selector.Code, selector, req.LaunchRequestParams, true, http.StatusOK, "", profileIDs, profileNames, startAt) + return + } + + profile, launchCode, status, errMsg := s.launchBySelector(selector, req.LaunchRequestParams) if errMsg != "" { writeJSON(w, status, map[string]interface{}{ "ok": false, "error": errMsg, }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, false, status, errMsg, "", "", startAt) return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "pid": profile.Pid, - "debugPort": profile.DebugPort, - }) - s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) + s.SetActiveProfile(profile) + writeJSON(w, http.StatusOK, s.launchSuccessPayload(profile, launchCode)) + s.appendLaunchLog(r.Method, r.URL.Path, clientIP, launchCode, selector, req.LaunchRequestParams, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt) } // handleLaunchLogs GET /api/launch/logs?limit=50 @@ -347,23 +464,191 @@ func (s *LaunchServer) handleLaunchLogs(w http.ResponseWriter, r *http.Request) }) } -func (s *LaunchServer) launchByCode(code string, params LaunchRequestParams) (*browser.Profile, int, string) { - profileId, err := s.service.Resolve(strings.TrimSpace(code)) - if err != nil { - return nil, http.StatusNotFound, "launch code not found" - } +func (s *LaunchServer) launchByCode(code string, params LaunchRequestParams) (*browser.Profile, string, int, string) { + return s.launchBySelectorInternal(normalizeLaunchSelector(LaunchSelector{Code: code}), params, false) +} - var profile *browser.Profile +func (s *LaunchServer) launchBySelector(selector LaunchSelector, params LaunchRequestParams) (*browser.Profile, string, int, string) { + return s.launchBySelectorInternal(selector, params, true) +} + +func (s *LaunchServer) launchProfile(profileID string, params LaunchRequestParams) (*browser.Profile, error) { if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok { - profile, err = starterWithParams.StartInstanceWithParams(profileId, params) - } else { - profile, err = s.starter.StartInstance(profileId) + return starterWithParams.StartInstanceWithParams(profileID, params) } - if err != nil { - return nil, http.StatusInternalServerError, err.Error() + return s.starter.StartInstance(profileID) +} + +func (s *LaunchServer) launchBySelectorInternal(selector LaunchSelector, params LaunchRequestParams, allowCodeKeywordFallback bool) (*browser.Profile, string, int, string) { + var ( + profileID string + launchCode string + err error + ) + + selector = normalizeLaunchSelector(selector) + if selector.IsEmpty() { + return nil, "", http.StatusBadRequest, "selector is required" + } + if err = selector.Validate(); err != nil { + return nil, "", http.StatusBadRequest, err.Error() + } + selector = s.withCodeKeywordFallback(selector, allowCodeKeywordFallback) + + if selector.OnlyCode() { + profileID, err = s.service.Resolve(selector.Code) + if err != nil { + return nil, "", http.StatusNotFound, "launch code not found" + } + launchCode = selector.Code + } else { + profileSnapshot, status, errMsg := s.findProfileBySelector(selector) + if errMsg != "" { + if selector.Code != "" { + launchCode = selector.Code + } + return nil, launchCode, status, errMsg + } + profileID = profileSnapshot.ProfileId + launchCode = profileSnapshot.LaunchCode } - return profile, http.StatusOK, "" + profile, err := s.launchProfile(profileID, params) + if err != nil { + return nil, launchCode, http.StatusInternalServerError, err.Error() + } + + if launchCode == "" && s.service != nil && profile != nil { + if code, codeErr := s.service.EnsureCode(profile.ProfileId); codeErr == nil { + launchCode = code + } + } + if profile != nil && launchCode != "" { + profile.LaunchCode = launchCode + } + + return profile, launchCode, http.StatusOK, "" +} + +func (s *LaunchServer) launchAllBySelector(selector LaunchSelector, params LaunchRequestParams) ([]*browser.Profile, int, string) { + selector = normalizeLaunchSelector(selector) + if selector.IsEmpty() { + return nil, http.StatusBadRequest, "selector is required" + } + if err := selector.Validate(); err != nil { + return nil, http.StatusBadRequest, err.Error() + } + selector = s.withCodeKeywordFallback(selector, true) + + snapshots, status, errMsg := s.findProfilesBySelector(selector) + if errMsg != "" { + return nil, status, errMsg + } + + profiles := make([]*browser.Profile, 0, len(snapshots)) + for _, snapshot := range snapshots { + profile, err := s.launchProfile(snapshot.ProfileId, params) + if err != nil { + label := strings.TrimSpace(snapshot.ProfileName) + if label == "" { + label = snapshot.ProfileId + } + return profiles, http.StatusInternalServerError, fmt.Sprintf("failed to start profile %s after launching %d profile(s): %v", label, len(profiles), err) + } + + launchCode := snapshot.LaunchCode + if launchCode == "" && s.service != nil && profile != nil { + if code, codeErr := s.service.EnsureCode(profile.ProfileId); codeErr == nil { + launchCode = code + } + } + if profile != nil && launchCode != "" { + profile.LaunchCode = launchCode + } + + profiles = append(profiles, profile) + } + + return profiles, http.StatusOK, "" +} + +func (s *LaunchServer) withCodeKeywordFallback(selector LaunchSelector, allow bool) LaunchSelector { + if !allow || strings.TrimSpace(selector.Code) == "" { + return selector + } + if s.service != nil { + if _, err := s.service.Resolve(selector.Code); err == nil { + return selector + } + } + + fallback := selector + if strings.TrimSpace(fallback.Key) == "" { + fallback.Key = selector.Code + } + fallback.Code = "" + return fallback +} + +func (s *LaunchServer) launchBatchSuccessPayload(profiles []*browser.Profile) map[string]interface{} { + items := make([]map[string]interface{}, 0, len(profiles)) + for i, profile := range profiles { + if profile == nil { + continue + } + item := map[string]interface{}{ + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + "isActive": i == len(profiles)-1, + } + items = append(items, item) + } + + activeProfile, _, _ := summarizeLaunchedProfiles(profiles) + cdpURL := s.CDPURL() + cdpPort := s.Port() + if cdpURL == "" && activeProfile != nil && activeProfile.DebugPort > 0 { + cdpPort = activeProfile.DebugPort + cdpURL = fmt.Sprintf("http://127.0.0.1:%d", activeProfile.DebugPort) + } + + payload := map[string]interface{}{ + "ok": true, + "matchMode": launchMatchModeAll, + "count": len(items), + "items": items, + "cdpPort": cdpPort, + "cdpUrl": cdpURL, + } + if activeProfile != nil { + payload["activeProfileId"] = activeProfile.ProfileId + payload["activeProfileName"] = activeProfile.ProfileName + } + return payload +} + +func summarizeLaunchedProfiles(profiles []*browser.Profile) (*browser.Profile, string, string) { + if len(profiles) == 0 { + return nil, "", "" + } + + ids := make([]string, 0, len(profiles)) + names := make([]string, 0, len(profiles)) + var active *browser.Profile + for _, profile := range profiles { + if profile == nil { + continue + } + active = profile + ids = append(ids, profile.ProfileId) + if trimmed := strings.TrimSpace(profile.ProfileName); trimmed != "" { + names = append(names, trimmed) + } + } + return active, strings.Join(ids, ","), strings.Join(names, ",") } // writeJSON 写入 JSON 响应 @@ -380,6 +665,7 @@ func NewTestHandler(s *LaunchServer) http.Handler { mux.HandleFunc("/api/launch", s.handleLaunchWithBody) mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs) mux.HandleFunc("/api/launch/", s.handleLaunch) + mux.HandleFunc("/", s.handleCDPProxy) return mux } @@ -400,13 +686,14 @@ func normalizeStringSlice(items []string) []string { return out } -func (s *LaunchServer) appendLaunchLog(method, path, clientIP, code string, params LaunchRequestParams, ok bool, status int, errMsg, profileID, profileName string, startAt time.Time) { +func (s *LaunchServer) appendLaunchLog(method, path, clientIP, code string, selector LaunchSelector, params LaunchRequestParams, ok bool, status int, errMsg, profileID, profileName string, startAt time.Time) { entry := LaunchCallRecord{ Timestamp: time.Now().Format(time.RFC3339), Method: method, Path: path, ClientIP: clientIP, Code: strings.TrimSpace(code), + Selector: selector, ProfileID: profileID, ProfileName: profileName, Params: params, diff --git a/backend/internal/launchcode/server_start_test.go b/backend/internal/launchcode/server_start_test.go index e6151df3..8c555799 100644 --- a/backend/internal/launchcode/server_start_test.go +++ b/backend/internal/launchcode/server_start_test.go @@ -36,7 +36,7 @@ func TestLaunchServerStartWithAutoPort(t *testing.T) { } } -func TestLaunchServerFallbackToRandomPortWhenPreferredIsBusy(t *testing.T) { +func TestLaunchServerReturnsErrorWhenPreferredPortIsBusy(t *testing.T) { occupied, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("占用端口失败: %v", err) @@ -47,18 +47,10 @@ func TestLaunchServerFallbackToRandomPortWhenPreferredIsBusy(t *testing.T) { svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) srv := launchcode.NewLaunchServer(svc, nil, nil, busyPort) - if err := srv.Start(); err != nil { - t.Fatalf("Start 失败: %v", err) - } - defer func() { - _ = srv.Stop() - }() - - actualPort := srv.Port() - if actualPort <= 0 { - t.Fatalf("随机回退端口无效: got=%d", actualPort) - } - if actualPort == busyPort { - t.Fatalf("期望回退到随机端口,但仍使用了被占用端口: %d", actualPort) + if err := srv.Start(); err == nil { + defer func() { + _ = srv.Stop() + }() + t.Fatalf("期望固定端口被占用时返回错误,但启动成功了: %d", busyPort) } } diff --git a/backend/internal/proxy/singbox.go b/backend/internal/proxy/singbox.go index 3e742497..2f64f074 100644 --- a/backend/internal/proxy/singbox.go +++ b/backend/internal/proxy/singbox.go @@ -1,7 +1,9 @@ package proxy import ( + "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/fsutil" "ant-chrome/backend/internal/logger" "encoding/json" "fmt" @@ -10,6 +12,7 @@ import ( "path/filepath" goruntime "runtime" "strings" + "sync" "time" ) @@ -20,6 +23,7 @@ type SingBoxBridge struct { Cmd *exec.Cmd Pid int Running bool + Stopping bool LastError string } @@ -29,6 +33,7 @@ type SingBoxManager struct { AppRoot string // 应用根目录,所有相对路径基于此解析 Bridges map[string]*SingBoxBridge OnBridgeDied func(key string, err error) + mu sync.Mutex } // NewSingBoxManager 创建 sing-box 管理器 @@ -65,21 +70,9 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows key := computeNodeKey(src) - // 复用已有桥接 - if bridge, ok := m.Bridges[key]; ok && bridge != nil && bridge.Running { - alive := bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil - if alive { - if err := waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond); err == nil { - log.Info("复用 sing-box 桥接", logger.F("key", key[:8]), logger.F("port", bridge.Port)) - return fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port), nil - } - } - log.Info("sing-box 桥接已失效,重新启动", logger.F("key", key[:8])) - if bridge.Cmd != nil && bridge.Cmd.Process != nil { - _ = bridge.Cmd.Process.Kill() - } - bridge.Running = false - delete(m.Bridges, key) + if socksURL, reused := m.tryReuseBridge(key); reused { + log.Info("复用 sing-box 桥接", logger.F("key", key[:8]), logger.F("socks_url", socksURL)) + return socksURL, nil } binaryPath, err := m.resolveBinary() @@ -128,7 +121,6 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows Pid: cmd.Process.Pid, Running: true, } - m.Bridges[key] = bridge log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port)) if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil { @@ -138,10 +130,11 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows if content, readErr := os.ReadFile(stderrPath); readErr == nil && len(content) > 0 { log.Error("sing-box stderr", logger.F("output", string(content))) } - _ = cmd.Process.Kill() + bridge.Stopping = true + m.stopBridgeProcess(bridge) bridge.Running = false + bridge.Pid = 0 bridge.LastError = err.Error() - delete(m.Bridges, key) log.Error("sing-box 端口不可用,重试", logger.F("error", err), logger.F("attempt", attempt)) lastErr = err time.Sleep(200 * time.Millisecond) @@ -152,14 +145,14 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows stderrFile.Close() } - go func(b *SingBoxBridge, nodeKey string) { - _ = b.Cmd.Wait() - b.Running = false - if m.OnBridgeDied != nil { - m.OnBridgeDied(nodeKey, fmt.Errorf("sing-box 桥接进程意外退出")) - } - }(bridge, key) + if socksURL, reused := m.registerBridge(key, bridge); reused { + log.Info("复用已就绪 sing-box 桥接", logger.F("key", key[:8]), logger.F("socks_url", socksURL)) + bridge.Stopping = true + m.stopBridgeProcess(bridge) + return socksURL, nil + } + go m.watchBridge(bridge, key) return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil } @@ -168,12 +161,105 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows // StopAll 关闭所有 sing-box 桥接进程 func (m *SingBoxManager) StopAll() { + m.mu.Lock() + bridges := make([]*SingBoxBridge, 0, len(m.Bridges)) for key, bridge := range m.Bridges { - if bridge != nil && bridge.Cmd != nil && bridge.Cmd.Process != nil { - _ = bridge.Cmd.Process.Kill() + if bridge != nil { + bridge.Stopping = true + bridges = append(bridges, bridge) } delete(m.Bridges, key) } + m.mu.Unlock() + + for _, bridge := range bridges { + m.stopBridgeProcess(bridge) + } +} + +func (m *SingBoxManager) tryReuseBridge(key string) (string, bool) { + var stale *SingBoxBridge + + m.mu.Lock() + if bridge, ok := m.Bridges[key]; ok && bridge != nil { + alive := bridge.Running && bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil + if alive && waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil { + socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port) + m.mu.Unlock() + return socksURL, true + } + + bridge.Stopping = true + stale = bridge + delete(m.Bridges, key) + } + m.mu.Unlock() + + if stale != nil { + m.stopBridgeProcess(stale) + } + return "", false +} + +func (m *SingBoxManager) registerBridge(key string, bridge *SingBoxBridge) (string, bool) { + var duplicate *SingBoxBridge + + m.mu.Lock() + if existing, ok := m.Bridges[key]; ok && existing != nil { + if existing == bridge { + m.mu.Unlock() + return "", false + } + + alive := existing.Running && existing.Cmd != nil && existing.Cmd.Process != nil && existing.Cmd.ProcessState == nil + if alive && waitPortReady("127.0.0.1", existing.Port, 800*time.Millisecond) == nil { + duplicate = bridge + socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port) + m.mu.Unlock() + if duplicate != nil { + duplicate.Stopping = true + m.stopBridgeProcess(duplicate) + } + return socksURL, true + } + + existing.Stopping = true + delete(m.Bridges, key) + duplicate = existing + } + m.Bridges[key] = bridge + m.mu.Unlock() + + if duplicate != nil { + m.stopBridgeProcess(duplicate) + } + return "", false +} + +func (m *SingBoxManager) watchBridge(bridge *SingBoxBridge, key string) { + if bridge == nil || bridge.Cmd == nil { + return + } + _ = bridge.Cmd.Wait() + + m.mu.Lock() + if current, ok := m.Bridges[key]; ok && current == bridge { + delete(m.Bridges, key) + } + bridge.Running = false + stopping := bridge.Stopping + m.mu.Unlock() + + if !stopping && m.OnBridgeDied != nil { + m.OnBridgeDied(key, fmt.Errorf("sing-box 桥接进程意外退出")) + } +} + +func (m *SingBoxManager) stopBridgeProcess(bridge *SingBoxBridge) { + if bridge == nil || bridge.Cmd == nil || bridge.Cmd.Process == nil { + return + } + _ = bridge.Cmd.Process.Kill() } func (m *SingBoxManager) resolveBinary() (string, error) { @@ -182,38 +268,65 @@ func (m *SingBoxManager) resolveBinary() (string, error) { resolved := resolveEnvPath(configPath, m.AppRoot) if resolved != "" { if _, err := os.Stat(resolved); err == nil { + if err := fsutil.EnsureExecutable(resolved); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", resolved, err) + } return resolved, nil } } } if env := strings.TrimSpace(os.Getenv("SINGBOX_BINARY_PATH")); env != "" { if _, err := os.Stat(env); err == nil { + if err := fsutil.EnsureExecutable(env); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", env, err) + } return env, nil } } - // 优先基于 appRoot 查找 bin/sing-box.exe - if m.AppRoot != "" { - candidate := filepath.Join(m.AppRoot, "bin", "sing-box.exe") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - // 兜底:exe 目录 - if exePath, err := os.Executable(); err == nil { - candidate := filepath.Join(filepath.Dir(exePath), "bin", "sing-box.exe") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - if path, err := exec.LookPath("sing-box"); err == nil { - return path, nil - } + + binaryNames := []string{"sing-box"} if goruntime.GOOS == "windows" { - if path, err := exec.LookPath("sing-box.exe"); err == nil { + binaryNames = []string{"sing-box.exe", "sing-box"} + } + platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH) + + searchDirs := make([]string, 0, 4) + if m.AppRoot != "" { + searchDirs = append(searchDirs, + filepath.Join(m.AppRoot, "bin", platformDir), + filepath.Join(m.AppRoot, "bin"), + ) + } + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + searchDirs = append(searchDirs, + filepath.Join(exeDir, "bin", platformDir), + filepath.Join(exeDir, "bin"), + ) + } + + for _, dir := range searchDirs { + for _, name := range binaryNames { + candidate := filepath.Join(dir, name) + if _, err := os.Stat(candidate); err == nil { + if err := fsutil.EnsureExecutable(candidate); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", candidate, err) + } + return candidate, nil + } + } + } + + for _, name := range binaryNames { + if path, err := exec.LookPath(name); err == nil { + if err := fsutil.EnsureExecutable(path); err != nil { + return "", fmt.Errorf("sing-box 文件不可执行: %s: %w", path, err) + } return path, nil } } - return "", fmt.Errorf("未找到 sing-box.exe。请将 sing-box.exe 放到 bin/ 目录,或在配置中设置 SingBoxBinaryPath") + + return "", fmt.Errorf("未找到 sing-box 可执行文件。请将 sing-box 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 SingBoxBinaryPath", platformDir) } func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}, port int) (string, error) { @@ -271,11 +384,7 @@ func (m *SingBoxManager) resolveWorkdir(key string) string { root = "data" } if !filepath.IsAbs(root) { - if m.AppRoot != "" { - root = filepath.Join(m.AppRoot, root) - } else if exePath, err := os.Executable(); err == nil { - root = filepath.Join(filepath.Dir(exePath), root) - } + root = apppath.Resolve(m.AppRoot, root) } return filepath.Join(root, "_singbox", key) } diff --git a/backend/internal/proxy/singbox_test.go b/backend/internal/proxy/singbox_test.go new file mode 100644 index 00000000..9eb93972 --- /dev/null +++ b/backend/internal/proxy/singbox_test.go @@ -0,0 +1,51 @@ +package proxy + +import "testing" + +func TestSingBoxRegisterBridgeStoresNewBridge(t *testing.T) { + manager := &SingBoxManager{ + Bridges: make(map[string]*SingBoxBridge), + } + bridge := &SingBoxBridge{ + NodeKey: "node-a", + Port: 21001, + Running: true, + } + + socksURL, reused := manager.registerBridge("node-a", bridge) + if reused { + t.Fatalf("expected new bridge registration, got reused with %q", socksURL) + } + if socksURL != "" { + t.Fatalf("expected empty socksURL for new bridge registration, got %q", socksURL) + } + if manager.Bridges["node-a"] != bridge { + t.Fatalf("bridge was not stored in manager") + } +} + +func TestSingBoxRegisterBridgeIgnoresSamePointer(t *testing.T) { + manager := &SingBoxManager{ + Bridges: make(map[string]*SingBoxBridge), + } + bridge := &SingBoxBridge{ + NodeKey: "node-a", + Port: 21001, + Running: true, + } + manager.Bridges["node-a"] = bridge + + socksURL, reused := manager.registerBridge("node-a", bridge) + if reused { + t.Fatalf("same bridge pointer must not be treated as duplicate, got reused with %q", socksURL) + } + if socksURL != "" { + t.Fatalf("expected empty socksURL when registering same pointer, got %q", socksURL) + } + if manager.Bridges["node-a"] != bridge { + t.Fatalf("bridge mapping changed unexpectedly") + } + if bridge.Stopping { + t.Fatalf("same bridge pointer should not be marked as stopping") + } +} diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index 17c55631..a542611b 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -1,7 +1,9 @@ package proxy import ( + "ant-chrome/backend/internal/apppath" "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/fsutil" "ant-chrome/backend/internal/logger" "crypto/sha256" "encoding/hex" @@ -62,7 +64,11 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox } } if !found { - return false, fmt.Sprintf("代理链路不可用:代理池节点已不存在(proxyId=%s)。可能因订阅刷新后节点下线或被删除,请重新选择代理后再启动。", proxyId) + // 兼容模式:如果 profile 内仍保留了可解析的 proxyConfig,则允许回退使用。 + // 这样可兼容历史版本中 proxyId 失效后的启动流程,避免升级后强制手工重绑。 + if src == "" { + return false, fmt.Sprintf("代理链路不可用:代理池节点已不存在(proxyId=%s)。可能因订阅刷新后节点下线或被删除,请重新选择代理后再启动。", proxyId) + } } } if src == "" { @@ -457,6 +463,9 @@ func (m *XrayManager) resolveBinary() (string, error) { resolved := resolveEnvPath(configPath, m.AppRoot) if resolved != "" { if _, err := os.Stat(resolved); err == nil { + if err := fsutil.EnsureExecutable(resolved); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", resolved, err) + } return resolved, nil } } @@ -464,32 +473,56 @@ func (m *XrayManager) resolveBinary() (string, error) { env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH")) if env != "" { if _, err := os.Stat(env); err == nil { + if err := fsutil.EnsureExecutable(env); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", env, err) + } return env, nil } } - // 优先基于 appRoot 查找 bin/xray.exe - if m.AppRoot != "" { - candidate := filepath.Join(m.AppRoot, "bin", "xray.exe") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - // 兜底:exe 目录 - if exePath, err := os.Executable(); err == nil { - candidate := filepath.Join(filepath.Dir(exePath), "bin", "xray.exe") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - if path, err := exec.LookPath("xray"); err == nil { - return path, nil - } + + binaryNames := []string{"xray"} if goruntime.GOOS == "windows" { - if path, err := exec.LookPath("xray.exe"); err == nil { + binaryNames = []string{"xray.exe", "xray"} + } + platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH) + + searchDirs := make([]string, 0, 4) + if m.AppRoot != "" { + searchDirs = append(searchDirs, + filepath.Join(m.AppRoot, "bin", platformDir), + filepath.Join(m.AppRoot, "bin"), + ) + } + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + searchDirs = append(searchDirs, + filepath.Join(exeDir, "bin", platformDir), + filepath.Join(exeDir, "bin"), + ) + } + + for _, dir := range searchDirs { + for _, name := range binaryNames { + candidate := filepath.Join(dir, name) + if _, err := os.Stat(candidate); err == nil { + if err := fsutil.EnsureExecutable(candidate); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", candidate, err) + } + return candidate, nil + } + } + } + + for _, name := range binaryNames { + if path, err := exec.LookPath(name); err == nil { + if err := fsutil.EnsureExecutable(path); err != nil { + return "", fmt.Errorf("xray 文件不可执行: %s: %w", path, err) + } return path, nil } } - return "", fmt.Errorf("未找到 xray.exe。请将 xray.exe 放到 bin/ 目录,或在配置中设置 XrayBinaryPath") + + return "", fmt.Errorf("未找到 xray 可执行文件。请将 xray 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 XrayBinaryPath", platformDir) } // parseDnsConfig 解析 DNS 配置,支持两种格式: @@ -627,11 +660,7 @@ func (m *XrayManager) resolveWorkdir(key string) string { root = "data" } if !filepath.IsAbs(root) { - if m.AppRoot != "" { - root = filepath.Join(m.AppRoot, root) - } else if exePath, err := os.Executable(); err == nil { - root = filepath.Join(filepath.Dir(exePath), root) - } + root = apppath.Resolve(m.AppRoot, root) } return filepath.Join(root, "_xray", key) } @@ -650,7 +679,7 @@ func normalizeNodeScheme(src string) string { } func resolveEnvPath(path string, appRoot string) string { - path = strings.TrimSpace(path) + path = fsutil.NormalizePathInput(path) if path == "" { return "" } diff --git a/backend/internal/proxy/xray_validate_test.go b/backend/internal/proxy/xray_validate_test.go index 5a9f2786..3a686096 100644 --- a/backend/internal/proxy/xray_validate_test.go +++ b/backend/internal/proxy/xray_validate_test.go @@ -28,6 +28,15 @@ func TestValidateProxyConfigMissingProxyId(t *testing.T) { } } +func TestValidateProxyConfigMissingProxyIdFallbackToRawConfig(t *testing.T) { + ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", []config.BrowserProxy{ + {ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"}, + }, "missing-proxy") + if !ok { + t.Fatalf("expected fallback proxyConfig to pass, msg=%s", msg) + } +} + func TestValidateProxyConfigStandardProxy(t *testing.T) { ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", nil, "") if !ok { diff --git a/backend/internal/tray/tray.go b/backend/internal/tray/tray.go index f5ca873c..6a13180a 100644 --- a/backend/internal/tray/tray.go +++ b/backend/internal/tray/tray.go @@ -4,6 +4,7 @@ package tray import ( _ "embed" + "runtime" "github.com/energye/systray" ) @@ -17,8 +18,12 @@ type Callbacks struct { OnQuit func() } -// Run 启动系统托盘(阻塞,需在独立 goroutine 中调用) +// Run 启动系统托盘(阻塞,需在独立 goroutine 中调用)。 +// Windows 托盘依赖消息循环,必须固定在同一个 OS 线程上。 func Run(cb Callbacks) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + systray.Run(func() { systray.SetIcon(iconData) systray.SetTitle("Ant Chrome") @@ -40,6 +45,12 @@ func Run(cb Callbacks) { } }) + systray.SetOnRClick(func(menu systray.IMenu) { + if menu != nil { + _ = menu.ShowMenu() + } + }) + mShow.Click(func() { if cb.OnShow != nil { cb.OnShow() diff --git a/backend/internal/tray/tray_stub.go b/backend/internal/tray/tray_stub.go new file mode 100644 index 00000000..9d8adeb0 --- /dev/null +++ b/backend/internal/tray/tray_stub.go @@ -0,0 +1,15 @@ +//go:build !windows + +package tray + +// Callbacks 托盘回调 +type Callbacks struct { + OnShow func() + OnQuit func() +} + +// Run 非 Windows 平台无托盘实现,保持空操作。 +func Run(cb Callbacks) {} + +// Quit 非 Windows 平台无托盘实现,保持空操作。 +func Quit() {} diff --git a/backend/license_state.go b/backend/license_state.go index 2a79dc04..c16828e5 100644 --- a/backend/license_state.go +++ b/backend/license_state.go @@ -1,6 +1,7 @@ package backend import ( + appconfig "ant-chrome/backend/internal/config" "encoding/json" "fmt" "os" @@ -80,7 +81,7 @@ func reconcileConfigWithLocalLicense(configPath string, cfg *Config) (bool, bool mergedKeys := unionUsedCDKeys(originalKeys, state.UsedCDKeys) effectiveMax := maxInt(originalMax, state.MaxProfileLimit) - minLimit := minimumProfileLimitForKeys(mergedKeys) + minLimit := appconfig.MinimumProfileLimitForUsedKeys(mergedKeys) if effectiveMax < minLimit { effectiveMax = minLimit } @@ -113,7 +114,7 @@ func normalizeLocalLicenseState(state *localLicenseState) { return } state.UsedCDKeys = normalizeUsedCDKeys(state.UsedCDKeys) - minLimit := minimumProfileLimitForKeys(state.UsedCDKeys) + minLimit := appconfig.MinimumProfileLimitForUsedKeys(state.UsedCDKeys) if state.MaxProfileLimit < minLimit { state.MaxProfileLimit = minLimit } @@ -153,11 +154,6 @@ func unionUsedCDKeys(primary, secondary []string) []string { return result } -func minimumProfileLimitForKeys(keys []string) int { - baseLimit := DefaultConfig().App.MaxProfileLimit - return baseLimit + len(normalizeUsedCDKeys(keys))*3 -} - func sameStringSlice(a, b []string) bool { if len(a) != len(b) { return false diff --git a/backend/license_state_test.go b/backend/license_state_test.go index 47bb9052..10aaedc3 100644 --- a/backend/license_state_test.go +++ b/backend/license_state_test.go @@ -15,7 +15,7 @@ func TestLoadConfigRestoresLocalLicenseState(t *testing.T) { t.Fatalf("写入测试配置失败: %v", err) } if err := saveLocalLicenseState(configPath, &localLicenseState{ - MaxProfileLimit: 12, + MaxProfileLimit: appconfig.GithubStarProfileTotal + appconfig.StandardCDKeyProfileBonus, UsedCDKeys: []string{"GITHUB_STAR_REWARD", "ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE"}, }); err != nil { t.Fatalf("写入本机额度状态失败: %v", err) @@ -26,7 +26,7 @@ func TestLoadConfigRestoresLocalLicenseState(t *testing.T) { t.Fatalf("LoadConfig 失败: %v", err) } - if loaded.App.MaxProfileLimit != 12 { + if loaded.App.MaxProfileLimit != appconfig.GithubStarProfileTotal+appconfig.StandardCDKeyProfileBonus { t.Fatalf("本机额度状态未恢复: got=%d", loaded.App.MaxProfileLimit) } if len(loaded.App.UsedCDKeys) != 2 { @@ -39,7 +39,7 @@ func TestLoadConfigSeedsLocalLicenseStateFromConfig(t *testing.T) { configPath := filepath.Join(root, "config.yaml") cfg := appconfig.DefaultConfig() - cfg.App.MaxProfileLimit = 15 + cfg.App.MaxProfileLimit = appconfig.GithubStarProfileTotal cfg.App.UsedCDKeys = []string{"GITHUB_STAR_REWARD"} if err := cfg.Save(configPath); err != nil { t.Fatalf("写入测试配置失败: %v", err) @@ -49,7 +49,7 @@ func TestLoadConfigSeedsLocalLicenseStateFromConfig(t *testing.T) { if err != nil { t.Fatalf("LoadConfig 失败: %v", err) } - if loaded.App.MaxProfileLimit != 15 { + if loaded.App.MaxProfileLimit != appconfig.GithubStarProfileTotal { t.Fatalf("LoadConfig 读取额度失败: got=%d", loaded.App.MaxProfileLimit) } @@ -60,7 +60,7 @@ func TestLoadConfigSeedsLocalLicenseStateFromConfig(t *testing.T) { if !exists { t.Fatalf("应当从现有配置补建本机额度状态") } - if state.MaxProfileLimit != 15 { + if state.MaxProfileLimit != appconfig.GithubStarProfileTotal { t.Fatalf("本机额度状态未补建: got=%d", state.MaxProfileLimit) } if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" { @@ -91,7 +91,7 @@ func TestRedeemGithubStarPersistsLocalLicenseState(t *testing.T) { if !exists { t.Fatalf("兑换后应写入本机额度状态") } - if state.MaxProfileLimit != 6 { + if state.MaxProfileLimit != appconfig.GithubStarProfileTotal { t.Fatalf("兑换后本机额度状态错误: got=%d", state.MaxProfileLimit) } if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" { diff --git a/backend/runtime_paths.go b/backend/runtime_paths.go new file mode 100644 index 00000000..bcef8eb9 --- /dev/null +++ b/backend/runtime_paths.go @@ -0,0 +1,23 @@ +package backend + +import "ant-chrome/backend/internal/apppath" + +// EnsureRuntimeLayout 为运行时准备已安装应用的用户可写目录。 +func EnsureRuntimeLayout(appRoot string) error { + return apppath.EnsureWritableLayout(appRoot) +} + +// ResolveRuntimePath 将相对路径解析到安装目录或用户状态目录。 +func ResolveRuntimePath(appRoot, p string) string { + return apppath.Resolve(appRoot, p) +} + +// RuntimeStateRoot 返回当前运行时使用的状态目录。 +func RuntimeStateRoot(appRoot string) string { + return apppath.StateRoot(appRoot) +} + +// RuntimeUsesDetachedState 表示当前是否启用了“安装目录只读、状态目录独立”的模式。 +func RuntimeUsesDetachedState(appRoot string) bool { + return apppath.IsDetached(appRoot) +} diff --git a/backend/test/launchcode/server_params_test.go b/backend/test/launchcode/server_params_test.go index cbdebdb3..8dc891bb 100644 --- a/backend/test/launchcode/server_params_test.go +++ b/backend/test/launchcode/server_params_test.go @@ -14,6 +14,7 @@ import ( type mockStarterWithParams struct { profiles map[string]*browser.Profile lastProfile string + started []string lastParams launchcode.LaunchRequestParams } @@ -27,6 +28,7 @@ func (m *mockStarterWithParams) addProfile(p *browser.Profile) { func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profile, error) { m.lastProfile = profileId + m.started = append(m.started, profileId) p, ok := m.profiles[profileId] if !ok { return nil, http.ErrMissingFile @@ -36,6 +38,7 @@ func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profil func (m *mockStarterWithParams) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { m.lastProfile = profileId + m.started = append(m.started, profileId) m.lastParams = params p, ok := m.profiles[profileId] if !ok { @@ -90,6 +93,102 @@ func TestLaunchWithParams(t *testing.T) { } } +func TestLaunchWithParamsUsingCodeAsKeywordFallback(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + profile := &browser.Profile{ + ProfileId: "profile-automation-keyword-fallback", + ProfileName: "automation-keyword-fallback", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 654, + DebugPort: 9666, + } + starter.addProfile(profile) + + manager := newSelectorTestManager(profile) + handler := buildTestHandlerWithManager(svc, starter, manager) + body := map[string]interface{}{ + "code": "buyer-001", + "launchArgs": []string{"--window-size=1280,800", "--lang=en-US"}, + "startUrls": []string{"https://example.com"}, + "skipDefaultStartUrls": true, + } + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profile.ProfileId { + t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) + } + if len(starter.lastParams.LaunchArgs) != 2 { + t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs) + } + if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { + t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs) + } + if !starter.lastParams.SkipDefaultStartURLs { + t.Fatal("skipDefaultStartUrls 传递错误") + } +} + +func TestLaunchWithParamsUsingCodeAsKeywordFallbackPrefersExactKeywordMatch(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + profileFuzzy := &browser.Profile{ + ProfileId: "profile-params-code-fuzzy", + ProfileName: "automation-fuzzy", + Keywords: []string{"buyer-001-old", "amazon"}, + Pid: 655, + DebugPort: 9667, + } + profileExact := &browser.Profile{ + ProfileId: "profile-params-code-exact", + ProfileName: "automation-exact", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 656, + DebugPort: 9668, + } + starter.addProfile(profileFuzzy) + starter.addProfile(profileExact) + + manager := newSelectorTestManager(profileFuzzy, profileExact) + handler := buildTestHandlerWithManager(svc, starter, manager) + body := map[string]interface{}{ + "code": "buyer-001", + "launchArgs": []string{"--window-size=1280,800", "--lang=en-US"}, + "startUrls": []string{"https://example.com"}, + "skipDefaultStartUrls": true, + } + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileExact.ProfileId { + t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) + } + if len(starter.lastParams.LaunchArgs) != 2 { + t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs) + } + if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { + t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs) + } + if !starter.lastParams.SkipDefaultStartURLs { + t.Fatal("skipDefaultStartUrls 传递错误") + } +} + func TestLaunchWithParamsBadRequest(t *testing.T) { svc := newInMemoryService() starter := newMockStarterWithParams() diff --git a/backend/test/launchcode/server_proxy_test.go b/backend/test/launchcode/server_proxy_test.go new file mode 100644 index 00000000..96759ed5 --- /dev/null +++ b/backend/test/launchcode/server_proxy_test.go @@ -0,0 +1,124 @@ +package launchcode_test + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "ant-chrome/backend/internal/browser" +) + +func mustDebugPortFromURL(t *testing.T, rawURL string) int { + t.Helper() + + hostPort := strings.TrimPrefix(rawURL, "http://") + host, portText, err := net.SplitHostPort(hostPort) + if err != nil { + t.Fatalf("解析测试 URL 失败: %v", err) + } + if host == "" { + t.Fatalf("测试 URL host 为空: %s", rawURL) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("解析测试端口失败: %v", err) + } + return port +} + +func TestCDPProxyReturnsUnavailableWithoutActiveTarget(t *testing.T) { + handler := buildTestHandler(newInMemoryService(), newMockStarter()) + + req := httptest.NewRequest(http.MethodGet, "/json/version", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if resp["error"] != "no active browser debug target" { + t.Fatalf("错误信息不正确: %+v", resp) + } +} + +func TestCDPProxySwitchesToLatestLaunchedProfile(t *testing.T) { + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"Browser":"Mock-A"}`)) + })) + defer serverA.Close() + + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"Browser":"Mock-B"}`)) + })) + defer serverB.Close() + + svc := newInMemoryService() + starter := newMockStarter() + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Profile A", + Pid: 1001, + DebugPort: mustDebugPortFromURL(t, serverA.URL), + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Profile B", + Pid: 1002, + DebugPort: mustDebugPortFromURL(t, serverB.URL), + } + starter.addProfile(profileA) + starter.addProfile(profileB) + + codeA, err := svc.EnsureCode(profileA.ProfileId) + if err != nil { + t.Fatalf("EnsureCode(A) 失败: %v", err) + } + codeB, err := svc.EnsureCode(profileB.ProfileId) + if err != nil { + t.Fatalf("EnsureCode(B) 失败: %v", err) + } + + handler := buildTestHandler(svc, starter) + + for _, tc := range []struct { + code string + wantMarker string + }{ + {code: codeA, wantMarker: "Mock-A"}, + {code: codeB, wantMarker: "Mock-B"}, + } { + launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+tc.code, nil) + launchResp := httptest.NewRecorder() + handler.ServeHTTP(launchResp, launchReq) + if launchResp.Code != http.StatusOK { + t.Fatalf("启动请求失败: code=%s status=%d body=%s", tc.code, launchResp.Code, launchResp.Body.String()) + } + + proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil) + proxyResp := httptest.NewRecorder() + handler.ServeHTTP(proxyResp, proxyReq) + if proxyResp.Code != http.StatusOK { + t.Fatalf("代理请求失败: code=%s status=%d body=%s", tc.code, proxyResp.Code, proxyResp.Body.String()) + } + if !strings.Contains(proxyResp.Body.String(), tc.wantMarker) { + t.Fatalf("代理未切换到最新实例: want=%s body=%s", tc.wantMarker, proxyResp.Body.String()) + } + } +} diff --git a/backend/test/launchcode/server_selector_test.go b/backend/test/launchcode/server_selector_test.go new file mode 100644 index 00000000..78fb3c73 --- /dev/null +++ b/backend/test/launchcode/server_selector_test.go @@ -0,0 +1,548 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/launchcode" +) + +func buildTestHandlerWithManager(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter, mgr *browser.Manager) http.Handler { + srv := launchcode.NewLaunchServer(svc, starter, mgr, 0) + return launchcode.NewTestHandler(srv) +} + +func newSelectorTestManager(profiles ...*browser.Profile) *browser.Manager { + items := make(map[string]*browser.Profile, len(profiles)) + for _, profile := range profiles { + items[profile.ProfileId] = profile + } + return &browser.Manager{ + Profiles: items, + } +} + +func TestLaunchWithKeywordSelector(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profile := &browser.Profile{ + ProfileId: "profile-keyword", + ProfileName: "Amazon US", + GroupId: "group-sales", + Tags: []string{"电商", "北美"}, + Keywords: []string{"amazon-us", "checkout", "buyer-account"}, + Pid: 9527, + DebugPort: 9333, + } + starter.addProfile(profile) + manager := newSelectorTestManager(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + payload := bytes.NewBufferString(`{ + "selector": { + "keyword": "checkout", + "tags": ["电商"], + "groupId": "group-sales" + }, + "skipDefaultStartUrls": true + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/launch", payload) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profile.ProfileId { + t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) + } + + var resp struct { + OK bool `json:"ok"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.ProfileID != profile.ProfileId { + t.Fatalf("响应不正确: %+v", resp) + } + if strings.TrimSpace(resp.LaunchCode) == "" { + t.Fatalf("期望返回 resolved launchCode,实际为空: %+v", resp) + } +} + +func TestLaunchWithTopLevelKeywordSelector(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profile := &browser.Profile{ + ProfileId: "profile-top-level", + ProfileName: "Billing Ops", + Keywords: []string{"billing", "invoice"}, + Pid: 1001, + DebugPort: 9444, + } + starter.addProfile(profile) + manager := newSelectorTestManager(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"keyword":"billing"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profile.ProfileId { + t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) + } +} + +func TestLaunchWithTopLevelKeyAliasSelector(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profile := &browser.Profile{ + ProfileId: "profile-top-level-key", + ProfileName: "Buyer Account", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 1002, + DebugPort: 9445, + } + starter.addProfile(profile) + manager := newSelectorTestManager(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profile.ProfileId { + t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) + } +} + +func TestLaunchWithTopLevelKeyPrefersExactKeywordMatch(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileFuzzy := &browser.Profile{ + ProfileId: "profile-key-fuzzy", + ProfileName: "Account A", + Keywords: []string{"buyer-001-old", "amazon"}, + Pid: 1004, + DebugPort: 9447, + } + profileExact := &browser.Profile{ + ProfileId: "profile-key-exact", + ProfileName: "Z Account", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 1005, + DebugPort: 9448, + } + starter.addProfile(profileFuzzy) + starter.addProfile(profileExact) + manager := newSelectorTestManager(profileFuzzy, profileExact) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileExact.ProfileId { + t.Fatalf("key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) + } +} + +func TestLaunchWithNestedSelectorKeyPrefersExactKeywordMatch(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileFuzzy := &browser.Profile{ + ProfileId: "profile-selector-key-fuzzy", + ProfileName: "Account A", + Keywords: []string{"buyer-001-old", "amazon"}, + Pid: 1006, + DebugPort: 9449, + } + profileExact := &browser.Profile{ + ProfileId: "profile-selector-key-exact", + ProfileName: "Z Account", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 1007, + DebugPort: 9450, + } + starter.addProfile(profileFuzzy) + starter.addProfile(profileExact) + manager := newSelectorTestManager(profileFuzzy, profileExact) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"key":"buyer-001"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileExact.ProfileId { + t.Fatalf("selector.key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) + } +} + +func TestLaunchWithTopLevelCodeFallbackToKeyword(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profile := &browser.Profile{ + ProfileId: "profile-top-level-code-fallback", + ProfileName: "Buyer Account 01", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 1003, + DebugPort: 9446, + } + starter.addProfile(profile) + manager := newSelectorTestManager(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profile.ProfileId { + t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) + } +} + +func TestLaunchWithTopLevelCodeFallbackPrefersExactKeywordMatch(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileFuzzy := &browser.Profile{ + ProfileId: "profile-code-fuzzy", + ProfileName: "Account A", + Keywords: []string{"buyer-001-old", "amazon"}, + Pid: 1008, + DebugPort: 9451, + } + profileExact := &browser.Profile{ + ProfileId: "profile-code-exact", + ProfileName: "Z Account", + Keywords: []string{"buyer-001", "amazon"}, + Pid: 1009, + DebugPort: 9452, + } + starter.addProfile(profileFuzzy) + starter.addProfile(profileExact) + manager := newSelectorTestManager(profileFuzzy, profileExact) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileExact.ProfileId { + t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) + } +} + +func TestLaunchWithAmbiguousKeywordSelectorReturnsFirstByDefault(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Account A", + Keywords: []string{"shop", "checkout"}, + Pid: 1001, + DebugPort: 9441, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Account B", + Keywords: []string{"shop", "refund"}, + Pid: 1002, + DebugPort: 9442, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileA, profileB) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileA.ProfileId { + t.Fatalf("关键字多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) + } +} + +func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Account A", + Keywords: []string{"shop", "checkout"}, + Pid: 1001, + DebugPort: 9441, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Account B", + Keywords: []string{"shop", "refund"}, + Pid: 1002, + DebugPort: 9442, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileA, profileB) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileA.ProfileId { + t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) + } +} + +func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Account A", + Keywords: []string{"shop", "checkout"}, + Pid: 1001, + DebugPort: 9441, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Account B", + Keywords: []string{"shop", "refund"}, + Pid: 1002, + DebugPort: 9442, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileA, profileB) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != "" { + t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile) + } + if !strings.Contains(w.Body.String(), "matchMode=first") { + t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String()) + } +} + +func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profile := &browser.Profile{ + ProfileId: "profile-get-code-only", + ProfileName: "Buyer Account 02", + Keywords: []string{"buyer-002"}, + Pid: 1004, + DebugPort: 9447, + } + starter.addProfile(profile) + manager := newSelectorTestManager(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != "" { + t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile) + } +} + +func TestLaunchWithMatchModeFirst(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "B Account", + Keywords: []string{"shop"}, + Pid: 2002, + DebugPort: 9552, + } + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "A Account", + Keywords: []string{"shop"}, + Pid: 2001, + DebugPort: 9551, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileB, profileA) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileA.ProfileId { + t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) + } +} + +func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "A Account", + Keywords: []string{"shop"}, + Pid: 2001, + DebugPort: 9551, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "B Account", + Keywords: []string{"shop"}, + Pid: 2002, + DebugPort: 9552, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileB, profileA) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if len(starter.started) != 2 { + t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started) + } + if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId { + t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started) + } + + var resp struct { + OK bool `json:"ok"` + Count int `json:"count"` + Items []struct { + ProfileID string `json:"profileId"` + IsActive bool `json:"isActive"` + } `json:"items"` + ActiveProfileID string `json:"activeProfileId"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 { + t.Fatalf("批量启动响应错误: %+v", resp) + } + if resp.ActiveProfileID != profileB.ProfileId { + t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId) + } + if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId { + t.Fatalf("items 顺序错误: %+v", resp.Items) + } + if resp.Items[0].IsActive || !resp.Items[1].IsActive { + t.Fatalf("isActive 标记错误: %+v", resp.Items) + } +} + +func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Account A", + Keywords: []string{"shop", "checkout"}, + Pid: 1001, + DebugPort: 9441, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Account B", + Keywords: []string{"shop", "refund"}, + Pid: 1002, + DebugPort: 9442, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileA, profileB) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) + } + if len(starter.started) != 0 { + t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started) + } +} diff --git a/bat/README.md b/bat/README.md index 5a8675cb..fcd6851f 100644 --- a/bat/README.md +++ b/bat/README.md @@ -1,10 +1,12 @@ # bat +> 脚本入口运行于 Windows。`publish.bat` 支持 Windows 打包,也可通过 Docker Desktop 调用 Linux 发布脚本。 + ## 用途 - `dev.bat`:本地开发启动 - `build.bat`:本地构建可执行文件 -- `publish.bat`:打包发布安装包 +- `publish.bat`:发布打包入口(Windows / Linux / 两者) ## 用法 @@ -16,6 +18,12 @@ bat\dev.bat ``` +说明: + +- 默认优先使用 `5218` 作为前端开发端口 +- 如果发现同项目残留的 `dev-watcher / vite` 进程,会先自动清理 +- 如果 `5218` 被其他程序占用,会自动切换到下一个可用端口,并把该端口同步传给 Vite 和 Wails + ### `build.bat` 构建 `build\bin\ant-chrome.exe`。 @@ -24,15 +32,39 @@ bat\dev.bat bat\build.bat ``` +说明: + +- 开发分支默认按完整源码构建 +- 若缺少 `go.mod`、`main.go`、`wails.json` 等核心入口文件,脚本会直接失败,避免复用旧产物掩盖问题 + ### `publish.bat` -生成安装包,需要先安装 NSIS。 +发布打包入口,启动后会提示选择: + +- `W`:仅 Windows +- `L`:仅 Linux(通过 Docker Desktop) +- `B`:Windows + Linux ```bat bat\publish.bat ``` -默认依赖路径: +也支持无交互参数(适合脚本调用): + +```bat +bat\publish.bat W +bat\publish.bat L +bat\publish.bat B +bat\publish.bat W -Version 1.1.0 +bat\publish.bat B -Version 1.1.0 +``` + +说明: + +- `-Version 1.1.0` 会覆盖本次发布使用的版本号。 +- Windows / Linux 包名、NSIS 安装包版本号,以及本次构建期间读取到的 `wails.json productVersion` 会统一使用该值。 + +Windows 打包依赖 NSIS,默认查找顺序: ```text MAKENSIS_PATH -> 直接指向 makensis.exe @@ -48,7 +80,7 @@ C:\Program Files (x86)\NSIS\makensis.exe C:\Program Files\NSIS\makensis.exe ``` -脚本使用的项目路径: +Windows 分支使用的项目路径: ```text 输入: @@ -56,7 +88,6 @@ C:\Program Files\NSIS\makensis.exe - publish\config.init.yaml - bin\xray.exe - bin\sing-box.exe -- chrome\ 临时目录: - publish\staging\ @@ -65,7 +96,36 @@ C:\Program Files\NSIS\makensis.exe - publish\output\AntBrowser-Setup-.exe ``` -产物: +说明: + +- Windows 安装包包含应用本体、默认配置和代理运行时。 +- 如果 `chrome\` 根目录或其一级子目录中检测到有效的 Windows `chrome.exe`,会自动一起打进 EXE 安装包。 +- 如果未检测到 Windows 内核,安装包仍会保留 `chrome\README.md` 说明文件。 + +Linux 分支会通过 Docker Desktop 调用: + +```text +docker build -f publish/linux/linux-builder.Dockerfile -t ant-browser-linux-builder:local publish/linux +docker run --rm -v :/workspace -w /workspace ant-browser-linux-builder:local ^ + bash -c "bash publish/linux/publish-linux.sh --arch " +``` + +要求:Docker Desktop 已安装并启动,且 Linux 容器引擎可用。 + +Linux 产物输出目录: + +```text +publish\output\ +``` + +常用环境变量: + +```text +NO_PAUSE=1 -> 运行结束不 pause(适合 CI 或脚本调用) +CI=1 -> 同样不 pause +``` + +Windows 产物: ```text publish\output\AntBrowser-Setup-.exe @@ -74,3 +134,5 @@ publish\output\AntBrowser-Setup-.exe ## 备注 - `generate-bindings.bat` 是辅助脚本,通常由 `build.bat` 调用。 +- `generate-bindings.bat`、`build.bat`、`dev.bat` 都假定当前分支是完整源码仓库。 +- 如果这些脚本报告缺少 `go.mod`、`main.go`、`wails.json`,应先恢复源码入口,而不是继续复用旧二进制。 diff --git a/bat/build.bat b/bat/build.bat index b286be9c..c98bd388 100644 --- a/bat/build.bat +++ b/bat/build.bat @@ -1,148 +1,22 @@ @echo off -chcp 65001 >nul -setlocal enabledelayedexpansion +setlocal EnableExtensions -REM 切换到项目根目录(脚本所在目录的上一级) -cd /d "%~dp0.." - -echo ======================================== -echo Ant Browser - Wails 构建脚本 -echo ======================================== -echo. -echo 当前工作目录: %CD% -echo. - -REM ======== 代理配置 ======== -REM 本地代理地址(例如 Clash、V2Ray 等) -set PROXY_HOST=127.0.0.1 -set PROXY_PORT=7890 -set USE_PROXY=1 - -REM 如果不需要使用代理,将 USE_PROXY 设置为 0 -REM set USE_PROXY=0 - -REM 设置代理环境变量 -if "%USE_PROXY%"=="1" ( - echo [0/7] 正在配置代理... - set HTTP_PROXY=http://%PROXY_HOST%:%PROXY_PORT% - set HTTPS_PROXY=http://%PROXY_HOST%:%PROXY_PORT% - set http_proxy=http://%PROXY_HOST%:%PROXY_PORT% - set https_proxy=http://%PROXY_HOST%:%PROXY_PORT% - - REM 配置 npm 代理 - call npm config set proxy http://%PROXY_HOST%:%PROXY_PORT% 2>nul - call npm config set https-proxy http://%PROXY_HOST%:%PROXY_PORT% 2>nul - - REM 配置 Go 代理环境变量 - set GOPROXY=https://goproxy.cn,direct - - echo ✓ 代理已配置: %PROXY_HOST%:%PROXY_PORT% - echo. +set "SCRIPT_DIR=%~dp0" +if not exist "%SCRIPT_DIR%build.ps1" ( + echo [ERROR] Missing bat\build.ps1 + if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause + endlocal & exit /b 1 ) - -REM 定义清理函数(用于恢复代理设置) -goto :skip_cleanup_function -:cleanup -if "%USE_PROXY%"=="1" ( - echo. - echo [清理代理配置...] - call npm config delete proxy 2>nul - call npm config delete https-proxy 2>nul - echo ✓ 代理配置已清理 -) -exit /b -:skip_cleanup_function - - -echo [1/7] 安装前端依赖... -cd frontend -call npm install -if %errorlevel% neq 0 ( - echo ✗ 安装前端依赖失败 - cd .. - call :cleanup - pause - exit /b 1 -) -cd .. +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build.ps1" %* +set "EXIT_CODE=%ERRORLEVEL%" echo. -echo [2/7] 安装 Go 依赖... -go mod download -go mod tidy -if %errorlevel% neq 0 ( - echo ✗ 安装 Go 依赖失败 - call :cleanup - pause - exit /b 1 -) - -echo. -echo [3/7] 创建临时 dist 目录... -if not exist "frontend\dist" ( - mkdir "frontend\dist" - echo. > "frontend\dist\index.html" - echo ✓ 临时 dist 目录已创建 +if "%EXIT_CODE%"=="0" ( + echo Build finished successfully. ) else ( - echo ✓ dist 目录已存在 + echo Build failed with exit code %EXIT_CODE%. ) -echo. -echo [4/7] 生成 Wails 绑定文件... -call bat\generate-bindings.bat --no-pause -if %errorlevel% neq 0 ( - echo ✗ 生成绑定文件失败 - call :cleanup - pause - exit /b 1 -) +if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause -echo. -echo [5/7] 构建前端项目... -REM 清理临时 dist 目录 -if exist "frontend\dist" ( - rmdir /S /Q "frontend\dist" 2>nul - echo ✓ 临时 dist 目录已清理 -) -cd frontend -call npm run build -if %errorlevel% neq 0 ( - echo ✗ 构建前端失败 - cd .. - call :cleanup - pause - exit /b 1 -) -cd .. - -echo. -echo [6/7] 构建应用... -wails build -if %errorlevel% neq 0 ( - echo ✗ 构建失败 - call :cleanup - pause - exit /b 1 -) - -echo. -echo [7/7] 复制运行时依赖... -if exist "bin" ( - xcopy /E /I /Y bin build\bin\bin >nul - echo ✓ bin 目录已复制到 build\bin\bin\ -) else ( - echo [Warn] bin 目录不存在,跳过复制 -) - -echo. -echo ======================================== -echo ✓ 构建成功! -echo ======================================== -echo. -echo 可执行文件位置: build\bin\ant-chrome.exe -echo. - -REM 清理代理配置 -call :cleanup - -pause +endlocal & exit /b %EXIT_CODE% diff --git a/bat/build.ps1 b/bat/build.ps1 new file mode 100644 index 00000000..b652dac5 --- /dev/null +++ b/bat/build.ps1 @@ -0,0 +1,159 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location $repoRoot + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$Arguments = @() + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + $argText = if ($Arguments.Count -gt 0) { " $($Arguments -join ' ')" } else { "" } + throw "$FilePath$argText failed with exit code $LASTEXITCODE" + } +} + +function Assert-RequiredSourceFiles { + param( + [Parameter(Mandatory = $true)] + [string]$Action, + [Parameter(Mandatory = $true)] + [string[]]$Paths + ) + + $missing = @() + foreach ($relativePath in $Paths) { + $fullPath = Join-Path $repoRoot $relativePath + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + $missing += $relativePath + } + } + + if ($missing.Count -gt 0) { + throw "$Action requires a complete source tree. Missing files: $($missing -join ', ')" + } +} + +try { + Write-Host "========================================" + Write-Host " Ant Browser - Build Script" + Write-Host "========================================" + Write-Host "" + Write-Host "Current workdir: $repoRoot" + Write-Host "" + + $proxyHost = "127.0.0.1" + $proxyPort = "7890" + $useProxy = $true + + if ($useProxy) { + Write-Host "[0/7] Configuring proxy..." + $proxyValue = "http://${proxyHost}:${proxyPort}" + $env:HTTP_PROXY = $proxyValue + $env:HTTPS_PROXY = $proxyValue + $env:http_proxy = $proxyValue + $env:https_proxy = $proxyValue + $env:GOPROXY = "https://goproxy.cn,direct" + + & npm config set proxy $proxyValue | Out-Null + & npm config set https-proxy $proxyValue | Out-Null + + Write-Host "OK proxy configured: ${proxyHost}:${proxyPort}" + Write-Host "" + } + + Assert-RequiredSourceFiles -Action "Building from source" -Paths @( + "go.mod", + "go.sum", + "main.go", + "wails.json" + ) + + Write-Host "[1/7] Installing frontend dependencies..." + Push-Location (Join-Path $repoRoot "frontend") + try { + Invoke-NativeCommand -FilePath "npm" -Arguments @("install") + Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "ensure:native") + } + finally { + Pop-Location + } + + Write-Host "" + Write-Host "[2/7] Installing Go dependencies..." + Invoke-NativeCommand -FilePath "go" -Arguments @("mod", "download") + Invoke-NativeCommand -FilePath "go" -Arguments @("mod", "tidy") + + Write-Host "" + Write-Host "[3/7] Ensuring frontend\dist exists..." + $frontendDist = Join-Path $repoRoot "frontend/dist" + $tempDistCreated = $false + if (-not (Test-Path -LiteralPath $frontendDist)) { + New-Item -ItemType Directory -Path $frontendDist -Force | Out-Null + Set-Content -LiteralPath (Join-Path $frontendDist "index.html") -Value "" -Encoding ascii + $tempDistCreated = $true + Write-Host "OK temporary dist directory created" + } else { + Write-Host "OK dist directory already exists" + } + + Write-Host "" + Write-Host "[4/7] Generating Wails bindings..." + Invoke-NativeCommand -FilePath "cmd" -Arguments @("/c", "call bat\generate-bindings.bat --no-pause") + + $binaryPath = Join-Path $repoRoot "build/bin/ant-chrome.exe" + + Write-Host "" + Write-Host "[5/7] Building frontend..." + if ($tempDistCreated -and (Test-Path -LiteralPath $frontendDist)) { + Remove-Item -LiteralPath $frontendDist -Recurse -Force -ErrorAction SilentlyContinue + } + Push-Location (Join-Path $repoRoot "frontend") + try { + Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "build") + } + finally { + Pop-Location + } + + Write-Host "" + Write-Host "[6/7] Building app..." + Invoke-NativeCommand -FilePath "wails" -Arguments @("build") + + if ($tempDistCreated -and (Test-Path -LiteralPath $frontendDist)) { + Remove-Item -LiteralPath $frontendDist -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host "" + Write-Host "[7/7] Copying runtime dependencies..." + $binDir = Join-Path $repoRoot "bin" + $targetDir = Join-Path $repoRoot "build/bin/bin" + if (Test-Path -LiteralPath $binDir -PathType Container) { + Copy-Item -LiteralPath $binDir -Destination $targetDir -Recurse -Force + Write-Host "OK copied bin directory to build\bin\bin\" + } else { + Write-Host "[WARN] bin directory not found, skipping copy" + } + + Write-Host "" + Write-Host "========================================" + Write-Host " OK build completed" + Write-Host "========================================" + Write-Host "" + Write-Host "Executable: build\bin\ant-chrome.exe" + exit 0 +} +catch { + Write-Host "" + Write-Host "[ERROR] $($_.Exception.Message)" + exit 1 +} +finally { + & npm config delete proxy 2>$null | Out-Null + & npm config delete https-proxy 2>$null | Out-Null +} diff --git a/bat/dev.bat b/bat/dev.bat index ee266f20..26ba5382 100644 --- a/bat/dev.bat +++ b/bat/dev.bat @@ -12,36 +12,74 @@ echo. call :cleanup_dev_logs +set PREFERRED_FRONTEND_PORT=5218 +set FRONTEND_PORT= + +if not defined FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB set FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB=256 +if not defined FRONTEND_NODE_MAX_SEMI_SPACE_SIZE_MB set FRONTEND_NODE_MAX_SEMI_SPACE_SIZE_MB=16 +if not defined FRONTEND_NODE_RSS_WARN_MB set FRONTEND_NODE_RSS_WARN_MB=256 +if not defined FRONTEND_NODE_RSS_HARD_LIMIT_MB set FRONTEND_NODE_RSS_HARD_LIMIT_MB=360 +if not defined FRONTEND_NODE_MEMORY_POLL_MS set FRONTEND_NODE_MEMORY_POLL_MS=3000 + echo Cleaning stale processes... +node frontend\scripts\dev-port-helper.mjs cleanup +if errorlevel 1 ( + echo [ERROR] Failed to clean stale frontend dev processes. + pause + exit /b 1 +) taskkill /F /IM ant-chrome-dev.exe >nul 2>&1 taskkill /F /IM ant-chrome.exe >nul 2>&1 echo. -set FRONTEND_PORT=5218 -set PORT_ERROR=0 -set TEMP_DEV_DIST_CREATED=0 -set TEMP_DEV_PLACEHOLDER_CREATED=0 - -call :cleanup_local_vite_port %FRONTEND_PORT% - -echo Checking port status... -call :check_port %FRONTEND_PORT% - -if "!PORT_ERROR!"=="1" ( - echo. - echo Please close the process using the occupied port and retry. +echo Resolving frontend dev port... +for /f "usebackq delims=" %%a in (`node frontend\scripts\dev-port-helper.mjs resolve --preferred %PREFERRED_FRONTEND_PORT%`) do ( + if not defined FRONTEND_PORT set "FRONTEND_PORT=%%a" +) +if not defined FRONTEND_PORT ( + echo [ERROR] Failed to resolve frontend dev port. pause exit /b 1 ) +if not "%FRONTEND_PORT%"=="%PREFERRED_FRONTEND_PORT%" ( + echo [ERROR] Preferred frontend port %PREFERRED_FRONTEND_PORT% is occupied by another program. + echo Wails dev in current mode must use the fixed port %PREFERRED_FRONTEND_PORT%. + echo Please free that port and retry. + pause + exit /b 1 +) +echo [OK] Frontend dev port: %FRONTEND_PORT% +echo. +set FRONTEND_PORT=%PREFERRED_FRONTEND_PORT% +echo Frontend Node old-space limit: %FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB% MB +echo Frontend Node semi-space limit: %FRONTEND_NODE_MAX_SEMI_SPACE_SIZE_MB% MB +echo Frontend Node RSS warning: %FRONTEND_NODE_RSS_WARN_MB% MB +echo Frontend Node RSS hard limit: %FRONTEND_NODE_RSS_HARD_LIMIT_MB% MB +echo Frontend Node RSS poll interval: %FRONTEND_NODE_MEMORY_POLL_MS% ms echo. set GOPROXY=https://goproxy.cn,direct echo Checking dependencies... -if not exist "go.sum" ( - echo Installing Go dependencies... - go mod download - go mod tidy +if not exist "go.mod" ( + echo [ERROR] go.mod not found in repository root. + echo This development branch must keep a complete Go source tree. + pause + exit /b 1 +) +if not exist "wails.json" ( + echo [ERROR] wails.json not found in repository root. + echo This development branch must keep a complete Wails source tree. + pause + exit /b 1 +) +echo Installing Go dependencies... +go mod download +go mod tidy +if errorlevel 1 ( + echo [ERROR] Failed to install Go dependencies. + pause + exit /b 1 ) if not exist "frontend\node_modules" ( @@ -53,40 +91,25 @@ if not exist "frontend\node_modules" ( echo. echo Regenerating Wails bindings... -if not exist "frontend\dist" ( - mkdir "frontend\dist" - set TEMP_DEV_DIST_CREATED=1 -) -if not exist "frontend\dist\__wails_placeholder__.txt" ( - echo placeholder> "frontend\dist\__wails_placeholder__.txt" - set TEMP_DEV_PLACEHOLDER_CREATED=1 -) -wails generate module +call bat\generate-bindings.bat --no-pause if errorlevel 1 ( - call :cleanup_temp_dist echo [ERROR] Failed to generate Wails bindings. pause exit /b 1 ) - -if exist "frontend\wailsjs" ( - xcopy /E /I /Y "frontend\wailsjs" "frontend\src\wailsjs" >nul -) if not exist "frontend\src\wailsjs" ( - call :cleanup_temp_dist echo [ERROR] Wails bindings output folder not found. pause exit /b 1 ) -call :cleanup_temp_dist echo. -echo Starting dev server... +echo Starting Wails dev... echo Frontend URL: http://127.0.0.1:%FRONTEND_PORT% -echo Wails dev endpoint: auto-select +echo Wails dev endpoint: http://127.0.0.1:%FRONTEND_PORT% echo. -wails dev -viteservertimeout 60 +wails dev -s -viteservertimeout 60 set EXIT_CODE=%errorlevel% if not "%EXIT_CODE%"=="0" ( @@ -97,15 +120,6 @@ if not "%EXIT_CODE%"=="0" ( pause exit /b %EXIT_CODE% -:cleanup_temp_dist -if "%TEMP_DEV_PLACEHOLDER_CREATED%"=="1" ( - del /F /Q "frontend\dist\__wails_placeholder__.txt" >nul 2>&1 -) -if "%TEMP_DEV_DIST_CREATED%"=="1" ( - rmdir /S /Q "frontend\dist" >nul 2>&1 -) -exit /b 0 - :cleanup_dev_logs for %%f in ( "tmp-npm-dev.err.log" @@ -125,43 +139,3 @@ for %%f in ( if exist %%~f del /F /Q %%~f >nul 2>&1 ) exit /b 0 - -:cleanup_local_vite_port -set "CHECK_PORT=%~1" -set "CHECK_PID=" -set "CHECK_CMDLINE=" -for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$port=%CHECK_PORT%; $procId=(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess); if($procId){Write-Output $procId}"`) do ( - set "CHECK_PID=%%a" -) -if not defined CHECK_PID exit /b 0 - -for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$line=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq %CHECK_PID% } | Select-Object -First 1 -ExpandProperty CommandLine; if($line){Write-Output $line}"`) do ( - set "CHECK_CMDLINE=%%a" -) - -echo !CHECK_CMDLINE! | findstr /I /C:"%CD%\frontend" >nul -set "MATCH_PROJECT=!errorlevel!" -echo !CHECK_CMDLINE! | findstr /I /C:"vite" >nul -set "MATCH_VITE=!errorlevel!" - -if "!MATCH_PROJECT!"=="0" if "!MATCH_VITE!"=="0" ( - echo Cleaning stale local Vite process on port %CHECK_PORT% ^(PID !CHECK_PID!^)... - taskkill /F /PID !CHECK_PID! /T >nul 2>&1 - timeout /t 1 /nobreak >nul -) -exit /b 0 - -:check_port -set "CHECK_PORT=%~1" -set "CHECK_PID=" -for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$port=%CHECK_PORT%; $procId=(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess); if($procId){Write-Output $procId}"`) do ( - set "CHECK_PID=%%a" -) - -if defined CHECK_PID ( - set PORT_ERROR=1 - echo [ERROR] Port %CHECK_PORT% is occupied. PID: !CHECK_PID! -) else ( - echo [OK] Port %CHECK_PORT% is available. -) -exit /b 0 diff --git a/bat/generate-bindings.bat b/bat/generate-bindings.bat index 836621e6..6274fd7c 100644 --- a/bat/generate-bindings.bat +++ b/bat/generate-bindings.bat @@ -18,6 +18,13 @@ echo. echo Working directory: %CD% echo. +if not exist "wails.json" ( + echo [ERROR] wails.json not found in repository root. + echo This development branch must keep a complete Wails source tree. + if not "%NO_PAUSE%"=="1" pause + exit /b 1 +) + echo [1/3] Ensure frontend\dist exists... if not exist "frontend\dist" ( mkdir "frontend\dist" diff --git a/bat/publish.bat b/bat/publish.bat index 5b7b954d..412a455e 100644 --- a/bat/publish.bat +++ b/bat/publish.bat @@ -1,248 +1,22 @@ @echo off -chcp 65001 >nul -setlocal enabledelayedexpansion +setlocal EnableExtensions -REM 切换到项目根目录(脚本所在目录的上一级) -cd /d "%~dp0.." - -echo ======================================== -echo Ant Browser - 发布打包脚本 -echo ======================================== -echo. -echo 当前工作目录: %CD% -echo. - -REM ======== [1/6] 检测 NSIS ======== -echo [1/6] 检测 NSIS 安装... -echo 支持环境变量:MAKENSIS_PATH / NSIS_PATH / NSIS_HOME -echo. - -set "MAKENSIS=" - -REM 优先级 1:MAKENSIS_PATH 直接指向 makensis.exe -if defined MAKENSIS_PATH ( - if exist "!MAKENSIS_PATH!" ( - set "MAKENSIS=!MAKENSIS_PATH!" - goto :nsis_found - ) +set "SCRIPT_DIR=%~dp0" +if not exist "%SCRIPT_DIR%publish.ps1" ( + echo [ERROR] Missing bat\publish.ps1 + if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause + endlocal & exit /b 1 ) - -REM 优先级 2:NSIS_PATH 可以是 makensis.exe 或 NSIS 目录 -if defined NSIS_PATH ( - if exist "!NSIS_PATH!\makensis.exe" ( - set "MAKENSIS=!NSIS_PATH!\makensis.exe" - goto :nsis_found - ) - if exist "!NSIS_PATH!" ( - set "MAKENSIS=!NSIS_PATH!" - goto :nsis_found - ) -) - -REM 优先级 3:NSIS_HOME 为 NSIS 安装根目录 -if defined NSIS_HOME ( - if exist "!NSIS_HOME!\makensis.exe" ( - set "MAKENSIS=!NSIS_HOME!\makensis.exe" - goto :nsis_found - ) -) - -REM 优先级 4:系统 PATH -for /f "delims=" %%i in ('where makensis.exe 2^>nul') do ( - set "MAKENSIS=%%i" - goto :nsis_found -) - -REM 优先级 5:常见安装目录 -if exist "C:\Program Files (x86)\NSIS\makensis.exe" ( - set "MAKENSIS=C:\Program Files (x86)\NSIS\makensis.exe" - goto :nsis_found -) -if exist "C:\Program Files\NSIS\makensis.exe" ( - set "MAKENSIS=C:\Program Files\NSIS\makensis.exe" - goto :nsis_found -) - -echo ✗ 未找到 NSIS(makensis.exe) -echo. -echo 请安装 NSIS 后,通过以下任一方式配置(PowerShell): -echo setx MAKENSIS_PATH "D:\tools\NSIS\makensis.exe" -echo setx NSIS_PATH "D:\tools\NSIS" -echo setx NSIS_HOME "D:\tools\NSIS" -echo. -echo 或下载安装:https://nsis.sourceforge.io/Download -echo. -pause -exit /b 1 - -:nsis_found -echo ✓ NSIS 已就绪: !MAKENSIS! -echo. - -REM ======== [2/6] 读取版本号 ======== -echo [2/6] 读取版本号... - -set "VERSION=" -for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content wails.json | ConvertFrom-Json).info.productVersion"`) do ( - set "VERSION=%%v" -) - -if "!VERSION!"=="" ( - echo ✗ 无法从 wails.json 读取版本号 - pause - exit /b 1 -) -echo ✓ 版本号: !VERSION! -echo. - -REM ======== [3/6] Wails 构建 ======== -echo [3/6] 执行 Wails 构建... - -set GOPROXY=https://goproxy.cn,direct -wails build -if %errorlevel% neq 0 ( - echo ✗ Wails 构建失败 - pause - exit /b 1 -) - -if not exist "build\bin\ant-chrome.exe" ( - echo ✗ 构建产物不存在: build\bin\ant-chrome.exe - pause - exit /b 1 -) -echo ✓ 构建成功: build\bin\ant-chrome.exe -echo. - -REM ======== [4/6] 组装 staging 目录 ======== -echo [4/6] 组装 staging 目录... - -set "STAGING=publish\staging" -set "RELEASE_CONFIG=publish\config.init.yaml" - -if exist "!STAGING!" rmdir /S /Q "!STAGING!" -mkdir "!STAGING!" - -copy /Y "build\bin\ant-chrome.exe" "!STAGING!\ant-chrome.exe" >nul -if errorlevel 1 ( - echo ✗ 复制 ant-chrome.exe 失败 - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -if not exist "!STAGING!\ant-chrome.exe" ( - echo ✗ staging 中缺少 ant-chrome.exe - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -echo ✓ 复制 ant-chrome.exe - -if not exist "!RELEASE_CONFIG!" ( - echo ✗ 未找到发布配置模板: !RELEASE_CONFIG! - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -copy /Y "!RELEASE_CONFIG!" "!STAGING!\config.yaml" >nul -if errorlevel 1 ( - echo ✗ 复制发布配置模板失败: !RELEASE_CONFIG! - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -if not exist "!STAGING!\config.yaml" ( - echo ✗ staging 中缺少 config.yaml(来源: !RELEASE_CONFIG!) - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -echo ✓ 复制发布配置模板 !RELEASE_CONFIG! -> config.yaml - -if not exist "bin" ( - echo ✗ bin\ 目录不存在,缺少代理运行时文件 - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -if not exist "bin\xray.exe" ( - echo ✗ 缺少运行时文件: bin\xray.exe - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -if not exist "bin\sing-box.exe" ( - echo ✗ 缺少运行时文件: bin\sing-box.exe - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) - -xcopy /E /I /Y bin "!STAGING!\bin" >nul -if not exist "!STAGING!\bin\xray.exe" ( - echo ✗ 复制后仍缺少 !STAGING!\bin\xray.exe - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -if not exist "!STAGING!\bin\sing-box.exe" ( - echo ✗ 复制后仍缺少 !STAGING!\bin\sing-box.exe - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -echo ✓ 复制 bin\(xray.exe, sing-box.exe) - -if not exist "chrome" ( - echo ✗ chrome\ 目录不存在,Chrome 内核为必需文件 - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -xcopy /E /I /Y chrome "!STAGING!\chrome" >nul -echo ✓ 复制 chrome\ - -mkdir "!STAGING!\data" -echo ✓ 创建空 data 目录(不打包 app.db,首次启动自动初始化) +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish.ps1" %* +set "EXIT_CODE=%ERRORLEVEL%" echo. -echo ✓ staging 目录组装完成 -echo. - -REM ======== [5/6] NSIS 打包 ======== -echo [5/6] 调用 NSIS 打包... - -if not exist "publish\output" mkdir "publish\output" - -REM 确保 installer.nsi 是 UTF-8 with BOM(NSIS Unicode True 要求) -powershell -NoProfile -Command "$f=(Resolve-Path 'publish\installer.nsi').Path; $c=[System.IO.File]::ReadAllText($f,[System.Text.Encoding]::UTF8); [System.IO.File]::WriteAllText($f,$c,[System.Text.UTF8Encoding]::new($true))" >nul - -for /f "usebackq delims=" %%p in (`powershell -NoProfile -Command "(Resolve-Path '!STAGING!').Path"`) do ( - set "STAGING_ABS=%%p" +if "%EXIT_CODE%"=="0" ( + echo Publish finished successfully. +) else ( + echo Publish failed with exit code %EXIT_CODE%. ) -"!MAKENSIS!" /DVERSION=!VERSION! "/DSTAGINGDIR=!STAGING_ABS!" publish\installer.nsi -if %errorlevel% neq 0 ( - echo ✗ NSIS 打包失败 - rmdir /S /Q "!STAGING!" - pause - exit /b 1 -) -echo ✓ 安装包生成成功 -echo. +if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause -REM ======== [6/6] 清理 staging ======== -echo [6/6] 清理临时文件... -rmdir /S /Q "!STAGING!" -echo ✓ staging 目录已清理 -echo. - -echo ======================================== -echo ✓ 发布完成! -echo ======================================== -echo. -echo 安装包位置: publish\output\AntBrowser-Setup-!VERSION!.exe -echo. -echo 提示:用户安装后可将旧的 data\ 目录粘贴到安装目录覆盖初始数据 -echo. -pause +endlocal & exit /b %EXIT_CODE% diff --git a/bat/publish.ps1 b/bat/publish.ps1 new file mode 100644 index 00000000..a3a50011 --- /dev/null +++ b/bat/publish.ps1 @@ -0,0 +1,635 @@ +param( + [string]$Target, + [string]$Version +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location $repoRoot + +$script:Version = "" +$script:LinuxArch = "" +$script:WindowsDone = $false +$script:LinuxDone = $false + +function Write-Section { + param([string]$Text) + + Write-Host "========================================" + Write-Host " $Text" + Write-Host "========================================" +} + +function Get-TrimmedText { + param([AllowNull()][string]$Value) + + if ($null -eq $Value) { + return "" + } + return $Value.Trim() +} + +function Assert-VersionValue { + param( + [Parameter(Mandatory = $true)] + [string]$Value, + [string]$Source = "版本号" + ) + + $trimmed = Get-TrimmedText $Value + if ($trimmed -eq "") { + throw "$Source 不能为空" + } + if ($trimmed -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?(?:\+[0-9A-Za-z\.-]+)?$') { + throw "$Source 格式无效: $trimmed`n 期望示例: 1.1.0 或 1.1.0-beta.1" + } + return $trimmed +} + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$Arguments = @() + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + $argText = if ($Arguments.Count -gt 0) { " $($Arguments -join ' ')" } else { "" } + throw "$FilePath$argText failed with exit code $LASTEXITCODE" + } +} + +function Assert-RequiredSourceFiles { + param( + [Parameter(Mandatory = $true)] + [string]$Action, + [Parameter(Mandatory = $true)] + [string[]]$Paths + ) + + $missing = @() + foreach ($relativePath in $Paths) { + $fullPath = Join-Path $repoRoot $relativePath + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + $missing += $relativePath + } + } + + if ($missing.Count -gt 0) { + throw "$Action requires a complete source tree. Missing files: $($missing -join ', ')" + } +} + +function Resolve-Version { + param([string]$ExplicitVersion) + + $explicit = Get-TrimmedText $ExplicitVersion + if ($explicit -ne "") { + $script:Version = Assert-VersionValue -Value $explicit -Source "传入版本号" + Write-Host "[1/3] 使用传入版本号..." + Write-Host "✓ 版本号: $script:Version" + Write-Host "" + return + } + + Write-Host "[1/3] 读取版本号..." + $wailsConfigPath = Join-Path $repoRoot "wails.json" + if (-not (Test-Path -LiteralPath $wailsConfigPath -PathType Leaf)) { + throw "无法读取版本号:缺少 wails.json" + } + + $wailsConfig = Get-Content -LiteralPath $wailsConfigPath -Raw | ConvertFrom-Json + $resolvedVersion = Get-TrimmedText ([string]$wailsConfig.info.productVersion) + if ($resolvedVersion -eq "") { + throw "无法从 wails.json 读取版本号" + } + + $script:Version = Assert-VersionValue -Value $resolvedVersion -Source "wails.json productVersion" + Write-Host "✓ 版本号: $script:Version" + Write-Host "" +} + +function Invoke-WithTemporaryWailsVersion { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$ScriptBlock + ) + + $wailsConfigPath = Join-Path $repoRoot "wails.json" + if (-not (Test-Path -LiteralPath $wailsConfigPath -PathType Leaf)) { + & $ScriptBlock + return + } + + $currentConfig = Get-Content -LiteralPath $wailsConfigPath -Raw | ConvertFrom-Json + $currentVersion = Get-TrimmedText ([string]$currentConfig.info.productVersion) + if ($currentVersion -eq $script:Version) { + & $ScriptBlock + return + } + + Write-Host " 临时覆盖 wails.json productVersion: $currentVersion -> $script:Version" + $originalBytes = [System.IO.File]::ReadAllBytes($wailsConfigPath) + try { + $currentConfig.info.productVersion = $script:Version + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + $jsonText = ($currentConfig | ConvertTo-Json -Depth 100) + [System.IO.File]::WriteAllText($wailsConfigPath, $jsonText + "`n", $utf8NoBom) + & $ScriptBlock + } + finally { + [System.IO.File]::WriteAllBytes($wailsConfigPath, $originalBytes) + } +} + +function Resolve-PublishTarget { + param([string]$InputTarget) + + $normalized = (Get-TrimmedText $InputTarget).ToUpperInvariant() + $mapping = @{ + "W" = "WINDOWS" + "WINDOWS" = "WINDOWS" + "L" = "LINUX" + "LINUX" = "LINUX" + "B" = "BOTH" + "BOTH" = "BOTH" + } + + if ($normalized -ne "") { + if (-not $mapping.ContainsKey($normalized)) { + throw "无效的打包目标: $InputTarget`n 支持参数: W/L/B 或 WINDOWS/LINUX/BOTH" + } + $resolvedTarget = $mapping[$normalized] + Write-Host "[2/3] 使用预设打包目标: $resolvedTarget" + Write-Host "" + return $resolvedTarget + } + + Write-Host "[2/3] 选择打包平台..." + Write-Host "" + Write-Host " [W] Windows" + Write-Host " [L] Linux(通过 Docker Desktop 执行)" + Write-Host " [B] Windows + Linux" + Write-Host "" + + while ($true) { + $choice = (Read-Host "请选择打包目标 [W/L/B]").Trim().ToUpperInvariant() + if ($choice -eq "") { + continue + } + if ($mapping.ContainsKey($choice)) { + $resolvedTarget = $mapping[$choice] + Write-Host "✓ 已选择: $resolvedTarget" + Write-Host "" + return $resolvedTarget + } + Write-Host "✗ 未选择有效目标" -ForegroundColor Yellow + } +} + +function Resolve-NsisPath { + Write-Host "[Windows] 检测 NSIS 安装..." + Write-Host " 支持环境变量:MAKENSIS_PATH / NSIS_PATH / NSIS_HOME" + Write-Host "" + + $candidates = @() + if ($env:MAKENSIS_PATH) { + $candidates += $env:MAKENSIS_PATH + } + if ($env:NSIS_PATH) { + $candidates += (Join-Path $env:NSIS_PATH "makensis.exe") + $candidates += $env:NSIS_PATH + } + if ($env:NSIS_HOME) { + $candidates += (Join-Path $env:NSIS_HOME "makensis.exe") + } + + $whereMakensis = Get-Command makensis.exe -ErrorAction SilentlyContinue + if ($whereMakensis) { + $candidates += $whereMakensis.Source + } + + $candidates += @( + "C:\Program Files (x86)\NSIS\makensis.exe", + "C:\Program Files\NSIS\makensis.exe" + ) + + foreach ($candidate in $candidates) { + $value = Get-TrimmedText $candidate + if ($value -eq "") { + continue + } + if (Test-Path -LiteralPath $value -PathType Leaf) { + Write-Host "✓ NSIS 已就绪: $value" + Write-Host "" + return $value + } + } + + throw "未找到 NSIS(makensis.exe)`n`n 请安装 NSIS 后,通过以下任一方式配置(PowerShell):`n setx MAKENSIS_PATH ""D:\tools\NSIS\makensis.exe""`n setx NSIS_PATH ""D:\tools\NSIS""`n setx NSIS_HOME ""D:\tools\NSIS""`n`n 或下载安装:https://nsis.sourceforge.io/Download" +} + +function Build-WindowsBinary { + Write-Host "[Windows] 执行 Wails 构建..." + $binaryPath = Join-Path $repoRoot "build/bin/ant-chrome.exe" + Assert-RequiredSourceFiles -Action "Windows packaging" -Paths @( + "go.mod", + "go.sum", + "main.go", + "wails.json" + ) + + $previousGoProxy = $env:GOPROXY + try { + $env:GOPROXY = "https://goproxy.cn,direct" + Push-Location (Join-Path $repoRoot "frontend") + try { + Write-Host "[Windows] 预检前端依赖..." + Invoke-NativeCommand -FilePath "npm" -Arguments @("ci", "--prefer-offline", "--no-audit", "--no-fund") + Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "ensure:native") + Write-Host "✓ 前端依赖已就绪" + Write-Host "" + } + finally { + Pop-Location + } + Invoke-NativeCommand -FilePath "wails" -Arguments @("build") + } + finally { + $env:GOPROXY = $previousGoProxy + } + + if (-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)) { + throw "构建产物不存在: build\bin\ant-chrome.exe" + } + Write-Host "✓ 构建成功: build\bin\ant-chrome.exe" + Write-Host "" +} + +function Assert-RuntimeHashes { + param( + [Parameter(Mandatory = $true)] + [string]$Target + ) + + $manifestPath = Join-Path $repoRoot "publish/runtime-manifest.json" + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "缺少运行时清单: publish\runtime-manifest.json" + } + + Write-Host "[Windows] 校验运行时哈希..." + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $entries = @($manifest.files | Where-Object { $_.targets -contains $Target }) + if ($entries.Count -eq 0) { + throw "运行时清单中不存在目标平台: $Target" + } + + $errors = New-Object System.Collections.Generic.List[string] + foreach ($entry in $entries) { + $relativePath = Get-TrimmedText ([string]$entry.path) + $expectedHash = (Get-TrimmedText ([string]$entry.sha256)).ToLowerInvariant() + + if ($relativePath -eq "") { + $errors.Add("manifest entry has empty path") + continue + } + if ($expectedHash -eq "" -or $expectedHash.Contains("todo_replace_with_sha256")) { + $errors.Add("$relativePath: sha256 is not initialized") + continue + } + + $fullPath = Join-Path $repoRoot ($relativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + $errors.Add("$relativePath: file not found") + continue + } + + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + $errors.Add("$relativePath: sha256 mismatch (expected $expectedHash, got $actualHash)") + } + } + + if ($errors.Count -gt 0) { + throw "运行时哈希校验失败:`n - $($errors -join "`n - ")" + } + + Write-Host "✓ 运行时哈希校验通过: $Target" + Write-Host "" +} + +function Test-PeExecutable { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath + ) + + if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { + return $false + } + + try { + $stream = [System.IO.File]::OpenRead($FilePath) + try { + if ($stream.Length -lt 2) { + return $false + } + + $header = New-Object byte[] 2 + $read = $stream.Read($header, 0, $header.Length) + return ($read -eq 2 -and $header[0] -eq 0x4D -and $header[1] -eq 0x5A) + } + finally { + $stream.Dispose() + } + } + catch { + return $false + } +} + +function Copy-DirectoryContents { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDir, + [Parameter(Mandatory = $true)] + [string]$DestinationDir + ) + + if (-not (Test-Path -LiteralPath $SourceDir -PathType Container)) { + return + } + + New-Item -ItemType Directory -Path $DestinationDir -Force | Out-Null + + foreach ($entry in (Get-ChildItem -LiteralPath $SourceDir -Force)) { + Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationDir $entry.Name) -Recurse -Force + } +} + +function Copy-WindowsChromePayload { + param( + [Parameter(Mandatory = $true)] + [string]$ChromeRoot, + [Parameter(Mandatory = $true)] + [string]$StagingDir + ) + + if (-not (Test-Path -LiteralPath $ChromeRoot -PathType Container)) { + Write-Host "[WARN] 缺少 chrome\ 目录,Windows 安装包将不包含浏览器内核" + return + } + + $stagingChromeDir = Join-Path $StagingDir "chrome" + $chromeReadme = Join-Path $ChromeRoot "README.md" + $copiedCores = @() + $rootExecutable = Join-Path $ChromeRoot "chrome.exe" + + if (Test-PeExecutable -FilePath $rootExecutable) { + Copy-DirectoryContents -SourceDir $ChromeRoot -DestinationDir $stagingChromeDir + $copiedCores += "chrome\" + } + else { + if (Test-Path -LiteralPath $chromeReadme -PathType Leaf) { + New-Item -ItemType Directory -Path $stagingChromeDir -Force | Out-Null + Copy-Item -LiteralPath $chromeReadme -Destination (Join-Path $stagingChromeDir "README.md") -Force + } + + foreach ($entry in (Get-ChildItem -LiteralPath $ChromeRoot -Force)) { + if (-not $entry.PSIsContainer) { + continue + } + + $candidateExe = Join-Path $entry.FullName "chrome.exe" + if (-not (Test-PeExecutable -FilePath $candidateExe)) { + continue + } + + New-Item -ItemType Directory -Path $stagingChromeDir -Force | Out-Null + Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $stagingChromeDir $entry.Name) -Recurse -Force + $copiedCores += $entry.Name + } + } + + if ($copiedCores.Count -gt 0) { + Write-Host ("✓ 自动打包 Windows 内核: {0}" -f ($copiedCores -join ", ")) + return + } + + if (Test-Path -LiteralPath $chromeReadme -PathType Leaf) { + Write-Host "✓ 保留 chrome\README.md(未发现可打包的 Windows 内核)" + } + else { + Write-Host "[WARN] 未发现可打包的 Windows 内核,且缺少 chrome\README.md" + } +} + +function New-WindowsStaging { + Write-Host "[Windows] 组装 staging 目录..." + + $stagingDir = Join-Path $repoRoot "publish/staging" + $releaseConfig = Join-Path $repoRoot "publish/config.init.yaml" + $binaryPath = Join-Path $repoRoot "build/bin/ant-chrome.exe" + $binDir = Join-Path $repoRoot "bin" + $chromeRoot = Join-Path $repoRoot "chrome" + + if (Test-Path -LiteralPath $stagingDir) { + Remove-Item -LiteralPath $stagingDir -Recurse -Force + } + New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null + + Copy-Item -LiteralPath $binaryPath -Destination (Join-Path $stagingDir "ant-chrome.exe") -Force + if (-not (Test-Path -LiteralPath (Join-Path $stagingDir "ant-chrome.exe") -PathType Leaf)) { + throw "staging 中缺少 ant-chrome.exe" + } + Write-Host "✓ 复制 ant-chrome.exe" + + if (-not (Test-Path -LiteralPath $releaseConfig -PathType Leaf)) { + throw "未找到发布配置模板: publish\config.init.yaml" + } + Copy-Item -LiteralPath $releaseConfig -Destination (Join-Path $stagingDir "config.yaml") -Force + Write-Host "✓ 复制发布配置模板 publish\config.init.yaml -> config.yaml" + + $stagingBinDir = Join-Path $stagingDir "bin" + New-Item -ItemType Directory -Path $stagingBinDir -Force | Out-Null + + foreach ($required in @("xray.exe", "sing-box.exe")) { + $source = Join-Path $binDir $required + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "缺少运行时文件: bin\$required" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $stagingBinDir $required) -Force + } + Write-Host "✓ 复制 bin\(xray.exe, sing-box.exe)" + + Copy-WindowsChromePayload -ChromeRoot $chromeRoot -StagingDir $stagingDir + + New-Item -ItemType Directory -Path (Join-Path $stagingDir "data") -Force | Out-Null + Write-Host "✓ 创建空 data 目录(不打包 app.db,首次启动自动初始化)" + Write-Host "" + Write-Host "✓ staging 目录组装完成" + Write-Host "" + + return $stagingDir +} + +function Invoke-WindowsPackaging { + param( + [Parameter(Mandatory = $true)] + [string]$MakensisPath, + [Parameter(Mandatory = $true)] + [string]$StagingDir + ) + + Write-Host "[Windows] 调用 NSIS 打包..." + $outputDir = Join-Path $repoRoot "publish/output" + if (-not (Test-Path -LiteralPath $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + } + + $installerPath = Join-Path $repoRoot "publish/installer.nsi" + $installerContent = [System.IO.File]::ReadAllText($installerPath, [System.Text.Encoding]::UTF8) + [System.IO.File]::WriteAllText($installerPath, $installerContent, [System.Text.UTF8Encoding]::new($true)) + + $stagingAbs = (Resolve-Path $StagingDir).Path + $compressionMode = if ($env:ANT_BROWSER_PUBLISH_BEST_COMPRESSION -eq "1") { "best" } else { "fast" } + $useNsisConfig = ($env:ANT_BROWSER_NSIS_USE_CONFIG -eq "1") + Write-Host " 压缩模式: $compressionMode" + if ($useNsisConfig) { + Write-Host " NSIS 全局配置: enabled" + } + else { + Write-Host " NSIS 全局配置: disabled (/NOCONFIG)" + } + $nsisArguments = @( + "/DVERSION=$script:Version", + "/DSTAGINGDIR=$stagingAbs", + "publish\installer.nsi" + ) + if (-not $useNsisConfig) { + $nsisArguments = @("/NOCONFIG") + $nsisArguments + } + if ($compressionMode -eq "best") { + $nsisArguments = @("/DBESTCOMPRESSION") + $nsisArguments + } + Invoke-NativeCommand -FilePath $MakensisPath -Arguments $nsisArguments + + Write-Host "✓ Windows 安装包生成成功" + Write-Host "" +} + +function Remove-WindowsStaging { + param([string]$StagingDir) + + if ($StagingDir -and (Test-Path -LiteralPath $StagingDir)) { + Write-Host "[Windows] 清理临时文件..." + Remove-Item -LiteralPath $StagingDir -Recurse -Force + Write-Host "✓ staging 目录已清理" + Write-Host "" + } +} + +function Publish-Windows { + Write-Host "[3/3] 开始 Windows 打包..." + Write-Host "" + + $makensisPath = Resolve-NsisPath + Assert-RuntimeHashes -Target "windows-amd64" + Build-WindowsBinary + + $stagingDir = $null + try { + $stagingDir = New-WindowsStaging + Invoke-WindowsPackaging -MakensisPath $makensisPath -StagingDir $stagingDir + } + finally { + Remove-WindowsStaging -StagingDir $stagingDir + } + + $script:WindowsDone = $true + Write-Host "✓ Windows 打包完成" + Write-Host "" +} + +function Publish-Linux { + Write-Host "[3/3] 开始 Linux 打包..." + Write-Host "" + + $linuxScript = Join-Path $repoRoot "publish/linux/publish-linux-docker.ps1" + $archOutFile = Join-Path $env:TEMP ("ant-browser-linux-arch-" + [guid]::NewGuid().ToString("N") + ".txt") + if (Test-Path -LiteralPath $archOutFile) { + Remove-Item -LiteralPath $archOutFile -Force + } + + try { + & powershell -NoProfile -ExecutionPolicy Bypass -File $linuxScript -RepoRoot $repoRoot -ArchOutFile $archOutFile -Version $script:Version + if ($LASTEXITCODE -ne 0) { + throw "Linux 打包失败" + } + + if (Test-Path -LiteralPath $archOutFile -PathType Leaf) { + $script:LinuxArch = (Get-Content -LiteralPath $archOutFile -Raw).Trim() + } + } + finally { + Remove-Item -LiteralPath $archOutFile -Force -ErrorAction SilentlyContinue + } + + $script:LinuxDone = $true + Write-Host "✓ Linux 打包完成" + Write-Host "" +} + +try { + Write-Section "Ant Browser - 发布打包脚本" + Write-Host "" + Write-Host "当前工作目录: $repoRoot" + Write-Host "" + + Resolve-Version -ExplicitVersion $Version + $publishTarget = Resolve-PublishTarget -InputTarget $Target + + Invoke-WithTemporaryWailsVersion { + switch ($publishTarget) { + "WINDOWS" { + Publish-Windows + } + "LINUX" { + Publish-Linux + } + "BOTH" { + Publish-Windows + Publish-Linux + } + default { + throw "不支持的打包目标: $publishTarget" + } + } + } + + Write-Host "" + Write-Section "✓ 发布完成!" + Write-Host "" + if ($script:WindowsDone) { + Write-Host "Windows 安装包: publish\output\AntBrowser-Setup-$script:Version.exe" + } + if ($script:LinuxDone) { + Write-Host "Linux 产物目录: publish\output\" + if ($script:LinuxArch -ne "") { + Write-Host "Linux 架构: $script:LinuxArch" + } + } + Write-Host "" + Write-Host "提示:用户安装后可将旧的 data\ 目录粘贴到安装目录覆盖初始数据" + exit 0 +} +catch { + Write-Host "" + Write-Section "✗ 发布失败" + Write-Host "" + Write-Host $_.Exception.Message + exit 1 +} diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 00000000..2b2aa9e4 --- /dev/null +++ b/bin/README.md @@ -0,0 +1,17 @@ +# Runtime binaries layout + +Windows (legacy): + +- `bin/xray.exe` +- `bin/sing-box.exe` + +Linux (new): + +- `bin/linux-amd64/xray` +- `bin/linux-amd64/sing-box` +- `bin/linux-arm64/xray` +- `bin/linux-arm64/sing-box` + +Runtime hashes are pinned in `publish/runtime-manifest.json`. +Pinned upstream archive sources are tracked in `publish/runtime-sources.json`. +Use `python3 tools/runtime/sync-runtime.py` to refresh Linux runtimes safely. diff --git a/bin/darwin-amd64/sing-box b/bin/darwin-amd64/sing-box new file mode 100644 index 00000000..df7cd2e6 Binary files /dev/null and b/bin/darwin-amd64/sing-box differ diff --git a/bin/darwin-amd64/xray b/bin/darwin-amd64/xray new file mode 100644 index 00000000..93821a40 Binary files /dev/null and b/bin/darwin-amd64/xray differ diff --git a/bin/darwin-arm64/sing-box b/bin/darwin-arm64/sing-box new file mode 100644 index 00000000..2b022e99 Binary files /dev/null and b/bin/darwin-arm64/sing-box differ diff --git a/bin/darwin-arm64/xray b/bin/darwin-arm64/xray new file mode 100644 index 00000000..fad039b3 Binary files /dev/null and b/bin/darwin-arm64/xray differ diff --git a/bin/linux-amd64/sing-box b/bin/linux-amd64/sing-box new file mode 100644 index 00000000..7bf90ad8 Binary files /dev/null and b/bin/linux-amd64/sing-box differ diff --git a/bin/linux-amd64/xray b/bin/linux-amd64/xray new file mode 100644 index 00000000..7c74767e Binary files /dev/null and b/bin/linux-amd64/xray differ diff --git a/bin/linux-arm64/sing-box b/bin/linux-arm64/sing-box new file mode 100644 index 00000000..f4e50b1d Binary files /dev/null and b/bin/linux-arm64/sing-box differ diff --git a/bin/linux-arm64/xray b/bin/linux-arm64/xray new file mode 100644 index 00000000..02c17773 Binary files /dev/null and b/bin/linux-arm64/xray differ diff --git a/config.yaml b/config.yaml index bf63a311..b9edb7bc 100644 --- a/config.yaml +++ b/config.yaml @@ -9,10 +9,10 @@ app: height: 1000 min_width: 1200 min_height: 700 - max_profile_limit: 3 + max_profile_limit: 20 used_cd_keys: [] runtime: - max_memory_mb: 1024 + max_memory_mb: 0 gc_percent: 100 logging: level: info @@ -50,4 +50,4 @@ browser: proxies: [] profiles: [] launch_server: - port: 0 + port: 19876 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b5347044..f0c9e2a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,13 @@ { "name": "ant-browser-frontend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ant-browser-frontend", - "version": "1.0.0", + "version": "1.1.0", + "hasInstallScript": true, "dependencies": { "@types/js-yaml": "^4.0.9", "clsx": "^2.0.0", @@ -25,7 +26,7 @@ "devDependencies": { "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", - "@vitejs/plugin-react": "^4.2.1", + "@vitejs/plugin-react-swc": "^4.3.0", "autoprefixer": "^10.4.16", "cross-env": "^10.1.0", "postcss": "^8.4.32", @@ -47,240 +48,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -290,54 +57,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -765,17 +484,6 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -862,13 +570,6 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", @@ -1177,49 +878,230 @@ "win32" ] }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@swc/core": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", + "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", "dev": true, - "license": "MIT", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.18", + "@swc/core-darwin-x64": "1.15.18", + "@swc/core-linux-arm-gnueabihf": "1.15.18", + "@swc/core-linux-arm64-gnu": "1.15.18", + "@swc/core-linux-arm64-musl": "1.15.18", + "@swc/core-linux-x64-gnu": "1.15.18", + "@swc/core-linux-x64-musl": "1.15.18", + "@swc/core-win32-arm64-msvc": "1.15.18", + "@swc/core-win32-ia32-msvc": "1.15.18", + "@swc/core-win32-x64-msvc": "1.15.18" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", + "integrity": "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.18.tgz", + "integrity": "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.18.tgz", + "integrity": "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.18.tgz", + "integrity": "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.18.tgz", + "integrity": "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.18.tgz", + "integrity": "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.18.tgz", + "integrity": "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.18.tgz", + "integrity": "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.18.tgz", + "integrity": "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.18.tgz", + "integrity": "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/types": "^7.28.2" + "@swc/counter": "^0.1.3" } }, "node_modules/@types/d3-array": { @@ -1369,27 +1251,30 @@ "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", + "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "1.0.0-rc.7", + "@swc/core": "^1.15.11" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/@vitejs/plugin-react-swc/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1745,13 +1630,6 @@ "node": ">= 6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -2233,16 +2111,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2645,32 +2513,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2718,16 +2560,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/lucide-react": { "version": "0.292.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", @@ -4058,16 +3890,6 @@ "react": ">=18" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-router": { "version": "6.30.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", @@ -4382,16 +4204,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5107,13 +4919,6 @@ "node": ">=8" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 90c5c188..5c1b9e54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,15 @@ { "name": "ant-browser-frontend", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { + "ensure:native": "node ./scripts/ensure-rollup-native.mjs", + "postinstall": "npm run ensure:native", "dev": "node ./scripts/dev-watcher.mjs", - "dev:raw": "cross-env NODE_OPTIONS=--max-old-space-size=500 vite", - "build": "cross-env NODE_OPTIONS=--max-old-space-size=1024 tsc && cross-env NODE_OPTIONS=--max-old-space-size=1024 vite build", - "preview": "cross-env NODE_OPTIONS=--max-old-space-size=500 vite preview" + "dev:raw": "npm run ensure:native && node --max-old-space-size=256 --max-semi-space-size=16 ./node_modules/vite/bin/vite.js", + "build": "npm run ensure:native && node --max-old-space-size=1024 ./node_modules/typescript/bin/tsc && node --max-old-space-size=1024 ./node_modules/vite/bin/vite.js build", + "preview": "npm run ensure:native && node --max-old-space-size=256 --max-semi-space-size=16 ./node_modules/vite/bin/vite.js preview" }, "dependencies": { "@types/js-yaml": "^4.0.9", @@ -27,7 +29,7 @@ "devDependencies": { "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", - "@vitejs/plugin-react": "^4.2.1", + "@vitejs/plugin-react-swc": "^4.3.0", "autoprefixer": "^10.4.16", "cross-env": "^10.1.0", "postcss": "^8.4.32", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 deleted file mode 100644 index 862e9531..00000000 --- a/frontend/package.json.md5 +++ /dev/null @@ -1 +0,0 @@ -0813f8076d07158b8ddcd1f7e61caed1 \ No newline at end of file diff --git a/frontend/scripts/dev-port-helper.mjs b/frontend/scripts/dev-port-helper.mjs new file mode 100644 index 00000000..62fe9fbc --- /dev/null +++ b/frontend/scripts/dev-port-helper.mjs @@ -0,0 +1,295 @@ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +export const frontendDir = resolve(scriptDir, '..') +export const repoRoot = resolve(frontendDir, '..') + +const frontendDirLower = frontendDir.toLowerCase() +const repoRootLower = repoRoot.toLowerCase() +const defaultPreferredPort = 5218 +const maxCandidateCount = 20 +const processInspectionFilter = "Name = 'node.exe' OR Name = 'cmd.exe' OR Name = 'npm.exe' OR Name = 'esbuild.exe' OR Name = 'wails.exe'" + +function runPowerShell(command, cwd = repoRoot) { + return spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { + cwd, + encoding: 'utf8', + }) +} + +function normalizeProcessChain(proc) { + const chain = Array.isArray(proc?.chain) ? proc.chain : [] + return [ + String(proc?.commandLine || ''), + ...chain.map((item) => String(item?.commandLine || '')), + ] + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) +} + +function collectProcessesByPowerShell(filterCommand) { + const result = runPowerShell(filterCommand) + + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || 'failed to inspect processes') + } + + const text = result.stdout.trim() + if (!text) { + return [] + } + + const parsed = JSON.parse(text) + return Array.isArray(parsed) ? parsed : [parsed] +} + +export function resolveRequestedPort(rawPort, fallbackPort = defaultPreferredPort) { + const parsed = Number.parseInt(String(rawPort || '').trim(), 10) + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + return parsed + } + return fallbackPort +} + +export function listListeners(port) { + const items = collectProcessesByPowerShell(` +$port = ${port} +$items = @() +$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 + if (-not $proc) { continue } + + $chain = @() + $current = $proc + $visited = @{} + $depth = 0 + while ($current -and $depth -lt 8 -and -not $visited.ContainsKey([string]$current.ProcessId)) { + $visited[[string]$current.ProcessId] = $true + $chain += [PSCustomObject]@{ + pid = [int]$current.ProcessId + parentProcessId = [int]$current.ParentProcessId + name = [string]$current.Name + commandLine = [string]$current.CommandLine + } + if ($current.ParentProcessId -le 0) { break } + $current = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" | Select-Object -First 1 + $depth++ + } + + $items += [PSCustomObject]@{ + pid = [int]$proc.ProcessId + parentProcessId = [int]$proc.ParentProcessId + name = [string]$proc.Name + commandLine = [string]$proc.CommandLine + chain = $chain + } +} +$items | ConvertTo-Json -Compress -Depth 6 +`) + return items +} + +export function summarizeProcess(proc) { + const name = proc?.name || 'unknown' + const pid = proc?.pid || 0 + const commandLine = String(proc?.commandLine || '').trim() + if (!commandLine) { + return `${name} (PID ${pid})` + } + return `${name} (PID ${pid}) ${commandLine}` +} + +export function isProjectDevProcess(proc) { + const lines = normalizeProcessChain(proc) + const hasProjectPath = lines.some((line) => line.includes(frontendDirLower) || line.includes(repoRootLower)) + const hasDevWatcher = lines.some((line) => line.includes('scripts/dev-watcher.mjs')) + const hasRawDev = lines.some((line) => line.includes('npm run dev:raw')) + const hasVite = lines.some((line) => line.includes('vite/bin/vite.js') || line.includes('\\vite\\bin\\vite.js')) + const hasNpmDev = lines.some((line) => line.includes('npm run dev')) + const hasEnsureNative = lines.some((line) => line.includes('ensure-rollup-native.mjs')) + const hasWailsDev = lines.some((line) => line.includes('wails dev')) + const hasEsbuild = lines.some((line) => line.includes('esbuild.exe') || line.endsWith('\\esbuild')) + const hasDevMarker = hasDevWatcher || hasRawDev || hasVite || hasNpmDev || hasEnsureNative || hasWailsDev || hasEsbuild + + return hasProjectPath && hasDevMarker +} + +export function killProcessTree(pid) { + if (!pid || pid <= 0) { + return true + } + + const killed = spawnSync('taskkill.exe', ['/F', '/T', '/PID', String(pid)], { + cwd: repoRoot, + stdio: 'ignore', + }) + return killed.status === 0 +} + +export function listProjectDevProcesses() { + return collectProcessesByPowerShell(` +$items = @() +$procs = Get-CimInstance Win32_Process -Filter "${processInspectionFilter}" +foreach ($proc in $procs) { + $chain = @() + $current = $proc + $visited = @{} + $depth = 0 + while ($current -and $depth -lt 8 -and -not $visited.ContainsKey([string]$current.ProcessId)) { + $visited[[string]$current.ProcessId] = $true + $chain += [PSCustomObject]@{ + pid = [int]$current.ProcessId + parentProcessId = [int]$current.ParentProcessId + name = [string]$current.Name + commandLine = [string]$current.CommandLine + } + if ($current.ParentProcessId -le 0) { break } + $current = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" | Select-Object -First 1 + $depth++ + } + + $items += [PSCustomObject]@{ + pid = [int]$proc.ProcessId + parentProcessId = [int]$proc.ParentProcessId + name = [string]$proc.Name + commandLine = [string]$proc.CommandLine + chain = $chain + } +} +$items | ConvertTo-Json -Compress -Depth 6 +`) +} + +function selectRootProcesses(processes) { + const pidSet = new Set(processes.map((proc) => proc.pid)) + return processes.filter((proc) => !pidSet.has(proc.parentProcessId)) +} + +function waitForPortToClear(port, timeoutMs = 2500) { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (listListeners(port).length === 0) { + return true + } + runPowerShell('Start-Sleep -Milliseconds 150') + } + return listListeners(port).length === 0 +} + +export function cleanupProjectListeners(port, logger = console.error) { + const listeners = listListeners(port) + let cleaned = false + + for (const proc of listeners) { + if (!isProjectDevProcess(proc)) { + continue + } + + cleaned = true + logger(`[dev] cleaning stale project dev process on ${port}: ${summarizeProcess(proc)}`) + if (!killProcessTree(proc.pid)) { + throw new Error(`failed to kill stale dev process ${proc.pid} on port ${port}`) + } + } + + if (cleaned && !waitForPortToClear(port)) { + throw new Error(`port ${port} is still occupied after cleaning stale project dev processes`) + } + + return cleaned +} + +export function cleanupProjectDevProcesses(logger = console.error) { + const processes = listProjectDevProcesses().filter(isProjectDevProcess) + const roots = selectRootProcesses(processes) + + for (const proc of roots) { + logger(`[dev] cleaning stale project dev process: ${summarizeProcess(proc)}`) + if (!killProcessTree(proc.pid)) { + throw new Error(`failed to kill stale project dev process ${proc.pid}`) + } + } + + return roots.length > 0 +} + +export function resolveFrontendDevPort(preferredPort = defaultPreferredPort, logger = console.error, allowFallback = true) { + const requestedPort = resolveRequestedPort(preferredPort) + + cleanupProjectListeners(requestedPort, logger) + let listeners = listListeners(requestedPort) + if (listeners.length === 0) { + return { + port: requestedPort, + preferredPort: requestedPort, + reusedPreferredPort: true, + reason: 'preferred-port-available', + } + } + + logger(`[dev] preferred frontend port ${requestedPort} is occupied by: ${listeners.map(summarizeProcess).join('; ')}`) + if (!allowFallback) { + throw new Error(`preferred frontend port ${requestedPort} is occupied`) + } + + for (let offset = 1; offset <= maxCandidateCount; offset++) { + const candidatePort = requestedPort + offset + cleanupProjectListeners(candidatePort, logger) + listeners = listListeners(candidatePort) + if (listeners.length === 0) { + logger(`[dev] switching frontend dev server from ${requestedPort} to ${candidatePort}`) + return { + port: candidatePort, + preferredPort: requestedPort, + reusedPreferredPort: false, + reason: 'fallback-port-selected', + } + } + } + + throw new Error(`failed to find a free frontend dev port in range ${requestedPort}-${requestedPort + maxCandidateCount}`) +} + +function parseCliArgs(argv) { + const args = argv.slice(2) + const command = args[0] || 'resolve' + let preferredPort = defaultPreferredPort + + for (let index = 1; index < args.length; index++) { + const item = args[index] + if (item === '--preferred' && index + 1 < args.length) { + preferredPort = resolveRequestedPort(args[index + 1], defaultPreferredPort) + index++ + } + } + + return { command, preferredPort } +} + +function runCli() { + const { command, preferredPort } = parseCliArgs(process.argv) + if (command === 'cleanup') { + cleanupProjectDevProcesses() + return + } + if (command !== 'resolve') { + throw new Error(`unsupported command: ${command}`) + } + + const result = resolveFrontendDevPort(preferredPort) + process.stdout.write(`${result.port}\n`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + runCli() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exit(1) + } +} diff --git a/frontend/scripts/dev-watcher.mjs b/frontend/scripts/dev-watcher.mjs index 7a0473ff..32034f59 100644 --- a/frontend/scripts/dev-watcher.mjs +++ b/frontend/scripts/dev-watcher.mjs @@ -1,97 +1,227 @@ import { spawn, spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' -const frontendDir = process.cwd() -const vitePort = 5218 -const frontendDirLower = frontendDir.toLowerCase() +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const frontendDir = resolve(scriptDir, '..') +const defaultVitePort = 5218 +const defaultMaxOldSpaceSizeMb = 256 +const defaultMaxSemiSpaceSizeMb = 16 +const defaultRssWarnMb = 256 +const defaultRssHardLimitMb = 360 +const defaultMemoryPollMs = 3000 +const nodeExecutable = process.execPath +const ensureNativeScript = resolve(frontendDir, 'scripts', 'ensure-rollup-native.mjs') +const viteEntry = resolve(frontendDir, 'node_modules', 'vite', 'bin', 'vite.js') -function runPowerShell(command) { - return spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { +function ensureNativeRuntime(env) { + const result = spawnSync(nodeExecutable, [ensureNativeScript], { + cwd: frontendDir, + stdio: 'inherit', + env, + }) + + if (result.error) { + throw result.error + } + if ((result.status ?? 0) !== 0) { + throw new Error(`ensure native failed with exit code ${result.status ?? 1}`) + } +} + +function resolveRequestedPort(rawPort, fallbackPort = defaultVitePort) { + const parsed = Number.parseInt(String(rawPort || '').trim(), 10) + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + return parsed + } + return fallbackPort +} + +function resolvePositiveInteger(rawValue, fallbackValue) { + const parsed = Number.parseInt(String(rawValue || '').trim(), 10) + if (Number.isInteger(parsed) && parsed > 0) { + return parsed + } + return fallbackValue +} + +function resolveNodeArgs(env) { + const maxOldSpaceSizeMb = resolvePositiveInteger( + env.FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB, + defaultMaxOldSpaceSizeMb, + ) + const maxSemiSpaceSizeMb = resolvePositiveInteger( + env.FRONTEND_NODE_MAX_SEMI_SPACE_SIZE_MB, + defaultMaxSemiSpaceSizeMb, + ) + + const args = [`--max-old-space-size=${maxOldSpaceSizeMb}`] + if (maxSemiSpaceSizeMb > 0) { + args.push(`--max-semi-space-size=${maxSemiSpaceSizeMb}`) + } + if (String(env.FRONTEND_NODE_HEAP_SNAPSHOT || '').trim() === '1') { + args.push('--heapsnapshot-near-heap-limit=2') + } + args.push(viteEntry) + + return { + args, + maxOldSpaceSizeMb, + maxSemiSpaceSizeMb, + } +} + +function killProcessTree(pid) { + if (!pid || pid <= 0) { + return + } + + if (process.platform === 'win32') { + spawnSync('taskkill.exe', ['/F', '/T', '/PID', String(pid)], { + stdio: 'ignore', + }) + return + } + + try { + process.kill(pid, 'SIGTERM') + } catch {} +} + +function readProcessRssMb(pid) { + if (!pid || pid <= 0) { + return 0 + } + + if (process.platform === 'win32') { + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `$proc = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($proc) { [math]::Round($proc.WorkingSet64 / 1MB, 0) }`, + ], + { + cwd: frontendDir, + encoding: 'utf8', + }, + ) + + if (result.status !== 0) { + return 0 + } + + return resolvePositiveInteger(result.stdout, 0) + } + + const result = spawnSync('ps', ['-o', 'rss=', '-p', String(pid)], { cwd: frontendDir, encoding: 'utf8', }) -} - -function listListeners(port) { - const frontend = frontendDir.replace(/'/g, "''") - const result = runPowerShell(` -$port = ${port} -$frontend = '${frontend}' -$items = @() -$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 - if ($proc) { - $items += [PSCustomObject]@{ - pid = [int]$proc.ProcessId - name = [string]$proc.Name - commandLine = [string]$proc.CommandLine - } - } -} -$items | ConvertTo-Json -Compress -`) - if (result.status !== 0) { - throw new Error(result.stderr?.trim() || `failed to inspect port ${port}`) + return 0 } - const text = result.stdout.trim() - if (!text) return [] - - const parsed = JSON.parse(text) - return Array.isArray(parsed) ? parsed : [parsed] + const rssKb = resolvePositiveInteger(result.stdout, 0) + return Math.round(rssKb / 1024) } -function isProjectVite(proc) { - const cmd = String(proc.commandLine || '').toLowerCase() - return cmd.includes(frontendDirLower) && cmd.includes('vite') -} +function startMemoryWatcher(child, env) { + const rssWarnMb = resolvePositiveInteger(env.FRONTEND_NODE_RSS_WARN_MB, defaultRssWarnMb) + const rssHardLimitMb = resolvePositiveInteger(env.FRONTEND_NODE_RSS_HARD_LIMIT_MB, defaultRssHardLimitMb) + const pollMs = resolvePositiveInteger(env.FRONTEND_NODE_MEMORY_POLL_MS, defaultMemoryPollMs) + let warnedAtMb = 0 -function ensureVitePortAvailable(port) { - if (process.platform !== 'win32') return - - const listeners = listListeners(port) - for (const proc of listeners) { - if (isProjectVite(proc)) { - console.log(`[dev] cleaning stale Vite on ${port} (PID ${proc.pid})`) - const killed = spawnSync('taskkill.exe', ['/F', '/T', '/PID', String(proc.pid)], { - cwd: frontendDir, - stdio: 'inherit', - }) - if (killed.status !== 0) { - throw new Error(`failed to kill stale Vite process ${proc.pid}`) - } - continue + const timer = setInterval(() => { + if (!child.pid || child.exitCode !== null) { + return } - const name = proc.name || 'unknown' - const cmd = proc.commandLine || '' - throw new Error(`port ${port} is already occupied by ${name} (PID ${proc.pid})\n${cmd}`) - } + const rssMb = readProcessRssMb(child.pid) + if (rssMb <= 0) { + return + } + + if (rssMb >= rssWarnMb && (warnedAtMb === 0 || Math.abs(rssMb - warnedAtMb) >= 128)) { + warnedAtMb = rssMb + console.warn(`[dev] vite RSS is ${rssMb} MB (warning threshold: ${rssWarnMb} MB)`) + } + + if (rssHardLimitMb > 0 && rssMb >= rssHardLimitMb) { + console.error(`[dev] vite RSS reached ${rssMb} MB, exceeding hard limit ${rssHardLimitMb} MB. stopping dev server.`) + killProcessTree(child.pid) + } + }, pollMs) + + timer.unref?.() + return timer } function main() { - ensureVitePortAvailable(vitePort) + const requestedPort = resolveRequestedPort(process.env.FRONTEND_PORT, defaultVitePort) - const command = process.platform === 'win32' ? 'cmd.exe' : 'npm' - const args = process.platform === 'win32' - ? ['/d', '/s', '/c', 'npm run dev:raw'] - : ['run', 'dev:raw'] + const childEnv = { + ...process.env, + FRONTEND_PORT: String(requestedPort), + } - const child = spawn(command, args, { + ensureNativeRuntime(childEnv) + + const nodeArgs = resolveNodeArgs(childEnv) + console.log( + `[dev] starting Vite on http://127.0.0.1:${requestedPort} with --max-old-space-size=${nodeArgs.maxOldSpaceSizeMb} MB --max-semi-space-size=${nodeArgs.maxSemiSpaceSizeMb} MB --rss-hard-limit=${resolvePositiveInteger(childEnv.FRONTEND_NODE_RSS_HARD_LIMIT_MB, defaultRssHardLimitMb)} MB`, + ) + + const child = spawn(nodeExecutable, nodeArgs.args, { cwd: frontendDir, stdio: 'inherit', - env: process.env, + env: childEnv, + }) + const memoryWatcher = startMemoryWatcher(child, childEnv) + let shuttingDown = false + + const shutdown = (exitCode = 0) => { + if (shuttingDown) { + return + } + shuttingDown = true + if (memoryWatcher) { + clearInterval(memoryWatcher) + } + if (child.pid && child.exitCode === null) { + killProcessTree(child.pid) + } + process.exit(exitCode) + } + + const handleSignal = (signal) => { + console.log(`[dev] received ${signal}, stopping Vite...`) + shutdown(0) + } + + process.on('SIGINT', handleSignal) + process.on('SIGTERM', handleSignal) + process.on('exit', () => { + if (memoryWatcher) { + clearInterval(memoryWatcher) + } + if (child.pid && child.exitCode === null) { + killProcessTree(child.pid) + } }) child.on('error', (error) => { console.error(`[dev] failed to start Vite: ${error instanceof Error ? error.message : String(error)}`) - process.exit(1) + shutdown(1) }) child.on('exit', (code, signal) => { + if (memoryWatcher) { + clearInterval(memoryWatcher) + } if (signal) { - process.kill(process.pid, signal) + console.error(`[dev] vite exited with signal ${signal}`) + process.exit(1) return } process.exit(code ?? 0) diff --git a/frontend/scripts/ensure-rollup-native.mjs b/frontend/scripts/ensure-rollup-native.mjs new file mode 100644 index 00000000..a6fd1858 --- /dev/null +++ b/frontend/scripts/ensure-rollup-native.mjs @@ -0,0 +1,140 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import process from 'node:process' + +const require = createRequire(import.meta.url) +const SKIP_ENV = 'ANT_SKIP_ROLLUP_NATIVE_INSTALL' + +function isMusl() { + try { + return !process.report?.getReport().header.glibcVersionRuntime + } catch { + return false + } +} + +function isMingw32() { + try { + return process.report?.getReport().header.osName.startsWith('MINGW32_NT') + } catch { + return false + } +} + +function resolveRollupPackageBase() { + const platformArchMap = { + android: { + arm: { base: 'android-arm-eabi' }, + arm64: { base: 'android-arm64' }, + }, + darwin: { + arm64: { base: 'darwin-arm64' }, + x64: { base: 'darwin-x64' }, + }, + freebsd: { + arm64: { base: 'freebsd-arm64' }, + x64: { base: 'freebsd-x64' }, + }, + linux: { + arm: { base: 'linux-arm-gnueabihf', musl: 'linux-arm-musleabihf' }, + arm64: { base: 'linux-arm64-gnu', musl: 'linux-arm64-musl' }, + loong64: { base: 'linux-loong64-gnu', musl: null }, + ppc64: { base: 'linux-ppc64-gnu', musl: null }, + riscv64: { base: 'linux-riscv64-gnu', musl: 'linux-riscv64-musl' }, + s390x: { base: 'linux-s390x-gnu', musl: null }, + x64: { base: 'linux-x64-gnu', musl: 'linux-x64-musl' }, + }, + openharmony: { + arm64: { base: 'openharmony-arm64' }, + }, + win32: { + arm64: { base: 'win32-arm64-msvc' }, + ia32: { base: 'win32-ia32-msvc' }, + x64: { base: isMingw32() ? 'win32-x64-gnu' : 'win32-x64-msvc' }, + }, + } + + const target = platformArchMap[process.platform]?.[process.arch] + if (!target) { + return null + } + + if ('musl' in target && isMusl()) { + return target.musl + } + + return target.base +} + +function getNpmInvocation() { + if (process.env.npm_execpath) { + return { + command: process.execPath, + args: [process.env.npm_execpath], + } + } + + return { + command: process.platform === 'win32' ? 'npm.cmd' : 'npm', + args: [], + } +} + +function loadRollupPackageJson() { + const rollupEntry = require.resolve('rollup') + const packagePath = join(dirname(rollupEntry), '..', 'package.json') + return JSON.parse(readFileSync(packagePath, 'utf8')) +} + +function ensureRollupNative() { + if (process.env[SKIP_ENV] === '1') { + return + } + + let rollupPackage + try { + rollupPackage = loadRollupPackageJson() + } catch { + return + } + + const packageBase = resolveRollupPackageBase() + if (!packageBase) { + return + } + + const packageName = `@rollup/rollup-${packageBase}` + const version = rollupPackage.optionalDependencies?.[packageName] + if (!version) { + return + } + + try { + require.resolve(packageName) + return + } catch { + console.log(`[postinstall] Missing ${packageName}, installing ${packageName}@${version}`) + } + + const npm = getNpmInvocation() + const result = spawnSync( + npm.command, + [...npm.args, 'install', '--no-save', `${packageName}@${version}`], + { + cwd: process.cwd(), + stdio: 'inherit', + env: { + ...process.env, + [SKIP_ENV]: '1', + }, + } + ) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +ensureRollupNative() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 027d4951..da6d28cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,31 +1,45 @@ -import { useEffect, useState } from 'react' +import { Suspense, lazy, useEffect, useState } from 'react' +import type { ComponentType } from 'react' import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import { ThemeProvider } from './shared/theme' import { Layout } from './shared/layout' -import { ToastContainer, Modal, Button } from './shared/components' +import { ToastContainer, Modal, Button, Loading } from './shared/components' import { AlertCircle } from 'lucide-react' -import { DashboardPage } from './modules/dashboard' -import { SettingsPage } from './modules/settings' -import { ProfilePage } from './modules/profile' -import { AdminKeygenPage } from './modules/profile/AdminKeygenPage' -import { ChartsPage } from './modules/charts' -import { - BrowserListPage, - BrowserDetailPage, - BrowserEditPage, - BrowserCopyPage, - BrowserLogsPage, - ProxyPoolPage, - CoreManagementPage, - BookmarkSettingsPage, - LaunchApiDocsPage, - TagManagementPage, - AutomationPage, - UsageTutorialPage, -} from './modules/browser' -import { QuickLaunchModal } from './modules/browser/components/QuickLaunchModal' import { useNotificationStore } from './store/notificationStore' import { useBackupStore } from './store/backupStore' +import { ForceQuit as ForceQuitApp } from './wailsjs/go/main/App' +import { Environment, Quit, WindowHide, WindowMinimise } from './wailsjs/runtime/runtime' + +function lazyNamed>>( + loader: () => Promise, + exportName: keyof TModule, +) { + return lazy(async () => { + const module = await loader() + return { + default: module[exportName] as ComponentType, + } + }) +} + +const DashboardPage = lazyNamed(() => import('./modules/dashboard/DashboardPage'), 'DashboardPage') +const SettingsPage = lazyNamed(() => import('./modules/settings/SettingsPage'), 'SettingsPage') +const ProfilePage = lazyNamed(() => import('./modules/profile/ProfilePage'), 'ProfilePage') +const AdminKeygenPage = lazyNamed(() => import('./modules/profile/AdminKeygenPage'), 'AdminKeygenPage') +const ChartsPage = lazyNamed(() => import('./modules/charts/ChartsPage'), 'ChartsPage') +const BrowserListPage = lazyNamed(() => import('./modules/browser/pages/BrowserListPage'), 'BrowserListPage') +const BrowserDetailPage = lazyNamed(() => import('./modules/browser/pages/BrowserDetailPage'), 'BrowserDetailPage') +const BrowserEditPage = lazyNamed(() => import('./modules/browser/pages/BrowserEditPage'), 'BrowserEditPage') +const BrowserCopyPage = lazyNamed(() => import('./modules/browser/pages/BrowserCopyPage'), 'BrowserCopyPage') +const BrowserLogsPage = lazyNamed(() => import('./modules/browser/pages/BrowserLogsPage'), 'BrowserLogsPage') +const ProxyPoolPage = lazyNamed(() => import('./modules/browser/pages/ProxyPoolPage'), 'ProxyPoolPage') +const CoreManagementPage = lazyNamed(() => import('./modules/browser/pages/CoreManagementPage'), 'CoreManagementPage') +const BookmarkSettingsPage = lazyNamed(() => import('./modules/browser/pages/BookmarkSettingsPage'), 'BookmarkSettingsPage') +const LaunchApiDocsPage = lazyNamed(() => import('./modules/browser/pages/LaunchApiDocsPage'), 'LaunchApiDocsPage') +const TagManagementPage = lazyNamed(() => import('./modules/browser/pages/TagManagementPage'), 'TagManagementPage') +const AutomationPage = lazyNamed(() => import('./modules/browser/pages/AutomationPage'), 'AutomationPage') +const UsageTutorialPage = lazyNamed(() => import('./modules/browser/pages/UsageTutorialPage'), 'UsageTutorialPage') +const QuickLaunchModal = lazyNamed(() => import('./modules/browser/components/QuickLaunchModal'), 'QuickLaunchModal') function useWailsNotifications() { const addNotification = useNotificationStore((s) => s.addNotification) @@ -77,9 +91,11 @@ function useWailsNotifications() { function CloseConfirmModal() { const [open, setOpen] = useState(false) + const [platform, setPlatform] = useState('windows') const importInProgress = useBackupStore((s) => s.importInProgress) const importProgress = useBackupStore((s) => s.importProgress) const importMessage = useBackupStore((s) => s.importMessage) + const supportsTray = platform === 'windows' useEffect(() => { const runtime = (window as any).runtime @@ -93,21 +109,42 @@ function CloseConfirmModal() { } }, []) + useEffect(() => { + let cancelled = false + + Environment() + .then((info) => { + if (!cancelled && info?.platform) { + setPlatform(info.platform) + } + }) + .catch(() => {}) + + return () => { + cancelled = true + } + }, []) + const handleMinimize = () => { setOpen(false) - const runtime = (window as any).runtime - runtime?.WindowHide?.() + if (supportsTray) { + WindowHide() + return + } + WindowMinimise() } const handleQuit = async () => { setOpen(false) - const goApp = (window as any).go?.main?.App - if (goApp?.ForceQuit) { - await goApp.ForceQuit() - } else { - const runtime = (window as any).runtime - runtime?.Quit?.() + try { + await Promise.race([ + ForceQuitApp(), + new Promise((resolve) => setTimeout(resolve, 1200)), + ]) + } catch (error) { + console.error('ForceQuit failed, falling back to runtime.Quit()', error) } + Quit() } return ( @@ -135,8 +172,9 @@ function CloseConfirmModal() {

) : (

- 退出后将停止所有在此客户端运行的服务,
- 如果您需要保持服务运行,请选择「最小化到托盘」。 + 退出后将停止所有在此客户端运行的服务。 +
+ {supportsTray ? '如果您需要保持服务运行,请选择「最小化到托盘」。' : 'Linux 当前不提供托盘最小化,关闭窗口将直接退出应用。'}

)} @@ -152,8 +190,8 @@ function CloseConfirmModal() { ) : ( <> - - - + ) : ( + + )} + diff --git a/frontend/src/modules/browser/pages/BrowserEditPage.tsx b/frontend/src/modules/browser/pages/BrowserEditPage.tsx index 8c0ae0cb..930ae587 100644 --- a/frontend/src/modules/browser/pages/BrowserEditPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserEditPage.tsx @@ -53,10 +53,13 @@ export function BrowserEditPage() { const list = await fetchBrowserProfiles() const current = list.find(item => item.profileId === id) if (!current) return + const normalizedCoreId = !current.coreId || current.coreId.toLowerCase() === 'default' + ? '' + : current.coreId setFormData({ profileName: current.profileName, userDataDir: current.userDataDir, - coreId: current.coreId, + coreId: normalizedCoreId, fingerprintArgs: current.fingerprintArgs, proxyId: current.proxyId, proxyConfig: current.proxyConfig, diff --git a/frontend/src/modules/browser/pages/BrowserListPage.tsx b/frontend/src/modules/browser/pages/BrowserListPage.tsx index 60ea6b4e..d5550810 100644 --- a/frontend/src/modules/browser/pages/BrowserListPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserListPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' -import { Activity, CheckCircle, ChevronDown, ChevronRight, ChevronUp, Copy, Edit2, FileText, Key, Pencil, Play, Plus, RefreshCw, RotateCcw, Settings, Sliders, Square, Star, Trash2, XCircle, Gift, LayoutGrid, List } from 'lucide-react' +import { Activity, CheckCircle, ChevronDown, ChevronRight, ChevronUp, Copy, Edit2, ExternalLink, FileText, Key, Pencil, Play, Plus, RefreshCw, RotateCcw, Settings, Sliders, Square, Star, Trash2, XCircle, Gift, LayoutGrid, List } from 'lucide-react' import { Badge, Button, Card, FormItem, Input, Modal, StatCard, Table, Textarea, toast } from '../../../shared/components' import { fetchDashboardStats, redeemCDKey, redeemGithubStar, reloadConfig } from '../../dashboard/api' import type { TableColumn } from '../../../shared/components/Table' @@ -8,7 +8,7 @@ import type { BrowserCore, BrowserCoreInput, BrowserProfile, BrowserProxy, Brows import { InstanceFilterBar, EMPTY_FILTERS } from '../components/InstanceFilterBar' import type { InstanceFilters } from '../components/InstanceFilterBar' import { KeywordsModal } from '../components/KeywordsModal' -import { EventsOn, EventsOff, BrowserOpenURL } from '../../../wailsjs/runtime/runtime' +import { EventsOn, BrowserOpenURL } from '../../../wailsjs/runtime/runtime' import { PROJECT_GITHUB_URL } from '../../../config/links' import { resolveActionErrorMessage } from '../utils/actionErrors' import { @@ -73,7 +73,18 @@ function BatchToolbar({ ) } -const statusVariant = (running: boolean) => (running ? 'success' : 'warning') +const resolveProfileStatus = (running: boolean, starting: boolean, stopping: boolean) => { + if (starting) { + return { variant: 'info' as const, label: '启动中' } + } + if (stopping) { + return { variant: 'default' as const, label: '停止中' } + } + if (running) { + return { variant: 'success' as const, label: '运行中' } + } + return { variant: 'warning' as const, label: '已停止' } +} const formatTime = (value?: string) => { if (!value) return '-' const date = new Date(value) @@ -238,6 +249,10 @@ export function BrowserListPage() { const [proxyErrorMsg, setProxyErrorMsg] = useState('') const [opError, setOpError] = useState('') const [pendingStartId, setPendingStartId] = useState(null) + const [startingIds, setStartingIds] = useState>(new Set()) + const [stoppingIds, setStoppingIds] = useState>(new Set()) + const profilesRef = useRef([]) + const silentRefreshInFlightRef = useRef(false) // 关键字弹窗 const [kwModal, setKwModal] = useState<{ open: boolean; profile: BrowserProfile | null }>({ open: false, profile: null }) @@ -277,15 +292,79 @@ export function BrowserListPage() { const [expandModalOpen, setExpandModalOpen] = useState(false) const [cdKey, setCdKey] = useState('') const [redeeming, setRedeeming] = useState(false) - const [maxProfileLimit, setMaxProfileLimit] = useState(3) - const [promoModalMsg, setPromoModalMsg] = useState('') + const [maxProfileLimit, setMaxProfileLimit] = useState(20) - const loadProfiles = async () => { - setLoading(true) + const updatePendingIds = ( + setter: React.Dispatch>>, + profileId: string, + active: boolean + ) => { + setter(prev => { + const next = new Set(prev) + if (active) { + next.add(profileId) + } else { + next.delete(profileId) + } + return next + }) + } + + const replaceProfilesState = (items: BrowserProfile[]) => { + profilesRef.current = items + setProfiles(items) + } + + const updateProfilesState = (updater: (items: BrowserProfile[]) => BrowserProfile[]) => { + const next = updater(profilesRef.current) + profilesRef.current = next + setProfiles(next) + } + + const mergeProfileState = (profile: BrowserProfile | null | undefined) => { + if (!profile) return + updateProfilesState(prev => prev.map(item => ( + item.profileId === profile.profileId ? { ...item, ...profile } : item + ))) + } + + const syncProfiles = (items: BrowserProfile[], syncRuntimeState: boolean) => { + if (syncRuntimeState) { + const previousById = new Map(profilesRef.current.map(item => [item.profileId, item])) + const newlyRunning = items.find(item => item.running && !previousById.get(item.profileId)?.running) + if (newlyRunning) { + updatePendingIds(setStartingIds, newlyRunning.profileId, false) + updatePendingIds(setStoppingIds, newlyRunning.profileId, false) + } + items.forEach(item => { + if (!item.running && previousById.get(item.profileId)?.running) { + updatePendingIds(setStartingIds, item.profileId, false) + updatePendingIds(setStoppingIds, item.profileId, false) + } + }) + } + replaceProfilesState(items) + } + + const loadProfiles = async ({ silent = false, syncRuntimeState = false }: { silent?: boolean; syncRuntimeState?: boolean } = {}) => { + if (silent && silentRefreshInFlightRef.current) { + return profilesRef.current + } + if (!silent) { + setLoading(true) + } else { + silentRefreshInFlightRef.current = true + } try { - setProfiles(await fetchBrowserProfiles()) + const items = await fetchBrowserProfiles() + syncProfiles(items, syncRuntimeState) + return items } finally { - setLoading(false) + if (silent) { + silentRefreshInFlightRef.current = false + } else { + setLoading(false) + } } } @@ -308,36 +387,98 @@ export function BrowserListPage() { try { await reloadConfig() const stats = await fetchDashboardStats() - setMaxProfileLimit(stats.maxProfileLimit || 3) + setMaxProfileLimit(stats.maxProfileLimit || 20) } catch { // ignore } } useEffect(() => { - loadProfiles() + void loadProfiles() loadGroups() loadQuota() fetchBrowserProxies().then(setProxies) fetchBrowserCores().then(setCores) // 监听浏览器实例生命周期事件,自动更新状态 - const events = ['browser:instance:started', 'browser:instance:stopped', 'browser:instance:crashed'] - events.forEach(e => EventsOn(e, () => loadProfiles())) + const offStarted = EventsOn('browser:instance:started', (payload: any) => { + const profileId = typeof payload === 'string' ? payload : payload?.profileId + if (profileId) { + updatePendingIds(setStartingIds, profileId, false) + updatePendingIds(setStoppingIds, profileId, false) + } + void loadProfiles({ silent: true, syncRuntimeState: true }) + }) + const offStopped = EventsOn('browser:instance:stopped', (payload: any) => { + const profileId = typeof payload === 'string' ? payload : payload?.profileId + if (profileId) { + updatePendingIds(setStartingIds, profileId, false) + updatePendingIds(setStoppingIds, profileId, false) + } + void loadProfiles({ silent: true, syncRuntimeState: true }) + }) + const offCrashed = EventsOn('browser:instance:crashed', (payload: any) => { + const profileId = typeof payload === 'string' ? payload : payload?.profileId + if (profileId) { + updatePendingIds(setStartingIds, profileId, false) + updatePendingIds(setStoppingIds, profileId, false) + } + void loadProfiles({ silent: true, syncRuntimeState: true }) + }) + + const timer = window.setInterval(() => { + if (document.visibilityState !== 'visible') return + void loadProfiles({ silent: true, syncRuntimeState: true }) + }, 2000) return () => { - events.forEach(e => EventsOff(e)) + window.clearInterval(timer) + offStarted?.() + offStopped?.() + offCrashed?.() } }, []) const runningCount = useMemo(() => profiles.filter(p => p.running).length, [profiles]) - const allTags = useMemo(() => { const set = new Set() profiles.forEach(p => p.tags?.forEach(t => set.add(t))) return Array.from(set).sort() }, [profiles]) + const defaultCore = useMemo(() => { + return cores.find(core => core.isDefault) || cores[0] || null + }, [cores]) + + const resolveProfileCore = (profile: BrowserProfile) => { + const coreId = (profile.coreId || '').trim() + if (coreId && !/^default$/i.test(coreId)) { + return cores.find(core => core.coreId === coreId) || null + } + return defaultCore + } + + const getProfileCoreLabel = (profile: BrowserProfile) => { + const resolvedCore = resolveProfileCore(profile) + if (resolvedCore) { + return resolvedCore.coreName + } + + const coreId = (profile.coreId || '').trim() + if (!coreId || /^default$/i.test(coreId)) { + return '使用默认内核' + } + return coreId + } + + const isProfileStarting = (profileId: string) => startingIds.has(profileId) + const isProfileStopping = (profileId: string) => stoppingIds.has(profileId) + const isProfileBusy = (profileId: string) => isProfileStarting(profileId) || isProfileStopping(profileId) + + const getProfileStatus = (profile: BrowserProfile) => ( + resolveProfileStatus(profile.running, isProfileStarting(profile.profileId), isProfileStopping(profile.profileId)) + ) + const filteredProfiles = useMemo(() => { const naturalCompare = (a: string, b: string): number => { const re = /(\d+)|(\D+)/g @@ -367,7 +508,10 @@ export function BrowserListPage() { if (filters.status === 'stopped' && p.running) return false if (filters.proxyId === '__none__' && (p.proxyId || p.proxyConfig)) return false if (filters.proxyId && filters.proxyId !== '__none__' && p.proxyId !== filters.proxyId) return false - if (filters.coreId && p.coreId !== filters.coreId) return false + if (filters.coreId) { + const effectiveCore = resolveProfileCore(p) + if (!effectiveCore || effectiveCore.coreId !== filters.coreId) return false + } if (filters.tags.size > 0 && !p.tags?.some(t => filters.tags.has(t))) return false if (filters.kwSearch) { const q = filters.kwSearch.toLowerCase() @@ -376,47 +520,61 @@ export function BrowserListPage() { } return true }).sort((a, b) => naturalCompare(a.profileName, b.profileName)) - }, [profiles, filters]) + }, [profiles, filters, defaultCore, cores]) const handleStart = async (profileId: string) => { const profile = profiles.find(p => p.profileId === profileId) - if (profile) { - const result = await validateProxyConfig(profile.proxyConfig || '', profile.proxyId || '') - if (!result.supported) { - setProxyErrorMsg(result.errorMsg) - setPendingStartId(profileId) - setProxyErrorModal(true) - return - } - } + updatePendingIds(setStartingIds, profileId, true) try { - await startBrowserInstance(profileId) - toast.success('实例已启动') - loadProfiles() + if (profile) { + const result = await validateProxyConfig(profile.proxyConfig || '', profile.proxyId || '') + if (!result.supported) { + setProxyErrorMsg(result.errorMsg) + setPendingStartId(profileId) + setProxyErrorModal(true) + return + } + } + + const startedProfile = await startBrowserInstance(profileId) + mergeProfileState(startedProfile) + toast.success(`实例已启动${startedProfile?.profileName ? `:${startedProfile.profileName}` : ''}`) + await loadProfiles({ silent: true, syncRuntimeState: true }) } catch (error: any) { setOpError(resolveActionErrorMessage(error, '实例启动失败')) - loadProfiles() + await loadProfiles({ silent: true, syncRuntimeState: true }) + } finally { + updatePendingIds(setStartingIds, profileId, false) } } const handleStop = async (profileId: string) => { + updatePendingIds(setStoppingIds, profileId, true) try { - await stopBrowserInstance(profileId) + const stoppedProfile = await stopBrowserInstance(profileId) + mergeProfileState(stoppedProfile) toast.success('实例已停止') - loadProfiles() + await loadProfiles({ silent: true, syncRuntimeState: true }) } catch (error: any) { toast.error(resolveActionErrorMessage(error, '实例停止失败')) + await loadProfiles({ silent: true, syncRuntimeState: true }) + } finally { + updatePendingIds(setStoppingIds, profileId, false) } } const handleRestart = async (profileId: string) => { + updatePendingIds(setStoppingIds, profileId, true) try { - await restartBrowserInstance(profileId) - toast.success('实例已重启') - loadProfiles() + const restartedProfile = await restartBrowserInstance(profileId) + mergeProfileState(restartedProfile) + toast.success(`实例已重启${restartedProfile?.profileName ? `:${restartedProfile.profileName}` : ''}`) + await loadProfiles({ silent: true, syncRuntimeState: true }) } catch (error: any) { setOpError(resolveActionErrorMessage(error, '实例重启失败')) - loadProfiles() + await loadProfiles({ silent: true, syncRuntimeState: true }) + } finally { + updatePendingIds(setStoppingIds, profileId, false) } } @@ -454,12 +612,16 @@ export function BrowserListPage() { for (const id of ids) { const profile = profiles.find(p => p.profileId === id) if (!profile || profile.running) continue + updatePendingIds(setStartingIds, id, true) try { - await startBrowserInstance(id) + const startedProfile = await startBrowserInstance(id) + mergeProfileState(startedProfile) success++ } catch (error: any) { failed++ failureMessages.push(`${profile.profileName}:${resolveActionErrorMessage(error, '实例启动失败')}`) + } finally { + updatePendingIds(setStartingIds, id, false) } } setBatchLoading(false) @@ -480,11 +642,15 @@ export function BrowserListPage() { for (const id of ids) { const profile = profiles.find(p => p.profileId === id) if (!profile || !profile.running) continue + updatePendingIds(setStoppingIds, id, true) try { - await stopBrowserInstance(id) + const stoppedProfile = await stopBrowserInstance(id) + mergeProfileState(stoppedProfile) success++ } catch { failed++ + } finally { + updatePendingIds(setStoppingIds, id, false) } } setBatchLoading(false) @@ -608,18 +774,16 @@ export function BrowserListPage() { setCdKey('') loadQuota() } else { - setPromoModalMsg(result.message || '兑换失败') + toast.error(result.message || '兑换失败') } } - const handleAcceptPromo = async () => { - setPromoModalMsg('') - BrowserOpenURL(PROJECT_GITHUB_URL) + const handleClaimStarGift = async () => { setRedeeming(true) const starRes = await redeemGithubStar() setRedeeming(false) if (starRes.success) { - toast.success('感谢您的支持!已为您增加 3 个永久额度!') + toast.success('感谢您的支持!已额外赠送 50 个永久额度!') setCdKey('') loadQuota() } else { @@ -627,6 +791,11 @@ export function BrowserListPage() { } } + const handleOpenGithubStarGift = async () => { + BrowserOpenURL(PROJECT_GITHUB_URL) + await handleClaimStarGift() + } + const columns: TableColumn[] = [ { key: 'selection', @@ -672,14 +841,16 @@ export function BrowserListPage() { key: 'running', title: '状态', width: 100, - render: (value) => {value ? '运行中' : '已停止'}, + render: (_, record) => { + const status = getProfileStatus(record) + return {status.label} + }, }, { key: 'coreId', title: '核心', - render: (value) => { - const core = cores.find(c => c.coreId === value) - return {core ? core.coreName : value || '-'} + render: (_, record) => { + return {getProfileCoreLabel(record)} }, }, { @@ -710,20 +881,30 @@ export function BrowserListPage() { key: 'actions', title: '操作', align: 'right', - render: (_, record) => ( -
- {record.running ? ( - - ) : ( - - )} - - - - - -
- ), + render: (_, record) => { + const isStarting = isProfileStarting(record.profileId) + const isStopping = isProfileStopping(record.profileId) + const isBusy = isProfileBusy(record.profileId) + + return ( +
+ {record.running ? ( + + ) : ( + + )} + + + + + +
+ ) + }, }, ] @@ -765,7 +946,7 @@ export function BrowserListPage() {
- + + ) : ( - + )} - - - - - + + + + +
{/* Body Grid: Key-Value Pairs */} -
+
内核版本 - {core?.coreName || record.coreId || '-'} + {core?.coreName || getProfileCoreLabel(record)}
代理配置 @@ -912,9 +1103,9 @@ export function BrowserListPage() {
{/* Footer: Keywords */} -
+
系统关键字 -
+
@@ -1016,7 +1207,7 @@ export function BrowserListPage() { initialKeywords={kwModal.profile.keywords || []} onClose={closeKwModal} onSaved={(keywords) => { - setProfiles(prev => prev.map(p => + updateProfilesState(prev => prev.map(p => p.profileId === kwModal.profile!.profileId ? { ...p, keywords } : p )) }} @@ -1066,9 +1257,19 @@ export function BrowserListPage() {
-

- ⭐ 福利放送:在弹窗验证时选择前往 GitHub 为本项目点亮 Star 即可自动获赠 3 个永久实例名额!每个账号限领一次。 -

+
+

点亮 GitHub Star 后,可再获赠 50 个永久额度

+ +
@@ -1101,36 +1302,6 @@ export function BrowserListPage() {
- {/* 获赠提示弹窗 */} - setPromoModalMsg('')} - title="获取更多额度" - width="400px" - footer={ - <> - - - - } - > -
-
- {promoModalMsg} -
-
-

我们注意到您的初始额度可能不够用了。

-

作为开源项目的支持者,如果您愿意给本仓库点一个 Star,我们将自动为您增加 3 个永久额度!

-

- 如果外部浏览器没有自动打开,可点击这里: - -

-

(每个账号仅限一次)

-
-
-
{/* 操作错误弹窗 */} HTTP 接口 -> 实例启动 -> 返回 debugPort -> CDP 接管 +任意语言客户端 / 调度器 + -> POST /api/launch + -> 选择器解析实例 + -> 启动浏览器 + -> 返回 cdpUrl + -> Playwright / Selenium / 自研 CDP 客户端接管 \`\`\` ` const DOC_QUICKSTART = `# 快速接入(3 分钟) -## 第一步:拿到实例 Code +建议先用 \`curl\` 把协议跑通,再换成你自己的语言封装;请求路径、Header、JSON 结构保持不变。 -在 **实例列表** 的“快捷码”列获取 Code。 +## 第一步:准备实例标识 -- 可直接使用已有 Code -- 也可点击编辑设置自定义 Code +推荐至少准备一种稳定标识: + +- \`launchCode\`:最稳,适合生产脚本 +- \`profileId\`:适合系统内部编排 +- \`key / keywords / tags\`:适合“按业务语义找实例”的场景 + +如果你准备使用关键字或标签: + +1. 打开实例编辑页 +2. 给目标实例填好 \`keywords\` +3. 视情况补上 \`tags\`、\`groupId\` + +如果你的外部脚本手里只有“账号 / 业务关键字”: + +- 推荐直接走 \`POST /api/launch\` +- 可以把账号或关键字直接放进 \`code\` +- 后端会先按真实 LaunchCode 查;查不到再按关键字匹配,并在多命中时默认取第一个 +- 如果需要把所有命中实例都启动,显式传 \`matchMode=all\` ## 第二步:健康检查 @@ -55,36 +99,160 @@ curl http://127.0.0.1:19876/api/health # {"ok":true} \`\`\` -## 第三步:按 Code 启动 +## 第三步:最简按 Code 启动 \`\`\`bash curl http://127.0.0.1:19876/api/launch/A3F9K2 \`\`\` -成功后会返回 \`debugPort\`,即可连接 CDP。 +成功后会返回 \`cdpUrl\`,即可通过统一入口端口接入 CDP。 -## 第四步:带参数启动(推荐自动化) +## 第四步:推荐改为 POST 主入口 \`\`\`bash curl -X POST http://127.0.0.1:19876/api/launch \\ -H "Content-Type: application/json" \\ -d '{ - "code":"A3F9K2", - "launchArgs":["--window-size=1280,800"], - "startUrls":["https://example.com"], - "skipDefaultStartUrls":true + "selector": { + "code": "A3F9K2" + }, + "launchArgs": ["--window-size=1280,800"], + "startUrls": ["https://example.com"], + "skipDefaultStartUrls": true }' \`\`\` + +如果你只有“账号 / 关键字”,也可以直接这样写: + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "code": "buyer-001", + "launchArgs": ["--window-size=1280,800"], + "startUrls": ["https://example.com"], + "skipDefaultStartUrls": true + }' +\`\`\` + +## 第五步:复杂场景改用选择器 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "keyword": "checkout", + "tags": ["电商", "北美"], + "groupId": "group-sales-us", + "matchMode": "unique" + }, + "skipDefaultStartUrls": true + }' +\`\`\` +` + +const DOC_SELECTOR = `# 目标实例选择器 + +## 设计目标 + +\`POST /api/launch\` 不再只接受 \`code\`,而是支持一组 **可组合** 的选择条件。 + +- 单一明确标识:用 \`code\` 或 \`profileId\` +- 人类可读标识:用 \`profileName\` +- 业务语义匹配:用 \`keyword / keywords / tag / tags / groupId\` + +补充说明: + +- 在 \`POST /api/launch\` 下,\`code\` 既可以传真实 LaunchCode,也可以传账号 / 关键字 +- 如果 \`code\` 不是有效 LaunchCode,后端会自动按关键字继续匹配 +- 这种“关键字兜底”只作用于 \`POST /api/launch\` +- 旧版 \`GET /api/launch/{code}\` 仍然只接受真实 Code + +## 推荐写法 + +推荐把所有选择条件放进 \`selector\` 对象: + +\`\`\`json +{ + "selector": { + "keyword": "checkout", + "tags": ["电商"], + "groupId": "group-sales-us", + "matchMode": "unique" + }, + "skipDefaultStartUrls": true +} +\`\`\` + +兼容旧版写法,也允许把字段直接放在顶层: + +\`\`\`json +{ + "keyword": "checkout", + "tags": ["电商"], + "groupId": "group-sales-us" +} +\`\`\` + +顶层字段和 \`selector\` 也可以同时出现,合并规则如下: + +- 同名单值字段以 \`selector\` 内为准:\`code / key / profileId / profileName / groupId / matchMode\` +- \`keyword / keywords\` 会合并、去重,并统一归一化到 \`keywords\` +- \`tag / tags\` 会合并、去重,并统一归一化到 \`tags\` + +## 选择字段 + +| 字段 | 类型 | 匹配方式 | 说明 | +|------|------|----------|------| +| \`code\` | string | 精确匹配 / 关键字兜底 | 会自动 trim 并转成大写;先按 LaunchCode 查;仅在 POST 请求里,查不到时再按关键字匹配 | +| \`profileId\` | string | 精确匹配 | 实例唯一 ID | +| \`profileName\` | string | 精确匹配 | 名称忽略大小写,适合名称唯一的实例 | +| \`key\` | string | 先精确后模糊 | 先在实例 \`keywords[]\` 中做精确相等;若没有精确命中,再按 contains 模糊匹配 | +| \`keyword\` | string | 模糊匹配 | 在实例 \`keywords[]\` 中做 contains;日志里会归一化到 \`keywords[]\` | +| \`keywords\` | string[] | 多词 AND | 每个查询词都必须命中某个关键字 | +| \`tag\` | string | 精确匹配 | 标签完全相等,忽略大小写;日志里会归一化到 \`tags[]\` | +| \`tags\` | string[] | 多标签 AND | 实例必须包含全部标签 | +| \`groupId\` | string | 精确匹配 | 只匹配指定分组 ID | +| \`matchMode\` | string | 行为控制 | \`unique\` / \`first\` / \`all\`;\`code / key / keyword / keywords\` 默认 \`first\`,其他默认 \`unique\` | + +## 组合规则 + +- 所有已提供条件按 **AND** 组合 +- \`keywords\` 是 AND 关系 +- \`tags\` 也是 AND 关系 +- \`keyword\` 会归一化到 \`keywords\` +- \`key\` 保持独立语义:优先精确匹配,未命中时再参与模糊匹配 +- \`tag\` 会并入 \`tags\` + +## matchMode 规则 + +| 值 | 含义 | +|----|------| +| \`unique\` | 显式要求唯一。命中 0 个返回 404,命中多个返回 409 | +| \`first\` | 当命中多个实例时,按后端稳定顺序取第一个;\`key / keyword / keywords\` 默认就是这个行为 | +| \`all\` | 当命中多个实例时,按后端稳定顺序依次启动全部实例,并返回 \`items[]\` | + +## 什么时候该用哪个字段 + +- 稳定生产脚本:优先 \`code\` +- 系统内部编排:优先 \`profileId\` +- 名称严格唯一:可用 \`profileName\` +- 一类实例共享规则:用 \`keyword + tags + groupId\` +- 外部脚本只有账号 / 关键字:可直接把值传到 \`POST.code\` +- 容许“取第一个命中实例”:加 \`matchMode=first\` +- 需要“把所有命中实例都启动”:加 \`matchMode=all\` ` const DOC_API_INDEX = `# 接口总览 | 能力 | 方法 | 路径 | 用途 | |------|------|------|------| -| 健康检查 | GET | \`/api/health\` | 检查服务可用性 | -| 按 Code 启动 | GET | \`/api/launch/{code}\` | 快速启动实例 | -| 参数化启动 | POST | \`/api/launch\` | 自动化脚本标准入口 | -| 调用记录 | GET | \`/api/launch/logs?limit=50\` | 最近调用排障 | +| 健康检查 | GET | \`/api/health\` | 检查 Launch 服务是否可用 | +| 按 Code 启动 | GET | \`/api/launch/{code}\` | 兼容旧版、最快捷的唤起方式 | +| 选择器启动 | POST | \`/api/launch\` | 支持 code / profileId / 名称 / 关键字 / 标签 / 分组 | +| CDP 统一入口 | GET / WS | \`/json/version\`、\`/json/list\`、\`/devtools/...\` | 将非 \`/api\` 请求代理到当前活动实例 | +| 调用记录 | GET | \`/api/launch/logs?limit=50\` | 查看最近接口调用与错误 | ` const DOC_API_HEALTH = `# 接口:健康检查 @@ -116,7 +284,9 @@ GET /api/launch/{code} ## 说明 -- 用于最简启动流程 +- 适合“我已经知道唯一 Code”的场景 +- 只支持 \`code\`,不支持复杂选择器 +- 不支持关键字兜底;如果你想传账号 / 关键字,请改用 \`POST /api/launch\` - 实例已运行时返回当前运行信息(幂等) ## 请求示例 @@ -132,13 +302,16 @@ curl http://127.0.0.1:19876/api/launch/A3F9K2 "ok": true, "profileId": "550e8400-e29b-41d4-a716-446655440000", "profileName": "账号 A", + "launchCode": "A3F9K2", "pid": 12345, - "debugPort": 9222 + "debugPort": 9222, + "cdpPort": 19876, + "cdpUrl": "http://127.0.0.1:19876" } \`\`\` ` -const DOC_API_LAUNCH_POST = `# 接口:参数化启动(自动化主入口) +const DOC_API_LAUNCH_POST = `# 接口:选择器启动(自动化主入口) \`\`\` POST /api/launch @@ -148,35 +321,227 @@ POST /api/launch | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| \`code\` | string | 是 | 实例 Code | +| \`selector\` | object | 否 | 推荐写法,放复杂选择条件 | +| \`code\` | string | 否 | 兼容旧版 flat 写法;POST 下支持“Code 优先,关键字兜底” | +| \`profileId\` | string | 否 | 顶层选择字段 | +| \`profileName\` | string | 否 | 顶层选择字段 | +| \`key / keyword / keywords\` | string / string[] | 否 | 顶层关键字条件 | +| \`tag / tags\` | string / string[] | 否 | 顶层标签条件 | +| \`groupId\` | string | 否 | 顶层分组条件 | +| \`matchMode\` | string | 否 | \`unique\` / \`first\` / \`all\` | | \`launchArgs\` | string[] | 否 | 本次附加启动参数 | | \`startUrls\` | string[] | 否 | 本次启动打开 URL 列表 | | \`skipDefaultStartUrls\` | boolean | 否 | 跳过系统默认起始页 | -## 请求示例 +> 注意:\`selector\` 与顶层选择字段可以混用,不是互斥关系。重名单值字段以 \`selector\` 为准;\`keyword / keywords\`、\`tag / tags\` 会合并并去重。至少要提供一个选择字段。 + +## 示例 1:旧版 code 写法 \`\`\`bash curl -X POST http://127.0.0.1:19876/api/launch \\ -H "Content-Type: application/json" \\ -d '{ "code":"A3F9K2", - "launchArgs":["--lang=en-US","--window-size=1366,768"], + "skipDefaultStartUrls":true + }' +\`\`\` + +## 示例 1B:\`code\` 直接传账号 / 关键字 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "code":"buyer-001", + "launchArgs":["--window-size=1280,800","--lang=en-US"], "startUrls":["https://example.com"], "skipDefaultStartUrls":true }' \`\`\` -## 成功响应 +说明: + +- 先按真实 LaunchCode 查 +- 查不到就把 \`buyer-001\` 当关键字去匹配实例 +- 如果命中多个实例,默认取第一个 + +## 示例 1C:\`code\` 传关键字,并把所有命中实例都启动 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "code":"shop", + "matchMode":"all", + "skipDefaultStartUrls":true + }' +\`\`\` + +## 示例 2:按 profileId 启动 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "profileId":"550e8400-e29b-41d4-a716-446655440000" + }, + "launchArgs":["--lang=en-US"] + }' +\`\`\` + +## 示例 3:按唯一名称启动 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "profileName":"账号 A" + } + }' +\`\`\` + +## 示例 4:按关键字 + 标签 + 分组定位实例 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "keyword":"checkout", + "tags":["电商","北美"], + "groupId":"group-sales-us", + "matchMode":"unique" + }, + "startUrls":["https://example.com/order"], + "skipDefaultStartUrls":true + }' +\`\`\` + +## 示例 5:多关键字 AND 匹配 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "keywords":["amazon","buyer","checkout"], + "tags":["电商"], + "matchMode":"unique" + } + }' +\`\`\` + +## 示例 6:允许多命中时取第一个 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "keyword":"shop", + "matchMode":"first" + } + }' +\`\`\` + +## 示例 7:允许多命中时全部启动 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "selector": { + "keyword":"shop", + "matchMode":"all" + } + }' +\`\`\` + +## 成功响应:单实例 \`\`\`json { "ok": true, "profileId": "550e8400-e29b-41d4-a716-446655440000", "profileName": "账号 A", + "launchCode": "A3F9K2", "pid": 12345, - "debugPort": 9222 + "debugPort": 9222, + "cdpPort": 19876, + "cdpUrl": "http://127.0.0.1:19876" } \`\`\` + +## 成功响应:多实例(\`matchMode=all\`) + +\`\`\`json +{ + "ok": true, + "matchMode": "all", + "count": 2, + "activeProfileId": "profile-b", + "activeProfileName": "账号 B", + "cdpPort": 19876, + "cdpUrl": "http://127.0.0.1:19876", + "items": [ + { + "profileId": "profile-a", + "profileName": "账号 A", + "launchCode": "A3F9K2", + "pid": 12345, + "debugPort": 9222, + "isActive": false + }, + { + "profileId": "profile-b", + "profileName": "账号 B", + "launchCode": "B7Q2W9", + "pid": 12346, + "debugPort": 9333, + "isActive": true + } + ] +} +\`\`\` + +补充说明: + +- \`activeProfileId\` 是当前统一 CDP 入口实际指向的实例 +- \`matchMode=all\` 时,会按后端稳定顺序依次启动;最后一个成功启动的实例会成为活动实例 +` + +const DOC_API_CDP = `# 接口:CDP 统一入口 + +\`\`\` +GET /json/version +GET /json/list +GET /devtools/... +WS /devtools/... +\`\`\` + +## 说明 + +- LaunchServer 会把所有非 \`/api\` 请求代理到当前活动实例的内部调试端口 +- 当前活动实例等于最近一次成功启动的实例 +- 如果使用 \`matchMode=all\`,则最后一个启动成功的实例会成为当前活动实例 +- 如果当前没有活动实例,请求会返回 \`503\` 和 \`no active browser debug target\` + +## 请求示例 + +\`\`\`bash +curl http://127.0.0.1:19876/json/version +\`\`\` + +\`\`\`bash +curl http://127.0.0.1:19876/json/list +\`\`\` + +## 使用建议 + +- 推荐直接把启动响应里的 \`cdpUrl\` 交给 Playwright / Selenium / 自研 CDP 客户端 +- 如果你手动请求 \`/json/version\` 或 \`/json/list\`,返回内容由目标 Chrome/CDP 直接提供,字段会随 Chrome 版本变化 ` const DOC_API_LOGS = `# 接口:调用记录 @@ -190,6 +555,8 @@ GET /api/launch/logs?limit=50 - 默认返回最近 50 条 - 最大支持 200 条 - 返回顺序:按时间倒序(最新在前) +- 不管是按 Code 启动,还是按关键字/标签启动,都会记录当次使用的 selector +- 日志里的 \`selector\` 是归一化结果:例如 \`keyword\` 会显示为 \`keywords[]\`,\`tag\` 会显示为 \`tags[]\` ## 请求示例 @@ -209,11 +576,17 @@ curl http://127.0.0.1:19876/api/launch/logs?limit=20 "path": "/api/launch", "clientIp": "127.0.0.1", "code": "A3F9K2", + "selector": { + "keywords": ["checkout"], + "tags": ["电商", "北美"], + "groupId": "group-sales-us", + "matchMode": "unique" + }, "profileId": "550e8400-e29b-41d4-a716-446655440000", "profileName": "账号 A", "params": { "launchArgs": ["--window-size=1280,800"], - "startUrls": ["https://example.com"], + "startUrls": ["https://example.com/order"], "skipDefaultStartUrls": true }, "ok": true, @@ -226,26 +599,115 @@ curl http://127.0.0.1:19876/api/launch/logs?limit=20 \`\`\` ` +const DOC_SCENARIOS = `# 场景示例 + +## 场景 1:生产环境固定实例 + +用 \`code\` 或 \`profileId\`。 + +原因: + +- 最稳定 +- 不怕名称变更 +- 不怕关键字维护失误 + +## 场景 1B:外部脚本只有账号 / 业务关键字 + +直接用 \`POST /api/launch\` 的 \`code\` 字段。 + +示例: + +\`\`\`json +{ + "code": "buyer-001", + "skipDefaultStartUrls": true +} +\`\`\` + +说明: + +- 这是给外部脚本最省事的写法 +- 后端会先按真实 Code 查,再按关键字兜底 +- 如果一类实例会命中多个,默认取第一个 +- 如果你就是要全起,显式加 \`matchMode=all\` + +## 场景 2:一个业务线下有多组实例 + +用 \`keyword + tags + groupId\`。 + +示例: + +\`\`\`json +{ + "selector": { + "keyword": "checkout", + "tags": ["电商", "北美"], + "groupId": "group-sales-us", + "matchMode": "unique" + } +} +\`\`\` + +## 场景 3:批量模板实例,需要把命中的实例全部启动 + +用 \`keyword + matchMode=all\`。 + +示例: + +\`\`\`json +{ + "selector": { + "keyword": "template", + "matchMode": "all" + } +} +\`\`\` + +## 场景 4:想按“业务语义”启动,但又担心误命中 + +用多条件收窄: + +\`\`\`json +{ + "selector": { + "keywords": ["amazon", "buyer", "checkout"], + "tags": ["电商", "北美"], + "groupId": "group-sales-us", + "matchMode": "unique" + } +} +\`\`\` + +## 场景 5:脚本第一次靠关键字命中,后续改用 Code + +返回体里会附带 \`launchCode\`,你可以把它缓存下来,下一次直接用 Code 启动。 +` + const DOC_ERRORS = `# 错误码与重试策略 | 状态码 | 场景 | 建议处理 | |--------|------|----------| -| 400 | 请求体非法 / 缺少字段 | 修复参数后重试 | +| 400 | 请求体非法 / 含未知字段 / selector 缺失 / matchMode 非法 | 修复参数后重试 | | 403 | 非 localhost 访问 | 改为本机请求 | -| 404 | Code 不存在 | 检查 Code 是否正确 | +| 404 | GET 的 Code 不存在 / POST 的 code 关键字兜底后仍未命中 / selector 没命中实例 | 检查 code、keywords、tags、groupId | | 405 | 方法错误 | 使用正确 HTTP 方法 | +| 409 | selector 命中多个实例 | 收窄条件,或显式设 \`matchMode=first\` 或 \`matchMode=all\` | | 500 | 启动失败 | 查 \`/api/launch/logs\` + 应用日志 | +| 503 | 访问 CDP 统一入口时还没有活动实例 | 先成功调用一次启动接口,再访问 \`cdpUrl\` | ## 自动化建议 - 设置请求超时(3-10 秒) - 对 \`500\` 可短暂重试(指数退避) -- 对 \`400/404\` 不建议盲目重试 +- 对 \`400/404/409\` 不建议盲目重试 +- 对复杂 selector,先在低风险环境验证日志是否命中正确实例 ` -const DOC_EXAMPLES = `# 自动化示例 +const DOC_EXAMPLES = `# 多语言调用示例(同一协议) -## Python:启动并连接 CDP +下面这些示例调用的是同一组 HTTP 接口,只是客户端语法不同。你可以直接替换成自己的语言或框架实现。 + +## Python:按关键字启动并连接 CDP \`\`\`python import requests @@ -253,84 +715,171 @@ from playwright.sync_api import sync_playwright BASE = "http://127.0.0.1:19876" -def launch_by_code(code: str) -> dict: - r = requests.post( +def launch(selector: dict) -> dict: + res = requests.post( f"{BASE}/api/launch", json={ - "code": code, - "launchArgs": ["--window-size=1280,800"], + "selector": selector, "skipDefaultStartUrls": True, }, timeout=10, ) - r.raise_for_status() - data = r.json() - if not data.get("ok"): - raise RuntimeError(data.get("error", "launch failed")) + data = res.json() + if not res.ok or not data.get("ok"): + raise RuntimeError(data.get("error", f"HTTP {res.status_code}")) return data with sync_playwright() as p: - data = launch_by_code("A3F9K2") - browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{data['debugPort']}") + data = launch({ + "keyword": "checkout", + "tags": ["电商", "北美"], + "groupId": "group-sales-us", + "matchMode": "unique", + }) + browser = p.chromium.connect_over_cdp(data["cdpUrl"]) page = browser.contexts[0].new_page() page.goto("https://example.com") \`\`\` -## Node.js:最小调用 +## Node.js:封装通用 launch(selector) \`\`\`javascript const BASE = 'http://127.0.0.1:19876' -async function launch(code) { +async function launch(selector, extra = {}) { const res = await fetch(\`\${BASE}/api/launch\`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, skipDefaultStartUrls: true }) + body: JSON.stringify({ + selector, + skipDefaultStartUrls: true, + ...extra, + }), }) + const data = await res.json() - if (!res.ok || !data.ok) throw new Error(data.error || \`HTTP \${res.status}\`) + if (!res.ok || !data.ok) { + throw new Error(data.error || \`HTTP \${res.status}\`) + } return data } + +await launch({ + keywords: ['amazon', 'buyer', 'checkout'], + tags: ['电商'], + matchMode: 'unique', +}) +\`\`\` + +## PowerShell:按名称或关键字启动 + +\`\`\`powershell +$body = @{ + code = "buyer-001" + skipDefaultStartUrls = $true +} | ConvertTo-Json -Depth 6 + +Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:19876/api/launch" -ContentType "application/json" -Body $body +\`\`\` + +## cURL:\`code\` 传关键字 + +\`\`\`bash +curl -X POST http://127.0.0.1:19876/api/launch \\ + -H "Content-Type: application/json" \\ + -d '{ + "code":"buyer-001", + "skipDefaultStartUrls":true + }' \`\`\` ` const DOC_PRACTICES = `# 最佳实践 -## 1) Code 管理 +## 1) 稳定标识优先级 -- 为每个实例分配稳定 Code -- 高风险脚本使用专用 Code,不与人工操作混用 -- 变更 Code 后同步更新你的任务配置 +推荐优先级: -## 2) 启动参数策略 +1. \`code\` +2. \`profileId\` +3. \`profileName\` +4. \`keyword / tags / groupId\` + +补充: + +- 如果外部脚本只有账号 / 关键字,又不想额外构造 selector,优先用 \`POST.code\` + +## 2) 关键字维护规范 + +- \`keywords\` 里尽量放稳定业务词,不要放容易漂移的描述 +- 同一类实例的关键字保持风格统一 +- 如果脚本要靠关键字精确命中,至少再加一个 \`tag\` 或 \`groupId\` + +## 3) 标签与分组策略 + +- \`tags\` 用来表达能力或属性,例如:\`电商\`、\`北美\`、\`付款\` +- \`groupId\` 用来表达组织归属,例如:\`group-sales-us\` +- 不建议只靠单个宽泛标签做生产启动条件 + +## 4) 启动参数策略 - 把通用参数放在实例默认配置 -- 仅把“任务相关参数”放在 \`POST /api/launch\` 的 \`launchArgs\` +- 把任务临时参数放在 \`launchArgs\` +- 只在当前任务需要时才传 \`startUrls\` -## 3) 排障流程 +## 5) 排障流程 1. 先调 \`/api/health\` -2. 再调启动接口 -3. 失败时查 \`/api/launch/logs\` -4. 最后结合应用日志定位 +2. 再调 \`POST /api/launch\` +3. 如果报 \`409\`,先收窄 selector;如果业务允许多命中,再改 \`matchMode=first\` 或 \`matchMode=all\` +4. 如果报 \`500\`,再查 \`/api/launch/logs\` 和应用日志 ` const DOC_TROUBLESHOOT = `# 常见问题 -## Q1:返回 \`launch code not found\` +## Q1:返回 \`selector is required\` -- Code 拼写错误或未分配 -- 检查实例列表中的 Code 是否一致 +- 你没有提供 \`selector\` +- 也没有提供顶层的 \`code / profileId / keyword / tags\` 等字段 -## Q2:返回 \`forbidden: only localhost is allowed\` +## Q2:返回 \`launch code not found\` + +- Code 拼写错误 +- 目标实例没有这个 Code +- 你把自定义 Code 改过,但脚本没同步 +- 这通常是 \`GET /api/launch/{code}\` 或“你明确想按真实 Code 启动”的报错 +- 如果你传的是账号 / 关键字,请改用 \`POST /api/launch\` + +## Q3:返回 \`profile selector matched no instance\` + +- 关键字没有配置到实例的 \`keywords\` +- \`tags\` 或 \`groupId\` 条件写错 +- 组合条件过严,导致 0 命中 +- 或者你在 \`POST\` 里把 \`code\` 当关键字传了,但该关键字没有命中任何实例 + +## Q4:返回 \`selector matched multiple profiles\` + +- 说明关键字过宽或标签过少 +- 优先补 \`groupId\` / 更多 \`tags\` +- 如果业务允许,显式加 \`matchMode=first\` +- 如果你要把这些命中实例全部启动,改用 \`matchMode=all\` + +## Q5:返回 \`forbidden: only localhost is allowed\` - 当前服务只允许本机访问 - 请在同一台机器发起请求 -## Q3:返回 \`500\` 启动失败 +## Q6:返回 \`500\` 启动失败 -- 先查 \`/api/launch/logs\` 的 \`error\` +- 先看 \`/api/launch/logs\` 里的 \`error\` - 再检查内核路径、代理配置、启动参数是否合法 +- 如果是复杂 selector,先确认命中的实例就是你预期那一个 + +## Q7:访问 \`cdpUrl\` 返回 \`no active browser debug target\` + +- 说明当前还没有活动实例 +- 先调用一次 \`GET /api/launch/{code}\` 或 \`POST /api/launch\` +- 如果刚启动完实例仍然出现这个问题,再检查启动接口是否真的返回了 \`200\` ` // ============================================================================ @@ -355,6 +904,11 @@ const DOC_TREE: DocNode[] = [ label: '快速接入', content: DOC_QUICKSTART, }, + { + id: 'selector', + label: '选择器规则', + content: DOC_SELECTOR, + }, { id: 'api-index', label: '接口总览', @@ -367,9 +921,15 @@ const DOC_TREE: DocNode[] = [ { id: 'api-health', label: '健康检查', content: DOC_API_HEALTH }, { id: 'api-launch-get', label: '按 Code 启动', content: DOC_API_LAUNCH_GET }, { id: 'api-launch-post', label: '参数化启动', content: DOC_API_LAUNCH_POST }, + { id: 'api-cdp', label: 'CDP 统一入口', content: DOC_API_CDP }, { id: 'api-logs', label: '调用记录', content: DOC_API_LOGS }, ], }, + { + id: 'scenarios', + label: '场景示例', + content: DOC_SCENARIOS, + }, { id: 'errors', label: '错误与重试', @@ -377,7 +937,7 @@ const DOC_TREE: DocNode[] = [ }, { id: 'examples', - label: '代码示例', + label: '多语言示例', content: DOC_EXAMPLES, }, { @@ -709,4 +1269,3 @@ export function LaunchApiDocsPage() {
) } - diff --git a/frontend/src/modules/browser/pages/ProxyPoolPage.tsx b/frontend/src/modules/browser/pages/ProxyPoolPage.tsx index a7ce3a5c..69a3760f 100644 --- a/frontend/src/modules/browser/pages/ProxyPoolPage.tsx +++ b/frontend/src/modules/browser/pages/ProxyPoolPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Button, Card, ConfirmModal, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components' +import { Button, Card, ConfirmModal, FormItem, Input, Modal, Select, Switch, Table, Textarea, toast } from '../../../shared/components' import type { SortOrder, TableColumn } from '../../../shared/components/Table' import type { BrowserProxy, ProxyIPHealthResult } from '../types' import { fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL } from '../api' @@ -39,6 +39,37 @@ interface ClashProxy { [key: string]: any } +type ProxyImportMode = 'clash' | 'direct' + +interface DirectImportForm { + proxyName: string + protocol: 'http' | 'https' | 'socks5' + server: string + port: string + username: string + password: string +} + +const DIRECT_PROXY_PROTOCOL_OPTIONS = [ + { value: 'http', label: 'HTTP' }, + { value: 'https', label: 'HTTPS' }, + { value: 'socks5', label: 'SOCKS5' }, +] as const + +const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = { + proxyName: '', + protocol: 'http', + server: '', + port: '', + username: '', + password: '', +} + +interface ImportCandidate { + proxyName: string + proxyConfig: string +} + interface ProxyDisplayInfo { proxyId: string proxyName: string @@ -205,6 +236,116 @@ function parseClashImportText(raw: string): ClashProxy[] { throw new Error('无效的 YAML 格式,需要包含 proxies 数组') } +function normalizeDirectProxyConfig(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return '' + if (/^socket:\/\//i.test(trimmed)) { + return trimmed.replace(/^socket:\/\//i, 'socks5://') + } + if (/^socks:\/\//i.test(trimmed)) { + return trimmed.replace(/^socks:\/\//i, 'socks5://') + } + return trimmed +} + +function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string { + const name = rawName.trim() + const fallbackName = server + ? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}` + : `导入代理 ${index + 1}` + const finalName = name || fallbackName + return prefix ? `${prefix}-${finalName}` : finalName +} + +function formatDirectProxyHost(raw: string): string { + const host = raw.trim() + if (!host) return '' + if (host.startsWith('[') && host.endsWith(']')) { + return host + } + return host.includes(':') ? `[${host}]` : host +} + +function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate { + const serverInput = form.server.trim() + if (!serverInput) { + throw new Error('请输入代理地址') + } + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) { + throw new Error('代理地址只需要填写主机名或 IP,不需要协议头') + } + + const portInput = form.port.trim() + if (!portInput) { + throw new Error('请输入代理端口') + } + if (!/^\d+$/.test(portInput)) { + throw new Error('代理端口必须为数字') + } + + const port = Number(portInput) + if (port < 1 || port > 65535) { + throw new Error('代理端口必须在 1-65535 之间') + } + + const username = form.username.trim() + const password = form.password + if (password && !username) { + throw new Error('填写密码时请同时填写账号') + } + + const auth = username + ? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@` + : '' + const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}` + + let parsedURL: URL + try { + parsedURL = new URL(rawConfig) + } catch { + throw new Error('请输入有效的代理地址') + } + + if (!parsedURL.hostname) { + throw new Error('请输入有效的代理地址') + } + + const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '') + const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1') + + return { + proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''), + proxyConfig: normalizedConfig, + } +} + +function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] { + return parsedProxies.map((proxy, index) => ({ + proxyName: resolveImportedProxyName(proxy, index, prefix), + proxyConfig: proxyToYaml(proxy), + })) +} + +function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] { + return candidates.map((candidate, index) => { + const info = parseProxyInfo(candidate.proxyConfig) + return { + proxyId: `preview-${index}`, + proxyName: candidate.proxyName, + proxyConfig: candidate.proxyConfig, + groupName, + sourceId: '', + sourceUrl: '', + sourceAutoRefresh: false, + sourceRefreshIntervalM: 0, + sourceLastRefreshAt: '', + type: info.type || '-', + server: info.server || '-', + port: info.port || 0, + } + }) +} + function parseTimestampMs(value: string): number { const v = (value || '').trim() if (!v) return 0 @@ -231,6 +372,43 @@ function sourceHostLabel(sourceURL: string): string { } } +function normalizeSourceURL(sourceURL: string): string { + const raw = (sourceURL || '').trim() + if (!raw) return '' + try { + const parsed = new URL(raw) + parsed.hash = '' + return parsed.toString() + } catch { + return raw + } +} + +function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string { + const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}` + // djb2 变体,输出稳定且实现简单。 + let hash = 5381 + for (let i = 0; i < key.length; i += 1) { + hash = ((hash << 5) + hash) ^ key.charCodeAt(i) + } + const unsigned = hash >>> 0 + return `src-${unsigned.toString(36)}` +} + +function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string { + const normalizedURL = normalizeSourceURL(sourceURL) + const normalizedPrefix = sourceNamePrefix.trim() + const existing = list.find(item => + normalizeSourceURL(item.sourceUrl || '') === normalizedURL && + (item.sourceNamePrefix || '').trim() === normalizedPrefix && + (item.sourceId || '').trim() !== '' + ) + if (existing?.sourceId?.trim()) { + return existing.sourceId.trim() + } + return buildStableSourceID(sourceURL, sourceNamePrefix) +} + function collectURLImportSources(list: BrowserProxy[]): URLImportSourceMeta[] { const sourceMap = new Map() for (const item of list) { @@ -273,15 +451,10 @@ function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: stri return prefix ? `${prefix}-${rawName}` : rawName } -function buildRefreshedSourceProxies( - parsedProxies: ClashProxy[], - oldSourceProxies: BrowserProxy[], - meta: URLImportSourceMeta, - refreshedAt: string -): BrowserProxy[] { +function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) { const exactMap = new Map() const nameMap = new Map() - for (const item of oldSourceProxies) { + oldSourceProxies.forEach(item => { const exactKey = `${item.proxyName}|||${item.proxyConfig}` const exactList = exactMap.get(exactKey) || [] exactList.push(item) @@ -291,15 +464,16 @@ function buildRefreshedSourceProxies( const nameList = nameMap.get(nameKey) || [] nameList.push(item) nameMap.set(nameKey, nameList) - } + }) - const pickExisting = (name: string, configText: string): string | null => { + return (name: string, configText: string): string | null => { const exactKey = `${name}|||${configText}` const exactList = exactMap.get(exactKey) if (exactList && exactList.length > 0) { const item = exactList.shift() if (item?.proxyId) return item.proxyId } + const nameList = nameMap.get(name) if (nameList && nameList.length > 0) { const item = nameList.shift() @@ -307,6 +481,15 @@ function buildRefreshedSourceProxies( } return null } +} + +function buildRefreshedSourceProxies( + parsedProxies: ClashProxy[], + oldSourceProxies: BrowserProxy[], + meta: URLImportSourceMeta, + refreshedAt: string +): BrowserProxy[] { + const pickExisting = createExistingProxyIDPicker(oldSourceProxies) const prefix = meta.sourceNamePrefix.trim() const sourceGroupName = meta.sourceGroupName.trim() @@ -530,12 +713,14 @@ export function ProxyPoolPage() { const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false) const [importModalOpen, setImportModalOpen] = useState(false) + const [importMode, setImportMode] = useState('clash') const [importUrl, setImportUrl] = useState('') const [importResolvedUrl, setImportResolvedUrl] = useState('') const [importText, setImportText] = useState('') const [importDnsServers, setImportDnsServers] = useState('') const [importNamePrefix, setImportNamePrefix] = useState('') const [importGroupName, setImportGroupName] = useState('') + const [directImportForm, setDirectImportForm] = useState(() => ({ ...INITIAL_DIRECT_IMPORT_FORM })) const [previewModalOpen, setPreviewModalOpen] = useState(false) const [previewList, setPreviewList] = useState([]) const [removedPreviewProxyNames, setRemovedPreviewProxyNames] = useState([]) @@ -1228,25 +1413,13 @@ export function ProxyPoolPage() { setDeletingId(null) } - const buildImportPreview = (parsedProxies: ClashProxy[]) => { - return parsedProxies.map((p, idx) => { - const prefix = importNamePrefix.trim() - const proxyName = resolveImportedProxyName(p, idx, prefix) - return { - proxyId: `preview-${idx}`, - proxyName, - proxyConfig: proxyToYaml(p), - groupName: importGroupName.trim(), - sourceId: '', - sourceUrl: '', - sourceAutoRefresh: false, - sourceRefreshIntervalM: 0, - sourceLastRefreshAt: '', - type: p.type || '-', - server: p.server || '-', - port: p.port || 0, - } as ProxyDisplayInfo - }) + const handleImportModeChange = (nextMode: ProxyImportMode) => { + setImportMode(nextMode) + setImportResolvedUrl('') + if (nextMode !== 'clash') { + setImportUrl('') + setImportDnsServers('') + } } const handleFetchImportURL = async () => { @@ -1283,14 +1456,17 @@ export function ProxyPoolPage() { } } - const handleParseYaml = () => { + const handleParseImport = () => { try { - const parsedProxies = parseClashImportText(importText) - if (!parsedProxies.length) { + const prefix = importNamePrefix.trim() + const candidates = importMode === 'clash' + ? buildImportCandidatesFromClash(parseClashImportText(importText), prefix) + : [buildDirectImportCandidate(directImportForm)] + if (!candidates.length) { toast.error('未解析到可导入代理') return } - const preview = buildImportPreview(parsedProxies) + const preview = buildImportPreview(candidates, importGroupName.trim()) setRemovedPreviewProxyNames([]) setPreviewList(preview) setImportModalOpen(false) @@ -1307,19 +1483,23 @@ export function ProxyPoolPage() { } setImporting(true) try { - const sourceURL = importResolvedUrl.trim() + const sourceURL = importMode === 'clash' ? importResolvedUrl.trim() : '' const isURLImport = !!sourceURL - const sourceID = isURLImport ? `src-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : '' - const sourceNamePrefix = importNamePrefix.trim() + const sourceNamePrefix = importMode === 'clash' ? importNamePrefix.trim() : '' + const sourceID = isURLImport ? resolveImportSourceID(proxies, sourceURL, sourceNamePrefix) : '' const sourceAutoRefresh = isURLImport ? globalAutoRefreshEnabled : false const sourceRefreshIntervalM = sourceAutoRefresh ? globalRefreshInterval : 0 const sourceLastRefreshAt = isURLImport ? new Date().toISOString() : '' + const oldSourceProxies = isURLImport + ? proxies.filter(item => (item.sourceId || '').trim() === sourceID) + : [] + const pickExistingID = createExistingProxyIDPicker(oldSourceProxies) - const newProxies: BrowserProxy[] = previewList.map((p, idx) => ({ - proxyId: `proxy-${Date.now()}-${idx}`, + const newProxies: BrowserProxy[] = previewList.map((p) => ({ + proxyId: pickExistingID(p.proxyName, p.proxyConfig) || nextProxyID(), proxyName: p.proxyName, proxyConfig: p.proxyConfig, - dnsServers: importDnsServers.trim() || undefined, + dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined, groupName: importGroupName.trim() || undefined, sourceId: sourceID || undefined, sourceUrl: sourceURL || undefined, @@ -1328,7 +1508,9 @@ export function ProxyPoolPage() { sourceRefreshIntervalM, sourceLastRefreshAt: sourceLastRefreshAt || undefined, })) - const allProxies = [...proxies, ...newProxies] + const allProxies = isURLImport + ? proxies.filter(item => (item.sourceId || '').trim() !== sourceID).concat(newProxies) + : [...proxies, ...newProxies] await saveProxies(allProxies) if (isURLImport && removedPreviewProxyNames.length > 0) { appendSourceIgnoredProxyNames(sourceID, removedPreviewProxyNames) @@ -1340,6 +1522,7 @@ export function ProxyPoolPage() { setImportDnsServers('') setImportNamePrefix('') setImportGroupName('') + setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM }) setPreviewList([]) setRemovedPreviewProxyNames([]) toast.success(`成功导入 ${newProxies.length} 个代理`) @@ -1351,13 +1534,16 @@ export function ProxyPoolPage() { } const selectedCount = selectedIds.size + const canParseImport = importMode === 'clash' + ? !!importText.trim() + : !!directImportForm.server.trim() && !!directImportForm.port.trim() return (

代理池配置

-

管理 Clash VPN 代理,供实例绑定使用

+

管理代理配置,支持 Clash 订阅、HTTP、HTTPS、SOCKS5

- +
@@ -1454,47 +1640,123 @@ export function ProxyPoolPage() { /> - setImportModalOpen(false)} title="导入 Clash 代理配置" width="600px" + setImportModalOpen(false)} title="导入代理配置" width="600px" footer={ <> - + }>
-

支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)

- -
- { - const next = e.target.value - setImportUrl(next) - if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) { - setImportResolvedUrl('') - } - }} - placeholder="https://example.com/clash/subscription" - className="flex-1" +
+ + +
+

+ {importMode === 'clash' + ? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)' + : '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'} +

+ {importMode === 'clash' && ( + <> + +
+ { + const next = e.target.value + setImportUrl(next) + if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) { + setImportResolvedUrl('') + } + }} + placeholder="https://example.com/clash/subscription" + className="flex-1" + /> + +
+ {importResolvedUrl.trim() && ( +

+ 已绑定订阅:{importResolvedUrl} +

+ )} +

获取成功后会自动回填 YAML 文本,并尝试自动填充 DNS 与建议分组;自动刷新时间请在列表顶部统一配置

+
+