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