mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.0 - 2026-03-19
|
||||
|
||||
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题。
|
||||
- 新增 SOCKS 代理测试支持:SOCKS 代理能力已进入测试阶段,后续会继续验证稳定性与兼容性。
|
||||
- 实验性支持接口触发浏览器:支持通过接口启动浏览器实例,便于后续接入自动化流程。
|
||||
@@ -1,9 +1,9 @@
|
||||
# Ant Browser
|
||||
|
||||
> 面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。
|
||||
> 面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具(Windows / Linux)。
|
||||
|
||||
[](https://github.com/black-ant/Ant-Browser/releases)
|
||||
[](https://github.com/black-ant/Ant-Browser/releases)
|
||||
[](https://github.com/black-ant/Ant-Browser/releases)
|
||||
[](https://github.com/black-ant/Ant-Browser/issues)
|
||||
|
||||
## 推荐内核项目
|
||||
@@ -16,11 +16,13 @@ Ant Browser 当前推荐配套使用的浏览器内核,来源于开源项目 [
|
||||
|
||||
这个项目为 Ant Browser 的内核准备提供了直接可用的基础来源,这里先对原项目做明确推荐与致谢。
|
||||
|
||||
Ant Browser 的目标很明确:在一台 Windows 设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。
|
||||
Ant Browser 的目标很明确:在一台桌面设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。
|
||||
|
||||
## 目录
|
||||
|
||||
- [项目简介](#项目简介)
|
||||
- [近期更新](#近期更新)
|
||||
- [更新日志](CHANGELOG.md)
|
||||
- [核心特性](#核心特性)
|
||||
- [界面预览](#界面预览)
|
||||
- [快速开始](#快速开始)
|
||||
@@ -47,6 +49,16 @@ Ant Browser 适合以下场景:
|
||||
- 统一管理浏览器内核、标签、关键字和快捷打开码
|
||||
- 在本地保存配置和运行数据,便于自主控制
|
||||
|
||||
## 近期更新
|
||||
|
||||
### 1.1.0 · 2026-03-19
|
||||
|
||||
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题
|
||||
- 新增 SOCKS 代理测试支持:SOCKS 代理能力已进入测试阶段,后续会继续验证稳定性与兼容性
|
||||
- 实验性支持接口触发浏览器:支持通过接口启动浏览器实例,便于后续接入自动化流程
|
||||
|
||||
完整历史版本记录见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
## 源码分支说明
|
||||
|
||||
- `master`:面向开发者的干净基线分支,不提交 `data/app.db`、实例目录或其他用户数据。首次启动时会自动初始化空数据库。
|
||||
@@ -111,7 +123,9 @@ Ant Browser 适合以下场景:
|
||||
|
||||
### 环境要求
|
||||
|
||||
- 操作系统:Windows 10 / Windows 11(64 位)
|
||||
- 操作系统:
|
||||
- Windows 10 / 11(64 位)
|
||||
- Linux(amd64 / arm64)
|
||||
- 建议内存:8 GB 及以上
|
||||
- 建议磁盘空间:2 GB 以上
|
||||
|
||||
@@ -120,13 +134,27 @@ Ant Browser 适合以下场景:
|
||||
1. 前往 Releases 页面下载最新版本:<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
@@ -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{"默认"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: "与系统/架构不兼容",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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("等待条件成立超时")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 根目录绝对路径。
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package apppath
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveReadOnlyLinuxInstallUsesUserDataRoot(t *testing.T) {
|
||||
if goruntime.GOOS != "linux" {
|
||||
t.Skip("linux-only path behavior")
|
||||
}
|
||||
|
||||
xdgDataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", xdgDataHome)
|
||||
|
||||
installRoot := filepath.Join(t.TempDir(), "opt-app")
|
||||
if err := os.MkdirAll(filepath.Join(installRoot, "bin"), 0755); err != nil {
|
||||
t.Fatalf("创建 installRoot 失败: %v", err)
|
||||
}
|
||||
if err := os.Chmod(installRoot, 0555); err != nil {
|
||||
t.Fatalf("设置 installRoot 权限失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chmod(installRoot, 0755)
|
||||
_ = os.Chmod(filepath.Join(installRoot, "bin"), 0755)
|
||||
})
|
||||
|
||||
configPath := resolveForOS(installRoot, "config.yaml", "linux")
|
||||
binPath := resolveForOS(installRoot, "bin/xray", "linux")
|
||||
|
||||
expectedStateRoot := filepath.Join(xdgDataHome, appStateDirName)
|
||||
if !strings.HasPrefix(configPath, expectedStateRoot+string(os.PathSeparator)) {
|
||||
t.Fatalf("config path 应落到用户目录,got=%s want-prefix=%s", configPath, expectedStateRoot)
|
||||
}
|
||||
if binPath != filepath.Join(installRoot, "bin", "xray") {
|
||||
t.Fatalf("bin path 不应迁移到用户目录,got=%s", binPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDarwinAppBundleUsesApplicationSupportStateRoot(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
installRoot := filepath.Join(t.TempDir(), "Ant Browser.app", "Contents", "MacOS")
|
||||
if err := os.MkdirAll(filepath.Join(installRoot, "bin"), 0755); err != nil {
|
||||
t.Fatalf("创建 installRoot 失败: %v", err)
|
||||
}
|
||||
|
||||
configPath := resolveForOS(installRoot, "config.yaml", "darwin")
|
||||
binPath := resolveForOS(installRoot, "bin/xray", "darwin")
|
||||
expectedStateRoot := filepath.Join(homeDir, "Library", "Application Support", appStateDirName)
|
||||
|
||||
if configPath != filepath.Join(expectedStateRoot, "config.yaml") {
|
||||
t.Fatalf("darwin config path 应落到 Application Support,got=%s want=%s", configPath, filepath.Join(expectedStateRoot, "config.yaml"))
|
||||
}
|
||||
if binPath != filepath.Join(installRoot, "bin", "xray") {
|
||||
t.Fatalf("darwin bin path 不应迁移到用户目录,got=%s", binPath)
|
||||
}
|
||||
|
||||
root := detectForOS(installRoot, "darwin")
|
||||
if !root.detached {
|
||||
t.Fatal("expected darwin .app bundle root to use detached state")
|
||||
}
|
||||
if root.stateRoot != expectedStateRoot {
|
||||
t.Fatalf("unexpected darwin state root: got=%s want=%s", root.stateRoot, expectedStateRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWritableLayoutSeedsConfigAndChrome(t *testing.T) {
|
||||
if goruntime.GOOS != "linux" {
|
||||
t.Skip("linux-only path behavior")
|
||||
}
|
||||
|
||||
xdgDataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", xdgDataHome)
|
||||
|
||||
installRoot := filepath.Join(t.TempDir(), "opt-app")
|
||||
if err := os.MkdirAll(filepath.Join(installRoot, "chrome"), 0755); err != nil {
|
||||
t.Fatalf("创建 chrome 目录失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installRoot, "config.yaml"), []byte("name: linux\n"), 0644); err != nil {
|
||||
t.Fatalf("写入 config.yaml 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installRoot, "chrome", "README.md"), []byte("placeholder\n"), 0644); err != nil {
|
||||
t.Fatalf("写入 README 失败: %v", err)
|
||||
}
|
||||
if err := os.Chmod(installRoot, 0555); err != nil {
|
||||
t.Fatalf("设置 installRoot 权限失败: %v", err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(installRoot, "chrome"), 0555); err != nil {
|
||||
t.Fatalf("设置 chrome 目录权限失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chmod(installRoot, 0755)
|
||||
_ = os.Chmod(filepath.Join(installRoot, "chrome"), 0755)
|
||||
})
|
||||
|
||||
if err := ensureWritableLayoutForOS(installRoot, "linux"); err != nil {
|
||||
t.Fatalf("EnsureWritableLayout 返回错误: %v", err)
|
||||
}
|
||||
|
||||
stateRoot := filepath.Join(xdgDataHome, appStateDirName)
|
||||
assertFileContent(t, filepath.Join(stateRoot, "config.yaml"), "name: linux\n")
|
||||
assertFileContent(t, filepath.Join(stateRoot, "chrome", "README.md"), "placeholder\n")
|
||||
if _, err := os.Stat(filepath.Join(stateRoot, "data")); err != nil {
|
||||
t.Fatalf("data 目录未创建: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWritableLayoutSeedsDarwinBundleStateRoot(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
installRoot := filepath.Join(t.TempDir(), "Ant Browser.app", "Contents", "MacOS")
|
||||
if err := os.MkdirAll(filepath.Join(installRoot, "chrome"), 0755); err != nil {
|
||||
t.Fatalf("创建 chrome 目录失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installRoot, "config.yaml"), []byte("name: mac\n"), 0644); err != nil {
|
||||
t.Fatalf("写入 config.yaml 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installRoot, "chrome", "README.md"), []byte("mac placeholder\n"), 0644); err != nil {
|
||||
t.Fatalf("写入 README 失败: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureWritableLayoutForOS(installRoot, "darwin"); err != nil {
|
||||
t.Fatalf("ensureWritableLayoutForOS 返回错误: %v", err)
|
||||
}
|
||||
|
||||
stateRoot := filepath.Join(homeDir, "Library", "Application Support", appStateDirName)
|
||||
assertFileContent(t, filepath.Join(stateRoot, "config.yaml"), "name: mac\n")
|
||||
assertFileContent(t, filepath.Join(stateRoot, "chrome", "README.md"), "mac placeholder\n")
|
||||
if _, err := os.Stat(filepath.Join(stateRoot, "data")); err != nil {
|
||||
t.Fatalf("data 目录未创建: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读取文件失败 %s: %v", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("文件内容不符合预期 %s: got=%q want=%q", path, string(data), want)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,49 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxProfileLimit = 20
|
||||
StandardCDKeyProfileBonus = 10
|
||||
GithubStarRewardKey = "GITHUB_STAR_REWARD"
|
||||
GithubStarProfileBonus = 50
|
||||
GithubStarProfileTotal = DefaultMaxProfileLimit + GithubStarProfileBonus
|
||||
DefaultLaunchServerPort = 19876
|
||||
)
|
||||
|
||||
// RewardForUsedKey 返回指定兑换记录对应的永久额度奖励。
|
||||
func RewardForUsedKey(key string) int {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(key))
|
||||
if normalized == "" {
|
||||
return 0
|
||||
}
|
||||
if normalized == GithubStarRewardKey {
|
||||
return GithubStarProfileBonus
|
||||
}
|
||||
return StandardCDKeyProfileBonus
|
||||
}
|
||||
|
||||
// MinimumProfileLimitForUsedKeys 根据兑换记录计算最低应得实例额度。
|
||||
func MinimumProfileLimitForUsedKeys(keys []string) int {
|
||||
limit := DefaultMaxProfileLimit
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(key))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
limit += RewardForUsedKey(normalized)
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// LaunchServerConfig Launch HTTP 服务配置
|
||||
type LaunchServerConfig struct {
|
||||
// Port <= 0 时自动分配随机可用端口(推荐)。
|
||||
// Port 为对外暴露的固定入口端口。
|
||||
// Launch API 与 CDP 代理共用此端口,便于外部工具固定接入。
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
@@ -54,7 +94,7 @@ type WindowConfig struct {
|
||||
|
||||
// RuntimeConfig 运行时配置
|
||||
type RuntimeConfig struct {
|
||||
MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存限制(MB)
|
||||
MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存软限制(MB),0 表示禁用
|
||||
GCPercent int `yaml:"gc_percent"` // GC 触发百分比
|
||||
}
|
||||
|
||||
@@ -123,18 +163,22 @@ type BrowserEnvironment struct {
|
||||
}
|
||||
|
||||
type BrowserProfileConfig struct {
|
||||
ProfileId string `yaml:"profile_id" json:"profileId"`
|
||||
ProfileName string `yaml:"profile_name" json:"profileName"`
|
||||
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
|
||||
CoreId string `yaml:"core_id" json:"coreId"`
|
||||
FingerprintArgs []string `yaml:"fingerprint_args" json:"fingerprintArgs"`
|
||||
ProxyId string `yaml:"proxy_id" json:"proxyId"`
|
||||
ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"`
|
||||
LaunchArgs []string `yaml:"launch_args" json:"launchArgs"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"`
|
||||
CreatedAt string `yaml:"created_at" json:"createdAt"`
|
||||
UpdatedAt string `yaml:"updated_at" json:"updatedAt"`
|
||||
ProfileId string `yaml:"profile_id" json:"profileId"`
|
||||
ProfileName string `yaml:"profile_name" json:"profileName"`
|
||||
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
|
||||
CoreId string `yaml:"core_id" json:"coreId"`
|
||||
FingerprintArgs []string `yaml:"fingerprint_args" json:"fingerprintArgs"`
|
||||
ProxyId string `yaml:"proxy_id" json:"proxyId"`
|
||||
ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"`
|
||||
ProxyBindSourceID string `yaml:"proxy_bind_source_id,omitempty" json:"proxyBindSourceId,omitempty"`
|
||||
ProxyBindSourceURL string `yaml:"proxy_bind_source_url,omitempty" json:"proxyBindSourceUrl,omitempty"`
|
||||
ProxyBindName string `yaml:"proxy_bind_name,omitempty" json:"proxyBindName,omitempty"`
|
||||
ProxyBindUpdatedAt string `yaml:"proxy_bind_updated_at,omitempty" json:"proxyBindUpdatedAt,omitempty"`
|
||||
LaunchArgs []string `yaml:"launch_args" json:"launchArgs"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"`
|
||||
CreatedAt string `yaml:"created_at" json:"createdAt"`
|
||||
UpdatedAt string `yaml:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// LoggingConfig 日志配置
|
||||
@@ -223,17 +267,9 @@ func normalizeConfig(config *Config) {
|
||||
config.App.UsedCDKeys = []string{}
|
||||
}
|
||||
|
||||
// 兼容老版本配置: 如果之前没有 max_profile_limit,它会被解析成 0,
|
||||
// 若用户在 0 状态下兑换了额度(比如 0+3=3),基础的 3 额度会被覆盖。
|
||||
// 这里通过统计兑换记录重新保底验证它的额度即可修复。
|
||||
expectedLimit := defaultConfig.App.MaxProfileLimit
|
||||
for _, k := range config.App.UsedCDKeys {
|
||||
if k == "GITHUB_STAR_REWARD" {
|
||||
expectedLimit += 3
|
||||
} else {
|
||||
expectedLimit += 3
|
||||
}
|
||||
}
|
||||
// 兼容老版本/损坏配置:若 max_profile_limit 缺失或被写成过小值,
|
||||
// 通过兑换记录重新计算最低应得额度,避免基础额度或奖励额度丢失。
|
||||
expectedLimit := MinimumProfileLimitForUsedKeys(config.App.UsedCDKeys)
|
||||
if config.App.MaxProfileLimit < expectedLimit {
|
||||
config.App.MaxProfileLimit = expectedLimit
|
||||
}
|
||||
@@ -308,7 +344,7 @@ func normalizeConfig(config *Config) {
|
||||
config.Browser.Profiles = []BrowserProfileConfig{}
|
||||
}
|
||||
|
||||
if config.LaunchServer.Port < 0 {
|
||||
if config.LaunchServer.Port <= 0 {
|
||||
config.LaunchServer.Port = defaultConfig.LaunchServer.Port
|
||||
}
|
||||
}
|
||||
@@ -340,12 +376,12 @@ func DefaultConfig() *Config {
|
||||
MinWidth: 1200,
|
||||
MinHeight: 700,
|
||||
},
|
||||
MaxProfileLimit: 3,
|
||||
MaxProfileLimit: DefaultMaxProfileLimit,
|
||||
UsedCDKeys: []string{},
|
||||
},
|
||||
Runtime: RuntimeConfig{
|
||||
MaxMemoryMB: 1024, // 默认 1GB
|
||||
GCPercent: 100, // 默认 100%
|
||||
MaxMemoryMB: 0, // 默认禁用软限制,避免把运行中的前后端直接顶死
|
||||
GCPercent: 100, // 默认 100%
|
||||
},
|
||||
Browser: BrowserConfig{
|
||||
UserDataRoot: "data",
|
||||
@@ -376,7 +412,7 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
},
|
||||
LaunchServer: LaunchServerConfig{
|
||||
Port: 0,
|
||||
Port: DefaultLaunchServerPort,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -388,6 +424,9 @@ func (c *Config) Save(configPath string) error {
|
||||
return fmt.Errorf("序列化配置失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
return fmt.Errorf("创建配置目录失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||
}
|
||||
@@ -423,6 +462,9 @@ func SaveProxies(path string, proxies []BrowserProxy) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化代理数据失败: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("创建代理目录失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("写入代理文件失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsUsingCodeAsKeywordFallbackPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-params-code-fuzzy",
|
||||
ProfileName: "automation-fuzzy",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 655,
|
||||
DebugPort: 9667,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-params-code-exact",
|
||||
ProfileName: "automation-exact",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 656,
|
||||
DebugPort: 9668,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
body := map[string]interface{}{
|
||||
"code": "buyer-001",
|
||||
"launchArgs": []string{"--window-size=1280,800", "--lang=en-US"},
|
||||
"startUrls": []string{"https://example.com"},
|
||||
"skipDefaultStartUrls": true,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsBadRequest(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
)
|
||||
|
||||
func mustDebugPortFromURL(t *testing.T, rawURL string) int {
|
||||
t.Helper()
|
||||
|
||||
hostPort := strings.TrimPrefix(rawURL, "http://")
|
||||
host, portText, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试 URL 失败: %v", err)
|
||||
}
|
||||
if host == "" {
|
||||
t.Fatalf("测试 URL host 为空: %s", rawURL)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试端口失败: %v", err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func TestCDPProxyReturnsUnavailableWithoutActiveTarget(t *testing.T) {
|
||||
handler := buildTestHandler(newInMemoryService(), newMockStarter())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/json/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp["error"] != "no active browser debug target" {
|
||||
t.Fatalf("错误信息不正确: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPProxySwitchesToLatestLaunchedProfile(t *testing.T) {
|
||||
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"Browser":"Mock-A"}`))
|
||||
}))
|
||||
defer serverA.Close()
|
||||
|
||||
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"Browser":"Mock-B"}`))
|
||||
}))
|
||||
defer serverB.Close()
|
||||
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Profile A",
|
||||
Pid: 1001,
|
||||
DebugPort: mustDebugPortFromURL(t, serverA.URL),
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Profile B",
|
||||
Pid: 1002,
|
||||
DebugPort: mustDebugPortFromURL(t, serverB.URL),
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
|
||||
codeA, err := svc.EnsureCode(profileA.ProfileId)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode(A) 失败: %v", err)
|
||||
}
|
||||
codeB, err := svc.EnsureCode(profileB.ProfileId)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode(B) 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
for _, tc := range []struct {
|
||||
code string
|
||||
wantMarker string
|
||||
}{
|
||||
{code: codeA, wantMarker: "Mock-A"},
|
||||
{code: codeB, wantMarker: "Mock-B"},
|
||||
} {
|
||||
launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+tc.code, nil)
|
||||
launchResp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(launchResp, launchReq)
|
||||
if launchResp.Code != http.StatusOK {
|
||||
t.Fatalf("启动请求失败: code=%s status=%d body=%s", tc.code, launchResp.Code, launchResp.Body.String())
|
||||
}
|
||||
|
||||
proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil)
|
||||
proxyResp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(proxyResp, proxyReq)
|
||||
if proxyResp.Code != http.StatusOK {
|
||||
t.Fatalf("代理请求失败: code=%s status=%d body=%s", tc.code, proxyResp.Code, proxyResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(proxyResp.Body.String(), tc.wantMarker) {
|
||||
t.Fatalf("代理未切换到最新实例: want=%s body=%s", tc.wantMarker, proxyResp.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
func buildTestHandlerWithManager(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter, mgr *browser.Manager) http.Handler {
|
||||
srv := launchcode.NewLaunchServer(svc, starter, mgr, 0)
|
||||
return launchcode.NewTestHandler(srv)
|
||||
}
|
||||
|
||||
func newSelectorTestManager(profiles ...*browser.Profile) *browser.Manager {
|
||||
items := make(map[string]*browser.Profile, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
items[profile.ProfileId] = profile
|
||||
}
|
||||
return &browser.Manager{
|
||||
Profiles: items,
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithKeywordSelector(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-keyword",
|
||||
ProfileName: "Amazon US",
|
||||
GroupId: "group-sales",
|
||||
Tags: []string{"电商", "北美"},
|
||||
Keywords: []string{"amazon-us", "checkout", "buyer-account"},
|
||||
Pid: 9527,
|
||||
DebugPort: 9333,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
payload := bytes.NewBufferString(`{
|
||||
"selector": {
|
||||
"keyword": "checkout",
|
||||
"tags": ["电商"],
|
||||
"groupId": "group-sales"
|
||||
},
|
||||
"skipDefaultStartUrls": true
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.ProfileID != profile.ProfileId {
|
||||
t.Fatalf("响应不正确: %+v", resp)
|
||||
}
|
||||
if strings.TrimSpace(resp.LaunchCode) == "" {
|
||||
t.Fatalf("期望返回 resolved launchCode,实际为空: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelKeywordSelector(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-top-level",
|
||||
ProfileName: "Billing Ops",
|
||||
Keywords: []string{"billing", "invoice"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9444,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"keyword":"billing"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelKeyAliasSelector(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-top-level-key",
|
||||
ProfileName: "Buyer Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9445,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelKeyPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-key-fuzzy",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 1004,
|
||||
DebugPort: 9447,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-key-exact",
|
||||
ProfileName: "Z Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1005,
|
||||
DebugPort: 9448,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithNestedSelectorKeyPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-selector-key-fuzzy",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 1006,
|
||||
DebugPort: 9449,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-selector-key-exact",
|
||||
ProfileName: "Z Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1007,
|
||||
DebugPort: 9450,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"key":"buyer-001"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("selector.key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackToKeyword(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-top-level-code-fallback",
|
||||
ProfileName: "Buyer Account 01",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1003,
|
||||
DebugPort: 9446,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-code-fuzzy",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 1008,
|
||||
DebugPort: 9451,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-code-exact",
|
||||
ProfileName: "Z Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1009,
|
||||
DebugPort: 9452,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithAmbiguousKeywordSelectorReturnsFirstByDefault(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("关键字多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "matchMode=first") {
|
||||
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-get-code-only",
|
||||
ProfileName: "Buyer Account 02",
|
||||
Keywords: []string{"buyer-002"},
|
||||
Pid: 1004,
|
||||
DebugPort: 9447,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeFirst(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 2 {
|
||||
t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
|
||||
}
|
||||
if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
|
||||
t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
IsActive bool `json:"isActive"`
|
||||
} `json:"items"`
|
||||
ActiveProfileID string `json:"activeProfileId"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("批量启动响应错误: %+v", resp)
|
||||
}
|
||||
if resp.ActiveProfileID != profileB.ProfileId {
|
||||
t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
|
||||
}
|
||||
if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
|
||||
t.Fatalf("items 顺序错误: %+v", resp.Items)
|
||||
}
|
||||
if resp.Items[0].IsActive || !resp.Items[1].IsActive {
|
||||
t.Fatalf("isActive 标记错误: %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 0 {
|
||||
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
|
||||
}
|
||||
}
|
||||
+68
-6
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,248 +1,22 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
setlocal EnableExtensions
|
||||
|
||||
REM 切换到项目根目录(脚本所在目录的上一级)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ========================================
|
||||
echo Ant Browser - 发布打包脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 当前工作目录: %CD%
|
||||
echo.
|
||||
|
||||
REM ======== [1/6] 检测 NSIS ========
|
||||
echo [1/6] 检测 NSIS 安装...
|
||||
echo 支持环境变量:MAKENSIS_PATH / NSIS_PATH / NSIS_HOME
|
||||
echo.
|
||||
|
||||
set "MAKENSIS="
|
||||
|
||||
REM 优先级 1:MAKENSIS_PATH 直接指向 makensis.exe
|
||||
if defined MAKENSIS_PATH (
|
||||
if exist "!MAKENSIS_PATH!" (
|
||||
set "MAKENSIS=!MAKENSIS_PATH!"
|
||||
goto :nsis_found
|
||||
)
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if not exist "%SCRIPT_DIR%publish.ps1" (
|
||||
echo [ERROR] Missing bat\publish.ps1
|
||||
if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause
|
||||
endlocal & exit /b 1
|
||||
)
|
||||
|
||||
REM 优先级 2:NSIS_PATH 可以是 makensis.exe 或 NSIS 目录
|
||||
if defined NSIS_PATH (
|
||||
if exist "!NSIS_PATH!\makensis.exe" (
|
||||
set "MAKENSIS=!NSIS_PATH!\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
if exist "!NSIS_PATH!" (
|
||||
set "MAKENSIS=!NSIS_PATH!"
|
||||
goto :nsis_found
|
||||
)
|
||||
)
|
||||
|
||||
REM 优先级 3:NSIS_HOME 为 NSIS 安装根目录
|
||||
if defined NSIS_HOME (
|
||||
if exist "!NSIS_HOME!\makensis.exe" (
|
||||
set "MAKENSIS=!NSIS_HOME!\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
)
|
||||
|
||||
REM 优先级 4:系统 PATH
|
||||
for /f "delims=" %%i in ('where makensis.exe 2^>nul') do (
|
||||
set "MAKENSIS=%%i"
|
||||
goto :nsis_found
|
||||
)
|
||||
|
||||
REM 优先级 5:常见安装目录
|
||||
if exist "C:\Program Files (x86)\NSIS\makensis.exe" (
|
||||
set "MAKENSIS=C:\Program Files (x86)\NSIS\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
if exist "C:\Program Files\NSIS\makensis.exe" (
|
||||
set "MAKENSIS=C:\Program Files\NSIS\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
|
||||
echo ✗ 未找到 NSIS(makensis.exe)
|
||||
echo.
|
||||
echo 请安装 NSIS 后,通过以下任一方式配置(PowerShell):
|
||||
echo setx MAKENSIS_PATH "D:\tools\NSIS\makensis.exe"
|
||||
echo setx NSIS_PATH "D:\tools\NSIS"
|
||||
echo setx NSIS_HOME "D:\tools\NSIS"
|
||||
echo.
|
||||
echo 或下载安装:https://nsis.sourceforge.io/Download
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:nsis_found
|
||||
echo ✓ NSIS 已就绪: !MAKENSIS!
|
||||
echo.
|
||||
|
||||
REM ======== [2/6] 读取版本号 ========
|
||||
echo [2/6] 读取版本号...
|
||||
|
||||
set "VERSION="
|
||||
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content wails.json | ConvertFrom-Json).info.productVersion"`) do (
|
||||
set "VERSION=%%v"
|
||||
)
|
||||
|
||||
if "!VERSION!"=="" (
|
||||
echo ✗ 无法从 wails.json 读取版本号
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 版本号: !VERSION!
|
||||
echo.
|
||||
|
||||
REM ======== [3/6] Wails 构建 ========
|
||||
echo [3/6] 执行 Wails 构建...
|
||||
|
||||
set GOPROXY=https://goproxy.cn,direct
|
||||
wails build
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ Wails 构建失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "build\bin\ant-chrome.exe" (
|
||||
echo ✗ 构建产物不存在: build\bin\ant-chrome.exe
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 构建成功: build\bin\ant-chrome.exe
|
||||
echo.
|
||||
|
||||
REM ======== [4/6] 组装 staging 目录 ========
|
||||
echo [4/6] 组装 staging 目录...
|
||||
|
||||
set "STAGING=publish\staging"
|
||||
set "RELEASE_CONFIG=publish\config.init.yaml"
|
||||
|
||||
if exist "!STAGING!" rmdir /S /Q "!STAGING!"
|
||||
mkdir "!STAGING!"
|
||||
|
||||
copy /Y "build\bin\ant-chrome.exe" "!STAGING!\ant-chrome.exe" >nul
|
||||
if errorlevel 1 (
|
||||
echo ✗ 复制 ant-chrome.exe 失败
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "!STAGING!\ant-chrome.exe" (
|
||||
echo ✗ staging 中缺少 ant-chrome.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 复制 ant-chrome.exe
|
||||
|
||||
if not exist "!RELEASE_CONFIG!" (
|
||||
echo ✗ 未找到发布配置模板: !RELEASE_CONFIG!
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
copy /Y "!RELEASE_CONFIG!" "!STAGING!\config.yaml" >nul
|
||||
if errorlevel 1 (
|
||||
echo ✗ 复制发布配置模板失败: !RELEASE_CONFIG!
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "!STAGING!\config.yaml" (
|
||||
echo ✗ staging 中缺少 config.yaml(来源: !RELEASE_CONFIG!)
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 复制发布配置模板 !RELEASE_CONFIG! -> config.yaml
|
||||
|
||||
if not exist "bin" (
|
||||
echo ✗ bin\ 目录不存在,缺少代理运行时文件
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "bin\xray.exe" (
|
||||
echo ✗ 缺少运行时文件: bin\xray.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "bin\sing-box.exe" (
|
||||
echo ✗ 缺少运行时文件: bin\sing-box.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
xcopy /E /I /Y bin "!STAGING!\bin" >nul
|
||||
if not exist "!STAGING!\bin\xray.exe" (
|
||||
echo ✗ 复制后仍缺少 !STAGING!\bin\xray.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "!STAGING!\bin\sing-box.exe" (
|
||||
echo ✗ 复制后仍缺少 !STAGING!\bin\sing-box.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 复制 bin\(xray.exe, sing-box.exe)
|
||||
|
||||
if not exist "chrome" (
|
||||
echo ✗ chrome\ 目录不存在,Chrome 内核为必需文件
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
xcopy /E /I /Y chrome "!STAGING!\chrome" >nul
|
||||
echo ✓ 复制 chrome\
|
||||
|
||||
mkdir "!STAGING!\data"
|
||||
echo ✓ 创建空 data 目录(不打包 app.db,首次启动自动初始化)
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish.ps1" %*
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
echo ✓ staging 目录组装完成
|
||||
echo.
|
||||
|
||||
REM ======== [5/6] NSIS 打包 ========
|
||||
echo [5/6] 调用 NSIS 打包...
|
||||
|
||||
if not exist "publish\output" mkdir "publish\output"
|
||||
|
||||
REM 确保 installer.nsi 是 UTF-8 with BOM(NSIS Unicode True 要求)
|
||||
powershell -NoProfile -Command "$f=(Resolve-Path 'publish\installer.nsi').Path; $c=[System.IO.File]::ReadAllText($f,[System.Text.Encoding]::UTF8); [System.IO.File]::WriteAllText($f,$c,[System.Text.UTF8Encoding]::new($true))" >nul
|
||||
|
||||
for /f "usebackq delims=" %%p in (`powershell -NoProfile -Command "(Resolve-Path '!STAGING!').Path"`) do (
|
||||
set "STAGING_ABS=%%p"
|
||||
if "%EXIT_CODE%"=="0" (
|
||||
echo Publish finished successfully.
|
||||
) else (
|
||||
echo Publish failed with exit code %EXIT_CODE%.
|
||||
)
|
||||
|
||||
"!MAKENSIS!" /DVERSION=!VERSION! "/DSTAGINGDIR=!STAGING_ABS!" publish\installer.nsi
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ NSIS 打包失败
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 安装包生成成功
|
||||
echo.
|
||||
if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause
|
||||
|
||||
REM ======== [6/6] 清理 staging ========
|
||||
echo [6/6] 清理临时文件...
|
||||
rmdir /S /Q "!STAGING!"
|
||||
echo ✓ staging 目录已清理
|
||||
echo.
|
||||
|
||||
echo ========================================
|
||||
echo ✓ 发布完成!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 安装包位置: publish\output\AntBrowser-Setup-!VERSION!.exe
|
||||
echo.
|
||||
echo 提示:用户安装后可将旧的 data\ 目录粘贴到安装目录覆盖初始数据
|
||||
echo.
|
||||
pause
|
||||
endlocal & exit /b %EXIT_CODE%
|
||||
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
param(
|
||||
[string]$Target,
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
Set-Location $repoRoot
|
||||
|
||||
$script:Version = ""
|
||||
$script:LinuxArch = ""
|
||||
$script:WindowsDone = $false
|
||||
$script:LinuxDone = $false
|
||||
|
||||
function Write-Section {
|
||||
param([string]$Text)
|
||||
|
||||
Write-Host "========================================"
|
||||
Write-Host " $Text"
|
||||
Write-Host "========================================"
|
||||
}
|
||||
|
||||
function Get-TrimmedText {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return ""
|
||||
}
|
||||
return $Value.Trim()
|
||||
}
|
||||
|
||||
function Assert-VersionValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Value,
|
||||
[string]$Source = "版本号"
|
||||
)
|
||||
|
||||
$trimmed = Get-TrimmedText $Value
|
||||
if ($trimmed -eq "") {
|
||||
throw "$Source 不能为空"
|
||||
}
|
||||
if ($trimmed -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?(?:\+[0-9A-Za-z\.-]+)?$') {
|
||||
throw "$Source 格式无效: $trimmed`n 期望示例: 1.1.0 或 1.1.0-beta.1"
|
||||
}
|
||||
return $trimmed
|
||||
}
|
||||
|
||||
function Invoke-NativeCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments = @()
|
||||
)
|
||||
|
||||
& $FilePath @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$argText = if ($Arguments.Count -gt 0) { " $($Arguments -join ' ')" } else { "" }
|
||||
throw "$FilePath$argText failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RequiredSourceFiles {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Action,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Paths
|
||||
)
|
||||
|
||||
$missing = @()
|
||||
foreach ($relativePath in $Paths) {
|
||||
$fullPath = Join-Path $repoRoot $relativePath
|
||||
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
||||
$missing += $relativePath
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
throw "$Action requires a complete source tree. Missing files: $($missing -join ', ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-Version {
|
||||
param([string]$ExplicitVersion)
|
||||
|
||||
$explicit = Get-TrimmedText $ExplicitVersion
|
||||
if ($explicit -ne "") {
|
||||
$script:Version = Assert-VersionValue -Value $explicit -Source "传入版本号"
|
||||
Write-Host "[1/3] 使用传入版本号..."
|
||||
Write-Host "✓ 版本号: $script:Version"
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "[1/3] 读取版本号..."
|
||||
$wailsConfigPath = Join-Path $repoRoot "wails.json"
|
||||
if (-not (Test-Path -LiteralPath $wailsConfigPath -PathType Leaf)) {
|
||||
throw "无法读取版本号:缺少 wails.json"
|
||||
}
|
||||
|
||||
$wailsConfig = Get-Content -LiteralPath $wailsConfigPath -Raw | ConvertFrom-Json
|
||||
$resolvedVersion = Get-TrimmedText ([string]$wailsConfig.info.productVersion)
|
||||
if ($resolvedVersion -eq "") {
|
||||
throw "无法从 wails.json 读取版本号"
|
||||
}
|
||||
|
||||
$script:Version = Assert-VersionValue -Value $resolvedVersion -Source "wails.json productVersion"
|
||||
Write-Host "✓ 版本号: $script:Version"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Invoke-WithTemporaryWailsVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[scriptblock]$ScriptBlock
|
||||
)
|
||||
|
||||
$wailsConfigPath = Join-Path $repoRoot "wails.json"
|
||||
if (-not (Test-Path -LiteralPath $wailsConfigPath -PathType Leaf)) {
|
||||
& $ScriptBlock
|
||||
return
|
||||
}
|
||||
|
||||
$currentConfig = Get-Content -LiteralPath $wailsConfigPath -Raw | ConvertFrom-Json
|
||||
$currentVersion = Get-TrimmedText ([string]$currentConfig.info.productVersion)
|
||||
if ($currentVersion -eq $script:Version) {
|
||||
& $ScriptBlock
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host " 临时覆盖 wails.json productVersion: $currentVersion -> $script:Version"
|
||||
$originalBytes = [System.IO.File]::ReadAllBytes($wailsConfigPath)
|
||||
try {
|
||||
$currentConfig.info.productVersion = $script:Version
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
$jsonText = ($currentConfig | ConvertTo-Json -Depth 100)
|
||||
[System.IO.File]::WriteAllText($wailsConfigPath, $jsonText + "`n", $utf8NoBom)
|
||||
& $ScriptBlock
|
||||
}
|
||||
finally {
|
||||
[System.IO.File]::WriteAllBytes($wailsConfigPath, $originalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-PublishTarget {
|
||||
param([string]$InputTarget)
|
||||
|
||||
$normalized = (Get-TrimmedText $InputTarget).ToUpperInvariant()
|
||||
$mapping = @{
|
||||
"W" = "WINDOWS"
|
||||
"WINDOWS" = "WINDOWS"
|
||||
"L" = "LINUX"
|
||||
"LINUX" = "LINUX"
|
||||
"B" = "BOTH"
|
||||
"BOTH" = "BOTH"
|
||||
}
|
||||
|
||||
if ($normalized -ne "") {
|
||||
if (-not $mapping.ContainsKey($normalized)) {
|
||||
throw "无效的打包目标: $InputTarget`n 支持参数: W/L/B 或 WINDOWS/LINUX/BOTH"
|
||||
}
|
||||
$resolvedTarget = $mapping[$normalized]
|
||||
Write-Host "[2/3] 使用预设打包目标: $resolvedTarget"
|
||||
Write-Host ""
|
||||
return $resolvedTarget
|
||||
}
|
||||
|
||||
Write-Host "[2/3] 选择打包平台..."
|
||||
Write-Host ""
|
||||
Write-Host " [W] Windows"
|
||||
Write-Host " [L] Linux(通过 Docker Desktop 执行)"
|
||||
Write-Host " [B] Windows + Linux"
|
||||
Write-Host ""
|
||||
|
||||
while ($true) {
|
||||
$choice = (Read-Host "请选择打包目标 [W/L/B]").Trim().ToUpperInvariant()
|
||||
if ($choice -eq "") {
|
||||
continue
|
||||
}
|
||||
if ($mapping.ContainsKey($choice)) {
|
||||
$resolvedTarget = $mapping[$choice]
|
||||
Write-Host "✓ 已选择: $resolvedTarget"
|
||||
Write-Host ""
|
||||
return $resolvedTarget
|
||||
}
|
||||
Write-Host "✗ 未选择有效目标" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-NsisPath {
|
||||
Write-Host "[Windows] 检测 NSIS 安装..."
|
||||
Write-Host " 支持环境变量:MAKENSIS_PATH / NSIS_PATH / NSIS_HOME"
|
||||
Write-Host ""
|
||||
|
||||
$candidates = @()
|
||||
if ($env:MAKENSIS_PATH) {
|
||||
$candidates += $env:MAKENSIS_PATH
|
||||
}
|
||||
if ($env:NSIS_PATH) {
|
||||
$candidates += (Join-Path $env:NSIS_PATH "makensis.exe")
|
||||
$candidates += $env:NSIS_PATH
|
||||
}
|
||||
if ($env:NSIS_HOME) {
|
||||
$candidates += (Join-Path $env:NSIS_HOME "makensis.exe")
|
||||
}
|
||||
|
||||
$whereMakensis = Get-Command makensis.exe -ErrorAction SilentlyContinue
|
||||
if ($whereMakensis) {
|
||||
$candidates += $whereMakensis.Source
|
||||
}
|
||||
|
||||
$candidates += @(
|
||||
"C:\Program Files (x86)\NSIS\makensis.exe",
|
||||
"C:\Program Files\NSIS\makensis.exe"
|
||||
)
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
$value = Get-TrimmedText $candidate
|
||||
if ($value -eq "") {
|
||||
continue
|
||||
}
|
||||
if (Test-Path -LiteralPath $value -PathType Leaf) {
|
||||
Write-Host "✓ NSIS 已就绪: $value"
|
||||
Write-Host ""
|
||||
return $value
|
||||
}
|
||||
}
|
||||
|
||||
throw "未找到 NSIS(makensis.exe)`n`n 请安装 NSIS 后,通过以下任一方式配置(PowerShell):`n setx MAKENSIS_PATH ""D:\tools\NSIS\makensis.exe""`n setx NSIS_PATH ""D:\tools\NSIS""`n setx NSIS_HOME ""D:\tools\NSIS""`n`n 或下载安装:https://nsis.sourceforge.io/Download"
|
||||
}
|
||||
|
||||
function Build-WindowsBinary {
|
||||
Write-Host "[Windows] 执行 Wails 构建..."
|
||||
$binaryPath = Join-Path $repoRoot "build/bin/ant-chrome.exe"
|
||||
Assert-RequiredSourceFiles -Action "Windows packaging" -Paths @(
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"main.go",
|
||||
"wails.json"
|
||||
)
|
||||
|
||||
$previousGoProxy = $env:GOPROXY
|
||||
try {
|
||||
$env:GOPROXY = "https://goproxy.cn,direct"
|
||||
Push-Location (Join-Path $repoRoot "frontend")
|
||||
try {
|
||||
Write-Host "[Windows] 预检前端依赖..."
|
||||
Invoke-NativeCommand -FilePath "npm" -Arguments @("ci", "--prefer-offline", "--no-audit", "--no-fund")
|
||||
Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "ensure:native")
|
||||
Write-Host "✓ 前端依赖已就绪"
|
||||
Write-Host ""
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
Invoke-NativeCommand -FilePath "wails" -Arguments @("build")
|
||||
}
|
||||
finally {
|
||||
$env:GOPROXY = $previousGoProxy
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)) {
|
||||
throw "构建产物不存在: build\bin\ant-chrome.exe"
|
||||
}
|
||||
Write-Host "✓ 构建成功: build\bin\ant-chrome.exe"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Assert-RuntimeHashes {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Target
|
||||
)
|
||||
|
||||
$manifestPath = Join-Path $repoRoot "publish/runtime-manifest.json"
|
||||
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
|
||||
throw "缺少运行时清单: publish\runtime-manifest.json"
|
||||
}
|
||||
|
||||
Write-Host "[Windows] 校验运行时哈希..."
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
$entries = @($manifest.files | Where-Object { $_.targets -contains $Target })
|
||||
if ($entries.Count -eq 0) {
|
||||
throw "运行时清单中不存在目标平台: $Target"
|
||||
}
|
||||
|
||||
$errors = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($entry in $entries) {
|
||||
$relativePath = Get-TrimmedText ([string]$entry.path)
|
||||
$expectedHash = (Get-TrimmedText ([string]$entry.sha256)).ToLowerInvariant()
|
||||
|
||||
if ($relativePath -eq "") {
|
||||
$errors.Add("manifest entry has empty path")
|
||||
continue
|
||||
}
|
||||
if ($expectedHash -eq "" -or $expectedHash.Contains("todo_replace_with_sha256")) {
|
||||
$errors.Add("$relativePath: sha256 is not initialized")
|
||||
continue
|
||||
}
|
||||
|
||||
$fullPath = Join-Path $repoRoot ($relativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
||||
$errors.Add("$relativePath: file not found")
|
||||
continue
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
$errors.Add("$relativePath: sha256 mismatch (expected $expectedHash, got $actualHash)")
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors.Count -gt 0) {
|
||||
throw "运行时哈希校验失败:`n - $($errors -join "`n - ")"
|
||||
}
|
||||
|
||||
Write-Host "✓ 运行时哈希校验通过: $Target"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Test-PeExecutable {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($FilePath)
|
||||
try {
|
||||
if ($stream.Length -lt 2) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$header = New-Object byte[] 2
|
||||
$read = $stream.Read($header, 0, $header.Length)
|
||||
return ($read -eq 2 -and $header[0] -eq 0x4D -and $header[1] -eq 0x5A)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-DirectoryContents {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SourceDir,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DestinationDir
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourceDir -PathType Container)) {
|
||||
return
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DestinationDir -Force | Out-Null
|
||||
|
||||
foreach ($entry in (Get-ChildItem -LiteralPath $SourceDir -Force)) {
|
||||
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationDir $entry.Name) -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-WindowsChromePayload {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ChromeRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StagingDir
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ChromeRoot -PathType Container)) {
|
||||
Write-Host "[WARN] 缺少 chrome\ 目录,Windows 安装包将不包含浏览器内核"
|
||||
return
|
||||
}
|
||||
|
||||
$stagingChromeDir = Join-Path $StagingDir "chrome"
|
||||
$chromeReadme = Join-Path $ChromeRoot "README.md"
|
||||
$copiedCores = @()
|
||||
$rootExecutable = Join-Path $ChromeRoot "chrome.exe"
|
||||
|
||||
if (Test-PeExecutable -FilePath $rootExecutable) {
|
||||
Copy-DirectoryContents -SourceDir $ChromeRoot -DestinationDir $stagingChromeDir
|
||||
$copiedCores += "chrome\"
|
||||
}
|
||||
else {
|
||||
if (Test-Path -LiteralPath $chromeReadme -PathType Leaf) {
|
||||
New-Item -ItemType Directory -Path $stagingChromeDir -Force | Out-Null
|
||||
Copy-Item -LiteralPath $chromeReadme -Destination (Join-Path $stagingChromeDir "README.md") -Force
|
||||
}
|
||||
|
||||
foreach ($entry in (Get-ChildItem -LiteralPath $ChromeRoot -Force)) {
|
||||
if (-not $entry.PSIsContainer) {
|
||||
continue
|
||||
}
|
||||
|
||||
$candidateExe = Join-Path $entry.FullName "chrome.exe"
|
||||
if (-not (Test-PeExecutable -FilePath $candidateExe)) {
|
||||
continue
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $stagingChromeDir -Force | Out-Null
|
||||
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $stagingChromeDir $entry.Name) -Recurse -Force
|
||||
$copiedCores += $entry.Name
|
||||
}
|
||||
}
|
||||
|
||||
if ($copiedCores.Count -gt 0) {
|
||||
Write-Host ("✓ 自动打包 Windows 内核: {0}" -f ($copiedCores -join ", "))
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $chromeReadme -PathType Leaf) {
|
||||
Write-Host "✓ 保留 chrome\README.md(未发现可打包的 Windows 内核)"
|
||||
}
|
||||
else {
|
||||
Write-Host "[WARN] 未发现可打包的 Windows 内核,且缺少 chrome\README.md"
|
||||
}
|
||||
}
|
||||
|
||||
function New-WindowsStaging {
|
||||
Write-Host "[Windows] 组装 staging 目录..."
|
||||
|
||||
$stagingDir = Join-Path $repoRoot "publish/staging"
|
||||
$releaseConfig = Join-Path $repoRoot "publish/config.init.yaml"
|
||||
$binaryPath = Join-Path $repoRoot "build/bin/ant-chrome.exe"
|
||||
$binDir = Join-Path $repoRoot "bin"
|
||||
$chromeRoot = Join-Path $repoRoot "chrome"
|
||||
|
||||
if (Test-Path -LiteralPath $stagingDir) {
|
||||
Remove-Item -LiteralPath $stagingDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
|
||||
|
||||
Copy-Item -LiteralPath $binaryPath -Destination (Join-Path $stagingDir "ant-chrome.exe") -Force
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $stagingDir "ant-chrome.exe") -PathType Leaf)) {
|
||||
throw "staging 中缺少 ant-chrome.exe"
|
||||
}
|
||||
Write-Host "✓ 复制 ant-chrome.exe"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $releaseConfig -PathType Leaf)) {
|
||||
throw "未找到发布配置模板: publish\config.init.yaml"
|
||||
}
|
||||
Copy-Item -LiteralPath $releaseConfig -Destination (Join-Path $stagingDir "config.yaml") -Force
|
||||
Write-Host "✓ 复制发布配置模板 publish\config.init.yaml -> config.yaml"
|
||||
|
||||
$stagingBinDir = Join-Path $stagingDir "bin"
|
||||
New-Item -ItemType Directory -Path $stagingBinDir -Force | Out-Null
|
||||
|
||||
foreach ($required in @("xray.exe", "sing-box.exe")) {
|
||||
$source = Join-Path $binDir $required
|
||||
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||
throw "缺少运行时文件: bin\$required"
|
||||
}
|
||||
Copy-Item -LiteralPath $source -Destination (Join-Path $stagingBinDir $required) -Force
|
||||
}
|
||||
Write-Host "✓ 复制 bin\(xray.exe, sing-box.exe)"
|
||||
|
||||
Copy-WindowsChromePayload -ChromeRoot $chromeRoot -StagingDir $stagingDir
|
||||
|
||||
New-Item -ItemType Directory -Path (Join-Path $stagingDir "data") -Force | Out-Null
|
||||
Write-Host "✓ 创建空 data 目录(不打包 app.db,首次启动自动初始化)"
|
||||
Write-Host ""
|
||||
Write-Host "✓ staging 目录组装完成"
|
||||
Write-Host ""
|
||||
|
||||
return $stagingDir
|
||||
}
|
||||
|
||||
function Invoke-WindowsPackaging {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$MakensisPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StagingDir
|
||||
)
|
||||
|
||||
Write-Host "[Windows] 调用 NSIS 打包..."
|
||||
$outputDir = Join-Path $repoRoot "publish/output"
|
||||
if (-not (Test-Path -LiteralPath $outputDir)) {
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$installerPath = Join-Path $repoRoot "publish/installer.nsi"
|
||||
$installerContent = [System.IO.File]::ReadAllText($installerPath, [System.Text.Encoding]::UTF8)
|
||||
[System.IO.File]::WriteAllText($installerPath, $installerContent, [System.Text.UTF8Encoding]::new($true))
|
||||
|
||||
$stagingAbs = (Resolve-Path $StagingDir).Path
|
||||
$compressionMode = if ($env:ANT_BROWSER_PUBLISH_BEST_COMPRESSION -eq "1") { "best" } else { "fast" }
|
||||
$useNsisConfig = ($env:ANT_BROWSER_NSIS_USE_CONFIG -eq "1")
|
||||
Write-Host " 压缩模式: $compressionMode"
|
||||
if ($useNsisConfig) {
|
||||
Write-Host " NSIS 全局配置: enabled"
|
||||
}
|
||||
else {
|
||||
Write-Host " NSIS 全局配置: disabled (/NOCONFIG)"
|
||||
}
|
||||
$nsisArguments = @(
|
||||
"/DVERSION=$script:Version",
|
||||
"/DSTAGINGDIR=$stagingAbs",
|
||||
"publish\installer.nsi"
|
||||
)
|
||||
if (-not $useNsisConfig) {
|
||||
$nsisArguments = @("/NOCONFIG") + $nsisArguments
|
||||
}
|
||||
if ($compressionMode -eq "best") {
|
||||
$nsisArguments = @("/DBESTCOMPRESSION") + $nsisArguments
|
||||
}
|
||||
Invoke-NativeCommand -FilePath $MakensisPath -Arguments $nsisArguments
|
||||
|
||||
Write-Host "✓ Windows 安装包生成成功"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Remove-WindowsStaging {
|
||||
param([string]$StagingDir)
|
||||
|
||||
if ($StagingDir -and (Test-Path -LiteralPath $StagingDir)) {
|
||||
Write-Host "[Windows] 清理临时文件..."
|
||||
Remove-Item -LiteralPath $StagingDir -Recurse -Force
|
||||
Write-Host "✓ staging 目录已清理"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-Windows {
|
||||
Write-Host "[3/3] 开始 Windows 打包..."
|
||||
Write-Host ""
|
||||
|
||||
$makensisPath = Resolve-NsisPath
|
||||
Assert-RuntimeHashes -Target "windows-amd64"
|
||||
Build-WindowsBinary
|
||||
|
||||
$stagingDir = $null
|
||||
try {
|
||||
$stagingDir = New-WindowsStaging
|
||||
Invoke-WindowsPackaging -MakensisPath $makensisPath -StagingDir $stagingDir
|
||||
}
|
||||
finally {
|
||||
Remove-WindowsStaging -StagingDir $stagingDir
|
||||
}
|
||||
|
||||
$script:WindowsDone = $true
|
||||
Write-Host "✓ Windows 打包完成"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Publish-Linux {
|
||||
Write-Host "[3/3] 开始 Linux 打包..."
|
||||
Write-Host ""
|
||||
|
||||
$linuxScript = Join-Path $repoRoot "publish/linux/publish-linux-docker.ps1"
|
||||
$archOutFile = Join-Path $env:TEMP ("ant-browser-linux-arch-" + [guid]::NewGuid().ToString("N") + ".txt")
|
||||
if (Test-Path -LiteralPath $archOutFile) {
|
||||
Remove-Item -LiteralPath $archOutFile -Force
|
||||
}
|
||||
|
||||
try {
|
||||
& powershell -NoProfile -ExecutionPolicy Bypass -File $linuxScript -RepoRoot $repoRoot -ArchOutFile $archOutFile -Version $script:Version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Linux 打包失败"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $archOutFile -PathType Leaf) {
|
||||
$script:LinuxArch = (Get-Content -LiteralPath $archOutFile -Raw).Trim()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $archOutFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$script:LinuxDone = $true
|
||||
Write-Host "✓ Linux 打包完成"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Section "Ant Browser - 发布打包脚本"
|
||||
Write-Host ""
|
||||
Write-Host "当前工作目录: $repoRoot"
|
||||
Write-Host ""
|
||||
|
||||
Resolve-Version -ExplicitVersion $Version
|
||||
$publishTarget = Resolve-PublishTarget -InputTarget $Target
|
||||
|
||||
Invoke-WithTemporaryWailsVersion {
|
||||
switch ($publishTarget) {
|
||||
"WINDOWS" {
|
||||
Publish-Windows
|
||||
}
|
||||
"LINUX" {
|
||||
Publish-Linux
|
||||
}
|
||||
"BOTH" {
|
||||
Publish-Windows
|
||||
Publish-Linux
|
||||
}
|
||||
default {
|
||||
throw "不支持的打包目标: $publishTarget"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Section "✓ 发布完成!"
|
||||
Write-Host ""
|
||||
if ($script:WindowsDone) {
|
||||
Write-Host "Windows 安装包: publish\output\AntBrowser-Setup-$script:Version.exe"
|
||||
}
|
||||
if ($script:LinuxDone) {
|
||||
Write-Host "Linux 产物目录: publish\output\"
|
||||
if ($script:LinuxArch -ne "") {
|
||||
Write-Host "Linux 架构: $script:LinuxArch"
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "提示:用户安装后可将旧的 data\ 目录粘贴到安装目录覆盖初始数据"
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Section "✗ 发布失败"
|
||||
Write-Host ""
|
||||
Write-Host $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+231
-426
@@ -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",
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
0813f8076d07158b8ddcd1f7e61caed1
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,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 />)
|
||||
|
||||
|
||||
@@ -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> 唤起实例,适合 Playwright、Selenium、自研调度器等自动化流程。
|
||||
已支持通过本地 <code>HTTP + JSON</code> 协议唤起实例,并通过同一个固定端口暴露 CDP 入口。只要能发 HTTP 请求,和调用语言无关;Playwright、Selenium、自研调度器都只是接入方。
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-2">
|
||||
当前 Launch 地址:<code>{launchBaseUrl}</code>
|
||||
@@ -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 没命中实例 | 检查 code、keywords、tags、groupId |
|
||||
| 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 订阅、HTTP、HTTPS、SOCKS5</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 自动拉取并解析(含 proxies、dns、proxy-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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# 资源文件夹
|
||||
|
||||
此文件夹用于存放应用程序所需的各种资源文件,如图像、图标、字体等。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `images/` - 存放图像资源
|
||||
- `logo.png` - 应用程序logo
|
||||
@@ -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
Reference in New Issue
Block a user