From 0853cec437847808dfe3e5f50b2a4936d7e4cfad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 11 Jun 2026 15:06:37 +0200 Subject: [PATCH 1/2] fix(ui): tray exit-node toggle no longer disables other routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray Selected an exit node with append=false, which the RouteSelector treats as "drop the whole current selection" (default-on semantics), so enabling an exit node also turned off every non-exit routed network the user had on. Send append=true instead and let the daemon's SelectNetworks handler deselect only the sibling exit nodes — matching the frontend's toggleExitNode, which already used append=true. Add a RouteSelector regression test covering the handler sequence. --- .../routeselector/routeselector_test.go | 28 +++++++++++++++++++ client/ui/tray_exitnodes.go | 14 ++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 3f0d9f120..3af1961f4 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -825,3 +825,31 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) { }) } } + +// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the +// tray exit-node toggle disabling every non-exit routed network. The tray used +// to Select an exit node with append=false, which the RouteSelector treats as +// "drop the whole current selection" (default-on semantics) — so enabling an +// exit node also turned off every LAN/route the user had on. The fix sends +// append=true and lets the daemon's SelectNetworks handler deselect only the +// sibling exit nodes. This test models that handler sequence against the +// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other +// exit nodes) must leave non-exit routes untouched. +func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { + rs := routeselector.NewRouteSelector() + all := []route.NetID{"exitA", "exitB", "lan1", "lan2"} + + // User has two LAN routes on (default-on: nothing deselected => all selected). + require.True(t, rs.IsSelected("lan1")) + require.True(t, rs.IsSelected("lan2")) + + // Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true) + // then deselects sibling exit nodes (exitB), never the LAN routes. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all)) + + assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on") + assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected") + assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") + assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") +} diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go index 53a434653..0b87fb36e 100644 --- a/client/ui/tray_exitnodes.go +++ b/client/ui/tray_exitnodes.go @@ -97,17 +97,19 @@ func (t *Tray) refreshExitNodes() { } // toggleExitNode activates or deactivates one exit node by NetID. Exit nodes -// are mutually exclusive, so Select uses append=false to clear any other -// active node before turning this one on; deselecting an active node turns -// routing off entirely. Mirrors the frontend's toggleExitNode semantics. Runs -// the RPC off the menu-click goroutine and re-fetches so the ✓ moves to the -// new selection. +// are mutually exclusive, but enforcement of that lives daemon-side: the +// SelectNetworks handler deselects every other exit node when this Select +// activates one. So Select uses append=true — append=false would tell the +// RouteSelector to drop the whole current selection (default-on semantics), +// which also turns off every non-exit routed network the user had enabled. +// Mirrors the frontend's toggleExitNode semantics. Runs the RPC off the +// menu-click goroutine and re-fetches so the ✓ moves to the new selection. func (t *Tray) toggleExitNode(id string, selected bool) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: false, All: false} + params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: true, All: false} var err error if selected { err = t.svc.Networks.Deselect(ctx, params) From b35ca9fde35f5b4dc76e6cb9542850f0408ab299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 11 Jun 2026 15:07:28 +0200 Subject: [PATCH 2/2] Revert "fix recenter?" This reverts commit 0d950d46f33f82488121b3206bcb7386ce09ebd7. --- client/ui/recenter_linux.go | 57 ++++------------------------- client/ui/services/windowmanager.go | 17 ++++----- 2 files changed, 15 insertions(+), 59 deletions(-) diff --git a/client/ui/recenter_linux.go b/client/ui/recenter_linux.go index d25666306..2c574e08f 100644 --- a/client/ui/recenter_linux.go +++ b/client/ui/recenter_linux.go @@ -2,57 +2,16 @@ package main -import ( - "os" - "strings" -) - // recenterOnShowPredicate returns the predicate WindowManager uses to decide // whether to re-center its Go-shown windows (main, Settings) on each show. // -// Re-centering is needed only on bare WMs (fluxbox, IceWM, twm …) that neither -// place small windows for us nor restore their position across a hide -> show -// round-trip — the same environment the in-process XEmbed tray host serves. -// xembedTrayAvailable (a _NET_SYSTEM_TRAY_S0 selection-owner probe) detects -// that host, but it is NOT a clean proxy for "bare WM": full desktops like -// Cinnamon, MATE, XFCE and LXDE ship a legacy XEmbed systray AND a real -// compositing WM (Muffin/Marco/xfwm4) that places windows itself. On those, -// running the post-show X11 re-center makes the window visibly jump from the -// WM's placement to ours (reported on Cinnamon/Mint/X11). So we additionally -// require that the session does NOT advertise a known full desktop. -// -// Evaluated per show (not once at startup) because the XEmbed tray can appear -// after the UI starts — the panel and the autostarted app race at login — and -// xembedTrayAvailable is a cheap, side-effect-free selection-owner probe. +// On Linux this is xembedTrayAvailable: re-centering is needed only in the +// minimal-WM / in-process-XEmbed-tray environment, where the window manager +// neither centers small windows for us nor restores their position across a +// hide -> show round-trip. The predicate is evaluated per show (not once at +// startup) because the XEmbed tray can appear after the UI starts — the panel +// and the autostarted app race at login — and xembedTrayAvailable is a cheap, +// side-effect-free selection-owner probe, fine to call repeatedly. func recenterOnShowPredicate() func() bool { - return func() bool { - return xembedTrayAvailable() && !inFullDesktopEnvironment() - } -} - -// fullDesktopTokens are desktop-environment identifiers whose window manager -// places and restores windows for us. Several also expose a legacy XEmbed -// systray, so they must be excluded from the re-center path explicitly. -// Matched case-insensitively against the colon-separated XDG_CURRENT_DESKTOP / -// DESKTOP_SESSION tokens. Bare WMs leave these unset (or report their own name, -// e.g. "Fluxbox"), which is absent here — so they still re-center. -var fullDesktopTokens = []string{ - "cinnamon", "mate", "xfce", "gnome", "kde", "plasma", - "lxde", "lxqt", "unity", "budgie", "deepin", "pantheon", -} - -// inFullDesktopEnvironment reports whether the session advertises one of the -// known full desktop environments via XDG_CURRENT_DESKTOP or DESKTOP_SESSION. -func inFullDesktopEnvironment() bool { - for _, env := range []string{"XDG_CURRENT_DESKTOP", "DESKTOP_SESSION"} { - for _, tok := range strings.Split(os.Getenv(env), ":") { - tok = strings.ToLower(strings.TrimSpace(tok)) - for _, full := range fullDesktopTokens { - if strings.Contains(tok, full) { - return true - } - } - } - } - return false + return xembedTrayAvailable } diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 27777d7cb..d3d06324b 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -163,12 +163,10 @@ type WindowManager struct { hiddenForLogin []application.Window mu sync.Mutex // recenterOnShow reports whether Go should re-center the Go-shown - // windows (main, Settings) on each show. Only true on bare WMs (the - // in-process XEmbed-tray environment minus the full desktops — Cinnamon, - // MATE, XFCE … — that also expose an XEmbed systray), where the WM neither - // centers small windows for us nor restores their position across a - // hide -> show round-trip. On full desktops (GNOME/KDE/Cinnamon) the WM - // handles placement, so + // windows (main, Settings) on each show. Only true in the minimal-WM / + // in-process XEmbed-tray environment, where the WM neither centers small + // windows for us nor restores their position across a hide -> show + // round-trip. On full desktops (GNOME/KDE) the WM handles placement, so // re-centering is unnecessary and would fight a window the user moved — // there this stays nil and centerWhenReady is a no-op. Set by the Linux // startup path via SetRecenterOnShow; nil on macOS/Windows and in tests. @@ -651,10 +649,9 @@ func (s *WindowManager) ShowMain() { // SetRecenterOnShow installs the predicate that gates Go-side re-centering of // the main and Settings windows (see the recenterOnShow field). The Linux -// startup path passes a predicate that is true only on bare WMs (XEmbed tray -// present and no full desktop advertised), so re-centering happens only where -// the WM won't place windows for us; macOS/Windows and tests leave it unset, -// making centerWhenReady a no-op. +// startup path passes xembedTrayAvailable so re-centering happens only in the +// minimal-WM / in-process-XEmbed-tray environment; macOS/Windows and tests +// leave it unset, making centerWhenReady a no-op. func (s *WindowManager) SetRecenterOnShow(pred func() bool) { s.recenterOnShow = pred }