feat: 单实例锁与窗口恢复机制
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

- GUI 单实例锁: 新增 internal/instance 包,首实例监听专用端点,
  第二实例发送 focus 信号后静默退出 (Unix: gui.sock, Win: 18924)
- 守护进程弱锁修复: NewServer() 在 os.Remove 前先 net.Dial 探测,
  有活进程时拒绝启动,避免静默抢占 socket 导致孤儿进程
- macOS .app 重开恢复: 通过 class_addMethod 给 GLFWApplicationDelegate
  注册 applicationShouldHandleReopen:hasVisibleWindows: 回调,
  绕过 GLFW 直接用 Cocoa makeKeyAndOrderFront: 显示窗口
- 三平台覆盖: macOS/Linux 用 unix socket, Windows 用 TCP 端口
This commit is contained in:
2026-07-09 00:29:21 +08:00
parent c56fc0c21c
commit 96b1a8839c
11 changed files with 259 additions and 7 deletions
+53
View File
@@ -1,12 +1,14 @@
package ui
import (
"errors"
"os"
"sync"
"lmvpn/internal/config"
"lmvpn/internal/db"
"lmvpn/internal/i18n"
"lmvpn/internal/instance"
"lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/log"
@@ -54,6 +56,7 @@ type App struct {
currentProfile *model.ServerProfile
defaultProfileID int64
langSetting string
windowHidden bool
}
// Run initialises and starts the GUI application.
@@ -66,6 +69,17 @@ func Run() {
// Logging.
log.Init(log.RoleGUI, paths.LogFile())
// Single-instance enforcement: the first instance holds the lock;
// a second instance signals "focus" to the first and exits.
focusCh, err := instance.Acquire()
if err != nil {
if errors.Is(err, instance.ErrAlreadyRunning) {
log.L().Info("another instance is running, exiting")
os.Exit(0)
}
log.L().Warn("instance lock failed, continuing", "error", err)
}
// Database.
store, err := db.Open()
if err != nil {
@@ -101,6 +115,44 @@ func Run() {
// System tray.
a.setupTray()
// Listen for "focus" signals from second instances and bring the
// window to the front.
if focusCh != nil {
go func() {
for range focusCh {
fyne.Do(func() {
a.windowHidden = false
showAndActivate()
a.window.Show()
a.window.RequestFocus()
})
}
}()
}
// Register a Cocoa handler so that re-opening the .app bundle
// (which does NOT start a new process on macOS) brings the hidden
// window back. On other platforms this is a no-op. This must be
// deferred until after the Fyne/GLFW event loop has started,
// because GLFWApplicationDelegate is not created until glfw.Init()
// runs inside runGL().
a.fyneApp.Lifecycle().SetOnStarted(func() {
onAppActive = func() {
if a.windowHidden {
a.windowHidden = false
showAndActivate()
fyne.Do(func() {
if a.windowHidden {
return
}
a.window.Show()
a.window.RequestFocus()
})
}
}
registerReopenHandler()
})
// Auto-connect if configured.
if cfg.AutoConnect && a.currentProfile != nil {
go func() {
@@ -111,6 +163,7 @@ func Run() {
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.windowHidden = true
hideDockIcon()
a.window.Hide()
} else {