4 Commits
Author SHA1 Message Date
kevin 96b1a8839c 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 端口
2026-07-09 00:29:21 +08:00
kevin c56fc0c21c fix: CDN优选IP支持IPv6、格式校验及错误降级
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- replaceHost() 自动为裸 IPv6 地址包裹方括号,修复 IPv6 URL 畸形问题
- 新增 ValidateServerIPs() 校验 IP 格式,保存配置时拦截非法输入
- GetServerIPList() 运行时自动过滤非法 IP 条目
- CDN IP 尝试时 TLS/认证错误降级为非致命,跳过该 IP 继续故障转移
  而非终止整个连接循环(仅原始域名 ipIndex==0 时才判为致命)
- 占位符提示支持 IPv4/IPv6
2026-07-08 23:09:26 +08:00
kevin bb4e4552d8 feat: 记住上次选中的配置,下次启动自动选中
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 启用 AppConfig.DefaultProfileID 字段(此前声明但未使用)
- 启动时 loadProfiles() 优先选中上次记录的配置,找不到则回退到第一个
- 切换配置时立即持久化到 config.yml,防止崩溃丢失
- 使用 Profile ID 而非名称,避免重命名后失效
2026-07-08 21:05:32 +08:00
kevin 3e7df0f4d8 fix: 修复连接状态下切换配置后断开重连导致异常的问题
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
问题现象:连接中切换配置 -> 断开 -> 连接,会出现连接后瞬间断开、
长时间无法重连。而先断开再切换配置则正常。

根因分析(三个层面的资源泄漏叠加):

1. pumpPackets 死锁 (session.go)
   Disconnect() 只关闭 WebSocket transport,不关闭 TUN 设备。
   TUN->WS goroutine 阻塞在 dev.Read(),pumpPackets 的 wg.Wait()
   永远不返回,cleanup()(关闭 TUN/删路由)无法执行,导致
   TUN 设备和路由泄漏。

2. 僵尸 eventLoop + 僵尸 IPC 连接 (view.go)
   onDisconnect 不清空 ipcClient、不关闭 IPC 连接,旧 eventLoop
   持续运行接收广播事件。eventLoop 正常事件处理无身份校验,
   旧 session 的 disconnected 广播会覆盖新 session 的 connected 状态。

3. stopSession 不等待 goroutine 退出 (daemon.go)
   d.session = nil 在 goroutine 仍在运行时即设置,新旧 session
   并发执行导致 TUN/路由冲突。无 mutex 保护并发访问。

修复内容(10 项,3 个文件):

session.go:
- Disconnect() 新增 dev.Close() 解除 pumpPackets 死锁
- 新增 done channel,Disconnect() 阻塞等待 run goroutine 完全退出
- run() deferred setState 跳过用户主动断开时的冗余广播
- run() 顶部 ctx.Err() 检查后补 cleanup()

daemon.go:
- 新增 sync.Mutex 保护 session/cancel 并发访问
- 拆分 stopSession/stopSessionLocked 避免死锁

