From 0694625a86b4137ffe623947ed16982e89b3e626 Mon Sep 17 00:00:00 2001 From: stefan Date: Sun, 23 Aug 2026 06:32:28 +0700 Subject: [PATCH 1/5] fix(linux): do not treat opening the tray menu as a click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a tray icon ran the click handler as well as opening the menu, so an app that shows its window on click had the window toggle on every right-click — and with an attached window, toggle again on the next one. The dbusmenu "opened" event says the host is about to show the context menu, which is the secondary button. ItemIsMenu is published as false, so a primary click arrives as Activate and is already handled there; the menu-open path only needs to run the onMenuOpen callback. --- v3/pkg/application/systemtray_linux.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/v3/pkg/application/systemtray_linux.go b/v3/pkg/application/systemtray_linux.go index fc8307ee88f..d355d6885a6 100644 --- a/v3/pkg/application/systemtray_linux.go +++ b/v3/pkg/application/systemtray_linux.go @@ -718,9 +718,11 @@ func (s *linuxSystemTray) Event(id int32, eventID string, data dbus.Variant, tim gtkDispatch(item.menuItem.handleClick) } case "opened": - if s.parent.clickHandler != nil { - s.parent.clickHandler() - } + // Not the click handler: "opened" says the host is about to show the + // context menu, which is the secondary button. ItemIsMenu is false, so + // a primary click arrives as Activate and is handled there — firing the + // click handler here as well makes every right-click do whatever a + // left-click does, on top of opening the menu. if s.parent.onMenuOpen != nil { s.parent.onMenuOpen() } From 118410698bb0a46ee71da8938087f977131eb633 Mon Sep 17 00:00:00 2001 From: stefan Date: Mon, 24 Aug 2026 00:15:43 +0700 Subject: [PATCH 2/5] docs: changelog entry --- v3/UNRELEASED_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 33638e7fc86..efc20eee8a1 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -23,6 +23,7 @@ After processing, the content will be moved to the main changelog and this file ## Fixed +- Fix right-clicking a Linux system tray icon running the click handler as well as opening the menu, so an attached window toggled on every right-click ## Deprecated From c0ee57b787166a9de00c235ae8dd8056c80c0b98 Mon Sep 17 00:00:00 2001 From: stefan Date: Mon, 24 Aug 2026 00:54:13 +0700 Subject: [PATCH 3/5] docs: reference the PR in the changelog entry --- v3/UNRELEASED_CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index efc20eee8a1..4df482d79f1 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -23,7 +23,7 @@ After processing, the content will be moved to the main changelog and this file ## Fixed -- Fix right-clicking a Linux system tray icon running the click handler as well as opening the menu, so an attached window toggled on every right-click +- Fix right-clicking a Linux system tray icon running the click handler as well as opening the menu, so an attached window toggled on every right-click (#6018) ## Deprecated From 54652d6ed30a95adc991f18c1a444e1f532acf46 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 27 Aug 2026 11:18:23 +0700 Subject: [PATCH 4/5] test(linux): cover tray menu-open vs click dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the fix: Event("opened") must run onMenuOpen and not the click handler, Event("closed") must run onMenuClose and not the click handler, and Activate — the path a primary click actually arrives on — must still run it. --- .../systemtray_linux_event_test.go | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 v3/pkg/application/systemtray_linux_event_test.go diff --git a/v3/pkg/application/systemtray_linux_event_test.go b/v3/pkg/application/systemtray_linux_event_test.go new file mode 100644 index 00000000000..40d06b3cb07 --- /dev/null +++ b/v3/pkg/application/systemtray_linux_event_test.go @@ -0,0 +1,90 @@ +//go:build linux && !android && !server + +package application + +import ( + "testing" + + "github.com/godbus/dbus/v5" +) + +// withStubApplication installs an App with no logger for the duration of the +// test: the dbusmenu callbacks log through globalApplication, which is nil in a +// test binary. +func withStubApplication(t *testing.T) { + t.Helper() + prev := globalApplication + globalApplication = &App{} + t.Cleanup(func() { globalApplication = prev }) +} + +// The dbusmenu "opened" event is the host announcing that it is about to show +// the context menu, which is the secondary button. Running the click handler +// here as well made every right-click do whatever a left-click does — on an +// app whose click handler toggles a window, right-clicking toggled it. +func TestLinuxSystemTrayMenuEventsDoNotClick(t *testing.T) { + withStubApplication(t) + + var clicks, opens, closes int + tray := &linuxSystemTray{parent: &SystemTray{ + clickHandler: func() { clicks++ }, + onMenuOpen: func() { opens++ }, + onMenuClose: func() { closes++ }, + }} + + if err := tray.Event(0, "opened", dbus.Variant{}, 0); err != nil { + t.Fatalf(`Event("opened") returned %v`, err) + } + if opens != 1 { + t.Errorf(`Event("opened") ran onMenuOpen %d times, want 1`, opens) + } + if clicks != 0 { + t.Errorf(`Event("opened") ran the click handler %d times, want 0`, clicks) + } + + if err := tray.Event(0, "closed", dbus.Variant{}, 0); err != nil { + t.Fatalf(`Event("closed") returned %v`, err) + } + if closes != 1 { + t.Errorf(`Event("closed") ran onMenuClose %d times, want 1`, closes) + } + if clicks != 0 { + t.Errorf(`Event("closed") ran the click handler %d times, want 0`, clicks) + } +} + +// The counterpart: ItemIsMenu is published as false, so the primary button +// arrives as Activate, which is now the only path to the click handler. +func TestLinuxSystemTrayActivateClicks(t *testing.T) { + withStubApplication(t) + + var clicks int + tray := &linuxSystemTray{parent: &SystemTray{ + clickHandler: func() { clicks++ }, + }} + + if err := tray.Activate(12, 34); err != nil { + t.Fatalf("Activate returned %v", err) + } + if clicks != 1 { + t.Errorf("Activate ran the click handler %d times, want 1", clicks) + } + if tray.lastClickX != 12 || tray.lastClickY != 34 { + t.Errorf("Activate recorded (%d,%d), want (12,34)", tray.lastClickX, tray.lastClickY) + } +} + +// A tray with no handlers attached must not panic on the same events. +func TestLinuxSystemTrayEventsWithoutHandlers(t *testing.T) { + withStubApplication(t) + + tray := &linuxSystemTray{parent: &SystemTray{}} + for _, eventID := range []string{"opened", "closed"} { + if err := tray.Event(0, eventID, dbus.Variant{}, 0); err != nil { + t.Fatalf("Event(%q) returned %v", eventID, err) + } + } + if err := tray.Activate(0, 0); err != nil { + t.Fatalf("Activate returned %v", err) + } +} From abc88367bf95859378bae59bcbcc3e67384efca3 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 27 Aug 2026 11:42:21 +0700 Subject: [PATCH 5/5] test(linux): assert tray events run only their own callback Each assertion now pins the whole set of click / onMenuOpen / onMenuClose counts rather than the one callback it is about, and Activate is checked against a tray that has the menu callbacks attached, so an event reaching a callback it should not fails the test. --- .../systemtray_linux_event_test.go | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/v3/pkg/application/systemtray_linux_event_test.go b/v3/pkg/application/systemtray_linux_event_test.go index 40d06b3cb07..8ea67fc24bd 100644 --- a/v3/pkg/application/systemtray_linux_event_test.go +++ b/v3/pkg/application/systemtray_linux_event_test.go @@ -8,14 +8,37 @@ import ( "github.com/godbus/dbus/v5" ) -// withStubApplication installs an App with no logger for the duration of the -// test: the dbusmenu callbacks log through globalApplication, which is nil in a -// test binary. -func withStubApplication(t *testing.T) { +// trayHandlerCounts records which of the SystemTray callbacks a dbusmenu +// event reached, so each assertion can pin the whole set rather than the one +// callback it is about — an event running a callback it should not is the +// failure mode here. +type trayHandlerCounts struct { + clicks, opens, closes int +} + +// newCountingTray builds a tray with all three callbacks attached and an App +// with no logger installed: the dbusmenu callbacks log through +// globalApplication, which is nil in a test binary. +func newCountingTray(t *testing.T, counts *trayHandlerCounts) *linuxSystemTray { t.Helper() + prev := globalApplication globalApplication = &App{} t.Cleanup(func() { globalApplication = prev }) + + return &linuxSystemTray{parent: &SystemTray{ + clickHandler: func() { counts.clicks++ }, + onMenuOpen: func() { counts.opens++ }, + onMenuClose: func() { counts.closes++ }, + }} +} + +func (c trayHandlerCounts) check(t *testing.T, after string, clicks, opens, closes int) { + t.Helper() + if c.clicks != clicks || c.opens != opens || c.closes != closes { + t.Errorf("after %s: click=%d onMenuOpen=%d onMenuClose=%d, want %d/%d/%d", + after, c.clicks, c.opens, c.closes, clicks, opens, closes) + } } // The dbusmenu "opened" event is the host announcing that it is about to show @@ -23,52 +46,31 @@ func withStubApplication(t *testing.T) { // here as well made every right-click do whatever a left-click does — on an // app whose click handler toggles a window, right-clicking toggled it. func TestLinuxSystemTrayMenuEventsDoNotClick(t *testing.T) { - withStubApplication(t) - - var clicks, opens, closes int - tray := &linuxSystemTray{parent: &SystemTray{ - clickHandler: func() { clicks++ }, - onMenuOpen: func() { opens++ }, - onMenuClose: func() { closes++ }, - }} + var counts trayHandlerCounts + tray := newCountingTray(t, &counts) if err := tray.Event(0, "opened", dbus.Variant{}, 0); err != nil { t.Fatalf(`Event("opened") returned %v`, err) } - if opens != 1 { - t.Errorf(`Event("opened") ran onMenuOpen %d times, want 1`, opens) - } - if clicks != 0 { - t.Errorf(`Event("opened") ran the click handler %d times, want 0`, clicks) - } + counts.check(t, `Event("opened")`, 0, 1, 0) if err := tray.Event(0, "closed", dbus.Variant{}, 0); err != nil { t.Fatalf(`Event("closed") returned %v`, err) } - if closes != 1 { - t.Errorf(`Event("closed") ran onMenuClose %d times, want 1`, closes) - } - if clicks != 0 { - t.Errorf(`Event("closed") ran the click handler %d times, want 0`, clicks) - } + counts.check(t, `Event("closed")`, 0, 1, 1) } // The counterpart: ItemIsMenu is published as false, so the primary button // arrives as Activate, which is now the only path to the click handler. func TestLinuxSystemTrayActivateClicks(t *testing.T) { - withStubApplication(t) - - var clicks int - tray := &linuxSystemTray{parent: &SystemTray{ - clickHandler: func() { clicks++ }, - }} + var counts trayHandlerCounts + tray := newCountingTray(t, &counts) if err := tray.Activate(12, 34); err != nil { t.Fatalf("Activate returned %v", err) } - if clicks != 1 { - t.Errorf("Activate ran the click handler %d times, want 1", clicks) - } + counts.check(t, "Activate", 1, 0, 0) + if tray.lastClickX != 12 || tray.lastClickY != 34 { t.Errorf("Activate recorded (%d,%d), want (12,34)", tray.lastClickX, tray.lastClickY) } @@ -76,7 +78,9 @@ func TestLinuxSystemTrayActivateClicks(t *testing.T) { // A tray with no handlers attached must not panic on the same events. func TestLinuxSystemTrayEventsWithoutHandlers(t *testing.T) { - withStubApplication(t) + prev := globalApplication + globalApplication = &App{} + t.Cleanup(func() { globalApplication = prev }) tray := &linuxSystemTray{parent: &SystemTray{}} for _, eventID := range []string{"opened", "closed"} {