mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: add profile import export package
This commit is contained in:
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0 - 2026-06-23
|
||||
|
||||
- 自动化增强:完善自动化脚本能力,支持更完整的脚本导入、运行、目标实例选择和执行记录管理。
|
||||
- 插件管理:新增插件包管理能力,支持插件安装、导入、启停、删除、实例限制和单实例插件配置。
|
||||
- VPN 优化:优化代理/VPN 连接链路,完善 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力。
|
||||
- 实例迁移:支持浏览器实例导入导出,可将实例配置和用户数据打包迁移到新环境。
|
||||
- 数据保留:实例导出包包含完整浏览器用户数据目录,可保留 Cookie、LocalStorage、IndexedDB、扩展本地数据等浏览器状态。
|
||||
- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理。
|
||||
- 批量操作:实例列表支持批量导出、批量启动、批量停止、批量删除等操作,提升多实例管理效率。
|
||||
- 单例操作:单个实例的“更多”菜单新增导出入口,便于快速迁移单个实例。
|
||||
- 分组管理:完善实例分组、标签、关键字等管理能力,方便按业务场景组织实例。
|
||||
- 文档中心:补充启动 API、自动化、插件管理、代理配置等说明,降低接入和排查成本。
|
||||
- 状态检测:增强实例运行状态、调试端口、代理健康、测速结果等状态反馈。
|
||||
- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口,减少页面拥挤和无效信息。
|
||||
|
||||
## 1.2.0 - 2026-05-09
|
||||
|
||||
- 接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力。
|
||||
- 自动化接口:脚本执行支持 selector / params 覆盖和 timeoutMs 超时控制,双实例 runtime 流程支持超时取消与错误返回。
|
||||
- 代理池增强:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入。
|
||||
- 代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测。
|
||||
- 实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点。
|
||||
- 书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例。
|
||||
|
||||
## 1.1.0 - 2026-03-19
|
||||
|
||||
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题。
|
||||
|
||||
@@ -51,6 +51,15 @@ Ant Browser 适合以下场景:
|
||||
|
||||
## 近期更新
|
||||
|
||||
### 1.3.0 · 2026-06-23
|
||||
|
||||
- 自动化增强:完善自动化脚本导入、运行、目标实例选择和执行记录管理,提升多实例自动化编排能力
|
||||
- 插件管理:新增插件包管理能力,支持插件安装、导入、启停、删除、实例限制和单实例插件配置
|
||||
- VPN 优化:优化代理/VPN 连接链路,完善 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力
|
||||
- 实例迁移:支持实例导入导出,可将实例配置和完整浏览器用户数据目录打包迁移到新环境
|
||||
- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理
|
||||
- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口,减少页面拥挤和无效信息
|
||||
|
||||
### 1.2.0 · 2026-05-09
|
||||
|
||||
- 重点升级接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力
|
||||
@@ -83,6 +92,10 @@ Ant Browser 适合以下场景:
|
||||
- 内核管理:支持维护多个 Chrome 内核版本,并设置默认内核
|
||||
- 快捷启动:支持通过实例 Code 和 `Ctrl + K` 快速打开目标实例
|
||||
- 标签与检索:支持按标签、关键字、状态、代理、内核、分组进行筛选
|
||||
- 自动化脚本:支持脚本导入、运行、目标实例选择、执行记录和外部接口调用
|
||||
- 插件管理:支持插件安装、导入、启停、删除、实例限制和单实例插件配置
|
||||
- 实例迁移:支持将实例配置和浏览器用户数据目录导出为 ZIP,并导入为新实例
|
||||
- VPN / 代理检测:支持连接栈预热、测速、IP 健康检测和代理异常处理
|
||||
- 本地化存储:配置和实例数据保存在本地,适合长期使用和备份
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
|
||||
"github.com/google/uuid"
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
const profilePackageFormat = "ant-chrome-profile-package"
|
||||
|
||||
type ProfilePackageManifest struct {
|
||||
Format string `json:"format"`
|
||||
Version int `json:"version"`
|
||||
ExportedAt string `json:"exportedAt"`
|
||||
ProfileCount int `json:"profileCount"`
|
||||
}
|
||||
|
||||
type ProfilePackageExportResult struct {
|
||||
Cancelled bool `json:"cancelled"`
|
||||
ZipPath string `json:"zipPath"`
|
||||
ProfileCount int `json:"profileCount"`
|
||||
FileCount int `json:"fileCount"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ProfilePackageImportResult struct {
|
||||
Cancelled bool `json:"cancelled"`
|
||||
ImportedCount int `json:"importedCount"`
|
||||
ProfileMappings map[string]string `json:"profileMappings"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BrowserProfilePackageExport 导出选中的实例配置和浏览器用户数据目录。
|
||||
func (a *App) BrowserProfilePackageExport(profileIds []string) (ProfilePackageExportResult, error) {
|
||||
a.maintenanceMu.Lock()
|
||||
defer a.maintenanceMu.Unlock()
|
||||
|
||||
ids := normalizeProfilePackageIDs(profileIds)
|
||||
if len(ids) == 0 {
|
||||
return ProfilePackageExportResult{}, fmt.Errorf("请选择要导出的实例")
|
||||
}
|
||||
if a.ctx == nil {
|
||||
return ProfilePackageExportResult{}, fmt.Errorf("应用上下文未初始化")
|
||||
}
|
||||
|
||||
profiles, err := a.collectProfilesForPackage(ids)
|
||||
if err != nil {
|
||||
return ProfilePackageExportResult{}, err
|
||||
}
|
||||
|
||||
defaultName := fmt.Sprintf("ant-chrome-profile-package-%s.zip", time.Now().Format("20060102-150405"))
|
||||
savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
|
||||
Title: "导出实例",
|
||||
DefaultFilename: defaultName,
|
||||
Filters: []wailsruntime.FileFilter{
|
||||
{DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return ProfilePackageExportResult{}, fmt.Errorf("打开保存对话框失败: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(savePath) == "" {
|
||||
return ProfilePackageExportResult{Cancelled: true, Message: "已取消导出"}, nil
|
||||
}
|
||||
savePath = ensureZipSuffix(savePath)
|
||||
|
||||
fileCount, err := a.writeProfilePackage(savePath, profiles)
|
||||
if err != nil {
|
||||
return ProfilePackageExportResult{}, err
|
||||
}
|
||||
return ProfilePackageExportResult{
|
||||
Cancelled: false,
|
||||
ZipPath: savePath,
|
||||
ProfileCount: len(profiles),
|
||||
FileCount: fileCount,
|
||||
Message: "导出完成",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BrowserProfilePackageImport 导入实例包,冲突时始终生成新实例和新目录。
|
||||
func (a *App) BrowserProfilePackageImport() (ProfilePackageImportResult, error) {
|
||||
a.maintenanceMu.Lock()
|
||||
defer a.maintenanceMu.Unlock()
|
||||
|
||||
if a.ctx == nil {
|
||||
return ProfilePackageImportResult{}, fmt.Errorf("应用上下文未初始化")
|
||||
}
|
||||
zipPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: "导入实例",
|
||||
Filters: []wailsruntime.FileFilter{
|
||||
{DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return ProfilePackageImportResult{}, fmt.Errorf("打开文件对话框失败: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(zipPath) == "" {
|
||||
return ProfilePackageImportResult{Cancelled: true, Message: "已取消导入"}, nil
|
||||
}
|
||||
return a.importProfilePackageFromPath(zipPath)
|
||||
}
|
||||
|
||||
func (a *App) collectProfilesForPackage(profileIds []string) ([]browser.Profile, error) {
|
||||
a.browserMgr.InitData()
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
profiles := make([]browser.Profile, 0, len(profileIds))
|
||||
missing := make([]string, 0)
|
||||
running := make([]string, 0)
|
||||
for _, id := range profileIds {
|
||||
profile := a.browserMgr.Profiles[id]
|
||||
if profile == nil {
|
||||
missing = append(missing, id)
|
||||
continue
|
||||
}
|
||||
if profile.Running {
|
||||
running = append(running, profile.ProfileName)
|
||||
continue
|
||||
}
|
||||
copyProfile := *profile
|
||||
copyProfile.LaunchCode = ""
|
||||
copyProfile.Running = false
|
||||
copyProfile.DebugPort = 0
|
||||
copyProfile.DebugReady = false
|
||||
copyProfile.Pid = 0
|
||||
copyProfile.RuntimeWarning = ""
|
||||
copyProfile.LastError = ""
|
||||
a.prepareProfileProxyForPackage(©Profile)
|
||||
profiles = append(profiles, copyProfile)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("实例不存在: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
if len(running) > 0 {
|
||||
return nil, fmt.Errorf("请先停止实例再导出: %s", strings.Join(running, ", "))
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (a *App) writeProfilePackage(zipPath string, profiles []browser.Profile) (int, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0o755); err != nil {
|
||||
return 0, fmt.Errorf("创建导出目录失败: %w", err)
|
||||
}
|
||||
tmpPath := zipPath + ".tmp"
|
||||
_ = os.Remove(tmpPath)
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("创建导出文件失败: %w", err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(out)
|
||||
fileCount := 0
|
||||
|
||||
writeErr := func() error {
|
||||
manifest := ProfilePackageManifest{
|
||||
Format: profilePackageFormat,
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
ProfileCount: len(profiles),
|
||||
}
|
||||
if err := writeProfilePackageJSON(zipWriter, "manifest.json", manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
fileCount++
|
||||
if err := writeProfilePackageJSON(zipWriter, "profiles.json", profiles); err != nil {
|
||||
return err
|
||||
}
|
||||
fileCount++
|
||||
for i := range profiles {
|
||||
profile := &profiles[i]
|
||||
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
|
||||
if _, err := os.Stat(userDataDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("读取用户数据目录失败: %w", err)
|
||||
}
|
||||
added, err := writeProfilePackageDir(zipWriter, userDataDir, "user-data/"+profile.ProfileId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打包用户数据失败 [%s]: %w", profile.ProfileName, err)
|
||||
}
|
||||
fileCount += added
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
closeZipErr := zipWriter.Close()
|
||||
closeFileErr := out.Close()
|
||||
if writeErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return 0, writeErr
|
||||
}
|
||||
if closeZipErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return 0, closeZipErr
|
||||
}
|
||||
if closeFileErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return 0, closeFileErr
|
||||
}
|
||||
if err := os.Rename(tmpPath, zipPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return 0, fmt.Errorf("保存导出文件失败: %w", err)
|
||||
}
|
||||
return fileCount, nil
|
||||
}
|
||||
|
||||
func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImportResult, error) {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return ProfilePackageImportResult{}, fmt.Errorf("打开实例包失败: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var manifest ProfilePackageManifest
|
||||
if err := readProfilePackageJSON(reader.File, "manifest.json", &manifest); err != nil {
|
||||
return ProfilePackageImportResult{}, err
|
||||
}
|
||||
if manifest.Format != profilePackageFormat || manifest.Version != 1 {
|
||||
return ProfilePackageImportResult{}, fmt.Errorf("不支持的实例包格式")
|
||||
}
|
||||
var profiles []browser.Profile
|
||||
if err := readProfilePackageJSON(reader.File, "profiles.json", &profiles); err != nil {
|
||||
return ProfilePackageImportResult{}, err
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return ProfilePackageImportResult{}, fmt.Errorf("实例包为空")
|
||||
}
|
||||
|
||||
a.browserMgr.InitData()
|
||||
if a.config.App.MaxProfileLimit > 0 {
|
||||
a.browserMgr.Mutex.Lock()
|
||||
currentCount := len(a.browserMgr.Profiles)
|
||||
a.browserMgr.Mutex.Unlock()
|
||||
if currentCount+len(profiles) > a.config.App.MaxProfileLimit {
|
||||
return ProfilePackageImportResult{}, fmt.Errorf("实例数量已达上限 (%d个),无法导入 %d 个实例", a.config.App.MaxProfileLimit, len(profiles))
|
||||
}
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
mappings := make(map[string]string, len(profiles))
|
||||
prepared := make([]browser.Profile, 0, len(profiles))
|
||||
for _, source := range profiles {
|
||||
oldID := strings.TrimSpace(source.ProfileId)
|
||||
if oldID == "" {
|
||||
oldID = uuid.NewString()
|
||||
}
|
||||
newID := uuid.NewString()
|
||||
source.ProfileId = newID
|
||||
source.ProfileName = buildImportedProfileName(source.ProfileName)
|
||||
source.UserDataDir = newID
|
||||
source.Running = false
|
||||
source.DebugPort = 0
|
||||
source.DebugReady = false
|
||||
source.Pid = 0
|
||||
source.RuntimeWarning = ""
|
||||
source.LastError = ""
|
||||
source.LaunchCode = ""
|
||||
source.CreatedAt = now
|
||||
source.UpdatedAt = now
|
||||
source.DeletedAt = ""
|
||||
a.applyImportedProfileProxyByName(&source)
|
||||
prepared = append(prepared, source)
|
||||
mappings[oldID] = newID
|
||||
}
|
||||
|
||||
for _, profile := range prepared {
|
||||
if err := a.extractProfileUserData(reader.File, mappings, profile.ProfileId); err != nil {
|
||||
return ProfilePackageImportResult{}, err
|
||||
}
|
||||
}
|
||||
a.browserMgr.Mutex.Lock()
|
||||
for i := range prepared {
|
||||
profile := &prepared[i]
|
||||
a.browserMgr.Profiles[profile.ProfileId] = profile
|
||||
if a.launchCodeSvc != nil {
|
||||
if code, err := a.launchCodeSvc.EnsureCode(profile.ProfileId); err == nil {
|
||||
profile.LaunchCode = code
|
||||
}
|
||||
}
|
||||
}
|
||||
a.browserMgr.Mutex.Unlock()
|
||||
if err := a.browserMgr.SaveProfiles(); err != nil {
|
||||
return ProfilePackageImportResult{}, err
|
||||
}
|
||||
|
||||
return ProfilePackageImportResult{
|
||||
Cancelled: false,
|
||||
ImportedCount: len(prepared),
|
||||
ProfileMappings: mappings,
|
||||
Message: "导入完成",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) extractProfileUserData(files []*zip.File, mappings map[string]string, newProfileID string) error {
|
||||
oldProfileID := ""
|
||||
for oldID, mappedID := range mappings {
|
||||
if mappedID == newProfileID {
|
||||
oldProfileID = oldID
|
||||
break
|
||||
}
|
||||
}
|
||||
if oldProfileID == "" {
|
||||
return fmt.Errorf("实例映射不存在: %s", newProfileID)
|
||||
}
|
||||
profile := &browser.Profile{ProfileId: newProfileID, UserDataDir: newProfileID}
|
||||
destDir := a.browserMgr.ResolveUserDataDir(profile)
|
||||
if err := os.RemoveAll(destDir); err != nil {
|
||||
return fmt.Errorf("清理用户数据目录失败: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建用户数据目录失败: %w", err)
|
||||
}
|
||||
prefix := "user-data/" + oldProfileID + "/"
|
||||
for _, file := range files {
|
||||
name := filepath.ToSlash(file.Name)
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
rel := strings.TrimPrefix(name, prefix)
|
||||
if rel == "" {
|
||||
continue
|
||||
}
|
||||
if err := extractProfilePackageFile(file, destDir, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeProfilePackageJSON(zipWriter *zip.Writer, name string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = writer.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeProfilePackageDir(zipWriter *zip.Writer, srcDir string, destPrefix string) (int, error) {
|
||||
count := 0
|
||||
err := filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
zipName := filepath.ToSlash(filepath.Join(destPrefix, rel))
|
||||
if entry.IsDir() {
|
||||
_, err := zipWriter.Create(strings.TrimSuffix(zipName, "/") + "/")
|
||||
return err
|
||||
}
|
||||
writer, err := zipWriter.Create(zipName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(writer, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func readProfilePackageJSON(files []*zip.File, name string, target any) error {
|
||||
for _, file := range files {
|
||||
if filepath.ToSlash(file.Name) != name {
|
||||
continue
|
||||
}
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
return json.NewDecoder(reader).Decode(target)
|
||||
}
|
||||
return fmt.Errorf("实例包缺少 %s", name)
|
||||
}
|
||||
|
||||
func extractProfilePackageFile(file *zip.File, destDir string, rel string) error {
|
||||
cleanRel := filepath.Clean(filepath.FromSlash(rel))
|
||||
if cleanRel == "." || strings.HasPrefix(cleanRel, "..") || filepath.IsAbs(cleanRel) {
|
||||
return fmt.Errorf("非法路径: %s", rel)
|
||||
}
|
||||
target := filepath.Join(destDir, cleanRel)
|
||||
cleanDest := filepath.Clean(destDir)
|
||||
cleanTarget := filepath.Clean(target)
|
||||
if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("非法路径: %s", rel)
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, reader)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func normalizeProfilePackageIDs(ids []string) []string {
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func ensureZipSuffix(path string) string {
|
||||
if strings.EqualFold(filepath.Ext(path), ".zip") {
|
||||
return path
|
||||
}
|
||||
return path + ".zip"
|
||||
}
|
||||
|
||||
func buildImportedProfileName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "导入实例"
|
||||
}
|
||||
return name + "(导入)"
|
||||
}
|
||||
|
||||
func (a *App) prepareProfileProxyForPackage(profile *browser.Profile) {
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
proxyName := strings.TrimSpace(profile.ProxyBindName)
|
||||
if proxyName == "" {
|
||||
if proxy, ok := a.browserMgr.GetProxyByID(profile.ProxyId); ok {
|
||||
proxyName = strings.TrimSpace(proxy.ProxyName)
|
||||
}
|
||||
}
|
||||
profile.ProxyId = ""
|
||||
profile.ProxyConfig = ""
|
||||
profile.ProxyBindSourceID = ""
|
||||
profile.ProxyBindSourceURL = ""
|
||||
profile.ProxyBindName = proxyName
|
||||
profile.ProxyBindUpdatedAt = ""
|
||||
}
|
||||
|
||||
func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) {
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
proxyName := strings.TrimSpace(profile.ProxyBindName)
|
||||
profile.ProxyId = ""
|
||||
profile.ProxyConfig = ""
|
||||
profile.ProxyBindSourceID = ""
|
||||
profile.ProxyBindSourceURL = ""
|
||||
profile.ProxyBindUpdatedAt = ""
|
||||
if proxyName == "" {
|
||||
profile.ProxyBindName = ""
|
||||
return
|
||||
}
|
||||
if proxy, ok := a.findUniqueProxyByName(proxyName); ok {
|
||||
browser.BindProfileToProxy(profile, proxy, true)
|
||||
return
|
||||
}
|
||||
profile.ProxyBindName = ""
|
||||
}
|
||||
|
||||
func (a *App) findUniqueProxyByName(proxyName string) (browser.Proxy, bool) {
|
||||
target := strings.ToLower(strings.TrimSpace(proxyName))
|
||||
if target == "" {
|
||||
return browser.Proxy{}, false
|
||||
}
|
||||
proxies := browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
|
||||
var hit browser.Proxy
|
||||
matched := 0
|
||||
for _, proxy := range proxies {
|
||||
if strings.ToLower(strings.TrimSpace(proxy.ProxyName)) != target {
|
||||
continue
|
||||
}
|
||||
hit = proxy
|
||||
matched++
|
||||
if matched > 1 {
|
||||
return browser.Proxy{}, false
|
||||
}
|
||||
}
|
||||
return hit, matched == 1
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { applyBrowserProfileCopyOptionsToArgs, createBrowserProfileCopyOptions } from '../copyOptions'
|
||||
import { buildBrowserProfileCopyName } from '../copyName'
|
||||
import type { BrowserProfile, BrowserProfileCopyOptions, BrowserProfileInput } from '../types'
|
||||
import type {
|
||||
BrowserProfile,
|
||||
BrowserProfileCopyOptions,
|
||||
BrowserProfileInput,
|
||||
BrowserProfilePackageExportResult,
|
||||
BrowserProfilePackageImportResult,
|
||||
} from '../types'
|
||||
import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime'
|
||||
|
||||
export async function fetchBrowserProfiles(): Promise<BrowserProfile[]> {
|
||||
@@ -38,6 +44,33 @@ export async function fetchAllTags(): Promise<string[]> {
|
||||
return Array.from(tags).sort()
|
||||
}
|
||||
|
||||
export async function exportBrowserProfilePackage(profileIds: string[]): Promise<BrowserProfilePackageExportResult> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.BrowserProfilePackageExport) {
|
||||
return await bindings.BrowserProfilePackageExport(profileIds)
|
||||
}
|
||||
return {
|
||||
cancelled: true,
|
||||
zipPath: '',
|
||||
profileCount: 0,
|
||||
fileCount: 0,
|
||||
message: '当前环境不支持导出实例',
|
||||
}
|
||||
}
|
||||
|
||||
export async function importBrowserProfilePackage(): Promise<BrowserProfilePackageImportResult> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.BrowserProfilePackageImport) {
|
||||
return await bindings.BrowserProfilePackageImport()
|
||||
}
|
||||
return {
|
||||
cancelled: true,
|
||||
importedCount: 0,
|
||||
profileMappings: {},
|
||||
message: '当前环境不支持导入实例',
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBrowserProfile(input: BrowserProfileInput): Promise<BrowserProfile | null> {
|
||||
const bindings: any = await getBindings()
|
||||
if (bindings?.BrowserProfileCreate) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, XCircle } from 'lucide-react'
|
||||
import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, Upload, XCircle } from 'lucide-react'
|
||||
|
||||
import { Button, Card, FormItem, Input, Modal, StatCard, Switch, Table, Textarea } from '../../../shared/components'
|
||||
import type { TableColumn } from '../../../shared/components/Table'
|
||||
@@ -27,6 +27,8 @@ interface BrowserListHeaderProps {
|
||||
onOpenSettings: () => void
|
||||
onOpenExpandModal: () => void
|
||||
onOpenTrash: () => void
|
||||
onImportProfiles: () => void
|
||||
importingProfiles?: boolean
|
||||
onViewModeChange: (next: BrowserViewMode) => void
|
||||
}
|
||||
|
||||
@@ -47,6 +49,8 @@ export function BrowserListHeader({
|
||||
onOpenSettings,
|
||||
onOpenExpandModal,
|
||||
onOpenTrash,
|
||||
onImportProfiles,
|
||||
importingProfiles = false,
|
||||
onViewModeChange,
|
||||
}: BrowserListHeaderProps) {
|
||||
return (
|
||||
@@ -75,6 +79,9 @@ export function BrowserListHeader({
|
||||
<Button variant="secondary" size="sm" onClick={onOpenTrash}>
|
||||
<Trash2 className="w-4 h-4" />回收站
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={onImportProfiles} loading={importingProfiles}>
|
||||
<Upload className="w-4 h-4" />导入实例
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Copy, Pencil, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, Copy, Download, Pencil, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button, toast } from '../../../shared/components'
|
||||
import { regenerateBrowserProfileCode, setBrowserProfileCode } from '../api'
|
||||
@@ -11,8 +11,10 @@ interface BatchToolbarProps {
|
||||
onDeselectAll: () => void
|
||||
onBatchStart: () => void
|
||||
onBatchStop: () => void
|
||||
onBatchExport: () => void
|
||||
onBatchDelete: () => void
|
||||
batchLoading: boolean
|
||||
exporting?: boolean
|
||||
}
|
||||
|
||||
export function BatchToolbar({
|
||||
@@ -22,8 +24,10 @@ export function BatchToolbar({
|
||||
onDeselectAll,
|
||||
onBatchStart,
|
||||
onBatchStop,
|
||||
onBatchExport,
|
||||
onBatchDelete,
|
||||
batchLoading,
|
||||
exporting = false,
|
||||
}: BatchToolbarProps) {
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
@@ -39,6 +43,9 @@ export function BatchToolbar({
|
||||
<Button size="sm" variant="secondary" onClick={onBatchStop} loading={batchLoading} title="批量停止">
|
||||
<Square className="w-3.5 h-3.5" />停止
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onBatchExport} loading={exporting} title="导出实例">
|
||||
<Download className="w-3.5 h-3.5" />导出
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -151,16 +158,16 @@ export function KeywordInlineRow({ keywords }: KeywordInlineRowProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-4 w-full">
|
||||
<div className="flex items-start gap-4 w-full min-w-0">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex flex-wrap gap-2 flex-1 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
|
||||
className={`flex flex-wrap gap-2 flex-1 min-w-0 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
|
||||
>
|
||||
{keywords.map((keyword, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-2.5 py-1 text-left text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)]"
|
||||
className="inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-2.5 py-1 text-left text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)]"
|
||||
title={`点击复制:${keyword}`}
|
||||
onClick={() => { void handleCopyKeyword(keyword) }}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Copy, Key, Loader2, MoreHorizontal, Play, Puzzle, Repeat2, RotateCcw, Settings, Square, Trash2, Wifi } from 'lucide-react'
|
||||
import { Copy, Download, Key, Loader2, MoreHorizontal, Play, Puzzle, Repeat2, RotateCcw, Settings, Square, Trash2, Wifi } from 'lucide-react'
|
||||
|
||||
import { Badge, Button, Card, Table } from '../../../shared/components'
|
||||
import type { TableColumn } from '../../../shared/components/Table'
|
||||
@@ -39,6 +39,7 @@ interface BrowserProfilesPanelProps {
|
||||
onRestart: (profileId: string) => void
|
||||
onOpenKeywords: (profile: BrowserProfile) => void
|
||||
onOpenExtensions: (profile: BrowserProfile) => void
|
||||
onExport: (profile: BrowserProfile) => void
|
||||
onOpenCopy: (profile: BrowserProfile) => void
|
||||
onOpenProxyPicker: (profile: BrowserProfile) => void
|
||||
onDelete: (profileId: string) => void
|
||||
@@ -151,6 +152,7 @@ function ProfileMoreActions({
|
||||
onRestart,
|
||||
onOpenKeywords,
|
||||
onOpenExtensions,
|
||||
onExport,
|
||||
}: {
|
||||
open: boolean
|
||||
disabled: boolean
|
||||
@@ -159,6 +161,7 @@ function ProfileMoreActions({
|
||||
onRestart: () => void
|
||||
onOpenKeywords: () => void
|
||||
onOpenExtensions: () => void
|
||||
onExport: () => void
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -170,7 +173,7 @@ function ProfileMoreActions({
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const menuWidth = 128
|
||||
const menuHeight = 128
|
||||
const menuHeight = 168
|
||||
const gap = 8
|
||||
const left = Math.max(8, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8))
|
||||
const belowTop = rect.bottom + gap
|
||||
@@ -246,6 +249,14 @@ function ProfileMoreActions({
|
||||
<Puzzle className="w-3.5 h-3.5" />
|
||||
插件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => runAndClose(onExport)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
导出
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
@@ -400,6 +411,7 @@ export function BrowserProfilesPanel({
|
||||
onRestart,
|
||||
onOpenKeywords,
|
||||
onOpenExtensions,
|
||||
onExport,
|
||||
onOpenCopy,
|
||||
onOpenProxyPicker,
|
||||
onDelete,
|
||||
@@ -528,6 +540,7 @@ export function BrowserProfilesPanel({
|
||||
onRestart={() => onRestart(record.profileId)}
|
||||
onOpenKeywords={() => onOpenKeywords(record)}
|
||||
onOpenExtensions={() => onOpenExtensions(record)}
|
||||
onExport={() => onExport(record)}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(record.profileId)} title="删除" disabled={isBusy}><Trash2 className="w-3.5 h-3.5 text-red-500" /></Button>
|
||||
</div>
|
||||
|
||||
@@ -27,17 +27,17 @@ export function KeywordsExpandRow({ keywords, colSpan }: Props) {
|
||||
{!keywords?.length ? (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">-</span>
|
||||
) : (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex flex-wrap gap-2 flex-1 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
|
||||
className={`flex flex-wrap gap-2 flex-1 min-w-0 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
|
||||
>
|
||||
{keywords.map((kw, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs
|
||||
className="inline-flex max-w-full min-w-0 items-center gap-1.5 px-2.5 py-1 rounded-md text-xs
|
||||
bg-[var(--color-bg-surface)] border border-[var(--color-border-default)]
|
||||
text-[var(--color-text-secondary)] max-w-[200px]"
|
||||
text-[var(--color-text-secondary)]"
|
||||
title={kw}
|
||||
>
|
||||
<span className="text-[var(--color-text-muted)] font-mono shrink-0">{i + 1}.</span>
|
||||
|
||||
@@ -18,7 +18,9 @@ import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup'
|
||||
import {
|
||||
copyBrowserProfile,
|
||||
deleteBrowserProfile,
|
||||
exportBrowserProfilePackage,
|
||||
fetchBrowserProfileTrash,
|
||||
importBrowserProfilePackage,
|
||||
permanentlyDeleteBrowserProfile,
|
||||
restoreBrowserProfile,
|
||||
startBrowserInstance,
|
||||
@@ -40,6 +42,7 @@ export function BrowserListPage() {
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
const [profilePackageBusy, setProfilePackageBusy] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
open: boolean
|
||||
mode: 'single' | 'batch'
|
||||
@@ -259,6 +262,62 @@ export function BrowserListPage() {
|
||||
loadProfiles()
|
||||
}
|
||||
|
||||
const handleBatchExport = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0 || profilePackageBusy) return
|
||||
const runningNames = profiles
|
||||
.filter(profile => ids.includes(profile.profileId) && profile.running)
|
||||
.map(profile => profile.profileName)
|
||||
if (runningNames.length > 0) {
|
||||
toast.error(`请先停止实例再导出:${runningNames.slice(0, 3).join('、')}${runningNames.length > 3 ? ' 等' : ''}`)
|
||||
return
|
||||
}
|
||||
setProfilePackageBusy(true)
|
||||
try {
|
||||
const result = await exportBrowserProfilePackage(ids)
|
||||
if (result.cancelled) return
|
||||
toast.success(`已导出 ${result.profileCount} 个实例`)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '导出实例失败')
|
||||
} finally {
|
||||
setProfilePackageBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportProfile = async (profile: BrowserProfile) => {
|
||||
if (profilePackageBusy) return
|
||||
if (profile.running) {
|
||||
toast.error(`请先停止实例再导出:${profile.profileName}`)
|
||||
return
|
||||
}
|
||||
setProfilePackageBusy(true)
|
||||
try {
|
||||
const result = await exportBrowserProfilePackage([profile.profileId])
|
||||
if (result.cancelled) return
|
||||
toast.success(`已导出:${profile.profileName}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '导出实例失败')
|
||||
} finally {
|
||||
setProfilePackageBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportProfiles = async () => {
|
||||
if (profilePackageBusy) return
|
||||
setProfilePackageBusy(true)
|
||||
try {
|
||||
const result = await importBrowserProfilePackage()
|
||||
if (result.cancelled) return
|
||||
toast.success(`已导入 ${result.importedCount} 个实例`)
|
||||
setSelectedIds(new Set())
|
||||
await loadProfiles()
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '导入实例失败')
|
||||
} finally {
|
||||
setProfilePackageBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteConfirm = (profileId: string) => {
|
||||
const profile = profiles.find(item => item.profileId === profileId)
|
||||
setDeleteConfirm({
|
||||
@@ -419,6 +478,8 @@ export function BrowserListPage() {
|
||||
onRefresh={() => { void loadProfiles() }}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenTrash={openTrashModal}
|
||||
onImportProfiles={handleImportProfiles}
|
||||
importingProfiles={profilePackageBusy}
|
||||
onOpenExpandModal={() => {
|
||||
setExpandModalOpen(true)
|
||||
loadQuota()
|
||||
@@ -434,8 +495,10 @@ export function BrowserListPage() {
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onBatchStart={handleBatchStart}
|
||||
onBatchStop={handleBatchStop}
|
||||
onBatchExport={handleBatchExport}
|
||||
onBatchDelete={openBatchDeleteConfirm}
|
||||
batchLoading={batchLoading}
|
||||
exporting={profilePackageBusy}
|
||||
/>
|
||||
|
||||
<BrowserProfilesPanel
|
||||
@@ -459,6 +522,7 @@ export function BrowserListPage() {
|
||||
onRestart={(profileId) => { void handleRestart(profileId) }}
|
||||
onOpenKeywords={openKwModal}
|
||||
onOpenExtensions={openExtensionModal}
|
||||
onExport={(profile) => { void handleExportProfile(profile) }}
|
||||
onOpenCopy={openCopyModal}
|
||||
onOpenProxyPicker={setProxyPickerProfile}
|
||||
onDelete={openDeleteConfirm}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
export const DOC_CHANGELOG = `# 更新日志
|
||||
|
||||
## 1.3.0 - 2026-06-18
|
||||
## 1.3.0 - 2026-06-23
|
||||
|
||||
- 插件包管理:支持商店链接 / 插件 ID 安装、本地插件包导入、本地插件目录导入、插件启停、删除和安装历史。
|
||||
- 插件实例限制:限制实例弹窗按分组展示实例,支持整组选择 / 取消,并显示每组已选数量。
|
||||
- 单实例插件配置:实例列表可进入单实例插件配置,支持从全局继承切换为独立插件清单。
|
||||
- 代理核心管理:补齐 xray、sing-box、mihomo 运行核心检测、下载、清理和启动锁处理。
|
||||
- 内核全局设置:全局设置只读字段统一为一致的灰底展示,减少普通值和多行参数的样式割裂。
|
||||
- 文档中心:补齐插件包管理、操作流程和全局设置说明,并接回脚本自动化章节内容。
|
||||
- 自动化增强:完善自动化脚本导入、运行、目标实例选择和执行记录管理。
|
||||
- 插件管理:支持插件安装、导入、启停、删除、实例限制和单实例插件配置。
|
||||
- VPN 优化:优化 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力。
|
||||
- 实例迁移:支持实例导入导出,可将实例配置和完整浏览器用户数据目录打包迁移到新环境。
|
||||
- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理。
|
||||
- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口。
|
||||
|
||||
## 1.2.0 - 2026-05-05
|
||||
## 1.2.0 - 2026-05-09
|
||||
|
||||
- 文档中心改版:把使用教程、内核、代理、接口和排障内容收进同一个入口,接口详情改为按章节逐步查看。
|
||||
- Launch API 完整化:补齐实例增删改查、按 code / selector 启动、runtime session、runtime status、runtime stop 和统一 CDP 入口。
|
||||
- 自动化脚本中心:支持脚本列表、详情、执行和运行记录;脚本可复用默认 selector / params,也可在执行时覆盖。
|
||||
- OpenClaw 对接:新增 ant-chrome-openclaw skill、安装脚本、HTTP 调用参考和同机远程 CDP 接管流程。
|
||||
- 代理能力增强:代理池支持更多导入与检测场景,补齐链式代理编辑、IP 健康检查和测速链路。
|
||||
- 工程拆分:实例启动、备份恢复、自动化运行时、发布脚本等模块拆分,减少单文件堆叠,便于后续维护。
|
||||
- 接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口。
|
||||
- 自动化接口:脚本执行支持 selector / params 覆盖和 timeoutMs 超时控制,双实例 runtime 流程支持超时取消与错误返回。
|
||||
- 代理池增强:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入。
|
||||
- 代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测。
|
||||
- 实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点。
|
||||
- 书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例。
|
||||
|
||||
## 1.1.0 - 2026-03-19
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
代理池配置 -> 导入 / 添加代理
|
||||
插件包管理 -> 安装 / 导入插件
|
||||
实例列表 -> 新建实例 -> 选择内核 / 代理 -> 启动
|
||||
实例列表 -> 导入实例 / 选中实例 -> 导出
|
||||
\`\`\`
|
||||
|
||||
## 最小上手顺序
|
||||
@@ -18,6 +19,19 @@
|
||||
5. 打开 \`指纹浏览器 > 实例列表\`,新建实例并按需配置插件
|
||||
6. 保存后直接启动
|
||||
|
||||
## 实例迁移
|
||||
|
||||
\`\`\`text
|
||||
导出:实例列表 -> 勾选实例 -> 导出
|
||||
单个导出:实例列表 -> 更多 -> 导出
|
||||
导入:实例列表 -> 导入实例 -> 选择 ZIP
|
||||
\`\`\`
|
||||
|
||||
- 导出前先停止实例,避免浏览器数据文件被占用
|
||||
- 导出包包含实例配置和完整浏览器用户数据目录
|
||||
- 导入时会生成新实例,不覆盖当前已有实例
|
||||
- 代理只按名称适配本地同名代理,未匹配时自动清空代理
|
||||
|
||||
## 最小 HTTP 对接
|
||||
|
||||
\`\`\`bash
|
||||
@@ -74,6 +88,7 @@ export const DOC_OPERATION_FLOW = `# 操作流程
|
||||
3. 插件包管理:安装插件,按需限制实例
|
||||
4. 实例列表:创建实例并选择内核、代理、分组
|
||||
5. 启动实例:确认 debugReady 后再接管
|
||||
6. 迁移实例:停止实例后导出 ZIP,新环境中导入为新实例
|
||||
\`\`\`
|
||||
|
||||
## 插件接入流程
|
||||
|
||||
@@ -41,6 +41,21 @@ export interface BrowserProfileInput {
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export interface BrowserProfilePackageExportResult {
|
||||
cancelled: boolean
|
||||
zipPath: string
|
||||
profileCount: number
|
||||
fileCount: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BrowserProfilePackageImportResult {
|
||||
cancelled: boolean
|
||||
importedCount: number
|
||||
profileMappings: Record<string, string>
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BrowserProfileCopyMode = 'auto_fingerprint' | 'regular'
|
||||
|
||||
export type BrowserProfileAutomationTarget =
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {automation} from '../models';
|
||||
import {backend} from '../models';
|
||||
@@ -172,6 +172,10 @@ export function BrowserProfileList():Promise<Array<browser.Profile>>;
|
||||
|
||||
export function BrowserProfileListByTag(arg1:string):Promise<Array<browser.Profile>>;
|
||||
|
||||
export function BrowserProfilePackageExport(arg1:Array<string>):Promise<any>;
|
||||
|
||||
export function BrowserProfilePackageImport():Promise<any>;
|
||||
|
||||
export function BrowserProfilePermanentlyDelete(arg1:string):Promise<void>;
|
||||
|
||||
export function BrowserProfileRegenerateCode(arg1:string):Promise<string>;
|
||||
|
||||
@@ -326,6 +326,14 @@ export function BrowserProfileListByTag(arg1) {
|
||||
return window['go']['main']['App']['BrowserProfileListByTag'](arg1);
|
||||
}
|
||||
|
||||
export function BrowserProfilePackageExport(arg1) {
|
||||
return window['go']['main']['App']['BrowserProfilePackageExport'](arg1);
|
||||
}
|
||||
|
||||
export function BrowserProfilePackageImport() {
|
||||
return window['go']['main']['App']['BrowserProfilePackageImport']();
|
||||
}
|
||||
|
||||
export function BrowserProfilePermanentlyDelete(arg1) {
|
||||
return window['go']['main']['App']['BrowserProfilePermanentlyDelete'](arg1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user