publish: 1.1.0 snapshot (ff46de5)

channel: master
version: 1.1.0
source-ref: master
published-at-utc: 2026-03-20T14:14:07Z
This commit is contained in:
Ant Browser Release Bot
2026-03-20 22:14:08 +08:00
parent 6428e1f661
commit fc5a6fadbe
127 changed files with 9852 additions and 2434 deletions
+68
View File
@@ -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
+22 -4
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.1.0 - 2026-03-19
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题。
- 新增 SOCKS 代理测试支持:SOCKS 代理能力已进入测试阶段,后续会继续验证稳定性与兼容性。
- 实验性支持接口触发浏览器:支持通过接口启动浏览器实例,便于后续接入自动化流程。
+34 -6
View File
@@ -1,9 +1,9 @@
# Ant Browser
> 面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。
> 面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具Windows / Linux
[![Release](https://img.shields.io/github/v/release/black-ant/Ant-Browser?sort=semver)](https://github.com/black-ant/Ant-Browser/releases)
[![Platform](https://img.shields.io/badge/Platform-Windows%2010%2F11-blue)](https://github.com/black-ant/Ant-Browser/releases)
[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-blue)](https://github.com/black-ant/Ant-Browser/releases)
[![Issues](https://img.shields.io/github/issues/black-ant/Ant-Browser)](https://github.com/black-ant/Ant-Browser/issues)
## 推荐内核项目
@@ -16,11 +16,13 @@ Ant Browser 当前推荐配套使用的浏览器内核,来源于开源项目 [
这个项目为 Ant Browser 的内核准备提供了直接可用的基础来源,这里先对原项目做明确推荐与致谢。
Ant Browser 的目标很明确:在一台 Windows 设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。
Ant Browser 的目标很明确:在一台桌面设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。
## 目录
- [项目简介](#项目简介)
- [近期更新](#近期更新)
- [更新日志](CHANGELOG.md)
- [核心特性](#核心特性)
- [界面预览](#界面预览)
- [快速开始](#快速开始)
@@ -47,6 +49,16 @@ Ant Browser 适合以下场景:
- 统一管理浏览器内核、标签、关键字和快捷打开码
- 在本地保存配置和运行数据,便于自主控制
## 近期更新
### 1.1.0 · 2026-03-19
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题
- 新增 SOCKS 代理测试支持:SOCKS 代理能力已进入测试阶段,后续会继续验证稳定性与兼容性
- 实验性支持接口触发浏览器:支持通过接口启动浏览器实例,便于后续接入自动化流程
完整历史版本记录见 [CHANGELOG.md](CHANGELOG.md)。
## 源码分支说明
- `master`:面向开发者的干净基线分支,不提交 `data/app.db`、实例目录或其他用户数据。首次启动时会自动初始化空数据库。
@@ -111,7 +123,9 @@ Ant Browser 适合以下场景:
### 环境要求
- 操作系统:Windows 10 / Windows 1164 位)
- 操作系统:
- Windows 10 / 1164 位)
- Linuxamd64 / arm64
- 建议内存:8 GB 及以上
- 建议磁盘空间:2 GB 以上
@@ -120,13 +134,27 @@ Ant Browser 适合以下场景:
1. 前往 Releases 页面下载最新版本:<https://github.com/black-ant/Ant-Browser/releases>
2. 安装版直接运行 `AntBrowser-Setup-*.exe`
3. 便携版解压后运行 `ant-chrome.exe`
4. Linux 包下载后可直接安装 `ant-browser_<version>_<arch>.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-<arch>/xray``bin/linux-<arch>/sing-box`
5. 运行时文件采用“仓库固定 + 哈希校验”,校验清单在 `publish/runtime-manifest.json`,固定来源清单在 `publish/runtime-sources.json`
6. 如需刷新 Linux 运行时,执行 `python3 tools/runtime/sync-runtime.py`(会按固定来源下载、校验归档并更新 manifest)。
### 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)。
### 准备浏览器内核
+93 -18
View File
@@ -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{"默认"},
+3 -9
View File
@@ -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
}
+1 -5
View File
@@ -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)
+27
View File
@@ -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")
}
}
+47
View File
@@ -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()
+244 -28
View File
@@ -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)
}
+38 -6
View File
@@ -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"),
+6 -1
View File
@@ -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: "与系统/架构不兼容",
},
}
+179
View File
@@ -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("等待条件成立超时")
}
+6
View File
@@ -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
}
+7 -4
View File
@@ -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 {
+7 -16
View File
@@ -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 根目录绝对路径。
+51
View File
@@ -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),
)
}
+2 -2
View File
@@ -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 {
+9 -17
View File
@@ -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{
+286
View File
@@ -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()
}
+149
View File
@@ -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 Supportgot=%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)
}
}
+1 -1
View File
@@ -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))
+2 -2
View File
@@ -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)
}
+7
View File
@@ -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
}
@@ -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)
}
}
+40 -19
View File
@@ -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
}
+41
View File
@@ -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
}
+131
View File
@@ -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)
}
}
+2 -35
View File
@@ -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(), ", ")))
}
}
@@ -0,0 +1,10 @@
//go:build !windows
package browser
import "fmt"
// readSystemProxy 非 Windows 平台不支持从注册表读取系统代理,直接返回未启用。
func readSystemProxy() (string, error) {
return "", fmt.Errorf("当前平台不支持系统代理注册表读取")
}
@@ -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
}
+2 -17
View File
@@ -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
}
+90 -60
View File
@@ -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
+25 -6
View File
@@ -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,
)
+208
View File
@@ -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, ""
}
@@ -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)
}
}
+26 -33
View File
@@ -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)
}
+72 -30
View File
@@ -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"` // 最大内存限制(MB0 表示禁用
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)
}
+4 -4
View File
@@ -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)
}
}
+10
View File
@@ -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,
+69
View File
@@ -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
}
+43
View File
@@ -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())
}
}
+382
View File
@@ -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
}
+347 -60
View File
@@ -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,
@@ -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)
}
}
+160 -51
View File
@@ -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)
}
+51
View File
@@ -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")
}
}
+55 -26
View File
@@ -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 ""
}
@@ -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 {
+12 -1
View File
@@ -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()
+15
View File
@@ -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() {}
+3 -7
View File
@@ -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
+6 -6
View File
@@ -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" {
+23
View File
@@ -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)
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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()
@@ -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,实际 %dbody=%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())
}
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%s", w.Code, w.Body.String())
}
if len(starter.started) != 0 {
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
}
}
+68 -6
View File
@@ -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-<version>.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 <repo>:/workspace -w /workspace ant-browser-linux-builder:local ^
bash -c "bash publish/linux/publish-linux.sh --arch <Docker当前架构>"
```
要求:Docker Desktop 已安装并启动,且 Linux 容器引擎可用。
Linux 产物输出目录:
```text
publish\output\
```
常用环境变量:
```text
NO_PAUSE=1 -> 运行结束不 pause(适合 CI 或脚本调用)
CI=1 -> 同样不 pause
```
Windows 产物:
```text
publish\output\AntBrowser-Setup-<version>.exe
@@ -74,3 +134,5 @@ publish\output\AntBrowser-Setup-<version>.exe
## 备注
- `generate-bindings.bat` 是辅助脚本,通常由 `build.bat` 调用。
- `generate-bindings.bat``build.bat``dev.bat` 都假定当前分支是完整源码仓库。
- 如果这些脚本报告缺少 `go.mod``main.go``wails.json`,应先恢复源码入口,而不是继续复用旧二进制。
+13 -139
View File
@@ -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%
+159
View File
@@ -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
}
+59 -85
View File
@@ -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
+7
View File
@@ -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"
+14 -240
View File
@@ -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 优先级 1MAKENSIS_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 优先级 2NSIS_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 优先级 3NSIS_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 ✗ 未找到 NSISmakensis.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 BOMNSIS 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%
+635
View File
@@ -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 "未找到 NSISmakensis.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
}
+17
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -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
+231 -426
View File
@@ -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",
+7 -5
View File
@@ -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",
-1
View File
@@ -1 +0,0 @@
0813f8076d07158b8ddcd1f7e61caed1
+295
View File
@@ -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)
}
}
+195 -65
View File
@@ -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)
+140
View File
@@ -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()
+104 -55
View File
@@ -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<TModule extends Record<string, ComponentType<any>>>(
loader: () => Promise<TModule>,
exportName: keyof TModule,
) {
return lazy(async () => {
const module = await loader()
return {
default: module[exportName] as ComponentType<any>,
}
})
}
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() {
</p>
) : (
<p className="text-sm text-[var(--color-text-secondary)] text-center mb-6">
退<br />
退
<br />
{supportsTray ? '如果您需要保持服务运行,请选择「最小化到托盘」。' : 'Linux 当前不提供托盘最小化,关闭窗口将直接退出应用。'}
</p>
)}
@@ -152,8 +190,8 @@ function CloseConfirmModal() {
</>
) : (
<>
<Button variant="secondary" className="flex-1" onClick={handleMinimize}>
<Button variant="secondary" className="flex-1" onClick={supportsTray ? handleMinimize : () => setOpen(false)}>
{supportsTray ? '最小化到托盘' : '取消'}
</Button>
<Button variant="danger" className="flex-1" onClick={handleQuit}>
退
@@ -169,6 +207,11 @@ function CloseConfirmModal() {
function App() {
useWailsNotifications()
const [quickLaunchOpen, setQuickLaunchOpen] = useState(false)
const routeFallback = (
<div className="flex min-h-[240px] items-center justify-center py-10">
<Loading text="页面加载中..." />
</div>
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -189,30 +232,36 @@ function App() {
<ThemeProvider>
<Router>
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/charts" element={<ChartsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/keygen" element={<AdminKeygenPage />} />
<Route path="/browser/list" element={<BrowserListPage />} />
<Route path="/browser/detail/:id" element={<BrowserDetailPage />} />
<Route path="/browser/edit/:id" element={<BrowserEditPage />} />
<Route path="/browser/copy/:id" element={<BrowserCopyPage />} />
<Route path="/browser/monitor" element={<Navigate to="/browser/list" replace />} />
<Route path="/browser/logs" element={<BrowserLogsPage />} />
<Route path="/browser/proxy-pool" element={<ProxyPoolPage />} />
<Route path="/browser/cores" element={<CoreManagementPage />} />
<Route path="/browser/bookmarks" element={<BookmarkSettingsPage />} />
<Route path="/browser/automation" element={<AutomationPage />} />
<Route path="/browser/launch-api" element={<LaunchApiDocsPage />} />
<Route path="/browser/tags" element={<TagManagementPage />} />
<Route path="/system/tutorial" element={<UsageTutorialPage />} />
</Routes>
<Suspense fallback={routeFallback}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/charts" element={<ChartsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/keygen" element={<AdminKeygenPage />} />
<Route path="/browser/list" element={<BrowserListPage />} />
<Route path="/browser/detail/:id" element={<BrowserDetailPage />} />
<Route path="/browser/edit/:id" element={<BrowserEditPage />} />
<Route path="/browser/copy/:id" element={<BrowserCopyPage />} />
<Route path="/browser/monitor" element={<Navigate to="/browser/list" replace />} />
<Route path="/browser/logs" element={<BrowserLogsPage />} />
<Route path="/browser/proxy-pool" element={<ProxyPoolPage />} />
<Route path="/browser/cores" element={<CoreManagementPage />} />
<Route path="/browser/bookmarks" element={<BookmarkSettingsPage />} />
<Route path="/browser/automation" element={<AutomationPage />} />
<Route path="/browser/launch-api" element={<LaunchApiDocsPage />} />
<Route path="/browser/tags" element={<TagManagementPage />} />
<Route path="/system/tutorial" element={<UsageTutorialPage />} />
</Routes>
</Suspense>
</Layout>
<ToastContainer />
<CloseConfirmModal />
<QuickLaunchModal open={quickLaunchOpen} onClose={() => setQuickLaunchOpen(false)} />
<Suspense fallback={null}>
{quickLaunchOpen ? (
<QuickLaunchModal open={quickLaunchOpen} onClose={() => setQuickLaunchOpen(false)} />
) : null}
</Suspense>
</Router>
</ThemeProvider>
)
+4 -5
View File
@@ -59,11 +59,10 @@ export interface ProfilePageLocalConfig {
export const profilePageConfig: ProfilePageLocalConfig = {
remoteAuthor: {
// 当前先使用一个测试用不可达地址,验证超时/失败后回退默认资料的逻辑
// 后续你补真实地址时,直接替换这里即可。
// 留空时直接使用本地默认资料;需要远程作者页时再替换为真实地址
// https://static.antblack.de/profile/author.json
// https://raw.githubusercontent.com/<user>/<repo>/main/author.json
authorURL: 'http://10.255.255.1/profile-author.json',
authorURL: '',
timeoutMs: 1000,
},
defaultAuthor: {
@@ -95,7 +94,7 @@ export const profilePageConfig: ProfilePageLocalConfig = {
{
name: '公众号',
description: '微信搜索后即可关注',
detail: 'xxx',
detail: '整理中',
icon: 'message-square',
},
],
@@ -103,7 +102,7 @@ export const profilePageConfig: ProfilePageLocalConfig = {
project: {
name: projectConfig.name,
introBadge: projectConfig.name,
introText: '是一个面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。',
introText: '是一个面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具。',
techStack: ['Wails', 'React', 'TypeScript'],
description: '项目当前聚焦浏览器实例隔离、代理池配置、浏览器内核管理、标签检索和快捷启动等核心能力,适合跨境电商、社媒运营、本地测试以及需要统一管理浏览器环境的团队场景。',
actions: [
+3 -4
View File
@@ -7,9 +7,8 @@
// 项目基础信息
export const projectConfig = {
name: 'Ant Browser',
shortName: '',
description: '基于 Wails + React 的桌面应用脚手架',
version: '1.0.0',
shortName: 'Ant',
description: '面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具',
primaryColor: 'primary',
}
@@ -36,7 +35,7 @@ export const navigationConfig: NavSection[] = [
title: '指纹浏览器',
items: [
{ name: '实例列表', path: '/browser/list', icon: 'Monitor' },
{ name: '自动化(未完成)', path: '/browser/automation', icon: 'Bot' },
{ name: '自动化接口', path: '/browser/automation', icon: 'Bot' },
{ name: '内核管理', path: '/browser/cores', icon: 'Cpu' },
{ name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' },
{ name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' },
+1 -6
View File
@@ -1,13 +1,8 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
;(window as Window & { __ANT_APP_BOOTED__?: boolean }).__ANT_APP_BOOTED__ = true
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
+8
View File
@@ -628,6 +628,8 @@ export interface LaunchServerInfo {
port: number
preferredPort: number
baseUrl: string
cdpUrl: string
activeDebugPort: number
ready: boolean
}
@@ -638,12 +640,16 @@ function normalizeLaunchServerInfo(payload: any): LaunchServerInfo {
const fallbackPort = preferredPort > 0 ? preferredPort : 19876
const effectivePort = port > 0 ? port : fallbackPort
const baseUrl = String(payload?.baseUrl || (effectivePort > 0 ? `http://${host}:${effectivePort}` : ''))
const cdpUrl = String(payload?.cdpUrl || baseUrl)
const activeDebugPort = Number(payload?.activeDebugPort) || 0
return {
host,
port: effectivePort,
preferredPort,
baseUrl,
cdpUrl,
activeDebugPort,
ready: !!payload?.ready && port > 0,
}
}
@@ -664,6 +670,8 @@ export async function fetchLaunchServerInfo(): Promise<LaunchServerInfo> {
port: 19876,
preferredPort: 19876,
baseUrl: 'http://127.0.0.1:19876',
cdpUrl: 'http://127.0.0.1:19876',
activeDebugPort: 0,
ready: false,
}
}
@@ -21,7 +21,8 @@ const sampleResponse = `{
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"pid": 12345,
"debugPort": 9222
"debugPort": 9222,
"cdpUrl": "http://127.0.0.1:19876"
}`
function buildSampleLogsRequest(baseUrl: string): string {
@@ -73,11 +74,11 @@ export function AutomationPage() {
<div className="flex items-start justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-[var(--color-accent-muted)] text-[var(--color-accent)] text-xs font-medium mb-3">
<Bot className="w-3.5 h-3.5" />
<Bot className="w-3.5 h-3.5" />
</div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-secondary)] mt-2">
<code> Code + </code> PlaywrightSelenium
<code>HTTP + JSON</code> CDP HTTP PlaywrightSelenium
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-2">
Launch <code>{launchBaseUrl}</code>
@@ -96,7 +97,8 @@ export function AutomationPage() {
{sampleRequest}
</pre>
<div className="mt-3 text-sm text-[var(--color-text-secondary)] space-y-1">
<p><code>code</code>: </p>
<p><code>code</code> / <code>key</code>: <code>code</code> LaunchCode <code>key</code> </p>
<p><code>matchMode</code>: <code>unique</code> / <code>first</code> / <code>all</code> <code>key</code> <code>first</code></p>
<p><code>launchArgs</code>: Chrome </p>
<p><code>startUrls</code>: </p>
<p><code>skipDefaultStartUrls</code>: <code>true</code> </p>
@@ -105,7 +107,7 @@ export function AutomationPage() {
<Card
title="2) 响应结构"
subtitle="成功返回 pid + debugPort,可直接接 CDP"
subtitle="成功返回 pid + cdpUrl;外部统一使用固定端口接 CDP"
actions={<CopyCodeButton text={sampleResponse} />}
>
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
@@ -130,7 +132,7 @@ export function AutomationPage() {
<div className="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
<Rocket className="w-4 h-4 mt-0.5 text-[var(--color-accent)]" />
<p>
</p>
</div>
</Card>
@@ -4,6 +4,7 @@ import { Copy, Globe, Play, RefreshCw, RotateCcw, Square } from 'lucide-react'
import { Badge, Button, Card, Input, Table, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProfile, BrowserTab } from '../types'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
import {
fetchBrowserProfiles,
fetchBrowserTabs,
@@ -39,11 +40,13 @@ export function BrowserDetailPage() {
const [tabs, setTabs] = useState<BrowserTab[]>([])
const [targetUrl, setTargetUrl] = useState('https://example.com')
const [activeTab, setActiveTab] = useState<TabKey>('overview')
const [pendingAction, setPendingAction] = useState<'starting' | 'stopping' | 'restarting' | null>(null)
const loadProfile = async () => {
const list = await fetchBrowserProfiles()
const current = list.find(item => item.profileId === id) || null
setProfile(current)
return current
}
const loadTabs = async () => {
@@ -52,8 +55,37 @@ export function BrowserDetailPage() {
setTabs(list)
}
useEffect(() => { loadProfile() }, [id])
useEffect(() => { loadTabs() }, [id])
useEffect(() => { void loadProfile() }, [id])
useEffect(() => { void loadTabs() }, [id])
useEffect(() => {
if (!id) return
const handleRuntimeChange = (payload: any) => {
const profileId = typeof payload === 'string' ? payload : payload?.profileId
if (profileId !== id) return
setPendingAction(null)
void loadProfile()
if (typeof payload === 'string' || payload?.error) {
setTabs([])
return
}
void loadTabs()
}
const offStarted = EventsOn('browser:instance:started', handleRuntimeChange)
const offStopped = EventsOn('browser:instance:stopped', handleRuntimeChange)
const offCrashed = EventsOn('browser:instance:crashed', handleRuntimeChange)
return () => {
offStarted?.()
offStopped?.()
offCrashed?.()
}
}, [id])
if (!profile) {
return (
@@ -69,35 +101,50 @@ export function BrowserDetailPage() {
}
const handleStart = async () => {
setPendingAction('starting')
try {
await startBrowserInstance(profile.profileId)
const startedProfile = await startBrowserInstance(profile.profileId)
if (startedProfile) {
setProfile(startedProfile)
}
toast.success('实例已启动')
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例启动失败'))
} finally {
loadProfile()
await loadProfile()
setPendingAction(null)
}
}
const handleStop = async () => {
setPendingAction('stopping')
try {
await stopBrowserInstance(profile.profileId)
const stoppedProfile = await stopBrowserInstance(profile.profileId)
if (stoppedProfile) {
setProfile(stoppedProfile)
}
toast.success('实例已停止')
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例停止失败'))
} finally {
loadProfile()
await loadProfile()
setPendingAction(null)
}
}
const handleRestart = async () => {
setPendingAction('restarting')
try {
await restartBrowserInstance(profile.profileId)
const restartedProfile = await restartBrowserInstance(profile.profileId)
if (restartedProfile) {
setProfile(restartedProfile)
}
toast.success('实例已重启')
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例重启失败'))
} finally {
loadProfile()
await loadProfile()
setPendingAction(null)
}
}
@@ -113,6 +160,11 @@ export function BrowserDetailPage() {
},
]
const isStarting = pendingAction === 'starting'
const isStopping = pendingAction === 'stopping'
const isRestarting = pendingAction === 'restarting'
const isBusy = pendingAction !== null
return (
<div className="space-y-5 animate-fade-in">
{/* 页头 */}
@@ -240,17 +292,20 @@ export function BrowserDetailPage() {
<Card title="快捷操作" subtitle="快速控制实例">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={handleStart}>
<Play className="w-4 h-4" />
</Button>
<Button size="sm" variant="secondary" onClick={handleStop}>
<Square className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" onClick={handleRestart}>
<RotateCcw className="w-4 h-4" />
{profile.running ? (
<Button size="sm" variant="secondary" onClick={handleStop} loading={isStopping} disabled={isBusy && !isStopping}>
{!isStopping && <Square className="w-4 h-4" />}
{isStopping ? '停止中' : '停止'}
</Button>
) : (
<Button size="sm" onClick={handleStart} loading={isStarting} disabled={isBusy && !isStarting}>
{!isStarting && <Play className="w-4 h-4" />}
{isStarting ? '启动中' : '启动'}
</Button>
)}
<Button size="sm" variant="ghost" onClick={handleRestart} loading={isRestarting} disabled={isBusy && !isRestarting}>
{!isRestarting && <RotateCcw className="w-4 h-4" />}
{isRestarting ? '重启中' : '重启'}
</Button>
</div>
</Card>
@@ -53,10 +53,13 @@ export function BrowserEditPage() {
const list = await fetchBrowserProfiles()
const current = list.find(item => item.profileId === id)
if (!current) return
const normalizedCoreId = !current.coreId || current.coreId.toLowerCase() === 'default'
? ''
: current.coreId
setFormData({
profileName: current.profileName,
userDataDir: current.userDataDir,
coreId: current.coreId,
coreId: normalizedCoreId,
fingerprintArgs: current.fingerprintArgs,
proxyId: current.proxyId,
proxyConfig: current.proxyConfig,
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Activity, CheckCircle, ChevronDown, ChevronRight, ChevronUp, Copy, Edit2, FileText, Key, Pencil, Play, Plus, RefreshCw, RotateCcw, Settings, Sliders, Square, Star, Trash2, XCircle, Gift, LayoutGrid, List } from 'lucide-react'
import { Activity, CheckCircle, ChevronDown, ChevronRight, ChevronUp, Copy, Edit2, ExternalLink, FileText, Key, Pencil, Play, Plus, RefreshCw, RotateCcw, Settings, Sliders, Square, Star, Trash2, XCircle, Gift, LayoutGrid, List } from 'lucide-react'
import { Badge, Button, Card, FormItem, Input, Modal, StatCard, Table, Textarea, toast } from '../../../shared/components'
import { fetchDashboardStats, redeemCDKey, redeemGithubStar, reloadConfig } from '../../dashboard/api'
import type { TableColumn } from '../../../shared/components/Table'
@@ -8,7 +8,7 @@ import type { BrowserCore, BrowserCoreInput, BrowserProfile, BrowserProxy, Brows
import { InstanceFilterBar, EMPTY_FILTERS } from '../components/InstanceFilterBar'
import type { InstanceFilters } from '../components/InstanceFilterBar'
import { KeywordsModal } from '../components/KeywordsModal'
import { EventsOn, EventsOff, BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
import { EventsOn, BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
import { PROJECT_GITHUB_URL } from '../../../config/links'
import { resolveActionErrorMessage } from '../utils/actionErrors'
import {
@@ -73,7 +73,18 @@ function BatchToolbar({
)
}
const statusVariant = (running: boolean) => (running ? 'success' : 'warning')
const resolveProfileStatus = (running: boolean, starting: boolean, stopping: boolean) => {
if (starting) {
return { variant: 'info' as const, label: '启动中' }
}
if (stopping) {
return { variant: 'default' as const, label: '停止中' }
}
if (running) {
return { variant: 'success' as const, label: '运行中' }
}
return { variant: 'warning' as const, label: '已停止' }
}
const formatTime = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
@@ -238,6 +249,10 @@ export function BrowserListPage() {
const [proxyErrorMsg, setProxyErrorMsg] = useState('')
const [opError, setOpError] = useState('')
const [pendingStartId, setPendingStartId] = useState<string | null>(null)
const [startingIds, setStartingIds] = useState<Set<string>>(new Set())
const [stoppingIds, setStoppingIds] = useState<Set<string>>(new Set())
const profilesRef = useRef<BrowserProfile[]>([])
const silentRefreshInFlightRef = useRef(false)
// 关键字弹窗
const [kwModal, setKwModal] = useState<{ open: boolean; profile: BrowserProfile | null }>({ open: false, profile: null })
@@ -277,15 +292,79 @@ export function BrowserListPage() {
const [expandModalOpen, setExpandModalOpen] = useState(false)
const [cdKey, setCdKey] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [maxProfileLimit, setMaxProfileLimit] = useState(3)
const [promoModalMsg, setPromoModalMsg] = useState('')
const [maxProfileLimit, setMaxProfileLimit] = useState(20)
const loadProfiles = async () => {
setLoading(true)
const updatePendingIds = (
setter: React.Dispatch<React.SetStateAction<Set<string>>>,
profileId: string,
active: boolean
) => {
setter(prev => {
const next = new Set(prev)
if (active) {
next.add(profileId)
} else {
next.delete(profileId)
}
return next
})
}
const replaceProfilesState = (items: BrowserProfile[]) => {
profilesRef.current = items
setProfiles(items)
}
const updateProfilesState = (updater: (items: BrowserProfile[]) => BrowserProfile[]) => {
const next = updater(profilesRef.current)
profilesRef.current = next
setProfiles(next)
}
const mergeProfileState = (profile: BrowserProfile | null | undefined) => {
if (!profile) return
updateProfilesState(prev => prev.map(item => (
item.profileId === profile.profileId ? { ...item, ...profile } : item
)))
}
const syncProfiles = (items: BrowserProfile[], syncRuntimeState: boolean) => {
if (syncRuntimeState) {
const previousById = new Map(profilesRef.current.map(item => [item.profileId, item]))
const newlyRunning = items.find(item => item.running && !previousById.get(item.profileId)?.running)
if (newlyRunning) {
updatePendingIds(setStartingIds, newlyRunning.profileId, false)
updatePendingIds(setStoppingIds, newlyRunning.profileId, false)
}
items.forEach(item => {
if (!item.running && previousById.get(item.profileId)?.running) {
updatePendingIds(setStartingIds, item.profileId, false)
updatePendingIds(setStoppingIds, item.profileId, false)
}
})
}
replaceProfilesState(items)
}
const loadProfiles = async ({ silent = false, syncRuntimeState = false }: { silent?: boolean; syncRuntimeState?: boolean } = {}) => {
if (silent && silentRefreshInFlightRef.current) {
return profilesRef.current
}
if (!silent) {
setLoading(true)
} else {
silentRefreshInFlightRef.current = true
}
try {
setProfiles(await fetchBrowserProfiles())
const items = await fetchBrowserProfiles()
syncProfiles(items, syncRuntimeState)
return items
} finally {
setLoading(false)
if (silent) {
silentRefreshInFlightRef.current = false
} else {
setLoading(false)
}
}
}
@@ -308,36 +387,98 @@ export function BrowserListPage() {
try {
await reloadConfig()
const stats = await fetchDashboardStats()
setMaxProfileLimit(stats.maxProfileLimit || 3)
setMaxProfileLimit(stats.maxProfileLimit || 20)
} catch {
// ignore
}
}
useEffect(() => {
loadProfiles()
void loadProfiles()
loadGroups()
loadQuota()
fetchBrowserProxies().then(setProxies)
fetchBrowserCores().then(setCores)
// 监听浏览器实例生命周期事件,自动更新状态
const events = ['browser:instance:started', 'browser:instance:stopped', 'browser:instance:crashed']
events.forEach(e => EventsOn(e, () => loadProfiles()))
const offStarted = EventsOn('browser:instance:started', (payload: any) => {
const profileId = typeof payload === 'string' ? payload : payload?.profileId
if (profileId) {
updatePendingIds(setStartingIds, profileId, false)
updatePendingIds(setStoppingIds, profileId, false)
}
void loadProfiles({ silent: true, syncRuntimeState: true })
})
const offStopped = EventsOn('browser:instance:stopped', (payload: any) => {
const profileId = typeof payload === 'string' ? payload : payload?.profileId
if (profileId) {
updatePendingIds(setStartingIds, profileId, false)
updatePendingIds(setStoppingIds, profileId, false)
}
void loadProfiles({ silent: true, syncRuntimeState: true })
})
const offCrashed = EventsOn('browser:instance:crashed', (payload: any) => {
const profileId = typeof payload === 'string' ? payload : payload?.profileId
if (profileId) {
updatePendingIds(setStartingIds, profileId, false)
updatePendingIds(setStoppingIds, profileId, false)
}
void loadProfiles({ silent: true, syncRuntimeState: true })
})
const timer = window.setInterval(() => {
if (document.visibilityState !== 'visible') return
void loadProfiles({ silent: true, syncRuntimeState: true })
}, 2000)
return () => {
events.forEach(e => EventsOff(e))
window.clearInterval(timer)
offStarted?.()
offStopped?.()
offCrashed?.()
}
}, [])
const runningCount = useMemo(() => profiles.filter(p => p.running).length, [profiles])
const allTags = useMemo(() => {
const set = new Set<string>()
profiles.forEach(p => p.tags?.forEach(t => set.add(t)))
return Array.from(set).sort()
}, [profiles])
const defaultCore = useMemo(() => {
return cores.find(core => core.isDefault) || cores[0] || null
}, [cores])
const resolveProfileCore = (profile: BrowserProfile) => {
const coreId = (profile.coreId || '').trim()
if (coreId && !/^default$/i.test(coreId)) {
return cores.find(core => core.coreId === coreId) || null
}
return defaultCore
}
const getProfileCoreLabel = (profile: BrowserProfile) => {
const resolvedCore = resolveProfileCore(profile)
if (resolvedCore) {
return resolvedCore.coreName
}
const coreId = (profile.coreId || '').trim()
if (!coreId || /^default$/i.test(coreId)) {
return '使用默认内核'
}
return coreId
}
const isProfileStarting = (profileId: string) => startingIds.has(profileId)
const isProfileStopping = (profileId: string) => stoppingIds.has(profileId)
const isProfileBusy = (profileId: string) => isProfileStarting(profileId) || isProfileStopping(profileId)
const getProfileStatus = (profile: BrowserProfile) => (
resolveProfileStatus(profile.running, isProfileStarting(profile.profileId), isProfileStopping(profile.profileId))
)
const filteredProfiles = useMemo(() => {
const naturalCompare = (a: string, b: string): number => {
const re = /(\d+)|(\D+)/g
@@ -367,7 +508,10 @@ export function BrowserListPage() {
if (filters.status === 'stopped' && p.running) return false
if (filters.proxyId === '__none__' && (p.proxyId || p.proxyConfig)) return false
if (filters.proxyId && filters.proxyId !== '__none__' && p.proxyId !== filters.proxyId) return false
if (filters.coreId && p.coreId !== filters.coreId) return false
if (filters.coreId) {
const effectiveCore = resolveProfileCore(p)
if (!effectiveCore || effectiveCore.coreId !== filters.coreId) return false
}
if (filters.tags.size > 0 && !p.tags?.some(t => filters.tags.has(t))) return false
if (filters.kwSearch) {
const q = filters.kwSearch.toLowerCase()
@@ -376,47 +520,61 @@ export function BrowserListPage() {
}
return true
}).sort((a, b) => naturalCompare(a.profileName, b.profileName))
}, [profiles, filters])
}, [profiles, filters, defaultCore, cores])
const handleStart = async (profileId: string) => {
const profile = profiles.find(p => p.profileId === profileId)
if (profile) {
const result = await validateProxyConfig(profile.proxyConfig || '', profile.proxyId || '')
if (!result.supported) {
setProxyErrorMsg(result.errorMsg)
setPendingStartId(profileId)
setProxyErrorModal(true)
return
}
}
updatePendingIds(setStartingIds, profileId, true)
try {
await startBrowserInstance(profileId)
toast.success('实例已启动')
loadProfiles()
if (profile) {
const result = await validateProxyConfig(profile.proxyConfig || '', profile.proxyId || '')
if (!result.supported) {
setProxyErrorMsg(result.errorMsg)
setPendingStartId(profileId)
setProxyErrorModal(true)
return
}
}
const startedProfile = await startBrowserInstance(profileId)
mergeProfileState(startedProfile)
toast.success(`实例已启动${startedProfile?.profileName ? `${startedProfile.profileName}` : ''}`)
await loadProfiles({ silent: true, syncRuntimeState: true })
} catch (error: any) {
setOpError(resolveActionErrorMessage(error, '实例启动失败'))
loadProfiles()
await loadProfiles({ silent: true, syncRuntimeState: true })
} finally {
updatePendingIds(setStartingIds, profileId, false)
}
}
const handleStop = async (profileId: string) => {
updatePendingIds(setStoppingIds, profileId, true)
try {
await stopBrowserInstance(profileId)
const stoppedProfile = await stopBrowserInstance(profileId)
mergeProfileState(stoppedProfile)
toast.success('实例已停止')
loadProfiles()
await loadProfiles({ silent: true, syncRuntimeState: true })
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例停止失败'))
await loadProfiles({ silent: true, syncRuntimeState: true })
} finally {
updatePendingIds(setStoppingIds, profileId, false)
}
}
const handleRestart = async (profileId: string) => {
updatePendingIds(setStoppingIds, profileId, true)
try {
await restartBrowserInstance(profileId)
toast.success('实例已重启')
loadProfiles()
const restartedProfile = await restartBrowserInstance(profileId)
mergeProfileState(restartedProfile)
toast.success(`实例已重启${restartedProfile?.profileName ? `${restartedProfile.profileName}` : ''}`)
await loadProfiles({ silent: true, syncRuntimeState: true })
} catch (error: any) {
setOpError(resolveActionErrorMessage(error, '实例重启失败'))
loadProfiles()
await loadProfiles({ silent: true, syncRuntimeState: true })
} finally {
updatePendingIds(setStoppingIds, profileId, false)
}
}
@@ -454,12 +612,16 @@ export function BrowserListPage() {
for (const id of ids) {
const profile = profiles.find(p => p.profileId === id)
if (!profile || profile.running) continue
updatePendingIds(setStartingIds, id, true)
try {
await startBrowserInstance(id)
const startedProfile = await startBrowserInstance(id)
mergeProfileState(startedProfile)
success++
} catch (error: any) {
failed++
failureMessages.push(`${profile.profileName}${resolveActionErrorMessage(error, '实例启动失败')}`)
} finally {
updatePendingIds(setStartingIds, id, false)
}
}
setBatchLoading(false)
@@ -480,11 +642,15 @@ export function BrowserListPage() {
for (const id of ids) {
const profile = profiles.find(p => p.profileId === id)
if (!profile || !profile.running) continue
updatePendingIds(setStoppingIds, id, true)
try {
await stopBrowserInstance(id)
const stoppedProfile = await stopBrowserInstance(id)
mergeProfileState(stoppedProfile)
success++
} catch {
failed++
} finally {
updatePendingIds(setStoppingIds, id, false)
}
}
setBatchLoading(false)
@@ -608,18 +774,16 @@ export function BrowserListPage() {
setCdKey('')
loadQuota()
} else {
setPromoModalMsg(result.message || '兑换失败')
toast.error(result.message || '兑换失败')
}
}
const handleAcceptPromo = async () => {
setPromoModalMsg('')
BrowserOpenURL(PROJECT_GITHUB_URL)
const handleClaimStarGift = async () => {
setRedeeming(true)
const starRes = await redeemGithubStar()
setRedeeming(false)
if (starRes.success) {
toast.success('感谢您的支持!已为您增加 3 个永久额度!')
toast.success('感谢您的支持!已额外赠送 50 个永久额度!')
setCdKey('')
loadQuota()
} else {
@@ -627,6 +791,11 @@ export function BrowserListPage() {
}
}
const handleOpenGithubStarGift = async () => {
BrowserOpenURL(PROJECT_GITHUB_URL)
await handleClaimStarGift()
}
const columns: TableColumn<BrowserProfile>[] = [
{
key: 'selection',
@@ -672,14 +841,16 @@ export function BrowserListPage() {
key: 'running',
title: '状态',
width: 100,
render: (value) => <Badge variant={statusVariant(value)} dot>{value ? '运行中' : '已停止'}</Badge>,
render: (_, record) => {
const status = getProfileStatus(record)
return <Badge variant={status.variant} dot>{status.label}</Badge>
},
},
{
key: 'coreId',
title: '核心',
render: (value) => {
const core = cores.find(c => c.coreId === value)
return <span className="text-xs">{core ? core.coreName : value || '-'}</span>
render: (_, record) => {
return <span className="text-xs">{getProfileCoreLabel(record)}</span>
},
},
{
@@ -710,20 +881,30 @@ export function BrowserListPage() {
key: 'actions',
title: '操作',
align: 'right',
render: (_, record) => (
<div className="flex justify-end gap-1">
{record.running ? (
<Button size="sm" variant="secondary" onClick={() => handleStop(record.profileId)} title="停止"><Square className="w-3.5 h-3.5" /></Button>
) : (
<Button size="sm" onClick={() => handleStart(record.profileId)} title="启动"><Play className="w-3.5 h-3.5 fill-current" /></Button>
)}
<Button size="sm" variant="ghost" onClick={() => handleRestart(record.profileId)} title="重启"><RotateCcw className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => openKwModal(record)} title="关键字"><Key className="w-3.5 h-3.5" /></Button>
<Link to={`/browser/edit/${record.profileId}`}><Button size="sm" variant="ghost" title="配置"><Settings className="w-3.5 h-3.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => openCopyModal(record)} title="克隆"><Copy className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(record.profileId)} title="删除"><Trash2 className="w-3.5 h-3.5 text-red-500" /></Button>
</div>
),
render: (_, record) => {
const isStarting = isProfileStarting(record.profileId)
const isStopping = isProfileStopping(record.profileId)
const isBusy = isProfileBusy(record.profileId)
return (
<div className="flex justify-end gap-1">
{record.running ? (
<Button size="sm" variant="secondary" onClick={() => handleStop(record.profileId)} title="停止" loading={isStopping}>
{!isStopping && <Square className="w-3.5 h-3.5" />}
</Button>
) : (
<Button size="sm" onClick={() => handleStart(record.profileId)} title="启动" loading={isStarting}>
{!isStarting && <Play className="w-3.5 h-3.5 fill-current" />}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => handleRestart(record.profileId)} title="重启" disabled={isBusy}><RotateCcw className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => openKwModal(record)} title="关键字" disabled={isBusy}><Key className="w-3.5 h-3.5" /></Button>
<Link to={`/browser/edit/${record.profileId}`}><Button size="sm" variant="ghost" title="配置" disabled={isBusy}><Settings className="w-3.5 h-3.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => openCopyModal(record)} title="克隆" disabled={isBusy}><Copy className="w-3.5 h-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(record.profileId)} title="删除" disabled={isBusy}><Trash2 className="w-3.5 h-3.5 text-red-500" /></Button>
</div>
)
},
},
]
@@ -765,7 +946,7 @@ export function BrowserListPage() {
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setHeaderCollapsed(prev => !prev)}>{headerCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}{headerCollapsed ? '展开面板' : '收起面板'}</Button>
<Button variant="secondary" size="sm" onClick={loadProfiles}><RefreshCw className="w-4 h-4" /></Button>
<Button variant="secondary" size="sm" onClick={() => { void loadProfiles() }}><RefreshCw className="w-4 h-4" /></Button>
<Button variant="secondary" size="sm" onClick={handleOpenSettings}><Sliders className="w-4 h-4" /></Button>
<Button variant="secondary" size="sm" onClick={() => { setCdKey(''); setExpandModalOpen(true); loadQuota() }} className="text-[var(--color-primary)] border-[var(--color-primary)] hover:bg-[var(--color-primary)]/10">
<Gift className="w-4 h-4" />
@@ -840,18 +1021,22 @@ export function BrowserListPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[500px] p-4 items-start content-start">
{filteredProfiles.map((record) => {
const isSelected = selectedIds.has(record.profileId)
const core = cores.find(c => c.coreId === record.coreId)
const core = resolveProfileCore(record)
const proxy = proxies.find(p => p.proxyId === record.proxyId)
const status = getProfileStatus(record)
const isStarting = isProfileStarting(record.profileId)
const isStopping = isProfileStopping(record.profileId)
const isBusy = isProfileBusy(record.profileId)
return (
<div
key={record.profileId}
className={`flex flex-col border rounded-xl bg-[var(--color-bg-surface)] p-3 shadow-[0_1px_4px_rgba(0,0,0,0.08)] transition-all duration-200 h-fit max-h-[320px] overflow-y-auto
className={`flex flex-col border rounded-xl bg-[var(--color-bg-surface)] p-3 shadow-[0_1px_4px_rgba(0,0,0,0.08)] transition-all duration-200 h-[320px] overflow-hidden
${isSelected ? 'border-[var(--color-accent)] ring-1 ring-[var(--color-accent)]/20' : 'border-[var(--color-border-default)] hover:border-[var(--color-accent)]'}
`}
>
{/* Header Row: Title, Status, Checkbox, Actions */}
<div className="flex flex-col gap-3 pb-3 border-b border-[var(--color-border-muted)]/50">
<div className="flex flex-col gap-3 pb-3 border-b border-[var(--color-border-muted)]/50 shrink-0">
<div className="flex justify-between items-start gap-2">
<div className="flex items-center gap-2 flex-wrap">
@@ -871,31 +1056,37 @@ export function BrowserListPage() {
)}
</div>
<Badge variant={statusVariant(record.running)} dot dotClassName="w-2 h-2 shrink-0">
{record.running ? '运行中' : '已停止'}
<Badge variant={status.variant} dot dotClassName="w-2 h-2 shrink-0">
{status.label}
</Badge>
</div>
<div className="flex items-center gap-1 flex-wrap">
{record.running ? (
<Button size="sm" variant="secondary" onClick={() => handleStop(record.profileId)} title="停止"><Square className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="secondary" onClick={() => handleStop(record.profileId)} title={isStopping ? '停止中' : '停止'} loading={isStopping}>
{!isStopping && <Square className="w-4 h-4 mr-1.5" />}
{isStopping ? '停止中' : '停止'}
</Button>
) : (
<Button size="sm" onClick={() => handleStart(record.profileId)} title="启动"><Play className="w-4 h-4 fill-current mr-1.5" /></Button>
<Button size="sm" onClick={() => handleStart(record.profileId)} title={isStarting ? '启动中' : '启动'} loading={isStarting}>
{!isStarting && <Play className="w-4 h-4 fill-current mr-1.5" />}
{isStarting ? '启动中' : '启动'}
</Button>
)}
<span className="w-px h-4 bg-[var(--color-border-muted)] mx-1"></span>
<Button size="sm" variant="ghost" onClick={() => handleRestart(record.profileId)} title="重启" className="px-3"><RotateCcw className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => openKwModal(record)} title="关键字管理" className="px-3"><Key className="w-4 h-4 mr-1.5" /></Button>
<Link to={`/browser/edit/${record.profileId}`}><Button size="sm" variant="ghost" title="配置" className="px-3"><Settings className="w-4 h-4 mr-1.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => openCopyModal(record)} title="克隆" className="px-3"><Copy className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(record.profileId)} title="删除" className="px-3 text-red-500 hover:text-red-600 hover:bg-red-50"><Trash2 className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleRestart(record.profileId)} title="重启" className="px-3" disabled={isBusy}><RotateCcw className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => openKwModal(record)} title="关键字管理" className="px-3" disabled={isBusy}><Key className="w-4 h-4 mr-1.5" /></Button>
<Link to={`/browser/edit/${record.profileId}`}><Button size="sm" variant="ghost" title="配置" className="px-3" disabled={isBusy}><Settings className="w-4 h-4 mr-1.5" /></Button></Link>
<Button size="sm" variant="ghost" onClick={() => openCopyModal(record)} title="克隆" className="px-3" disabled={isBusy}><Copy className="w-4 h-4 mr-1.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(record.profileId)} title="删除" className="px-3 text-red-500 hover:text-red-600 hover:bg-red-50" disabled={isBusy}><Trash2 className="w-4 h-4 mr-1.5" /></Button>
</div>
</div>
{/* Body Grid: Key-Value Pairs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 py-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 py-2 shrink-0">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-[var(--color-text-muted)] font-medium"></span>
<span className="text-xs text-[var(--color-text-primary)]">{core?.coreName || record.coreId || '-'}</span>
<span className="text-xs text-[var(--color-text-primary)]">{core?.coreName || getProfileCoreLabel(record)}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-[var(--color-text-muted)] font-medium"></span>
@@ -912,9 +1103,9 @@ export function BrowserListPage() {
</div>
{/* Footer: Keywords */}
<div className="border-t border-[var(--color-border-muted)]/50 pt-2 flex items-start gap-2">
<div className="border-t border-[var(--color-border-muted)]/50 pt-2 flex items-start gap-2 flex-1 min-h-0">
<span className="text-xs font-medium text-[var(--color-text-primary)] shrink-0 pt-0.5"></span>
<div className="flex-1">
<div className="flex-1 min-h-0 overflow-y-auto pr-1">
<KeywordInlineRow keywords={record.keywords || []} />
</div>
</div>
@@ -1016,7 +1207,7 @@ export function BrowserListPage() {
initialKeywords={kwModal.profile.keywords || []}
onClose={closeKwModal}
onSaved={(keywords) => {
setProfiles(prev => prev.map(p =>
updateProfilesState(prev => prev.map(p =>
p.profileId === kwModal.profile!.profileId ? { ...p, keywords } : p
))
}}
@@ -1066,9 +1257,19 @@ export function BrowserListPage() {
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-600 dark:text-blue-400">
<strong></strong> GitHub Star 3
</p>
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-[var(--color-text-primary)]"> GitHub Star 50 </p>
<button
type="button"
className="shrink-0 rounded-full p-2 text-[var(--color-accent)] transition-colors hover:bg-[var(--color-accent)]/10 disabled:opacity-50"
onClick={handleOpenGithubStarGift}
disabled={redeeming}
title="打开 GitHub 并领取赠送"
aria-label="打开 GitHub 并领取赠送"
>
<ExternalLink className="w-4 h-4" />
</button>
</div>
</div>
</div>
</Modal>
@@ -1101,36 +1302,6 @@ export function BrowserListPage() {
</div>
</Modal>
{/* 获赠提示弹窗 */}
<Modal
open={!!promoModalMsg}
onClose={() => setPromoModalMsg('')}
title="获取更多额度"
width="400px"
footer={
<>
<Button variant="secondary" onClick={() => setPromoModalMsg('')}></Button>
<Button onClick={handleAcceptPromo}> GitHub </Button>
</>
}
>
<div className="space-y-4">
<div className="text-[var(--color-error)] font-medium">
{promoModalMsg}
</div>
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
<p className="mb-2"></p>
<p> <strong>Star</strong> <strong>3</strong> </p>
<p className="text-xs text-[var(--color-text-muted)] mt-2">
<button type="button" className="ml-1 break-all text-[var(--color-accent)] underline underline-offset-2" onClick={() => BrowserOpenURL(PROJECT_GITHUB_URL)}>
{PROJECT_GITHUB_URL}
</button>
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-2"></p>
</div>
</div>
</Modal>
{/* 操作错误弹窗 */}
<Modal
open={!!opError}
@@ -14,39 +14,83 @@ const DOC_OVERVIEW = `# 自动化接口文档(重构版)
##
****
** / HTTP ** 4
1. Code
2. CDP
3.
1. Code
2. \`profileId / profileName / keyword / tags / groupId\` 选择实例
3. \`cdpUrl\` 接入 CDP
4.
##
##
- Python / Node.js / PowerShell
- RPA
-
\`HTTP + JSON\` 唤起接口,协议层只约定:
- HTTP
- \`Content-Type: application/json\`
- JSON JSON
- \`127.0.0.1\`
- Python / Node.js / Go / Java / C# / PowerShell / curl
-
##
- \`GET /api/launch/{code}\`
- \`POST /api/launch\`
- \`POST /api/launch\` 中的 \`code\` 字段支持“LaunchCode 优先,关键字兜底”
- \`code / profileId / profileName / key / keyword / keywords / tag / tags / groupId / matchMode\`
- \`selector\` 与顶层选择字段可混用,服务端会做归一化合并
- \`key\` 会优先精确命中 \`keywords[]\`;精确没命中时再参与模糊匹配
- \`unique\` / \`first\` / \`all\`
- \`profileId / profileName / launchCode / pid / debugPort / cdpPort / cdpUrl\`
- 使 LaunchServer CDP\`debugPort\` 仅表示内部实际调试端口
- \`GET /api/launch/logs\`,其中 \`selector\` 为归一化后的结构
##
-
- Ant Browser
- Launch
- Code
- \`key / keyword / tags\` 选择实例,需要先在实例配置里维护这些字段
- \`groupId\`,请保证脚本拿到的是分组 ID,不是分组展示名
##
\`\`\`
-> HTTP -> -> debugPort -> CDP
/
-> POST /api/launch
->
->
-> cdpUrl
-> Playwright / Selenium / CDP
\`\`\`
`
const DOC_QUICKSTART = `# 快速接入(3 分钟)
## Code
\`curl\` 把协议跑通,再换成你自己的语言封装;请求路径、Header、JSON 结构保持不变。
**** Code
##
- 使 Code
- Code
- \`launchCode\`:最稳,适合生产脚本
- \`profileId\`:适合系统内部编排
- \`key / keywords / tags\`:适合“按业务语义找实例”的场景
使
1.
2. \`keywords\`
3. \`tags\`\`groupId\`
/
- \`POST /api/launch\`
- \`code\`
- LaunchCode
- \`matchMode=all\`
##
@@ -55,36 +99,160 @@ curl http://127.0.0.1:19876/api/health
# {"ok":true}
\`\`\`
## Code
## Code
\`\`\`bash
curl http://127.0.0.1:19876/api/launch/A3F9K2
\`\`\`
\`debugPort\`,即可连接 CDP。
\`cdpUrl\`,即可通过统一入口端口接入 CDP。
##
## POST
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"A3F9K2",
"launchArgs":["--window-size=1280,800"],
"startUrls":["https://example.com"],
"skipDefaultStartUrls":true
"selector": {
"code": "A3F9K2"
},
"launchArgs": ["--window-size=1280,800"],
"startUrls": ["https://example.com"],
"skipDefaultStartUrls": true
}'
\`\`\`
/
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code": "buyer-001",
"launchArgs": ["--window-size=1280,800"],
"startUrls": ["https://example.com"],
"skipDefaultStartUrls": true
}'
\`\`\`
##
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"keyword": "checkout",
"tags": ["电商", "北美"],
"groupId": "group-sales-us",
"matchMode": "unique"
},
"skipDefaultStartUrls": true
}'
\`\`\`
`
const DOC_SELECTOR = `# 目标实例选择器
##
\`POST /api/launch\` 不再只接受 \`code\`,而是支持一组 **可组合** 的选择条件。
- \`code\`\`profileId\`
- \`profileName\`
- \`keyword / keywords / tag / tags / groupId\`
- \`POST /api/launch\` 下,\`code\` 既可以传真实 LaunchCode,也可以传账号 / 关键字
- \`code\` 不是有效 LaunchCode,后端会自动按关键字继续匹配
- \`POST /api/launch\`
- \`GET /api/launch/{code}\` 仍然只接受真实 Code
##
\`selector\` 对象:
\`\`\`json
{
"selector": {
"keyword": "checkout",
"tags": ["电商"],
"groupId": "group-sales-us",
"matchMode": "unique"
},
"skipDefaultStartUrls": true
}
\`\`\`
\`\`\`json
{
"keyword": "checkout",
"tags": ["电商"],
"groupId": "group-sales-us"
}
\`\`\`
\`selector\` 也可以同时出现,合并规则如下:
- \`selector\` 内为准:\`code / key / profileId / profileName / groupId / matchMode\`
- \`keyword / keywords\` 会合并、去重,并统一归一化到 \`keywords\`
- \`tag / tags\` 会合并、去重,并统一归一化到 \`tags\`
##
| | | | |
|------|------|----------|------|
| \`code\` | string | 精确匹配 / 关键字兜底 | 会自动 trim 并转成大写;先按 LaunchCode 查;仅在 POST 请求里,查不到时再按关键字匹配 |
| \`profileId\` | string | 精确匹配 | 实例唯一 ID |
| \`profileName\` | string | 精确匹配 | 名称忽略大小写,适合名称唯一的实例 |
| \`key\` | string | 先精确后模糊 | 先在实例 \`keywords[]\` 中做精确相等;若没有精确命中,再按 contains 模糊匹配 |
| \`keyword\` | string | 模糊匹配 | 在实例 \`keywords[]\` 中做 contains;日志里会归一化到 \`keywords[]\` |
| \`keywords\` | string[] | 多词 AND | 每个查询词都必须命中某个关键字 |
| \`tag\` | string | 精确匹配 | 标签完全相等,忽略大小写;日志里会归一化到 \`tags[]\` |
| \`tags\` | string[] | 多标签 AND | 实例必须包含全部标签 |
| \`groupId\` | string | 精确匹配 | 只匹配指定分组 ID |
| \`matchMode\` | string | 行为控制 | \`unique\` / \`first\` / \`all\`\`code / key / keyword / keywords\` 默认 \`first\`,其他默认 \`unique\` |
##
- **AND**
- \`keywords\` 是 AND 关系
- \`tags\` 也是 AND 关系
- \`keyword\` 会归一化到 \`keywords\`
- \`key\` 保持独立语义:优先精确匹配,未命中时再参与模糊匹配
- \`tag\` 会并入 \`tags\`
## matchMode
| | |
|----|------|
| \`unique\` | 显式要求唯一。命中 0 个返回 404,命中多个返回 409 |
| \`first\` | 当命中多个实例时,按后端稳定顺序取第一个;\`key / keyword / keywords\` 默认就是这个行为 |
| \`all\` | 当命中多个实例时,按后端稳定顺序依次启动全部实例,并返回 \`items[]\` |
##
- \`code\`
- \`profileId\`
- \`profileName\`
- \`keyword + tags + groupId\`
- / \`POST.code\`
- \`matchMode=first\`
- \`matchMode=all\`
`
const DOC_API_INDEX = `# 接口总览
| | | | |
|------|------|------|------|
| | GET | \`/api/health\` | 检查服务可用 |
| Code | GET | \`/api/launch/{code}\` | 快速启动实例 |
| | POST | \`/api/launch\` | 自动化脚本标准入口 |
| | GET | \`/api/launch/logs?limit=50\` | 最近调用排障 |
| | GET | \`/api/health\` | 检查 Launch 服务是否可用 |
| Code | GET | \`/api/launch/{code}\` | 兼容旧版、最快捷的唤起方式 |
| | POST | \`/api/launch\` | 支持 code / profileId / 名称 / 关键字 / 标签 / 分组 |
| CDP | GET / WS | \`/json/version\`\`/json/list\`\`/devtools/...\` | 将非 \`/api\` 请求代理到当前活动实例 |
| | GET | \`/api/launch/logs?limit=50\` | 查看最近接口调用与错误 |
`
const DOC_API_HEALTH = `# 接口:健康检查
@@ -116,7 +284,9 @@ GET /api/launch/{code}
##
-
- Code
- \`code\`,不支持复杂选择器
- / \`POST /api/launch\`
-
##
@@ -132,13 +302,16 @@ curl http://127.0.0.1:19876/api/launch/A3F9K2
"ok": true,
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"launchCode": "A3F9K2",
"pid": 12345,
"debugPort": 9222
"debugPort": 9222,
"cdpPort": 19876,
"cdpUrl": "http://127.0.0.1:19876"
}
\`\`\`
`
const DOC_API_LAUNCH_POST = `# 接口:参数化启动(自动化主入口)
const DOC_API_LAUNCH_POST = `# 接口:选择器启动(自动化主入口)
\`\`\`
POST /api/launch
@@ -148,35 +321,227 @@ POST /api/launch
| | | | |
|------|------|------|------|
| \`code\` | string | | 实例 Code |
| \`selector\` | object | | 推荐写法,放复杂选择条件 |
| \`code\` | string | 否 | 兼容旧版 flat 写法;POST 下支持“Code 优先,关键字兜底” |
| \`profileId\` | string | 否 | 顶层选择字段 |
| \`profileName\` | string | 否 | 顶层选择字段 |
| \`key / keyword / keywords\` | string / string[] | 否 | 顶层关键字条件 |
| \`tag / tags\` | string / string[] | 否 | 顶层标签条件 |
| \`groupId\` | string | 否 | 顶层分组条件 |
| \`matchMode\` | string | 否 | \`unique\` / \`first\` / \`all\` |
| \`launchArgs\` | string[] | 否 | 本次附加启动参数 |
| \`startUrls\` | string[] | 否 | 本次启动打开 URL 列表 |
| \`skipDefaultStartUrls\` | boolean | 否 | 跳过系统默认起始页 |
##
> \`selector\` 与顶层选择字段可以混用,不是互斥关系。重名单值字段以 \`selector\` 为准;\`keyword / keywords\`\`tag / tags\` 会合并并去重。至少要提供一个选择字段。
## 1 code
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"A3F9K2",
"launchArgs":["--lang=en-US","--window-size=1366,768"],
"skipDefaultStartUrls":true
}'
\`\`\`
## 1B\`code\` 直接传账号 / 关键字
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"buyer-001",
"launchArgs":["--window-size=1280,800","--lang=en-US"],
"startUrls":["https://example.com"],
"skipDefaultStartUrls":true
}'
\`\`\`
##
- LaunchCode
- \`buyer-001\` 当关键字去匹配实例
-
## 1C\`code\` 传关键字,并把所有命中实例都启动
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"shop",
"matchMode":"all",
"skipDefaultStartUrls":true
}'
\`\`\`
## 2 profileId
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"profileId":"550e8400-e29b-41d4-a716-446655440000"
},
"launchArgs":["--lang=en-US"]
}'
\`\`\`
## 3
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"profileName":"账号 A"
}
}'
\`\`\`
## 4 + +
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"keyword":"checkout",
"tags":["电商","北美"],
"groupId":"group-sales-us",
"matchMode":"unique"
},
"startUrls":["https://example.com/order"],
"skipDefaultStartUrls":true
}'
\`\`\`
## 5 AND
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"keywords":["amazon","buyer","checkout"],
"tags":["电商"],
"matchMode":"unique"
}
}'
\`\`\`
## 6
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"keyword":"shop",
"matchMode":"first"
}
}'
\`\`\`
## 7
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"selector": {
"keyword":"shop",
"matchMode":"all"
}
}'
\`\`\`
##
\`\`\`json
{
"ok": true,
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"launchCode": "A3F9K2",
"pid": 12345,
"debugPort": 9222
"debugPort": 9222,
"cdpPort": 19876,
"cdpUrl": "http://127.0.0.1:19876"
}
\`\`\`
## \`matchMode=all\`
\`\`\`json
{
"ok": true,
"matchMode": "all",
"count": 2,
"activeProfileId": "profile-b",
"activeProfileName": "账号 B",
"cdpPort": 19876,
"cdpUrl": "http://127.0.0.1:19876",
"items": [
{
"profileId": "profile-a",
"profileName": "账号 A",
"launchCode": "A3F9K2",
"pid": 12345,
"debugPort": 9222,
"isActive": false
},
{
"profileId": "profile-b",
"profileName": "账号 B",
"launchCode": "B7Q2W9",
"pid": 12346,
"debugPort": 9333,
"isActive": true
}
]
}
\`\`\`
- \`activeProfileId\` 是当前统一 CDP 入口实际指向的实例
- \`matchMode=all\` 时,会按后端稳定顺序依次启动;最后一个成功启动的实例会成为活动实例
`
const DOC_API_CDP = `# 接口:CDP 统一入口
\`\`\`
GET /json/version
GET /json/list
GET /devtools/...
WS /devtools/...
\`\`\`
##
- LaunchServer \`/api\` 请求代理到当前活动实例的内部调试端口
-
- 使 \`matchMode=all\`,则最后一个启动成功的实例会成为当前活动实例
- \`503\`\`no active browser debug target\`
##
\`\`\`bash
curl http://127.0.0.1:19876/json/version
\`\`\`
\`\`\`bash
curl http://127.0.0.1:19876/json/list
\`\`\`
## 使
- \`cdpUrl\` 交给 Playwright / Selenium / 自研 CDP 客户端
- \`/json/version\`\`/json/list\`,返回内容由目标 Chrome/CDP 直接提供,字段会随 Chrome 版本变化
`
const DOC_API_LOGS = `# 接口:调用记录
@@ -190,6 +555,8 @@ GET /api/launch/logs?limit=50
- 50
- 200
-
- Code /使 selector
- \`selector\` 是归一化结果:例如 \`keyword\` 会显示为 \`keywords[]\`\`tag\` 会显示为 \`tags[]\`
##
@@ -209,11 +576,17 @@ curl http://127.0.0.1:19876/api/launch/logs?limit=20
"path": "/api/launch",
"clientIp": "127.0.0.1",
"code": "A3F9K2",
"selector": {
"keywords": ["checkout"],
"tags": ["电商", "北美"],
"groupId": "group-sales-us",
"matchMode": "unique"
},
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"params": {
"launchArgs": ["--window-size=1280,800"],
"startUrls": ["https://example.com"],
"startUrls": ["https://example.com/order"],
"skipDefaultStartUrls": true
},
"ok": true,
@@ -226,26 +599,115 @@ curl http://127.0.0.1:19876/api/launch/logs?limit=20
\`\`\`
`
const DOC_SCENARIOS = `# 场景示例
## 1
\`code\`\`profileId\`
-
-
-
## 1B /
\`POST /api/launch\`\`code\` 字段。
\`\`\`json
{
"code": "buyer-001",
"skipDefaultStartUrls": true
}
\`\`\`
-
- Code
-
- \`matchMode=all\`
## 2线
\`keyword + tags + groupId\`
\`\`\`json
{
"selector": {
"keyword": "checkout",
"tags": ["电商", "北美"],
"groupId": "group-sales-us",
"matchMode": "unique"
}
}
\`\`\`
## 3
\`keyword + matchMode=all\`
\`\`\`json
{
"selector": {
"keyword": "template",
"matchMode": "all"
}
}
\`\`\`
## 4
\`\`\`json
{
"selector": {
"keywords": ["amazon", "buyer", "checkout"],
"tags": ["电商", "北美"],
"groupId": "group-sales-us",
"matchMode": "unique"
}
}
\`\`\`
## 5 Code
\`launchCode\`,你可以把它缓存下来,下一次直接用 Code 启动。
`
const DOC_ERRORS = `# 错误码与重试策略
| | | |
|--------|------|----------|
| 400 | / | |
| 400 | / / selector / matchMode | |
| 403 | localhost 访 | |
| 404 | Code | Code |
| 404 | GET Code / POST code / selector | codekeywordstagsgroupId |
| 405 | | 使 HTTP |
| 409 | selector | \`matchMode=first\`\`matchMode=all\` |
| 500 | | \`/api/launch/logs\` + 应用日志 |
| 503 | 访 CDP | 访 \`cdpUrl\` |
##
- 3-10
- \`500\` 可短暂重试(指数退避)
- \`400/404\` 不建议盲目重试
- \`400/404/409\` 不建议盲目重试
- selector
`
const DOC_EXAMPLES = `# 自动化示例
const DOC_EXAMPLES = `# 多语言调用示例(同一协议)
## Python CDP
HTTP
## Python CDP
\`\`\`python
import requests
@@ -253,84 +715,171 @@ from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:19876"
def launch_by_code(code: str) -> dict:
r = requests.post(
def launch(selector: dict) -> dict:
res = requests.post(
f"{BASE}/api/launch",
json={
"code": code,
"launchArgs": ["--window-size=1280,800"],
"selector": selector,
"skipDefaultStartUrls": True,
},
timeout=10,
)
r.raise_for_status()
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("error", "launch failed"))
data = res.json()
if not res.ok or not data.get("ok"):
raise RuntimeError(data.get("error", f"HTTP {res.status_code}"))
return data
with sync_playwright() as p:
data = launch_by_code("A3F9K2")
browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{data['debugPort']}")
data = launch({
"keyword": "checkout",
"tags": ["电商", "北美"],
"groupId": "group-sales-us",
"matchMode": "unique",
})
browser = p.chromium.connect_over_cdp(data["cdpUrl"])
page = browser.contexts[0].new_page()
page.goto("https://example.com")
\`\`\`
## Node.js
## Node.js launch(selector)
\`\`\`javascript
const BASE = 'http://127.0.0.1:19876'
async function launch(code) {
async function launch(selector, extra = {}) {
const res = await fetch(\`\${BASE}/api/launch\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, skipDefaultStartUrls: true })
body: JSON.stringify({
selector,
skipDefaultStartUrls: true,
...extra,
}),
})
const data = await res.json()
if (!res.ok || !data.ok) throw new Error(data.error || \`HTTP \${res.status}\`)
if (!res.ok || !data.ok) {
throw new Error(data.error || \`HTTP \${res.status}\`)
}
return data
}
await launch({
keywords: ['amazon', 'buyer', 'checkout'],
tags: ['电商'],
matchMode: 'unique',
})
\`\`\`
## PowerShell
\`\`\`powershell
$body = @{
code = "buyer-001"
skipDefaultStartUrls = $true
} | ConvertTo-Json -Depth 6
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:19876/api/launch" -ContentType "application/json" -Body $body
\`\`\`
## cURL\`code\` 传关键字
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"buyer-001",
"skipDefaultStartUrls":true
}'
\`\`\`
`
const DOC_PRACTICES = `# 最佳实践
## 1) Code
## 1)
- Code
- 使 Code
- Code
## 2)
1. \`code\`
2. \`profileId\`
3. \`profileName\`
4. \`keyword / tags / groupId\`
- / selector \`POST.code\`
## 2)
- \`keywords\` 里尽量放稳定业务词,不要放容易漂移的描述
-
- \`tag\`\`groupId\`
## 3)
- \`tags\` 用来表达能力或属性,例如:\`电商\`\`北美\`\`付款\`
- \`groupId\` 用来表达组织归属,例如:\`group-sales-us\`
-
## 4)
-
- \`POST /api/launch\`\`launchArgs\`
- \`launchArgs\`
- \`startUrls\`
## 3)
## 5)
1. \`/api/health\`
2.
3. \`/api/launch/logs\`
4.
2. \`POST /api/launch\`
3. \`409\`,先收窄 selector;如果业务允许多命中,再改 \`matchMode=first\`\`matchMode=all\`
4. \`500\`,再查 \`/api/launch/logs\` 和应用日志
`
const DOC_TROUBLESHOOT = `# 常见问题
## Q1 \`launch code not found\`
## Q1 \`selector is required\`
- Code
- Code
- \`selector\`
- \`code / profileId / keyword / tags\` 等字段
## Q2 \`forbidden: only localhost is allowed\`
## Q2 \`launch code not found\`
- Code
- Code
- Code
- \`GET /api/launch/{code}\` 或“你明确想按真实 Code 启动”的报错
- / \`POST /api/launch\`
## Q3 \`profile selector matched no instance\`
- \`keywords\`
- \`tags\`\`groupId\` 条件写错
- 0
- \`POST\` 里把 \`code\` 当关键字传了,但该关键字没有命中任何实例
## Q4 \`selector matched multiple profiles\`
-
- \`groupId\` / 更多 \`tags\`
- \`matchMode=first\`
- \`matchMode=all\`
## Q5 \`forbidden: only localhost is allowed\`
- 访
-
## Q3 \`500\` 启动失败
## Q6 \`500\` 启动失败
- \`/api/launch/logs\`\`error\`
- \`/api/launch/logs\` \`error\`
-
- selector
## Q7访 \`cdpUrl\` 返回 \`no active browser debug target\`
-
- \`GET /api/launch/{code}\`\`POST /api/launch\`
- \`200\`
`
// ============================================================================
@@ -355,6 +904,11 @@ const DOC_TREE: DocNode[] = [
label: '快速接入',
content: DOC_QUICKSTART,
},
{
id: 'selector',
label: '选择器规则',
content: DOC_SELECTOR,
},
{
id: 'api-index',
label: '接口总览',
@@ -367,9 +921,15 @@ const DOC_TREE: DocNode[] = [
{ id: 'api-health', label: '健康检查', content: DOC_API_HEALTH },
{ id: 'api-launch-get', label: '按 Code 启动', content: DOC_API_LAUNCH_GET },
{ id: 'api-launch-post', label: '参数化启动', content: DOC_API_LAUNCH_POST },
{ id: 'api-cdp', label: 'CDP 统一入口', content: DOC_API_CDP },
{ id: 'api-logs', label: '调用记录', content: DOC_API_LOGS },
],
},
{
id: 'scenarios',
label: '场景示例',
content: DOC_SCENARIOS,
},
{
id: 'errors',
label: '错误与重试',
@@ -377,7 +937,7 @@ const DOC_TREE: DocNode[] = [
},
{
id: 'examples',
label: '代码示例',
label: '多语言示例',
content: DOC_EXAMPLES,
},
{
@@ -709,4 +1269,3 @@ export function LaunchApiDocsPage() {
</div>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Card, ConfirmModal, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components'
import { Button, Card, ConfirmModal, FormItem, Input, Modal, Select, Switch, Table, Textarea, toast } from '../../../shared/components'
import type { SortOrder, TableColumn } from '../../../shared/components/Table'
import type { BrowserProxy, ProxyIPHealthResult } from '../types'
import { fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL } from '../api'
@@ -39,6 +39,37 @@ interface ClashProxy {
[key: string]: any
}
type ProxyImportMode = 'clash' | 'direct'
interface DirectImportForm {
proxyName: string
protocol: 'http' | 'https' | 'socks5'
server: string
port: string
username: string
password: string
}
const DIRECT_PROXY_PROTOCOL_OPTIONS = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
proxyName: '',
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
}
interface ImportCandidate {
proxyName: string
proxyConfig: string
}
interface ProxyDisplayInfo {
proxyId: string
proxyName: string
@@ -205,6 +236,116 @@ function parseClashImportText(raw: string): ClashProxy[] {
throw new Error('无效的 YAML 格式,需要包含 proxies 数组')
}
function normalizeDirectProxyConfig(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
if (/^socket:\/\//i.test(trimmed)) {
return trimmed.replace(/^socket:\/\//i, 'socks5://')
}
if (/^socks:\/\//i.test(trimmed)) {
return trimmed.replace(/^socks:\/\//i, 'socks5://')
}
return trimmed
}
function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string {
const name = rawName.trim()
const fallbackName = server
? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}`
: `导入代理 ${index + 1}`
const finalName = name || fallbackName
return prefix ? `${prefix}-${finalName}` : finalName
}
function formatDirectProxyHost(raw: string): string {
const host = raw.trim()
if (!host) return ''
if (host.startsWith('[') && host.endsWith(']')) {
return host
}
return host.includes(':') ? `[${host}]` : host
}
function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
const serverInput = form.server.trim()
if (!serverInput) {
throw new Error('请输入代理地址')
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) {
throw new Error('代理地址只需要填写主机名或 IP,不需要协议头')
}
const portInput = form.port.trim()
if (!portInput) {
throw new Error('请输入代理端口')
}
if (!/^\d+$/.test(portInput)) {
throw new Error('代理端口必须为数字')
}
const port = Number(portInput)
if (port < 1 || port > 65535) {
throw new Error('代理端口必须在 1-65535 之间')
}
const username = form.username.trim()
const password = form.password
if (password && !username) {
throw new Error('填写密码时请同时填写账号')
}
const auth = username
? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: ''
const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}`
let parsedURL: URL
try {
parsedURL = new URL(rawConfig)
} catch {
throw new Error('请输入有效的代理地址')
}
if (!parsedURL.hostname) {
throw new Error('请输入有效的代理地址')
}
const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '')
const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1')
return {
proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''),
proxyConfig: normalizedConfig,
}
}
function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
return parsedProxies.map((proxy, index) => ({
proxyName: resolveImportedProxyName(proxy, index, prefix),
proxyConfig: proxyToYaml(proxy),
}))
}
function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] {
return candidates.map((candidate, index) => {
const info = parseProxyInfo(candidate.proxyConfig)
return {
proxyId: `preview-${index}`,
proxyName: candidate.proxyName,
proxyConfig: candidate.proxyConfig,
groupName,
sourceId: '',
sourceUrl: '',
sourceAutoRefresh: false,
sourceRefreshIntervalM: 0,
sourceLastRefreshAt: '',
type: info.type || '-',
server: info.server || '-',
port: info.port || 0,
}
})
}
function parseTimestampMs(value: string): number {
const v = (value || '').trim()
if (!v) return 0
@@ -231,6 +372,43 @@ function sourceHostLabel(sourceURL: string): string {
}
}
function normalizeSourceURL(sourceURL: string): string {
const raw = (sourceURL || '').trim()
if (!raw) return ''
try {
const parsed = new URL(raw)
parsed.hash = ''
return parsed.toString()
} catch {
return raw
}
}
function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string {
const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}`
// djb2 变体,输出稳定且实现简单。
let hash = 5381
for (let i = 0; i < key.length; i += 1) {
hash = ((hash << 5) + hash) ^ key.charCodeAt(i)
}
const unsigned = hash >>> 0
return `src-${unsigned.toString(36)}`
}
function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string {
const normalizedURL = normalizeSourceURL(sourceURL)
const normalizedPrefix = sourceNamePrefix.trim()
const existing = list.find(item =>
normalizeSourceURL(item.sourceUrl || '') === normalizedURL &&
(item.sourceNamePrefix || '').trim() === normalizedPrefix &&
(item.sourceId || '').trim() !== ''
)
if (existing?.sourceId?.trim()) {
return existing.sourceId.trim()
}
return buildStableSourceID(sourceURL, sourceNamePrefix)
}
function collectURLImportSources(list: BrowserProxy[]): URLImportSourceMeta[] {
const sourceMap = new Map<string, URLImportSourceMeta>()
for (const item of list) {
@@ -273,15 +451,10 @@ function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: stri
return prefix ? `${prefix}-${rawName}` : rawName
}
function buildRefreshedSourceProxies(
parsedProxies: ClashProxy[],
oldSourceProxies: BrowserProxy[],
meta: URLImportSourceMeta,
refreshedAt: string
): BrowserProxy[] {
function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
const exactMap = new Map<string, BrowserProxy[]>()
const nameMap = new Map<string, BrowserProxy[]>()
for (const item of oldSourceProxies) {
oldSourceProxies.forEach(item => {
const exactKey = `${item.proxyName}|||${item.proxyConfig}`
const exactList = exactMap.get(exactKey) || []
exactList.push(item)
@@ -291,15 +464,16 @@ function buildRefreshedSourceProxies(
const nameList = nameMap.get(nameKey) || []
nameList.push(item)
nameMap.set(nameKey, nameList)
}
})
const pickExisting = (name: string, configText: string): string | null => {
return (name: string, configText: string): string | null => {
const exactKey = `${name}|||${configText}`
const exactList = exactMap.get(exactKey)
if (exactList && exactList.length > 0) {
const item = exactList.shift()
if (item?.proxyId) return item.proxyId
}
const nameList = nameMap.get(name)
if (nameList && nameList.length > 0) {
const item = nameList.shift()
@@ -307,6 +481,15 @@ function buildRefreshedSourceProxies(
}
return null
}
}
function buildRefreshedSourceProxies(
parsedProxies: ClashProxy[],
oldSourceProxies: BrowserProxy[],
meta: URLImportSourceMeta,
refreshedAt: string
): BrowserProxy[] {
const pickExisting = createExistingProxyIDPicker(oldSourceProxies)
const prefix = meta.sourceNamePrefix.trim()
const sourceGroupName = meta.sourceGroupName.trim()
@@ -530,12 +713,14 @@ export function ProxyPoolPage() {
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false)
const [importModalOpen, setImportModalOpen] = useState(false)
const [importMode, setImportMode] = useState<ProxyImportMode>('clash')
const [importUrl, setImportUrl] = useState('')
const [importResolvedUrl, setImportResolvedUrl] = useState('')
const [importText, setImportText] = useState('')
const [importDnsServers, setImportDnsServers] = useState('')
const [importNamePrefix, setImportNamePrefix] = useState('')
const [importGroupName, setImportGroupName] = useState('')
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewList, setPreviewList] = useState<ProxyDisplayInfo[]>([])
const [removedPreviewProxyNames, setRemovedPreviewProxyNames] = useState<string[]>([])
@@ -1228,25 +1413,13 @@ export function ProxyPoolPage() {
setDeletingId(null)
}
const buildImportPreview = (parsedProxies: ClashProxy[]) => {
return parsedProxies.map((p, idx) => {
const prefix = importNamePrefix.trim()
const proxyName = resolveImportedProxyName(p, idx, prefix)
return {
proxyId: `preview-${idx}`,
proxyName,
proxyConfig: proxyToYaml(p),
groupName: importGroupName.trim(),
sourceId: '',
sourceUrl: '',
sourceAutoRefresh: false,
sourceRefreshIntervalM: 0,
sourceLastRefreshAt: '',
type: p.type || '-',
server: p.server || '-',
port: p.port || 0,
} as ProxyDisplayInfo
})
const handleImportModeChange = (nextMode: ProxyImportMode) => {
setImportMode(nextMode)
setImportResolvedUrl('')
if (nextMode !== 'clash') {
setImportUrl('')
setImportDnsServers('')
}
}
const handleFetchImportURL = async () => {
@@ -1283,14 +1456,17 @@ export function ProxyPoolPage() {
}
}
const handleParseYaml = () => {
const handleParseImport = () => {
try {
const parsedProxies = parseClashImportText(importText)
if (!parsedProxies.length) {
const prefix = importNamePrefix.trim()
const candidates = importMode === 'clash'
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
: [buildDirectImportCandidate(directImportForm)]
if (!candidates.length) {
toast.error('未解析到可导入代理')
return
}
const preview = buildImportPreview(parsedProxies)
const preview = buildImportPreview(candidates, importGroupName.trim())
setRemovedPreviewProxyNames([])
setPreviewList(preview)
setImportModalOpen(false)
@@ -1307,19 +1483,23 @@ export function ProxyPoolPage() {
}
setImporting(true)
try {
const sourceURL = importResolvedUrl.trim()
const sourceURL = importMode === 'clash' ? importResolvedUrl.trim() : ''
const isURLImport = !!sourceURL
const sourceID = isURLImport ? `src-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : ''
const sourceNamePrefix = importNamePrefix.trim()
const sourceNamePrefix = importMode === 'clash' ? importNamePrefix.trim() : ''
const sourceID = isURLImport ? resolveImportSourceID(proxies, sourceURL, sourceNamePrefix) : ''
const sourceAutoRefresh = isURLImport ? globalAutoRefreshEnabled : false
const sourceRefreshIntervalM = sourceAutoRefresh ? globalRefreshInterval : 0
const sourceLastRefreshAt = isURLImport ? new Date().toISOString() : ''
const oldSourceProxies = isURLImport
? proxies.filter(item => (item.sourceId || '').trim() === sourceID)
: []
const pickExistingID = createExistingProxyIDPicker(oldSourceProxies)
const newProxies: BrowserProxy[] = previewList.map((p, idx) => ({
proxyId: `proxy-${Date.now()}-${idx}`,
const newProxies: BrowserProxy[] = previewList.map((p) => ({
proxyId: pickExistingID(p.proxyName, p.proxyConfig) || nextProxyID(),
proxyName: p.proxyName,
proxyConfig: p.proxyConfig,
dnsServers: importDnsServers.trim() || undefined,
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
groupName: importGroupName.trim() || undefined,
sourceId: sourceID || undefined,
sourceUrl: sourceURL || undefined,
@@ -1328,7 +1508,9 @@ export function ProxyPoolPage() {
sourceRefreshIntervalM,
sourceLastRefreshAt: sourceLastRefreshAt || undefined,
}))
const allProxies = [...proxies, ...newProxies]
const allProxies = isURLImport
? proxies.filter(item => (item.sourceId || '').trim() !== sourceID).concat(newProxies)
: [...proxies, ...newProxies]
await saveProxies(allProxies)
if (isURLImport && removedPreviewProxyNames.length > 0) {
appendSourceIgnoredProxyNames(sourceID, removedPreviewProxyNames)
@@ -1340,6 +1522,7 @@ export function ProxyPoolPage() {
setImportDnsServers('')
setImportNamePrefix('')
setImportGroupName('')
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
setPreviewList([])
setRemovedPreviewProxyNames([])
toast.success(`成功导入 ${newProxies.length} 个代理`)
@@ -1351,13 +1534,16 @@ export function ProxyPoolPage() {
}
const selectedCount = selectedIds.size
const canParseImport = importMode === 'clash'
? !!importText.trim()
: !!directImportForm.server.trim() && !!directImportForm.port.trim()
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> Clash VPN 使</p>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> Clash HTTPHTTPSSOCKS5</p>
</div>
<div className="flex gap-2">
<Button
@@ -1371,7 +1557,7 @@ export function ProxyPoolPage() {
</Button>
<Button size="sm" variant="secondary" onClick={handleCheckAllIPHealth} loading={checkingAllIPHealth} disabled={filteredList.length === 0}>IP健康</Button>
<Button size="sm" variant="secondary" onClick={handleTestAll} loading={testingAll} disabled={filteredList.length === 0}></Button>
<Button size="sm" onClick={() => setImportModalOpen(true)}> Clash</Button>
<Button size="sm" onClick={() => setImportModalOpen(true)}></Button>
</div>
</div>
@@ -1454,47 +1640,123 @@ export function ProxyPoolPage() {
/>
</Card>
<Modal open={importModalOpen} onClose={() => setImportModalOpen(false)} title="导入 Clash 代理配置" width="600px"
<Modal open={importModalOpen} onClose={() => setImportModalOpen(false)} title="导入代理配置" width="600px"
footer={
<>
<Button variant="secondary" onClick={() => setImportModalOpen(false)} disabled={fetchingImportUrl}></Button>
<Button onClick={handleParseYaml} disabled={fetchingImportUrl || !importText.trim()}></Button>
<Button onClick={handleParseImport} disabled={fetchingImportUrl || !canParseImport}></Button>
</>
}>
<div className="space-y-4">
<p className="text-sm text-[var(--color-text-muted)]"> Clash YAML URL proxiesdnsproxy-groups</p>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<Input
value={importUrl}
onChange={e => {
const next = e.target.value
setImportUrl(next)
if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
setImportResolvedUrl('')
}
}}
placeholder="https://example.com/clash/subscription"
className="flex-1"
<div className="grid grid-cols-2 gap-2">
<Button
variant={importMode === 'clash' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('clash')}
>
Clash / YAML
</Button>
<Button
variant={importMode === 'direct' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('direct')}
>
HTTP / SOCKS5
</Button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'}
</p>
{importMode === 'clash' && (
<>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<Input
value={importUrl}
onChange={e => {
const next = e.target.value
setImportUrl(next)
if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
setImportResolvedUrl('')
}
}}
placeholder="https://example.com/clash/subscription"
className="flex-1"
/>
<Button
variant="secondary"
onClick={handleFetchImportURL}
loading={fetchingImportUrl}
disabled={!importUrl.trim()}
>
URL
</Button>
</div>
{importResolvedUrl.trim() && (
<p className="text-xs text-[var(--color-success)] mt-1 break-all">
{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"> YAML DNS </p>
</FormItem>
<Textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={12}
placeholder={`proxies:\n - name: vless-v6\n type: vless\n server: example.com\n port: 443\n uuid: your-uuid\n ...`}
/>
<Button
variant="secondary"
onClick={handleFetchImportURL}
loading={fetchingImportUrl}
disabled={!importUrl.trim()}
>
URL
</Button>
</>
)}
{importMode === 'direct' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="例如:香港节点"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
{importResolvedUrl.trim() && (
<p className="text-xs text-[var(--color-success)] mt-1 break-all">
{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"> YAML DNS </p>
</FormItem>
<Textarea value={importText} onChange={e => setImportText(e.target.value)} rows={12}
placeholder={`proxies:\n - name: vless-v6\n type: vless\n server: example.com\n port: 443\n uuid: your-uuid\n ...`} />
)}
<FormItem label="分组名称(可选)">
<Input
value={importGroupName}
@@ -1509,28 +1771,32 @@ export function ProxyPoolPage() {
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"></p>
</FormItem>
<FormItem label="名称前缀(可选)">
<Input
value={importNamePrefix}
onChange={e => setImportNamePrefix(e.target.value)}
placeholder="例如:HK、US、机场A"
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
<code className="px-1 bg-[var(--color-bg-secondary)] rounded">-</code>
</p>
</FormItem>
<FormItem label="批量 DNS 配置(可选)">
<Textarea value={importDnsServers} onChange={e => setImportDnsServers(e.target.value)} rows={5}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
<p className="text-xs text-[var(--color-text-muted)] mt-1"> DNS</p>
</FormItem>
{importMode === 'clash' && (
<FormItem label="名称前缀(可选)">
<Input
value={importNamePrefix}
onChange={e => setImportNamePrefix(e.target.value)}
placeholder="例如:HK、US、机场A"
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
<code className="px-1 bg-[var(--color-bg-secondary)] rounded">-</code>
</p>
</FormItem>
)}
{importMode === 'clash' && (
<FormItem label="批量 DNS 配置(可选)">
<Textarea value={importDnsServers} onChange={e => setImportDnsServers(e.target.value)} rows={5}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
<p className="text-xs text-[var(--color-text-muted)] mt-1"> DNS</p>
</FormItem>
)}
</div>
</Modal>
<Modal open={previewModalOpen} onClose={() => setPreviewModalOpen(false)} title="确认导入以下代理" width="700px"
footer={<><Button variant="secondary" onClick={() => { setPreviewModalOpen(false); setImportModalOpen(true) }}></Button><Button onClick={handleConfirmImport} loading={importing} disabled={previewList.length === 0}></Button></>}>
<div className="space-y-3">
{importDnsServers.trim() && (
{importMode === 'clash' && importDnsServers.trim() && (
<p className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-secondary)] px-3 py-2 rounded"> DNS</p>
)}
<p className="text-xs text-[var(--color-text-muted)]">
@@ -1553,12 +1819,12 @@ export function ProxyPoolPage() {
</datalist>
</FormItem>
<FormItem label="代理配置">
<Textarea value={editForm.proxyConfig} onChange={e => setEditForm(prev => ({ ...prev, proxyConfig: e.target.value }))} rows={10} placeholder="Clash YAML 格式配置" />
<Textarea value={editForm.proxyConfig} onChange={e => setEditForm(prev => ({ ...prev, proxyConfig: e.target.value }))} rows={10} placeholder="支持 Clash YAML、http://、https://、socks5:// 代理配置" />
</FormItem>
<FormItem label="DNS 服务器(可选)">
<Textarea value={editForm.dnsServers} onChange={e => setEditForm(prev => ({ ...prev, dnsServers: e.target.value }))} rows={6}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
<p className="text-xs text-[var(--color-text-muted)] mt-1"> Clash dns: YAML 使 DNS</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1"> Clash dns: YAML Clash / HTTP/SOCKS5 使 DNS </p>
</FormItem>
</div>
</Modal>
+4
View File
@@ -6,6 +6,10 @@ export interface BrowserProfile {
fingerprintArgs: string[]
proxyId: string
proxyConfig: string
proxyBindSourceId?: string
proxyBindSourceUrl?: string
proxyBindName?: string
proxyBindUpdatedAt?: string
launchArgs: string[]
tags: string[]
keywords: string[]
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Monitor, Play, Shield, Cpu, ArrowRight, Globe, Settings } from 'lucide-react'
import { Monitor, Play, Shield, Cpu, ArrowRight, ExternalLink, Globe, Settings } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Card, Button, Modal, toast } from '../../shared/components'
import { Card, Button, toast } from '../../shared/components'
import { fetchDashboardStats, redeemCDKey, redeemGithubStar, reloadConfig } from './api'
import type { DashboardStats } from './types'
import { BrowserOpenURL } from '../../wailsjs/runtime/runtime'
@@ -42,12 +42,12 @@ export function DashboardPage() {
proxyCount: 0,
coreCount: 0,
memUsedMB: 0,
maxProfileLimit: 3,
maxProfileLimit: 20,
appVersion: 'unknown',
})
const [loading, setLoading] = useState(true)
const [cdKey, setCdKey] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [promoModalMsg, setPromoModalMsg] = useState('')
useEffect(() => {
load()
@@ -73,18 +73,16 @@ export function DashboardPage() {
setCdKey('')
load() // Refresh stats
} else {
setPromoModalMsg(result.message || '兑换失败')
toast.error(result.message || '兑换失败')
}
}
const handleAcceptPromo = async () => {
setPromoModalMsg('')
BrowserOpenURL(PROJECT_GITHUB_URL)
const handleClaimStarGift = async () => {
setRedeeming(true)
const starRes = await redeemGithubStar()
setRedeeming(false)
if (starRes.success) {
toast.success('感谢您的支持!已为您增加 3 个永久额度!')
toast.success('感谢您的支持!已额外赠送 50 个永久额度!')
setCdKey('')
load()
} else {
@@ -92,6 +90,11 @@ export function DashboardPage() {
}
}
const handleOpenGithubStarGift = async () => {
BrowserOpenURL(PROJECT_GITHUB_URL)
await handleClaimStarGift()
}
const v = (n: number) => loading ? '-' : n.toString()
return (
@@ -157,7 +160,7 @@ export function DashboardPage() {
<Card title="系统信息">
<div className="space-y-1">
{[
{ label: '系统版本', value: '1.0.0' },
{ label: '系统版本', value: loading ? '-' : stats.appVersion },
{ label: '运行环境', value: 'Wails v2 + React' },
{ label: '数据存储', value: 'SQLite + YAML' },
{ label: '内存占用', value: loading ? '-' : `${stats.memUsedMB} MB` },
@@ -196,39 +199,25 @@ export function DashboardPage() {
{loading ? '-' : `${stats.totalInstances} / ${stats.maxProfileLimit}`}
</span>
</p>
<div className="mt-4 rounded-lg border border-blue-500/20 bg-blue-500/10 p-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-[var(--color-text-primary)]"> GitHub Star 50 </p>
<button
type="button"
className="shrink-0 rounded-full p-2 text-[var(--color-accent)] transition-colors hover:bg-[var(--color-accent)]/10 disabled:opacity-50"
onClick={handleOpenGithubStarGift}
disabled={redeeming}
title="打开 GitHub 并领取赠送"
aria-label="打开 GitHub 并领取赠送"
>
<ExternalLink className="w-4 h-4" />
</button>
</div>
</div>
</div>
</Card>
</div>
<Modal
open={!!promoModalMsg}
onClose={() => setPromoModalMsg('')}
title="获取更多额度"
width="400px"
footer={
<>
<Button variant="secondary" onClick={() => setPromoModalMsg('')}></Button>
<Button onClick={handleAcceptPromo}> GitHub </Button>
</>
}
>
<div className="space-y-4">
<div className="text-[var(--color-error)] font-medium">
{promoModalMsg}
</div>
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
<p className="mb-2"></p>
<p> <strong>Star</strong> <strong>3</strong> </p>
<p className="text-xs text-[var(--color-text-muted)] mt-2">
<button type="button" className="ml-1 break-all text-[var(--color-accent)] underline underline-offset-2" onClick={() => BrowserOpenURL(PROJECT_GITHUB_URL)}>
{PROJECT_GITHUB_URL}
</button>
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-2"></p>
</div>
</div>
</Modal>
</div>
)
}
+4 -3
View File
@@ -13,20 +13,21 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
if (bindings?.GetDashboardStats) {
try {
const data = await bindings.GetDashboardStats()
const licenseStatus = bindings.GetLicenseStatus ? await bindings.GetLicenseStatus() : { maxLimit: 3 }
const licenseStatus = bindings.GetLicenseStatus ? await bindings.GetLicenseStatus() : { maxLimit: 20 }
return {
totalInstances: data?.totalInstances ?? 0,
runningInstances: data?.runningInstances ?? 0,
proxyCount: data?.proxyCount ?? 0,
coreCount: data?.coreCount ?? 0,
memUsedMB: data?.memUsedMB ?? 0,
maxProfileLimit: licenseStatus?.maxLimit ?? 3,
maxProfileLimit: licenseStatus?.maxLimit ?? 20,
appVersion: data?.appVersion ?? 'unknown',
}
} catch (e) {
console.error('fetchDashboardStats error:', e)
}
}
return { totalInstances: 0, runningInstances: 0, proxyCount: 0, coreCount: 0, memUsedMB: 0, maxProfileLimit: 3 }
return { totalInstances: 0, runningInstances: 0, proxyCount: 0, coreCount: 0, memUsedMB: 0, maxProfileLimit: 20, appVersion: 'unknown' }
}
export async function redeemCDKey(cdkey: string): Promise<{ success: boolean, message?: string }> {
+1
View File
@@ -5,4 +5,5 @@ export interface DashboardStats {
coreCount: number
memUsedMB: number
maxProfileLimit: number
appVersion: string
}
@@ -94,7 +94,7 @@ export function AdminKeygenPage() {
<div className="space-y-6 animate-fade-in max-w-4xl mx-auto">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"> - CDKey </h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> 3 </p>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> 10 </p>
</div>
<Card>
-8
View File
@@ -1,8 +0,0 @@
# 资源文件夹
此文件夹用于存放应用程序所需的各种资源文件,如图像、图标、字体等。
## 目录结构
- `images/` - 存放图像资源
- `logo.png` - 应用程序logo
-103
View File
@@ -1,103 +0,0 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom'
interface DrawerProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
footer?: ReactNode
placement?: DrawerPlacement
width?: string
height?: string
closable?: boolean
}
const placementStyles = {
left: 'left-0 top-0 bottom-0 animate-slide-in-left',
right: 'right-0 top-0 bottom-0 animate-slide-in-right',
top: 'top-0 left-0 right-0 animate-slide-in-top',
bottom: 'bottom-0 left-0 right-0 animate-slide-in-bottom',
}
export function Drawer({
open,
onClose,
title,
children,
footer,
placement = 'right',
width = '400px',
height = '300px',
closable = true,
}: DrawerProps) {
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [open])
if (!open) return null
const isHorizontal = placement === 'left' || placement === 'right'
const size = isHorizontal ? { width } : { height }
return (
<div className="fixed inset-0 z-50">
{/* 遮罩层 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={closable ? onClose : undefined}
/>
{/* 抽屉内容 */}
<div
className={clsx(
'absolute bg-[var(--color-bg-elevated)] shadow-2xl flex flex-col',
placementStyles[placement]
)}
style={size}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
{(title || closable) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)] flex-shrink-0">
{title && (
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{title}
</h3>
)}
{closable && (
<button
onClick={onClose}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] transition-colors ml-auto"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
{/* 内容区 */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{children}
</div>
{/* 底部按钮 */}
{footer && (
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border)] flex-shrink-0">
{footer}
</div>
)}
</div>
</div>
)
}
@@ -1,99 +0,0 @@
import { ReactNode, useState, useRef, useEffect } from 'react'
import { ChevronDown } from 'lucide-react'
import clsx from 'clsx'
export interface DropdownItem {
key: string
label: ReactNode
icon?: ReactNode
disabled?: boolean
danger?: boolean
divider?: boolean
}
interface DropdownProps {
items: DropdownItem[]
onSelect?: (key: string) => void
children?: ReactNode
trigger?: ReactNode
placement?: 'bottom-left' | 'bottom-right'
}
export function Dropdown({
items,
onSelect,
children,
trigger,
placement = 'bottom-left',
}: DropdownProps) {
const [visible, setVisible] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (visible) {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setVisible(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [visible])
const handleSelect = (item: DropdownItem) => {
if (item.disabled || item.divider) return
onSelect?.(item.key)
setVisible(false)
}
const placementStyles = {
'bottom-left': 'left-0',
'bottom-right': 'right-0',
}
return (
<div ref={containerRef} className="relative inline-block">
<div onClick={() => setVisible(!visible)} className="cursor-pointer">
{trigger || children || (
<button className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-bg-muted)] transition-colors">
<span className="text-sm"></span>
<ChevronDown className="w-4 h-4" />
</button>
)}
</div>
{visible && (
<div
className={clsx(
'absolute top-full mt-2 z-50 min-w-[160px] bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 animate-scale-in',
placementStyles[placement]
)}
>
{items.map((item, index) => (
item.divider ? (
<div key={index} className="h-px bg-[var(--color-border)] my-1" />
) : (
<button
key={item.key}
onClick={() => handleSelect(item)}
disabled={item.disabled}
className={clsx(
'w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors',
item.disabled
? 'opacity-40 cursor-not-allowed'
: item.danger
? 'text-[var(--color-error)] hover:bg-[var(--color-error)]/15'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)]'
)}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</button>
)
))}
</div>
)}
</div>
)
}
@@ -1,140 +0,0 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
interface PaginationProps {
current: number
total: number
pageSize: number
onChange: (page: number) => void
onPageSizeChange?: (size: number) => void
pageSizeOptions?: number[]
showTotal?: boolean
showPageSize?: boolean
}
export function Pagination({
current,
total,
pageSize,
onChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
showTotal = true,
showPageSize = true,
}: PaginationProps) {
const totalPages = Math.ceil(total / pageSize)
// 生成页码数组
const getPageNumbers = () => {
const pages: (number | string)[] = []
const maxVisible = 5
if (totalPages <= maxVisible + 2) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1)
if (current > 3) pages.push('...')
const start = Math.max(2, current - 1)
const end = Math.min(totalPages - 1, current + 1)
for (let i = start; i <= end; i++) pages.push(i)
if (current < totalPages - 2) pages.push('...')
pages.push(totalPages)
}
return pages
}
if (total === 0) return null
return (
<div className="flex items-center justify-between gap-4 py-3 px-4 border-t border-[var(--color-border)]">
{/* 左侧:总数和每页条数 */}
<div className="flex items-center gap-4 text-sm text-[var(--color-text-muted)]">
{showTotal && (
<span> <span className="font-medium text-[var(--color-text-secondary)]">{total}</span> </span>
)}
{showPageSize && onPageSizeChange && (
<div className="flex items-center gap-2">
<span></span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="px-2 py-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/50"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span></span>
</div>
)}
</div>
{/* 右侧:分页按钮 */}
<div className="flex items-center gap-1">
{/* 首页 */}
<button
onClick={() => onChange(1)}
disabled={current === 1}
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="首页"
>
<ChevronsLeft className="w-4 h-4" />
</button>
{/* 上一页 */}
<button
onClick={() => onChange(current - 1)}
disabled={current === 1}
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="上一页"
>
<ChevronLeft className="w-4 h-4" />
</button>
{/* 页码 */}
{getPageNumbers().map((page, index) => (
typeof page === 'number' ? (
<button
key={index}
onClick={() => onChange(page)}
className={`min-w-[32px] h-8 px-2 rounded-md text-sm font-medium transition-colors ${
page === current
? 'bg-[var(--color-accent)] text-white'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)]'
}`}
>
{page}
</button>
) : (
<span key={index} className="px-1 text-[var(--color-text-muted)]">...</span>
)
))}
{/* 下一页 */}
<button
onClick={() => onChange(current + 1)}
disabled={current === totalPages}
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="下一页"
>
<ChevronRight className="w-4 h-4" />
</button>
{/* 末页 */}
<button
onClick={() => onChange(totalPages)}
disabled={current === totalPages}
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="末页"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More