diff --git a/Makefile b/Makefile index 8e07c54..5f59cd5 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.1 +SEMVER ?= 0.4.2 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/daemon/daemon.go b/internal/daemon/daemon.go index 47da857..63d4c54 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -19,6 +19,7 @@ import ( "net" "os" "os/signal" + "sync" "syscall" "lmvpn/internal/ipc" @@ -92,6 +93,7 @@ func chownToUser(path string, uid, gid int) { type daemon struct { server *ipc.Server + mu sync.Mutex session *vpn.SessionManager cancel context.CancelFunc } @@ -109,8 +111,11 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) { d.server.Close() os.Exit(0) case ipc.CmdStats: - if d.session != nil { - snap := d.session.Stats().Snapshot() + d.mu.Lock() + sess := d.session + d.mu.Unlock() + if sess != nil { + snap := sess.Stats().Snapshot() d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) } _ = 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) { + d.mu.Lock() + defer d.mu.Unlock() if req.Config == nil { _ = ipc.WriteErr(conn, "missing config") return } if d.session != nil { - d.stopSession() + d.stopSessionLocked() } cfg := vpn.SessionConfig{ @@ -170,6 +177,12 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { } func (d *daemon) stopSession() { + d.mu.Lock() + defer d.mu.Unlock() + d.stopSessionLocked() +} + +func (d *daemon) stopSessionLocked() { if d.cancel != nil { d.cancel() d.cancel = nil diff --git a/internal/ui/view.go b/internal/ui/view.go index 34c792d..b15f5ed 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -39,6 +39,13 @@ func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) { } else { a.disconnectBtn.Disable() } + if a.profileSelect != nil { + if connectEnabled { + a.profileSelect.Enable() + } else { + a.profileSelect.Disable() + } + } a.setupTray() } @@ -132,8 +139,13 @@ func (a *App) onConnect() { } a.mu.Lock() + oldClient := a.ipcClient a.ipcClient = client a.mu.Unlock() + if oldClient != nil { + _ = ipc.SendStop(oldClient) + oldClient.Close() + } // Get password from keychain. password, err := a.kc.GetPassword(a.currentProfile.Name) @@ -190,13 +202,24 @@ func (a *App) onConnect() { func (a *App) onDisconnect() { a.mu.Lock() client := a.ipcClient + a.ipcClient = nil a.mu.Unlock() if client == nil { return } _ = ipc.SendStop(client) + client.Close() a.setConnButtons(true, false) 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. @@ -234,17 +257,33 @@ func (a *App) eventLoop() { switch ev.Event { case ipc.EvState: 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: if ev.Stats != nil { s := *ev.Stats fyne.Do(func() { - a.applyStats(s) + a.mu.Lock() + current := a.ipcClient + a.mu.Unlock() + if current == client { + a.applyStats(s) + } }) } case ipc.EvError: fyne.Do(func() { + a.mu.Lock() + current := a.ipcClient + a.mu.Unlock() + if current != client { + return + } if ev.Code == "tls_error" { showError(i18n.T("DlgTLSError"), i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window) diff --git a/internal/vpn/session.go b/internal/vpn/session.go index c57a542..f76d74b 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -60,6 +60,7 @@ type SessionManager struct { dev tun.Device routeMgr *route.Manager conn *transport.Conn + done chan struct{} // EWMA speed smoothing state. Only touched by reportStats (single // 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) sm.cancel = cancel sm.running = true + sm.done = make(chan struct{}) sm.mu.Unlock() go sm.run(ctx, cfg) @@ -126,21 +128,32 @@ func (sm *SessionManager) Disconnect() { if cancel != nil { cancel() } - // Close the transport to unblock the packet pump. + // Close the transport to unblock the WS->TUN goroutine. sm.mu.Lock() conn := sm.conn + dev := sm.dev sm.mu.Unlock() if conn != nil { 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 // and CDN IP failover. func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { fatal := false + defer close(sm.done) defer func() { - if !fatal { + if !fatal && ctx.Err() == nil { sm.setState(stats.StateDisconnected) } }() @@ -154,6 +167,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { for { if ctx.Err() != nil { + sm.cleanup() return }