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:
Ant Browser Release Bot
2026-03-29 19:32:05 +08:00
parent fc5a6fadbe
commit 3d264eb83d
58 changed files with 6106 additions and 464 deletions
+2
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
+68
View File
@@ -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)
}
}
+62
View File
@@ -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")
}
}
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+54 -1
View File
@@ -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")
}
}
+410 -10
View File
@@ -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)
}
}
+19
View File
@@ -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)
+80
View File
@@ -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)
}
+217
View File
@@ -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
}
+213
View File
@@ -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
}
+53
View File
@@ -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)
}
+857
View File
@@ -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()
}
+4
View File
@@ -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 内核配置输入
+34 -6
View File
@@ -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,
},
},
}
}
+22
View File
@@ -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) {
+93
View File
@@ -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)
})
}
+575
View File
@@ -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
}
}
+78 -35
View File
@@ -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())
}
}
+12 -3
View File
@@ -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 {
+3 -2
View File
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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
View File
@@ -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
View File
@@ -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
View File
@@ -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)")
}
}
+66
View File
@@ -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
}
+11 -10
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
7eabda3c0c6240dd458970bfdd19c33c
+198 -38
View File
@@ -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
View File
@@ -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>
</>
)}
+1 -1
View File
@@ -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' },
+30 -4
View File
@@ -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 PlaywrightSelenium
<code>HTTP + JSON</code> CDP HTTP PlaywrightSelenium
</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}: &lt;your-api-key&gt;</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 | codekeywordstagsgroupId |
| 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}: &lt;your-api-key&gt;</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}: &lt;your-api-key&gt;</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>
+4
View File
@@ -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
View File
@@ -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>;
+4
View File
@@ -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);
}
+8
View File
@@ -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
View File
@@ -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',
+56 -26
View File
@@ -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 已退出")
}
}
+2 -2
View File
@@ -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"
+143
View File
@@ -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
}
-2
View File
@@ -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"