From 3d264eb83df036b7edc064ee6d8d7b2751495b6d Mon Sep 17 00:00:00 2001 From: Ant Browser Release Bot Date: Sun, 29 Mar 2026 19:32:05 +0800 Subject: [PATCH] publish: 1.1.0 snapshot (a358335) channel: master version: 1.1.0 source-ref: master published-at-utc: 2026-03-29T11:32:04Z --- .gitignore | 2 + README.md | 9 +- backend/app.go | 59 +- backend/app_browser_settings_test.go | 68 ++ backend/app_close_behavior_test.go | 62 ++ backend/app_cookie.go | 7 +- backend/app_instance.go | 188 ++-- backend/app_instance_errors.go | 256 +++++- backend/app_instance_errors_test.go | 55 +- backend/app_instance_start_test.go | 420 ++++++++- backend/app_launchcode.go | 19 + backend/browser_launch_args.go | 80 ++ backend/browser_process_monitor.go | 217 +++++ backend/browser_runtime_state.go | 213 +++++ backend/browser_start_settings.go | 53 ++ backend/cmd/profile-recover/main.go | 857 ++++++++++++++++++ backend/internal/browser/types.go | 4 + backend/internal/config/config.go | 40 +- backend/internal/config/config_test.go | 22 + backend/internal/launchcode/auth.go | 93 ++ backend/internal/launchcode/profile_api.go | 575 ++++++++++++ backend/internal/launchcode/server.go | 113 ++- .../launchcode/server_auth_internal_test.go | 29 + backend/internal/tray/tray.go | 15 +- backend/internal/tray/tray_stub.go | 5 +- backend/test/launchcode/server_auth_test.go | 81 ++ .../launchcode/server_profile_create_test.go | 252 +++++ .../launchcode/server_profile_manage_test.go | 328 +++++++ backend/test/launchcode/server_proxy_test.go | 44 + bat/README.md | 88 +- bat/dev.bat | 528 +++++++++-- bat/publish.ps1 | 6 +- bat/recover-profiles.ps1 | 66 ++ frontend/package-lock.json | 21 +- frontend/package.json.md5 | 1 + frontend/scripts/dev-watcher.mjs | 236 ++++- frontend/src/App.tsx | 82 +- frontend/src/config/project.config.ts | 2 +- frontend/src/modules/browser/api.ts | 34 +- .../browser/components/CookieManagerCard.tsx | 21 +- .../browser/components/QuickLaunchModal.tsx | 9 +- .../modules/browser/pages/AutomationPage.tsx | 345 +++++-- .../browser/pages/BrowserDetailPage.tsx | 46 +- .../modules/browser/pages/BrowserEditPage.tsx | 43 +- .../modules/browser/pages/BrowserListPage.tsx | 89 +- .../browser/pages/CoreManagementPage.tsx | 38 + .../browser/pages/LaunchApiDocsPage.tsx | 405 ++++++++- .../browser/pages/UsageTutorialPage.tsx | 41 +- frontend/src/modules/browser/types.ts | 4 + .../src/modules/browser/utils/actionErrors.ts | 29 +- frontend/src/wailsjs/go/main/App.d.ts | 2 + frontend/src/wailsjs/go/main/App.js | 4 + frontend/src/wailsjs/go/models.ts | 8 + frontend/vite.config.ts | 25 +- main.go | 82 +- publish/linux/publish-linux.sh | 4 +- scripts/run-limited-frontend-dev.ps1 | 143 +++ wails.json | 2 - 58 files changed, 6106 insertions(+), 464 deletions(-) create mode 100644 backend/app_browser_settings_test.go create mode 100644 backend/browser_launch_args.go create mode 100644 backend/browser_process_monitor.go create mode 100644 backend/browser_runtime_state.go create mode 100644 backend/browser_start_settings.go create mode 100644 backend/cmd/profile-recover/main.go create mode 100644 backend/internal/launchcode/auth.go create mode 100644 backend/internal/launchcode/profile_api.go create mode 100644 backend/internal/launchcode/server_auth_internal_test.go create mode 100644 backend/test/launchcode/server_auth_test.go create mode 100644 backend/test/launchcode/server_profile_create_test.go create mode 100644 backend/test/launchcode/server_profile_manage_test.go create mode 100644 bat/recover-profiles.ps1 create mode 100644 frontend/package.json.md5 create mode 100644 scripts/run-limited-frontend-dev.ps1 diff --git a/.gitignore b/.gitignore index 0c57f943..90b276f7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ Thumbs.db # Logs *.log *.err +tmp-frontend-limited-watcher.pid npm-debug.log* yarn-debug.log* yarn-error.log* @@ -83,6 +84,7 @@ __pycache__/ # Test outputs / coverage coverage/ *.out +tmp-debug-port*/ # Env files .env diff --git a/README.md b/README.md index 453b838d..6c1a61ab 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,18 @@ Ant Browser 适合以下场景: 1. 开发默认使用 `master` 分支;该分支不带测试用户数据,适合作为日常开发基线。 2. 如需带测试库的演示环境,请切换到 `user_data` 分支。 -3. Windows 执行 `bat\dev.bat`;Linux 直接执行 `wails dev` 启动项目。 +3. Windows 统一执行 `bat\dev.bat`;默认是稳定模式,如需前端 HMR 联调使用 `bat\dev.bat live`,如需受限内存复现使用 `bat\dev.bat limited`。 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)。 +开发模式说明: + +- `bat\dev.bat`:默认稳定模式,先构建 `frontend/dist`,再以静态资源模式启动 Wails,不依赖外部 Vite dev server +- `bat\dev.bat live`:显式启动 Vite watcher,并通过 `-frontenddevserverurl` 接入桌面壳 +- `bat\dev.bat limited`:在 `live` 基础上为 watcher 与其子进程附加 Windows Job Object 内存限制 +- 如需为依赖下载配置代理,可在启动前设置 `DEV_PROXY_URL`、`DEV_NO_PROXY`、`DEV_GOPROXY` + ### Linux 发布打包(源码) Linux 发布脚本位于 `publish/linux/`。 diff --git a/backend/app.go b/backend/app.go index 3471d01d..9e7c4dde 100644 --- a/backend/app.go +++ b/backend/app.go @@ -24,6 +24,13 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" ) +type quitMode uint8 + +const ( + quitModeFull quitMode = iota + quitModeAppOnly +) + // App 应用结构体 type App struct { ctx context.Context @@ -41,6 +48,7 @@ type App struct { version string forceQuit bool // 强制退出标志,用于跳过 OnBeforeClose 的拦截 + quitMode quitMode // 退出模式:全量退出 / 仅退出应用 maintenanceMu sync.Mutex // 维护类操作(初始化/导入/导出)互斥锁 bridgeMu sync.Mutex xrayBridgeRefs map[string]string @@ -183,6 +191,11 @@ func (a *App) startup(ctx context.Context) { // 启动 LaunchServer port := a.config.LaunchServer.Port a.launchServer = launchcode.NewLaunchServer(a.launchCodeSvc, a, a.browserMgr, port) + a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ + Enabled: a.config.LaunchServer.Auth.Enabled, + APIKey: a.config.LaunchServer.Auth.APIKey, + Header: a.config.LaunchServer.Auth.Header, + }) if err := a.launchServer.Start(); err != nil { log.Error("LaunchServer 启动失败", logger.F("error", err)) } else { @@ -254,6 +267,13 @@ func (a *App) ReloadConfig() error { if a.singboxMgr != nil { a.singboxMgr.Config = cfg } + if a.launchServer != nil { + a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ + Enabled: cfg.LaunchServer.Auth.Enabled, + APIKey: cfg.LaunchServer.Auth.APIKey, + Header: cfg.LaunchServer.Auth.Header, + }) + } log.Info("前端触发配置重载成功") return nil @@ -274,8 +294,12 @@ func (a *App) applyRuntimeConfig(cfg config.RuntimeConfig) { func (a *App) shutdown(ctx context.Context) { log := logger.New("App") - log.Info("应用正在关闭...") - a.stopRuntimeServices() + if a.shouldStopRuntimeServicesOnShutdown() { + log.Info("应用正在关闭...") + a.stopRuntimeServices() + } else { + log.Info("应用正在关闭(保留当前已打开的浏览器实例)...") + } a.finalizeShutdown() } @@ -285,13 +309,21 @@ func (a *App) GetInterceptor() *logger.MethodInterceptor { // ForceQuit 设置强制退出标志并调用 runtime.Quit func (a *App) ForceQuit() { - a.forceQuit = true + a.setQuitMode(quitModeFull) a.stopRuntimeServices() if a.ctx != nil { runtime.Quit(a.ctx) } } +// QuitAppOnly 仅退出应用本身,保留当前已打开的浏览器实例。 +func (a *App) QuitAppOnly() { + a.setQuitMode(quitModeAppOnly) + if a.ctx != nil { + runtime.Quit(a.ctx) + } +} + func Start(a *App, ctx context.Context) { a.startup(ctx) } @@ -308,6 +340,15 @@ func platformSupportsTrayCloseFlowForOS(goos string) bool { return strings.EqualFold(strings.TrimSpace(goos), "windows") } +func (a *App) setQuitMode(mode quitMode) { + a.forceQuit = true + a.quitMode = mode +} + +func (a *App) shouldStopRuntimeServicesOnShutdown() bool { + return a.quitMode != quitModeAppOnly +} + func ShouldBlockClose(a *App, ctx context.Context) bool { if a.forceQuit { return false @@ -490,6 +531,8 @@ func (a *App) GetBrowserSettings() BrowserSettings { DefaultFingerprintArgs: append([]string{}, a.config.Browser.DefaultFingerprintArgs...), DefaultLaunchArgs: append([]string{}, a.config.Browser.DefaultLaunchArgs...), DefaultProxy: a.config.Browser.DefaultProxy, + StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config), + StartStableWindowMs: browserStartStableWindowMillis(a.config), } } @@ -499,6 +542,16 @@ func (a *App) SaveBrowserSettings(settings BrowserSettings) error { a.config.Browser.DefaultFingerprintArgs = append([]string{}, settings.DefaultFingerprintArgs...) a.config.Browser.DefaultLaunchArgs = append([]string{}, settings.DefaultLaunchArgs...) a.config.Browser.DefaultProxy = strings.TrimSpace(settings.DefaultProxy) + if settings.StartReadyTimeoutMs > 0 { + a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs + } else if a.config.Browser.StartReadyTimeoutMs <= 0 { + a.config.Browser.StartReadyTimeoutMs = browserStartReadyTimeoutMillis(nil) + } + if settings.StartStableWindowMs > 0 { + a.config.Browser.StartStableWindowMs = settings.StartStableWindowMs + } else if a.config.Browser.StartStableWindowMs <= 0 { + a.config.Browser.StartStableWindowMs = browserStartStableWindowMillis(nil) + } if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil { log.Error("浏览器配置保存失败", logger.F("error", err)) return err diff --git a/backend/app_browser_settings_test.go b/backend/app_browser_settings_test.go new file mode 100644 index 00000000..4705f709 --- /dev/null +++ b/backend/app_browser_settings_test.go @@ -0,0 +1,68 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "testing" +) + +func TestBrowserStartTimingSettingsUsesDefaultsWhenUnset(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.StartReadyTimeoutMs = 0 + cfg.Browser.StartStableWindowMs = -1 + + readyMs := browserStartReadyTimeoutMillis(cfg) + stableMs := browserStartStableWindowMillis(cfg) + + if readyMs != 3000 { + t.Fatalf("expected default ready timeout 3000ms, got %d", readyMs) + } + if stableMs != 1200 { + t.Fatalf("expected default stable window 1200ms, got %d", stableMs) + } +} + +func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Browser.StartReadyTimeoutMs = 15000 + app.config.Browser.StartStableWindowMs = 2400 + + if err := app.SaveBrowserSettings(BrowserSettings{ + UserDataRoot: app.config.Browser.UserDataRoot, + DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), + DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), + DefaultProxy: app.config.Browser.DefaultProxy, + }); err != nil { + t.Fatalf("SaveBrowserSettings returned error: %v", err) + } + + if app.config.Browser.StartReadyTimeoutMs != 15000 { + t.Fatalf("expected ready timeout to be preserved, got %d", app.config.Browser.StartReadyTimeoutMs) + } + if app.config.Browser.StartStableWindowMs != 2400 { + t.Fatalf("expected stable window to be preserved, got %d", app.config.Browser.StartStableWindowMs) + } +} + +func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + + if err := app.SaveBrowserSettings(BrowserSettings{ + UserDataRoot: app.config.Browser.UserDataRoot, + DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), + DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), + DefaultProxy: app.config.Browser.DefaultProxy, + StartReadyTimeoutMs: 18000, + StartStableWindowMs: 3000, + }); err != nil { + t.Fatalf("SaveBrowserSettings returned error: %v", err) + } + + if app.config.Browser.StartReadyTimeoutMs != 18000 { + t.Fatalf("expected ready timeout 18000ms, got %d", app.config.Browser.StartReadyTimeoutMs) + } + if app.config.Browser.StartStableWindowMs != 3000 { + t.Fatalf("expected stable window 3000ms, got %d", app.config.Browser.StartStableWindowMs) + } +} diff --git a/backend/app_close_behavior_test.go b/backend/app_close_behavior_test.go index 84ea3896..c920e74d 100644 --- a/backend/app_close_behavior_test.go +++ b/backend/app_close_behavior_test.go @@ -1,6 +1,8 @@ package backend import ( + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" "context" goruntime "runtime" "testing" @@ -25,3 +27,63 @@ func TestShouldBlockClose_NonWindowsDoesNotIntercept(t *testing.T) { t.Fatal("expected non-Windows close to proceed without interception") } } + +func TestQuitAppOnlyKeepsTrackedBrowsers(t *testing.T) { + app := NewApp("") + app.browserMgr = browser.NewManager(config.DefaultConfig(), "") + app.browserMgr.Profiles = map[string]*BrowserProfile{ + "profile-1": { + ProfileId: "profile-1", + Running: true, + }, + } + app.browserMgr.BrowserProcesses["profile-1"] = nil + + app.QuitAppOnly() + + if !app.forceQuit { + t.Fatal("expected QuitAppOnly to set forceQuit") + } + if app.quitMode != quitModeAppOnly { + t.Fatalf("expected quitModeAppOnly, got %v", app.quitMode) + } + if app.shouldStopRuntimeServicesOnShutdown() { + t.Fatal("expected app-only quit to skip runtime service shutdown") + } + if _, ok := app.browserMgr.BrowserProcesses["profile-1"]; !ok { + t.Fatal("expected tracked browser to remain untouched before process shutdown") + } + if !app.browserMgr.Profiles["profile-1"].Running { + t.Fatal("expected app-only quit to keep running profile state intact") + } +} + +func TestForceQuitStopsTrackedBrowsers(t *testing.T) { + app := NewApp("") + app.browserMgr = browser.NewManager(config.DefaultConfig(), "") + app.browserMgr.Profiles = map[string]*BrowserProfile{ + "profile-1": { + ProfileId: "profile-1", + Running: true, + }, + } + app.browserMgr.BrowserProcesses["profile-1"] = nil + + app.ForceQuit() + + if !app.forceQuit { + t.Fatal("expected ForceQuit to set forceQuit") + } + if app.quitMode != quitModeFull { + t.Fatalf("expected quitModeFull, got %v", app.quitMode) + } + if !app.shouldStopRuntimeServicesOnShutdown() { + t.Fatal("expected full quit to stop runtime services") + } + if _, ok := app.browserMgr.BrowserProcesses["profile-1"]; ok { + t.Fatal("expected ForceQuit to clear tracked browser processes") + } + if app.browserMgr.Profiles["profile-1"].Running { + t.Fatal("expected ForceQuit to mark the profile as stopped") + } +} diff --git a/backend/app_cookie.go b/backend/app_cookie.go index 08443e18..286e7abf 100644 --- a/backend/app_cookie.go +++ b/backend/app_cookie.go @@ -158,8 +158,11 @@ func (a *App) getDebugPort(profileId string) (int, error) { if !exists { return 0, fmt.Errorf("profile not found: %s", profileId) } - if !profile.Running || profile.DebugPort == 0 { - return 0, fmt.Errorf("实例未运行或调试端口不可用") + if !profile.Running { + return 0, fmt.Errorf("实例未运行") + } + if profile.DebugPort == 0 || !profile.DebugReady { + return 0, fmt.Errorf("实例调试接口尚未就绪,请稍后重试") } return profile.DebugPort, nil } diff --git a/backend/app_instance.go b/backend/app_instance.go index d0c371c1..2025c5cd 100644 --- a/backend/app_instance.go +++ b/backend/app_instance.go @@ -46,7 +46,7 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s return nil, err } if profile.Running { - if !isBrowserProfileLive(profile) { + if !isBrowserProfileLive(profile, a.browserMgr.BrowserProcesses[profileId]) { log.Info("检测到实例运行状态已失效,准备重新启动", logger.F("profile_id", profileId), logger.F("pid", profile.Pid), @@ -67,21 +67,17 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s return profile, startErr } } - if a.launchServer != nil { + if a.launchServer != nil && profile.DebugReady { 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, - }) - } + a.emitBrowserInstanceStarted(profile, true) return profile, nil } } + sanitizedProfileLaunchArgs, managedProfileArgs := sanitizeManagedLaunchArgs(profile.LaunchArgs) + sanitizedExtraLaunchArgs, managedExtraArgs := sanitizeManagedLaunchArgs(normalizedExtraLaunchArgs) + logManagedLaunchArgOverrides(log, profileId, "profile.launchArgs", managedProfileArgs) + logManagedLaunchArgOverrides(log, profileId, "start.extraLaunchArgs", managedExtraArgs) proxyChanged := a.browserMgr.ApplyDefaults(profile) if proxyChanged { @@ -91,7 +87,7 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile) if err != nil { startErr := fmt.Errorf("实例启动失败:%w", err) - log.Error("内核路径解析失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error())) + log.Error("内核路径解析失败", logger.F("profile_id", profileId), logger.F("error", err.Error()), logger.F("reason", startErr.Error())) profile.LastError = startErr.Error() return profile, startErr } @@ -99,7 +95,7 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s userDataDir := a.browserMgr.ResolveUserDataDir(profile) if err := os.MkdirAll(userDataDir, 0755); err != nil { startErr := fmt.Errorf("实例启动失败:无法创建用户数据目录 %s。原因:%w。请检查目录权限或路径配置。", userDataDir, err) - log.Error("用户数据目录创建失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err), logger.F("reason", startErr.Error())) + log.Error("用户数据目录创建失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err.Error()), logger.F("reason", startErr.Error())) profile.LastError = startErr.Error() return profile, startErr } @@ -181,17 +177,21 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s log.Info("xray 桥接成功", logger.F("socks_url", socksURL)) } - debugPort, err := nextAvailablePort() + startReadyTimeout, startStableWindow := a.browserStartTimingSettings() + maxStartAttempts := browserStartAttemptCount() + totalReadyTimeout := time.Duration(maxStartAttempts) * startReadyTimeout + var lastStartErr error + assignedDebugPort, err := nextAvailablePort() if err != nil { startErr := fmt.Errorf("实例启动失败:本地调试端口分配失败。原因:%v。请关闭占用端口的程序后重试。", err) - log.Error("调试端口分配失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error())) + log.Error("调试端口分配失败", logger.F("profile_id", profileId), logger.F("error", err.Error()), logger.F("reason", startErr.Error())) profile.LastError = startErr.Error() return profile, startErr } args := []string{ fmt.Sprintf("--user-data-dir=%s", userDataDir), - fmt.Sprintf("--remote-debugging-port=%d", debugPort), + fmt.Sprintf("--remote-debugging-port=%d", assignedDebugPort), "--disable-session-crashed-bubble", } @@ -220,56 +220,110 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy)) } args = append(args, profile.FingerprintArgs...) - args = append(args, profile.LaunchArgs...) - args = append(args, normalizedExtraLaunchArgs...) + args = append(args, sanitizedProfileLaunchArgs...) + args = append(args, sanitizedExtraLaunchArgs...) args = appendLaunchTargets(args, profile, normalizedStartURLs, skipDefaultStartURLs) cmd := exec.Command(chromeBinaryPath, args...) cmd.Dir = filepath.Dir(chromeBinaryPath) + monitor, err := newBrowserProcessMonitor(cmd) + if err != nil { + startErr := fmt.Errorf("实例启动失败:无法建立浏览器错误输出捕获。可执行文件:%s。原因:%v。", chromeBinaryPath, err) + log.Error("浏览器错误输出捕获初始化失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err.Error()), logger.F("reason", startErr.Error())) + profile.LastError = startErr.Error() + return profile, startErr + } if err := cmd.Start(); err != nil { startErr := fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) - log.Error("浏览器进程启动失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error())) + log.Error("浏览器进程启动失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err.Error()), logger.F("reason", startErr.Error())) profile.LastError = startErr.Error() return profile, startErr } - 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) - go func() { - _ = cmd.Wait() - }() - profile.LastError = startErr.Error() - return profile, startErr + monitor.Start() + + for attempt := 1; attempt <= maxStartAttempts; attempt++ { + stableDebugPort, readyErr := waitBrowserDebugPortStable(assignedDebugPort, userDataDir, startReadyTimeout, startStableWindow, monitor) + if readyErr == nil { + a.markProfileRunningLocked(profileId, profile, cmd, cmd.Process.Pid, stableDebugPort, true, "") + if acquiredXrayBridgeKey != "" { + a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) + releaseXrayBridge = false + } + + log.Info("实例启动", + logger.F("profile_id", profileId), + logger.F("debug_port", stableDebugPort), + logger.F("pid", profile.Pid), + logger.F("proxy", effectiveProxy), + logger.F("attempt", attempt), + logger.F("max_attempts", maxStartAttempts), + logger.F("args", strings.Join(args, " ")), + ) + a.emitBrowserInstanceStarted(profile, false) + + go a.waitBrowserProcess(profileId, monitor) + return profile, nil + } + + startErr := fmt.Errorf("%s", describeBrowserReadyFailure(chromeBinaryPath, assignedDebugPort, totalReadyTimeout, readyErr)) + lastStartErr = startErr + log.Error("浏览器启动未就绪", + logger.F("profile_id", profileId), + logger.F("chrome", chromeBinaryPath), + logger.F("debug_port", assignedDebugPort), + logger.F("attempt", attempt), + logger.F("max_attempts", maxStartAttempts), + logger.F("error", readyErr.Error()), + logger.F("reason", startErr.Error()), + ) + + if attempt < maxStartAttempts && shouldRetryBrowserReadyFailure(readyErr) { + log.Warn("浏览器启动未就绪,继续检测", + logger.F("profile_id", profileId), + logger.F("debug_port", assignedDebugPort), + logger.F("attempt", attempt), + logger.F("next_attempt", attempt+1), + logger.F("max_attempts", maxStartAttempts), + logger.F("timeout_ms", startReadyTimeout.Milliseconds()), + ) + continue + } + + break } - a.browserMgr.BrowserProcesses[profileId] = cmd - profile.Running = true - profile.DebugPort = debugPort - profile.Pid = cmd.Process.Pid - profile.LastStartAt = time.Now().Format(time.RFC3339) - profile.LastError = "" - if acquiredXrayBridgeKey != "" { - a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) - releaseXrayBridge = false - } - if a.launchServer != nil { - a.launchServer.SetActiveProfile(profile) + pendingStartNotice := "" + if shouldKeepBrowserRunningPendingDebugReady(assignedDebugPort, monitor) { + runtimeWarning := browserDebugPendingWarning(totalReadyTimeout) + pendingStartNotice = browserDebugPendingStartNotice(totalReadyTimeout) + a.markProfileRunningLocked(profileId, profile, cmd, cmd.Process.Pid, assignedDebugPort, false, runtimeWarning) + if acquiredXrayBridgeKey != "" { + a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey) + releaseXrayBridge = false + } + + log.Warn("浏览器窗口已启动,但调试接口在等待窗口内未就绪,转入后台附着", + logger.F("profile_id", profileId), + logger.F("debug_port", assignedDebugPort), + logger.F("pid", profile.Pid), + logger.F("max_attempts", maxStartAttempts), + logger.F("warning", runtimeWarning), + ) + a.emitBrowserInstanceStarted(profile, false) + go a.waitBrowserProcess(profileId, monitor) + go a.waitBrowserDebugReadyAsync(profileId, assignedDebugPort, browserAsyncDebugAttachTimeout) } - 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", map[string]interface{}{ - "profileId": profile.ProfileId, - "profileName": profile.ProfileName, - "debugPort": profile.DebugPort, - "pid": profile.Pid, - "reused": false, - }) + if pendingStartNotice != "" { + profile.LastError = pendingStartNotice + return profile, fmt.Errorf("%s", pendingStartNotice) } - go a.waitBrowserProcess(profileId, cmd) - return profile, nil + if lastStartErr != nil { + profile.LastError = lastStartErr.Error() + return profile, lastStartErr + } + return profile, fmt.Errorf("实例启动失败:浏览器在等待窗口内仍未就绪") } func (a *App) BrowserInstanceStop(profileId string) (*BrowserProfile, error) { @@ -475,8 +529,8 @@ func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab { } } -func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) { - err := cmd.Wait() +func (a *App) waitBrowserProcess(profileId string, monitor *browserProcessMonitor) { + err := monitor.Wait() log := logger.New("Browser") debugPort := 0 @@ -492,10 +546,21 @@ func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) { } a.browserMgr.Mutex.Unlock() - if wasRunning && debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) { + if wasRunning && debugPort > 0 { + snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, browserLauncherDetachGraceWindow) + if snapshot != nil { + if changed { + log.Info("浏览器启动器进程退出后,调试接口延迟就绪", + logger.F("profile_id", profileId), + logger.F("debug_port", debugPort), + ) + a.emitBrowserInstanceUpdated(snapshot) + } + } + a.browserMgr.Mutex.Lock() profile, exists = a.browserMgr.Profiles[profileId] - if exists && profile.Running && profile.DebugPort == debugPort { + if exists && profile.Running && profile.DebugPort == debugPort && profile.DebugReady && canConnectDebugPort(debugPort, 250*time.Millisecond) { delete(a.browserMgr.BrowserProcesses, profileId) profile.Pid = 0 shouldMonitorDetached = true @@ -635,20 +700,15 @@ func appendLaunchTargets(args []string, profile *BrowserProfile, startURLs []str 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.DebugReady = false profile.Pid = 0 profile.DebugPort = 0 + profile.RuntimeWarning = "" profile.LastStopAt = time.Now().Format(time.RFC3339) delete(a.browserMgr.BrowserProcesses, profileId) a.releaseProfileXrayBridge(profileId) @@ -671,7 +731,9 @@ func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraL args := []string{ fmt.Sprintf("--user-data-dir=%s", userDataDir), } - args = append(args, extraLaunchArgs...) + sanitizedExtraLaunchArgs, managedExtraArgs := sanitizeManagedLaunchArgs(extraLaunchArgs) + logManagedLaunchArgOverrides(logger.New("Browser"), profile.ProfileId, "running-window.extraLaunchArgs", managedExtraArgs) + args = append(args, sanitizedExtraLaunchArgs...) if len(startURLs) > 0 { args = append(args, startURLs...) } else { diff --git a/backend/app_instance_errors.go b/backend/app_instance_errors.go index 5174b63a..9215a3fc 100644 --- a/backend/app_instance_errors.go +++ b/backend/app_instance_errors.go @@ -1,43 +1,259 @@ package backend import ( + "encoding/json" + "errors" "fmt" + "io" "net" + "net/http" + "os" + "path/filepath" + "strconv" "strings" "time" ) const browserStartReadyTimeout = 10 * time.Second const browserStartStableWindow = 1200 * time.Millisecond +const browserDebugProbeTimeout = 250 * time.Millisecond -func waitBrowserDebugPortReady(debugPort int, timeout time.Duration) error { +var errBrowserDebugPortPending = errors.New("browser debug port pending") + +type browserStartupExitError struct { + exitErr error + stderrTail string +} + +func (e *browserStartupExitError) Error() string { + detail := e.Detail() + if detail == "" && e.exitErr != nil { + detail = strings.TrimSpace(e.exitErr.Error()) + } + if detail == "" { + return "browser process exited before ready" + } + return fmt.Sprintf("browser process exited before ready: %s", detail) +} + +func (e *browserStartupExitError) Detail() string { + lines := strings.Split(strings.TrimSpace(e.stderrTail), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line != "" { + return line + } + } + return "" +} + +func newBrowserStartupExitError(result browserProcessExitResult) error { + return &browserStartupExitError{ + exitErr: result.Err, + stderrTail: result.StderrTail, + } +} + +func waitBrowserDebugPortReady(initialDebugPort int, userDataDir string, timeout time.Duration, monitor *browserProcessMonitor) (int, error) { deadline := time.Now().Add(timeout) + allowDetachedGrace := initialDebugPort > 0 + var lastErr error + var exitResult browserProcessExitResult + exitObserved := false for time.Now().Before(deadline) { - if canConnectDebugPort(debugPort, 250*time.Millisecond) { - return nil + debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor) + if resolveErr == nil { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return debugPort, nil + } else { + lastErr = err + } + } else if !errors.Is(resolveErr, errBrowserDebugPortPending) { + lastErr = resolveErr + } + if monitor != nil && monitor.HasExited() { + if !exitObserved { + exitResult = monitor.Result() + exitObserved = true + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(exitResult) + } + exitDeadline := time.Now().Add(browserLauncherDetachGraceWindow) + if exitDeadline.After(deadline) { + deadline = exitDeadline + } + } } time.Sleep(150 * time.Millisecond) } + if !exitObserved && monitor != nil && monitor.HasExited() { + exitResult = monitor.Result() + exitObserved = true + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(exitResult) + } + postExitDeadline := time.Now().Add(browserLauncherDetachGraceWindow) + for time.Now().Before(postExitDeadline) { + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return debugPort, nil + } + } + time.Sleep(150 * time.Millisecond) + } + } + if exitObserved { + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return debugPort, nil + } + } + return 0, newBrowserStartupExitError(exitResult) + } + if lastErr != nil { + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪:%w", timeout.Round(time.Second), debugPort, lastErr) + } + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,尚未获取调试端口:%w", timeout.Round(time.Second), lastErr) + } - return fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort) + if debugPort, resolveErr := resolveBrowserDebugPort(initialDebugPort, userDataDir, monitor); resolveErr == nil { + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort) + } + + return 0, fmt.Errorf("浏览器进程未在 %s 内完成启动,尚未获取调试端口", timeout.Round(time.Second)) } -func waitBrowserDebugPortStable(debugPort int, timeout time.Duration, stableFor time.Duration) error { - if err := waitBrowserDebugPortReady(debugPort, timeout); err != nil { - return err +func waitBrowserDebugPortStable(initialDebugPort int, userDataDir string, timeout time.Duration, stableFor time.Duration, monitor *browserProcessMonitor) (int, error) { + debugPort, err := waitBrowserDebugPortReady(initialDebugPort, userDataDir, timeout, monitor) + if err != nil { + return 0, err } if stableFor <= 0 { - return nil + return debugPort, nil } + allowDetachedGrace := initialDebugPort > 0 deadline := time.Now().Add(stableFor) for time.Now().Before(deadline) { - if !canConnectDebugPort(debugPort, 250*time.Millisecond) { - return fmt.Errorf("浏览器调试端口 %d 短暂就绪后又失效", debugPort) + if monitor != nil && monitor.HasExited() { + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(monitor.Result()) + } + } + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err != nil { + if monitor != nil && monitor.HasExited() { + if !allowDetachedGrace { + return 0, newBrowserStartupExitError(monitor.Result()) + } + } + return 0, fmt.Errorf("浏览器调试端口 %d 短暂就绪后又失效:%w", debugPort, err) } time.Sleep(150 * time.Millisecond) } + return debugPort, nil +} + +func resolveBrowserDebugPort(initialDebugPort int, userDataDir string, monitor *browserProcessMonitor) (int, error) { + if initialDebugPort > 0 { + return initialDebugPort, nil + } + if monitor != nil { + if debugPort, ok := monitor.DebugPort(); ok { + return debugPort, nil + } + } + if debugPort, err := readBrowserDebugPortFile(userDataDir); err == nil { + if monitor != nil { + monitor.SetDebugPort(debugPort) + } + return debugPort, nil + } else if !errors.Is(err, errBrowserDebugPortPending) { + return 0, err + } + return 0, errBrowserDebugPortPending +} + +func readBrowserDebugPortFile(userDataDir string) (int, error) { + userDataDir = strings.TrimSpace(userDataDir) + if userDataDir == "" { + return 0, errBrowserDebugPortPending + } + + data, err := os.ReadFile(filepath.Join(userDataDir, "DevToolsActivePort")) + if err != nil { + if os.IsNotExist(err) { + return 0, errBrowserDebugPortPending + } + return 0, fmt.Errorf("读取 DevToolsActivePort 失败: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + return 0, errBrowserDebugPortPending + } + + port, err := strconv.Atoi(strings.TrimSpace(lines[0])) + if err != nil || port <= 0 { + return 0, fmt.Errorf("DevToolsActivePort 内容无效: %q", lines[0]) + } + return port, nil +} + +func probeBrowserDebugPort(debugPort int, requestTimeout time.Duration) error { + if debugPort <= 0 { + return fmt.Errorf("invalid debug port %d", debugPort) + } + + client := &http.Client{Timeout: requestTimeout} + versionErr := probeBrowserJSONVersion(client, debugPort) + if versionErr == nil { + return nil + } + + listErr := probeBrowserJSONList(client, debugPort) + if listErr == nil { + return nil + } + + return fmt.Errorf("%v; %v", versionErr, listErr) +} + +func probeBrowserJSONVersion(client *http.Client, debugPort int) error { + var payload struct { + Browser string `json:"Browser"` + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := fetchBrowserDebugJSON(client, debugPort, "/json/version", &payload); err != nil { + return err + } + if strings.TrimSpace(payload.Browser) == "" && strings.TrimSpace(payload.WebSocketDebuggerURL) == "" { + return fmt.Errorf("/json/version missing Browser and webSocketDebuggerUrl") + } + return nil +} + +func probeBrowserJSONList(client *http.Client, debugPort int) error { + var payload []map[string]interface{} + return fetchBrowserDebugJSON(client, debugPort, "/json/list", &payload) +} + +func fetchBrowserDebugJSON(client *http.Client, debugPort int, path string, dest interface{}) error { + url := fmt.Sprintf("http://127.0.0.1:%d%s", debugPort, path) + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("%s request failed: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", path, resp.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(resp.Body, 256*1024)) + if err := decoder.Decode(dest); err != nil { + return fmt.Errorf("%s returned invalid JSON: %w", path, err) + } return nil } @@ -85,5 +301,23 @@ func describeChromeProcessStartError(chromeBinaryPath string, err error) string } func describeBrowserReadyTimeout(debugPort int, timeout time.Duration) string { - return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,调试端口 %d 未开启。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second), debugPort) + if debugPort <= 0 { + return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,且未获取到调试端口。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second)) + } + return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,调试端口 %d 未就绪。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second), debugPort) +} + +func describeBrowserReadyFailure(chromeBinaryPath string, debugPort int, timeout time.Duration, err error) string { + var exitErr *browserStartupExitError + if errors.As(err, &exitErr) { + detail := exitErr.Detail() + if detail == "" && exitErr.exitErr != nil { + detail = strings.TrimSpace(exitErr.exitErr.Error()) + } + if detail != "" { + return fmt.Sprintf("实例启动失败:浏览器进程在完成就绪前退出。可执行文件:%s。原因:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath, detail) + } + return fmt.Sprintf("实例启动失败:浏览器进程在完成就绪前退出。可执行文件:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath) + } + return describeBrowserReadyTimeout(debugPort, timeout) } diff --git a/backend/app_instance_errors_test.go b/backend/app_instance_errors_test.go index 6616ee9d..c2505ef3 100644 --- a/backend/app_instance_errors_test.go +++ b/backend/app_instance_errors_test.go @@ -47,7 +47,60 @@ func TestDescribeChromeProcessStartError(t *testing.T) { func TestDescribeBrowserReadyTimeout(t *testing.T) { got := describeBrowserReadyTimeout(9222, 10*time.Second) - if !strings.Contains(got, "调试端口 9222 未开启") { + if !strings.Contains(got, "调试端口 9222 未就绪") { t.Fatalf("unexpected timeout message: %q", got) } } + +func TestDescribeBrowserReadyTimeoutWithoutPort(t *testing.T) { + got := describeBrowserReadyTimeout(0, 10*time.Second) + if !strings.Contains(got, "未获取到调试端口") { + t.Fatalf("unexpected timeout message: %q", got) + } +} + +func TestDescribeBrowserReadyFailureUsesExitDetail(t *testing.T) { + err := &browserStartupExitError{ + exitErr: fmt.Errorf("exit status 5"), + stderrTail: "sandbox initialization failed", + } + + got := describeBrowserReadyFailure(`C:\chrome.exe`, 9222, 10*time.Second, err) + if !strings.Contains(got, "sandbox initialization failed") { + t.Fatalf("expected exit detail in message, got %q", got) + } + if strings.Contains(got, "调试端口 9222 未就绪") { + t.Fatalf("expected exit detail message instead of timeout, got %q", got) + } +} + +func TestBrowserStartAttemptCountDefault(t *testing.T) { + if browserStartAttemptCount() != 5 { + t.Fatalf("expected default browser start attempts to be 5, got %d", browserStartAttemptCount()) + } +} + +func TestBrowserDebugPendingMessages(t *testing.T) { + warning := browserDebugPendingWarning(15 * time.Second) + if !strings.Contains(warning, "15 秒") || !strings.Contains(warning, "继续在后台连接") { + t.Fatalf("unexpected pending warning: %q", warning) + } + + notice := browserDebugPendingStartNotice(15 * time.Second) + if !strings.Contains(notice, "尚未完成接管") || !strings.Contains(notice, "稍后查看实例状态") { + t.Fatalf("unexpected pending start notice: %q", notice) + } +} + +func TestShouldRetryBrowserReadyFailure(t *testing.T) { + if !shouldRetryBrowserReadyFailure(fmt.Errorf("browser debug port 9222 not ready")) { + t.Fatal("expected timeout-like ready failure to be retryable") + } + + if shouldRetryBrowserReadyFailure(&browserStartupExitError{ + exitErr: fmt.Errorf("exit status 5"), + stderrTail: "missing libEGL.dll", + }) { + t.Fatal("expected process exit before ready to stop retrying") + } +} diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go index c9353a55..c4d8c3e5 100644 --- a/backend/app_instance_start_test.go +++ b/backend/app_instance_start_test.go @@ -3,10 +3,16 @@ package backend import ( "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" + "errors" + "fmt" "net" + "net/http" + "os" "os/exec" + "path/filepath" "reflect" goruntime "runtime" + "strings" "testing" "time" ) @@ -37,22 +43,56 @@ func TestIsBrowserProfileLive(t *testing.T) { Running: true, DebugPort: listenerPort(t, ln), } - if !isBrowserProfileLive(profile) { + if !isBrowserProfileLive(profile, nil) { t.Fatal("期望存活中的调试端口被识别为运行中实例") } - if isBrowserProfileLive(&BrowserProfile{Running: true, DebugPort: 0}) { + if isBrowserProfileLive(&BrowserProfile{Running: true, DebugPort: 0}, nil) { t.Fatal("debugPort=0 不应被识别为运行中实例") } } +func TestIsBrowserProfileLiveKeepsPendingDebugProcessAlive(t *testing.T) { + t.Parallel() + + cmd := longLivedCommand(2 * time.Second) + if err := cmd.Start(); err != nil { + t.Fatalf("启动长生命周期测试进程失败: %v", err) + } + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }() + + profile := &BrowserProfile{ + Running: true, + Pid: cmd.Process.Pid, + DebugPort: 0, + DebugReady: false, + } + if !isBrowserProfileLive(profile, cmd) { + t.Fatal("期望调试接口未就绪但进程仍存活时识别为运行中实例") + } +} + func TestWaitBrowserDebugPortStableKeepsListeningPort(t *testing.T) { t.Parallel() - ln := mustListenLoopback(t) - defer ln.Close() + server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) + case "/json/list": + _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() - if err := waitBrowserDebugPortStable(listenerPort(t, ln), time.Second, 250*time.Millisecond); err != nil { + if _, err := waitBrowserDebugPortStable(server.port, "", time.Second, 250*time.Millisecond, nil); err != nil { t.Fatalf("waitBrowserDebugPortStable 返回错误: %v", err) } } @@ -60,18 +100,172 @@ func TestWaitBrowserDebugPortStableKeepsListeningPort(t *testing.T) { func TestWaitBrowserDebugPortStableRejectsEphemeralPort(t *testing.T) { t.Parallel() - ln := mustListenLoopback(t) - port := listenerPort(t, ln) + server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) + case "/json/list": + _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) + default: + http.NotFound(w, r) + } + })) + port := server.port time.AfterFunc(120*time.Millisecond, func() { - _ = ln.Close() + _ = server.Close() }) - err := waitBrowserDebugPortStable(port, time.Second, 400*time.Millisecond) + _, err := waitBrowserDebugPortStable(port, "", time.Second, 400*time.Millisecond, nil) if err == nil { t.Fatal("期望短暂就绪后关闭的端口被判定为失败") } } +func TestWaitBrowserDebugPortStableRejectsPlainTCPPort(t *testing.T) { + t.Parallel() + + ln := mustListenLoopback(t) + defer ln.Close() + + _, err := waitBrowserDebugPortStable(listenerPort(t, ln), "", 700*time.Millisecond, 250*time.Millisecond, nil) + if err == nil { + t.Fatal("期望仅开放 TCP 端口但无 DevTools HTTP 时启动失败") + } +} + +func TestWaitBrowserDebugPortStableDiscoversPortFromStderr(t *testing.T) { + t.Parallel() + + server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) + case "/json/list": + _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cmd := stderrPortCommand(server.port, 2*time.Second) + monitor, err := newBrowserProcessMonitor(cmd) + if err != nil { + t.Fatalf("初始化浏览器进程监控失败: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("启动测试命令失败: %v", err) + } + monitor.Start() + + debugPort, err := waitBrowserDebugPortStable(0, "", 2*time.Second, 250*time.Millisecond, monitor) + if err != nil { + t.Fatalf("期望从 stderr 自动发现调试端口,实际错误: %v", err) + } + if debugPort != server.port { + t.Fatalf("期望发现调试端口 %d,实际=%d", server.port, debugPort) + } +} + +func TestWaitBrowserDebugPortStableDiscoversPortFromDevToolsFile(t *testing.T) { + t.Parallel() + + server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) + case "/json/list": + _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + userDataDir := t.TempDir() + writeDevToolsActivePortFile(t, userDataDir, server.port) + + debugPort, err := waitBrowserDebugPortStable(0, userDataDir, time.Second, 250*time.Millisecond, nil) + if err != nil { + t.Fatalf("期望从 DevToolsActivePort 自动发现调试端口,实际错误: %v", err) + } + if debugPort != server.port { + t.Fatalf("期望发现调试端口 %d,实际=%d", server.port, debugPort) + } +} + +func TestWaitBrowserDebugPortStableReturnsProcessExitDetail(t *testing.T) { + t.Parallel() + + cmd := stderrFailingCommand("missing libEGL.dll") + monitor, err := newBrowserProcessMonitor(cmd) + if err != nil { + t.Fatalf("初始化浏览器进程监控失败: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("启动测试命令失败: %v", err) + } + monitor.Start() + + startedAt := time.Now() + _, err = waitBrowserDebugPortStable(0, "", 2*time.Second, 250*time.Millisecond, monitor) + if err == nil { + t.Fatal("期望启动前退出被判定为失败") + } + if time.Since(startedAt) >= 2*time.Second { + t.Fatalf("期望在超时前返回进程退出错误,实际耗时=%s", time.Since(startedAt)) + } + + var exitErr *browserStartupExitError + if !errors.As(err, &exitErr) { + t.Fatalf("期望 browserStartupExitError,实际=%T %v", err, err) + } + if !strings.Contains(exitErr.Detail(), "missing libEGL.dll") { + t.Fatalf("期望 stderr 细节被捕获,实际=%q", exitErr.Detail()) + } +} + +func TestWaitBrowserDebugPortStableAllowsDebugPortAfterLauncherExit(t *testing.T) { + t.Parallel() + + port := freeLoopbackPort(t) + cmd := shortLivedCommand() + monitor, err := newBrowserProcessMonitor(cmd) + if err != nil { + t.Fatalf("初始化浏览器进程监控失败: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("启动短命测试命令失败: %v", err) + } + monitor.Start() + + serverReady := make(chan *devToolsTestServer, 1) + go func() { + time.Sleep(300 * time.Millisecond) + serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) + case "/json/list": + _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) + default: + http.NotFound(w, r) + } + })) + }() + + debugPort, err := waitBrowserDebugPortStable(port, "", 100*time.Millisecond, 250*time.Millisecond, monitor) + server := <-serverReady + defer server.Close() + + if err != nil { + t.Fatalf("期望启动器退出后仍能等待到调试端口就绪,实际错误: %v", err) + } + if debugPort != port { + t.Fatalf("期望发现调试端口 %d,实际=%d", port, debugPort) + } +} + func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) { ln := mustListenLoopback(t) port := listenerPort(t, ln) @@ -84,20 +278,26 @@ func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) { ProfileName: "Detached Browser", Running: true, DebugPort: port, + DebugReady: true, Pid: 12345, }, } app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) cmd := shortLivedCommand() + monitor, err := newBrowserProcessMonitor(cmd) + if err != nil { + t.Fatalf("初始化测试进程监控失败: %v", err) + } if err := cmd.Start(); err != nil { t.Fatalf("启动短命测试进程失败: %v", err) } + monitor.Start() app.browserMgr.BrowserProcesses["profile-detached"] = cmd done := make(chan struct{}) go func() { - app.waitBrowserProcess("profile-detached", cmd) + app.waitBrowserProcess("profile-detached", monitor) close(done) }() @@ -127,6 +327,99 @@ func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) { } } +func TestWaitForBrowserDebugReadyMarksProfileReady(t *testing.T) { + t.Parallel() + + port := freeLoopbackPort(t) + app := NewApp("") + app.browserMgr = browser.NewManager(config.DefaultConfig(), "") + app.browserMgr.Profiles = map[string]*BrowserProfile{ + "profile-ready": { + ProfileId: "profile-ready", + ProfileName: "Ready Browser", + Running: true, + DebugPort: port, + DebugReady: false, + RuntimeWarning: "pending", + LastStartAt: time.Now().Format(time.RFC3339), + }, + } + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + + serverReady := make(chan *devToolsTestServer, 1) + go func() { + time.Sleep(200 * time.Millisecond) + serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) + case "/json/list": + _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) + default: + http.NotFound(w, r) + } + })) + }() + + snapshot, changed := app.waitForBrowserDebugReady("profile-ready", port, 2*time.Second) + server := <-serverReady + defer server.Close() + + if snapshot == nil { + t.Fatal("期望等待到调试接口就绪") + } + if !changed { + t.Fatal("期望调试接口就绪后标记实例状态变更") + } + if !snapshot.DebugReady { + t.Fatal("期望实例被标记为调试接口已就绪") + } + if snapshot.RuntimeWarning != "" { + t.Fatalf("期望调试接口就绪后清空警告,实际=%q", snapshot.RuntimeWarning) + } +} + +func TestSanitizeManagedLaunchArgsRemovesSystemManagedFlags(t *testing.T) { + t.Parallel() + + got, removed := sanitizeManagedLaunchArgs([]string{ + "--lang=en-US", + "--remote-debugging-port=9222", + "--user-data-dir", "D:\\profiles\\demo", + "--proxy-server", "http://127.0.0.1:9000", + "--remote-debugging-pipe", + "https://example.com", + }) + + wantArgs := []string{"--lang=en-US", "https://example.com"} + if !reflect.DeepEqual(got, wantArgs) { + t.Fatalf("sanitizeManagedLaunchArgs args mismatch: got=%v want=%v", got, wantArgs) + } + + wantRemoved := []string{ + "--remote-debugging-port", + "--user-data-dir", + "--proxy-server", + "--remote-debugging-pipe", + } + if !reflect.DeepEqual(removed, wantRemoved) { + t.Fatalf("sanitizeManagedLaunchArgs removed mismatch: got=%v want=%v", removed, wantRemoved) + } +} + +func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) { + t.Parallel() + + input := []string{"--lang=en-US", "--disable-sync", "https://example.com"} + got, removed := sanitizeManagedLaunchArgs(input) + if !reflect.DeepEqual(got, input) { + t.Fatalf("sanitizeManagedLaunchArgs should preserve unmanaged args: got=%v want=%v", got, input) + } + if len(removed) != 0 { + t.Fatalf("sanitizeManagedLaunchArgs should not report managed args, got=%v", removed) + } +} + func mustListenLoopback(t *testing.T) net.Listener { t.Helper() @@ -165,6 +458,36 @@ func shortLivedCommand() *exec.Cmd { return exec.Command("sh", "-c", "exit 0") } +func longLivedCommand(duration time.Duration) *exec.Cmd { + if goruntime.GOOS == "windows" { + seconds := int(duration / time.Second) + if seconds < 1 { + seconds = 1 + } + return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1)) + } + return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds())) +} + +func stderrFailingCommand(message string) *exec.Cmd { + if goruntime.GOOS == "windows" { + return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message)) + } + return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message)) +} + +func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd { + if goruntime.GOOS == "windows" { + seconds := int(holdFor / time.Second) + if seconds < 1 { + seconds = 1 + } + // ping -n N waits roughly N-1 seconds on Windows. + return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1)) + } + return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds())) +} + func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { t.Helper() @@ -177,3 +500,80 @@ func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { } t.Fatal("等待条件成立超时") } + +func freeLoopbackPort(t *testing.T) int { + t.Helper() + + ln := mustListenLoopback(t) + port := listenerPort(t, ln) + _ = ln.Close() + return port +} + +type devToolsTestServer struct { + port int + server *http.Server + done chan struct{} +} + +func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("启动 DevTools 测试服务失败: %v", err) + } + + srv := &http.Server{Handler: handler} + done := make(chan struct{}) + go func() { + defer close(done) + _ = srv.Serve(ln) + }() + + return &devToolsTestServer{ + port: listenerPort(t, ln), + server: srv, + done: done, + } +} + +func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer { + t.Helper() + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err) + } + + srv := &http.Server{Handler: handler} + done := make(chan struct{}) + go func() { + defer close(done) + _ = srv.Serve(ln) + }() + + return &devToolsTestServer{ + port: port, + server: srv, + done: done, + } +} + +func (s *devToolsTestServer) Close() error { + if s == nil || s.server == nil { + return nil + } + err := s.server.Close() + <-s.done + return err +} + +func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) { + t.Helper() + + content := fmt.Sprintf("%d\n/devtools/browser/test\n", port) + if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil { + t.Fatalf("写入 DevToolsActivePort 失败: %v", err) + } +} diff --git a/backend/app_launchcode.go b/backend/app_launchcode.go index b46b6228..57d148a0 100644 --- a/backend/app_launchcode.go +++ b/backend/app_launchcode.go @@ -55,13 +55,26 @@ func (a *App) BrowserInstanceStartByCode(code string) (*browser.Profile, error) // GetLaunchServerInfo 返回 LaunchServer 的当前监听信息(Wails 绑定) func (a *App) GetLaunchServerInfo() map[string]interface{} { preferredPort := 0 + authRequested := false + authConfigured := false + authEnabled := false + authHeader := launchcode.DefaultAPIKeyHeader if a.config != nil { preferredPort = a.config.LaunchServer.Port + authRequested = a.config.LaunchServer.Auth.Enabled + authConfigured = a.config.LaunchServer.Auth.APIKey != "" + if header := a.config.LaunchServer.Auth.Header; header != "" { + authHeader = header + } } actualPort := 0 if a.launchServer != nil { actualPort = a.launchServer.Port() + authRequested = a.launchServer.APIAuthRequested() + authConfigured = a.launchServer.APIAuthConfigured() + authEnabled = a.launchServer.APIAuthEnabled() + authHeader = a.launchServer.APIAuthHeader() } info := map[string]interface{}{ @@ -69,6 +82,12 @@ func (a *App) GetLaunchServerInfo() map[string]interface{} { "preferredPort": preferredPort, "port": actualPort, "ready": actualPort > 0, + "apiAuth": map[string]interface{}{ + "requested": authRequested, + "configured": authConfigured, + "enabled": authEnabled, + "header": authHeader, + }, } if actualPort > 0 { info["baseUrl"] = fmt.Sprintf("http://127.0.0.1:%d", actualPort) diff --git a/backend/browser_launch_args.go b/backend/browser_launch_args.go new file mode 100644 index 00000000..2a671948 --- /dev/null +++ b/backend/browser_launch_args.go @@ -0,0 +1,80 @@ +package backend + +import ( + "ant-chrome/backend/internal/logger" + "strings" +) + +type managedLaunchArgSpec struct { + prefix string + takesValue bool +} + +var managedLaunchArgSpecs = []managedLaunchArgSpec{ + {prefix: "--user-data-dir", takesValue: true}, + {prefix: "--remote-debugging-port", takesValue: true}, + {prefix: "--remote-debugging-address", takesValue: true}, + {prefix: "--remote-debugging-pipe", takesValue: false}, + {prefix: "--proxy-server", takesValue: true}, +} + +func sanitizeManagedLaunchArgs(args []string) ([]string, []string) { + if len(args) == 0 { + return nil, nil + } + + sanitized := make([]string, 0, len(args)) + removed := make([]string, 0, 4) + + for i := 0; i < len(args); i++ { + arg := strings.TrimSpace(args[i]) + if arg == "" { + continue + } + + spec, matched := matchManagedLaunchArg(arg) + if !matched { + sanitized = append(sanitized, arg) + continue + } + + removed = appendUniqueString(removed, spec.prefix) + if spec.takesValue && !strings.Contains(arg, "=") && i+1 < len(args) { + next := strings.TrimSpace(args[i+1]) + if next != "" && !strings.HasPrefix(next, "-") { + i++ + } + } + } + + return sanitized, removed +} + +func matchManagedLaunchArg(arg string) (managedLaunchArgSpec, bool) { + for _, spec := range managedLaunchArgSpecs { + if strings.EqualFold(arg, spec.prefix) || strings.HasPrefix(strings.ToLower(arg), strings.ToLower(spec.prefix)+"=") { + return spec, true + } + } + return managedLaunchArgSpec{}, false +} + +func logManagedLaunchArgOverrides(log *logger.Logger, profileId string, source string, managedArgs []string) { + if log == nil || len(managedArgs) == 0 { + return + } + log.Warn("忽略由系统接管的浏览器启动参数", + logger.F("profile_id", profileId), + logger.F("source", source), + logger.F("managed_args", managedArgs), + ) +} + +func appendUniqueString(items []string, value string) []string { + for _, item := range items { + if strings.EqualFold(item, value) { + return items + } + } + return append(items, value) +} diff --git a/backend/browser_process_monitor.go b/backend/browser_process_monitor.go new file mode 100644 index 00000000..80dec455 --- /dev/null +++ b/backend/browser_process_monitor.go @@ -0,0 +1,217 @@ +package backend + +import ( + "bufio" + "fmt" + "io" + "net/url" + "os/exec" + "strconv" + "strings" + "sync" +) + +const ( + browserStderrTailMaxLines = 40 + browserStderrTailMaxBytes = 4 * 1024 +) + +type browserProcessExitResult struct { + Err error + StderrTail string +} + +type browserProcessMonitor struct { + cmd *exec.Cmd + stderr io.ReadCloser + stderrTail *tailTextBuffer + stderrDone chan struct{} + waitDone chan struct{} + + mu sync.Mutex + result browserProcessExitResult + debugPort int +} + +func newBrowserProcessMonitor(cmd *exec.Cmd) (*browserProcessMonitor, error) { + if cmd == nil { + return nil, fmt.Errorf("browser command is nil") + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + + return &browserProcessMonitor{ + cmd: cmd, + stderr: stderr, + stderrTail: newTailTextBuffer(browserStderrTailMaxLines, browserStderrTailMaxBytes), + stderrDone: make(chan struct{}), + waitDone: make(chan struct{}), + }, nil +} + +func (m *browserProcessMonitor) Start() { + go m.captureStderr() + go m.waitForExit() +} + +func (m *browserProcessMonitor) Done() <-chan struct{} { + return m.waitDone +} + +func (m *browserProcessMonitor) HasExited() bool { + select { + case <-m.waitDone: + return true + default: + return false + } +} + +func (m *browserProcessMonitor) Result() browserProcessExitResult { + <-m.waitDone + + m.mu.Lock() + defer m.mu.Unlock() + return m.result +} + +func (m *browserProcessMonitor) Wait() error { + return m.Result().Err +} + +func (m *browserProcessMonitor) DebugPort() (int, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if m.debugPort <= 0 { + return 0, false + } + return m.debugPort, true +} + +func (m *browserProcessMonitor) SetDebugPort(port int) { + if port <= 0 { + return + } + + m.mu.Lock() + if m.debugPort <= 0 { + m.debugPort = port + } + m.mu.Unlock() +} + +func (m *browserProcessMonitor) captureStderr() { + defer close(m.stderrDone) + + if m.stderr == nil { + return + } + defer m.stderr.Close() + + scanner := bufio.NewScanner(m.stderr) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + m.stderrTail.Append(line) + if port, ok := parseBrowserDebugPortFromStderrLine(line); ok { + m.SetDebugPort(port) + } + } + if err := scanner.Err(); err != nil { + m.stderrTail.Append(fmt.Sprintf("[stderr read error] %v", err)) + } +} + +func (m *browserProcessMonitor) waitForExit() { + err := m.cmd.Wait() + <-m.stderrDone + + m.mu.Lock() + m.result = browserProcessExitResult{ + Err: err, + StderrTail: m.stderrTail.String(), + } + m.mu.Unlock() + close(m.waitDone) +} + +type tailTextBuffer struct { + maxLines int + maxBytes int + + mu sync.Mutex + lines []string + totalBytes int +} + +func newTailTextBuffer(maxLines int, maxBytes int) *tailTextBuffer { + if maxLines <= 0 { + maxLines = 1 + } + if maxBytes <= 0 { + maxBytes = 1024 + } + + return &tailTextBuffer{ + maxLines: maxLines, + maxBytes: maxBytes, + } +} + +func (b *tailTextBuffer) Append(line string) { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return + } + if len(trimmed) > b.maxBytes { + trimmed = trimmed[len(trimmed)-b.maxBytes:] + } + + b.mu.Lock() + defer b.mu.Unlock() + + b.lines = append(b.lines, trimmed) + b.totalBytes += len(trimmed) + 1 + for len(b.lines) > b.maxLines || b.totalBytes > b.maxBytes { + if len(b.lines) == 0 { + b.totalBytes = 0 + break + } + b.totalBytes -= len(b.lines[0]) + 1 + b.lines = b.lines[1:] + } +} + +func (b *tailTextBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return strings.Join(b.lines, "\n") +} + +func parseBrowserDebugPortFromStderrLine(line string) (int, bool) { + const marker = "DevTools listening on " + + idx := strings.Index(line, marker) + if idx < 0 { + return 0, false + } + + rawURL := strings.TrimSpace(line[idx+len(marker):]) + if rawURL == "" { + return 0, false + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return 0, false + } + + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port <= 0 { + return 0, false + } + return port, true +} diff --git a/backend/browser_runtime_state.go b/backend/browser_runtime_state.go new file mode 100644 index 00000000..2f35ab38 --- /dev/null +++ b/backend/browser_runtime_state.go @@ -0,0 +1,213 @@ +package backend + +import ( + "fmt" + "os" + "os/exec" + stdruntime "runtime" + "syscall" + "time" + + "ant-chrome/backend/internal/logger" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const ( + browserAsyncDebugAttachTimeout = 45 * time.Second + browserLauncherDetachGraceWindow = 15 * time.Second +) + +func copyBrowserProfileSnapshot(profile *BrowserProfile) *BrowserProfile { + if profile == nil { + return nil + } + snapshot := *profile + return &snapshot +} + +func browserDebugPendingWarning(timeout time.Duration) string { + return fmt.Sprintf("浏览器窗口已启动,但调试接口在 %s 内仍未就绪;系统会继续在后台连接。连接完成前,Cookie、自动化和统一 CDP 入口暂不可用。", formatBrowserWaitWindow(timeout)) +} + +func browserDebugPendingStartNotice(timeout time.Duration) string { + return fmt.Sprintf("浏览器窗口已启动,但在 %s 内尚未完成接管;系统会继续在后台连接,请稍后查看实例状态。连接完成前,Cookie、自动化和统一 CDP 入口暂不可用。", formatBrowserWaitWindow(timeout)) +} + +func formatBrowserWaitWindow(timeout time.Duration) string { + if timeout <= 0 { + return "当前等待窗口" + } + + rounded := timeout.Round(100 * time.Millisecond) + if rounded%time.Second == 0 { + return fmt.Sprintf("%d 秒", rounded/time.Second) + } + if rounded%time.Millisecond == 0 { + return fmt.Sprintf("%d 毫秒", rounded/time.Millisecond) + } + return rounded.String() +} + +func browserInstanceEventPayload(profile *BrowserProfile, reused bool) map[string]interface{} { + if profile == nil { + return map[string]interface{}{} + } + return map[string]interface{}{ + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "debugPort": profile.DebugPort, + "debugReady": profile.DebugReady, + "pid": profile.Pid, + "reused": reused, + "running": profile.Running, + "runtimeWarning": profile.RuntimeWarning, + } +} + +func (a *App) emitBrowserInstanceStarted(profile *BrowserProfile, reused bool) { + if a == nil || a.ctx == nil || profile == nil { + return + } + runtime.EventsEmit(a.ctx, "browser:instance:started", browserInstanceEventPayload(profile, reused)) +} + +func (a *App) emitBrowserInstanceUpdated(profile *BrowserProfile) { + if a == nil || a.ctx == nil || profile == nil { + return + } + runtime.EventsEmit(a.ctx, "browser:instance:updated", browserInstanceEventPayload(profile, false)) +} + +func (a *App) markProfileRunningLocked(profileId string, profile *BrowserProfile, cmd *exec.Cmd, pid int, debugPort int, debugReady bool, runtimeWarning string) { + if profile == nil { + return + } + profile.Running = true + profile.DebugPort = debugPort + profile.DebugReady = debugReady + profile.Pid = pid + profile.LastStartAt = time.Now().Format(time.RFC3339) + profile.RuntimeWarning = runtimeWarning + profile.LastError = "" + if cmd != nil { + a.browserMgr.BrowserProcesses[profileId] = cmd + } + if debugReady && a.launchServer != nil { + a.launchServer.SetActiveProfile(profile) + } +} + +func (a *App) markProfileDebugReadyLocked(profile *BrowserProfile, debugPort int) { + if profile == nil { + return + } + profile.DebugPort = debugPort + profile.DebugReady = true + profile.RuntimeWarning = "" + profile.LastError = "" +} + +func (a *App) setProfileDebugReady(profileId string, debugPort int) (*BrowserProfile, bool) { + if a == nil || a.browserMgr == nil { + return nil, false + } + + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists || profile == nil || !profile.Running || profile.DebugPort != debugPort { + a.browserMgr.Mutex.Unlock() + return nil, false + } + + changed := !profile.DebugReady || profile.RuntimeWarning != "" + if changed { + a.markProfileDebugReadyLocked(profile, debugPort) + } + snapshot := copyBrowserProfileSnapshot(profile) + a.browserMgr.Mutex.Unlock() + + if snapshot != nil && snapshot.DebugReady && a.launchServer != nil { + a.launchServer.SetActiveProfile(snapshot) + } + return snapshot, changed +} + +func (a *App) waitForBrowserDebugReady(profileId string, debugPort int, timeout time.Duration) (*BrowserProfile, bool) { + if a == nil || a.browserMgr == nil || debugPort <= 0 || timeout <= 0 { + return nil, false + } + + deadline := time.Now().Add(timeout) + for { + a.browserMgr.Mutex.Lock() + profile, exists := a.browserMgr.Profiles[profileId] + if !exists || profile == nil || !profile.Running || profile.DebugPort != debugPort { + a.browserMgr.Mutex.Unlock() + return nil, false + } + if profile.DebugReady { + snapshot := copyBrowserProfileSnapshot(profile) + a.browserMgr.Mutex.Unlock() + return snapshot, false + } + a.browserMgr.Mutex.Unlock() + + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return a.setProfileDebugReady(profileId, debugPort) + } + if time.Now().After(deadline) { + return nil, false + } + time.Sleep(250 * time.Millisecond) + } +} + +func (a *App) waitBrowserDebugReadyAsync(profileId string, debugPort int, timeout time.Duration) { + snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, timeout) + if snapshot == nil || !changed { + return + } + + logger.New("Browser").Info("实例调试接口已就绪", + logger.F("profile_id", profileId), + logger.F("debug_port", debugPort), + ) + a.emitBrowserInstanceUpdated(snapshot) +} + +func shouldKeepBrowserRunningPendingDebugReady(debugPort int, monitor *browserProcessMonitor) bool { + return debugPort > 0 && monitor != nil && !monitor.HasExited() +} + +func isBrowserProfileLive(profile *BrowserProfile, trackedCmd *exec.Cmd) bool { + if profile == nil || !profile.Running { + return false + } + if profile.DebugPort > 0 && canConnectDebugPort(profile.DebugPort, 250*time.Millisecond) { + return true + } + if profile.Pid > 0 && isProcessAlive(profile.Pid) { + return true + } + if trackedCmd != nil && trackedCmd.Process != nil && trackedCmd.Process.Pid > 0 { + return isProcessAlive(trackedCmd.Process.Pid) + } + return false +} + +func isProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + if stdruntime.GOOS == "windows" { + alive, err := isProcessAliveWindows(pid) + return err == nil && alive + } + + process, err := os.FindProcess(pid) + if err != nil || process == nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} diff --git a/backend/browser_start_settings.go b/backend/browser_start_settings.go new file mode 100644 index 00000000..0585d726 --- /dev/null +++ b/backend/browser_start_settings.go @@ -0,0 +1,53 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "errors" + "time" +) + +const ( + defaultBrowserStartReadyTimeout = 3 * time.Second + defaultBrowserStartStableWindow = 1200 * time.Millisecond + defaultBrowserStartMaxAttempts = 5 +) + +func browserStartReadyTimeoutMillis(cfg *config.Config) int { + fallback := int(defaultBrowserStartReadyTimeout / time.Millisecond) + if cfg == nil { + return fallback + } + if cfg.Browser.StartReadyTimeoutMs > 0 { + return cfg.Browser.StartReadyTimeoutMs + } + return fallback +} + +func browserStartStableWindowMillis(cfg *config.Config) int { + fallback := int(defaultBrowserStartStableWindow / time.Millisecond) + if cfg == nil { + return fallback + } + if cfg.Browser.StartStableWindowMs > 0 { + return cfg.Browser.StartStableWindowMs + } + return fallback +} + +func (a *App) browserStartTimingSettings() (time.Duration, time.Duration) { + return time.Duration(browserStartReadyTimeoutMillis(a.config)) * time.Millisecond, + time.Duration(browserStartStableWindowMillis(a.config)) * time.Millisecond +} + +func browserStartAttemptCount() int { + return defaultBrowserStartMaxAttempts +} + +func shouldRetryBrowserReadyFailure(err error) bool { + if err == nil { + return false + } + + var exitErr *browserStartupExitError + return !errors.As(err, &exitErr) +} diff --git a/backend/cmd/profile-recover/main.go b/backend/cmd/profile-recover/main.go new file mode 100644 index 00000000..908917eb --- /dev/null +++ b/backend/cmd/profile-recover/main.go @@ -0,0 +1,857 @@ +package main + +import ( + "ant-chrome/backend/internal/apppath" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/database" + "database/sql" + "encoding/binary" + "encoding/json" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf16" + + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +type options struct { + appRoot string + configPath string + apply bool + repairStrategy string + namePrefix string + onlyDirs map[string]struct{} +} + +type selectedCore struct { + CoreID string `json:"coreId"` + CoreName string `json:"coreName"` + CorePath string `json:"corePath"` + BinaryPath string `json:"binaryPath"` + Source string `json:"source"` +} + +type repairResult struct { + TargetDirName string `json:"targetDirName"` + TargetPath string `json:"targetPath"` +} + +type candidateInspection struct { + LooksLikeBrowserData bool `json:"looksLikeBrowserData"` + Markers []string `json:"markers,omitempty"` + LastBrowser string `json:"lastBrowser,omitempty"` + LastVersion string `json:"lastVersion,omitempty"` + Risky bool `json:"risky"` + RiskReasons []string `json:"riskReasons,omitempty"` +} + +type reportEntry struct { + DirName string `json:"dirName"` + ResolvedPath string `json:"resolvedPath"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` + ExistingProfileID string `json:"existingProfileId,omitempty"` + ExistingProfileName string `json:"existingProfileName,omitempty"` + RestoredProfileID string `json:"restoredProfileId,omitempty"` + RestoredProfileName string `json:"restoredProfileName,omitempty"` + RegisteredUserDataDir string `json:"registeredUserDataDir,omitempty"` + Repair *repairResult `json:"repair,omitempty"` + Inspection candidateInspection `json:"inspection"` +} + +type reportSummary struct { + Scanned int `json:"scanned"` + Candidates int `json:"candidates"` + Existing int `json:"existing"` + Restored int `json:"restored"` + RepairCopies int `json:"repairCopies"` + Skipped int `json:"skipped"` + Warnings int `json:"warnings"` +} + +type recoveryReport struct { + Timestamp string `json:"timestamp"` + AppRoot string `json:"appRoot"` + ConfigPath string `json:"configPath"` + DBPath string `json:"dbPath"` + UserDataRoot string `json:"userDataRoot"` + Apply bool `json:"apply"` + RepairStrategy string `json:"repairStrategy"` + NamePrefix string `json:"namePrefix"` + SelectedCore selectedCore `json:"selectedCore"` + BackupDir string `json:"backupDir,omitempty"` + ReportPath string `json:"reportPath,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Summary reportSummary `json:"summary"` + Entries []reportEntry `json:"entries"` +} + +type existingProfile struct { + ProfileID string + ProfileName string + UserDataDir string + ResolvedPath string +} + +var volatileDirNames = map[string]struct{}{ + "browsermetrics": {}, + "deferredbrowsermetrics": {}, + "graphitedawncache": {}, + "grshadercache": {}, + "shadercache": {}, + "component_crx_cache": {}, + "extensions_crx_cache": {}, + "cache": {}, + "code cache": {}, + "gpucache": {}, +} + +var volatileFileNames = map[string]struct{}{ + "lock": {}, + "local state.bad": {}, +} + +func main() { + opts := parseFlags() + + report, err := run(opts) + if err != nil { + fmt.Fprintf(os.Stderr, "profile recovery failed: %v\n", err) + os.Exit(1) + } + + printSummary(report) +} + +func parseFlags() options { + var ( + appRoot = flag.String("app-root", ".", "Ant Browser app root, for example E:\\software\\Ant Browser") + configPath = flag.String("config", "", "Optional config.yaml path override") + apply = flag.Bool("apply", false, "Write restored profiles into app.db") + repairStrategy = flag.String("repair-strategy", "none", "Repair strategy for risky directories: none or risky") + namePrefix = flag.String("name-prefix", "恢复", "Prefix used for restored profile names") + only = flag.String("only", "", "Optional comma-separated directory names to restore") + ) + flag.Parse() + + filter := make(map[string]struct{}) + for _, item := range strings.Split(strings.TrimSpace(*only), ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + filter[strings.ToLower(item)] = struct{}{} + } + + return options{ + appRoot: strings.TrimSpace(*appRoot), + configPath: strings.TrimSpace(*configPath), + apply: *apply, + repairStrategy: strings.ToLower(strings.TrimSpace(*repairStrategy)), + namePrefix: strings.TrimSpace(*namePrefix), + onlyDirs: filter, + } +} + +func run(opts options) (*recoveryReport, error) { + appRoot := normalizeRoot(opts.appRoot) + configPath := opts.configPath + if configPath == "" { + configPath = filepath.Join(appRoot, "config.yaml") + } + configPath = normalizePath(configPath) + + cfg, err := config.Load(configPath) + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + + dbPath := apppath.Resolve(appRoot, cfg.Database.SQLite.Path) + userDataRoot := apppath.Resolve(appRoot, cfg.Browser.UserDataRoot) + now := time.Now() + + report := &recoveryReport{ + Timestamp: now.Format(time.RFC3339), + AppRoot: appRoot, + ConfigPath: configPath, + DBPath: dbPath, + UserDataRoot: userDataRoot, + Apply: opts.apply, + RepairStrategy: normalizeRepairStrategy(opts.repairStrategy), + NamePrefix: opts.namePrefix, + } + + if report.RepairStrategy == "" { + return nil, fmt.Errorf("unsupported repair strategy %q", opts.repairStrategy) + } + + if err := os.MkdirAll(userDataRoot, 0755); err != nil { + return nil, fmt.Errorf("ensure user data root: %w", err) + } + + selectedCore, warnings, err := selectCore(appRoot, cfg, dbPath, opts.apply) + if err != nil { + return nil, err + } + report.SelectedCore = selectedCore + report.Warnings = append(report.Warnings, warnings...) + report.Summary.Warnings = len(report.Warnings) + + existingProfiles, dbConn, dbHandle, err := loadExistingProfiles(dbPath, userDataRoot, opts.apply) + if err != nil { + return nil, err + } + if dbHandle != nil { + defer dbHandle.Close() + } + if dbConn != nil { + defer dbConn.Close() + } + + existingByPath := make(map[string]existingProfile, len(existingProfiles)) + for _, item := range existingProfiles { + existingByPath[normalizePath(item.ResolvedPath)] = item + } + + if opts.apply { + backupDir, backupErr := backupDatabaseFiles(dbPath, now) + if backupErr != nil { + return nil, backupErr + } + report.BackupDir = backupDir + } + + entries, err := os.ReadDir(userDataRoot) + if err != nil { + return nil, fmt.Errorf("read user data root: %w", err) + } + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) + }) + + var profileDAO *browser.SQLiteProfileDAO + if opts.apply { + if dbHandle == nil { + return nil, fmt.Errorf("database handle not initialized in apply mode") + } + if err := dbHandle.Migrate(); err != nil { + return nil, fmt.Errorf("migrate database: %w", err) + } + profileDAO = browser.NewSQLiteProfileDAO(dbHandle.GetConn()) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + dirName := entry.Name() + if len(opts.onlyDirs) > 0 { + if _, ok := opts.onlyDirs[strings.ToLower(dirName)]; !ok { + continue + } + } + + report.Summary.Scanned++ + + resolvedPath := filepath.Join(userDataRoot, dirName) + inspection := inspectUserDataDir(resolvedPath, selectedCore.BinaryPath) + item := reportEntry{ + DirName: dirName, + ResolvedPath: resolvedPath, + Inspection: inspection, + } + + if !inspection.LooksLikeBrowserData { + item.Action = "skipped" + item.Reason = "not a browser user data directory" + report.Summary.Skipped++ + report.Entries = append(report.Entries, item) + continue + } + report.Summary.Candidates++ + + if existing, ok := existingByPath[normalizePath(resolvedPath)]; ok { + item.Action = "existing" + item.Reason = "already registered in browser_profiles" + item.ExistingProfileID = existing.ProfileID + item.ExistingProfileName = existing.ProfileName + report.Summary.Existing++ + report.Entries = append(report.Entries, item) + continue + } + + targetDirName := dirName + targetPath := resolvedPath + var repair *repairResult + action := "would_restore" + if opts.apply { + action = "restored" + } + + if report.RepairStrategy == "risky" && inspection.Risky { + if opts.apply { + targetDirName, targetPath, err = createRepairCopy(userDataRoot, dirName, resolvedPath) + if err != nil { + item.Action = "error" + item.Reason = fmt.Sprintf("create repair copy failed: %v", err) + report.Summary.Skipped++ + report.Entries = append(report.Entries, item) + report.Warnings = append(report.Warnings, item.Reason) + report.Summary.Warnings = len(report.Warnings) + continue + } + report.Summary.RepairCopies++ + } else { + targetDirName = predictedRepairDirName(dirName, now) + targetPath = filepath.Join(userDataRoot, targetDirName) + } + repair = &repairResult{ + TargetDirName: targetDirName, + TargetPath: targetPath, + } + if opts.apply { + action = "restored_with_repair_copy" + } else { + action = "would_restore_with_repair_copy" + } + } + + profileID := uuid.NewString() + profileName := buildProfileName(opts.namePrefix, targetDirName) + registeredUserDataDir := targetDirName + + if opts.apply { + if profileDAO == nil { + return nil, fmt.Errorf("profile dao not initialized in apply mode") + } + p := &browser.Profile{ + ProfileId: profileID, + ProfileName: profileName, + UserDataDir: registeredUserDataDir, + CoreId: selectedCore.CoreID, + FingerprintArgs: append([]string{}, cfg.Browser.DefaultFingerprintArgs...), + ProxyId: "", + ProxyConfig: "", + LaunchArgs: append([]string{}, cfg.Browser.DefaultLaunchArgs...), + Tags: []string{"恢复"}, + Keywords: []string{}, + GroupId: "", + CreatedAt: now.Format(time.RFC3339), + UpdatedAt: now.Format(time.RFC3339), + } + if err := profileDAO.Upsert(p); err != nil { + item.Action = "error" + item.Reason = fmt.Sprintf("insert browser_profiles failed: %v", err) + report.Summary.Skipped++ + report.Entries = append(report.Entries, item) + report.Warnings = append(report.Warnings, item.Reason) + report.Summary.Warnings = len(report.Warnings) + continue + } + existingByPath[normalizePath(targetPath)] = existingProfile{ + ProfileID: profileID, + ProfileName: profileName, + UserDataDir: registeredUserDataDir, + ResolvedPath: targetPath, + } + } + + item.Action = action + item.Reason = "directory is present on disk but missing in browser_profiles" + item.RestoredProfileID = profileID + item.RestoredProfileName = profileName + item.RegisteredUserDataDir = registeredUserDataDir + item.Repair = repair + report.Summary.Restored++ + report.Entries = append(report.Entries, item) + } + + reportPath, err := writeReport(report, now) + if err != nil { + report.Warnings = append(report.Warnings, fmt.Sprintf("write report failed: %v", err)) + report.Summary.Warnings = len(report.Warnings) + } else { + report.ReportPath = reportPath + } + + return report, nil +} + +func normalizeRepairStrategy(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "none": + return "none" + case "risky": + return "risky" + default: + return "" + } +} + +func selectCore(appRoot string, cfg *config.Config, dbPath string, apply bool) (selectedCore, []string, error) { + var warnings []string + + if info, err := os.Stat(dbPath); err == nil && !info.IsDir() { + db, err := openQueryDB(dbPath) + if err != nil { + return selectedCore{}, warnings, fmt.Errorf("open database for core selection: %w", err) + } + defer db.Close() + + cores, err := browser.NewSQLiteCoreDAO(db).List() + if err == nil && len(cores) > 0 { + picked := pickCoreFromList(appRoot, cores, "database") + return picked, warnings, nil + } + if err != nil { + warnings = append(warnings, fmt.Sprintf("load cores from database failed, fallback to config: %v", err)) + } + } + + if len(cfg.Browser.Cores) > 0 { + picked := pickCoreFromConfig(appRoot, cfg.Browser.Cores, "config") + return picked, warnings, nil + } + + if apply { + warnings = append(warnings, "no browser core was found; restored profiles will be created with empty core_id") + } + + return selectedCore{ + CoreID: "", + CoreName: "", + CorePath: "", + Source: "none", + }, warnings, nil +} + +func pickCoreFromList(appRoot string, cores []browser.Core, source string) selectedCore { + for _, core := range cores { + if core.IsDefault { + return buildSelectedCore(appRoot, core.CoreId, core.CoreName, core.CorePath, source) + } + } + first := cores[0] + return buildSelectedCore(appRoot, first.CoreId, first.CoreName, first.CorePath, source) +} + +func pickCoreFromConfig(appRoot string, cores []config.BrowserCore, source string) selectedCore { + for _, core := range cores { + if core.IsDefault { + return buildSelectedCore(appRoot, core.CoreId, core.CoreName, core.CorePath, source) + } + } + first := cores[0] + return buildSelectedCore(appRoot, first.CoreId, first.CoreName, first.CorePath, source) +} + +func buildSelectedCore(appRoot, coreID, coreName, corePath, source string) selectedCore { + coreAbsPath := apppath.Resolve(appRoot, corePath) + return selectedCore{ + CoreID: strings.TrimSpace(coreID), + CoreName: strings.TrimSpace(coreName), + CorePath: strings.TrimSpace(corePath), + BinaryPath: filepath.Join(coreAbsPath, "chrome.exe"), + Source: source, + } +} + +func loadExistingProfiles(dbPath string, userDataRoot string, apply bool) ([]existingProfile, *sql.DB, *database.DB, error) { + dbExists := fileExists(dbPath) + if !dbExists && !apply { + return nil, nil, nil, nil + } + + if apply { + handle, err := database.NewDB(dbPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("open database: %w", err) + } + list, err := browser.NewSQLiteProfileDAO(handle.GetConn()).List() + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + return nil, nil, handle, nil + } + _ = handle.Close() + return nil, nil, nil, fmt.Errorf("load existing profiles: %w", err) + } + return toExistingProfiles(list, userDataRoot), nil, handle, nil + } + + db, err := openQueryDB(dbPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("open database: %w", err) + } + list, err := browser.NewSQLiteProfileDAO(db).List() + if err != nil { + _ = db.Close() + return nil, nil, nil, fmt.Errorf("load existing profiles: %w", err) + } + return toExistingProfiles(list, userDataRoot), db, nil, nil +} + +func toExistingProfiles(items []*browser.Profile, userDataRoot string) []existingProfile { + out := make([]existingProfile, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + out = append(out, existingProfile{ + ProfileID: item.ProfileId, + ProfileName: item.ProfileName, + UserDataDir: item.UserDataDir, + ResolvedPath: resolveUserDataPath(userDataRoot, item.UserDataDir), + }) + } + return out +} + +func resolveUserDataPath(userDataRoot string, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if filepath.IsAbs(raw) { + return filepath.Clean(raw) + } + return filepath.Join(userDataRoot, raw) +} + +func openQueryDB(dbPath string) (*sql.DB, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +func backupDatabaseFiles(dbPath string, now time.Time) (string, error) { + dataRoot := filepath.Dir(dbPath) + backupDir := filepath.Join(dataRoot, "recovery-backups", now.Format("20060102-150405")) + if err := os.MkdirAll(backupDir, 0755); err != nil { + return "", fmt.Errorf("create backup dir: %w", err) + } + + for _, src := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { + if !fileExists(src) { + continue + } + dst := filepath.Join(backupDir, filepath.Base(src)) + if err := copyFile(src, dst); err != nil { + return "", fmt.Errorf("backup %s: %w", src, err) + } + } + + return backupDir, nil +} + +func inspectUserDataDir(dirPath string, currentCoreBinaryPath string) candidateInspection { + inspection := candidateInspection{} + + markers := make([]string, 0, 4) + for _, marker := range []string{"Local State", "Default", "Last Browser", "Last Version"} { + if fileExists(filepath.Join(dirPath, marker)) { + markers = append(markers, marker) + } + } + inspection.Markers = markers + inspection.LooksLikeBrowserData = len(markers) > 0 + if !inspection.LooksLikeBrowserData { + return inspection + } + + if raw, err := os.ReadFile(filepath.Join(dirPath, "Last Browser")); err == nil { + inspection.LastBrowser = decodePossiblyUTF16(raw) + } + if raw, err := os.ReadFile(filepath.Join(dirPath, "Last Version")); err == nil { + inspection.LastVersion = strings.TrimSpace(string(raw)) + } + + if fileExists(filepath.Join(dirPath, "Local State.bad")) { + inspection.Risky = true + inspection.RiskReasons = append(inspection.RiskReasons, "Local State.bad exists") + } + if inspection.LastBrowser != "" && currentCoreBinaryPath != "" { + if normalizePath(inspection.LastBrowser) != normalizePath(currentCoreBinaryPath) { + inspection.Risky = true + inspection.RiskReasons = append(inspection.RiskReasons, fmt.Sprintf("Last Browser points to %s", inspection.LastBrowser)) + } + } + + return inspection +} + +func createRepairCopy(userDataRoot, dirName, sourcePath string) (string, string, error) { + targetDirName := uniqueRepairDirName(userDataRoot, dirName) + targetPath := filepath.Join(userDataRoot, targetDirName) + if err := copyDirFiltered(sourcePath, targetPath); err != nil { + return "", "", err + } + return targetDirName, targetPath, nil +} + +func predictedRepairDirName(dirName string, now time.Time) string { + return fmt.Sprintf("%s__repair_%s", dirName, now.Format("20060102-150405")) +} + +func uniqueRepairDirName(userDataRoot, dirName string) string { + base := predictedRepairDirName(dirName, time.Now()) + target := filepath.Join(userDataRoot, base) + if !fileExists(target) { + return base + } + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s_%02d", base, i) + if !fileExists(filepath.Join(userDataRoot, candidate)) { + return candidate + } + } +} + +func copyDirFiltered(src, dst string) error { + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&os.ModeSymlink != 0 { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + + if shouldSkipRepairPath(rel, d.IsDir()) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + return copyFile(path, target) + }) +} + +func shouldSkipRepairPath(rel string, isDir bool) bool { + clean := filepath.ToSlash(strings.TrimSpace(rel)) + base := strings.ToLower(filepath.Base(clean)) + + if strings.HasPrefix(base, "singleton") { + return true + } + if strings.HasSuffix(base, ".tmp") { + return true + } + if _, ok := volatileFileNames[base]; ok { + return true + } + + if !isDir { + return false + } + + if _, ok := volatileDirNames[base]; ok { + return true + } + + parent := strings.ToLower(filepath.Base(filepath.Dir(clean))) + if parent == "default" { + if _, ok := volatileDirNames[base]; ok { + return true + } + } + + return false +} + +func buildProfileName(prefix, dirName string) string { + name := strings.TrimSpace(dirName) + if isUUIDLike(name) { + name = name[:8] + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return name + } + return fmt.Sprintf("%s-%s", prefix, name) +} + +func isUUIDLike(value string) bool { + _, err := uuid.Parse(strings.TrimSpace(value)) + return err == nil +} + +func writeReport(report *recoveryReport, now time.Time) (string, error) { + if report == nil { + return "", fmt.Errorf("report is nil") + } + + reportDir := filepath.Join(report.UserDataRoot, "recovery-reports") + if err := os.MkdirAll(reportDir, 0755); err != nil { + return "", err + } + reportPath := filepath.Join(reportDir, fmt.Sprintf("profile-recover-%s.json", now.Format("20060102-150405"))) + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", err + } + if err := os.WriteFile(reportPath, data, 0644); err != nil { + return "", err + } + return reportPath, nil +} + +func printSummary(report *recoveryReport) { + fmt.Printf("AppRoot: %s\n", report.AppRoot) + fmt.Printf("DBPath: %s\n", report.DBPath) + fmt.Printf("UserDataRoot: %s\n", report.UserDataRoot) + mode := "preview" + if report.Apply { + mode = "apply" + } + fmt.Printf("Mode: %s\n", mode) + if report.SelectedCore.CoreID != "" || report.SelectedCore.CoreName != "" { + fmt.Printf("SelectedCore: %s (%s)\n", report.SelectedCore.CoreName, report.SelectedCore.CoreID) + } + if report.BackupDir != "" { + fmt.Printf("BackupDir: %s\n", report.BackupDir) + } + if report.ReportPath != "" { + fmt.Printf("Report: %s\n", report.ReportPath) + } + fmt.Printf("Scanned=%d Candidates=%d Existing=%d Restored=%d RepairCopies=%d Skipped=%d Warnings=%d\n", + report.Summary.Scanned, + report.Summary.Candidates, + report.Summary.Existing, + report.Summary.Restored, + report.Summary.RepairCopies, + report.Summary.Skipped, + report.Summary.Warnings, + ) + for _, entry := range report.Entries { + fmt.Printf("- [%s] %s", entry.Action, entry.DirName) + if entry.RestoredProfileName != "" { + fmt.Printf(" -> %s", entry.RestoredProfileName) + } + if entry.ExistingProfileName != "" { + fmt.Printf(" -> %s", entry.ExistingProfileName) + } + if entry.Reason != "" { + fmt.Printf(" (%s)", entry.Reason) + } + fmt.Println() + } + if len(report.Warnings) > 0 { + fmt.Println("Warnings:") + for _, warning := range report.Warnings { + fmt.Printf(" - %s\n", warning) + } + } +} + +func decodePossiblyUTF16(raw []byte) string { + if len(raw) == 0 { + return "" + } + if len(raw) >= 2 && len(raw)%2 == 0 { + zeros := 0 + for i := 1; i < len(raw); i += 2 { + if raw[i] == 0 { + zeros++ + } + } + if zeros >= len(raw)/4 { + u16 := make([]uint16, 0, len(raw)/2) + for i := 0; i+1 < len(raw); i += 2 { + u16 = append(u16, binary.LittleEndian.Uint16(raw[i:i+2])) + } + return strings.TrimSpace(string(utf16.Decode(u16))) + } + } + return strings.TrimSpace(string(raw)) +} + +func normalizeRoot(root string) string { + root = strings.TrimSpace(root) + if root == "" { + root = "." + } + abs, err := filepath.Abs(root) + if err != nil { + return filepath.Clean(root) + } + return filepath.Clean(abs) +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + return strings.ToLower(filepath.Clean(p)) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go index 41eae9bc..4075d9fd 100644 --- a/backend/internal/browser/types.go +++ b/backend/internal/browser/types.go @@ -27,7 +27,9 @@ type Profile struct { LaunchCode string `json:"launchCode"` Running bool `json:"running"` DebugPort int `json:"debugPort"` + DebugReady bool `json:"debugReady"` Pid int `json:"pid"` + RuntimeWarning string `json:"runtimeWarning"` LastError string `json:"lastError"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` @@ -63,6 +65,8 @@ type Settings struct { DefaultFingerprintArgs []string `json:"defaultFingerprintArgs"` DefaultLaunchArgs []string `json:"defaultLaunchArgs"` DefaultProxy string `json:"defaultProxy"` + StartReadyTimeoutMs int `json:"startReadyTimeoutMs"` + StartStableWindowMs int `json:"startStableWindowMs"` } // CoreInput 内核配置输入 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3935634d..2e00454d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -10,12 +10,13 @@ import ( ) const ( - DefaultMaxProfileLimit = 20 - StandardCDKeyProfileBonus = 10 - GithubStarRewardKey = "GITHUB_STAR_REWARD" - GithubStarProfileBonus = 50 - GithubStarProfileTotal = DefaultMaxProfileLimit + GithubStarProfileBonus - DefaultLaunchServerPort = 19876 + DefaultMaxProfileLimit = 20 + StandardCDKeyProfileBonus = 10 + GithubStarRewardKey = "GITHUB_STAR_REWARD" + GithubStarProfileBonus = 50 + GithubStarProfileTotal = DefaultMaxProfileLimit + GithubStarProfileBonus + DefaultLaunchServerPort = 19876 + DefaultLaunchServerAPIKeyHeader = "X-Ant-Api-Key" ) // RewardForUsedKey 返回指定兑换记录对应的永久额度奖励。 @@ -53,6 +54,14 @@ type LaunchServerConfig struct { // Port 为对外暴露的固定入口端口。 // Launch API 与 CDP 代理共用此端口,便于外部工具固定接入。 Port int `yaml:"port"` + // Auth 为 Launch API 的可选本地认证配置。 + Auth LaunchServerAuthConfig `yaml:"auth"` +} + +type LaunchServerAuthConfig struct { + Enabled bool `yaml:"enabled"` + APIKey string `yaml:"api_key"` + Header string `yaml:"header"` } // Config 应用配置 @@ -108,6 +117,8 @@ type BrowserConfig struct { DefaultFingerprintArgs []string `yaml:"default_fingerprint_args"` DefaultLaunchArgs []string `yaml:"default_launch_args"` DefaultProxy string `yaml:"default_proxy"` + StartReadyTimeoutMs int `yaml:"start_ready_timeout_ms,omitempty"` + StartStableWindowMs int `yaml:"start_stable_window_ms,omitempty"` DefaultBookmarks []BrowserBookmark `yaml:"default_bookmarks,omitempty"` Cores []BrowserCore `yaml:"cores,omitempty"` Proxies []BrowserProxy `yaml:"proxies,omitempty"` @@ -331,6 +342,12 @@ func normalizeConfig(config *Config) { if len(config.Browser.DefaultLaunchArgs) == 0 { config.Browser.DefaultLaunchArgs = append([]string{}, defaultConfig.Browser.DefaultLaunchArgs...) } + if config.Browser.StartReadyTimeoutMs <= 0 { + config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs + } + if config.Browser.StartStableWindowMs <= 0 { + config.Browser.StartStableWindowMs = defaultConfig.Browser.StartStableWindowMs + } if config.Browser.DefaultBookmarks == nil { config.Browser.DefaultBookmarks = []BrowserBookmark{} } @@ -347,6 +364,10 @@ func normalizeConfig(config *Config) { if config.LaunchServer.Port <= 0 { config.LaunchServer.Port = defaultConfig.LaunchServer.Port } + config.LaunchServer.Auth.APIKey = strings.TrimSpace(config.LaunchServer.Auth.APIKey) + if strings.TrimSpace(config.LaunchServer.Auth.Header) == "" { + config.LaunchServer.Auth.Header = defaultConfig.LaunchServer.Auth.Header + } } func cloneInterceptorConfig(src InterceptorConfig) InterceptorConfig { @@ -388,6 +409,8 @@ func DefaultConfig() *Config { DefaultFingerprintArgs: []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"}, DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"}, DefaultProxy: "", + StartReadyTimeoutMs: 3000, + StartStableWindowMs: 1200, }, Logging: LoggingConfig{ Level: "info", @@ -413,6 +436,11 @@ func DefaultConfig() *Config { }, LaunchServer: LaunchServerConfig{ Port: DefaultLaunchServerPort, + Auth: LaunchServerAuthConfig{ + Enabled: false, + APIKey: "", + Header: DefaultLaunchServerAPIKeyHeader, + }, }, } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index bac5146f..3eca9f18 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -63,6 +63,15 @@ browser: {} if cfg.LaunchServer.Port != DefaultLaunchServerPort { t.Fatalf("LaunchServer.Port 未补齐: got=%d", cfg.LaunchServer.Port) } + if cfg.LaunchServer.Auth.Enabled { + t.Fatalf("LaunchServer.Auth.Enabled 默认应为 false: got=%v", cfg.LaunchServer.Auth.Enabled) + } + if cfg.LaunchServer.Auth.APIKey != "" { + t.Fatalf("LaunchServer.Auth.APIKey 默认应为空: got=%q", cfg.LaunchServer.Auth.APIKey) + } + if cfg.LaunchServer.Auth.Header != DefaultLaunchServerAPIKeyHeader { + t.Fatalf("LaunchServer.Auth.Header 未补齐: got=%q", cfg.LaunchServer.Auth.Header) + } } func TestLoadPreservesExplicitConfig(t *testing.T) { @@ -119,6 +128,10 @@ browser: profiles: [] launch_server: port: 30000 + auth: + enabled: true + api_key: secret-key + header: X-Custom-Ant-Key ` if err := os.WriteFile(configPath, []byte(customConfig), 0o644); err != nil { t.Fatalf("写入测试配置失败: %v", err) @@ -153,6 +166,15 @@ launch_server: if cfg.LaunchServer.Port != 30000 { t.Fatalf("LaunchServer.Port 显式配置被覆盖: got=%d", cfg.LaunchServer.Port) } + if !cfg.LaunchServer.Auth.Enabled { + t.Fatalf("LaunchServer.Auth.Enabled 显式配置被覆盖") + } + if cfg.LaunchServer.Auth.APIKey != "secret-key" { + t.Fatalf("LaunchServer.Auth.APIKey 显式配置被覆盖: got=%q", cfg.LaunchServer.Auth.APIKey) + } + if cfg.LaunchServer.Auth.Header != "X-Custom-Ant-Key" { + t.Fatalf("LaunchServer.Auth.Header 显式配置被覆盖: got=%q", cfg.LaunchServer.Auth.Header) + } } func TestLoadMigratesLegacyRootLogPath(t *testing.T) { diff --git a/backend/internal/launchcode/auth.go b/backend/internal/launchcode/auth.go new file mode 100644 index 00000000..103e7ea5 --- /dev/null +++ b/backend/internal/launchcode/auth.go @@ -0,0 +1,93 @@ +package launchcode + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +const DefaultAPIKeyHeader = "X-Ant-Api-Key" + +// APIAuthConfig 定义 LaunchServer 对 /api/* 请求的可选认证配置。 +type APIAuthConfig struct { + Enabled bool + APIKey string + Header string +} + +func normalizeAPIAuthConfig(cfg APIAuthConfig) APIAuthConfig { + cfg.APIKey = strings.TrimSpace(cfg.APIKey) + cfg.Header = strings.TrimSpace(cfg.Header) + if cfg.Header == "" { + cfg.Header = DefaultAPIKeyHeader + } + return cfg +} + +func (cfg APIAuthConfig) Requested() bool { + return cfg.Enabled +} + +func (cfg APIAuthConfig) Configured() bool { + return cfg.APIKey != "" +} + +func (cfg APIAuthConfig) Active() bool { + return cfg.Requested() && cfg.Configured() +} + +func (s *LaunchServer) SetAPIAuthConfig(cfg APIAuthConfig) { + s.authMu.Lock() + s.apiAuth = normalizeAPIAuthConfig(cfg) + s.authMu.Unlock() +} + +func (s *LaunchServer) apiAuthConfig() APIAuthConfig { + s.authMu.RLock() + cfg := s.apiAuth + s.authMu.RUnlock() + return cfg +} + +func (s *LaunchServer) APIAuthHeader() string { + return s.apiAuthConfig().Header +} + +func (s *LaunchServer) APIAuthRequested() bool { + return s.apiAuthConfig().Requested() +} + +func (s *LaunchServer) APIAuthConfigured() bool { + return s.apiAuthConfig().Configured() +} + +func (s *LaunchServer) APIAuthEnabled() bool { + return s.apiAuthConfig().Active() +} + +func (s *LaunchServer) apiAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return + } + + cfg := s.apiAuthConfig() + if !cfg.Active() { + next.ServeHTTP(w, r) + return + } + + providedKey := strings.TrimSpace(r.Header.Get(cfg.Header)) + if subtle.ConstantTimeCompare([]byte(providedKey), []byte(cfg.APIKey)) != 1 { + writeJSON(w, http.StatusUnauthorized, map[string]interface{}{ + "ok": false, + "error": "unauthorized: invalid api key", + "authHeader": cfg.Header, + }) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/launchcode/profile_api.go b/backend/internal/launchcode/profile_api.go new file mode 100644 index 00000000..1bbf12bf --- /dev/null +++ b/backend/internal/launchcode/profile_api.go @@ -0,0 +1,575 @@ +package launchcode + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/logger" +) + +// ProfileWriteRequest 用于创建/更新实例配置。 +// profile 为持久化配置;start 为本次自动启动的临时参数。 +type ProfileWriteRequest struct { + Profile *browser.ProfileInput `json:"profile"` + LaunchCode string `json:"launchCode"` + AutoLaunch bool `json:"autoLaunch"` + Start *LaunchRequestParams `json:"start"` +} + +type profileCreator interface { + CreateProfile(input browser.ProfileInput) (*browser.Profile, error) +} + +type profileUpdater interface { + UpdateProfile(profileID string, input browser.ProfileInput) (*browser.Profile, error) +} + +type profileDeleter interface { + DeleteProfile(profileID string) error +} + +func (s *LaunchServer) handleProfiles(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleListProfiles(w, r) + case http.MethodPost: + s.handleCreateProfile(w, r) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": "method not allowed", + }) + } +} + +func (s *LaunchServer) handleProfileByID(w http.ResponseWriter, r *http.Request) { + profileID, ok := parseProfilePathID(r.URL.Path) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]interface{}{ + "ok": false, + "error": "profile not found", + }) + return + } + + switch r.Method { + case http.MethodGet: + s.handleGetProfile(w, r, profileID) + case http.MethodPut: + s.handleUpdateProfile(w, r, profileID) + case http.MethodDelete: + s.handleDeleteProfile(w, r, profileID) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "ok": false, + "error": "method not allowed", + }) + } +} + +// handleCreateProfile POST /api/profiles +func (s *LaunchServer) handleCreateProfile(w http.ResponseWriter, r *http.Request) { + log := logger.New("LaunchServer") + startAt := time.Now() + + req, status, errMsg := decodeProfileWriteRequest(r) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + input := normalizeProfileInput(*req.Profile) + profile, launchCode, status, errMsg := s.createProfile(input, req.LaunchCode) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req) + if launchErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "created": true, + "updated": false, + "launched": false, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "profile": profile, + "error": launchErr.Error(), + }) + log.Warn("Profile API 创建后自动启动失败", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + logger.F("error", launchErr.Error()), + ) + return + } + if launched { + mergeProfileRuntime(profile, launchedProfile) + s.SetActiveProfile(profile) + } + + writeJSON(w, http.StatusCreated, s.profileWriteSuccessPayload(profile, launchCode, true, false, launched)) + log.Info("Profile API 创建实例", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("auto_launch", launched), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + ) +} + +func (s *LaunchServer) handleListProfiles(w http.ResponseWriter, _ *http.Request) { + items, status, errMsg := s.listProfiles() + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "count": len(items), + "items": items, + }) +} + +func (s *LaunchServer) handleGetProfile(w http.ResponseWriter, _ *http.Request, profileID string) { + profile, status, errMsg := s.profileSnapshotByID(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + "profile": profile, + }) +} + +func (s *LaunchServer) handleUpdateProfile(w http.ResponseWriter, r *http.Request, profileID string) { + log := logger.New("LaunchServer") + startAt := time.Now() + + previous, status, errMsg := s.profileSnapshotByID(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + req, status, errMsg := decodeProfileWriteRequest(r) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + input := normalizeProfileInput(*req.Profile) + profile, launchCode, status, errMsg := s.updateProfile(profileID, input, req.LaunchCode, previous) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + + launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req) + if launchErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "created": false, + "updated": true, + "launched": false, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "profile": profile, + "error": launchErr.Error(), + }) + log.Warn("Profile API 更新后自动启动失败", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + logger.F("error", launchErr.Error()), + ) + return + } + if launched { + mergeProfileRuntime(profile, launchedProfile) + s.SetActiveProfile(profile) + } + + writeJSON(w, http.StatusOK, s.profileWriteSuccessPayload(profile, launchCode, false, true, launched)) + log.Info("Profile API 更新实例", + logger.F("profile_id", profile.ProfileId), + logger.F("profile_name", profile.ProfileName), + logger.F("launch_code", launchCode), + logger.F("auto_launch", launched), + logger.F("duration_ms", time.Since(startAt).Milliseconds()), + ) +} + +func (s *LaunchServer) handleDeleteProfile(w http.ResponseWriter, _ *http.Request, profileID string) { + profile, status, errMsg := s.profileSnapshotByID(profileID) + if errMsg != "" { + writeJSON(w, status, map[string]interface{}{ + "ok": false, + "error": errMsg, + }) + return + } + if profile.Running { + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "ok": false, + "error": "running profile cannot be deleted", + }) + return + } + + if err := s.deleteProfileInternal(profileID); err != nil { + writeJSON(w, mapProfileWriteErrorStatus(err), map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + if s.service != nil { + _ = s.service.Remove(profileID) + } + s.ClearActiveProfile(profileID) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "deleted": true, + "profileId": profileID, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + }) +} + +func (s *LaunchServer) createProfile(input browser.ProfileInput, requestedCode string) (*browser.Profile, string, int, string) { + profile, err := s.createProfileInternal(input) + if err != nil { + return nil, "", mapProfileWriteErrorStatus(err), err.Error() + } + if profile == nil { + return nil, "", http.StatusInternalServerError, "profile creation returned nil profile" + } + + launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, strings.TrimSpace(profile.LaunchCode), requestedCode) + if errMsg != "" { + _ = s.deleteCreatedProfile(profile.ProfileId) + return nil, "", status, errMsg + } + profile.LaunchCode = launchCode + return profile, launchCode, http.StatusCreated, "" +} + +func (s *LaunchServer) updateProfile(profileID string, input browser.ProfileInput, requestedCode string, previous *browser.Profile) (*browser.Profile, string, int, string) { + profile, err := s.updateProfileInternal(profileID, input) + if err != nil { + return nil, "", mapProfileWriteErrorStatus(err), err.Error() + } + if profile == nil { + return nil, "", http.StatusInternalServerError, "profile update returned nil profile" + } + + currentCode := "" + if previous != nil { + currentCode = strings.TrimSpace(previous.LaunchCode) + } + launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, currentCode, requestedCode) + if errMsg != "" { + if rollbackErr := s.rollbackProfileUpdate(profileID, previous); rollbackErr != nil { + logger.New("LaunchServer").Warn("Profile API 更新回滚失败", + logger.F("profile_id", profileID), + logger.F("error", rollbackErr.Error()), + ) + } + return nil, "", status, errMsg + } + profile.LaunchCode = launchCode + return profile, launchCode, http.StatusOK, "" +} + +func (s *LaunchServer) maybeAutoLaunchProfile(profile *browser.Profile, req ProfileWriteRequest) (*browser.Profile, bool, error) { + if profile == nil || !req.AutoLaunch { + return nil, false, nil + } + + params := LaunchRequestParams{} + if req.Start != nil { + params = LaunchRequestParams{ + LaunchArgs: normalizeStringSlice(req.Start.LaunchArgs), + StartURLs: normalizeStringSlice(req.Start.StartURLs), + SkipDefaultStartURLs: req.Start.SkipDefaultStartURLs, + } + } + + launchedProfile, err := s.launchProfile(profile.ProfileId, params) + if err != nil { + return nil, false, err + } + return launchedProfile, true, nil +} + +func (s *LaunchServer) createProfileInternal(input browser.ProfileInput) (*browser.Profile, error) { + if creator, ok := s.starter.(profileCreator); ok { + return creator.CreateProfile(input) + } + if s.browserMgr != nil { + return s.browserMgr.Create(input) + } + return nil, http.ErrNotSupported +} + +func (s *LaunchServer) updateProfileInternal(profileID string, input browser.ProfileInput) (*browser.Profile, error) { + if updater, ok := s.starter.(profileUpdater); ok { + return updater.UpdateProfile(profileID, input) + } + if s.browserMgr != nil { + return s.browserMgr.Update(profileID, input) + } + return nil, http.ErrNotSupported +} + +func (s *LaunchServer) deleteCreatedProfile(profileID string) error { + if deleter, ok := s.starter.(profileDeleter); ok { + return deleter.DeleteProfile(profileID) + } + if s.browserMgr != nil { + return s.browserMgr.Delete(profileID) + } + return nil +} + +func (s *LaunchServer) deleteProfileInternal(profileID string) error { + return s.deleteCreatedProfile(profileID) +} + +func (s *LaunchServer) rollbackProfileUpdate(profileID string, previous *browser.Profile) error { + if previous == nil { + return nil + } + _, err := s.updateProfileInternal(profileID, profileToInput(previous)) + return err +} + +func (s *LaunchServer) listProfiles() ([]browser.Profile, int, string) { + if s.browserMgr == nil { + return nil, http.StatusServiceUnavailable, "profile catalog is not available" + } + + items := s.browserMgr.List() + for i := range items { + items[i].LaunchCode = s.resolveProfileLaunchCode(items[i].ProfileId, items[i].LaunchCode) + } + return items, http.StatusOK, "" +} + +func (s *LaunchServer) profileSnapshotByID(profileID string) (*browser.Profile, int, string) { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return nil, http.StatusNotFound, "profile not found" + } + if s.browserMgr == nil { + return nil, http.StatusServiceUnavailable, "profile catalog is not available" + } + + s.browserMgr.Mutex.Lock() + profile, ok := s.browserMgr.Profiles[profileID] + var snapshot browser.Profile + if ok && profile != nil { + snapshot = *profile + } + s.browserMgr.Mutex.Unlock() + if !ok { + return nil, http.StatusNotFound, "profile not found" + } + + snapshot.LaunchCode = s.resolveProfileLaunchCode(snapshot.ProfileId, snapshot.LaunchCode) + return &snapshot, http.StatusOK, "" +} + +func (s *LaunchServer) applyRequestedLaunchCode(profileID, currentCode, requestedCode string) (string, int, string) { + currentCode = strings.TrimSpace(currentCode) + requestedCode = strings.TrimSpace(requestedCode) + if requestedCode == "" { + return s.resolveProfileLaunchCode(profileID, currentCode), http.StatusOK, "" + } + if s.service == nil { + return "", http.StatusServiceUnavailable, "launch code service is unavailable" + } + + code, err := s.service.SetCode(profileID, requestedCode) + if err != nil { + return "", mapProfileWriteErrorStatus(err), err.Error() + } + return code, http.StatusOK, "" +} + +func (s *LaunchServer) resolveProfileLaunchCode(profileID, currentCode string) string { + if trimmed := strings.TrimSpace(currentCode); trimmed != "" { + return trimmed + } + if s.service == nil || strings.TrimSpace(profileID) == "" { + return "" + } + code, err := s.service.EnsureCode(profileID) + if err != nil { + return "" + } + return code +} + +func (s *LaunchServer) profileWriteSuccessPayload(profile *browser.Profile, launchCode string, created bool, updated bool, launched bool) map[string]interface{} { + payload := map[string]interface{}{ + "ok": true, + "created": created, + "updated": updated, + "launched": launched, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "profile": profile, + } + + if !launched { + return payload + } + + for key, value := range s.launchSuccessPayload(profile, launchCode) { + payload[key] = value + } + payload["created"] = created + payload["updated"] = updated + payload["launched"] = true + payload["profile"] = profile + return payload +} + +func decodeProfileWriteRequest(r *http.Request) (ProfileWriteRequest, int, string) { + if r.Method != http.MethodPost && r.Method != http.MethodPut { + return ProfileWriteRequest{}, http.StatusMethodNotAllowed, "method not allowed" + } + + var req ProfileWriteRequest + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return ProfileWriteRequest{}, http.StatusBadRequest, "invalid request body" + } + if req.Profile == nil { + return ProfileWriteRequest{}, http.StatusBadRequest, "profile is required" + } + return req, http.StatusOK, "" +} + +func normalizeProfileInput(input browser.ProfileInput) browser.ProfileInput { + return browser.ProfileInput{ + ProfileName: strings.TrimSpace(input.ProfileName), + UserDataDir: strings.TrimSpace(input.UserDataDir), + CoreId: strings.TrimSpace(input.CoreId), + FingerprintArgs: normalizeStringSlice(input.FingerprintArgs), + ProxyId: strings.TrimSpace(input.ProxyId), + ProxyConfig: strings.TrimSpace(input.ProxyConfig), + LaunchArgs: normalizeStringSlice(input.LaunchArgs), + Tags: normalizeStringSlice(input.Tags), + Keywords: normalizeStringSlice(input.Keywords), + GroupId: strings.TrimSpace(input.GroupId), + } +} + +func profileToInput(profile *browser.Profile) browser.ProfileInput { + if profile == nil { + return browser.ProfileInput{} + } + return browser.ProfileInput{ + ProfileName: strings.TrimSpace(profile.ProfileName), + UserDataDir: strings.TrimSpace(profile.UserDataDir), + CoreId: strings.TrimSpace(profile.CoreId), + FingerprintArgs: append([]string{}, profile.FingerprintArgs...), + ProxyId: strings.TrimSpace(profile.ProxyId), + ProxyConfig: strings.TrimSpace(profile.ProxyConfig), + LaunchArgs: append([]string{}, profile.LaunchArgs...), + Tags: append([]string{}, profile.Tags...), + Keywords: append([]string{}, profile.Keywords...), + GroupId: strings.TrimSpace(profile.GroupId), + } +} + +func mergeProfileRuntime(target, runtimeProfile *browser.Profile) { + if target == nil || runtimeProfile == nil { + return + } + target.Running = runtimeProfile.Running + target.DebugPort = runtimeProfile.DebugPort + target.DebugReady = runtimeProfile.DebugReady + target.Pid = runtimeProfile.Pid + target.RuntimeWarning = runtimeProfile.RuntimeWarning + target.LastError = runtimeProfile.LastError + target.LastStartAt = runtimeProfile.LastStartAt + target.LastStopAt = runtimeProfile.LastStopAt +} + +func parseProfilePathID(path string) (string, bool) { + path = strings.TrimPrefix(path, "/api/profiles/") + path = strings.TrimSpace(path) + if path == "" || strings.Contains(path, "/") { + return "", false + } + return path, true +} + +func mapProfileWriteErrorStatus(err error) int { + if err == nil { + return http.StatusOK + } + + msg := strings.ToLower(strings.TrimSpace(err.Error())) + switch { + case msg == strings.ToLower(strings.TrimSpace(http.ErrNotSupported.Error())): + return http.StatusServiceUnavailable + case strings.Contains(msg, "profile not found"): + return http.StatusNotFound + case strings.Contains(msg, "running profile cannot be deleted"): + return http.StatusConflict + case strings.Contains(msg, "launch code already exists"): + return http.StatusConflict + case strings.Contains(msg, "launch code format invalid"), + strings.Contains(msg, "launch code must be"): + return http.StatusBadRequest + case strings.Contains(msg, "实例数量已达上限"): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} diff --git a/backend/internal/launchcode/server.go b/backend/internal/launchcode/server.go index cca55b80..39ec087c 100644 --- a/backend/internal/launchcode/server.go +++ b/backend/internal/launchcode/server.go @@ -76,22 +76,26 @@ type LaunchServer struct { port int server *http.Server mu sync.Mutex + authMu sync.RWMutex logMu sync.Mutex callLogs []LaunchCallRecord activeMu sync.RWMutex activePort int activeID string activeName string + apiAuth APIAuthConfig } // NewLaunchServer 创建 LaunchServer func NewLaunchServer(service *LaunchCodeService, starter BrowserStarter, mgr *browser.Manager, port int) *LaunchServer { - return &LaunchServer{ + srv := &LaunchServer{ service: service, starter: starter, browserMgr: mgr, port: port, } + srv.SetAPIAuthConfig(APIAuthConfig{}) + return srv } // Start 非阻塞启动 HTTP 服务。 @@ -99,14 +103,7 @@ func NewLaunchServer(service *LaunchCodeService, starter BrowserStarter, mgr *br // - 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) + handler := s.buildHandler(true) preferredPort := s.port ln, port, err := bindLaunchListener(preferredPort) @@ -125,6 +122,12 @@ func (s *LaunchServer) Start() error { } else { log.Info("LaunchServer 使用固定端口", logger.F("port", port)) } + auth := s.apiAuthConfig() + if auth.Active() { + log.Info("LaunchServer API 认证已启用", logger.F("header", auth.Header)) + } else if auth.Requested() && !auth.Configured() { + log.Warn("LaunchServer API 认证配置未生效", logger.F("reason", "api_key is empty"), logger.F("header", auth.Header)) + } log.Info("LaunchServer 已启动", logger.F("port", port)) go func() { @@ -136,6 +139,27 @@ func (s *LaunchServer) Start() error { return nil } +func (s *LaunchServer) buildMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/profiles", s.handleProfiles) + mux.HandleFunc("/api/profiles/", s.handleProfileByID) + 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 +} + +func (s *LaunchServer) buildHandler(includeLocalhost bool) http.Handler { + var handler http.Handler = s.buildMux() + handler = s.apiAuthMiddleware(handler) + if includeLocalhost { + handler = s.localhostMiddleware(handler) + } + return handler +} + func bindLaunchListener(preferredPort int) (net.Listener, int, error) { if preferredPort <= 0 { ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -217,7 +241,7 @@ func (s *LaunchServer) ActiveDebugPort() int { // SetActiveProfile 将统一入口切换到指定实例的调试端口。 func (s *LaunchServer) SetActiveProfile(profile *browser.Profile) { - if profile == nil || profile.DebugPort <= 0 { + if profile == nil || profile.DebugPort <= 0 || !profile.DebugReady { return } @@ -341,20 +365,22 @@ func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) { 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 { + if cdpURL == "" && profile != nil && profile.DebugReady && 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, - "cdpPort": cdpPort, - "cdpUrl": cdpURL, + "ok": true, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": launchCode, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + "debugReady": profile.DebugReady, + "runtimeWarning": profile.RuntimeWarning, + "cdpPort": cdpPort, + "cdpUrl": cdpURL, } } @@ -474,9 +500,30 @@ func (s *LaunchServer) launchBySelector(selector LaunchSelector, params LaunchRe func (s *LaunchServer) launchProfile(profileID string, params LaunchRequestParams) (*browser.Profile, error) { if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok { - return starterWithParams.StartInstanceWithParams(profileID, params) + profile, err := starterWithParams.StartInstanceWithParams(profileID, params) + return normalizeLaunchedProfileRuntime(profile), err } - return s.starter.StartInstance(profileID) + profile, err := s.starter.StartInstance(profileID) + return normalizeLaunchedProfileRuntime(profile), err +} + +func normalizeLaunchedProfileRuntime(profile *browser.Profile) *browser.Profile { + if profile == nil { + return nil + } + + // Backward compatibility: older starter implementations only filled pid/debugPort. + if !profile.Running && (profile.Pid > 0 || profile.DebugPort > 0) { + profile.Running = true + } + if !profile.DebugReady && + profile.DebugPort > 0 && + strings.TrimSpace(profile.RuntimeWarning) == "" && + (profile.Running || profile.Pid > 0) { + profile.DebugReady = true + } + + return profile } func (s *LaunchServer) launchBySelectorInternal(selector LaunchSelector, params LaunchRequestParams, allowCodeKeywordFallback bool) (*browser.Profile, string, int, string) { @@ -597,12 +644,14 @@ func (s *LaunchServer) launchBatchSuccessPayload(profiles []*browser.Profile) ma 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, + "profileId": profile.ProfileId, + "profileName": profile.ProfileName, + "launchCode": profile.LaunchCode, + "pid": profile.Pid, + "debugPort": profile.DebugPort, + "debugReady": profile.DebugReady, + "runtimeWarning": profile.RuntimeWarning, + "isActive": i == len(profiles)-1, } items = append(items, item) } @@ -610,7 +659,7 @@ func (s *LaunchServer) launchBatchSuccessPayload(profiles []*browser.Profile) ma activeProfile, _, _ := summarizeLaunchedProfiles(profiles) cdpURL := s.CDPURL() cdpPort := s.Port() - if cdpURL == "" && activeProfile != nil && activeProfile.DebugPort > 0 { + if cdpURL == "" && activeProfile != nil && activeProfile.DebugReady && activeProfile.DebugPort > 0 { cdpPort = activeProfile.DebugPort cdpURL = fmt.Sprintf("http://127.0.0.1:%d", activeProfile.DebugPort) } @@ -660,13 +709,7 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) { // NewTestHandler 返回不含 localhost 限制的 handler,仅供测试使用 func NewTestHandler(s *LaunchServer) http.Handler { - 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) - return mux + return s.buildHandler(false) } func normalizeStringSlice(items []string) []string { diff --git a/backend/internal/launchcode/server_auth_internal_test.go b/backend/internal/launchcode/server_auth_internal_test.go new file mode 100644 index 00000000..59507f8f --- /dev/null +++ b/backend/internal/launchcode/server_auth_internal_test.go @@ -0,0 +1,29 @@ +package launchcode + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBuildHandlerRejectsNonLocalRequestBeforeAPIAuth(t *testing.T) { + srv := NewLaunchServer(NewLaunchCodeService(NewMemoryLaunchCodeDAO()), nil, nil, 0) + srv.SetAPIAuthConfig(APIAuthConfig{ + Enabled: true, + APIKey: "secret-key", + Header: "X-Test-Api-Key", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + req.RemoteAddr = "10.0.0.8:3456" + w := httptest.NewRecorder() + srv.buildHandler(true).ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("非 localhost 请求应优先返回 403: got=%d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "forbidden: only localhost is allowed") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } +} diff --git a/backend/internal/tray/tray.go b/backend/internal/tray/tray.go index 6a13180a..76729f58 100644 --- a/backend/internal/tray/tray.go +++ b/backend/internal/tray/tray.go @@ -14,8 +14,9 @@ var iconData []byte // Callbacks 托盘回调 type Callbacks struct { - OnShow func() - OnQuit func() + OnShow func() + OnQuitAppOnly func() + OnQuit func() } // Run 启动系统托盘(阻塞,需在独立 goroutine 中调用)。 @@ -31,7 +32,8 @@ func Run(cb Callbacks) { mShow := systray.AddMenuItem("显示窗口", "显示主窗口") systray.AddSeparator() - mQuit := systray.AddMenuItem("退出", "退出应用") + mQuitAppOnly := systray.AddMenuItem("仅退出应用", "关闭客户端,保留已打开的浏览器") + mQuit := systray.AddMenuItem("退出应用与浏览器", "退出应用并关闭当前打开的浏览器") systray.SetOnClick(func(menu systray.IMenu) { if cb.OnShow != nil { @@ -57,6 +59,13 @@ func Run(cb Callbacks) { } }) + mQuitAppOnly.Click(func() { + systray.Quit() + if cb.OnQuitAppOnly != nil { + cb.OnQuitAppOnly() + } + }) + mQuit.Click(func() { systray.Quit() if cb.OnQuit != nil { diff --git a/backend/internal/tray/tray_stub.go b/backend/internal/tray/tray_stub.go index 9d8adeb0..e20a4005 100644 --- a/backend/internal/tray/tray_stub.go +++ b/backend/internal/tray/tray_stub.go @@ -4,8 +4,9 @@ package tray // Callbacks 托盘回调 type Callbacks struct { - OnShow func() - OnQuit func() + OnShow func() + OnQuitAppOnly func() + OnQuit func() } // Run 非 Windows 平台无托盘实现,保持空操作。 diff --git a/backend/test/launchcode/server_auth_test.go b/backend/test/launchcode/server_auth_test.go new file mode 100644 index 00000000..8eaeb27c --- /dev/null +++ b/backend/test/launchcode/server_auth_test.go @@ -0,0 +1,81 @@ +package launchcode_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "ant-chrome/backend/internal/launchcode" +) + +func buildAuthProtectedTestHandler() http.Handler { + srv := launchcode.NewLaunchServer(newInMemoryService(), newMockStarter(), nil, 0) + srv.SetAPIAuthConfig(launchcode.APIAuthConfig{ + Enabled: true, + APIKey: "secret-key", + Header: "X-Test-Api-Key", + }) + return launchcode.NewTestHandler(srv) +} + +func TestAPIAuthRejectsMissingKey(t *testing.T) { + handler := buildAuthProtectedTestHandler() + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("缺少 API Key 时应返回 401: got=%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"] != "unauthorized: invalid api key" { + t.Fatalf("错误信息不正确: %+v", resp) + } + if resp["authHeader"] != "X-Test-Api-Key" { + t.Fatalf("应返回当前使用的认证 Header: %+v", resp) + } +} + +func TestAPIAuthRejectsWrongKey(t *testing.T) { + handler := buildAuthProtectedTestHandler() + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + req.Header.Set("X-Test-Api-Key", "wrong-key") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("错误 API Key 时应返回 401: got=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestAPIAuthAllowsCorrectKey(t *testing.T) { + handler := buildAuthProtectedTestHandler() + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + req.Header.Set("X-Test-Api-Key", "secret-key") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("正确 API Key 时应返回 200: got=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestAPIAuthDoesNotProtectCDPProxyRoutes(t *testing.T) { + handler := buildAuthProtectedTestHandler() + + req := httptest.NewRequest(http.MethodGet, "/json/version", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("CDP 路径不应被 API 认证拦截: got=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/backend/test/launchcode/server_profile_create_test.go b/backend/test/launchcode/server_profile_create_test.go new file mode 100644 index 00000000..a68f82fb --- /dev/null +++ b/backend/test/launchcode/server_profile_create_test.go @@ -0,0 +1,252 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" +) + +type managerBackedStarter struct { + mgr *browser.Manager + started []string + lastParams launchcode.LaunchRequestParams +} + +func (m *managerBackedStarter) StartInstance(profileID string) (*browser.Profile, error) { + profile, ok := m.mgr.Profiles[profileID] + if !ok || profile == nil { + return nil, fmt.Errorf("profile not found: %s", profileID) + } + + m.started = append(m.started, profileID) + profile.Running = true + profile.Pid = 4000 + len(m.started) + profile.DebugPort = 9300 + len(m.started) + profile.LastStartAt = time.Now().Format(time.RFC3339) + return profile, nil +} + +func (m *managerBackedStarter) StartInstanceWithParams(profileID string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { + m.lastParams = params + return m.StartInstance(profileID) +} + +func newProfileCreateTestManager(t *testing.T, configure func(*config.Config)) *browser.Manager { + t.Helper() + + cfg := config.DefaultConfig() + if configure != nil { + configure(cfg) + } + return browser.NewManager(cfg, t.TempDir()) +} + +func TestCreateProfileAPIStoresProxyAndMetadata(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { + cfg.Browser.Proxies = []config.BrowserProxy{ + { + ProxyId: "proxy-us", + ProxyName: "US Residential", + ProxyConfig: "socks5://127.0.0.1:1080", + }, + } + }) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + payload := bytes.NewBufferString(`{ + "profile": { + "profileName": "buyer-001", + "userDataDir": "buyers/buyer-001", + "proxyId": "proxy-us", + "launchArgs": ["--lang=en-US"], + "tags": ["电商", "北美"], + "keywords": ["buyer-001", "amazon"], + "groupId": "group-sales-us" + }, + "launchCode": "buyer_001" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("期望 201,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Created bool `json:"created"` + Launched bool `json:"launched"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` + Profile *browser.Profile `json:"profile"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + + if !resp.OK || !resp.Created || resp.Launched { + t.Fatalf("响应状态错误: %+v", resp) + } + if resp.Profile == nil { + t.Fatalf("响应缺少 profile: %+v", resp) + } + if resp.LaunchCode != "BUYER_001" { + t.Fatalf("launchCode 未归一化: %s", resp.LaunchCode) + } + if resp.Profile.ProxyId != "proxy-us" { + t.Fatalf("proxyId 不正确: %+v", resp.Profile) + } + if resp.Profile.ProxyConfig != "socks5://127.0.0.1:1080" { + t.Fatalf("proxyConfig 未按代理池解析: %+v", resp.Profile) + } + if resp.Profile.GroupId != "group-sales-us" { + t.Fatalf("groupId 不正确: %+v", resp.Profile) + } + if len(resp.Profile.Tags) != 2 || len(resp.Profile.Keywords) != 2 { + t.Fatalf("tags/keywords 不正确: %+v", resp.Profile) + } + + resolvedProfileID, err := svc.Resolve("BUYER_001") + if err != nil { + t.Fatalf("launchCode 未写入服务: %v", err) + } + if resolvedProfileID != resp.ProfileID { + t.Fatalf("launchCode 绑定的 profileId 错误: got=%s want=%s", resolvedProfileID, resp.ProfileID) + } +} + +func TestCreateProfileAPIAutoLaunchPassesStartParams(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + payload := bytes.NewBufferString(`{ + "profile": { + "profileName": "buyer-002", + "proxyConfig": "http://user:pass@127.0.0.1:8080", + "launchArgs": ["--disable-sync"], + "keywords": ["buyer-002"] + }, + "autoLaunch": true, + "start": { + "launchArgs": ["--window-size=1280,800", "--lang=en-US"], + "startUrls": ["https://example.com/order"], + "skipDefaultStartUrls": true + } + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("期望 201,实际 %d,body=%s", w.Code, w.Body.String()) + } + if len(starter.started) != 1 { + t.Fatalf("应自动启动 1 次,实际 %+v", starter.started) + } + if len(starter.lastParams.LaunchArgs) != 2 { + t.Fatalf("一次性 launchArgs 未透传: %+v", starter.lastParams) + } + if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com/order" { + t.Fatalf("startUrls 未透传: %+v", starter.lastParams) + } + if !starter.lastParams.SkipDefaultStartURLs { + t.Fatalf("skipDefaultStartUrls 未透传: %+v", starter.lastParams) + } + + var resp struct { + OK bool `json:"ok"` + Created bool `json:"created"` + Launched bool `json:"launched"` + CDPURL string `json:"cdpUrl"` + DebugPort int `json:"debugPort"` + Profile *browser.Profile `json:"profile"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + + if !resp.OK || !resp.Created || !resp.Launched { + t.Fatalf("响应状态错误: %+v", resp) + } + if resp.Profile == nil || !resp.Profile.Running { + t.Fatalf("自动启动后的 profile 状态错误: %+v", resp) + } + if resp.DebugPort == 0 || resp.CDPURL == "" { + t.Fatalf("缺少调试端口/CDP 地址: %+v", resp) + } + if resp.Profile.ProxyConfig != "http://user:pass@127.0.0.1:8080" { + t.Fatalf("直连代理配置未保存: %+v", resp.Profile) + } +} + +func TestCreateProfileAPIRejectsMissingProfile(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + req := httptest.NewRequest(http.MethodPost, "/api/profiles", bytes.NewBufferString(`{"launchCode":"buyer_003"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } +} + +func TestCreateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + existing, err := mgr.Create(browser.ProfileInput{ProfileName: "existing"}) + if err != nil { + t.Fatalf("预创建实例失败: %v", err) + } + if _, err := svc.SetCode(existing.ProfileId, "BUYER_DUP"); err != nil { + t.Fatalf("预设 launchCode 失败: %v", err) + } + + beforeCount := len(mgr.List()) + + payload := bytes.NewBufferString(`{ + "profile": { + "profileName": "new-buyer", + "keywords": ["new-buyer"] + }, + "launchCode": "BUYER_DUP" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload) + 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()) + } + + afterCount := len(mgr.List()) + if afterCount != beforeCount { + t.Fatalf("launchCode 冲突后应回滚创建: before=%d after=%d", beforeCount, afterCount) + } +} diff --git a/backend/test/launchcode/server_profile_manage_test.go b/backend/test/launchcode/server_profile_manage_test.go new file mode 100644 index 00000000..65da3a9e --- /dev/null +++ b/backend/test/launchcode/server_profile_manage_test.go @@ -0,0 +1,328 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" +) + +func TestListProfilesAPIIncludesLaunchCodes(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { + cfg.Browser.Proxies = []config.BrowserProxy{ + { + ProxyId: "proxy-us", + ProxyName: "US Residential", + ProxyConfig: "socks5://127.0.0.1:1080", + }, + } + }) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + first, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-a", + ProxyId: "proxy-us", + Tags: []string{"电商"}, + Keywords: []string{"buyer-a"}, + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + second, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-b", + ProxyConfig: "http://127.0.0.1:8080", + Keywords: []string{"buyer-b"}, + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + if _, err := svc.SetCode(first.ProfileId, "BUYER_A"); err != nil { + t.Fatalf("设置 launchCode 失败: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/profiles", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Count int `json:"count"` + Items []browser.Profile `json:"items"` + } + 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) + } + + seen := make(map[string]browser.Profile, len(resp.Items)) + for _, item := range resp.Items { + if item.LaunchCode == "" { + t.Fatalf("列表应返回 launchCode: %+v", resp.Items) + } + seen[item.ProfileId] = item + } + if _, ok := seen[first.ProfileId]; !ok { + t.Fatalf("列表缺少第一个实例: %+v", resp.Items) + } + if _, ok := seen[second.ProfileId]; !ok { + t.Fatalf("列表缺少第二个实例: %+v", resp.Items) + } +} + +func TestGetProfileAPIReturnsProfileByID(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + profile, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-get", + ProxyConfig: "http://127.0.0.1:8080", + Tags: []string{"北美"}, + Keywords: []string{"buyer-get"}, + GroupId: "group-get", + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + if _, err := svc.SetCode(profile.ProfileId, "BUYER_GET"); err != nil { + t.Fatalf("设置 launchCode 失败: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/profiles/"+profile.ProfileId, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + LaunchCode string `json:"launchCode"` + Profile *browser.Profile `json:"profile"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Profile == nil { + t.Fatalf("查询响应错误: %+v", resp) + } + if resp.LaunchCode != "BUYER_GET" || resp.Profile.GroupId != "group-get" { + t.Fatalf("查询字段错误: %+v", resp) + } +} + +func TestUpdateProfileAPIUpdatesFieldsAndAutoLaunches(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { + cfg.Browser.Proxies = []config.BrowserProxy{ + { + ProxyId: "proxy-us", + ProxyName: "US Residential", + ProxyConfig: "socks5://127.0.0.1:1080", + }, + } + }) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + profile, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-old", + ProxyConfig: "http://127.0.0.1:8080", + Keywords: []string{"buyer-old"}, + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + if _, err := svc.SetCode(profile.ProfileId, "BUYER_OLD"); err != nil { + t.Fatalf("设置 launchCode 失败: %v", err) + } + + payload := bytes.NewBufferString(`{ + "profile": { + "profileName": "buyer-new", + "userDataDir": "buyers/buyer-new", + "proxyId": "proxy-us", + "launchArgs": ["--lang=en-US"], + "tags": ["电商", "北美"], + "keywords": ["buyer-new", "amazon"], + "groupId": "group-sales-us" + }, + "launchCode": "BUYER_NEW", + "autoLaunch": true, + "start": { + "launchArgs": ["--window-size=1280,800"], + "startUrls": ["https://example.com/order"], + "skipDefaultStartUrls": true + } + }`) + + req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+profile.ProfileId, 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 len(starter.started) != 1 { + t.Fatalf("更新后应自动启动 1 次,实际 %+v", starter.started) + } + if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com/order" { + t.Fatalf("startUrls 未透传: %+v", starter.lastParams) + } + + updated, status, errMsg := handlerProfileSnapshot(t, mgr, svc, profile.ProfileId) + if errMsg != "" || status != http.StatusOK { + t.Fatalf("读取更新后实例失败: status=%d err=%s", status, errMsg) + } + if updated.ProfileName != "buyer-new" || updated.ProxyId != "proxy-us" || updated.ProxyConfig != "socks5://127.0.0.1:1080" { + t.Fatalf("更新未生效: %+v", updated) + } + if updated.GroupId != "group-sales-us" || updated.LaunchCode != "BUYER_NEW" || !updated.Running { + t.Fatalf("更新后的分组/launchCode/运行状态错误: %+v", updated) + } +} + +func TestUpdateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + first, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-first", + ProxyConfig: "http://127.0.0.1:8080", + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + second, err := mgr.Create(browser.ProfileInput{ + ProfileName: "buyer-second", + ProxyConfig: "http://127.0.0.1:9090", + }) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + if _, err := svc.SetCode(first.ProfileId, "BUYER_FIRST"); err != nil { + t.Fatalf("设置 launchCode 失败: %v", err) + } + if _, err := svc.SetCode(second.ProfileId, "BUYER_SECOND"); err != nil { + t.Fatalf("设置 launchCode 失败: %v", err) + } + + payload := bytes.NewBufferString(`{ + "profile": { + "profileName": "buyer-first-updated", + "proxyConfig": "http://127.0.0.1:10080", + "keywords": ["buyer-first-updated"] + }, + "launchCode": "BUYER_SECOND" + }`) + + req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+first.ProfileId, payload) + 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()) + } + + current, status, errMsg := handlerProfileSnapshot(t, mgr, svc, first.ProfileId) + if errMsg != "" || status != http.StatusOK { + t.Fatalf("读取回滚后实例失败: status=%d err=%s", status, errMsg) + } + if current.ProfileName != "buyer-first" || current.ProxyConfig != "http://127.0.0.1:8080" || current.LaunchCode != "BUYER_FIRST" { + t.Fatalf("launchCode 冲突后应回滚更新: %+v", current) + } +} + +func TestDeleteProfileAPIRemovesProfileAndLaunchCode(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + profile, err := mgr.Create(browser.ProfileInput{ProfileName: "buyer-delete"}) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + if _, err := svc.SetCode(profile.ProfileId, "BUYER_DELETE"); err != nil { + t.Fatalf("设置 launchCode 失败: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/profiles/"+profile.ProfileId, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if _, ok := mgr.Profiles[profile.ProfileId]; ok { + t.Fatalf("实例删除后仍存在于内存: %s", profile.ProfileId) + } + if _, err := svc.Resolve("BUYER_DELETE"); err == nil { + t.Fatal("删除后 launchCode 仍可解析") + } +} + +func TestDeleteProfileAPIRejectsRunningProfile(t *testing.T) { + svc := newInMemoryService() + mgr := newProfileCreateTestManager(t, nil) + starter := &managerBackedStarter{mgr: mgr} + handler := buildTestHandlerWithManager(svc, starter, mgr) + + profile, err := mgr.Create(browser.ProfileInput{ProfileName: "buyer-running"}) + if err != nil { + t.Fatalf("创建测试实例失败: %v", err) + } + mgr.Profiles[profile.ProfileId].Running = true + + req := httptest.NewRequest(http.MethodDelete, "/api/profiles/"+profile.ProfileId, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) + } + if _, ok := mgr.Profiles[profile.ProfileId]; !ok { + t.Fatalf("运行中实例不应被删除: %s", profile.ProfileId) + } +} + +func handlerProfileSnapshot(t *testing.T, mgr *browser.Manager, svc interface { + EnsureCode(profileID string) (string, error) +}, profileID string) (*browser.Profile, int, string) { + t.Helper() + + mgr.Mutex.Lock() + profile, ok := mgr.Profiles[profileID] + var snapshot browser.Profile + if ok && profile != nil { + snapshot = *profile + } + mgr.Mutex.Unlock() + if !ok { + return nil, http.StatusNotFound, "profile not found" + } + if snapshot.LaunchCode == "" { + if code, err := svc.EnsureCode(snapshot.ProfileId); err == nil { + snapshot.LaunchCode = code + } + } + return &snapshot, http.StatusOK, "" +} diff --git a/backend/test/launchcode/server_proxy_test.go b/backend/test/launchcode/server_proxy_test.go index 96759ed5..5eb6950c 100644 --- a/backend/test/launchcode/server_proxy_test.go +++ b/backend/test/launchcode/server_proxy_test.go @@ -122,3 +122,47 @@ func TestCDPProxySwitchesToLatestLaunchedProfile(t *testing.T) { } } } + +func TestCDPProxySkipsPendingDebugProfile(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarter() + profile := &browser.Profile{ + ProfileId: "profile-pending", + ProfileName: "Profile Pending", + Running: true, + Pid: 2001, + DebugPort: 9777, + DebugReady: false, + RuntimeWarning: "debug pending", + } + starter.addProfile(profile) + + code, err := svc.EnsureCode(profile.ProfileId) + if err != nil { + t.Fatalf("EnsureCode 失败: %v", err) + } + + handler := buildTestHandler(svc, starter) + + launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) + launchResp := httptest.NewRecorder() + handler.ServeHTTP(launchResp, launchReq) + if launchResp.Code != http.StatusOK { + t.Fatalf("启动请求失败: status=%d body=%s", launchResp.Code, launchResp.Body.String()) + } + + var launchPayload map[string]interface{} + if err := json.NewDecoder(launchResp.Body).Decode(&launchPayload); err != nil { + t.Fatalf("解析启动响应失败: %v", err) + } + if ready, _ := launchPayload["debugReady"].(bool); ready { + t.Fatalf("pending 实例不应被标记为 debugReady: %+v", launchPayload) + } + + proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil) + proxyResp := httptest.NewRecorder() + handler.ServeHTTP(proxyResp, proxyReq) + if proxyResp.Code != http.StatusServiceUnavailable { + t.Fatalf("pending 实例不应成为活动 CDP target: status=%d body=%s", proxyResp.Code, proxyResp.Body.String()) + } +} diff --git a/bat/README.md b/bat/README.md index fcd6851f..65e8cf2f 100644 --- a/bat/README.md +++ b/bat/README.md @@ -4,25 +4,72 @@ ## 用途 -- `dev.bat`:本地开发启动 +- `dev.bat`:统一的本地开发入口 - `build.bat`:本地构建可执行文件 - `publish.bat`:发布打包入口(Windows / Linux / 两者) +- `recover-profiles.ps1`:从现有 `user_data_root` 目录补回丢失的实例配置 ## 用法 ### `dev.bat` -适合日常开发。 +统一入口,按参数切换开发模式,避免多个 bat 文件误导使用者。 ```bat bat\dev.bat +bat\dev.bat live +bat\dev.bat limited ``` -说明: +模式说明: -- 默认优先使用 `5218` 作为前端开发端口 -- 如果发现同项目残留的 `dev-watcher / vite` 进程,会先自动清理 -- 如果 `5218` 被其他程序占用,会自动切换到下一个可用端口,并把该端口同步传给 Vite 和 Wails +- `bat\dev.bat`:默认稳定模式。先生成 Wails bindings,再构建 `frontend/dist`,最后以静态资源模式启动 Wails +- `bat\dev.bat live`:显式启动 `frontend/scripts/dev-watcher.mjs`,并通过 `-frontenddevserverurl` 接入 Vite dev server +- `bat\dev.bat limited`:在 `live` 基础上通过 `scripts/run-limited-frontend-dev.ps1` 给 watcher 及其子进程附加 Windows Job Object 内存限制 + +默认行为: + +- 稳定模式不依赖外部 Vite dev server,因此不会因为 watcher 或 `5218` 端口异常直接白屏 +- `live` 模式默认优先使用 `5218`,若端口被其他程序占用,会自动切换到下一个可用端口 +- watcher 默认 `FRONTEND_NODE_RSS_HARD_LIMIT_MB=0`,即只告警,不默认 RSS 强杀 +- `limited` 模式默认 `FRONTEND_PROCESS_MEMORY_LIMIT_MB=512` + +常用内存控制变量: + +```text +FRONTEND_PROCESS_MEMORY_LIMIT_MB +FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB +FRONTEND_NODE_MAX_SEMI_SPACE_SIZE_MB +FRONTEND_NODE_RSS_WARN_MB +FRONTEND_NODE_RSS_HARD_LIMIT_MB +FRONTEND_NODE_RSS_HARD_LIMIT_HITS +FRONTEND_NODE_RSS_AUTO_RESTART +FRONTEND_NODE_RSS_RESTART_DELAY_MS +FRONTEND_NODE_RSS_RESTART_MAX_COUNT +FRONTEND_NODE_RSS_RESTART_WINDOW_MS +FRONTEND_NODE_MEMORY_POLL_MS +FRONTEND_DISABLE_HMR +``` + +开发代理相关变量: + +```text +DEV_PROXY_URL -> 为 npm / Node / Go 下载流量注入 HTTP(S) 代理 +DEV_NO_PROXY -> 设置 NO_PROXY / no_proxy +DEV_GOPROXY -> 覆盖 GOPROXY;未设置时默认使用 https://goproxy.cn,direct +``` + +日志: + +- `live` / `limited` 模式的 watcher 日志会写入仓库根目录: +- `tmp-npm-dev.log` +- `tmp-npm-dev.err.log` + +FAQ: + +- 为什么默认模式没有 HMR:因为默认入口优先保证桌面壳可用性,不依赖外部 Vite +- 什么情况下用 `bat\dev.bat live`:页面样式、交互、接口联调需要快速热更新时 +- 什么情况下用 `bat\dev.bat limited`:低内存机器、复现 Vite 内存膨胀、或需要显式进程级内存约束时 ### `build.bat` @@ -131,6 +178,35 @@ Windows 产物: publish\output\AntBrowser-Setup-.exe ``` +### `recover-profiles.ps1` + +用于“实例配置丢了,但 `data\` 目录还在”的恢复场景。 + +默认只预览,不写数据库: + +```powershell +pwsh -File bat/recover-profiles.ps1 -AppRoot 'E:\software\Ant Browser' +``` + +确认结果后再写回 `app.db`: + +```powershell +pwsh -File bat/recover-profiles.ps1 -AppRoot 'E:\software\Ant Browser' -Apply +``` + +如果旧目录来自备份恢复,且怀疑存在跨内核残留状态,可同时为“风险目录”创建一份 `__repair_时间戳` 副本,再将新配置指向副本: + +```powershell +pwsh -File bat/recover-profiles.ps1 -AppRoot 'E:\software\Ant Browser' -Apply -RepairRisky +``` + +说明: + +- 脚本会调用 `go run ./backend/cmd/profile-recover` +- `-Apply` 模式会先在 `data\recovery-backups\` 下备份当前数据库文件 +- 默认不会删除旧目录,也不会主动清理登录态文件 +- 运行 `-Apply` 前应先关闭 Ant Browser,避免并发写库 + ## 备注 - `generate-bindings.bat` 是辅助脚本,通常由 `build.bat` 调用。 diff --git a/bat/dev.bat b/bat/dev.bat index 26ba5382..165f4d7f 100644 --- a/bat/dev.bat +++ b/bat/dev.bat @@ -2,128 +2,472 @@ setlocal EnableExtensions EnableDelayedExpansion cd /d "%~dp0.." +set "EXIT_CODE=0" +set "NO_PAUSE=0" +set "SHOW_USAGE=0" +set "DEV_MODE=stable" +set "LIMITED_WATCHER_PID_FILE=tmp-frontend-limited-watcher.pid" +set "PREFERRED_FRONTEND_PORT=5218" +set "FRONTEND_PORT=" +set "WATCHER_PID=" +set "WATCHER_STARTED=0" +call :parse_args %* +if errorlevel 1 ( + set "EXIT_CODE=1" + goto :finish +) + +if "%SHOW_USAGE%"=="1" ( + call :print_usage + goto :finish +) + +if /I "%DEV_MODE%"=="stable" ( + call :run_stable + set "EXIT_CODE=%errorlevel%" + goto :finish +) + +if /I "%DEV_MODE%"=="live" ( + call :run_live 0 + set "EXIT_CODE=%errorlevel%" + goto :finish +) + +if /I "%DEV_MODE%"=="limited" ( + call :run_live 1 + set "EXIT_CODE=%errorlevel%" + goto :finish +) + +echo [ERROR] Unsupported dev mode: %DEV_MODE% +set "EXIT_CODE=1" + +:finish +if "%WATCHER_STARTED%"=="1" call :cleanup_watcher >nul 2>&1 +if "%NO_PAUSE%"=="1" exit /b %EXIT_CODE% +if "%CI%"=="1" exit /b %EXIT_CODE% + +pause +exit /b %EXIT_CODE% + +:parse_args +if "%~1"=="" exit /b 0 +if /I "%~1"=="--no-pause" ( + set "NO_PAUSE=1" + shift + goto :parse_args +) +if /I "%~1"=="--help" ( + set "SHOW_USAGE=1" + shift + goto :parse_args +) +if /I "%~1"=="-h" ( + set "SHOW_USAGE=1" + shift + goto :parse_args +) +if /I "%~1"=="stable" ( + set "DEV_MODE=stable" + shift + goto :parse_args +) +if /I "%~1"=="live" ( + set "DEV_MODE=live" + shift + goto :parse_args +) +if /I "%~1"=="limited" ( + set "DEV_MODE=limited" + shift + goto :parse_args +) + +echo [ERROR] Unsupported argument: %~1 +echo. +call :print_usage +exit /b 1 + +:print_usage +echo Usage: +echo bat\dev.bat [stable^|live^|limited] [--no-pause] +echo. +echo Modes: +echo stable Default. Build frontend static assets and start Wails without Vite dev server. +echo live Start Vite watcher and connect Wails to the frontend dev server. +echo limited Same as live, but add Windows Job Object memory limits to the watcher chain. +echo. +echo Examples: +echo bat\dev.bat +echo bat\dev.bat live +echo bat\dev.bat limited --no-pause +exit /b 0 + +:run_stable echo ======================================== echo Ant Chrome - Dev Launcher echo ======================================== echo. echo Current workdir: %CD% +echo Mode: stable echo. call :cleanup_dev_logs +call :apply_proxy_settings -set PREFERRED_FRONTEND_PORT=5218 -set FRONTEND_PORT= +echo Frontend mode: stable static assets +echo Frontend build: one-shot npm run build +echo Wails frontend dev server: disabled +call :print_proxy_settings -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 +call :cleanup_app_processes +call :cleanup_frontend_dev_processes warn +if errorlevel 1 exit /b 1 -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. +call :cleanup_dev_binary +if errorlevel 1 exit /b 1 -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. +call :prepare_tooling +if errorlevel 1 exit /b 1 -set GOPROXY=https://goproxy.cn,direct - -echo Checking dependencies... -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" ( - echo Installing frontend dependencies... - pushd frontend - call npm install - popd -) -echo. - -echo Regenerating Wails bindings... -call bat\generate-bindings.bat --no-pause -if errorlevel 1 ( - echo [ERROR] Failed to generate Wails bindings. - pause - exit /b 1 -) -if not exist "frontend\src\wailsjs" ( - echo [ERROR] Wails bindings output folder not found. - pause - exit /b 1 -) -echo. +call :build_frontend_assets +if errorlevel 1 exit /b 1 echo Starting Wails dev... -echo Frontend URL: http://127.0.0.1:%FRONTEND_PORT% -echo Wails dev endpoint: http://127.0.0.1:%FRONTEND_PORT% +echo Asset source: frontend\dist +echo Auto reload: disabled echo. -wails dev -s -viteservertimeout 60 -set EXIT_CODE=%errorlevel% +wails dev -m -nogorebuild -noreload -s -skipbindings -assetdir frontend/dist +set "EXIT_CODE=%errorlevel%" if not "%EXIT_CODE%"=="0" ( echo. echo [ERROR] wails dev exited with code %EXIT_CODE%. ) -pause exit /b %EXIT_CODE% +:run_live +set "FRONTEND_LIMITED_MODE=%~1" + +if "%FRONTEND_LIMITED_MODE%"=="1" ( + if not defined FRONTEND_PROCESS_MEMORY_LIMIT_MB set "FRONTEND_PROCESS_MEMORY_LIMIT_MB=512" + if not defined FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB set "FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB=256" + if not defined FRONTEND_NODE_RSS_WARN_MB set "FRONTEND_NODE_RSS_WARN_MB=256" + if not defined FRONTEND_NODE_RSS_AUTO_RESTART set "FRONTEND_NODE_RSS_AUTO_RESTART=0" + if not defined FRONTEND_NODE_RSS_RESTART_MAX_COUNT set "FRONTEND_NODE_RSS_RESTART_MAX_COUNT=1" +) else ( + if not defined FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB set "FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB=512" + if not defined FRONTEND_NODE_RSS_WARN_MB set "FRONTEND_NODE_RSS_WARN_MB=384" + if not defined FRONTEND_NODE_RSS_AUTO_RESTART set "FRONTEND_NODE_RSS_AUTO_RESTART=1" + if not defined FRONTEND_NODE_RSS_RESTART_MAX_COUNT set "FRONTEND_NODE_RSS_RESTART_MAX_COUNT=3" +) +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_HARD_LIMIT_MB set "FRONTEND_NODE_RSS_HARD_LIMIT_MB=0" +if not defined FRONTEND_NODE_RSS_HARD_LIMIT_HITS set "FRONTEND_NODE_RSS_HARD_LIMIT_HITS=3" +if not defined FRONTEND_NODE_RSS_RESTART_DELAY_MS set "FRONTEND_NODE_RSS_RESTART_DELAY_MS=1500" +if not defined FRONTEND_NODE_RSS_RESTART_WINDOW_MS set "FRONTEND_NODE_RSS_RESTART_WINDOW_MS=300000" +if not defined FRONTEND_NODE_MEMORY_POLL_MS set "FRONTEND_NODE_MEMORY_POLL_MS=3000" +if not defined FRONTEND_DISABLE_HMR set "FRONTEND_DISABLE_HMR=0" + +echo ======================================== +echo Ant Chrome - Dev Launcher +echo ======================================== +echo. +echo Current workdir: %CD% +if "%FRONTEND_LIMITED_MODE%"=="1" ( + echo Mode: limited +) else ( + echo Mode: live +) +echo. + +call :cleanup_dev_logs +call :apply_proxy_settings + +if "%FRONTEND_LIMITED_MODE%"=="1" ( + echo Frontend mode: live dev server with Job Object memory limit +) else ( + echo Frontend mode: live dev server +) +echo Preferred 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 hard-limit hits: %FRONTEND_NODE_RSS_HARD_LIMIT_HITS% +echo Frontend Node RSS auto restart: %FRONTEND_NODE_RSS_AUTO_RESTART% +echo Frontend Node RSS restart delay: %FRONTEND_NODE_RSS_RESTART_DELAY_MS% ms +echo Frontend Node RSS restart max count: %FRONTEND_NODE_RSS_RESTART_MAX_COUNT% +echo Frontend Node RSS restart window: %FRONTEND_NODE_RSS_RESTART_WINDOW_MS% ms +echo Frontend Node RSS poll interval: %FRONTEND_NODE_MEMORY_POLL_MS% ms +echo Frontend HMR disabled: %FRONTEND_DISABLE_HMR% +if "%FRONTEND_LIMITED_MODE%"=="1" echo Frontend process memory limit: %FRONTEND_PROCESS_MEMORY_LIMIT_MB% MB +call :print_proxy_settings + +call :cleanup_app_processes +call :cleanup_frontend_dev_processes strict +if errorlevel 1 exit /b 1 + +call :cleanup_dev_binary +if errorlevel 1 exit /b 1 + +call :resolve_frontend_dev_port +if errorlevel 1 exit /b 1 + +call :prepare_tooling +if errorlevel 1 exit /b 1 + +call :ensure_embed_dist +if errorlevel 1 exit /b 1 + +call :start_watcher +if errorlevel 1 exit /b 1 + +call :wait_for_frontend_dev_server +if errorlevel 1 exit /b 1 +echo. + +echo Starting Wails dev... +echo Frontend URL: http://127.0.0.1:%FRONTEND_PORT% +echo. + +wails dev -m -s -skipbindings -frontenddevserverurl http://127.0.0.1:%FRONTEND_PORT% -viteservertimeout 60 +set "EXIT_CODE=%errorlevel%" + +if not "%EXIT_CODE%"=="0" ( + echo. + echo [ERROR] wails dev exited with code %EXIT_CODE%. +) + +exit /b %EXIT_CODE% + +:apply_proxy_settings +if defined DEV_PROXY_URL ( + set "HTTP_PROXY=%DEV_PROXY_URL%" + set "HTTPS_PROXY=%DEV_PROXY_URL%" + set "http_proxy=%DEV_PROXY_URL%" + set "https_proxy=%DEV_PROXY_URL%" +) +if defined DEV_NO_PROXY ( + set "NO_PROXY=%DEV_NO_PROXY%" + set "no_proxy=%DEV_NO_PROXY%" +) +if defined DEV_GOPROXY set "GOPROXY=%DEV_GOPROXY%" +if not defined DEV_GOPROXY if not defined GOPROXY set "GOPROXY=https://goproxy.cn,direct" +exit /b 0 + +:print_proxy_settings +if defined DEV_PROXY_URL ( + echo HTTP/HTTPS proxy: %DEV_PROXY_URL% +) else ( + echo HTTP/HTTPS proxy: disabled +) +if defined DEV_NO_PROXY ( + echo NO_PROXY: %DEV_NO_PROXY% +) +echo Go proxy: %GOPROXY% +echo. +exit /b 0 + +:cleanup_app_processes +echo Cleaning stale app processes... +taskkill /F /IM ant-chrome-dev.exe >nul 2>&1 +echo. +exit /b 0 + +:cleanup_frontend_dev_processes +echo Cleaning stale frontend dev processes... +node frontend\scripts\dev-port-helper.mjs cleanup +if errorlevel 1 ( + if /I "%~1"=="warn" ( + echo [WARN] Failed to clean stale frontend dev processes. Continuing... + echo. + exit /b 0 + ) + echo [ERROR] Failed to clean stale frontend dev processes. + echo. + exit /b 1 +) +echo. +exit /b 0 + +:cleanup_dev_binary +echo Removing stale dev binary... +if exist "build\bin\ant-chrome-dev.exe" ( + powershell -NoProfile -Command "$p='build\\bin\\ant-chrome-dev.exe'; for($i=0;$i -lt 5;$i++){ if(-not (Test-Path $p)){ exit 0 }; Remove-Item -Path $p -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1 }; if(Test-Path $p){ exit 2 } else { exit 0 }" + if errorlevel 1 ( + echo [ERROR] Cannot remove build\bin\ant-chrome-dev.exe. + echo End ant-chrome-dev.exe in Task Manager and retry. + exit /b 1 + ) +) +if exist "build\bin\ant-chrome-dev.exe~" del /F /Q "build\bin\ant-chrome-dev.exe~" >nul 2>&1 +echo. +exit /b 0 + +:resolve_frontend_dev_port +echo Resolving frontend dev port... +set "FRONTEND_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. + exit /b 1 +) +echo [OK] Frontend dev port: %FRONTEND_PORT% +echo. +exit /b 0 + +:prepare_tooling +call :check_dependencies +if errorlevel 1 exit /b 1 + +call :download_go_dependencies +if errorlevel 1 exit /b 1 + +call :install_frontend_dependencies +if errorlevel 1 exit /b 1 + +call :regenerate_bindings +if errorlevel 1 exit /b 1 + +exit /b 0 + +:check_dependencies +echo Checking dependencies... +if not exist "go.mod" ( + echo [ERROR] go.mod not found in repository root. + exit /b 1 +) +if not exist "wails.json" ( + echo [ERROR] wails.json not found in repository root. + exit /b 1 +) +exit /b 0 + +:download_go_dependencies +echo Downloading Go dependencies... +go mod download +if errorlevel 1 ( + echo [ERROR] Failed to download Go dependencies. + exit /b 1 +) +exit /b 0 + +:install_frontend_dependencies +if not exist "frontend\node_modules" ( + echo Installing frontend dependencies... + pushd frontend + call npm install + set "NPM_INSTALL_EXIT_CODE=!errorlevel!" + popd + if not "!NPM_INSTALL_EXIT_CODE!"=="0" ( + echo [ERROR] Failed to install frontend dependencies. + exit /b 1 + ) +) +echo. +exit /b 0 + +:regenerate_bindings +echo Regenerating Wails bindings... +call bat\generate-bindings.bat --no-pause +if errorlevel 1 ( + echo [ERROR] Failed to generate Wails bindings. + exit /b 1 +) +if not exist "frontend\src\wailsjs" ( + echo [ERROR] Wails bindings output folder not found. + exit /b 1 +) +echo. +exit /b 0 + +:build_frontend_assets +echo Building frontend static assets... +pushd frontend +call npm run build +set "FRONTEND_BUILD_EXIT_CODE=!errorlevel!" +popd +if not "!FRONTEND_BUILD_EXIT_CODE!"=="0" ( + echo [ERROR] Frontend build failed. + exit /b 1 +) +if not exist "frontend\dist\index.html" ( + echo [ERROR] frontend\dist\index.html was not generated. + exit /b 1 +) +echo. +exit /b 0 + +:ensure_embed_dist +if not exist "frontend\dist" ( + mkdir "frontend\dist" >nul 2>&1 +) +if not exist "frontend\dist\__wails_placeholder__.txt" ( + echo placeholder> "frontend\dist\__wails_placeholder__.txt" +) +if not exist "frontend\dist" ( + echo [ERROR] Failed to prepare frontend\dist for go:embed. + exit /b 1 +) +exit /b 0 + +:wait_for_frontend_dev_server +powershell -NoProfile -Command "$port=%FRONTEND_PORT%; $pid=%WATCHER_PID%; $deadline=(Get-Date).AddSeconds(20); while((Get-Date) -lt $deadline){ $listener = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1; if($listener){ exit 0 }; if(-not (Get-Process -Id $pid -ErrorAction SilentlyContinue)){ exit 2 }; Start-Sleep -Milliseconds 500 }; exit 1" +if "%errorlevel%"=="0" ( + echo [OK] Frontend dev server is listening on %FRONTEND_PORT%. + exit /b 0 +) +if "%errorlevel%"=="2" ( + echo [ERROR] Frontend watcher exited before the dev server became ready. +) else ( + echo [ERROR] Timed out waiting for the frontend dev server on port %FRONTEND_PORT%. +) +if exist "tmp-npm-dev.err.log" type "tmp-npm-dev.err.log" +exit /b 1 + +:cleanup_watcher +if defined WATCHER_PID ( + taskkill /F /T /PID %WATCHER_PID% >nul 2>&1 +) +if exist "%LIMITED_WATCHER_PID_FILE%" del /F /Q "%LIMITED_WATCHER_PID_FILE%" >nul 2>&1 +node frontend\scripts\dev-port-helper.mjs cleanup >nul 2>&1 +set "WATCHER_STARTED=0" +exit /b 0 + +:start_watcher +echo Starting frontend watcher... +set "WATCHER_PID=" +if "%FRONTEND_LIMITED_MODE%"=="1" ( + for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$p = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','scripts/run-limited-frontend-dev.ps1','-WorkingDirectory','%CD%','-MemoryLimitMB','%FRONTEND_PROCESS_MEMORY_LIMIT_MB%','-MaxOldSpaceMB','%FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB%','-MaxSemiSpaceMB','%FRONTEND_NODE_MAX_SEMI_SPACE_SIZE_MB%','-PidFile','%LIMITED_WATCHER_PID_FILE%') -WorkingDirectory '%CD%' -RedirectStandardOutput 'tmp-npm-dev.log' -RedirectStandardError 'tmp-npm-dev.err.log' -PassThru; Write-Output $p.Id"`) do ( + if not defined WATCHER_PID set "WATCHER_PID=%%a" + ) +) else ( + for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$p = Start-Process -FilePath 'node' -ArgumentList @('frontend/scripts/dev-watcher.mjs') -WorkingDirectory '%CD%' -RedirectStandardOutput 'tmp-npm-dev.log' -RedirectStandardError 'tmp-npm-dev.err.log' -PassThru; Write-Output $p.Id"`) do ( + if not defined WATCHER_PID set "WATCHER_PID=%%a" + ) +) +if not defined WATCHER_PID ( + echo [ERROR] Failed to start frontend watcher. + exit /b 1 +) +set "WATCHER_STARTED=1" +echo [OK] Frontend watcher PID: %WATCHER_PID% +echo Watcher logs: tmp-npm-dev.log / tmp-npm-dev.err.log +echo. +exit /b 0 + :cleanup_dev_logs for %%f in ( "tmp-npm-dev.err.log" "tmp-npm-dev.log" + "tmp-frontend-limited-watcher.pid" "tmp-wails-err.log" "tmp-wails-out.log" "tmp-wails2-err.log" diff --git a/bat/publish.ps1 b/bat/publish.ps1 index a3a50011..2a6e2203 100644 --- a/bat/publish.ps1 +++ b/bat/publish.ps1 @@ -297,19 +297,19 @@ function Assert-RuntimeHashes { continue } if ($expectedHash -eq "" -or $expectedHash.Contains("todo_replace_with_sha256")) { - $errors.Add("$relativePath: sha256 is not initialized") + $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") + $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)") + $errors.Add("${relativePath}: sha256 mismatch (expected $expectedHash, got $actualHash)") } } diff --git a/bat/recover-profiles.ps1 b/bat/recover-profiles.ps1 new file mode 100644 index 00000000..e4df8f5d --- /dev/null +++ b/bat/recover-profiles.ps1 @@ -0,0 +1,66 @@ +param( + [string]$AppRoot = '.', + [switch]$Apply, + [switch]$RepairRisky, + [string[]]$Only +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +try { + $goCmd = Get-Command go -ErrorAction Stop +} catch { + throw "Go was not found in PATH. Install Go before running this script." +} + +$resolvedAppRoot = $AppRoot +if (-not [System.IO.Path]::IsPathRooted($resolvedAppRoot)) { + $resolvedAppRoot = [System.IO.Path]::GetFullPath((Join-Path (Get-Location) $resolvedAppRoot)) +} + +$toolArgs = @( + 'run', + './backend/cmd/profile-recover', + '--app-root', + $resolvedAppRoot +) + +if ($Apply) { + $toolArgs += '--apply' +} + +if ($RepairRisky) { + $toolArgs += @('--repair-strategy', 'risky') +} + +if ($Only -and $Only.Count -gt 0) { + $joined = ($Only | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join ',' + if ($joined) { + $toolArgs += @('--only', $joined) + } +} + +Write-Host "RepoRoot: $repoRoot" +Write-Host "AppRoot: $resolvedAppRoot" +if ($Apply) { + Write-Host "Mode: apply" + Write-Host "Notice: Close Ant Browser before apply mode." +} else { + Write-Host "Mode: preview" +} +if ($RepairRisky) { + Write-Host "Repair: risky" +} + +Push-Location $repoRoot +try { + & $goCmd.Source @toolArgs + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} finally { + Pop-Location +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0c9e2a7..bd0b35e0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1389,13 +1389,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.28", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", - "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", "dev": true, - "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -1479,9 +1481,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "dev": true, "funding": [ { @@ -1496,8 +1498,7 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/ccount": { "version": "2.0.1", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 00000000..257f3d9f --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +7eabda3c0c6240dd458970bfdd19c33c \ No newline at end of file diff --git a/frontend/scripts/dev-watcher.mjs b/frontend/scripts/dev-watcher.mjs index 32034f59..645990c4 100644 --- a/frontend/scripts/dev-watcher.mjs +++ b/frontend/scripts/dev-watcher.mjs @@ -5,10 +5,15 @@ import { fileURLToPath } from 'node:url' const scriptDir = dirname(fileURLToPath(import.meta.url)) const frontendDir = resolve(scriptDir, '..') const defaultVitePort = 5218 -const defaultMaxOldSpaceSizeMb = 256 +const defaultMaxOldSpaceSizeMb = 512 const defaultMaxSemiSpaceSizeMb = 16 -const defaultRssWarnMb = 256 -const defaultRssHardLimitMb = 360 +const defaultRssWarnMb = 384 +const defaultRssHardLimitMb = 0 +const defaultRssHardLimitHits = 3 +const defaultRssAutoRestart = false +const defaultRssRestartDelayMs = 1500 +const defaultRssRestartMaxCount = 3 +const defaultRssRestartWindowMs = 300000 const defaultMemoryPollMs = 3000 const nodeExecutable = process.execPath const ensureNativeScript = resolve(frontendDir, 'scripts', 'ensure-rollup-native.mjs') @@ -45,6 +50,33 @@ function resolvePositiveInteger(rawValue, fallbackValue) { return fallbackValue } +function resolveNonNegativeInteger(rawValue, fallbackValue) { + const raw = String(rawValue ?? '').trim() + if (!raw) { + return fallbackValue + } + + const parsed = Number.parseInt(raw, 10) + if (Number.isInteger(parsed) && parsed >= 0) { + return parsed + } + return fallbackValue +} + +function resolveBoolean(rawValue, fallbackValue) { + const raw = String(rawValue ?? '').trim().toLowerCase() + if (!raw) { + return fallbackValue + } + if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on') { + return true + } + if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'off') { + return false + } + return fallbackValue +} + function resolveNodeArgs(env) { const maxOldSpaceSizeMb = resolvePositiveInteger( env.FRONTEND_NODE_MAX_OLD_SPACE_SIZE_MB, @@ -126,11 +158,19 @@ function readProcessRssMb(pid) { return Math.round(rssKb / 1024) } -function startMemoryWatcher(child, env) { +function startMemoryWatcher(child, env, onHardLimitReached) { const rssWarnMb = resolvePositiveInteger(env.FRONTEND_NODE_RSS_WARN_MB, defaultRssWarnMb) - const rssHardLimitMb = resolvePositiveInteger(env.FRONTEND_NODE_RSS_HARD_LIMIT_MB, defaultRssHardLimitMb) + const rssHardLimitMb = resolveNonNegativeInteger( + env.FRONTEND_NODE_RSS_HARD_LIMIT_MB, + defaultRssHardLimitMb, + ) + const rssHardLimitHits = resolvePositiveInteger( + env.FRONTEND_NODE_RSS_HARD_LIMIT_HITS, + defaultRssHardLimitHits, + ) const pollMs = resolvePositiveInteger(env.FRONTEND_NODE_MEMORY_POLL_MS, defaultMemoryPollMs) let warnedAtMb = 0 + let overHardLimitHits = 0 const timer = setInterval(() => { if (!child.pid || child.exitCode !== null) { @@ -148,9 +188,30 @@ function startMemoryWatcher(child, env) { } if (rssHardLimitMb > 0 && rssMb >= rssHardLimitMb) { - console.error(`[dev] vite RSS reached ${rssMb} MB, exceeding hard limit ${rssHardLimitMb} MB. stopping dev server.`) - killProcessTree(child.pid) + overHardLimitHits += 1 + + if (overHardLimitHits >= rssHardLimitHits) { + console.error( + `[dev] vite RSS reached ${rssMb} MB, exceeding hard limit ${rssHardLimitMb} MB for ${overHardLimitHits}/${rssHardLimitHits} checks. stopping Vite child.`, + ) + try { + onHardLimitReached?.({ + rssMb, + rssHardLimitMb, + hits: overHardLimitHits, + requiredHits: rssHardLimitHits, + }) + } catch {} + killProcessTree(child.pid) + } else { + console.warn( + `[dev] vite RSS reached ${rssMb} MB (hard limit ${rssHardLimitMb} MB), hit ${overHardLimitHits}/${rssHardLimitHits}. waiting before taking action.`, + ) + } + return } + + overHardLimitHits = 0 }, pollMs) timer.unref?.() @@ -168,32 +229,146 @@ function main() { ensureNativeRuntime(childEnv) const nodeArgs = resolveNodeArgs(childEnv) + const rssHardLimitMb = resolveNonNegativeInteger( + childEnv.FRONTEND_NODE_RSS_HARD_LIMIT_MB, + defaultRssHardLimitMb, + ) + const rssHardLimitHits = resolvePositiveInteger( + childEnv.FRONTEND_NODE_RSS_HARD_LIMIT_HITS, + defaultRssHardLimitHits, + ) + const rssAutoRestartEnabled = resolveBoolean( + childEnv.FRONTEND_NODE_RSS_AUTO_RESTART, + defaultRssAutoRestart, + ) + const rssRestartDelayMs = resolvePositiveInteger( + childEnv.FRONTEND_NODE_RSS_RESTART_DELAY_MS, + defaultRssRestartDelayMs, + ) + const rssRestartMaxCount = resolvePositiveInteger( + childEnv.FRONTEND_NODE_RSS_RESTART_MAX_COUNT, + defaultRssRestartMaxCount, + ) + const rssRestartWindowMs = resolvePositiveInteger( + childEnv.FRONTEND_NODE_RSS_RESTART_WINDOW_MS, + defaultRssRestartWindowMs, + ) + const hardLimitDisplay = rssHardLimitMb > 0 ? `${rssHardLimitMb} MB` : 'disabled' + const restartDisplay = rssAutoRestartEnabled + ? `on(${rssRestartMaxCount}/${rssRestartWindowMs}ms delay=${rssRestartDelayMs}ms)` + : 'off' + 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`, + `[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=${hardLimitDisplay} --rss-hard-limit-hits=${rssHardLimitHits} --rss-auto-restart=${restartDisplay}`, ) - const child = spawn(nodeExecutable, nodeArgs.args, { - cwd: frontendDir, - stdio: 'inherit', - env: childEnv, - }) - const memoryWatcher = startMemoryWatcher(child, childEnv) let shuttingDown = false + let child = null + let memoryWatcher = null + let childKilledByRssLimit = false + let restartTimer = null + let rssRestartTimestamps = [] + + const clearRestartTimer = () => { + if (restartTimer) { + clearTimeout(restartTimer) + restartTimer = null + } + } + + const clearMemoryWatcher = () => { + if (memoryWatcher) { + clearInterval(memoryWatcher) + memoryWatcher = null + } + } + + const canRestartAfterRssLimit = () => { + if (!rssAutoRestartEnabled) { + return false + } + + const now = Date.now() + rssRestartTimestamps = rssRestartTimestamps.filter((timestamp) => now - timestamp <= rssRestartWindowMs) + if (rssRestartTimestamps.length >= rssRestartMaxCount) { + return false + } + + rssRestartTimestamps.push(now) + return true + } const shutdown = (exitCode = 0) => { if (shuttingDown) { return } + shuttingDown = true - if (memoryWatcher) { - clearInterval(memoryWatcher) - } - if (child.pid && child.exitCode === null) { + clearRestartTimer() + clearMemoryWatcher() + + if (child && child.pid && child.exitCode === null) { killProcessTree(child.pid) } + process.exit(exitCode) } + const launchViteChild = () => { + childKilledByRssLimit = false + child = spawn(nodeExecutable, nodeArgs.args, { + cwd: frontendDir, + stdio: 'inherit', + env: childEnv, + }) + + memoryWatcher = startMemoryWatcher(child, childEnv, () => { + childKilledByRssLimit = true + }) + + child.on('error', (error) => { + console.error(`[dev] failed to start Vite: ${error instanceof Error ? error.message : String(error)}`) + shutdown(1) + }) + + child.on('exit', (code, signal) => { + clearMemoryWatcher() + child = null + + if (shuttingDown) { + return + } + + if (childKilledByRssLimit) { + if (!canRestartAfterRssLimit()) { + console.error( + `[dev] vite exceeded RSS hard limit repeatedly and auto restart budget is exhausted (${rssRestartMaxCount} times / ${rssRestartWindowMs}ms).`, + ) + process.exit(1) + return + } + + console.warn(`[dev] restarting Vite after RSS hard-limit stop in ${rssRestartDelayMs}ms...`) + restartTimer = setTimeout(() => { + restartTimer = null + if (!shuttingDown) { + launchViteChild() + } + }, rssRestartDelayMs) + restartTimer.unref?.() + return + } + + if (signal) { + console.error(`[dev] vite exited with signal ${signal}`) + process.exit(1) + return + } + + process.exit(code ?? 0) + }) + } + const handleSignal = (signal) => { console.log(`[dev] received ${signal}, stopping Vite...`) shutdown(0) @@ -202,30 +377,15 @@ function main() { process.on('SIGINT', handleSignal) process.on('SIGTERM', handleSignal) process.on('exit', () => { - if (memoryWatcher) { - clearInterval(memoryWatcher) - } - if (child.pid && child.exitCode === null) { + clearRestartTimer() + clearMemoryWatcher() + + if (child && 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)}`) - shutdown(1) - }) - - child.on('exit', (code, signal) => { - if (memoryWatcher) { - clearInterval(memoryWatcher) - } - if (signal) { - console.error(`[dev] vite exited with signal ${signal}`) - process.exit(1) - return - } - process.exit(code ?? 0) - }) + launchViteChild() } try { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da6d28cf..e92d9fe8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,7 @@ import { ToastContainer, Modal, Button, Loading } from './shared/components' import { AlertCircle } from 'lucide-react' import { useNotificationStore } from './store/notificationStore' import { useBackupStore } from './store/backupStore' -import { ForceQuit as ForceQuitApp } from './wailsjs/go/main/App' +import { ForceQuit as ForceQuitApp, QuitAppOnly as QuitAppOnlyApp } from './wailsjs/go/main/App' import { Environment, Quit, WindowHide, WindowMinimise } from './wailsjs/runtime/runtime' function lazyNamed>>( @@ -92,16 +92,19 @@ function useWailsNotifications() { function CloseConfirmModal() { const [open, setOpen] = useState(false) const [platform, setPlatform] = useState('windows') + const [quittingAction, setQuittingAction] = useState<'app-only' | 'app-and-browser' | null>(null) const importInProgress = useBackupStore((s) => s.importInProgress) const importProgress = useBackupStore((s) => s.importProgress) const importMessage = useBackupStore((s) => s.importMessage) const supportsTray = platform === 'windows' + const quitting = quittingAction !== null useEffect(() => { const runtime = (window as any).runtime if (!runtime?.EventsOn) return const off = runtime.EventsOn('app:request-close', () => { + setQuittingAction(null) setOpen(true) }) return () => { @@ -125,7 +128,13 @@ function CloseConfirmModal() { } }, []) + const closeModal = () => { + if (quitting) return + setOpen(false) + } + const handleMinimize = () => { + if (quitting) return setOpen(false) if (supportsTray) { WindowHide() @@ -134,8 +143,18 @@ function CloseConfirmModal() { WindowMinimise() } - const handleQuit = async () => { - setOpen(false) + const handleQuitAppOnly = async () => { + setQuittingAction('app-only') + try { + await QuitAppOnlyApp() + } catch (error) { + console.error('QuitAppOnly failed', error) + setQuittingAction(null) + } + } + + const handleQuitAppAndBrowsers = async () => { + setQuittingAction('app-and-browser') try { await Promise.race([ ForceQuitApp(), @@ -150,9 +169,10 @@ function CloseConfirmModal() { return ( setOpen(false)} - title={importInProgress ? '关闭应用确认' : '退出确认'} - width="360px" + onClose={closeModal} + title={importInProgress ? '关闭应用确认' : undefined} + width={importInProgress ? '360px' : '420px'} + closable={!quitting} >
-

