mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
chore: prepare 1.3.0 release
This commit is contained in:
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 内核配置。")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface BrowserProfilePackageImportResult {
|
||||
cancelled: boolean
|
||||
importedCount: number
|
||||
profileMappings: Record<string, string>
|
||||
warnings?: string[]
|
||||
message: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,4 +2,6 @@
|
||||
|
||||
package main
|
||||
|
||||
func grantExistingSingleInstanceForeground(pid int) {}
|
||||
|
||||
func activateExistingSingleInstanceWindow(pid int) {}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user