feat: 单实例锁与窗口恢复机制
- 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:
@@ -11,7 +11,7 @@ GO = go
|
||||
CGO_ENABLED = 1
|
||||
WINDRES ?= x86_64-w64-mingw32-windres
|
||||
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
||||
SEMVER ?= 0.4.4
|
||||
SEMVER ?= 0.4.6
|
||||
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
VERSION = $(SEMVER)-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package instance implements single-instance enforcement for the GUI
|
||||
// process. The first instance to start acquires the lock by listening
|
||||
// on a dedicated endpoint; subsequent instances dial the endpoint
|
||||
// (signalling "bring to front") and exit immediately.
|
||||
//
|
||||
// The lock is self-cleaning: a crashed process releases the listener
|
||||
// automatically (the kernel closes the socket). On unix a stale socket
|
||||
// file may remain; Acquire probes it with a dial before removing.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// ErrAlreadyRunning is returned by Acquire when another GUI instance
|
||||
// is already running. The caller should exit silently.
|
||||
var ErrAlreadyRunning = errors.New("another instance is already running")
|
||||
|
||||
// Acquire attempts to become the sole GUI instance. On success it
|
||||
// returns a channel that receives a value every time another instance
|
||||
// signals (the caller should bring its window to the front). The
|
||||
// listener is closed when the channel is closed, which happens never
|
||||
// during normal operation - the process simply exits and the OS
|
||||
// releases the socket.
|
||||
//
|
||||
// On failure it returns ErrAlreadyRunning (the caller should exit) or
|
||||
// another error (the caller may continue without single-instance
|
||||
// protection, logging the issue).
|
||||
func Acquire() (<-chan struct{}, error) {
|
||||
netType := paths.GUILockNetwork()
|
||||
addr := paths.GUILockAddress()
|
||||
|
||||
l, err := net.Listen(netType, addr)
|
||||
if err == nil {
|
||||
ch := make(chan struct{}, 1)
|
||||
go acceptLoop(l, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Listen failed - check whether another instance is alive.
|
||||
if c, derr := net.Dial(netType, addr); derr == nil {
|
||||
c.Close()
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
|
||||
// Nobody is listening. On unix this is likely a stale socket file
|
||||
// left by a crashed process; remove it and retry once.
|
||||
if netType == "unix" {
|
||||
_ = os.Remove(addr)
|
||||
l, err = net.Listen(netType, addr)
|
||||
if err == nil {
|
||||
ch := make(chan struct{}, 1)
|
||||
go acceptLoop(l, ch)
|
||||
return ch, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("acquire instance lock: %w", err)
|
||||
}
|
||||
|
||||
// acceptLoop accepts connections from second instances. Each connection
|
||||
// is a "focus" signal; the handler closes it immediately and notifies
|
||||
// the caller via ch.
|
||||
func acceptLoop(l net.Listener, ch chan<- struct{}) {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -109,8 +109,16 @@ func NewServer() (*Server, error) {
|
||||
netType := paths.IPCNetwork()
|
||||
addr := paths.IPCAddress()
|
||||
|
||||
// Clean up stale unix socket file (not needed for TCP).
|
||||
// Guard against a second daemon instance on unix: probe the
|
||||
// socket before removing it. If the dial succeeds, another
|
||||
// daemon is alive and owns the socket - refuse to start so we
|
||||
// don't silently orphan it. Only remove the file when the dial
|
||||
// fails (stale socket from a crashed process).
|
||||
if netType == "unix" {
|
||||
if c, derr := net.Dial(netType, addr); derr == nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("daemon already running on %s", addr)
|
||||
}
|
||||
_ = os.Remove(addr)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ func IPCNetwork() string { return "unix" }
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
func GUILockNetwork() string { return "unix" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -13,6 +13,12 @@ func IPCNetwork() string { return "unix" }
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
func GUILockNetwork() string { return "unix" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -20,6 +20,13 @@ const ipcPort = "18923"
|
||||
|
||||
func IPCAddress() string { return "127.0.0.1:" + ipcPort }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
// Windows uses TCP (same reason as IPC: AF_UNIX integrity-level checks).
|
||||
func GUILockNetwork() string { return "tcp" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return "127.0.0.1:18924" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import "C"
|
||||
|
||||
// onAppActive is called from the Cocoa main thread when the app
|
||||
// becomes active (e.g. the user re-opens the .app bundle while the
|
||||
// process is still running). It is set once in Run() before the
|
||||
// event loop starts, so no synchronisation is needed.
|
||||
var onAppActive func()
|
||||
|
||||
//export cmAppBecameActive
|
||||
func cmAppBecameActive() {
|
||||
if onAppActive != nil {
|
||||
onAppActive()
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,79 @@ package ui
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
|
||||
// Forward declaration: Go callback (defined via //export in
|
||||
// dock_callback_darwin.go). Called when the user re-opens the .app
|
||||
// bundle while the process is still running (e.g. after closing the
|
||||
// window to tray).
|
||||
extern void cmAppBecameActive(void);
|
||||
|
||||
// appShouldHandleReopen is the IMP added to GLFWApplicationDelegate
|
||||
// for the applicationShouldHandleReopen:hasVisibleWindows: selector.
|
||||
// This is the delegate method macOS calls when the user re-opens an
|
||||
// already-running .app bundle. We return YES so macOS proceeds with
|
||||
// its default activation, and invoke the Go callback to show the
|
||||
// hidden window.
|
||||
static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
|
||||
cmAppBecameActive();
|
||||
return YES;
|
||||
}
|
||||
|
||||
static void setDockIconVisible(int visible) {
|
||||
Class cls = objc_getClass("NSApplication");
|
||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1);
|
||||
}
|
||||
|
||||
static void cmActivateApp(void) {
|
||||
Class cls = objc_getClass("NSApplication");
|
||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||
((void (*)(id, SEL, BOOL))objc_msgSend)(app, sel_getUid("activateIgnoringOtherApps:"), YES);
|
||||
}
|
||||
|
||||
// cmShowAndActivate switches to regular activation policy (dock icon
|
||||
// visible), activates the app, and makes every NSWindow key+front.
|
||||
// This bypasses GLFW's glfwShowWindow (which uses orderFront:nil and
|
||||
// is unreliable during NSApplication activation-policy transitions).
|
||||
static void cmShowAndActivate(void) {
|
||||
Class cls = objc_getClass("NSApplication");
|
||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||
// Regular activation policy (dock icon visible).
|
||||
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), 0);
|
||||
// Bring app to foreground.
|
||||
((void (*)(id, SEL, BOOL))objc_msgSend)(app, sel_getUid("activateIgnoringOtherApps:"), YES);
|
||||
// Make every window key and visible. NSArray* windows = [app windows];
|
||||
id windows = ((id (*)(id, SEL))objc_msgSend)(app, sel_getUid("windows"));
|
||||
// NSUInteger count = [windows count];
|
||||
unsigned long count = ((unsigned long (*)(id, SEL))objc_msgSend)(windows, sel_getUid("count"));
|
||||
for (unsigned long i = 0; i < count; i++) {
|
||||
// id w = [windows objectAtIndex:i];
|
||||
id w = ((id (*)(id, SEL, unsigned long))objc_msgSend)(windows, sel_getUid("objectAtIndex:"), i);
|
||||
// [w makeKeyAndOrderFront:nil];
|
||||
((void (*)(id, SEL, id))objc_msgSend)(w, sel_getUid("makeKeyAndOrderFront:"), nil);
|
||||
}
|
||||
}
|
||||
|
||||
// cmRegisterReopenHandler adds applicationShouldHandleReopen:
|
||||
// hasVisibleWindows: to the GLFWApplicationDelegate class at runtime
|
||||
// (class_addMethod). This lets us detect when macOS re-opens the
|
||||
// .app bundle without starting a new process (which bypasses our IPC
|
||||
// single-instance mechanism). If the class already implements the
|
||||
// method, this is a no-op.
|
||||
static void cmRegisterReopenHandler(void) {
|
||||
Class cls = objc_getClass("GLFWApplicationDelegate");
|
||||
if (!cls) return;
|
||||
SEL sel = sel_getUid("applicationShouldHandleReopen:hasVisibleWindows:");
|
||||
if (class_getInstanceMethod(cls, sel)) return;
|
||||
class_addMethod(cls, sel, (IMP)appShouldHandleReopen, "B@:@B");
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func showDockIcon() { C.setDockIconVisible(1) }
|
||||
func hideDockIcon() { C.setDockIconVisible(0) }
|
||||
|
||||
func activateApp() { C.cmActivateApp() }
|
||||
|
||||
func showAndActivate() { C.cmShowAndActivate() }
|
||||
|
||||
func registerReopenHandler() { C.cmRegisterReopenHandler() }
|
||||
|
||||
@@ -4,3 +4,9 @@ package ui
|
||||
|
||||
func showDockIcon() {}
|
||||
func hideDockIcon() {}
|
||||
|
||||
func activateApp() {}
|
||||
|
||||
func showAndActivate() {}
|
||||
|
||||
func registerReopenHandler() {}
|
||||
|
||||
+6
-5
@@ -60,11 +60,12 @@ func (a *App) setupTray() {
|
||||
}
|
||||
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
a.windowHidden = false
|
||||
showAndActivate()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
connectItem,
|
||||
disconnectItem,
|
||||
|
||||
Reference in New Issue
Block a user