view.go:
- onDisconnect 置 ipcClient=nil + client.Close() 终止僵尸 eventLoop
- eventLoop 三个事件分支均加 if current == client 身份校验
- onConnect 覆盖前清理旧 IPC client
- setConnButtons 连接时禁用配置下拉框
2026-07-08 20:57:50 +08:00
18 changed files with 451 additions and 52 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.4.1 SEMVER ?= 0.4.6
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH) VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
+16 -3
View File
@@ -19,6 +19,7 @@ import (
"net" "net"
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"lmvpn/internal/ipc" "lmvpn/internal/ipc"
@@ -92,6 +93,7 @@ func chownToUser(path string, uid, gid int) {
type daemon struct { type daemon struct {
server *ipc.Server server *ipc.Server
mu sync.Mutex
session *vpn.SessionManager session *vpn.SessionManager
cancel context.CancelFunc cancel context.CancelFunc
} }
@@ -109,8 +111,11 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
d.server.Close() d.server.Close()
os.Exit(0) os.Exit(0)
case ipc.CmdStats: case ipc.CmdStats:
if d.session != nil { d.mu.Lock()
snap := d.session.Stats().Snapshot() sess := d.session
d.mu.Unlock()
if sess != nil {
snap := sess.Stats().Snapshot()
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
} }
_ = ipc.WriteOK(conn) _ = ipc.WriteOK(conn)
@@ -122,12 +127,14 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
} }
func (d *daemon) startSession(conn net.Conn, req ipc.Request) { func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
d.mu.Lock()
defer d.mu.Unlock()
if req.Config == nil { if req.Config == nil {
_ = ipc.WriteErr(conn, "missing config") _ = ipc.WriteErr(conn, "missing config")
return return
} }
if d.session != nil { if d.session != nil {
d.stopSession() d.stopSessionLocked()
} }
cfg := vpn.SessionConfig{ cfg := vpn.SessionConfig{
@@ -170,6 +177,12 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
} }
func (d *daemon) stopSession() { func (d *daemon) stopSession() {
d.mu.Lock()
defer d.mu.Unlock()
d.stopSessionLocked()
}
func (d *daemon) stopSessionLocked() {
if d.cancel != nil { if d.cancel != nil {
d.cancel() d.cancel()
d.cancel = nil d.cancel = nil
+2 -1
View File
@@ -45,6 +45,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
DlgProfileTitle = "Profile" DlgProfileTitle = "Profile"
DlgValidationTitle = "Validation" DlgValidationTitle = "Validation"
DlgValidationMsg = "Name, Host, and Username are required." DlgValidationMsg = "Name, Host, and Username are required."
DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}"
DlgDaemonError = "Daemon Error" DlgDaemonError = "Daemon Error"
DlgCredentialError = "Credential Error" DlgCredentialError = "Credential Error"
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it." DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
@@ -93,7 +94,7 @@ FieldMTUOverride = "MTU Override"
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderMTU = "0 = use server MTU" PlaceholderMTU = "0 = use server MTU"
PlaceholderPasswordUnchanged = "(unchanged)" PlaceholderPasswordUnchanged = "(unchanged)"
PlaceholderServerIPs = "e.g. 1.2.3.4, 5.6.7.8" PlaceholderServerIPs = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8"
AuthModeBoth = "Both (JWT + Password)" AuthModeBoth = "Both (JWT + Password)"
AuthModeJWT = "JWT" AuthModeJWT = "JWT"
+2 -1
View File
@@ -45,6 +45,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
DlgProfileTitle = "配置" DlgProfileTitle = "配置"
DlgValidationTitle = "验证" DlgValidationTitle = "验证"
DlgValidationMsg = "名称、主机名和用户名为必填项。" DlgValidationMsg = "名称、主机名和用户名为必填项。"
DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}"
DlgDaemonError = "守护进程错误" DlgDaemonError = "守护进程错误"
DlgCredentialError = "凭据错误" DlgCredentialError = "凭据错误"
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。" DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
@@ -93,7 +94,7 @@ FieldMTUOverride = "MTU 覆盖"
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderMTU = "0 = 使用服务器 MTU" PlaceholderMTU = "0 = 使用服务器 MTU"
PlaceholderPasswordUnchanged = "(未更改)" PlaceholderPasswordUnchanged = "(未更改)"
PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8" PlaceholderServerIPs = "支持 IPv4/IPv6例: 1.2.3.4, 5.6.7.8"
AuthModeBoth = "全部(JWT + 密码)" AuthModeBoth = "全部(JWT + 密码)"
AuthModeJWT = "JWT" AuthModeJWT = "JWT"
+81
View File
@@ -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
View File
@@ -109,8 +109,16 @@ func NewServer() (*Server, error) {
netType := paths.IPCNetwork() netType := paths.IPCNetwork()
addr := paths.IPCAddress() 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 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) _ = os.Remove(addr)
} }
+21 -9
View File
@@ -4,6 +4,7 @@ package model
import ( import (
"fmt" "fmt"
"net"
"strings" "strings"
"time" "time"
) )
@@ -85,20 +86,31 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string {
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path) return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
} }
// GetServerIPList parses ServerIPs into a string slice. // ValidateServerIPs parses ServerIPs, returning valid IP addresses
func (p *ServerProfile) GetServerIPList() []string { // and any invalid entries (for UI error reporting).
func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) {
if p.ServerIPs == "" { if p.ServerIPs == "" {
return nil return nil, nil
} }
parts := strings.Split(p.ServerIPs, ",") for _, part := range strings.Split(p.ServerIPs, ",") {
var out []string
for _, part := range parts {
s := strings.TrimSpace(part) s := strings.TrimSpace(part)
if s != "" { if s == "" {
out = append(out, s) continue
}
if net.ParseIP(s) != nil {
valid = append(valid, s)
} else {
invalid = append(invalid, s)
} }
} }
return out return
}
// GetServerIPList returns only valid IP addresses from ServerIPs,
// silently filtering out any malformed entries.
func (p *ServerProfile) GetServerIPList() []string {
valid, _ := p.ValidateServerIPs()
return valid
} }
// ConnectionStatus records the outcome of a connection attempt. // ConnectionStatus records the outcome of a connection attempt.
+6
View File
@@ -13,6 +13,12 @@ func IPCNetwork() string { return "unix" }
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC. // IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
func IPCAddress() string { return "/tmp/lmvpn.sock" } 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() { func init() {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
recomputePaths(home) recomputePaths(home)
+6
View File
@@ -13,6 +13,12 @@ func IPCNetwork() string { return "unix" }
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC. // IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
func IPCAddress() string { return "/tmp/lmvpn.sock" } 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() { func init() {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
recomputePaths(home) recomputePaths(home)
+7
View File
@@ -20,6 +20,13 @@ const ipcPort = "18923"
func IPCAddress() string { return "127.0.0.1:" + ipcPort } 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() { func init() {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
recomputePaths(home) recomputePaths(home)
+95 -7
View File
@@ -1,12 +1,14 @@
package ui package ui
import ( import (
"errors"
"os" "os"
"sync" "sync"
"lmvpn/internal/config" "lmvpn/internal/config"
"lmvpn/internal/db" "lmvpn/internal/db"
"lmvpn/internal/i18n" "lmvpn/internal/i18n"
"lmvpn/internal/instance"
"lmvpn/internal/ipc" "lmvpn/internal/ipc"
"lmvpn/internal/keychain" "lmvpn/internal/keychain"
"lmvpn/internal/log" "lmvpn/internal/log"
@@ -48,11 +50,13 @@ type App struct {
disconnectBtn *widget.Button disconnectBtn *widget.Button
// State // State
mu sync.Mutex mu sync.Mutex
ipcClient *ipc.Client ipcClient *ipc.Client
profiles []model.ServerProfile profiles []model.ServerProfile
currentProfile *model.ServerProfile currentProfile *model.ServerProfile
langSetting string defaultProfileID int64
langSetting string
windowHidden bool
} }
// Run initialises and starts the GUI application. // Run initialises and starts the GUI application.
@@ -65,6 +69,17 @@ func Run() {
// Logging. // Logging.
log.Init(log.RoleGUI, paths.LogFile()) 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. // Database.
store, err := db.Open() store, err := db.Open()
if err != nil { if err != nil {
@@ -84,6 +99,7 @@ func Run() {
db: store, db: store,
kc: keychain.New(), kc: keychain.New(),
langSetting: cfg.Language, langSetting: cfg.Language,
defaultProfileID: cfg.DefaultProfileID,
listSelectedIndex: -1, listSelectedIndex: -1,
} }
@@ -99,6 +115,44 @@ func Run() {
// System tray. // System tray.
a.setupTray() 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. // Auto-connect if configured.
if cfg.AutoConnect && a.currentProfile != nil { if cfg.AutoConnect && a.currentProfile != nil {
go func() { go func() {
@@ -109,6 +163,7 @@ func Run() {
a.window.SetCloseIntercept(func() { a.window.SetCloseIntercept(func() {
if cfg.CloseToTray { if cfg.CloseToTray {
a.windowHidden = true
hideDockIcon() hideDockIcon()
a.window.Hide() a.window.Hide()
} else { } else {
@@ -144,8 +199,21 @@ func (a *App) loadProfiles() {
names := a.profileNames() names := a.profileNames()
a.profileSelect.Options = names a.profileSelect.Options = names
if len(names) > 0 { if len(names) > 0 {
a.profileSelect.SetSelectedIndex(0) selected := false
a.selectProfileByName(names[0]) if a.defaultProfileID > 0 {
for i := range a.profiles {
if a.profiles[i].ID == a.defaultProfileID {
a.profileSelect.SetSelectedIndex(i)
a.selectProfileByName(a.profiles[i].Name)
selected = true
break
}
}
}
if !selected {
a.profileSelect.SetSelectedIndex(0)
a.selectProfileByName(names[0])
}
} else { } else {
a.currentProfile = nil a.currentProfile = nil
a.profileSelect.SetSelected("") a.profileSelect.SetSelected("")
@@ -182,6 +250,26 @@ func (a *App) selectProfileByName(name string) {
} }
} }
// saveDefaultProfile persists the currently selected profile ID to the
// config file so it can be restored on the next launch.
func (a *App) saveDefaultProfile() {
if a.currentProfile == nil {
return
}
a.defaultProfileID = a.currentProfile.ID
cfg, err := config.Load()
if err != nil {
cfg = config.Default()
}
if cfg.DefaultProfileID == a.currentProfile.ID {
return
}
cfg.DefaultProfileID = a.currentProfile.ID
if err := config.Save(cfg); err != nil {
log.L().Error("save default profile", "error", err)
}
}
// onAddProfile shows a dialog to create a new profile. // onAddProfile shows a dialog to create a new profile.
func (a *App) onAddProfile() { func (a *App) onAddProfile() {
a.showProfileDialog(nil) a.showProfileDialog(nil)
+18
View File
@@ -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()
}
}
+66
View File
@@ -8,13 +8,79 @@ package ui
#include <objc/runtime.h> #include <objc/runtime.h>
#include <objc/message.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) { static void setDockIconVisible(int visible) {
Class cls = objc_getClass("NSApplication"); Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication")); id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1); ((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" import "C"
func showDockIcon() { C.setDockIconVisible(1) } func showDockIcon() { C.setDockIconVisible(1) }
func hideDockIcon() { C.setDockIconVisible(0) } func hideDockIcon() { C.setDockIconVisible(0) }
func activateApp() { C.cmActivateApp() }
func showAndActivate() { C.cmShowAndActivate() }
func registerReopenHandler() { C.cmRegisterReopenHandler() }
+6
View File
@@ -4,3 +4,9 @@ package ui
func showDockIcon() {} func showDockIcon() {}
func hideDockIcon() {} func hideDockIcon() {}
func activateApp() {}
func showAndActivate() {}
func registerReopenHandler() {}
+12
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"lmvpn/internal/i18n" "lmvpn/internal/i18n"
"lmvpn/internal/model" "lmvpn/internal/model"
@@ -270,6 +271,17 @@ func (a *App) saveProfile(editing *model.ServerProfile,
return false return false
} }
if ips != "" {
tmp := &model.ServerProfile{ServerIPs: ips}
_, invalid := tmp.ValidateServerIPs()
if len(invalid) > 0 {
showError(i18n.T("DlgValidationTitle"),
fmt.Sprintf(i18n.T("DlgInvalidIPMsg"), strings.Join(invalid, ", ")),
a.window)
return false
}
}
port := parseIntDefault(portStr, 443) port := parseIntDefault(portStr, 443)
mtu := parseIntDefault(mtuStr, 0) mtu := parseIntDefault(mtuStr, 0)
+6 -5
View File
@@ -60,11 +60,12 @@ func (a *App) setupTray() {
} }
menu := fyne.NewMenu(i18n.T("WindowTitle"), menu := fyne.NewMenu(i18n.T("WindowTitle"),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() { fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
showDockIcon() a.windowHidden = false
a.window.Show() showAndActivate()
a.window.RequestFocus() a.window.Show()
}), a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(), fyne.NewMenuItemSeparator(),
connectItem, connectItem,
disconnectItem, disconnectItem,
+42 -2
View File
@@ -39,6 +39,13 @@ func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
} else { } else {
a.disconnectBtn.Disable() a.disconnectBtn.Disable()
} }
if a.profileSelect != nil {
if connectEnabled {
a.profileSelect.Enable()
} else {
a.profileSelect.Disable()
}
}
a.setupTray() a.setupTray()
} }
@@ -47,6 +54,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
// Profile selector. // Profile selector.
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) { a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
a.selectProfileByName(sel) a.selectProfileByName(sel)
a.saveDefaultProfile()
}) })
// Status display. // Status display.
@@ -132,8 +140,13 @@ func (a *App) onConnect() {
} }
a.mu.Lock() a.mu.Lock()
oldClient := a.ipcClient
a.ipcClient = client a.ipcClient = client
a.mu.Unlock() a.mu.Unlock()
if oldClient != nil {
_ = ipc.SendStop(oldClient)
oldClient.Close()
}
// Get password from keychain. // Get password from keychain.
password, err := a.kc.GetPassword(a.currentProfile.Name) password, err := a.kc.GetPassword(a.currentProfile.Name)
@@ -190,13 +203,24 @@ func (a *App) onConnect() {
func (a *App) onDisconnect() { func (a *App) onDisconnect() {
a.mu.Lock() a.mu.Lock()
client := a.ipcClient client := a.ipcClient
a.ipcClient = nil
a.mu.Unlock() a.mu.Unlock()
if client == nil { if client == nil {
return return
} }
_ = ipc.SendStop(client) _ = ipc.SendStop(client)
client.Close()
a.setConnButtons(true, false) a.setConnButtons(true, false)
a.stateLabel.SetText(i18n.T("StateDisconnected")) a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
} }
// eventLoop reads IPC events from the daemon and updates the UI. // eventLoop reads IPC events from the daemon and updates the UI.
@@ -234,17 +258,33 @@ func (a *App) eventLoop() {
switch ev.Event { switch ev.Event {
case ipc.EvState: case ipc.EvState:
fyne.Do(func() { fyne.Do(func() {
a.applyState(ev.State) a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyState(ev.State)
}
}) })
case ipc.EvStats: case ipc.EvStats:
if ev.Stats != nil { if ev.Stats != nil {
s := *ev.Stats s := *ev.Stats
fyne.Do(func() { fyne.Do(func() {
a.applyStats(s) a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyStats(s)
}
}) })
} }
case ipc.EvError: case ipc.EvError:
fyne.Do(func() { fyne.Do(func() {
a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current != client {
return
}
if ev.Code == "tls_error" { if ev.Code == "tls_error" {
showError(i18n.T("DlgTLSError"), showError(i18n.T("DlgTLSError"),
i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window) i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window)
+55 -22
View File
@@ -60,6 +60,7 @@ type SessionManager struct {
dev tun.Device dev tun.Device
routeMgr *route.Manager routeMgr *route.Manager
conn *transport.Conn conn *transport.Conn
done chan struct{}
// EWMA speed smoothing state. Only touched by reportStats (single // EWMA speed smoothing state. Only touched by reportStats (single
// goroutine), so no lock needed. ewma* fields hold the smoothed // goroutine), so no lock needed. ewma* fields hold the smoothed
@@ -106,6 +107,7 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
sm.cancel = cancel sm.cancel = cancel
sm.running = true sm.running = true
sm.done = make(chan struct{})
sm.mu.Unlock() sm.mu.Unlock()
go sm.run(ctx, cfg) go sm.run(ctx, cfg)
@@ -126,21 +128,32 @@ func (sm *SessionManager) Disconnect() {
if cancel != nil { if cancel != nil {
cancel() cancel()
} }
// Close the transport to unblock the packet pump. // Close the transport to unblock the WS->TUN goroutine.
sm.mu.Lock() sm.mu.Lock()
conn := sm.conn conn := sm.conn
dev := sm.dev
sm.mu.Unlock() sm.mu.Unlock()
if conn != nil { if conn != nil {
conn.Close() conn.Close()
} }
// Close the TUN device to unblock the TUN->WS goroutine's dev.Read().
if dev != nil {
dev.Close()
}
// Wait for the run goroutine to fully exit so that cleanup
// (route removal, TUN teardown) is complete before returning.
if sm.done != nil {
<-sm.done
}
} }
// run is the main session loop with exponential-backoff reconnection // run is the main session loop with exponential-backoff reconnection
// and CDN IP failover. // and CDN IP failover.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
fatal := false fatal := false
defer close(sm.done)
defer func() { defer func() {
if !fatal { if !fatal && ctx.Err() == nil {
sm.setState(stats.StateDisconnected) sm.setState(stats.StateDisconnected)
} }
}() }()
@@ -154,6 +167,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
for { for {
if ctx.Err() != nil { if ctx.Err() != nil {
sm.cleanup()
return return
} }
@@ -171,33 +185,45 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil { if err != nil {
log.L().Error("VPN connection failed", "error", err) log.L().Error("VPN connection failed", "error", err)
// A TLS certificate verification failure is not retryable: // A TLS certificate verification failure on the original
// the cert won't change between attempts, so stop the // hostname (ipIndex == 0) is not retryable: the cert won't
// loop and surface the reason to the user. // change between attempts, so stop the loop and surface the
// reason to the user. On a CDN edge IP (ipIndex > 0) the
// TLS error likely means that IP points to a different
// server; skip it and try the next target.
if tlsconfig.IsTLSError(err) { if tlsconfig.IsTLSError(err) {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err) if ipIndex == 0 {
sm.setState(stats.StateError) log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
if sm.onError != nil { sm.setState(stats.StateError)
sm.onError("tls_error", err.Error()) if sm.onError != nil {
sm.onError("tls_error", err.Error())
}
fatal = true
sm.cleanup()
return
} }
fatal = true log.L().Warn("TLS error on CDN IP, skipping",
sm.cleanup() "index", ipIndex, "ip", targets[ipIndex], "error", err)
return
} }
// A fatal authentication failure (wrong password, disabled // A fatal authentication failure (wrong password, disabled
// account, expired token, rate limit) is not retryable: // account, expired token, rate limit) on the original
// stop the loop and surface the reason to the user instead // hostname is not retryable. On a CDN edge IP it likely
// of hammering the server forever. // means the IP points to a different server that returned
// 401/403, so skip it instead of stopping the loop.
if code, msg, isFatal := fatalAuthError(err); isFatal { if code, msg, isFatal := fatalAuthError(err); isFatal {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg) if ipIndex == 0 {
sm.setState(stats.StateError) log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
if sm.onError != nil { sm.setState(stats.StateError)
sm.onError(string(code), msg) if sm.onError != nil {
sm.onError(string(code), msg)
}
fatal = true
sm.cleanup()
return
} }
fatal = true log.L().Warn("auth error on CDN IP, skipping",
sm.cleanup() "index", ipIndex, "ip", targets[ipIndex], "code", code)
return
} }
sm.setState(stats.StateReconnecting) sm.setState(stats.StateReconnecting)
@@ -659,7 +685,14 @@ func wsURLToHTTP(wsURL string) (string, error) {
// replaceHost substitutes the host portion of a URL string. // replaceHost substitutes the host portion of a URL string.
// e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws // e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws
// Bare IPv6 addresses are automatically bracketed:
// wss://host:443/ws with 2001:db8::1 → wss://[2001:db8::1]:443/ws
func replaceHost(rawURL, newHost string) string { func replaceHost(rawURL, newHost string) string {
// Auto-bracket bare IPv6 addresses so the colons in the address
// are not confused with the port separator.
if ip := net.ParseIP(newHost); ip != nil && ip.To4() == nil && !strings.HasPrefix(newHost, "[") {
newHost = "[" + newHost + "]"
}
u := rawURL u := rawURL
for _, prefix := range []string{"wss://", "ws://"} { for _, prefix := range []string{"wss://", "ws://"} {
if len(u) > len(prefix) && u[:len(prefix)] == prefix { if len(u) > len(prefix) && u[:len(prefix)] == prefix {