chore: prepare 1.3.0 release

This commit is contained in:
ant-black
2026-06-28 15:09:20 +08:00
parent 29f5c2e595
commit 5518481eb1
19 changed files with 527 additions and 73 deletions
+3 -1
View File
@@ -230,12 +230,14 @@ data/automation/scripts/<script-id>/
Windows 发布脚本默认保持原有 NSIS 安装包行为,也可以生成便携 ZIP,或一次生成两种产物:
```powershell
bat\publish.bat zip
bat\publish.bat both
bat\publish.bat -Target WINDOWS -WindowsFormat INSTALLER
bat\publish.bat -Target WINDOWS -WindowsFormat PORTABLE
bat\publish.bat -Target WINDOWS -WindowsFormat BOTH
```
省略 `-WindowsFormat` 时等同于 `INSTALLER`。安装包和便携 ZIP 输出到 `publish\output\`
省略 `-WindowsFormat` 时等同于 `INSTALLER``zip` 快捷命令只生成便携 ZIP`both` 快捷命令同时生成安装包和便携 ZIP。安装包和便携 ZIP 输出到 `publish\output\`
### Linux 发布打包(源码)
+5
View File
@@ -5,6 +5,7 @@ import "strings"
const (
profileProxyBridgeEngineXray = "xray"
profileProxyBridgeEngineSingBox = "sing-box"
profileProxyBridgeEngineMihomo = "mihomo"
)
type profileProxyBridgeRef struct {
@@ -64,6 +65,10 @@ func (a *App) releaseProxyBridgeRef(ref profileProxyBridgeRef) {
if a.singboxMgr != nil {
a.singboxMgr.ReleaseBridge(ref.Key)
}
case profileProxyBridgeEngineMihomo:
if a.clashMgr != nil {
a.clashMgr.ReleaseNodeBridge(ref.Key)
}
}
}
+8 -3
View File
@@ -1,6 +1,7 @@
package backend
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"ant-chrome/backend/internal/proxy"
"fmt"
@@ -70,7 +71,11 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
return "", profileProxyBridgeRef{}, false, startErr
}
resolution, err := proxy.ResolveProxyKernel(resolvedProxyConfig, proxies, resolvedProxyID, "")
connectorType := config.BrowserConnectorXray
if a.config != nil {
connectorType = config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
}
resolution, err := proxy.ResolveProxyKernelForConnector(resolvedProxyConfig, proxies, resolvedProxyID, connectorType)
if err != nil {
startErr := fmt.Errorf("实例启动失败:%s", err.Error())
profile.LastError = startErr.Error()
@@ -91,14 +96,14 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
profile.LastError = startErr.Error()
return "", profileProxyBridgeRef{}, false, startErr
}
proxyURL, bridgeErr := a.clashMgr.EnsureNodeBridge(resolvedProxyConfig, proxies, resolvedProxyID)
proxyURL, bridgeKey, bridgeErr := a.clashMgr.AcquireNodeBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:mihomo 代理桥接失败:%v", bridgeErr)
log.Error("代理桥接失败(mihomo)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
return "", profileProxyBridgeRef{}, false, startErr
}
return proxyURL, profileProxyBridgeRef{}, false, nil
return proxyURL, newProfileProxyBridgeRef(profileProxyBridgeEngineMihomo, bridgeKey), bridgeKey != "", nil
case proxy.ProxyKernelSingBox:
if a.singboxMgr == nil {
startErr := fmt.Errorf("实例启动失败:sing-box 管理器未初始化,无法启动该协议代理。请检查 sing-box 内核配置。")
+140 -35
View File
@@ -39,9 +39,18 @@ type ProfilePackageImportResult struct {
Cancelled bool `json:"cancelled"`
ImportedCount int `json:"importedCount"`
ProfileMappings map[string]string `json:"profileMappings"`
Warnings []string `json:"warnings"`
Message string `json:"message"`
}
type preparedProfilePackageImport struct {
Profile browser.Profile
OldProfileID string
FinalDir string
StagingDir string
HasUserData bool
}
// BrowserProfilePackageExport 导出选中的实例配置和浏览器用户数据目录。
func (a *App) BrowserProfilePackageExport(profileIds []string) (ProfilePackageExportResult, error) {
a.maintenanceMu.Lock()
@@ -242,7 +251,29 @@ func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImport
a.browserMgr.InitData()
now := time.Now().Format(time.RFC3339)
mappings := make(map[string]string, len(profiles))
prepared := make([]browser.Profile, 0, len(profiles))
warnings := make([]string, 0)
prepared := make([]preparedProfilePackageImport, 0, len(profiles))
batchID := uuid.NewString()
stagingRoot := a.profilePackageImportStagingRoot(batchID)
committedDirs := make([]string, 0, len(profiles))
committedProfiles := make([]string, 0, len(profiles))
committed := false
defer func() {
_ = os.RemoveAll(stagingRoot)
if !committed {
for _, dir := range committedDirs {
_ = os.RemoveAll(dir)
}
if len(committedProfiles) > 0 {
a.browserMgr.Mutex.Lock()
for _, profileID := range committedProfiles {
delete(a.browserMgr.Profiles, profileID)
}
a.browserMgr.Mutex.Unlock()
}
}
}()
for _, source := range profiles {
oldID := strings.TrimSpace(source.ProfileId)
if oldID == "" {
@@ -262,20 +293,45 @@ func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImport
source.CreatedAt = now
source.UpdatedAt = now
source.DeletedAt = ""
a.applyImportedProfileProxyByName(&source)
prepared = append(prepared, source)
if warning := a.applyImportedProfileProxyByName(&source); warning != "" {
warnings = append(warnings, fmt.Sprintf("实例「%s」%s", source.ProfileName, warning))
}
profile := &browser.Profile{ProfileId: newID, UserDataDir: newID}
finalDir := a.browserMgr.ResolveUserDataDir(profile)
stagingDir := filepath.Join(stagingRoot, newID)
hasUserData, err := a.extractProfileUserDataToDir(reader.File, oldID, stagingDir)
if err != nil {
return ProfilePackageImportResult{}, err
}
if !hasUserData {
warnings = append(warnings, fmt.Sprintf("实例「%s」没有用户数据目录,仅导入配置", source.ProfileName))
}
prepared = append(prepared, preparedProfilePackageImport{
Profile: source,
OldProfileID: oldID,
FinalDir: finalDir,
StagingDir: stagingDir,
HasUserData: hasUserData,
})
mappings[oldID] = newID
}
for _, profile := range prepared {
if err := a.extractProfileUserData(reader.File, mappings, profile.ProfileId); err != nil {
for _, item := range prepared {
if !item.HasUserData {
continue
}
if err := replaceProfileUserDataDir(item.StagingDir, item.FinalDir); err != nil {
return ProfilePackageImportResult{}, err
}
committedDirs = append(committedDirs, item.FinalDir)
}
a.browserMgr.Mutex.Lock()
for i := range prepared {
profile := &prepared[i]
profile := &prepared[i].Profile
a.browserMgr.Profiles[profile.ProfileId] = profile
committedProfiles = append(committedProfiles, profile.ProfileId)
if a.launchCodeSvc != nil {
if code, err := a.launchCodeSvc.EnsureCode(profile.ProfileId); err == nil {
profile.LaunchCode = code
@@ -286,35 +342,20 @@ func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImport
if err := a.browserMgr.SaveProfiles(); err != nil {
return ProfilePackageImportResult{}, err
}
committed = true
return ProfilePackageImportResult{
Cancelled: false,
ImportedCount: len(prepared),
ProfileMappings: mappings,
Warnings: warnings,
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)
}
func (a *App) extractProfileUserDataToDir(files []*zip.File, oldProfileID string, destDir string) (bool, error) {
prefix := "user-data/" + oldProfileID + "/"
hasUserData := false
for _, file := range files {
name := filepath.ToSlash(file.Name)
if !strings.HasPrefix(name, prefix) {
@@ -324,11 +365,20 @@ func (a *App) extractProfileUserData(files []*zip.File, mappings map[string]stri
if rel == "" {
continue
}
if !hasUserData {
if err := os.RemoveAll(destDir); err != nil {
return false, fmt.Errorf("清理临时用户数据目录失败: %w", err)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return false, fmt.Errorf("创建临时用户数据目录失败: %w", err)
}
hasUserData = true
}
if err := extractProfilePackageFile(file, destDir, rel); err != nil {
return err
return false, err
}
}
return nil
return hasUserData, nil
}
func writeProfilePackageJSON(zipWriter *zip.Writer, name string, value any) error {
@@ -434,6 +484,51 @@ func extractProfilePackageFile(file *zip.File, destDir string, rel string) error
return closeErr
}
func replaceProfileUserDataDir(stagingDir string, finalDir string) error {
if strings.TrimSpace(stagingDir) == "" || strings.TrimSpace(finalDir) == "" {
return fmt.Errorf("用户数据目录不能为空")
}
if err := os.MkdirAll(filepath.Dir(finalDir), 0o755); err != nil {
return fmt.Errorf("创建用户数据父目录失败: %w", err)
}
backupDir := finalDir + ".profile-package-backup-" + uuid.NewString()
finalExisted := false
if _, err := os.Stat(finalDir); err == nil {
finalExisted = true
if err := os.Rename(finalDir, backupDir); err != nil {
return fmt.Errorf("备份现有用户数据目录失败: %w", err)
}
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("检查用户数据目录失败: %w", err)
}
if err := os.Rename(stagingDir, finalDir); err != nil {
if finalExisted {
_ = os.Rename(backupDir, finalDir)
}
return fmt.Errorf("提交用户数据目录失败: %w", err)
}
if finalExisted {
_ = os.RemoveAll(backupDir)
}
return nil
}
func (a *App) profilePackageImportStagingRoot(batchID string) string {
root := "data"
if a.browserMgr != nil && a.browserMgr.Config != nil {
root = strings.TrimSpace(a.browserMgr.Config.Browser.UserDataRoot)
}
if root == "" {
root = "data"
}
if a.browserMgr != nil {
root = a.browserMgr.ResolveRelativePath(root)
} else {
root = a.resolveAppPath(root)
}
return filepath.Join(root, ".imports", strings.TrimSpace(batchID))
}
func normalizeProfilePackageIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
result := make([]string, 0, len(ids))
@@ -485,9 +580,9 @@ func (a *App) prepareProfileProxyForPackage(profile *browser.Profile) {
profile.ProxyBindUpdatedAt = ""
}
func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) {
func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) string {
if profile == nil {
return
return ""
}
proxyName := strings.TrimSpace(profile.ProxyBindName)
profile.ProxyId = ""
@@ -497,19 +592,29 @@ func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) {
profile.ProxyBindUpdatedAt = ""
if proxyName == "" {
profile.ProxyBindName = ""
return
return ""
}
if proxy, ok := a.findUniqueProxyByName(proxyName); ok {
proxy, matchCount := a.findProxiesByName(proxyName)
if matchCount == 1 {
browser.BindProfileToProxy(profile, proxy, true)
return
return ""
}
profile.ProxyBindName = ""
if matchCount == 0 {
return fmt.Sprintf("绑定代理「%s」未找到,已清空绑定", proxyName)
}
return fmt.Sprintf("绑定代理「%s」存在多个同名匹配,已清空绑定", proxyName)
}
func (a *App) findUniqueProxyByName(proxyName string) (browser.Proxy, bool) {
proxy, count := a.findProxiesByName(proxyName)
return proxy, count == 1
}
func (a *App) findProxiesByName(proxyName string) (browser.Proxy, int) {
target := strings.ToLower(strings.TrimSpace(proxyName))
if target == "" {
return browser.Proxy{}, false
return browser.Proxy{}, 0
}
proxies := browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
var hit browser.Proxy
@@ -521,8 +626,8 @@ func (a *App) findUniqueProxyByName(proxyName string) (browser.Proxy, bool) {
hit = proxy
matched++
if matched > 1 {
return browser.Proxy{}, false
return browser.Proxy{}, matched
}
}
return hit, matched == 1
return hit, matched
}
+124
View File
@@ -0,0 +1,124 @@
package backend
import (
"archive/zip"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
)
func TestProfilePackageImportSkipsMissingUserDataWithWarning(t *testing.T) {
app, zipPath := newProfilePackageImportTestApp(t, []browser.Profile{{
ProfileId: "source-1",
ProfileName: "源实例",
UserDataDir: "source-1",
ProxyBindName: "missing-proxy",
}}, nil)
result, err := app.importProfilePackageFromPath(zipPath)
if err != nil {
t.Fatalf("importProfilePackageFromPath returned error: %v", err)
}
if result.ImportedCount != 1 {
t.Fatalf("imported count = %d, want 1", result.ImportedCount)
}
if len(result.Warnings) != 2 {
t.Fatalf("warnings = %#v, want proxy and missing user-data warnings", result.Warnings)
}
joinedWarnings := strings.Join(result.Warnings, "\n")
if !strings.Contains(joinedWarnings, "missing-proxy") || !strings.Contains(joinedWarnings, "没有用户数据目录") {
t.Fatalf("unexpected warnings: %#v", result.Warnings)
}
newID := result.ProfileMappings["source-1"]
if newID == "" {
t.Fatalf("missing profile mapping: %#v", result.ProfileMappings)
}
if _, err := os.Stat(filepath.Join(app.config.Browser.UserDataRoot, newID)); !os.IsNotExist(err) {
t.Fatalf("missing user-data import should not create final dir, stat err=%v", err)
}
}
func TestProfilePackageImportCleansFinalDirWhenSaveFails(t *testing.T) {
app, zipPath := newProfilePackageImportTestApp(t, []browser.Profile{{
ProfileId: "source-1",
ProfileName: "源实例",
UserDataDir: "source-1",
}}, map[string]string{"source-1/Default/Preferences": "{}"})
configPath := filepath.Join(app.appRoot, "config.yaml")
if err := os.WriteFile(configPath, []byte("blocked"), 0o444); err != nil {
t.Fatalf("prepare readonly config failed: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(configPath, 0o644) })
_, err := app.importProfilePackageFromPath(zipPath)
if err == nil {
t.Fatal("expected import to fail when config save fails")
}
entries, readErr := os.ReadDir(app.config.Browser.UserDataRoot)
if readErr != nil {
t.Fatalf("read user data root failed: %v", readErr)
}
for _, entry := range entries {
if entry.Name() == ".imports" {
continue
}
t.Fatalf("expected final user-data dirs to be rolled back, found %s", entry.Name())
}
}
func newProfilePackageImportTestApp(t *testing.T, profiles []browser.Profile, userDataFiles map[string]string) (*App, string) {
t.Helper()
root := t.TempDir()
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = filepath.Join(root, "user-data")
app := NewApp(root)
app.config = cfg
app.browserMgr = browser.NewManager(cfg, root)
app.browserMgr.InitData()
zipPath := filepath.Join(root, "profile-package.zip")
writeTestProfilePackage(t, zipPath, profiles, userDataFiles)
return app, zipPath
}
func writeTestProfilePackage(t *testing.T, zipPath string, profiles []browser.Profile, userDataFiles map[string]string) {
t.Helper()
file, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create zip failed: %v", err)
}
zipWriter := zip.NewWriter(file)
writeJSONToZip(t, zipWriter, "manifest.json", ProfilePackageManifest{Format: profilePackageFormat, Version: 1, ProfileCount: len(profiles)})
writeJSONToZip(t, zipWriter, "profiles.json", profiles)
for name, content := range userDataFiles {
writer, err := zipWriter.Create("user-data/" + filepath.ToSlash(name))
if err != nil {
t.Fatalf("create zip entry failed: %v", err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("write zip entry failed: %v", err)
}
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("close zip failed: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close file failed: %v", err)
}
}
func writeJSONToZip(t *testing.T, zipWriter *zip.Writer, name string, value any) {
t.Helper()
writer, err := zipWriter.Create(name)
if err != nil {
t.Fatalf("create json entry failed: %v", err)
}
if err := json.NewEncoder(writer).Encode(value); err != nil {
t.Fatalf("encode json failed: %v", err)
}
}
+3
View File
@@ -107,6 +107,9 @@ func (a *App) autoDetectCores() {
cores = a.browserMgr.ListCores()
}
for _, core := range cores {
if a.browserMgr == nil {
break
}
result := a.browserMgr.ValidateCorePath(core.CorePath)
if result.Valid {
log.Debug("内核路径有效", logger.F("core_id", core.CoreId), logger.F("path", core.CorePath))
+1 -1
View File
@@ -39,7 +39,7 @@ func buildProxyHTTPClient(
) (*http.Client, error) {
src = strings.TrimSpace(resolveProxyConfig(src, proxies, proxyId))
log := logger.New("ProxyHTTPClient")
resolution, err := ResolveProxyKernel(src, proxies, proxyId, "")
resolution, err := ResolveProxyKernelForConnector(src, proxies, proxyId, connectorType)
if err != nil {
log.Warn("代理内核解析失败",
logger.F("proxy_id", proxyId),
+34
View File
@@ -83,6 +83,12 @@ func ResolveProxyKernel(proxyConfig string, proxies []config.BrowserProxy, proxy
return resolution, nil
}
func ResolveProxyKernelForConnector(proxyConfig string, proxies []config.BrowserProxy, proxyId string, connectorType string) (ProxyKernelResolution, error) {
src := strings.TrimSpace(resolveProxyConfig(proxyConfig, proxies, proxyId))
preferredKernel := preferredKernelForConnector(src, proxies, proxyId, connectorType)
return ResolveProxyKernel(src, proxies, proxyId, preferredKernel)
}
func DetectProxyProtocol(proxyConfig string) string {
src := strings.TrimSpace(proxyConfig)
l := strings.ToLower(src)
@@ -152,3 +158,31 @@ func containsKernel(kernels []string, kernel string) bool {
}
return false
}
func preferredKernelForConnector(src string, proxies []config.BrowserProxy, proxyId string, connectorType string) string {
if config.NormalizeBrowserConnectorType(connectorType) != config.BrowserConnectorMihomo {
return ""
}
if proxyHasExplicitPreferredKernel(proxies, proxyId) {
return ""
}
src = strings.TrimSpace(resolveProxyConfig(src, proxies, proxyId))
protocol := DetectProxyProtocol(src)
if containsKernel(SupportedKernelsForProtocol(protocol, src, proxies, proxyId), ProxyKernelMihomo) {
return ProxyKernelMihomo
}
return ""
}
func proxyHasExplicitPreferredKernel(proxies []config.BrowserProxy, proxyId string) bool {
proxyId = strings.TrimSpace(proxyId)
if proxyId == "" {
return false
}
for _, item := range proxies {
if strings.EqualFold(strings.TrimSpace(item.ProxyId), proxyId) {
return NormalizePreferredKernel(item.PreferredKernel) != ""
}
}
return false
}
@@ -48,3 +48,39 @@ func TestResolveProxyKernelReadsPreferredKernelFromProxy(t *testing.T) {
t.Fatalf("unexpected resolution: %+v", got)
}
}
func TestResolveProxyKernelForConnectorPrefersMihomoStack(t *testing.T) {
got, err := ResolveProxyKernelForConnector("vless://00000000-0000-0000-0000-000000000000@example.com:443", nil, "", config.BrowserConnectorMihomo)
if err != nil {
t.Fatalf("ResolveProxyKernelForConnector returned error: %v", err)
}
if got.Kernel != ProxyKernelMihomo {
t.Fatalf("kernel = %q, want %q; resolution=%+v", got.Kernel, ProxyKernelMihomo, got)
}
}
func TestResolveProxyKernelForConnectorKeepsSingBoxOnlyProtocols(t *testing.T) {
got, err := ResolveProxyKernelForConnector("hysteria2://pass@example.com:443", nil, "", config.BrowserConnectorXray)
if err != nil {
t.Fatalf("ResolveProxyKernelForConnector returned error: %v", err)
}
if got.Kernel != ProxyKernelSingBox {
t.Fatalf("kernel = %q, want %q; resolution=%+v", got.Kernel, ProxyKernelSingBox, got)
}
}
func TestResolveProxyKernelForConnectorExplicitPreferenceWins(t *testing.T) {
proxyID := "p1"
proxies := []config.BrowserProxy{{
ProxyId: proxyID,
ProxyConfig: "vless://00000000-0000-0000-0000-000000000000@example.com:443",
PreferredKernel: ProxyKernelXray,
}}
got, err := ResolveProxyKernelForConnector("", proxies, proxyID, config.BrowserConnectorMihomo)
if err != nil {
t.Fatalf("ResolveProxyKernelForConnector returned error: %v", err)
}
if got.Kernel != ProxyKernelXray {
t.Fatalf("kernel = %q, want explicit %q; resolution=%+v", got.Kernel, ProxyKernelXray, got)
}
}
+59 -15
View File
@@ -14,6 +14,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@@ -28,49 +29,86 @@ type MihomoNodeBridge struct {
Pid int
ConfigPath string
Running bool
RefCount int
LastUsedAt time.Time
ExitDone chan struct{}
ExitErr error
}
func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
proxyURL, _, err := m.ensureNodeBridge(proxyConfig, proxies, proxyId, false)
return proxyURL, err
}
func (m *ClashManager) AcquireNodeBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, string, error) {
return m.ensureNodeBridge(proxyConfig, proxies, proxyId, true)
}
func (m *ClashManager) ReleaseNodeBridge(key string) {
key = strings.TrimSpace(key)
if key == "" || m == nil {
return
}
var bridgeToStop *MihomoNodeBridge
m.mu.Lock()
bridge := m.NodeBridges[key]
if bridge != nil {
if bridge.RefCount > 0 {
bridge.RefCount--
}
bridge.LastUsedAt = time.Now()
if bridge.RefCount <= 0 {
bridge.Running = false
delete(m.NodeBridges, key)
bridgeToStop = bridge
}
}
m.mu.Unlock()
if bridgeToStop != nil && bridgeToStop.Cmd != nil && bridgeToStop.Cmd.Process != nil {
_ = bridgeToStop.Cmd.Process.Kill()
}
}
func (m *ClashManager) ensureNodeBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) {
log := logger.New("Mihomo")
src := strings.TrimSpace(resolveProxyConfig(proxyConfig, proxies, proxyId))
if src == "" {
return "", fmt.Errorf("未找到代理节点")
return "", "", fmt.Errorf("未找到代理节点")
}
if strings.EqualFold(src, "direct://") {
return "direct://", nil
return "direct://", "", nil
}
key := computeNodeKey(src + "\x00mihomo")
unlock := m.lockLaunchForKey(key)
defer unlock()
if proxyURL, reused := m.tryReuseMihomoNodeBridge(key); reused {
if proxyURL, reused := m.tryReuseMihomoNodeBridge(key, pin); reused {
log.Info("复用 mihomo 桥接", logger.F("engine", "mihomo"), logger.F("key", key[:8]), logger.F("proxy_url", proxyURL))
return proxyURL, nil
return proxyURL, key, nil
}
binaryPath, err := m.resolveMihomoBinary()
if err != nil {
return "", err
return "", "", err
}
node, err := buildMihomoNode(src)
if err != nil {
return "", err
return "", "", err
}
port, err := nextAvailablePort()
if err != nil {
return "", err
return "", "", err
}
controllerPort, err := nextAvailablePort()
if err != nil {
return "", err
return "", "", err
}
cfgPath, err := m.buildMihomoNodeConfig(key, node, port, controllerPort)
if err != nil {
return "", err
return "", "", err
}
cmd := exec.Command(binaryPath, "-f", cfgPath, "-d", filepath.Dir(cfgPath))
@@ -85,33 +123,36 @@ func (m *ClashManager) EnsureNodeBridge(proxyConfig string, proxies []config.Bro
if stderrFile != nil {
stderrFile.Close()
}
return "", fmt.Errorf("mihomo 启动失败: %w", err)
return "", "", fmt.Errorf("mihomo 启动失败: %w", err)
}
bridge := &MihomoNodeBridge{NodeKey: key, Port: port, ControllerPort: controllerPort, Cmd: cmd, Pid: cmd.Process.Pid, ConfigPath: cfgPath, Running: true, LastUsedAt: time.Now(), ExitDone: make(chan struct{})}
if pin {
bridge.RefCount = 1
}
m.watchMihomoNodeBridge(bridge)
if err := waitTCPPortReady("127.0.0.1", port, 10*time.Second); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
_ = cmd.Process.Kill()
return "", fmt.Errorf("mihomo mixed-port 未就绪: %w", err)
return "", "", fmt.Errorf("mihomo mixed-port 未就绪: %w", err)
}
if err := waitTCPPortReady("127.0.0.1", controllerPort, 10*time.Second); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
_ = cmd.Process.Kill()
return "", fmt.Errorf("mihomo 控制端口未就绪: %w", err)
return "", "", fmt.Errorf("mihomo 控制端口未就绪: %w", err)
}
if stderrFile != nil {
stderrFile.Close()
}
m.registerMihomoNodeBridge(key, bridge)
log.Info("mihomo 内核进程已启动", logger.F("engine", "mihomo"), logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
return fmt.Sprintf("http://127.0.0.1:%d", port), nil
return fmt.Sprintf("http://127.0.0.1:%d", port), key, nil
}
func (m *ClashManager) tryReuseMihomoNodeBridge(key string) (string, bool) {
func (m *ClashManager) tryReuseMihomoNodeBridge(key string, pin bool) (string, bool) {
if m == nil {
return "", false
}
@@ -141,6 +182,9 @@ func (m *ClashManager) tryReuseMihomoNodeBridge(key string) (string, bool) {
m.mu.Unlock()
return "", false
}
if pin {
bridge.RefCount++
}
bridge.LastUsedAt = time.Now()
m.mu.Unlock()
return fmt.Sprintf("http://127.0.0.1:%d", bridge.Port), true
@@ -394,7 +438,7 @@ func (m *ClashManager) resolveMihomoWorkdir(key string) string {
func waitTCPPortReady(host string, port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
address := fmt.Sprintf("%s:%d", host, port)
address := net.JoinHostPort(host, strconv.Itoa(port))
for {
conn, err := net.DialTimeout("tcp", address, 200*time.Millisecond)
if err == nil {
+1 -1
View File
@@ -221,7 +221,7 @@ func speedTestTargetURLs(cfg *SpeedTestConfig) []string {
}
func speedTestProbeEngine(src string, proxies []config.BrowserProxy, proxyId string, connectorType string) string {
resolution, err := ResolveProxyKernel(src, proxies, proxyId, "")
resolution, err := ResolveProxyKernelForConnector(src, proxies, proxyId, connectorType)
if err != nil {
if resolution.Kernel != "" {
return resolution.Kernel
+19
View File
@@ -7,7 +7,26 @@ if not exist "%SCRIPT_DIR%publish.ps1" (
if /I not "%NO_PAUSE%"=="1" if /I not "%CI%"=="1" pause
endlocal & exit /b 1
)
if /I "%~1"=="zip" (
shift /1
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish.ps1" -Target WINDOWS -WindowsFormat PORTABLE %*
goto :after_publish
)
if /I "%~1"=="portable" (
shift /1
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish.ps1" -Target WINDOWS -WindowsFormat PORTABLE %*
goto :after_publish
)
if /I "%~1"=="both" (
shift /1
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish.ps1" -Target WINDOWS -WindowsFormat BOTH %*
goto :after_publish
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish.ps1" %*
:after_publish
set "EXIT_CODE=%ERRORLEVEL%"
echo.
+47 -7
View File
@@ -2,7 +2,7 @@
[string]$Target,
[string]$Version,
[ValidateSet("INSTALLER", "PORTABLE", "BOTH")]
[string]$WindowsFormat = "INSTALLER"
[string]$WindowsFormat
)
Set-StrictMode -Version Latest
@@ -194,16 +194,55 @@ function Resolve-PublishTarget {
}
function Resolve-WindowsFormat {
param([string]$InputFormat)
param(
[string]$InputFormat,
[string]$PublishTarget,
[bool]$Interactive
)
$normalized = (Get-TrimmedText $InputFormat).ToUpperInvariant()
if ($normalized -eq "") {
if ($normalized -ne "") {
if ($normalized -notin @("INSTALLER", "PORTABLE", "BOTH")) {
throw "无效的 Windows 输出格式: $InputFormat`n 支持参数: INSTALLER/PORTABLE/BOTH"
}
return $normalized
}
if ($PublishTarget -notin @("WINDOWS", "BOTH") -or -not $Interactive) {
return "INSTALLER"
}
if ($normalized -notin @("INSTALLER", "PORTABLE", "BOTH")) {
throw "无效的 Windows 输出格式: $InputFormat`n 支持参数: INSTALLER/PORTABLE/BOTH"
$formatMapping = @{
"I" = "INSTALLER"
"INSTALLER" = "INSTALLER"
"Z" = "PORTABLE"
"ZIP" = "PORTABLE"
"P" = "PORTABLE"
"PORTABLE" = "PORTABLE"
"B" = "BOTH"
"BOTH" = "BOTH"
}
Write-Host "[2/3] 选择 Windows 输出格式..."
Write-Host ""
Write-Host " [I] 安装包(默认)"
Write-Host " [Z] 便携 ZIP"
Write-Host " [B] 安装包 + 便携 ZIP"
Write-Host ""
while ($true) {
$choice = (Read-Host "请选择 Windows 输出格式 [I/Z/B]").Trim().ToUpperInvariant()
if ($choice -eq "") {
$choice = "I"
}
if ($formatMapping.ContainsKey($choice)) {
$resolvedFormat = $formatMapping[$choice]
Write-Host "✓ 已选择: $resolvedFormat"
Write-Host ""
return $resolvedFormat
}
Write-Host "✗ 未选择有效输出格式" -ForegroundColor Yellow
}
return $normalized
}
function Resolve-NsisPath {
@@ -697,8 +736,9 @@ try {
Write-Host ""
Resolve-Version -ExplicitVersion $Version
$targetWasProvided = (Get-TrimmedText $Target) -ne ""
$publishTarget = Resolve-PublishTarget -InputTarget $Target
$resolvedWindowsFormat = Resolve-WindowsFormat -InputFormat $WindowsFormat
$resolvedWindowsFormat = Resolve-WindowsFormat -InputFormat $WindowsFormat -PublishTarget $publishTarget -Interactive (-not $targetWasProvided)
Invoke-WithTemporaryWailsVersion {
switch ($publishTarget) {
@@ -309,7 +309,12 @@ export function BrowserListPage() {
try {
const result = await importBrowserProfilePackage()
if (result.cancelled) return
toast.success(`已导入 ${result.importedCount} 个实例`)
const warnings = result.warnings || []
if (warnings.length > 0) {
toast.warning(`已导入 ${result.importedCount} 个实例,${warnings.length} 条提示:${warnings[0]}`)
} else {
toast.success(`已导入 ${result.importedCount} 个实例`)
}
setSelectedIds(new Set())
await loadProfiles()
} catch (error: any) {
+1
View File
@@ -53,6 +53,7 @@ export interface BrowserProfilePackageImportResult {
cancelled: boolean
importedCount: number
profileMappings: Record<string, string>
warnings?: string[]
message: string
}
+3 -1
View File
@@ -1,4 +1,4 @@
export namespace automation {
export namespace automation {
export class ScriptPublicAPIVariable {
name: string;
@@ -491,6 +491,7 @@ export namespace backend {
cancelled: boolean;
importedCount: number;
profileMappings: Record<string, string>;
warnings: string[];
message: string;
static createFrom(source: any = {}) {
@@ -502,6 +503,7 @@ export namespace backend {
this.cancelled = source["cancelled"];
this.importedCount = source["importedCount"];
this.profileMappings = source["profileMappings"];
this.warnings = source["warnings"];
this.message = source["message"];
}
}
+5
View File
@@ -91,13 +91,17 @@ func signalExistingSingleInstance(lockPath string) bool {
for attempt := 0; attempt < 5; attempt++ {
info, err := readSingleInstanceLock(lockPath)
if err == nil && strings.TrimSpace(info.Addr) != "" {
grantExistingSingleInstanceForeground(info.PID)
conn, dialErr := net.DialTimeout("tcp", info.Addr, 350*time.Millisecond)
if dialErr == nil {
_ = conn.SetDeadline(time.Now().Add(1200 * time.Millisecond))
_, _ = conn.Write([]byte("activate\n"))
_, _ = bufio.NewReader(conn).ReadString('\n')
_ = conn.Close()
activateExistingSingleInstanceWindow(info.PID)
return true
}
activateExistingSingleInstanceWindow(info.PID)
}
time.Sleep(120 * time.Millisecond)
}
@@ -137,6 +141,7 @@ func (g *singleInstanceGuard) handleConn(conn net.Conn) {
case g.activation <- struct{}{}:
default:
}
_, _ = conn.Write([]byte("ok\n"))
}
func (g *singleInstanceGuard) Close() {
+2
View File
@@ -2,4 +2,6 @@
package main
func grantExistingSingleInstanceForeground(pid int) {}
func activateExistingSingleInstanceWindow(pid int) {}
+30 -8
View File
@@ -9,6 +9,7 @@ import (
)
const (
swShow = 5
swRestore = 9
)
@@ -20,32 +21,53 @@ var (
procIsWindowVisible = user32Activate.NewProc("IsWindowVisible")
procShowWindow = user32Activate.NewProc("ShowWindow")
procSetForegroundWindow = user32Activate.NewProc("SetForegroundWindow")
procSetWindowPos = user32Activate.NewProc("SetWindowPos")
)
const (
hwndTopmost = ^uintptr(0)
hwndNotopmost = ^uintptr(1)
swpNoMove = 0x0002
swpNoSize = 0x0001
swpShowWindow = 0x0040
)
func grantExistingSingleInstanceForeground(pid int) {
if pid > 0 {
procAllowSetForegroundWindow.Call(uintptr(pid))
}
}
func activateExistingSingleInstanceWindow(pid int) {
if pid <= 0 {
return
}
procAllowSetForegroundWindow.Call(uintptr(pid))
grantExistingSingleInstanceForeground(pid)
if hwnd := findTopLevelWindowByPID(uint32(pid)); hwnd != 0 {
procShowWindow.Call(hwnd, swShow)
procShowWindow.Call(hwnd, swRestore)
procSetForegroundWindow.Call(hwnd)
if ok, _, _ := procSetForegroundWindow.Call(hwnd); ok == 0 {
procSetWindowPos.Call(hwnd, hwndTopmost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpShowWindow)
procSetWindowPos.Call(hwnd, hwndNotopmost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpShowWindow)
procSetForegroundWindow.Call(hwnd)
}
}
}
func findTopLevelWindowByPID(pid uint32) uintptr {
var matched uintptr
callback := windows.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
visible, _, _ := procIsWindowVisible.Call(hwnd)
if visible == 0 {
return 1
}
var windowPID uint32
procGetWindowThreadProcessID.Call(hwnd, uintptr(unsafe.Pointer(&windowPID)))
if windowPID == pid {
if visible, _, _ := procIsWindowVisible.Call(hwnd); visible == 0 && matched != 0 {
return 1
}
matched = hwnd
return 0
if visible, _, _ := procIsWindowVisible.Call(hwnd); visible != 0 {
return 0
}
return 1
}
return 1
})