diff --git a/README.md b/README.md index 2dbb2dd9..88d8473d 100644 --- a/README.md +++ b/README.md @@ -230,12 +230,14 @@ data/automation/scripts// 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 发布打包(源码) diff --git a/backend/app_bridge_refs.go b/backend/app_bridge_refs.go index b8e3fa83..a82276f3 100644 --- a/backend/app_bridge_refs.go +++ b/backend/app_bridge_refs.go @@ -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) + } } } diff --git a/backend/app_instance_start_proxy.go b/backend/app_instance_start_proxy.go index a79319db..b0599d62 100644 --- a/backend/app_instance_start_proxy.go +++ b/backend/app_instance_start_proxy.go @@ -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 内核配置。") diff --git a/backend/app_profile_package_api.go b/backend/app_profile_package_api.go index a7d1de31..b025d0d3 100644 --- a/backend/app_profile_package_api.go +++ b/backend/app_profile_package_api.go @@ -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 } diff --git a/backend/app_profile_package_api_test.go b/backend/app_profile_package_api_test.go new file mode 100644 index 00000000..9622cf35 --- /dev/null +++ b/backend/app_profile_package_api_test.go @@ -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) + } +} diff --git a/backend/app_utils.go b/backend/app_utils.go index 4724c9f9..09c095a0 100644 --- a/backend/app_utils.go +++ b/backend/app_utils.go @@ -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)) diff --git a/backend/internal/proxy/http_client.go b/backend/internal/proxy/http_client.go index 736a3ab8..4200eb7a 100644 --- a/backend/internal/proxy/http_client.go +++ b/backend/internal/proxy/http_client.go @@ -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), diff --git a/backend/internal/proxy/kernel_resolver.go b/backend/internal/proxy/kernel_resolver.go index 268f5cad..fbe1bfd9 100644 --- a/backend/internal/proxy/kernel_resolver.go +++ b/backend/internal/proxy/kernel_resolver.go @@ -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 +} diff --git a/backend/internal/proxy/kernel_resolver_test.go b/backend/internal/proxy/kernel_resolver_test.go index 9ea51849..21fcf555 100644 --- a/backend/internal/proxy/kernel_resolver_test.go +++ b/backend/internal/proxy/kernel_resolver_test.go @@ -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) + } +} diff --git a/backend/internal/proxy/mihomo_bridge.go b/backend/internal/proxy/mihomo_bridge.go index 64311b38..388af3ea 100644 --- a/backend/internal/proxy/mihomo_bridge.go +++ b/backend/internal/proxy/mihomo_bridge.go @@ -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 { diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go index 18004cd0..04dc2cef 100644 --- a/backend/internal/proxy/speedtest.go +++ b/backend/internal/proxy/speedtest.go @@ -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 diff --git a/bat/publish.bat b/bat/publish.bat index 412a455e..bbebb559 100644 --- a/bat/publish.bat +++ b/bat/publish.bat @@ -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. diff --git a/bat/publish.ps1 b/bat/publish.ps1 index 3c545582..ca4298da 100644 --- a/bat/publish.ps1 +++ b/bat/publish.ps1 @@ -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) { diff --git a/frontend/src/modules/browser/pages/BrowserListPage.tsx b/frontend/src/modules/browser/pages/BrowserListPage.tsx index 6b434c9d..ba1d2e30 100644 --- a/frontend/src/modules/browser/pages/BrowserListPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserListPage.tsx @@ -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) { diff --git a/frontend/src/modules/browser/types.ts b/frontend/src/modules/browser/types.ts index f53e72c4..9104add5 100644 --- a/frontend/src/modules/browser/types.ts +++ b/frontend/src/modules/browser/types.ts @@ -53,6 +53,7 @@ export interface BrowserProfilePackageImportResult { cancelled: boolean importedCount: number profileMappings: Record + warnings?: string[] message: string } diff --git a/frontend/src/wailsjs/go/models.ts b/frontend/src/wailsjs/go/models.ts index 3649a7d7..5324c326 100755 --- a/frontend/src/wailsjs/go/models.ts +++ b/frontend/src/wailsjs/go/models.ts @@ -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; + 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"]; } } diff --git a/single_instance.go b/single_instance.go index 8df35888..cf2574fa 100644 --- a/single_instance.go +++ b/single_instance.go @@ -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() { diff --git a/single_instance_activate_other.go b/single_instance_activate_other.go index d5777676..1834c04a 100644 --- a/single_instance_activate_other.go +++ b/single_instance_activate_other.go @@ -2,4 +2,6 @@ package main +func grantExistingSingleInstanceForeground(pid int) {} + func activateExistingSingleInstanceWindow(pid int) {} diff --git a/single_instance_activate_windows.go b/single_instance_activate_windows.go index 53490c5c..f94b00b0 100644 --- a/single_instance_activate_windows.go +++ b/single_instance_activate_windows.go @@ -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 })