Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15af9ef72c | ||
|
|
96b1a8839c | ||
|
|
c56fc0c21c |
@@ -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.3
|
SEMVER ?= 0.4.7
|
||||||
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -54,6 +56,7 @@ type App struct {
|
|||||||
currentProfile *model.ServerProfile
|
currentProfile *model.ServerProfile
|
||||||
defaultProfileID int64
|
defaultProfileID int64
|
||||||
langSetting string
|
langSetting string
|
||||||
|
windowHidden bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run initialises and starts the GUI application.
|
// Run initialises and starts the GUI application.
|
||||||
@@ -66,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 {
|
||||||
@@ -101,6 +115,46 @@ 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
|
||||||
|
activateApp()
|
||||||
|
showDockIcon()
|
||||||
|
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
|
||||||
|
activateApp()
|
||||||
|
showDockIcon()
|
||||||
|
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() {
|
||||||
@@ -111,6 +165,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 {
|
||||||
|
|||||||
@@ -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,54 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 registerReopenHandler() { C.cmRegisterReopenHandler() }
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ package ui
|
|||||||
|
|
||||||
func showDockIcon() {}
|
func showDockIcon() {}
|
||||||
func hideDockIcon() {}
|
func hideDockIcon() {}
|
||||||
|
|
||||||
|
func activateApp() {}
|
||||||
|
|
||||||
|
func registerReopenHandler() {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ 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() {
|
||||||
|
a.windowHidden = false
|
||||||
|
activateApp()
|
||||||
showDockIcon()
|
showDockIcon()
|
||||||
a.window.Show()
|
a.window.Show()
|
||||||
a.window.RequestFocus()
|
a.window.RequestFocus()
|
||||||
|
|||||||
+25
-6
@@ -185,10 +185,14 @@ 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) {
|
||||||
|
if ipIndex == 0 {
|
||||||
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
|
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
|
||||||
sm.setState(stats.StateError)
|
sm.setState(stats.StateError)
|
||||||
if sm.onError != nil {
|
if sm.onError != nil {
|
||||||
@@ -198,12 +202,17 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
|||||||
sm.cleanup()
|
sm.cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.L().Warn("TLS error on CDN IP, skipping",
|
||||||
|
"index", ipIndex, "ip", targets[ipIndex], "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
if ipIndex == 0 {
|
||||||
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
||||||
sm.setState(stats.StateError)
|
sm.setState(stats.StateError)
|
||||||
if sm.onError != nil {
|
if sm.onError != nil {
|
||||||
@@ -213,6 +222,9 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
|||||||
sm.cleanup()
|
sm.cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.L().Warn("auth error on CDN IP, skipping",
|
||||||
|
"index", ipIndex, "ip", targets[ipIndex], "code", code)
|
||||||
|
}
|
||||||
|
|
||||||
sm.setState(stats.StateReconnecting)
|
sm.setState(stats.StateReconnecting)
|
||||||
|
|
||||||
@@ -673,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user