diff --git a/Makefile b/Makefile index 3f0edfd..8432ef8 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/internal/instance/instance.go b/internal/instance/instance.go new file mode 100644 index 0000000..b9a9bfa --- /dev/null +++ b/internal/instance/instance.go @@ -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: + } + } +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 332c994..2022554 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -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) } diff --git a/internal/paths/paths_darwin.go b/internal/paths/paths_darwin.go index 6cd2055..7134173 100644 --- a/internal/paths/paths_darwin.go +++ b/internal/paths/paths_darwin.go @@ -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) diff --git a/internal/paths/paths_other.go b/internal/paths/paths_other.go index 735fa35..a1b90ae 100644 --- a/internal/paths/paths_other.go +++ b/internal/paths/paths_other.go @@ -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) diff --git a/internal/paths/paths_windows.go b/internal/paths/paths_windows.go index 2a8eeea..4b0b023 100644 --- a/internal/paths/paths_windows.go +++ b/internal/paths/paths_windows.go @@ -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) diff --git a/internal/ui/app.go b/internal/ui/app.go index ae34780..fc64dcf 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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 { diff --git a/internal/ui/dock_callback_darwin.go b/internal/ui/dock_callback_darwin.go new file mode 100644 index 0000000..9582481 --- /dev/null +++ b/internal/ui/dock_callback_darwin.go @@ -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() + } +} diff --git a/internal/ui/dock_darwin.go b/internal/ui/dock_darwin.go index 8f67a72..fc87167 100644 --- a/internal/ui/dock_darwin.go +++ b/internal/ui/dock_darwin.go @@ -8,13 +8,79 @@ package ui #include #include +// 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() } diff --git a/internal/ui/dock_other.go b/internal/ui/dock_other.go index 8bb2731..7f0c690 100644 --- a/internal/ui/dock_other.go +++ b/internal/ui/dock_other.go @@ -4,3 +4,9 @@ package ui func showDockIcon() {} func hideDockIcon() {} + +func activateApp() {} + +func showAndActivate() {} + +func registerReopenHandler() {} diff --git a/internal/ui/tray.go b/internal/ui/tray.go index a1457cd..38e6806 100644 --- a/internal/ui/tray.go +++ b/internal/ui/tray.go @@ -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,