Files
Ant-Browser/backend/app_instance.go
T
Ant Browser Release Bot fc5a6fadbe publish: 1.1.0 snapshot (ff46de5)
channel: master
version: 1.1.0
source-ref: master
published-at-utc: 2026-03-20T14:14:07Z
2026-03-20 22:14:08 +08:00

788 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backend
import (
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/logger"
"ant-chrome/backend/internal/proxy"
"fmt"
"os"
"os/exec"
"path/filepath"
stdruntime "runtime"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// ============================================================================
// 浏览器实例管理 API
// ============================================================================
func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) {
return a.browserInstanceStartInternal(profileId, nil, nil, false, false)
}
// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库)
func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) {
return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, true)
}
func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool) (*BrowserProfile, error) {
log := logger.New("Browser")
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs)
normalizedStartURLs := normalizeNonEmptyStrings(startURLs)
if preferVisibleWindow {
normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs)
}
profile, exists := a.browserMgr.Profiles[profileId]
if !exists {
err := fmt.Errorf("实例启动失败:未找到实例配置(ID=%s)。请刷新列表后重试。", profileId)
log.Error("实例不存在", logger.F("profile_id", profileId), logger.F("reason", err.Error()))
return nil, err
}
if profile.Running {
if !isBrowserProfileLive(profile) {
log.Info("检测到实例运行状态已失效,准备重新启动",
logger.F("profile_id", profileId),
logger.F("pid", profile.Pid),
logger.F("debug_port", profile.DebugPort),
)
a.markProfileStoppedLocked(profileId, profile)
} else {
if preferVisibleWindow {
if err := a.openBrowserWindowForRunningProfile(profile, normalizedExtraLaunchArgs, normalizedStartURLs); err != nil {
startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err)
log.Error("运行中实例窗口唤起失败",
logger.F("profile_id", profileId),
logger.F("debug_port", profile.DebugPort),
logger.F("error", err.Error()),
logger.F("reason", startErr.Error()),
)
profile.LastError = startErr.Error()
return profile, startErr
}
}
if a.launchServer != nil {
a.launchServer.SetActiveProfile(profile)
}
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "browser:instance:started", map[string]interface{}{
"profileId": profile.ProfileId,
"profileName": profile.ProfileName,
"debugPort": profile.DebugPort,
"pid": profile.Pid,
"reused": true,
})
}
return profile, nil
}
}
proxyChanged := a.browserMgr.ApplyDefaults(profile)
if proxyChanged {
_ = a.browserMgr.SaveProfiles()
}
chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile)
if err != nil {
startErr := fmt.Errorf("实例启动失败:%w", err)
log.Error("内核路径解析失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
return profile, startErr
}
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
if err := os.MkdirAll(userDataDir, 0755); err != nil {
startErr := fmt.Errorf("实例启动失败:无法创建用户数据目录 %s。原因:%w。请检查目录权限或路径配置。", userDataDir, err)
log.Error("用户数据目录创建失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
return profile, startErr
}
// 每次启动时合并默认书签(已存在的 URL 不重复添加)
if err := browser.EnsureDefaultBookmarks(userDataDir, a.BookmarkList()); err != nil {
log.Error("默认书签写入失败", logger.F("error", err.Error()))
}
proxies := a.getLatestProxies()
acquiredXrayBridgeKey := ""
releaseXrayBridge := false
defer func() {
if releaseXrayBridge && acquiredXrayBridgeKey != "" && a.xrayMgr != nil {
a.xrayMgr.ReleaseBridge(acquiredXrayBridgeKey)
}
}()
// 解析实际代理配置(可能来自 proxyId 引用)
resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig)
if profile.ProxyId != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, profile.ProxyId) {
resolvedProxyConfig = strings.TrimSpace(item.ProxyConfig)
break
}
}
}
effectiveProxy := resolvedProxyConfig
log.Info("代理配置检查",
logger.F("profile_id", profileId),
logger.F("proxy_id", profile.ProxyId),
logger.F("profile_proxy_config", profile.ProxyConfig),
logger.F("resolved_proxy_config", resolvedProxyConfig),
)
if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported {
startErr := fmt.Errorf("实例启动失败:%s", errorMsg)
profile.LastError = startErr.Error()
log.Error("代理配置无效", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId), logger.F("error", errorMsg), logger.F("reason", startErr.Error()))
return profile, startErr
}
if proxy.IsSingBoxProtocol(resolvedProxyConfig) {
// hysteria2 / tuic → sing-box 桥接
socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
log.Error("代理桥接失败(sing-box)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{
"profileId": profileId,
"profileName": profile.ProfileName,
"error": startErr.Error(),
})
}
return profile, startErr
}
effectiveProxy = socksURL
log.Info("sing-box 桥接成功", logger.F("socks_url", socksURL))
} else if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) {
// vmess / vless / trojan / ss → xray 桥接
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
log.Error("代理桥接失败(xray)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{
"profileId": profileId,
"profileName": profile.ProfileName,
"error": startErr.Error(),
})
}
return profile, startErr
}
acquiredXrayBridgeKey = bridgeKey
releaseXrayBridge = bridgeKey != ""
effectiveProxy = socksURL
log.Info("xray 桥接成功", logger.F("socks_url", socksURL))
}
debugPort, err := nextAvailablePort()
if err != nil {
startErr := fmt.Errorf("实例启动失败:本地调试端口分配失败。原因:%v。请关闭占用端口的程序后重试。", err)
log.Error("调试端口分配失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
return profile, startErr
}
args := []string{
fmt.Sprintf("--user-data-dir=%s", userDataDir),
fmt.Sprintf("--remote-debugging-port=%d", debugPort),
"--disable-session-crashed-bubble",
}
hasFingerprint := false
for _, arg := range profile.FingerprintArgs {
if strings.HasPrefix(arg, "--fingerprint=") {
hasFingerprint = true
break
}
}
if !hasFingerprint {
seed := 0
for _, char := range profile.ProfileId {
seed = (seed << 5) - seed + int(char)
}
if seed < 0 {
seed = -seed
}
args = append(args, fmt.Sprintf("--fingerprint=%d", seed))
}
if effectiveProxy == "direct://" {
// 强制直连,覆盖系统全局代理
args = append(args, "--proxy-server=direct://")
} else if effectiveProxy != "" {
args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy))
}
args = append(args, profile.FingerprintArgs...)
args = append(args, profile.LaunchArgs...)
args = append(args, normalizedExtraLaunchArgs...)
args = appendLaunchTargets(args, profile, normalizedStartURLs, skipDefaultStartURLs)
cmd := exec.Command(chromeBinaryPath, args...)
cmd.Dir = filepath.Dir(chromeBinaryPath)
if err := cmd.Start(); err != nil {
startErr := fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err))
log.Error("浏览器进程启动失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error()))
profile.LastError = startErr.Error()
return profile, startErr
}
if err := waitBrowserDebugPortStable(debugPort, browserStartReadyTimeout, browserStartStableWindow); err != nil {
startErr := fmt.Errorf("%s", describeBrowserReadyTimeout(debugPort, browserStartReadyTimeout))
log.Error("浏览器启动未就绪", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error()))
_ = a.stopProcessCmd(cmd)
go func() {
_ = cmd.Wait()
}()
profile.LastError = startErr.Error()
return profile, startErr
}
a.browserMgr.BrowserProcesses[profileId] = cmd
profile.Running = true
profile.DebugPort = debugPort
profile.Pid = cmd.Process.Pid
profile.LastStartAt = time.Now().Format(time.RFC3339)
profile.LastError = ""
if acquiredXrayBridgeKey != "" {
a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey)
releaseXrayBridge = false
}
if a.launchServer != nil {
a.launchServer.SetActiveProfile(profile)
}
log.Info("实例启动", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("pid", profile.Pid), logger.F("proxy", effectiveProxy), logger.F("args", strings.Join(args, " ")))
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "browser:instance:started", map[string]interface{}{
"profileId": profile.ProfileId,
"profileName": profile.ProfileName,
"debugPort": profile.DebugPort,
"pid": profile.Pid,
"reused": false,
})
}
go a.waitBrowserProcess(profileId, cmd)
return profile, nil
}
func (a *App) BrowserInstanceStop(profileId string) (*BrowserProfile, error) {
log := logger.New("Browser")
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
profile, exists := a.browserMgr.Profiles[profileId]
if !exists {
return nil, fmt.Errorf("profile not found")
}
cmd := a.browserMgr.BrowserProcesses[profileId]
debugPort := profile.DebugPort
if tryCloseBrowserViaCDP(debugPort, 5*time.Second) {
a.markProfileStoppedLocked(profileId, profile)
log.Info("实例停止", logger.F("profile_id", profileId), logger.F("method", "cdp"), logger.F("debug_port", debugPort))
return profile, nil
}
if cmd != nil && cmd.Process != nil {
if err := a.stopBrowserProcess(cmd); err != nil {
log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("error", err))
profile.LastError = err.Error()
return profile, err
}
}
if debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) {
err := fmt.Errorf("实例停止失败:浏览器仍在运行(调试端口 %d 仍可访问)", debugPort)
log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("reason", err.Error()))
profile.LastError = err.Error()
return profile, err
}
a.markProfileStoppedLocked(profileId, profile)
log.Info("实例停止", logger.F("profile_id", profileId))
return profile, nil
}
func (a *App) BrowserInstanceRestart(profileId string) (*BrowserProfile, error) {
if _, err := a.BrowserInstanceStop(profileId); err != nil {
return nil, err
}
return a.BrowserInstanceStart(profileId)
}
// BrowserProfileBatchSetTags 批量为实例设置标签(追加模式:将 tags 加入已有标签;replace 模式:直接替换)
func (a *App) BrowserProfileBatchSetTags(profileIds []string, tags []string, replace bool) error {
log := logger.New("Browser")
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
for _, profileId := range profileIds {
profile, exists := a.browserMgr.Profiles[profileId]
if !exists {
continue
}
if replace {
profile.Tags = tags
} else {
// 追加去重
existing := make(map[string]struct{})
for _, t := range profile.Tags {
existing[t] = struct{}{}
}
for _, t := range tags {
if _, ok := existing[t]; !ok {
profile.Tags = append(profile.Tags, t)
existing[t] = struct{}{}
}
}
}
profile.UpdatedAt = time.Now().Format(time.RFC3339)
if a.browserMgr.ProfileDAO != nil {
if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil {
log.Error("批量设置标签失败", logger.F("profile_id", profileId), logger.F("error", err))
return err
}
}
}
return nil
}
// BrowserProfileBatchRemoveTags 批量从实例移除指定标签
func (a *App) BrowserProfileBatchRemoveTags(profileIds []string, tags []string) error {
log := logger.New("Browser")
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
removeSet := make(map[string]struct{})
for _, t := range tags {
removeSet[t] = struct{}{}
}
for _, profileId := range profileIds {
profile, exists := a.browserMgr.Profiles[profileId]
if !exists {
continue
}
filtered := profile.Tags[:0]
for _, t := range profile.Tags {
if _, ok := removeSet[t]; !ok {
filtered = append(filtered, t)
}
}
profile.Tags = filtered
profile.UpdatedAt = time.Now().Format(time.RFC3339)
if a.browserMgr.ProfileDAO != nil {
if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil {
log.Error("批量移除标签失败", logger.F("profile_id", profileId), logger.F("error", err))
return err
}
}
}
return nil
}
// BrowserRenameTag 重命名所有实例中的指定标签
func (a *App) BrowserRenameTag(oldName string, newName string) error {
log := logger.New("Browser")
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return fmt.Errorf("标签名称不能为空")
}
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
changedCount := 0
for profileId, profile := range a.browserMgr.Profiles {
tagChanged := false
var newTags []string
for _, t := range profile.Tags {
if strings.EqualFold(t, oldName) {
newTags = append(newTags, newName)
tagChanged = true
} else {
newTags = append(newTags, t)
}
}
if tagChanged {
// 去重
uniqueTags := make([]string, 0)
seen := make(map[string]struct{})
for _, t := range newTags {
if _, ok := seen[t]; !ok {
uniqueTags = append(uniqueTags, t)
seen[t] = struct{}{}
}
}
profile.Tags = uniqueTags
profile.UpdatedAt = time.Now().Format(time.RFC3339)
if a.browserMgr.ProfileDAO != nil {
if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil {
log.Error("重命名标签保存失败", logger.F("profile_id", profileId), logger.F("error", err))
return err
}
}
changedCount++
}
}
if changedCount > 0 && a.browserMgr.ProfileDAO == nil {
if err := a.browserMgr.SaveProfiles(); err != nil {
return err
}
}
if changedCount > 0 {
log.Info("重命名标签成功", logger.F("old", oldName), logger.F("new", newName), logger.F("changed_profiles", changedCount))
}
return nil
}
func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) {
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
profile, exists := a.browserMgr.Profiles[profileId]
if !exists {
return nil, fmt.Errorf("profile not found")
}
return profile, nil
}
func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) bool {
a.browserMgr.Mutex.Lock()
profile, exists := a.browserMgr.Profiles[profileId]
a.browserMgr.Mutex.Unlock()
if !exists || !profile.Running {
return false
}
return true
}
func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab {
return []BrowserTab{
{TabId: "tab-1", Title: "新标签页", Url: "about:blank", Active: true},
{TabId: "tab-2", Title: "示例站点", Url: "https://example.com", Active: false},
}
}
func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) {
err := cmd.Wait()
log := logger.New("Browser")
debugPort := 0
profileName := profileId
shouldMonitorDetached := false
a.browserMgr.Mutex.Lock()
profile, exists := a.browserMgr.Profiles[profileId]
wasRunning := exists && profile.Running
if exists {
profileName = profile.ProfileName
debugPort = profile.DebugPort
}
a.browserMgr.Mutex.Unlock()
if wasRunning && debugPort > 0 && canConnectDebugPort(debugPort, 250*time.Millisecond) {
a.browserMgr.Mutex.Lock()
profile, exists = a.browserMgr.Profiles[profileId]
if exists && profile.Running && profile.DebugPort == debugPort {
delete(a.browserMgr.BrowserProcesses, profileId)
profile.Pid = 0
shouldMonitorDetached = true
}
a.browserMgr.Mutex.Unlock()
if shouldMonitorDetached {
log.Info("浏览器启动器进程已退出,切换为调试端口存活监控",
logger.F("profile_id", profileId),
logger.F("profile_name", profileName),
logger.F("debug_port", debugPort),
)
a.waitDetachedBrowser(profileId, debugPort)
return
}
}
a.browserMgr.Mutex.Lock()
profile, exists = a.browserMgr.Profiles[profileId]
wasRunning = exists && profile.Running
if exists {
profileName = profile.ProfileName
a.markProfileStoppedLocked(profileId, profile)
}
a.browserMgr.Mutex.Unlock()
if a.ctx == nil {
return
}
// 进程是正常退出(用户手动关闭)还是异常崩溃
if wasRunning && err != nil {
// 异常退出,推送崩溃通知
if exists && profile != nil {
profile.LastError = fmt.Sprintf("实例运行异常退出:%s", err.Error())
}
log.Error("浏览器进程异常退出", logger.F("profile_id", profileId), logger.F("profile_name", profileName), logger.F("error", err))
runtime.EventsEmit(a.ctx, "browser:instance:crashed", map[string]interface{}{
"profileId": profileId,
"profileName": profileName,
"error": err.Error(),
})
} else {
runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId)
}
}
func (a *App) waitDetachedBrowser(profileId string, debugPort int) {
const (
pollInterval = 500 * time.Millisecond
maxMisses = 3
)
log := logger.New("Browser")
misses := 0
for {
if canConnectDebugPort(debugPort, 250*time.Millisecond) {
misses = 0
time.Sleep(pollInterval)
continue
}
misses++
if misses < maxMisses {
time.Sleep(pollInterval)
continue
}
profileName := profileId
a.browserMgr.Mutex.Lock()
profile, exists := a.browserMgr.Profiles[profileId]
if !exists || !profile.Running || profile.DebugPort != debugPort {
a.browserMgr.Mutex.Unlock()
return
}
profileName = profile.ProfileName
a.markProfileStoppedLocked(profileId, profile)
a.browserMgr.Mutex.Unlock()
log.Info("检测到浏览器调试端口关闭,实例已停止",
logger.F("profile_id", profileId),
logger.F("profile_name", profileName),
logger.F("debug_port", debugPort),
)
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId)
}
return
}
}
func tryCloseBrowserViaCDP(debugPort int, timeout time.Duration) bool {
if debugPort <= 0 || !canConnectDebugPort(debugPort, 250*time.Millisecond) {
return false
}
_ = cdpBrowserCall(debugPort, "Browser.close", nil)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !canConnectDebugPort(debugPort, 250*time.Millisecond) {
return true
}
time.Sleep(150 * time.Millisecond)
}
return false
}
func normalizeNonEmptyStrings(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
v := strings.TrimSpace(item)
if v != "" {
out = append(out, v)
}
}
return out
}
func ensureNewWindowLaunchArg(args []string) []string {
for _, arg := range args {
if strings.EqualFold(strings.TrimSpace(arg), "--new-window") {
return args
}
}
return append(args, "--new-window")
}
func appendLaunchTargets(args []string, profile *BrowserProfile, startURLs []string, skipDefaultStartURLs bool) []string {
if len(startURLs) > 0 {
return append(args, startURLs...)
}
if !skipDefaultStartURLs {
return browser.BuildLaunchArgs(args, profile)
}
return args
}
func isBrowserProfileLive(profile *BrowserProfile) bool {
if profile == nil || !profile.Running || profile.DebugPort <= 0 {
return false
}
return canConnectDebugPort(profile.DebugPort, 250*time.Millisecond)
}
func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile) {
if profile == nil {
return
}
profile.Running = false
profile.Pid = 0
profile.DebugPort = 0
profile.LastStopAt = time.Now().Format(time.RFC3339)
delete(a.browserMgr.BrowserProcesses, profileId)
a.releaseProfileXrayBridge(profileId)
if a.launchServer != nil {
a.launchServer.ClearActiveProfile(profileId)
}
}
func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraLaunchArgs []string, startURLs []string) error {
chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile)
if err != nil {
return err
}
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
if err := os.MkdirAll(userDataDir, 0755); err != nil {
return fmt.Errorf("无法创建用户数据目录 %s%w", userDataDir, err)
}
args := []string{
fmt.Sprintf("--user-data-dir=%s", userDataDir),
}
args = append(args, extraLaunchArgs...)
if len(startURLs) > 0 {
args = append(args, startURLs...)
} else {
args = append(args, "about:blank")
}
cmd := exec.Command(chromeBinaryPath, args...)
cmd.Dir = filepath.Dir(chromeBinaryPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err))
}
go func() {
_ = cmd.Wait()
}()
return nil
}
func (a *App) stopBrowserProcess(cmd *exec.Cmd) error {
return a.stopProcessCmd(cmd)
}
func (a *App) stopProcessCmd(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
// Windows 下优先非强制 taskkill,尽量让 Chromium 走正常退出路径,减少“恢复页面”提示。
if stdruntime.GOOS == "windows" {
pid := cmd.Process.Pid
if pid > 0 {
softKillCmd := exec.Command("taskkill", "/PID", fmt.Sprintf("%d", pid), "/T")
hideWindow(softKillCmd)
if err := softKillCmd.Run(); err == nil {
if waitProcessExitWindows(pid, 3*time.Second) {
return nil
}
forceKillCmd := exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid), "/T")
hideWindow(forceKillCmd)
if forceErr := forceKillCmd.Run(); forceErr == nil {
_ = waitProcessExitWindows(pid, 2*time.Second)
return nil
}
}
}
}
err := cmd.Process.Kill()
if err == nil || isProcessAlreadyFinished(err) {
return nil
}
return err
}
func isProcessAlreadyFinished(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if msg == "" {
return false
}
if strings.Contains(msg, "process already finished") {
return true
}
if strings.Contains(msg, "not found") {
return true
}
if strings.Contains(msg, "no process") {
return true
}
if strings.Contains(msg, "不存在") {
return true
}
return false
}
func waitProcessExitWindows(pid int, timeout time.Duration) bool {
if pid <= 0 {
return true
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
alive, err := isProcessAliveWindows(pid)
if err == nil && !alive {
return true
}
time.Sleep(150 * time.Millisecond)
}
alive, err := isProcessAliveWindows(pid)
if err != nil {
return false
}
return !alive
}
func isProcessAliveWindows(pid int) (bool, error) {
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH")
hideWindow(cmd)
out, err := cmd.Output()
if err != nil {
return false, err
}
line := strings.TrimSpace(string(out))
if line == "" {
return false, nil
}
if strings.HasPrefix(strings.ToUpper(line), "INFO:") {
return false, nil
}
token := fmt.Sprintf("\",\"%d\",", pid)
return strings.Contains(line, token), nil
}