mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
publish: 1.1.0 snapshot (a358335)
channel: master version: 1.1.0 source-ref: master published-at-utc: 2026-03-29T11:32:04Z
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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-<arch>/xray`、`bin/linux-<arch>/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/`。
|
||||
|
||||
+56
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+125
-63
@@ -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 {
|
||||
|
||||
+245
-11
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 内核配置输入
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,8 +4,9 @@ package tray
|
||||
|
||||
// Callbacks 托盘回调
|
||||
type Callbacks struct {
|
||||
OnShow func()
|
||||
OnQuit func()
|
||||
OnShow func()
|
||||
OnQuitAppOnly func()
|
||||
OnQuit func()
|
||||
}
|
||||
|
||||
// Run 非 Windows 平台无托盘实现,保持空操作。
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+82
-6
@@ -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-<version>.exe
|
||||
```
|
||||
|
||||
### `recover-profiles.ps1`
|
||||
|
||||
用于“实例配置丢了,但 `data\<userDataDir>` 目录还在”的恢复场景。
|
||||
|
||||
默认只预览,不写数据库:
|
||||
|
||||
```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` 调用。
|
||||
|
||||
+436
-92
@@ -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"
|
||||
|
||||
+3
-3
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+11
-10
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
7eabda3c0c6240dd458970bfdd19c33c
|
||||
@@ -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 {
|
||||
|
||||
+63
-19
@@ -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<TModule extends Record<string, ComponentType<any>>>(
|
||||
@@ -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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={importInProgress ? '关闭应用确认' : '退出确认'}
|
||||
width="360px"
|
||||
onClose={closeModal}
|
||||
title={importInProgress ? '关闭应用确认' : undefined}
|
||||
width={importInProgress ? '360px' : '420px'}
|
||||
closable={!quitting}
|
||||
>
|
||||
<div className="flex flex-col items-center pt-2 pb-6 px-4">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
|
||||
@@ -160,9 +180,11 @@ function CloseConfirmModal() {
|
||||
}`}>
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[var(--color-text-primary)] mb-2">
|
||||
{importInProgress ? '正在加载中,是否关闭?' : '是否退出应用程序?'}
|
||||
</h3>
|
||||
{importInProgress && (
|
||||
<h3 className="text-lg font-medium text-[var(--color-text-primary)] mb-2">
|
||||
正在加载中,是否关闭?
|
||||
</h3>
|
||||
)}
|
||||
{importInProgress ? (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] text-center mb-6">
|
||||
当前正在加载配置
|
||||
@@ -171,30 +193,52 @@ function CloseConfirmModal() {
|
||||
{importMessage || '强制关闭会中断本次加载,是否仍要关闭应用?'}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] text-center mb-6">
|
||||
退出后将停止所有在此客户端运行的服务。
|
||||
<br />
|
||||
{supportsTray ? '如果您需要保持服务运行,请选择「最小化到托盘」。' : 'Linux 当前不提供托盘最小化,关闭窗口将直接退出应用。'}
|
||||
<p className="mb-6 text-sm text-center text-[var(--color-text-secondary)]">
|
||||
可仅退出应用,或连同浏览器一起关闭。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className={`w-full ${importInProgress ? 'flex gap-3' : 'flex flex-col gap-2'}`}>
|
||||
{importInProgress ? (
|
||||
<>
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setOpen(false)}>
|
||||
<Button variant="secondary" className="flex-1" onClick={closeModal} disabled={quitting}>
|
||||
继续加载
|
||||
</Button>
|
||||
<Button variant="danger" className="flex-1" onClick={handleQuit}>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
onClick={handleQuitAppAndBrowsers}
|
||||
loading={quittingAction === 'app-and-browser'}
|
||||
>
|
||||
仍要关闭
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" className="flex-1" onClick={supportsTray ? handleMinimize : () => setOpen(false)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full !bg-[#f3f4f6] !border-[#e5e7eb] !text-[var(--color-text-primary)] hover:!bg-[#e5e7eb]"
|
||||
onClick={supportsTray ? handleMinimize : closeModal}
|
||||
disabled={quitting}
|
||||
>
|
||||
{supportsTray ? '最小化到托盘' : '取消'}
|
||||
</Button>
|
||||
<Button variant="danger" className="flex-1" onClick={handleQuit}>
|
||||
直接退出
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleQuitAppOnly}
|
||||
loading={quittingAction === 'app-only'}
|
||||
disabled={quitting}
|
||||
>
|
||||
仅退出应用
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="w-full"
|
||||
onClick={handleQuitAppAndBrowsers}
|
||||
loading={quittingAction === 'app-and-browser'}
|
||||
disabled={quitting}
|
||||
>
|
||||
退出应用与浏览器
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<BrowserPr
|
||||
return (await bindings.BrowserInstanceStart(profileId)) || null
|
||||
}
|
||||
mockProfiles = mockProfiles.map(item =>
|
||||
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<BrowserPro
|
||||
return (await bindings.BrowserInstanceStop(profileId)) || null
|
||||
}
|
||||
mockProfiles = mockProfiles.map(item =>
|
||||
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<BrowserTab[]>
|
||||
export async function fetchBrowserSettings(): Promise<BrowserSettings> {
|
||||
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<boolean> {
|
||||
@@ -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<LaunchServerInfo> {
|
||||
cdpUrl: 'http://127.0.0.1:19876',
|
||||
activeDebugPort: 0,
|
||||
ready: false,
|
||||
apiAuth: {
|
||||
requested: false,
|
||||
configured: false,
|
||||
enabled: false,
|
||||
header: 'X-Ant-Api-Key',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CookieInfo[]>([])
|
||||
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 (
|
||||
<Card title="Cookie 管理" subtitle={subtitle}>
|
||||
@@ -112,6 +115,10 @@ export function CookieManagerCard({ profileId, profileName, running }: Props) {
|
||||
<p className="text-sm text-[var(--color-text-muted)] py-4 text-center">
|
||||
请先启动实例以查看 Cookie
|
||||
</p>
|
||||
) : !ready ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] py-4 text-center">
|
||||
浏览器已启动,正在等待调试接口就绪
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center justify-between">
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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}: <your-api-key>" \\\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}: <your-api-key>"`
|
||||
}
|
||||
|
||||
function CopyCodeButton({ text }: { text: string }) {
|
||||
@@ -43,9 +116,28 @@ function CopyCodeButton({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
|
||||
{text}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
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<LaunchServerInfo['apiAuth']>(DEFAULT_API_AUTH)
|
||||
const [activeTab, setActiveTab] = useState<AutomationTabKey>('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 (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
@@ -74,68 +170,211 @@ export function AutomationPage() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-[var(--color-accent-muted)] text-[var(--color-accent)] text-xs font-medium mb-3">
|
||||
<Bot className="w-3.5 h-3.5" /> 自动化接口
|
||||
<Bot className="w-3.5 h-3.5" /> 自动化接口(实验)
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">外部脚本唤起接口</h1>
|
||||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">外部脚本配置与唤起接口</h1>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mt-2">
|
||||
已支持通过本地 <code>HTTP + JSON</code> 协议唤起实例,并通过同一个固定端口暴露 CDP 入口。只要能发 HTTP 请求,和调用语言无关;Playwright、Selenium、自研调度器都只是接入方。
|
||||
已支持通过本地 <code>HTTP + JSON</code> 协议管理实例配置并唤起实例,并通过同一个固定端口暴露 CDP 入口。只要能发 HTTP 请求,和调用语言无关;Playwright、Selenium、自研调度器都只是接入方。
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-2">
|
||||
当前 Launch 地址:<code>{launchBaseUrl}</code>
|
||||
{!launchServerReady ? '(服务启动后会自动刷新)' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
{apiAuth.enabled
|
||||
? <>当前 API 认证已启用,请为所有 <code>/api/*</code> 请求追加 <code>{apiAuth.header}: <your-api-key></code>。</>
|
||||
: apiAuth.requested && !apiAuth.configured
|
||||
? <>当前配置要求启用 API 认证,但 <code>api_key</code> 为空,认证尚未生效。</>
|
||||
: <>当前 API 认证未启用;如需开启,可在 <code>config.yaml</code> 的 <code>launch_server.auth</code> 下配置。</>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="1) 参数化唤起接口"
|
||||
subtitle="POST /api/launch"
|
||||
actions={<CopyCodeButton text={sampleRequest} />}
|
||||
>
|
||||
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
|
||||
{sampleRequest}
|
||||
</pre>
|
||||
<div className="mt-3 text-sm text-[var(--color-text-secondary)] space-y-1">
|
||||
<p><code>code</code> / <code>key</code>: 二选一即可;<code>code</code> 按 LaunchCode 精确匹配,<code>key</code> 按实例关键字优先精确、未命中时再模糊匹配。</p>
|
||||
<p><code>matchMode</code>: 多命中时的行为控制,支持 <code>unique</code> / <code>first</code> / <code>all</code>;传 <code>key</code> 时默认 <code>first</code>。</p>
|
||||
<p><code>launchArgs</code>: 仅本次启动附加的 Chrome 启动参数(可选)。</p>
|
||||
<p><code>startUrls</code>: 启动后打开的页面列表(可选)。</p>
|
||||
<p><code>skipDefaultStartUrls</code>: 设为 <code>true</code> 时不追加系统默认起始页(可选)。</p>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex min-w-max border-b border-[var(--color-border)]">
|
||||
{AUTOMATION_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={[
|
||||
'px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap',
|
||||
activeTab === tab.key
|
||||
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="2) 响应结构"
|
||||
subtitle="成功返回 pid + cdpUrl;外部统一使用固定端口接 CDP"
|
||||
actions={<CopyCodeButton text={sampleResponse} />}
|
||||
>
|
||||
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
|
||||
{sampleResponse}
|
||||
</pre>
|
||||
</Card>
|
||||
<Card className="bg-[var(--color-bg-surface)]/70">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-[var(--color-accent-muted)] p-2 text-[var(--color-accent)]">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">{activeTabMeta.label}</p>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mt-1">{activeTabMeta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="3) 调用记录"
|
||||
subtitle="GET /api/launch/logs?limit=20"
|
||||
actions={<CopyCodeButton text={sampleLogsRequest} />}
|
||||
>
|
||||
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
|
||||
{sampleLogsRequest}
|
||||
</pre>
|
||||
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
|
||||
可查询最近接口调用记录(默认 50 条,最大 200 条),用于排查自动化脚本调用问题。
|
||||
</p>
|
||||
</Card>
|
||||
{activeTab === 'guide' && (
|
||||
<div className="space-y-5">
|
||||
<Card title="推荐接入顺序" subtitle="稳定性优先时,建议把创建、启动、接管拆开处理">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
|
||||
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Step 1</p>
|
||||
<p className="mt-2 font-medium text-[var(--color-text-primary)]">先创建配置</p>
|
||||
<p className="mt-1 text-[var(--color-text-secondary)]">先拿到 <code>profileId</code> 和 <code>launchCode</code>,把落库和启动拆开。</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Step 2</p>
|
||||
<p className="mt-2 font-medium text-[var(--color-text-primary)]">再调用启动</p>
|
||||
<p className="mt-1 text-[var(--color-text-secondary)]">启动失败时更容易单独重试,也更容易记录调度结果。</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Step 3</p>
|
||||
<p className="mt-2 font-medium text-[var(--color-text-primary)]">最后接 CDP</p>
|
||||
<p className="mt-1 text-[var(--color-text-secondary)]">统一使用响应里的 <code>cdpUrl</code>,不要自己拼内部调试端口。</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<Rocket className="w-4 h-4 mt-0.5 text-[var(--color-accent)]" />
|
||||
<p>
|
||||
当前这部分接口已经可用,后续会继续补充自动化任务编排、模板脚本、连接状态监控等增强能力。
|
||||
</p>
|
||||
<Card title="触发创建的方式" subtitle="推荐按用途选择 /api/profiles 的三种调用模式">
|
||||
<div className="text-sm text-[var(--color-text-secondary)] space-y-2">
|
||||
<p><code>仅创建配置</code>: 传 <code>profile</code>,不传 <code>autoLaunch</code>,接口只落库不启动浏览器。</p>
|
||||
<p><code>创建并立即启动</code>: 传 <code>profile</code> + <code>autoLaunch=true</code>,可再用 <code>start</code> 追加本次启动参数。</p>
|
||||
<p><code>先创建后单独唤起</code>: 先调用 <code>POST /api/profiles</code> 取得 <code>profileId</code> / <code>launchCode</code>,再调用 <code>POST /api/launch</code> 或 <code>GET /api/launch/{'{code}'}</code>。</p>
|
||||
<p>稳定性优先时,推荐默认走“先创建后单独唤起”,这样创建和启动失败可以分开处理、分开重试。</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'profiles' && (
|
||||
<div className="space-y-5">
|
||||
<Card
|
||||
title="仅创建实例配置"
|
||||
subtitle="POST /api/profiles"
|
||||
actions={<CopyCodeButton text={sampleCreateRequest} />}
|
||||
>
|
||||
<CodeBlock text={sampleCreateRequest} />
|
||||
<div className="mt-3 text-sm text-[var(--color-text-secondary)] space-y-1">
|
||||
<p><code>profile</code>: 持久化的实例配置,支持实例名、代理、标签、关键字、分组、默认启动参数等字段。</p>
|
||||
<p><code>launchCode</code>: 可选的自定义启动码;如果不传,系统会自动生成。</p>
|
||||
<p><code>autoLaunch</code> + <code>start</code>: 可选,表示创建后立即启动,并附带一次性启动参数。</p>
|
||||
<p>同一资源还支持 <code>GET /api/profiles</code>、<code>GET/PUT/DELETE /api/profiles/{'{profileId}'}</code>,用于后续查询、更新、删除。</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="创建响应"
|
||||
subtitle="创建成功后返回 profileId + launchCode"
|
||||
actions={<CopyCodeButton text={sampleCreateResponse} />}
|
||||
>
|
||||
<CodeBlock text={sampleCreateResponse} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="创建并立即启动"
|
||||
subtitle="POST /api/profiles + autoLaunch=true"
|
||||
actions={<CopyCodeButton text={sampleCreateAndLaunchRequest} />}
|
||||
>
|
||||
<CodeBlock text={sampleCreateAndLaunchRequest} />
|
||||
<div className="mt-3 text-sm text-[var(--color-text-secondary)] space-y-1">
|
||||
<p><code>autoLaunch=true</code>: 当前请求在创建完成后会直接启动实例。</p>
|
||||
<p><code>start</code>: 只作用于本次启动,不会写回实例持久化配置。</p>
|
||||
<p>如果创建已经成功但自动启动失败,响应里仍会标出 <code>created=true</code>,便于脚本分支处理。</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="创建并启动响应"
|
||||
subtitle="返回 created + launched + cdpUrl"
|
||||
actions={<CopyCodeButton text={sampleCreateAndLaunchResponse} />}
|
||||
>
|
||||
<CodeBlock text={sampleCreateAndLaunchResponse} />
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'launch' && (
|
||||
<div className="space-y-5">
|
||||
<Card title="启动接口使用建议" subtitle="把选择目标、附加参数和页面打开策略放在一次请求里">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-[var(--color-text-secondary)]">
|
||||
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-4">
|
||||
<p className="font-medium text-[var(--color-text-primary)]">目标匹配</p>
|
||||
<p className="mt-1"><code>code</code> 用于精确唤起;<code>key</code> 适合关键字检索和批量调度。</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-4">
|
||||
<p className="font-medium text-[var(--color-text-primary)]">接管方式</p>
|
||||
<p className="mt-1">外部统一使用固定 <code>cdpUrl</code> 连接,不直接依赖内部实际 <code>debugPort</code>。</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="参数化唤起接口"
|
||||
subtitle="POST /api/launch"
|
||||
actions={<CopyCodeButton text={sampleRequest} />}
|
||||
>
|
||||
<CodeBlock text={sampleRequest} />
|
||||
<div className="mt-3 text-sm text-[var(--color-text-secondary)] space-y-1">
|
||||
<p><code>code</code> / <code>key</code>: 二选一即可;<code>code</code> 按 LaunchCode 精确匹配,<code>key</code> 按实例关键字优先精确、未命中时再模糊匹配。</p>
|
||||
<p><code>matchMode</code>: 多命中时的行为控制,支持 <code>unique</code> / <code>first</code> / <code>all</code>;传 <code>key</code> 时默认 <code>first</code>。</p>
|
||||
<p><code>launchArgs</code>: 仅本次启动附加的 Chrome 启动参数。</p>
|
||||
<p><code>startUrls</code>: 启动后打开的页面列表。</p>
|
||||
<p><code>skipDefaultStartUrls</code>: 设为 <code>true</code> 时不追加系统默认起始页。</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="启动响应"
|
||||
subtitle="成功返回 pid + cdpUrl;外部统一使用固定端口接 CDP"
|
||||
actions={<CopyCodeButton text={sampleResponse} />}
|
||||
>
|
||||
<CodeBlock text={sampleResponse} />
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<div className="space-y-5">
|
||||
<Card
|
||||
title="调用记录"
|
||||
subtitle="GET /api/launch/logs?limit=20"
|
||||
actions={<CopyCodeButton text={sampleLogsRequest} />}
|
||||
>
|
||||
<CodeBlock text={sampleLogsRequest} />
|
||||
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
|
||||
可查询最近接口调用记录(默认 50 条,最大 200 条),用于排查自动化脚本调用问题。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="排障提示" subtitle="先看最近调用,再看实例是否已经完成后台接管">
|
||||
<div className="text-sm text-[var(--color-text-secondary)] space-y-2">
|
||||
<p>如果返回里已经有 <code>pid</code>,但 <code>debugReady=false</code>,说明窗口已拉起,只是 CDP 还在后台附着。</p>
|
||||
<p>如果接口直接返回错误,优先查看最近日志和实例最近错误,再决定是否重试。</p>
|
||||
<p>排查自动化脚本时,建议把请求参数、响应体和调用日志一起保存,方便复现。</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<Rocket className="w-4 h-4 mt-0.5 text-[var(--color-accent)]" />
|
||||
<p>
|
||||
当前这部分接口已经可用,后续会继续补充自动化任务编排、模板脚本、连接状态监控等增强能力。
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
@@ -209,7 +230,7 @@ export function BrowserDetailPage() {
|
||||
<div className="space-y-3 text-sm text-[var(--color-text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>状态</span>
|
||||
<Badge variant={statusVariant(profile.running)} dot>{profile.running ? '运行中' : '已停止'}</Badge>
|
||||
<Badge variant={runtimeStatus.variant} dot>{runtimeStatus.label}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>进程 PID</span>
|
||||
@@ -219,6 +240,10 @@ export function BrowserDetailPage() {
|
||||
<span>调试端口</span>
|
||||
<span>{profile.debugPort || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>调试状态</span>
|
||||
<span>{profile.debugReady ? '已就绪' : (profile.running ? '等待就绪' : '-')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>最近启动</span>
|
||||
<span>{formatTime(profile.lastStartAt)}</span>
|
||||
@@ -318,6 +343,14 @@ export function BrowserDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{profile.runtimeWarning && (
|
||||
<Card title="运行提示" subtitle="当前实例处于部分可用状态">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 whitespace-pre-line">
|
||||
{profile.runtimeWarning}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="打开地址" subtitle="向实例发送打开 URL 指令">
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<Input value={targetUrl} onChange={e => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="启动参数" subtitle="每行一个参数">
|
||||
<Textarea value={launchArgsText} onChange={e => { setLaunchArgsText(e.target.value); setIsDirty(true) }} rows={6} placeholder="--disable-sync" />
|
||||
<Card title="启动参数" subtitle={isCreate ? '新建时默认填入轻量参数模板,直接改这里即可' : '每行一个参数'}>
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={launchArgsText}
|
||||
onChange={e => { setLaunchArgsText(e.target.value); setIsDirty(true) }}
|
||||
rows={6}
|
||||
placeholder="--disable-sync"
|
||||
/>
|
||||
{isCreate && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">这里默认就是轻量参数模板;需要更复杂的参数,直接在此基础上修改。</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { InstanceFilters } from '../components/InstanceFilterBar'
|
||||
import { KeywordsModal } from '../components/KeywordsModal'
|
||||
import { EventsOn, BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
|
||||
import { PROJECT_GITHUB_URL } from '../../../config/links'
|
||||
import { resolveActionErrorMessage } from '../utils/actionErrors'
|
||||
import { resolveActionErrorMessage, resolveActionFeedback } from '../utils/actionErrors'
|
||||
import {
|
||||
copyBrowserProfile,
|
||||
deleteBrowserCore,
|
||||
@@ -73,13 +73,16 @@ function BatchToolbar({
|
||||
)
|
||||
}
|
||||
|
||||
const resolveProfileStatus = (running: boolean, starting: boolean, stopping: boolean) => {
|
||||
const resolveProfileStatus = (running: boolean, debugReady: boolean, starting: boolean, stopping: boolean) => {
|
||||
if (starting) {
|
||||
return { variant: 'info' as const, label: '启动中' }
|
||||
}
|
||||
if (stopping) {
|
||||
return { variant: 'default' as const, label: '停止中' }
|
||||
}
|
||||
if (running && !debugReady) {
|
||||
return { variant: 'info' as const, label: '运行中(待就绪)' }
|
||||
}
|
||||
if (running) {
|
||||
return { variant: 'success' as const, label: '运行中' }
|
||||
}
|
||||
@@ -276,7 +279,14 @@ export function BrowserListPage() {
|
||||
|
||||
// 基础配置弹窗
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
||||
const [settings, setSettings] = useState<BrowserSettings>({ userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' })
|
||||
const [settings, setSettings] = useState<BrowserSettings>({
|
||||
userDataRoot: 'data',
|
||||
defaultFingerprintArgs: [],
|
||||
defaultLaunchArgs: [],
|
||||
defaultProxy: '',
|
||||
startReadyTimeoutMs: 3000,
|
||||
startStableWindowMs: 1200,
|
||||
})
|
||||
const [fingerprintText, setFingerprintText] = useState('')
|
||||
const [launchText, setLaunchText] = useState('')
|
||||
const [savingSettings, setSavingSettings] = useState(false)
|
||||
@@ -409,6 +419,9 @@ export function BrowserListPage() {
|
||||
}
|
||||
void loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
})
|
||||
const offUpdated = EventsOn('browser:instance:updated', () => {
|
||||
void loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
})
|
||||
const offStopped = EventsOn('browser:instance:stopped', (payload: any) => {
|
||||
const profileId = typeof payload === 'string' ? payload : payload?.profileId
|
||||
if (profileId) {
|
||||
@@ -434,6 +447,7 @@ export function BrowserListPage() {
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
offStarted?.()
|
||||
offUpdated?.()
|
||||
offStopped?.()
|
||||
offCrashed?.()
|
||||
}
|
||||
@@ -476,7 +490,7 @@ export function BrowserListPage() {
|
||||
const isProfileBusy = (profileId: string) => isProfileStarting(profileId) || isProfileStopping(profileId)
|
||||
|
||||
const getProfileStatus = (profile: BrowserProfile) => (
|
||||
resolveProfileStatus(profile.running, isProfileStarting(profile.profileId), isProfileStopping(profile.profileId))
|
||||
resolveProfileStatus(profile.running, profile.debugReady, isProfileStarting(profile.profileId), isProfileStopping(profile.profileId))
|
||||
)
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
@@ -538,10 +552,19 @@ export function BrowserListPage() {
|
||||
|
||||
const startedProfile = await startBrowserInstance(profileId)
|
||||
mergeProfileState(startedProfile)
|
||||
toast.success(`实例已启动${startedProfile?.profileName ? `:${startedProfile.profileName}` : ''}`)
|
||||
if (startedProfile?.running && !startedProfile.debugReady && startedProfile.runtimeWarning) {
|
||||
toast.warning(startedProfile.runtimeWarning)
|
||||
} else {
|
||||
toast.success(`实例已启动${startedProfile?.profileName ? `:${startedProfile.profileName}` : ''}`)
|
||||
}
|
||||
await loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
} catch (error: any) {
|
||||
setOpError(resolveActionErrorMessage(error, '实例启动失败'))
|
||||
const feedback = resolveActionFeedback(error, '实例启动失败')
|
||||
if (feedback.tone === 'warning') {
|
||||
toast.warning(feedback.message)
|
||||
} else {
|
||||
toast.error(feedback.message)
|
||||
}
|
||||
await loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
} finally {
|
||||
updatePendingIds(setStartingIds, profileId, false)
|
||||
@@ -571,7 +594,12 @@ export function BrowserListPage() {
|
||||
toast.success(`实例已重启${restartedProfile?.profileName ? `:${restartedProfile.profileName}` : ''}`)
|
||||
await loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
} catch (error: any) {
|
||||
setOpError(resolveActionErrorMessage(error, '实例重启失败'))
|
||||
const feedback = resolveActionFeedback(error, '实例重启失败')
|
||||
if (feedback.tone === 'warning') {
|
||||
toast.warning(feedback.message)
|
||||
} else {
|
||||
setOpError(feedback.message)
|
||||
}
|
||||
await loadProfiles({ silent: true, syncRuntimeState: true })
|
||||
} finally {
|
||||
updatePendingIds(setStoppingIds, profileId, false)
|
||||
@@ -607,7 +635,8 @@ export function BrowserListPage() {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchLoading(true)
|
||||
let success = 0, failed = 0
|
||||
let success = 0, pending = 0, failed = 0
|
||||
const pendingMessages: string[] = []
|
||||
const failureMessages: string[] = []
|
||||
for (const id of ids) {
|
||||
const profile = profiles.find(p => p.profileId === id)
|
||||
@@ -618,18 +647,32 @@ export function BrowserListPage() {
|
||||
mergeProfileState(startedProfile)
|
||||
success++
|
||||
} catch (error: any) {
|
||||
failed++
|
||||
failureMessages.push(`${profile.profileName}:${resolveActionErrorMessage(error, '实例启动失败')}`)
|
||||
const feedback = resolveActionFeedback(error, '实例启动失败')
|
||||
if (feedback.pendingAttach) {
|
||||
pending++
|
||||
pendingMessages.push(`${profile.profileName}:${feedback.message}`)
|
||||
} else {
|
||||
failed++
|
||||
failureMessages.push(`${profile.profileName}:${feedback.message}`)
|
||||
}
|
||||
} finally {
|
||||
updatePendingIds(setStartingIds, id, false)
|
||||
}
|
||||
}
|
||||
setBatchLoading(false)
|
||||
toast.success(`批量启动完成:成功 ${success}${failed > 0 ? `,失败 ${failed}` : ''}`)
|
||||
const summary = [`成功 ${success}`]
|
||||
if (pending > 0) summary.push(`待接管 ${pending}`)
|
||||
if (failed > 0) summary.push(`失败 ${failed}`)
|
||||
toast.success(`批量启动完成:${summary.join(',')}`)
|
||||
if (pendingMessages.length > 0) {
|
||||
const preview = pendingMessages.slice(0, 3)
|
||||
const more = pendingMessages.length > preview.length ? `\n另有 ${pendingMessages.length - preview.length} 个实例已打开窗口,仍在后台接管。` : ''
|
||||
toast.warning(`以下实例已打开窗口,仍在后台接管:\n${preview.join('\n')}${more}`)
|
||||
}
|
||||
if (failureMessages.length > 0) {
|
||||
const preview = failureMessages.slice(0, 3)
|
||||
const more = failureMessages.length > preview.length ? `\n另有 ${failureMessages.length - preview.length} 个实例启动失败,请逐个检查。` : ''
|
||||
setOpError(`以下实例启动失败:\n${preview.join('\n')}${more}`)
|
||||
toast.error(`以下实例启动失败:\n${preview.join('\n')}${more}`)
|
||||
}
|
||||
loadProfiles()
|
||||
}
|
||||
@@ -1147,6 +1190,28 @@ export function BrowserListPage() {
|
||||
<FormItem label="默认代理">
|
||||
<Input value={settings.defaultProxy} onChange={e => setSettings(prev => ({ ...prev, defaultProxy: e.target.value }))} placeholder="http://127.0.0.1:7890" />
|
||||
</FormItem>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormItem label="启动就绪超时(毫秒)" hint="默认 3000,慢机器可调到 5000-10000">
|
||||
<Input
|
||||
type="number"
|
||||
min={1000}
|
||||
step={500}
|
||||
value={settings.startReadyTimeoutMs}
|
||||
onChange={e => setSettings(prev => ({ ...prev, startReadyTimeoutMs: Math.max(1000, Number(e.target.value) || 3000) }))}
|
||||
placeholder="3000"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="启动稳定窗口(毫秒)" hint="建议 1200-3000">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={settings.startStableWindowMs}
|
||||
onChange={e => setSettings(prev => ({ ...prev, startStableWindowMs: Math.max(0, Number(e.target.value) || 1200) }))}
|
||||
placeholder="1200"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export function CoreManagementPage() {
|
||||
defaultFingerprintArgs: [],
|
||||
defaultLaunchArgs: [],
|
||||
defaultProxy: '',
|
||||
startReadyTimeoutMs: 3000,
|
||||
startStableWindowMs: 1200,
|
||||
})
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
||||
const [settingsForm, setSettingsForm] = useState({
|
||||
@@ -36,6 +38,8 @@ export function CoreManagementPage() {
|
||||
defaultProxy: '',
|
||||
defaultFingerprintArgs: '',
|
||||
defaultLaunchArgs: '',
|
||||
startReadyTimeoutMs: 3000,
|
||||
startStableWindowMs: 1200,
|
||||
})
|
||||
const [savingSettings, setSavingSettings] = useState(false)
|
||||
|
||||
@@ -350,6 +354,8 @@ export function CoreManagementPage() {
|
||||
defaultProxy: settings.defaultProxy,
|
||||
defaultFingerprintArgs: settings.defaultFingerprintArgs.join('\n'),
|
||||
defaultLaunchArgs: settings.defaultLaunchArgs.join('\n'),
|
||||
startReadyTimeoutMs: settings.startReadyTimeoutMs,
|
||||
startStableWindowMs: settings.startStableWindowMs,
|
||||
})
|
||||
setSettingsModalOpen(true)
|
||||
}
|
||||
@@ -363,6 +369,8 @@ export function CoreManagementPage() {
|
||||
defaultProxy: settingsForm.defaultProxy.trim(),
|
||||
defaultFingerprintArgs: settingsForm.defaultFingerprintArgs.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
defaultLaunchArgs: settingsForm.defaultLaunchArgs.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
startReadyTimeoutMs: Math.max(1000, Number(settingsForm.startReadyTimeoutMs) || 3000),
|
||||
startStableWindowMs: Math.max(0, Number(settingsForm.startStableWindowMs) || 1200),
|
||||
}
|
||||
await saveBrowserSettings(newSettings)
|
||||
setSettings(newSettings)
|
||||
@@ -431,6 +439,14 @@ export function CoreManagementPage() {
|
||||
<p className="text-sm text-[var(--color-text-primary)]">-</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">启动就绪超时</p>
|
||||
<p className="text-sm text-[var(--color-text-primary)]">{settings.startReadyTimeoutMs} ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">启动稳定窗口</p>
|
||||
<p className="text-sm text-[var(--color-text-primary)]">{settings.startStableWindowMs} ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -489,6 +505,28 @@ export function CoreManagementPage() {
|
||||
placeholder="每行一个参数,如 --disable-sync"
|
||||
/>
|
||||
</FormItem>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormItem label="启动就绪超时(毫秒)" hint="默认 3000,慢机器可调到 5000-10000">
|
||||
<Input
|
||||
type="number"
|
||||
min={1000}
|
||||
step={500}
|
||||
value={settingsForm.startReadyTimeoutMs}
|
||||
onChange={e => setSettingsForm(prev => ({ ...prev, startReadyTimeoutMs: Math.max(1000, Number(e.target.value) || 3000) }))}
|
||||
placeholder="3000"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="启动稳定窗口(毫秒)" hint="建议 1200-3000">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={settingsForm.startStableWindowMs}
|
||||
onChange={e => setSettingsForm(prev => ({ ...prev, startStableWindowMs: Math.max(0, Number(e.target.value) || 1200) }))}
|
||||
placeholder="1200"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import { CheckCircle, ChevronRight, Copy, FileText } from 'lucide-react'
|
||||
import { toast } from '../../../shared/components'
|
||||
import { BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
|
||||
import { fetchLaunchServerInfo } from '../api'
|
||||
import { fetchLaunchServerInfo, type LaunchServerInfo } from '../api'
|
||||
|
||||
// ============================================================================
|
||||
// 文档内容(自动化优先重构版)
|
||||
@@ -14,12 +14,13 @@ const DOC_OVERVIEW = `# 自动化接口文档(重构版)
|
||||
|
||||
## 文档目标
|
||||
|
||||
本页聚焦 **外部脚本 / 调度器通过 HTTP 触发实例唤起** 的场景,重点回答 4 个问题:
|
||||
本页聚焦 **外部脚本 / 调度器通过 HTTP 创建并唤起实例** 的场景,重点回答 5 个问题:
|
||||
|
||||
1. 如何通过 Code 或关键字直接唤起实例
|
||||
2. 如何通过 \`profileId / profileName / keyword / tags / groupId\` 选择实例
|
||||
3. 如何带参数启动,并拿到固定 \`cdpUrl\` 接入 CDP
|
||||
4. 如何通过日志排查选择器命中和启动失败问题
|
||||
1. 如何通过 HTTP 创建实例配置,并写入代理 / 标签 / 关键字 / 分组等信息
|
||||
2. 如何通过 Code 或关键字直接唤起实例
|
||||
3. 如何通过 \`profileId / profileName / keyword / tags / groupId\` 选择实例
|
||||
4. 如何带参数启动,并拿到固定 \`cdpUrl\` 接入 CDP
|
||||
5. 如何通过日志排查选择器命中和启动失败问题
|
||||
|
||||
## 协议定位
|
||||
|
||||
@@ -29,6 +30,7 @@ const DOC_OVERVIEW = `# 自动化接口文档(重构版)
|
||||
- \`Content-Type: application/json\`
|
||||
- JSON 请求体与 JSON 响应体
|
||||
- 服务仅监听本机 \`127.0.0.1\`
|
||||
- 如启用 \`launch_server.auth\`,所有 \`/api/*\` 请求还需附带 API Key Header
|
||||
|
||||
因此它和调用语言无关:
|
||||
|
||||
@@ -37,6 +39,7 @@ const DOC_OVERVIEW = `# 自动化接口文档(重构版)
|
||||
|
||||
## 当前支持能力
|
||||
|
||||
- 支持通过 \`/api/profiles\` 进行实例配置的创建、查询、更新、删除,并写入代理 / 标签 / 关键字 / 分组 / 启动参数等信息
|
||||
- 兼容旧版:\`GET /api/launch/{code}\`
|
||||
- 推荐主入口:\`POST /api/launch\`
|
||||
- \`POST /api/launch\` 中的 \`code\` 字段支持“LaunchCode 优先,关键字兜底”
|
||||
@@ -44,14 +47,17 @@ const DOC_OVERVIEW = `# 自动化接口文档(重构版)
|
||||
- \`selector\` 与顶层选择字段可混用,服务端会做归一化合并
|
||||
- \`key\` 会优先精确命中 \`keywords[]\`;精确没命中时再参与模糊匹配
|
||||
- 多命中时支持三种行为:\`unique\` / \`first\` / \`all\`
|
||||
- 启动后返回:\`profileId / profileName / launchCode / pid / debugPort / cdpPort / cdpUrl\`
|
||||
- 启动后返回:\`profileId / profileName / launchCode / pid / debugPort / debugReady / runtimeWarning / cdpPort / cdpUrl\`
|
||||
- 外部统一使用 LaunchServer 固定端口接入 CDP,\`debugPort\` 仅表示内部实际调试端口
|
||||
- 当 \`debugReady=false\` 时,表示浏览器窗口已拉起,但 CDP 仍在后台附着;此时 \`runtimeWarning\` 会说明当前限制
|
||||
- 保留最近调用日志:\`GET /api/launch/logs\`,其中 \`selector\` 为归一化后的结构
|
||||
- 可选 API Key 认证:仅保护 \`/api/*\`,不改变 CDP 统一入口的 localhost 访问模型
|
||||
|
||||
## 运行前提
|
||||
|
||||
- Ant Browser 应用已启动
|
||||
- Launch 服务监听本机(地址见本页顶部)
|
||||
- 如启用了 API 认证,请准备好请求头 \`X-Ant-Api-Key: <your-api-key>\`
|
||||
- 如果你要用 \`key / keyword / tags\` 选择实例,需要先在实例配置里维护这些字段
|
||||
- 如果你要用 \`groupId\`,请保证脚本拿到的是分组 ID,不是分组展示名
|
||||
|
||||
@@ -59,10 +65,11 @@ const DOC_OVERVIEW = `# 自动化接口文档(重构版)
|
||||
|
||||
\`\`\`
|
||||
任意语言客户端 / 调度器
|
||||
-> POST /api/profiles(可选:先创建实例配置)
|
||||
-> POST /api/launch
|
||||
-> 选择器解析实例
|
||||
-> 启动浏览器
|
||||
-> 返回 cdpUrl
|
||||
-> 返回 cdpUrl / debugReady
|
||||
-> Playwright / Selenium / 自研 CDP 客户端接管
|
||||
\`\`\`
|
||||
`
|
||||
@@ -92,22 +99,40 @@ const DOC_QUICKSTART = `# 快速接入(3 分钟)
|
||||
- 后端会先按真实 LaunchCode 查;查不到再按关键字匹配,并在多命中时默认取第一个
|
||||
- 如果需要把所有命中实例都启动,显式传 \`matchMode=all\`
|
||||
|
||||
## 第二步:健康检查
|
||||
## 如果启用 API 认证
|
||||
|
||||
所有 \`/api/*\` 请求都需要追加认证头:
|
||||
|
||||
\`\`\`bash
|
||||
curl -H "X-Ant-Api-Key: <your-api-key>" http://127.0.0.1:19876/api/health
|
||||
\`\`\`
|
||||
|
||||
## 第二步:选择创建触发方式
|
||||
|
||||
推荐按你的编排方式选择下面三种触发模式:
|
||||
|
||||
1. 仅创建配置:\`POST /api/profiles\`,只写实例资料,不启动浏览器
|
||||
2. 创建并立即启动:\`POST /api/profiles\` + \`autoLaunch=true\`
|
||||
3. 先创建后再启动:先 \`POST /api/profiles\`,再 \`POST /api/launch\`
|
||||
|
||||
稳定性优先时,推荐第 3 种。这样创建和启动是两个独立动作,失败时更容易做幂等、补偿和重试。
|
||||
|
||||
## 第三步:健康检查
|
||||
|
||||
\`\`\`bash
|
||||
curl http://127.0.0.1:19876/api/health
|
||||
# {"ok":true}
|
||||
\`\`\`
|
||||
|
||||
## 第三步:最简按 Code 启动
|
||||
## 第四步:最简按 Code 启动
|
||||
|
||||
\`\`\`bash
|
||||
curl http://127.0.0.1:19876/api/launch/A3F9K2
|
||||
\`\`\`
|
||||
|
||||
成功后会返回 \`cdpUrl\`,即可通过统一入口端口接入 CDP。
|
||||
成功后会返回 \`cdpUrl\`;如果同时返回 \`debugReady=false\`,说明浏览器已启动,但统一 CDP 入口还在等待附着完成。
|
||||
|
||||
## 第四步:推荐改为 POST 主入口
|
||||
## 第五步:推荐改为 POST 主入口
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
@@ -135,7 +160,7 @@ curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
## 第五步:复杂场景改用选择器
|
||||
## 第六步:复杂场景改用选择器
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
@@ -249,10 +274,17 @@ const DOC_API_INDEX = `# 接口总览
|
||||
| 能力 | 方法 | 路径 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 健康检查 | GET | \`/api/health\` | 检查 Launch 服务是否可用 |
|
||||
| 实例配置管理 | GET / POST | \`/api/profiles\` | 查询实例列表,或创建包含代理/标签/关键字/分组的实例配置 |
|
||||
| 单实例配置管理 | GET / PUT / DELETE | \`/api/profiles/{profileId}\` | 查询、更新、删除指定实例配置 |
|
||||
| 按 Code 启动 | GET | \`/api/launch/{code}\` | 兼容旧版、最快捷的唤起方式 |
|
||||
| 选择器启动 | POST | \`/api/launch\` | 支持 code / profileId / 名称 / 关键字 / 标签 / 分组 |
|
||||
| CDP 统一入口 | GET / WS | \`/json/version\`、\`/json/list\`、\`/devtools/...\` | 将非 \`/api\` 请求代理到当前活动实例 |
|
||||
| 调用记录 | GET | \`/api/launch/logs?limit=50\` | 查看最近接口调用与错误 |
|
||||
|
||||
说明:
|
||||
|
||||
- 如已启用 API 认证,所有 \`/api/*\` 请求都需要追加 \`X-Ant-Api-Key: <your-api-key>\`
|
||||
- CDP 统一入口仍只受 localhost 限制,不额外读取 API Key
|
||||
`
|
||||
|
||||
const DOC_API_HEALTH = `# 接口:健康检查
|
||||
@@ -276,6 +308,262 @@ curl http://127.0.0.1:19876/api/health
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
const DOC_API_PROFILES = `# 接口:实例配置管理
|
||||
|
||||
\`\`\`
|
||||
GET /api/profiles
|
||||
POST /api/profiles
|
||||
GET /api/profiles/{profileId}
|
||||
PUT /api/profiles/{profileId}
|
||||
DELETE /api/profiles/{profileId}
|
||||
\`\`\`
|
||||
|
||||
## 说明
|
||||
|
||||
- 用于外部脚本完整管理实例配置:先查、再创建、后续更新,最后按需删除
|
||||
- \`profile\` 内是持久化配置,会保存到实例资料里
|
||||
- \`start\` 内是本次自动启动的临时参数;只有 \`autoLaunch=true\` 时才会使用
|
||||
- 如果传 \`launchCode\`,会尝试设置为自定义启动码;重复时返回 \`409\`
|
||||
- \`DELETE /api/profiles/{profileId}\` 会拒绝删除运行中的实例,避免留下未托管进程
|
||||
|
||||
## 触发创建的 3 种方式
|
||||
|
||||
### 方式 1:仅创建配置
|
||||
|
||||
- 适合先建档、后续再由别的任务决定何时启动
|
||||
- 请求里只传 \`profile\`,可选传 \`launchCode\`
|
||||
- 成功后返回 \`created=true\`、\`launched=false\`
|
||||
|
||||
### 方式 2:创建并立即启动
|
||||
|
||||
- 在 \`POST /api/profiles\` 里同时传 \`autoLaunch=true\`
|
||||
- 如需给这次启动加临时参数,放进 \`start\`
|
||||
- 成功后返回 \`created=true\`、\`launched=true\`,并附带 \`pid / debugReady / cdpUrl\`
|
||||
|
||||
### 方式 3:先创建,再单独触发启动
|
||||
|
||||
- 第一步:\`POST /api/profiles\`
|
||||
- 第二步:读取响应里的 \`launchCode\` 或 \`profileId\`
|
||||
- 第三步:再调用 \`POST /api/launch\` 或 \`GET /api/launch/{code}\`
|
||||
|
||||
推荐这个模式作为默认编排方式,因为它把“配置持久化成功”和“浏览器实际拉起成功”分离开了,更适合脚本做重试和补偿。
|
||||
|
||||
## 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| \`profile\` | object | 是 | 实例配置对象 |
|
||||
| \`profile.profileName\` | string | 是 | 实例名称 |
|
||||
| \`profile.userDataDir\` | string | 否 | 用户数据目录;为空时自动生成 |
|
||||
| \`profile.coreId\` | string | 否 | 指定浏览器内核 |
|
||||
| \`profile.fingerprintArgs\` | string[] | 否 | 持久化指纹参数 |
|
||||
| \`profile.proxyId\` | string | 否 | 代理池中的代理 ID;传它时会自动回填 \`proxyConfig\` |
|
||||
| \`profile.proxyConfig\` | string | 否 | 直接写死的代理配置,如 \`http://user:pass@host:port\` |
|
||||
| \`profile.launchArgs\` | string[] | 否 | 实例默认启动参数,会持久化 |
|
||||
| \`profile.tags\` | string[] | 否 | 实例标签 |
|
||||
| \`profile.keywords\` | string[] | 否 | 实例关键字,供 \`/api/launch\` 检索 |
|
||||
| \`profile.groupId\` | string | 否 | 所属分组 ID |
|
||||
| \`launchCode\` | string | 否 | 自定义启动码,4-32 位,字符集 \`A-Z 0-9 _ -\` |
|
||||
| \`autoLaunch\` | boolean | 否 | 创建后是否立即启动 |
|
||||
| \`start.launchArgs\` | string[] | 否 | 本次自动启动附加参数,不持久化 |
|
||||
| \`start.startUrls\` | string[] | 否 | 本次自动启动打开的 URL |
|
||||
| \`start.skipDefaultStartUrls\` | boolean | 否 | 本次自动启动时是否跳过系统默认起始页 |
|
||||
|
||||
## 示例 1:创建一个绑定代理池节点的实例
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:19876/api/profiles \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-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"
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
## 示例 2:创建后立刻启动,并带一次性打开页面参数
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:19876/api/profiles \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"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"],
|
||||
"startUrls": ["https://example.com/order"],
|
||||
"skipDefaultStartUrls": true
|
||||
}
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
## 成功响应:仅创建
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"ok": true,
|
||||
"created": true,
|
||||
"launched": false,
|
||||
"profileId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"profileName": "buyer-001",
|
||||
"launchCode": "BUYER_001",
|
||||
"profile": {
|
||||
"profileId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"profileName": "buyer-001",
|
||||
"proxyId": "proxy-us",
|
||||
"proxyConfig": "socks5://127.0.0.1:1080",
|
||||
"tags": ["电商", "北美"],
|
||||
"keywords": ["buyer-001", "amazon"],
|
||||
"groupId": "group-sales-us"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 成功响应:创建并启动
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"ok": true,
|
||||
"created": true,
|
||||
"launched": true,
|
||||
"profileId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"profileName": "buyer-002",
|
||||
"launchCode": "A3F9K2",
|
||||
"pid": 12345,
|
||||
"debugPort": 9222,
|
||||
"debugReady": true,
|
||||
"runtimeWarning": "",
|
||||
"cdpPort": 9222,
|
||||
"cdpUrl": "http://127.0.0.1:9222",
|
||||
"profile": {
|
||||
"profileId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"profileName": "buyer-002",
|
||||
"proxyConfig": "http://user:pass@127.0.0.1:8080",
|
||||
"running": true
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 示例 3:先创建,再按返回的 launchCode 单独触发启动
|
||||
|
||||
\`\`\`bash
|
||||
# 第一步:创建实例配置
|
||||
curl -X POST http://127.0.0.1:19876/api/profiles \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"profile": {
|
||||
"profileName": "buyer-003",
|
||||
"keywords": ["buyer-003"]
|
||||
}
|
||||
}'
|
||||
|
||||
# 假设响应里返回 launchCode=A8KQ21
|
||||
|
||||
# 第二步:后续任务再单独触发启动
|
||||
curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"code": "A8KQ21",
|
||||
"skipDefaultStartUrls": true
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
## 列表查询
|
||||
|
||||
\`\`\`bash
|
||||
curl http://127.0.0.1:19876/api/profiles
|
||||
\`\`\`
|
||||
|
||||
成功响应示例:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"ok": true,
|
||||
"count": 2,
|
||||
"items": [
|
||||
{
|
||||
"profileId": "profile-a",
|
||||
"profileName": "buyer-001",
|
||||
"launchCode": "BUYER_001",
|
||||
"proxyId": "proxy-us",
|
||||
"tags": ["电商", "北美"]
|
||||
},
|
||||
{
|
||||
"profileId": "profile-b",
|
||||
"profileName": "buyer-002",
|
||||
"launchCode": "A3F9K2",
|
||||
"proxyConfig": "http://user:pass@127.0.0.1:8080"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 单实例查询
|
||||
|
||||
\`\`\`bash
|
||||
curl http://127.0.0.1:19876/api/profiles/550e8400-e29b-41d4-a716-446655440000
|
||||
\`\`\`
|
||||
|
||||
## 更新实例配置
|
||||
|
||||
\`\`\`bash
|
||||
curl -X PUT http://127.0.0.1:19876/api/profiles/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"profile": {
|
||||
"profileName": "buyer-001-updated",
|
||||
"userDataDir": "buyers/buyer-001",
|
||||
"proxyId": "proxy-us",
|
||||
"launchArgs": ["--lang=en-US"],
|
||||
"tags": ["电商", "北美"],
|
||||
"keywords": ["buyer-001", "amazon"],
|
||||
"groupId": "group-sales-us"
|
||||
},
|
||||
"launchCode": "BUYER_001",
|
||||
"autoLaunch": true,
|
||||
"start": {
|
||||
"startUrls": ["https://example.com/order"],
|
||||
"skipDefaultStartUrls": true
|
||||
}
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
说明:
|
||||
|
||||
- \`PUT\` 采用“整份配置更新”语义,建议先 \`GET /api/profiles/{profileId}\` 再修改后回写
|
||||
- 如果 \`launchCode\` 冲突,后端会回滚本次更新,避免配置部分落库
|
||||
|
||||
## 删除实例配置
|
||||
|
||||
\`\`\`bash
|
||||
curl -X DELETE http://127.0.0.1:19876/api/profiles/550e8400-e29b-41d4-a716-446655440000
|
||||
\`\`\`
|
||||
|
||||
成功响应示例:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"ok": true,
|
||||
"deleted": true,
|
||||
"profileId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"profileName": "buyer-001",
|
||||
"launchCode": "BUYER_001"
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
const DOC_API_LAUNCH_GET = `# 接口:按 Code 启动
|
||||
|
||||
\`\`\`
|
||||
@@ -305,6 +593,8 @@ curl http://127.0.0.1:19876/api/launch/A3F9K2
|
||||
"launchCode": "A3F9K2",
|
||||
"pid": 12345,
|
||||
"debugPort": 9222,
|
||||
"debugReady": true,
|
||||
"runtimeWarning": "",
|
||||
"cdpPort": 19876,
|
||||
"cdpUrl": "http://127.0.0.1:19876"
|
||||
}
|
||||
@@ -469,6 +759,8 @@ curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
"launchCode": "A3F9K2",
|
||||
"pid": 12345,
|
||||
"debugPort": 9222,
|
||||
"debugReady": true,
|
||||
"runtimeWarning": "",
|
||||
"cdpPort": 19876,
|
||||
"cdpUrl": "http://127.0.0.1:19876"
|
||||
}
|
||||
@@ -492,6 +784,8 @@ curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
"launchCode": "A3F9K2",
|
||||
"pid": 12345,
|
||||
"debugPort": 9222,
|
||||
"debugReady": true,
|
||||
"runtimeWarning": "",
|
||||
"isActive": false
|
||||
},
|
||||
{
|
||||
@@ -500,6 +794,8 @@ curl -X POST http://127.0.0.1:19876/api/launch \\
|
||||
"launchCode": "B7Q2W9",
|
||||
"pid": 12346,
|
||||
"debugPort": 9333,
|
||||
"debugReady": true,
|
||||
"runtimeWarning": "",
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
@@ -526,6 +822,7 @@ WS /devtools/...
|
||||
- LaunchServer 会把所有非 \`/api\` 请求代理到当前活动实例的内部调试端口
|
||||
- 当前活动实例等于最近一次成功启动的实例
|
||||
- 如果使用 \`matchMode=all\`,则最后一个启动成功的实例会成为当前活动实例
|
||||
- 如果启动响应里 \`debugReady=false\`,说明当前实例暂时还不会成为活动实例,直到后台附着完成
|
||||
- 如果当前没有活动实例,请求会返回 \`503\` 和 \`no active browser debug target\`
|
||||
|
||||
## 请求示例
|
||||
@@ -687,13 +984,14 @@ const DOC_ERRORS = `# 错误码与重试策略
|
||||
|
||||
| 状态码 | 场景 | 建议处理 |
|
||||
|--------|------|----------|
|
||||
| 400 | 请求体非法 / 含未知字段 / selector 缺失 / matchMode 非法 | 修复参数后重试 |
|
||||
| 400 | 请求体非法 / 含未知字段 / selector 缺失 / matchMode 非法 / launchCode 格式错误 | 修复参数后重试 |
|
||||
| 401 | 已启用 API 认证,但缺少或写错 API Key | 补上正确的 \`X-Ant-Api-Key\` 请求头后重试 |
|
||||
| 403 | 非 localhost 访问 | 改为本机请求 |
|
||||
| 404 | GET 的 Code 不存在 / POST 的 code 关键字兜底后仍未命中 / selector 没命中实例 | 检查 code、keywords、tags、groupId |
|
||||
| 405 | 方法错误 | 使用正确 HTTP 方法 |
|
||||
| 409 | selector 命中多个实例 | 收窄条件,或显式设 \`matchMode=first\` 或 \`matchMode=all\` |
|
||||
| 409 | selector 命中多个实例 / 创建或更新时 launchCode 冲突 / 达到实例上限 / 删除运行中实例 | 收窄条件、换一个 launchCode,或先停掉实例后重试 |
|
||||
| 500 | 启动失败 | 查 \`/api/launch/logs\` + 应用日志 |
|
||||
| 503 | 访问 CDP 统一入口时还没有活动实例 | 先成功调用一次启动接口,再访问 \`cdpUrl\` |
|
||||
| 503 | 访问 CDP 统一入口时还没有活动实例,或启动响应仍处于 \`debugReady=false\` | 先确认启动接口成功,再等待 \`debugReady=true\` 后访问 \`cdpUrl\` |
|
||||
|
||||
## 自动化建议
|
||||
|
||||
@@ -707,6 +1005,38 @@ const DOC_EXAMPLES = `# 多语言调用示例(同一协议)
|
||||
|
||||
下面这些示例调用的是同一组 HTTP 接口,只是客户端语法不同。你可以直接替换成自己的语言或框架实现。
|
||||
|
||||
如果你启用了 API 认证,请记得在各语言客户端里补上 \`X-Ant-Api-Key\` 请求头。
|
||||
|
||||
## Python:触发创建并立即启动
|
||||
|
||||
\`\`\`python
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:19876"
|
||||
|
||||
def create_profile(auto_launch: bool = False) -> dict:
|
||||
res = requests.post(
|
||||
f"{BASE}/api/profiles",
|
||||
json={
|
||||
"profile": {
|
||||
"profileName": "buyer-100",
|
||||
"proxyId": "proxy-us",
|
||||
"keywords": ["buyer-100"],
|
||||
},
|
||||
"autoLaunch": auto_launch,
|
||||
"start": {
|
||||
"startUrls": ["https://example.com/order"],
|
||||
"skipDefaultStartUrls": True,
|
||||
} if auto_launch else None,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
data = res.json()
|
||||
if not res.ok or not data.get("ok"):
|
||||
raise RuntimeError(data.get("error", f"HTTP {res.status_code}"))
|
||||
return data
|
||||
\`\`\`
|
||||
|
||||
## Python:按关键字启动并连接 CDP
|
||||
|
||||
\`\`\`python
|
||||
@@ -827,7 +1157,13 @@ const DOC_PRACTICES = `# 最佳实践
|
||||
- 把任务临时参数放在 \`launchArgs\`
|
||||
- 只在当前任务需要时才传 \`startUrls\`
|
||||
|
||||
## 5) 排障流程
|
||||
## 5) 创建触发策略
|
||||
|
||||
- 批量导入或配置预热:用“仅创建配置”
|
||||
- 单任务一次性跑通:用“创建并立即启动”
|
||||
- 生产调度和高可用编排:优先“先创建,再单独触发启动”
|
||||
|
||||
## 6) 排障流程
|
||||
|
||||
1. 先调 \`/api/health\`
|
||||
2. 再调 \`POST /api/launch\`
|
||||
@@ -864,21 +1200,28 @@ const DOC_TROUBLESHOOT = `# 常见问题
|
||||
- 如果业务允许,显式加 \`matchMode=first\`
|
||||
- 如果你要把这些命中实例全部启动,改用 \`matchMode=all\`
|
||||
|
||||
## Q5:返回 \`forbidden: only localhost is allowed\`
|
||||
## Q5:返回 \`unauthorized: invalid api key\`
|
||||
|
||||
- 说明当前已经启用 API 认证
|
||||
- 你没有传认证头,或传错了 API Key
|
||||
- 请检查请求头 \`X-Ant-Api-Key: <your-api-key>\` 是否正确
|
||||
|
||||
## Q6:返回 \`forbidden: only localhost is allowed\`
|
||||
|
||||
- 当前服务只允许本机访问
|
||||
- 请在同一台机器发起请求
|
||||
|
||||
## Q6:返回 \`500\` 启动失败
|
||||
## Q7:返回 \`500\` 启动失败
|
||||
|
||||
- 先看 \`/api/launch/logs\` 里的 \`error\`
|
||||
- 再检查内核路径、代理配置、启动参数是否合法
|
||||
- 如果是复杂 selector,先确认命中的实例就是你预期那一个
|
||||
|
||||
## Q7:访问 \`cdpUrl\` 返回 \`no active browser debug target\`
|
||||
## Q8:访问 \`cdpUrl\` 返回 \`no active browser debug target\`
|
||||
|
||||
- 说明当前还没有活动实例
|
||||
- 先调用一次 \`GET /api/launch/{code}\` 或 \`POST /api/launch\`
|
||||
- 如果启动响应里 \`debugReady=false\`,说明实例正在后台附着,稍后再访问 \`cdpUrl\`
|
||||
- 如果刚启动完实例仍然出现这个问题,再检查启动接口是否真的返回了 \`200\`
|
||||
`
|
||||
|
||||
@@ -919,6 +1262,7 @@ const DOC_TREE: DocNode[] = [
|
||||
label: '核心接口',
|
||||
children: [
|
||||
{ id: 'api-health', label: '健康检查', content: DOC_API_HEALTH },
|
||||
{ id: 'api-profiles', label: '实例管理', content: DOC_API_PROFILES },
|
||||
{ id: 'api-launch-get', label: '按 Code 启动', content: DOC_API_LAUNCH_GET },
|
||||
{ id: 'api-launch-post', label: '参数化启动', content: DOC_API_LAUNCH_POST },
|
||||
{ id: 'api-cdp', label: 'CDP 统一入口', content: DOC_API_CDP },
|
||||
@@ -953,14 +1297,22 @@ const DOC_TREE: DocNode[] = [
|
||||
]
|
||||
|
||||
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 renderDocWithLaunchBase(raw: string, baseUrl: string): string {
|
||||
function renderDocWithLaunchContext(raw: string, baseUrl: string, authHeader: string): string {
|
||||
if (!raw) return raw
|
||||
const safeBase = baseUrl.trim() || DEFAULT_LAUNCH_BASE_URL
|
||||
const safeAuthHeader = authHeader.trim() || DEFAULT_API_AUTH.header
|
||||
const hostPort = safeBase.replace(/^https?:\/\//, '')
|
||||
return raw
|
||||
.split('http://127.0.0.1:19876').join(safeBase)
|
||||
.split('127.0.0.1:19876').join(hostPort)
|
||||
.split('X-Ant-Api-Key').join(safeAuthHeader)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1197,6 +1549,7 @@ export function LaunchApiDocsPage() {
|
||||
const [activeContent, setActiveContent] = useState(firstLeaf.content || '')
|
||||
const [launchBaseUrl, setLaunchBaseUrl] = useState(DEFAULT_LAUNCH_BASE_URL)
|
||||
const [launchServerReady, setLaunchServerReady] = useState(false)
|
||||
const [apiAuth, setApiAuth] = useState<LaunchServerInfo['apiAuth']>(DEFAULT_API_AUTH)
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
|
||||
const parents = collectParentIds(DOC_TREE, firstLeaf.id)
|
||||
@@ -1213,6 +1566,7 @@ export function LaunchApiDocsPage() {
|
||||
setLaunchBaseUrl(info.baseUrl)
|
||||
}
|
||||
setLaunchServerReady(info.ready)
|
||||
setApiAuth(info.apiAuth)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
@@ -1234,7 +1588,7 @@ export function LaunchApiDocsPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const renderedContent = renderDocWithLaunchBase(activeContent, launchBaseUrl)
|
||||
const renderedContent = renderDocWithLaunchContext(activeContent, launchBaseUrl, apiAuth.header)
|
||||
|
||||
return (
|
||||
<div className="flex h-full -m-5 overflow-hidden">
|
||||
@@ -1263,6 +1617,13 @@ export function LaunchApiDocsPage() {
|
||||
当前 Launch 地址:<code>{launchBaseUrl}</code>
|
||||
{!launchServerReady ? '(服务启动后会自动刷新)' : ''}
|
||||
</div>
|
||||
<div className="mb-4 px-3 py-2 text-xs rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)]">
|
||||
{apiAuth.enabled
|
||||
? <>当前 API 认证已启用,请为所有 <code>/api/*</code> 请求追加 <code>{apiAuth.header}: <your-api-key></code>。</>
|
||||
: apiAuth.requested && !apiAuth.configured
|
||||
? <>当前配置要求启用 API 认证,但 <code>api_key</code> 为空,认证尚未生效。</>
|
||||
: <>当前 API 认证未启用;如需开启,可在 <code>config.yaml</code> 的 <code>launch_server.auth</code> 下配置。</>}
|
||||
</div>
|
||||
<MarkdownContent content={renderedContent} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -3,9 +3,15 @@ import { BookOpen, Download, Globe, Keyboard, Layers, Monitor, Rocket } from 'lu
|
||||
import { Button, Card } from '../../../shared/components'
|
||||
import { BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
|
||||
import type { ReactNode } from 'react'
|
||||
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 StepCard({
|
||||
icon,
|
||||
@@ -45,9 +51,26 @@ function LinkButton({ url, children }: { url: string; children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function buildLaunchCodeCurlSample(baseUrl: string, apiAuth: LaunchServerInfo['apiAuth']): string {
|
||||
const authComment = apiAuth.enabled
|
||||
? `# 如果已启用认证,请追加请求头:${apiAuth.header}: <your-api-key>\n`
|
||||
: ''
|
||||
const authHeaderLine = apiAuth.enabled ? ` -H "${apiAuth.header}: <your-api-key>" \\\n` : ''
|
||||
const getSuffix = apiAuth.enabled ? ` \\\n -H "${apiAuth.header}: <your-api-key>"` : ''
|
||||
|
||||
return `${authComment}# 按 Code 启动
|
||||
curl ${baseUrl}/api/launch/A3F9K2${getSuffix}
|
||||
|
||||
# 带参数启动
|
||||
curl -X POST ${baseUrl}/api/launch \\
|
||||
-H "Content-Type: application/json" \\
|
||||
${authHeaderLine} -d '{"code":"A3F9K2","launchArgs":["--window-size=1280,800"]}'`
|
||||
}
|
||||
|
||||
export function UsageTutorialPage() {
|
||||
const [launchBaseUrl, setLaunchBaseUrl] = useState(DEFAULT_LAUNCH_BASE_URL)
|
||||
const [launchServerReady, setLaunchServerReady] = useState(false)
|
||||
const [apiAuth, setApiAuth] = useState<LaunchServerInfo['apiAuth']>(DEFAULT_API_AUTH)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -59,6 +82,7 @@ export function UsageTutorialPage() {
|
||||
setLaunchBaseUrl(info.baseUrl)
|
||||
}
|
||||
setLaunchServerReady(info.ready)
|
||||
setApiAuth(info.apiAuth)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
@@ -67,13 +91,7 @@ export function UsageTutorialPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const launchCodeCurlSample = `# 按 Code 启动
|
||||
curl ${launchBaseUrl}/api/launch/A3F9K2
|
||||
|
||||
# 带参数启动
|
||||
curl -X POST ${launchBaseUrl}/api/launch \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"code":"A3F9K2","launchArgs":["--window-size=1280,800"]}'`
|
||||
const launchCodeCurlSample = buildLaunchCodeCurlSample(launchBaseUrl, apiAuth)
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
@@ -146,6 +164,13 @@ Esc 关闭弹窗`}
|
||||
当前 Launch 地址:<code>{launchBaseUrl}</code>
|
||||
{!launchServerReady ? '(服务启动后会自动刷新)' : ''}
|
||||
</p>
|
||||
<p>
|
||||
{apiAuth.enabled
|
||||
? <>当前 API 认证已启用,请为所有 <code>/api/*</code> 请求追加 <code>{apiAuth.header}: <your-api-key></code>。</>
|
||||
: apiAuth.requested && !apiAuth.configured
|
||||
? <>当前配置要求启用 API 认证,但 <code>api_key</code> 为空,认证尚未生效。</>
|
||||
: <>当前 API 认证未启用;如需开启,可在 <code>config.yaml</code> 的 <code>launch_server.auth</code> 下配置。</>}
|
||||
</p>
|
||||
<pre className="text-xs font-mono bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
|
||||
{launchCodeCurlSample}
|
||||
</pre>
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface BrowserProfile {
|
||||
groupId?: string
|
||||
running: boolean
|
||||
debugPort: number
|
||||
debugReady: boolean
|
||||
pid: number
|
||||
runtimeWarning: string
|
||||
lastError: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -50,6 +52,8 @@ export interface BrowserSettings {
|
||||
defaultFingerprintArgs: string[]
|
||||
defaultLaunchArgs: string[]
|
||||
defaultProxy: string
|
||||
startReadyTimeoutMs: number
|
||||
startStableWindowMs: number
|
||||
}
|
||||
|
||||
export interface BrowserCore {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export function resolveActionErrorMessage(error: unknown, fallback: string): string {
|
||||
const backgroundAttachMarkers = [
|
||||
'系统会继续在后台连接',
|
||||
'系统将继续后台连接',
|
||||
'程序将继续后台重试',
|
||||
]
|
||||
|
||||
function extractActionMessage(error: unknown): string {
|
||||
const message =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
@@ -11,5 +17,24 @@ export function resolveActionErrorMessage(error: unknown, fallback: string): str
|
||||
return normalized
|
||||
}
|
||||
|
||||
return `${fallback},但系统没有返回明确原因。请在实例详情中查看最近错误,或检查应用日志。`
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isBackgroundAttachMessage(message: string): boolean {
|
||||
return backgroundAttachMarkers.some(marker => message.includes(marker))
|
||||
}
|
||||
|
||||
export function resolveActionFeedback(error: unknown, fallback: string): { message: string; tone: 'error' | 'warning'; pendingAttach: boolean } {
|
||||
const normalized = extractActionMessage(error)
|
||||
const message = normalized || `${fallback},但系统没有返回明确原因。请在实例详情中查看最近错误,或检查应用日志。`
|
||||
const pendingAttach = isBackgroundAttachMessage(message)
|
||||
return {
|
||||
message,
|
||||
tone: pendingAttach ? 'warning' : 'error',
|
||||
pendingAttach,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveActionErrorMessage(error: unknown, fallback: string): string {
|
||||
return resolveActionFeedback(error, fallback).message
|
||||
}
|
||||
|
||||
+2
@@ -153,6 +153,8 @@ export function OpenCorePath(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenUserDataDir(arg1:string):Promise<void>;
|
||||
|
||||
export function QuitAppOnly():Promise<void>;
|
||||
|
||||
export function RedeemCDKey(arg1:string):Promise<void>;
|
||||
|
||||
export function RedeemGithubStar():Promise<void>;
|
||||
|
||||
@@ -294,6 +294,10 @@ export function OpenUserDataDir(arg1) {
|
||||
return window['go']['main']['App']['OpenUserDataDir'](arg1);
|
||||
}
|
||||
|
||||
export function QuitAppOnly() {
|
||||
return window['go']['main']['App']['QuitAppOnly']();
|
||||
}
|
||||
|
||||
export function RedeemCDKey(arg1) {
|
||||
return window['go']['main']['App']['RedeemCDKey'](arg1);
|
||||
}
|
||||
|
||||
@@ -411,7 +411,9 @@ export namespace browser {
|
||||
launchCode: string;
|
||||
running: boolean;
|
||||
debugPort: number;
|
||||
debugReady: boolean;
|
||||
pid: number;
|
||||
runtimeWarning: string;
|
||||
lastError: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -442,7 +444,9 @@ export namespace browser {
|
||||
this.launchCode = source["launchCode"];
|
||||
this.running = source["running"];
|
||||
this.debugPort = source["debugPort"];
|
||||
this.debugReady = source["debugReady"];
|
||||
this.pid = source["pid"];
|
||||
this.runtimeWarning = source["runtimeWarning"];
|
||||
this.lastError = source["lastError"];
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
@@ -485,6 +489,8 @@ export namespace browser {
|
||||
defaultFingerprintArgs: string[];
|
||||
defaultLaunchArgs: string[];
|
||||
defaultProxy: string;
|
||||
startReadyTimeoutMs: number;
|
||||
startStableWindowMs: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Settings(source);
|
||||
@@ -496,6 +502,8 @@ export namespace browser {
|
||||
this.defaultFingerprintArgs = source["defaultFingerprintArgs"];
|
||||
this.defaultLaunchArgs = source["defaultLaunchArgs"];
|
||||
this.defaultProxy = source["defaultProxy"];
|
||||
this.startReadyTimeoutMs = source["startReadyTimeoutMs"];
|
||||
this.startStableWindowMs = source["startStableWindowMs"];
|
||||
}
|
||||
}
|
||||
export class Tab {
|
||||
|
||||
+21
-4
@@ -3,6 +3,20 @@ import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
const defaultDevPort = 5218
|
||||
|
||||
function resolveBoolean(rawValue: string | undefined, fallbackValue: boolean) {
|
||||
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 resolveDevPort() {
|
||||
const raw = Number.parseInt(process.env.FRONTEND_PORT || '', 10)
|
||||
if (Number.isInteger(raw) && raw > 0 && raw <= 65535) {
|
||||
@@ -12,6 +26,7 @@ function resolveDevPort() {
|
||||
}
|
||||
|
||||
const devPort = resolveDevPort()
|
||||
const disableHmr = resolveBoolean(process.env.FRONTEND_DISABLE_HMR, false)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -20,10 +35,12 @@ export default defineConfig({
|
||||
strictPort: true,
|
||||
host: '127.0.0.1',
|
||||
cors: true,
|
||||
hmr: {
|
||||
host: '127.0.0.1',
|
||||
protocol: 'ws',
|
||||
},
|
||||
hmr: disableHmr
|
||||
? false
|
||||
: {
|
||||
host: '127.0.0.1',
|
||||
protocol: 'ws',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
|
||||
@@ -46,6 +46,16 @@ type wailsBuildConfig struct {
|
||||
} `json:"info"`
|
||||
}
|
||||
|
||||
func envFlagEnabled(name string) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
||||
switch value {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBuildVersion() string {
|
||||
var cfg wailsBuildConfig
|
||||
if err := json.Unmarshal(wailsConfigJSON, &cfg); err != nil {
|
||||
@@ -120,25 +130,30 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("应用根目录: %s (dev=%v)", appRoot, isDevMode)
|
||||
startupDebugEnabled := envFlagEnabled("ANT_BROWSER_DEBUG_STARTUP")
|
||||
if startupDebugEnabled {
|
||||
log.Printf("应用根目录: %s (dev=%v)", appRoot, isDevMode)
|
||||
}
|
||||
if err := backend.EnsureRuntimeLayout(appRoot); err != nil {
|
||||
log.Printf("准备用户数据目录失败: %v", err)
|
||||
}
|
||||
if backend.RuntimeUsesDetachedState(appRoot) {
|
||||
if startupDebugEnabled && backend.RuntimeUsesDetachedState(appRoot) {
|
||||
log.Printf("检测到安装目录需要只读运行,状态目录切换到: %s", backend.RuntimeStateRoot(appRoot))
|
||||
}
|
||||
buildVersion := resolveBuildVersion()
|
||||
log.Printf("应用版本: %s", buildVersion)
|
||||
log.Printf(
|
||||
"Wails 启动环境: GOOS=%s GOARCH=%s DISPLAY=%q WAYLAND_DISPLAY=%q XDG_SESSION_TYPE=%q XDG_CURRENT_DESKTOP=%q",
|
||||
goruntime.GOOS,
|
||||
goruntime.GOARCH,
|
||||
os.Getenv("DISPLAY"),
|
||||
os.Getenv("WAYLAND_DISPLAY"),
|
||||
os.Getenv("XDG_SESSION_TYPE"),
|
||||
os.Getenv("XDG_CURRENT_DESKTOP"),
|
||||
)
|
||||
if goruntime.GOOS == "linux" && strings.TrimSpace(os.Getenv("DISPLAY")) == "" && strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) == "" {
|
||||
if startupDebugEnabled {
|
||||
log.Printf("应用版本: %s", buildVersion)
|
||||
log.Printf(
|
||||
"Wails 启动环境: GOOS=%s GOARCH=%s DISPLAY=%q WAYLAND_DISPLAY=%q XDG_SESSION_TYPE=%q XDG_CURRENT_DESKTOP=%q",
|
||||
goruntime.GOOS,
|
||||
goruntime.GOARCH,
|
||||
os.Getenv("DISPLAY"),
|
||||
os.Getenv("WAYLAND_DISPLAY"),
|
||||
os.Getenv("XDG_SESSION_TYPE"),
|
||||
os.Getenv("XDG_CURRENT_DESKTOP"),
|
||||
)
|
||||
}
|
||||
if startupDebugEnabled && goruntime.GOOS == "linux" && strings.TrimSpace(os.Getenv("DISPLAY")) == "" && strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) == "" {
|
||||
log.Printf("检测到 Linux 图形环境变量为空:DISPLAY / WAYLAND_DISPLAY 都未设置,GUI 窗口大概率无法创建")
|
||||
}
|
||||
|
||||
@@ -155,17 +170,21 @@ func main() {
|
||||
var wailsCtx context.Context
|
||||
startupReached := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-startupReached:
|
||||
return
|
||||
case <-time.After(12 * time.Second):
|
||||
log.Printf("Wails OnStartup 在 12 秒内未触发。若终端一直转圈但没有窗口,优先检查 Linux 图形环境、libgtk-3、libwebkit2gtk,以及是否运行在 SSH/容器/无桌面会话中")
|
||||
}
|
||||
}()
|
||||
if startupDebugEnabled {
|
||||
go func() {
|
||||
select {
|
||||
case <-startupReached:
|
||||
return
|
||||
case <-time.After(12 * time.Second):
|
||||
log.Printf("Wails OnStartup 在 12 秒内未触发。若终端一直转圈但没有窗口,优先检查 Linux 图形环境、libgtk-3、libwebkit2gtk,以及是否运行在 SSH/容器/无桌面会话中")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
log.Printf("准备调用 wails.Run 创建 GUI 窗口")
|
||||
if startupDebugEnabled {
|
||||
log.Printf("准备调用 wails.Run 创建 GUI 窗口")
|
||||
}
|
||||
err = wails.Run(&options.App{
|
||||
Title: cfg.App.Name,
|
||||
Width: cfg.App.Window.Width,
|
||||
@@ -178,7 +197,9 @@ func main() {
|
||||
BackgroundColour: &options.RGBA{R: 245, G: 247, B: 250, A: 255},
|
||||
OnStartup: func(ctx context.Context) {
|
||||
close(startupReached)
|
||||
log.Printf("Wails OnStartup 已触发,GUI 宿主已创建")
|
||||
if startupDebugEnabled {
|
||||
log.Printf("Wails OnStartup 已触发,GUI 宿主已创建")
|
||||
}
|
||||
wailsCtx = ctx
|
||||
// 启动系统托盘(非阻塞)
|
||||
go backend.RunTray(backend.TrayCallbacks{
|
||||
@@ -186,15 +207,22 @@ func main() {
|
||||
runtime.WindowShow(wailsCtx)
|
||||
runtime.WindowUnminimise(wailsCtx)
|
||||
},
|
||||
OnQuitAppOnly: func() {
|
||||
app.QuitAppOnly()
|
||||
},
|
||||
OnQuit: func() {
|
||||
app.ForceQuit()
|
||||
},
|
||||
})
|
||||
app.startup(ctx)
|
||||
log.Printf("后端 startup 已完成")
|
||||
if startupDebugEnabled {
|
||||
log.Printf("后端 startup 已完成")
|
||||
}
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
log.Printf("Wails OnShutdown 已触发")
|
||||
if startupDebugEnabled {
|
||||
log.Printf("Wails OnShutdown 已触发")
|
||||
}
|
||||
backend.QuitTray()
|
||||
app.shutdown(ctx)
|
||||
},
|
||||
@@ -219,5 +247,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal("启动应用失败:", err)
|
||||
}
|
||||
log.Printf("wails.Run 已退出")
|
||||
if startupDebugEnabled {
|
||||
log.Printf("wails.Run 已退出")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ fi
|
||||
|
||||
if [[ "$SKIP_BUILD" -ne 1 ]]; then
|
||||
echo "[1/5] Installing frontend dependencies..."
|
||||
(cd "$ROOT_DIR/frontend" && npm ci --prefer-offline --no-audit --no-fund)
|
||||
(cd "$ROOT_DIR/frontend" && BROWSERSLIST_IGNORE_OLD_DATA=1 npm ci --prefer-offline --no-audit --no-fund)
|
||||
|
||||
echo "[2/5] Building frontend assets..."
|
||||
(cd "$ROOT_DIR/frontend" && npm run build)
|
||||
(cd "$ROOT_DIR/frontend" && BROWSERSLIST_IGNORE_OLD_DATA=1 npm run build)
|
||||
|
||||
echo "[3/5] Building app binary with Wails..."
|
||||
rm -f "$APP_BIN"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkingDirectory,
|
||||
[int]$MemoryLimitMB = 512,
|
||||
[int]$MaxOldSpaceMB = 256,
|
||||
[int]$MaxSemiSpaceMB = 16,
|
||||
[string]$PidFile = "",
|
||||
[string[]]$NodeArgs = @("frontend/scripts/dev-watcher.mjs")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Remove-PidFile {
|
||||
if ($PidFile -and (Test-Path -LiteralPath $PidFile)) {
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if (-not ("AntChrome.JobObjectNative" -as [type])) {
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AntChrome {
|
||||
public static class JobObjectNative {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct IO_COUNTERS {
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
public const int JobObjectExtendedLimitInformation = 9;
|
||||
public const uint JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100;
|
||||
public const uint JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200;
|
||||
public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetInformationJobObject(
|
||||
IntPtr hJob,
|
||||
int JobObjectInfoClass,
|
||||
ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo,
|
||||
int cbJobObjectInfoLength
|
||||
);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
}
|
||||
}
|
||||
"@
|
||||
}
|
||||
|
||||
$jobName = "ant-chrome-node-$PID"
|
||||
$jobHandle = [AntChrome.JobObjectNative]::CreateJobObject([IntPtr]::Zero, $jobName)
|
||||
if ($jobHandle -eq [IntPtr]::Zero) {
|
||||
throw "CreateJobObject failed: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
|
||||
}
|
||||
|
||||
$memoryLimitBytes = [UInt64]$MemoryLimitMB * 1MB
|
||||
$limits = New-Object AntChrome.JobObjectNative+JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
$limits.BasicLimitInformation.LimitFlags = `
|
||||
[AntChrome.JobObjectNative]::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE -bor `
|
||||
[AntChrome.JobObjectNative]::JOB_OBJECT_LIMIT_JOB_MEMORY -bor `
|
||||
[AntChrome.JobObjectNative]::JOB_OBJECT_LIMIT_PROCESS_MEMORY
|
||||
$limits.ProcessMemoryLimit = [UIntPtr]::new($memoryLimitBytes)
|
||||
$limits.JobMemoryLimit = [UIntPtr]::new($memoryLimitBytes)
|
||||
|
||||
$limitStructSize = [Runtime.InteropServices.Marshal]::SizeOf($limits)
|
||||
if (-not [AntChrome.JobObjectNative]::SetInformationJobObject(
|
||||
$jobHandle,
|
||||
[AntChrome.JobObjectNative]::JobObjectExtendedLimitInformation,
|
||||
[ref]$limits,
|
||||
$limitStructSize
|
||||
)) {
|
||||
throw "SetInformationJobObject failed: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
|
||||
}
|
||||
|
||||
$currentProcessHandle = [System.Diagnostics.Process]::GetCurrentProcess().Handle
|
||||
if (-not [AntChrome.JobObjectNative]::AssignProcessToJobObject($jobHandle, $currentProcessHandle)) {
|
||||
throw "AssignProcessToJobObject failed: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
|
||||
}
|
||||
|
||||
$nodeOptions = "--max-old-space-size=$MaxOldSpaceMB --max-semi-space-size=$MaxSemiSpaceMB"
|
||||
if ($env:NODE_OPTIONS) {
|
||||
$env:NODE_OPTIONS = "$($env:NODE_OPTIONS) $nodeOptions"
|
||||
} else {
|
||||
$env:NODE_OPTIONS = $nodeOptions
|
||||
}
|
||||
$env:npm_config_node_options = $env:NODE_OPTIONS
|
||||
|
||||
if (-not (Test-Path -LiteralPath $WorkingDirectory)) {
|
||||
throw "Working directory not found: $WorkingDirectory"
|
||||
}
|
||||
|
||||
if ($PidFile) {
|
||||
$pidDirectory = Split-Path -Parent $PidFile
|
||||
if ($pidDirectory) {
|
||||
New-Item -ItemType Directory -Force -Path $pidDirectory | Out-Null
|
||||
}
|
||||
Set-Content -LiteralPath $PidFile -Value $PID -Encoding ascii -NoNewline
|
||||
}
|
||||
|
||||
Write-Host "[node-limit] job-memory=$MemoryLimitMB MB"
|
||||
Write-Host "[node-limit] node-options=$env:NODE_OPTIONS"
|
||||
Write-Host "[node-limit] working-directory=$WorkingDirectory"
|
||||
Write-Host "[node-limit] node-args=$($NodeArgs -join ' ')"
|
||||
|
||||
try {
|
||||
$nodeProcess = Start-Process -FilePath "node" -ArgumentList $NodeArgs -WorkingDirectory $WorkingDirectory -PassThru -NoNewWindow
|
||||
$nodeProcess.WaitForExit()
|
||||
exit $nodeProcess.ExitCode
|
||||
} finally {
|
||||
Remove-PidFile
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
"outputfilename": "ant-chrome",
|
||||
"frontend:install": "npm install && npm run ensure:native",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "node ./scripts/dev-watcher.mjs",
|
||||
"frontend:dev:serverUrl": "http://127.0.0.1:5218",
|
||||
"author": {
|
||||
"name": "Ant Chrome Team",
|
||||
"email": "contact@antblack.de"
|
||||
|
||||
Reference in New Issue
Block a user