- {importInProgress ? '正在加载中,是否关闭?' : '是否退出应用程序?'} -

+ {importInProgress && ( +

+ 正在加载中,是否关闭? +

+ )} {importInProgress ? (

当前正在加载配置 @@ -171,30 +193,52 @@ function CloseConfirmModal() { {importMessage || '强制关闭会中断本次加载,是否仍要关闭应用?'}

) : ( -

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

+ 可仅退出应用,或连同浏览器一起关闭。

)} -
+
{importInProgress ? ( <> - - ) : ( <> - - + )} diff --git a/frontend/src/config/project.config.ts b/frontend/src/config/project.config.ts index 2498fe43..82e24a38 100644 --- a/frontend/src/config/project.config.ts +++ b/frontend/src/config/project.config.ts @@ -35,7 +35,7 @@ export const navigationConfig: NavSection[] = [ title: '指纹浏览器', items: [ { name: '实例列表', path: '/browser/list', icon: 'Monitor' }, - { name: '自动化接口', path: '/browser/automation', icon: 'Bot' }, + { name: '自动化接口(实验)', path: '/browser/automation', icon: 'Bot' }, { name: '内核管理', path: '/browser/cores', icon: 'Cpu' }, { name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' }, { name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' }, diff --git a/frontend/src/modules/browser/api.ts b/frontend/src/modules/browser/api.ts index e26d50c4..6af40dab 100644 --- a/frontend/src/modules/browser/api.ts +++ b/frontend/src/modules/browser/api.ts @@ -22,7 +22,9 @@ let mockProfiles: BrowserProfile[] = [ keywords: [], running: false, debugPort: 0, + debugReady: false, pid: 0, + runtimeWarning: '', lastError: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -74,7 +76,9 @@ export async function createBrowserProfile(input: BrowserProfileInput): Promise< keywords: input.keywords || {}, running: false, debugPort: 0, + debugReady: false, pid: 0, + runtimeWarning: '', lastError: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -118,6 +122,8 @@ export async function copyBrowserProfile(profileId: string, newName: string): Pr profileName: newName || src.profileName + ' (副本)', userDataDir: `mock-${Date.now()}`, running: false, + debugReady: false, + runtimeWarning: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } @@ -135,7 +141,7 @@ export async function startBrowserInstance(profileId: string): Promise - item.profileId === profileId ? { ...item, running: true, debugPort: 9222, pid: Math.floor(Math.random() * 100000), lastStartAt: new Date().toISOString() } : item + item.profileId === profileId ? { ...item, running: true, debugPort: 9222, debugReady: true, pid: Math.floor(Math.random() * 100000), runtimeWarning: '', lastStartAt: new Date().toISOString() } : item ) return mockProfiles.find(item => item.profileId === profileId) || null } @@ -159,7 +165,7 @@ export async function stopBrowserInstance(profileId: string): Promise - item.profileId === profileId ? { ...item, running: false, pid: 0, lastStopAt: new Date().toISOString() } : item + item.profileId === profileId ? { ...item, running: false, debugReady: false, debugPort: 0, pid: 0, runtimeWarning: '', lastStopAt: new Date().toISOString() } : item ) return mockProfiles.find(item => item.profileId === profileId) || null } @@ -199,9 +205,9 @@ export async function fetchBrowserTabs(profileId: string): Promise export async function fetchBrowserSettings(): Promise { const bindings: any = await getBindings() if (bindings?.GetBrowserSettings) { - return (await bindings.GetBrowserSettings()) || { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' } + return (await bindings.GetBrowserSettings()) || { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '', startReadyTimeoutMs: 3000, startStableWindowMs: 1200 } } - return { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' } + return { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '', startReadyTimeoutMs: 3000, startStableWindowMs: 1200 } } export async function saveBrowserSettings(settings: BrowserSettings): Promise { @@ -631,6 +637,12 @@ export interface LaunchServerInfo { cdpUrl: string activeDebugPort: number ready: boolean + apiAuth: { + requested: boolean + configured: boolean + enabled: boolean + header: string + } } function normalizeLaunchServerInfo(payload: any): LaunchServerInfo { @@ -642,6 +654,13 @@ function normalizeLaunchServerInfo(payload: any): LaunchServerInfo { const baseUrl = String(payload?.baseUrl || (effectivePort > 0 ? `http://${host}:${effectivePort}` : '')) const cdpUrl = String(payload?.cdpUrl || baseUrl) const activeDebugPort = Number(payload?.activeDebugPort) || 0 + const apiAuthPayload = payload?.apiAuth || {} + const apiAuth = { + requested: !!apiAuthPayload?.requested, + configured: !!apiAuthPayload?.configured, + enabled: !!apiAuthPayload?.enabled, + header: String(apiAuthPayload?.header || 'X-Ant-Api-Key'), + } return { host, @@ -651,6 +670,7 @@ function normalizeLaunchServerInfo(payload: any): LaunchServerInfo { cdpUrl, activeDebugPort, ready: !!payload?.ready && port > 0, + apiAuth, } } @@ -673,6 +693,12 @@ export async function fetchLaunchServerInfo(): Promise { cdpUrl: 'http://127.0.0.1:19876', activeDebugPort: 0, ready: false, + apiAuth: { + requested: false, + configured: false, + enabled: false, + header: 'X-Ant-Api-Key', + }, } } diff --git a/frontend/src/modules/browser/components/CookieManagerCard.tsx b/frontend/src/modules/browser/components/CookieManagerCard.tsx index 180e1a73..cb20e225 100644 --- a/frontend/src/modules/browser/components/CookieManagerCard.tsx +++ b/frontend/src/modules/browser/components/CookieManagerCard.tsx @@ -9,6 +9,7 @@ interface Props { profileId: string profileName: string running: boolean + ready: boolean } const formatExpires = (expires: number) => { @@ -16,7 +17,7 @@ const formatExpires = (expires: number) => { return new Date(expires * 1000).toLocaleString('zh-CN') } -export function CookieManagerCard({ profileId, profileName, running }: Props) { +export function CookieManagerCard({ profileId, profileName, running, ready }: Props) { const [cookies, setCookies] = useState([]) const [filterDomain, setFilterDomain] = useState('') const [loading, setLoading] = useState(false) @@ -24,7 +25,7 @@ export function CookieManagerCard({ profileId, profileName, running }: Props) { const [showConfirm, setShowConfirm] = useState(false) const loadCookies = async () => { - if (!running) return + if (!ready) return setLoading(true) try { const list = await fetchBrowserCookies(profileId) @@ -37,9 +38,9 @@ export function CookieManagerCard({ profileId, profileName, running }: Props) { } useEffect(() => { - if (running) loadCookies() + if (ready) loadCookies() else setCookies([]) - }, [profileId, running]) + }, [profileId, ready]) const filteredCookies = useMemo(() => { if (!filterDomain.trim()) return cookies @@ -102,9 +103,11 @@ export function CookieManagerCard({ profileId, profileName, running }: Props) { }, ] - const subtitle = running - ? `共 ${cookies.length} 条${filterDomain ? `,已过滤 ${filteredCookies.length} 条` : ''}` - : '实例未运行,无法管理 Cookie' + const subtitle = !running + ? '实例未运行,无法管理 Cookie' + : !ready + ? '实例运行中,等待调试接口就绪后可管理 Cookie' + : `共 ${cookies.length} 条${filterDomain ? `,已过滤 ${filteredCookies.length} 条` : ''}` return ( @@ -112,6 +115,10 @@ export function CookieManagerCard({ profileId, profileName, running }: Props) {

请先启动实例以查看 Cookie

+ ) : !ready ? ( +

+ 浏览器已启动,正在等待调试接口就绪 +

) : (
diff --git a/frontend/src/modules/browser/components/QuickLaunchModal.tsx b/frontend/src/modules/browser/components/QuickLaunchModal.tsx index adced03b..2b63820e 100644 --- a/frontend/src/modules/browser/components/QuickLaunchModal.tsx +++ b/frontend/src/modules/browser/components/QuickLaunchModal.tsx @@ -3,7 +3,7 @@ import { Keyboard, Play, Search, Tag } from 'lucide-react' import { Badge, Button, Modal, toast } from '../../../shared/components' import { fetchBrowserProfiles, fetchGroups, startBrowserInstanceByCode } from '../api' import type { BrowserGroupWithCount, BrowserProfile } from '../types' -import { resolveActionErrorMessage } from '../utils/actionErrors' +import { resolveActionFeedback } from '../utils/actionErrors' interface QuickLaunchModalProps { open: boolean @@ -286,7 +286,12 @@ export function QuickLaunchModal({ open, onClose }: QuickLaunchModalProps) { onClose() return true } catch (error: any) { - toast.error(resolveActionErrorMessage(error, '按 Code 启动失败')) + const feedback = resolveActionFeedback(error, '按 Code 启动失败') + if (feedback.tone === 'warning') { + toast.warning(feedback.message) + } else { + toast.error(feedback.message) + } return false } finally { setStartingCode('') diff --git a/frontend/src/modules/browser/pages/AutomationPage.tsx b/frontend/src/modules/browser/pages/AutomationPage.tsx index 2f051bc6..41cece42 100644 --- a/frontend/src/modules/browser/pages/AutomationPage.tsx +++ b/frontend/src/modules/browser/pages/AutomationPage.tsx @@ -1,14 +1,62 @@ import { useEffect, useState } from 'react' import { Bot, Copy, Rocket } from 'lucide-react' import { Button, Card, toast } from '../../../shared/components' -import { fetchLaunchServerInfo } from '../api' +import { fetchLaunchServerInfo, type LaunchServerInfo } from '../api' const DEFAULT_LAUNCH_BASE_URL = 'http://127.0.0.1:19876' +const DEFAULT_API_AUTH: LaunchServerInfo['apiAuth'] = { + requested: false, + configured: false, + enabled: false, + header: 'X-Ant-Api-Key', +} -function buildSampleRequest(baseUrl: string): string { +function buildAuthHeaderLine(apiAuth: LaunchServerInfo['apiAuth']): string { + if (!apiAuth.enabled) return '' + return ` -H "${apiAuth.header}: " \\\n` +} + +function buildSampleCreateRequest(baseUrl: string, apiAuth: LaunchServerInfo['apiAuth']): string { + return `curl -X POST ${baseUrl}/api/profiles \\ + -H "Content-Type: application/json" \\ +${buildAuthHeaderLine(apiAuth)} -d '{ + "profile": { + "profileName": "buyer-001", + "userDataDir": "buyers/buyer-001", + "proxyId": "proxy-us", + "launchArgs": ["--lang=en-US"], + "tags": ["电商", "北美"], + "keywords": ["buyer-001", "amazon"], + "groupId": "group-sales-us" + }, + "launchCode": "BUYER_001" + }'` +} + +function buildSampleCreateAndLaunchRequest(baseUrl: string, apiAuth: LaunchServerInfo['apiAuth']): string { + return `curl -X POST ${baseUrl}/api/profiles \\ + -H "Content-Type: application/json" \\ +${buildAuthHeaderLine(apiAuth)} -d '{ + "profile": { + "profileName": "buyer-002", + "userDataDir": "buyers/buyer-002", + "proxyConfig": "http://user:pass@127.0.0.1:8080", + "launchArgs": ["--disable-sync"], + "keywords": ["buyer-002"] + }, + "autoLaunch": true, + "start": { + "launchArgs": ["--window-size=1280,800"], + "startUrls": ["https://example.com/order"], + "skipDefaultStartUrls": true + } + }'` +} + +function buildSampleRequest(baseUrl: string, apiAuth: LaunchServerInfo['apiAuth']): string { return `curl -X POST ${baseUrl}/api/launch \\ -H "Content-Type: application/json" \\ - -d '{ +${buildAuthHeaderLine(apiAuth)} -d '{ "code": "A3F9K2", "launchArgs": ["--window-size=1280,800", "--lang=en-US"], "startUrls": ["https://example.com"], @@ -16,6 +64,27 @@ function buildSampleRequest(baseUrl: string): string { }'` } +const sampleCreateResponse = `{ + "ok": true, + "created": true, + "launched": false, + "profileId": "550e8400-e29b-41d4-a716-446655440000", + "profileName": "buyer-001", + "launchCode": "BUYER_001" +}` + +const sampleCreateAndLaunchResponse = `{ + "ok": true, + "created": true, + "launched": true, + "profileId": "550e8400-e29b-41d4-a716-446655440001", + "profileName": "buyer-002", + "launchCode": "A3F9K2", + "pid": 12345, + "debugPort": 9222, + "cdpUrl": "http://127.0.0.1:19876" +}` + const sampleResponse = `{ "ok": true, "profileId": "550e8400-e29b-41d4-a716-446655440000", @@ -25,8 +94,12 @@ const sampleResponse = `{ "cdpUrl": "http://127.0.0.1:19876" }` -function buildSampleLogsRequest(baseUrl: string): string { - return `curl ${baseUrl}/api/launch/logs?limit=20` +function buildSampleLogsRequest(baseUrl: string, apiAuth: LaunchServerInfo['apiAuth']): string { + if (!apiAuth.enabled) { + return `curl ${baseUrl}/api/launch/logs?limit=20` + } + return `curl ${baseUrl}/api/launch/logs?limit=20 \\ + -H "${apiAuth.header}: "` } function CopyCodeButton({ text }: { text: string }) { @@ -43,9 +116,28 @@ function CopyCodeButton({ text }: { text: string }) { ) } +function CodeBlock({ text }: { text: string }) { + return ( +
+      {text}
+    
+ ) +} + +type AutomationTabKey = 'guide' | 'profiles' | 'launch' | 'logs' + +const AUTOMATION_TABS: { key: AutomationTabKey; label: string; description: string }[] = [ + { key: 'guide', label: '接入说明', description: '先理解整体调用方式和推荐流程。' }, + { key: 'profiles', label: '配置管理', description: '集中查看实例创建、配置落库和返回结构。' }, + { key: 'launch', label: '启动调用', description: '集中查看参数化唤起和启动响应。' }, + { key: 'logs', label: '日志排障', description: '集中查看日志查询和后续排障入口。' }, +] + export function AutomationPage() { const [launchBaseUrl, setLaunchBaseUrl] = useState(DEFAULT_LAUNCH_BASE_URL) const [launchServerReady, setLaunchServerReady] = useState(false) + const [apiAuth, setApiAuth] = useState(DEFAULT_API_AUTH) + const [activeTab, setActiveTab] = useState('guide') useEffect(() => { let disposed = false @@ -57,6 +149,7 @@ export function AutomationPage() { setLaunchBaseUrl(info.baseUrl) } setLaunchServerReady(info.ready) + setApiAuth(info.apiAuth) }) .catch(() => {}) @@ -65,8 +158,11 @@ export function AutomationPage() { } }, []) - const sampleRequest = buildSampleRequest(launchBaseUrl) - const sampleLogsRequest = buildSampleLogsRequest(launchBaseUrl) + const sampleCreateRequest = buildSampleCreateRequest(launchBaseUrl, apiAuth) + const sampleCreateAndLaunchRequest = buildSampleCreateAndLaunchRequest(launchBaseUrl, apiAuth) + const sampleRequest = buildSampleRequest(launchBaseUrl, apiAuth) + const sampleLogsRequest = buildSampleLogsRequest(launchBaseUrl, apiAuth) + const activeTabMeta = AUTOMATION_TABS.find(tab => tab.key === activeTab) || AUTOMATION_TABS[0] return (
@@ -74,68 +170,211 @@ export function AutomationPage() {
- 自动化接口 + 自动化接口(实验)
-

外部脚本唤起接口

+

外部脚本配置与唤起接口

- 已支持通过本地 HTTP + JSON 协议唤起实例,并通过同一个固定端口暴露 CDP 入口。只要能发 HTTP 请求,和调用语言无关;Playwright、Selenium、自研调度器都只是接入方。 + 已支持通过本地 HTTP + JSON 协议管理实例配置并唤起实例,并通过同一个固定端口暴露 CDP 入口。只要能发 HTTP 请求,和调用语言无关;Playwright、Selenium、自研调度器都只是接入方。

当前 Launch 地址:{launchBaseUrl} {!launchServerReady ? '(服务启动后会自动刷新)' : ''}

+

+ {apiAuth.enabled + ? <>当前 API 认证已启用,请为所有 /api/* 请求追加 {apiAuth.header}: <your-api-key>。 + : apiAuth.requested && !apiAuth.configured + ? <>当前配置要求启用 API 认证,但 api_key 为空,认证尚未生效。 + : <>当前 API 认证未启用;如需开启,可在 config.yamllaunch_server.auth 下配置。} +

- } - > -
-{sampleRequest}
-        
-
-

code / key: 二选一即可;code 按 LaunchCode 精确匹配,key 按实例关键字优先精确、未命中时再模糊匹配。

-

matchMode: 多命中时的行为控制,支持 unique / first / all;传 key 时默认 first

-

launchArgs: 仅本次启动附加的 Chrome 启动参数(可选)。

-

startUrls: 启动后打开的页面列表(可选)。

-

skipDefaultStartUrls: 设为 true 时不追加系统默认起始页(可选)。

+
+
+
+ {AUTOMATION_TABS.map(tab => ( + + ))} +
- - } - > -
-{sampleResponse}
-        
-
+ +
+
+ +
+
+

{activeTabMeta.label}

+

{activeTabMeta.description}

+
+
+
+
- } - > -
-{sampleLogsRequest}
-        
-

- 可查询最近接口调用记录(默认 50 条,最大 200 条),用于排查自动化脚本调用问题。 -

-
+ {activeTab === 'guide' && ( +
+ +
+
+

Step 1

+

先创建配置

+

先拿到 profileIdlaunchCode,把落库和启动拆开。

+
+
+

Step 2

+

再调用启动

+

启动失败时更容易单独重试,也更容易记录调度结果。

+
+
+

Step 3

+

最后接 CDP

+

统一使用响应里的 cdpUrl,不要自己拼内部调试端口。

+
+
+
- -
- -

- 当前这部分接口已经可用,后续会继续补充自动化任务编排、模板脚本、连接状态监控等增强能力。 -

+ +
+

仅创建配置: 传 profile,不传 autoLaunch,接口只落库不启动浏览器。

+

创建并立即启动: 传 profile + autoLaunch=true,可再用 start 追加本次启动参数。

+

先创建后单独唤起: 先调用 POST /api/profiles 取得 profileId / launchCode,再调用 POST /api/launchGET /api/launch/{'{code}'}

+

稳定性优先时,推荐默认走“先创建后单独唤起”,这样创建和启动失败可以分开处理、分开重试。

+
+
-
+ )} + + {activeTab === 'profiles' && ( +
+ } + > + +
+

profile: 持久化的实例配置,支持实例名、代理、标签、关键字、分组、默认启动参数等字段。

+

launchCode: 可选的自定义启动码;如果不传,系统会自动生成。

+

autoLaunch + start: 可选,表示创建后立即启动,并附带一次性启动参数。

+

同一资源还支持 GET /api/profilesGET/PUT/DELETE /api/profiles/{'{profileId}'},用于后续查询、更新、删除。

+
+
+ + } + > + + + + } + > + +
+

autoLaunch=true: 当前请求在创建完成后会直接启动实例。

+

start: 只作用于本次启动,不会写回实例持久化配置。

+

如果创建已经成功但自动启动失败,响应里仍会标出 created=true,便于脚本分支处理。

+
+
+ + } + > + + +
+ )} + + {activeTab === 'launch' && ( +
+ +
+
+

目标匹配

+

code 用于精确唤起;key 适合关键字检索和批量调度。

+
+
+

接管方式

+

外部统一使用固定 cdpUrl 连接,不直接依赖内部实际 debugPort

+
+
+
+ + } + > + +
+

code / key: 二选一即可;code 按 LaunchCode 精确匹配,key 按实例关键字优先精确、未命中时再模糊匹配。

+

matchMode: 多命中时的行为控制,支持 unique / first / all;传 key 时默认 first

+

launchArgs: 仅本次启动附加的 Chrome 启动参数。

+

startUrls: 启动后打开的页面列表。

+

skipDefaultStartUrls: 设为 true 时不追加系统默认起始页。

+
+
+ + } + > + + +
+ )} + + {activeTab === 'logs' && ( +
+ } + > + +

+ 可查询最近接口调用记录(默认 50 条,最大 200 条),用于排查自动化脚本调用问题。 +

+
+ + +
+

如果返回里已经有 pid,但 debugReady=false,说明窗口已拉起,只是 CDP 还在后台附着。

+

如果接口直接返回错误,优先查看最近日志和实例最近错误,再决定是否重试。

+

排查自动化脚本时,建议把请求参数、响应体和调用日志一起保存,方便复现。

+
+
+ + +
+ +

+ 当前这部分接口已经可用,后续会继续补充自动化任务编排、模板脚本、连接状态监控等增强能力。 +

+
+
+
+ )}
) } diff --git a/frontend/src/modules/browser/pages/BrowserDetailPage.tsx b/frontend/src/modules/browser/pages/BrowserDetailPage.tsx index 263bfbda..1773fc2b 100644 --- a/frontend/src/modules/browser/pages/BrowserDetailPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserDetailPage.tsx @@ -16,9 +16,13 @@ import { } from '../api' import { CookieManagerCard } from '../components/CookieManagerCard' import { SnapshotTab } from '../components/SnapshotTab' -import { resolveActionErrorMessage } from '../utils/actionErrors' +import { resolveActionErrorMessage, resolveActionFeedback } from '../utils/actionErrors' -const statusVariant = (running: boolean) => (running ? 'success' : 'warning') +const resolveRuntimeStatus = (running: boolean, debugReady: boolean) => { + if (!running) return { variant: 'warning' as const, label: '已停止' } + if (!debugReady) return { variant: 'info' as const, label: '运行中(待就绪)' } + return { variant: 'success' as const, label: '运行中' } +} const formatTime = (value?: string) => { if (!value) return '-' @@ -77,11 +81,13 @@ export function BrowserDetailPage() { } const offStarted = EventsOn('browser:instance:started', handleRuntimeChange) + const offUpdated = EventsOn('browser:instance:updated', handleRuntimeChange) const offStopped = EventsOn('browser:instance:stopped', handleRuntimeChange) const offCrashed = EventsOn('browser:instance:crashed', handleRuntimeChange) return () => { offStarted?.() + offUpdated?.() offStopped?.() offCrashed?.() } @@ -107,9 +113,18 @@ export function BrowserDetailPage() { if (startedProfile) { setProfile(startedProfile) } - toast.success('实例已启动') + if (startedProfile?.running && !startedProfile.debugReady && startedProfile.runtimeWarning) { + toast.warning(startedProfile.runtimeWarning) + } else { + toast.success('实例已启动') + } } catch (error: any) { - toast.error(resolveActionErrorMessage(error, '实例启动失败')) + const feedback = resolveActionFeedback(error, '实例启动失败') + if (feedback.tone === 'warning') { + toast.warning(feedback.message) + } else { + toast.error(feedback.message) + } } finally { await loadProfile() setPendingAction(null) @@ -141,7 +156,12 @@ export function BrowserDetailPage() { } toast.success('实例已重启') } catch (error: any) { - toast.error(resolveActionErrorMessage(error, '实例重启失败')) + const feedback = resolveActionFeedback(error, '实例重启失败') + if (feedback.tone === 'warning') { + toast.warning(feedback.message) + } else { + toast.error(feedback.message) + } } finally { await loadProfile() setPendingAction(null) @@ -164,6 +184,7 @@ export function BrowserDetailPage() { const isStopping = pendingAction === 'stopping' const isRestarting = pendingAction === 'restarting' const isBusy = pendingAction !== null + const runtimeStatus = resolveRuntimeStatus(profile.running, profile.debugReady) return (
@@ -209,7 +230,7 @@ export function BrowserDetailPage() {
状态 - {profile.running ? '运行中' : '已停止'} + {runtimeStatus.label}
进程 PID @@ -219,6 +240,10 @@ export function BrowserDetailPage() { 调试端口 {profile.debugPort || '-'}
+
+ 调试状态 + {profile.debugReady ? '已就绪' : (profile.running ? '等待就绪' : '-')} +
最近启动 {formatTime(profile.lastStartAt)} @@ -318,6 +343,14 @@ export function BrowserDetailPage() { )} + {profile.runtimeWarning && ( + +
+ {profile.runtimeWarning} +
+
+ )} +
setTargetUrl(e.target.value)} placeholder="请输入目标地址" /> @@ -336,6 +369,7 @@ export function BrowserDetailPage() { profileId={profile.profileId} profileName={profile.profileName} running={profile.running} + ready={profile.running && profile.debugReady} />
)} diff --git a/frontend/src/modules/browser/pages/BrowserEditPage.tsx b/frontend/src/modules/browser/pages/BrowserEditPage.tsx index 930ae587..659ae995 100644 --- a/frontend/src/modules/browser/pages/BrowserEditPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserEditPage.tsx @@ -3,12 +3,23 @@ import { useNavigate, useParams } from 'react-router-dom' import { FolderOpen, Layers } from 'lucide-react' import { Button, Card, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components' import type { BrowserCore, BrowserProfileInput, BrowserProxy, BrowserGroup } from '../types' -import { createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchGroups, openUserDataDir, updateBrowserProfile } from '../api' +import { createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchBrowserSettings, fetchGroups, openUserDataDir, updateBrowserProfile } from '../api' import { FingerprintPanel } from '../components/FingerprintPanel' import { TagInput } from '../components/TagInput' import { GroupSelector } from '../components/GroupSelector' import { ProxyPickerModal } from '../components/ProxyPickerModal' +const fallbackLowLaunchArgs = ['--disable-sync', '--no-first-run'] + +function normalizeLaunchArgs(args: string[]): string[] { + return (args || []).map(item => item.trim()).filter(Boolean) +} + +function resolveDefaultLaunchArgs(args: string[]): string[] { + const normalized = normalizeLaunchArgs(args) + return normalized.length > 0 ? normalized : fallbackLowLaunchArgs +} + export function BrowserEditPage() { const { id } = useParams() const navigate = useNavigate() @@ -38,21 +49,27 @@ export function BrowserEditPage() { useEffect(() => { const loadData = async () => { - const [coreList, proxyList, tagList, groupList] = await Promise.all([ + const [coreList, proxyList, tagList, groupList, settings] = await Promise.all([ fetchBrowserCores(), fetchBrowserProxies(), fetchAllTags(), fetchGroups(), + fetchBrowserSettings(), ]) + const resolvedDefaultLaunchArgs = resolveDefaultLaunchArgs(settings.defaultLaunchArgs || []) setCores(coreList) setProxies(proxyList) setAllTags(tagList) setGroups(groupList) - if (isCreate) return + if (isCreate) { + setLaunchArgsText(resolvedDefaultLaunchArgs.join('\n')) + return + } const list = await fetchBrowserProfiles() const current = list.find(item => item.profileId === id) if (!current) return + const currentLaunchArgs = normalizeLaunchArgs(current.launchArgs) const normalizedCoreId = !current.coreId || current.coreId.toLowerCase() === 'default' ? '' : current.coreId @@ -63,12 +80,12 @@ export function BrowserEditPage() { fingerprintArgs: current.fingerprintArgs, proxyId: current.proxyId, proxyConfig: current.proxyConfig, - launchArgs: current.launchArgs, + launchArgs: currentLaunchArgs, tags: current.tags, keywords: current.keywords || [], groupId: current.groupId || '', }) - setLaunchArgsText(current.launchArgs.join('\n')) + setLaunchArgsText(currentLaunchArgs.join('\n')) } loadData() }, [id, isCreate]) @@ -82,7 +99,7 @@ export function BrowserEditPage() { setSaving(true) const payload: BrowserProfileInput = { ...formData, - launchArgs: launchArgsText.split('\n').map((s: string) => s.trim()).filter(Boolean), + launchArgs: normalizeLaunchArgs(launchArgsText.split('\n')), } try { if (isCreate) { @@ -230,8 +247,18 @@ export function BrowserEditPage() { />
- -