Skip to content

Commit 0f9173e

Browse files
committed
Improve GNOME systray compatability
1 parent 82bda46 commit 0f9173e

File tree

7 files changed

+281
-3
lines changed

7 files changed

+281
-3
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,15 @@ env GITHUB_TOKEN=your_token_here goose
6868

6969
We don't yet persist fine-grained tokens to disk - PR's welcome!
7070

71+
## Usage
72+
73+
- **macOS/Windows**: Click the tray icon to show the menu
74+
- **Linux/BSD with snixembed**: Right-click the tray icon to show the menu (left-click refreshes PRs)
75+
7176
## Known Issues
7277

7378
- Visual notifications won't work on macOS until we release signed binaries.
74-
- Tray icons on GNOME require [snixembed](https://git.sr.ht/~steef/snixembed) and enabling the [Legacy Tray extension](https://www.omgubuntu.co.uk/2024/08/gnome-official-status-icons-extension).
79+
- Tray icons on GNOME require [snixembed](https://git.sr.ht/~steef/snixembed) and enabling the [Legacy Tray extension](https://www.omgubuntu.co.uk/2024/08/gnome-official-status-icons-extension). Goose will automatically launch snixembed if needed, but you must install it first (e.g., `apt install snixembed` or `yay -S snixembed`).
7580

7681
## Pricing
7782

cmd/goose/loginitem_other.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
//go:build !darwin
22

3-
// Package main - loginitem_other.go provides stub functions for non-macOS platforms.
43
package main
54

65
import "context"

cmd/goose/main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"sync"
1818
"time"
1919

20+
"github.com/codeGROOVE-dev/goose/cmd/goose/x11tray"
2021
"github.com/codeGROOVE-dev/retry"
2122
"github.com/codeGROOVE-dev/turnclient/pkg/turn"
2223
"github.com/energye/systray"
@@ -110,6 +111,7 @@ type App struct {
110111
enableAutoBrowser bool
111112
}
112113

114+
//nolint:maintidx // Main function complexity is acceptable for initialization logic
113115
func main() {
114116
// Parse command line flags
115117
var targetUser string
@@ -294,6 +296,15 @@ func main() {
294296
slog.Info("Skipping user load - no GitHub client available")
295297
}
296298

299+
slog.Info("Checking system tray availability...")
300+
trayProxy, err := x11tray.EnsureTray(ctx)
301+
if err != nil {
302+
slog.Error("FATAL: System tray unavailable",
303+
"error", err,
304+
"help", "Ensure your desktop environment has a system tray, or install snixembed")
305+
os.Exit(1)
306+
}
307+
297308
slog.Info("Starting systray...")
298309
// Create a cancellable context for the application
299310
appCtx, cancel := context.WithCancel(ctx)
@@ -305,6 +316,13 @@ func main() {
305316
if app.sprinklerMonitor != nil {
306317
app.sprinklerMonitor.stop()
307318
}
319+
// Stop tray proxy if we started one
320+
if trayProxy != nil {
321+
slog.Info("Stopping system tray proxy")
322+
if err := trayProxy.Stop(); err != nil {
323+
slog.Warn("Failed to stop tray proxy cleanly", "error", err)
324+
}
325+
}
308326
app.cleanupOldCache()
309327
})
310328
}
@@ -423,7 +441,10 @@ func (app *App) onReady(ctx context.Context) {
423441
}
424442
}
425443

444+
// On Unix platforms with snixembed, menu display is controlled by physical
445+
// right-clicks detected by snixembed. Left-click is used for refresh action.
426446
if menu != nil {
447+
// On macOS/Windows, show the menu
427448
if err := menu.ShowMenu(); err != nil {
428449
slog.Error("Failed to show menu", "error", err)
429450
}
@@ -433,10 +454,13 @@ func (app *App) onReady(ctx context.Context) {
433454
systray.SetOnRClick(func(menu systray.IMenu) {
434455
slog.Debug("Right click detected")
435456
if menu != nil {
457+
// On macOS/Windows, explicitly show the menu
436458
if err := menu.ShowMenu(); err != nil {
437459
slog.Error("Failed to show menu", "error", err)
438460
}
439461
}
462+
// On Unix platforms with snixembed, the menu is automatically shown
463+
// by snixembed when it detects the right-click
440464
})
441465

442466
// Check if we have an auth error

cmd/goose/ui.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ func (app *App) setTrayTitle() {
266266
}
267267

268268
// addPRSection adds a section of PRs to the menu.
269+
//
270+
//nolint:maintidx // Function complexity is inherent to PR menu building logic
269271
func (app *App) addPRSection(ctx context.Context, prs []PR, sectionTitle string, blockedCount int) {
270272
slog.Debug("[MENU] addPRSection called",
271273
"section", sectionTitle,

cmd/goose/x11tray/tray_other.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//go:build !linux && !freebsd && !openbsd && !netbsd && !dragonfly && !solaris && !illumos && !aix
2+
3+
package x11tray
4+
5+
import (
6+
"context"
7+
)
8+
9+
// HealthCheck always returns nil on non-Unix platforms where system tray
10+
// availability is handled differently by the OS.
11+
func HealthCheck() error {
12+
return nil
13+
}
14+
15+
// ProxyProcess represents a running proxy process (not used on non-Unix platforms).
16+
type ProxyProcess struct{}
17+
18+
// Stop is a no-op on non-Unix platforms.
19+
func (p *ProxyProcess) Stop() error {
20+
return nil
21+
}
22+
23+
// TryProxy is not needed on non-Unix platforms and always returns nil.
24+
func TryProxy(ctx context.Context) (*ProxyProcess, error) {
25+
return nil, nil
26+
}
27+
28+
// EnsureTray always succeeds on non-Unix platforms.
29+
func EnsureTray(ctx context.Context) (*ProxyProcess, error) {
30+
return nil, nil
31+
}
32+
33+
// ShowContextMenu is a no-op on non-Unix platforms.
34+
// On macOS and Windows, the systray library handles menu display natively.
35+
func ShowContextMenu() {
36+
// No-op - menu display is handled by the systray library on these platforms
37+
}

cmd/goose/x11tray/tray_unix.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
//go:build linux || freebsd || openbsd || netbsd || dragonfly || solaris || illumos || aix
2+
3+
// Package x11tray provides system tray functionality for Unix platforms.
4+
// It handles StatusNotifierItem integration via DBus and manages the snixembed proxy
5+
// for compatibility with legacy X11 system trays.
6+
package x11tray
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"log/slog"
13+
"os"
14+
"os/exec"
15+
"strings"
16+
"time"
17+
18+
"github.com/godbus/dbus/v5"
19+
)
20+
21+
const (
22+
statusNotifierWatcher = "org.kde.StatusNotifierWatcher"
23+
statusNotifierWatcherPath = "/StatusNotifierWatcher"
24+
)
25+
26+
// HealthCheck verifies that a system tray implementation is available via D-Bus.
27+
// It checks for the KDE StatusNotifierWatcher service which is required for
28+
// system tray icons on modern Linux desktops.
29+
//
30+
// Returns nil if a tray is available, or an error describing the issue.
31+
func HealthCheck() error {
32+
conn, err := dbus.ConnectSessionBus()
33+
if err != nil {
34+
return fmt.Errorf("failed to connect to D-Bus session bus: %w", err)
35+
}
36+
defer func() {
37+
if err := conn.Close(); err != nil {
38+
slog.Debug("[X11TRAY] Failed to close DBus connection", "error", err)
39+
}
40+
}()
41+
42+
// Check if the StatusNotifierWatcher service exists
43+
var names []string
44+
err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
45+
if err != nil {
46+
return fmt.Errorf("failed to query D-Bus services: %w", err)
47+
}
48+
49+
for _, name := range names {
50+
if name == statusNotifierWatcher {
51+
slog.Debug("[X11TRAY] StatusNotifierWatcher found", "service", statusNotifierWatcher)
52+
return nil
53+
}
54+
}
55+
56+
return fmt.Errorf("no system tray found: %s service not available", statusNotifierWatcher)
57+
}
58+
59+
// ProxyProcess represents a running snixembed background process.
60+
type ProxyProcess struct {
61+
cmd *exec.Cmd
62+
cancel context.CancelFunc
63+
}
64+
65+
// Stop terminates the proxy process gracefully.
66+
func (p *ProxyProcess) Stop() error {
67+
if p.cancel != nil {
68+
p.cancel()
69+
}
70+
if p.cmd != nil && p.cmd.Process != nil {
71+
return p.cmd.Process.Kill()
72+
}
73+
return nil
74+
}
75+
76+
// TryProxy attempts to start snixembed as a system tray proxy service.
77+
// snixembed bridges legacy X11 system trays to modern StatusNotifier-based trays.
78+
//
79+
// If successful, returns a ProxyProcess that should be stopped when the application exits.
80+
// Returns an error if snixembed is not found or fails to start successfully.
81+
func TryProxy(ctx context.Context) (*ProxyProcess, error) {
82+
// Check if snixembed is available
83+
snixembedPath, err := exec.LookPath("snixembed")
84+
if err != nil {
85+
return nil, errors.New(
86+
"snixembed not found in PATH: install it with your package manager " +
87+
"(e.g., 'apt install snixembed' or 'yay -S snixembed')")
88+
}
89+
90+
slog.Info("[X11TRAY] Starting snixembed proxy", "path", snixembedPath)
91+
92+
// Create a cancellable context for the proxy process
93+
proxyCtx, cancel := context.WithCancel(ctx)
94+
95+
// Start snixembed in the background
96+
cmd := exec.CommandContext(proxyCtx, snixembedPath)
97+
98+
// Capture output for debugging
99+
if err := cmd.Start(); err != nil {
100+
cancel()
101+
return nil, fmt.Errorf("failed to start snixembed: %w", err)
102+
}
103+
104+
proxy := &ProxyProcess{
105+
cmd: cmd,
106+
cancel: cancel,
107+
}
108+
109+
// Give snixembed time to register with D-Bus
110+
// This is necessary because the service takes a moment to become available
111+
time.Sleep(500 * time.Millisecond)
112+
113+
// Verify that the proxy worked by checking again
114+
if err := HealthCheck(); err != nil {
115+
// snixembed started but didn't fix the problem
116+
if stopErr := proxy.Stop(); stopErr != nil {
117+
slog.Debug("[X11TRAY] Failed to stop proxy after failed health check", "error", stopErr)
118+
}
119+
return nil, fmt.Errorf("snixembed started but system tray still unavailable: %w", err)
120+
}
121+
122+
slog.Info("[X11TRAY] snixembed proxy started successfully")
123+
return proxy, nil
124+
}
125+
126+
// EnsureTray checks for system tray availability and attempts to start a proxy if needed.
127+
// This is a convenience function that combines HealthCheck and TryProxy.
128+
//
129+
// Returns a ProxyProcess if one was started (caller must Stop() it on exit), or nil if
130+
// the native tray was available. Returns an error if no tray solution could be found.
131+
func EnsureTray(ctx context.Context) (*ProxyProcess, error) {
132+
// First, check if we already have a working tray
133+
if err := HealthCheck(); err == nil {
134+
slog.Debug("[X11TRAY] Native system tray available")
135+
// No proxy needed (nil) and no error (nil) - native tray is working
136+
return nil, nil //nolint:nilnil // nil proxy is valid when native tray exists
137+
}
138+
139+
slog.Warn("[X11TRAY] No native system tray found, attempting to start proxy")
140+
141+
// Try to start the proxy
142+
proxy, err := TryProxy(ctx)
143+
if err != nil {
144+
return nil, fmt.Errorf("system tray unavailable and proxy failed: %w", err)
145+
}
146+
147+
return proxy, nil
148+
}
149+
150+
// ShowContextMenu triggers the context menu via DBus on Unix platforms.
151+
// On Linux/FreeBSD with StatusNotifierItem, the menu parameter in click handlers is nil,
152+
// so we need to manually call the ContextMenu method via DBus to show the menu.
153+
func ShowContextMenu() {
154+
conn, err := dbus.ConnectSessionBus()
155+
if err != nil {
156+
slog.Warn("[X11TRAY] Failed to connect to session bus", "error", err)
157+
return
158+
}
159+
defer func() {
160+
if err := conn.Close(); err != nil {
161+
slog.Debug("[X11TRAY] Failed to close DBus connection", "error", err)
162+
}
163+
}()
164+
165+
// Find our StatusNotifierItem service
166+
var names []string
167+
err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
168+
if err != nil {
169+
slog.Warn("[X11TRAY] Failed to list DBus names", "error", err)
170+
return
171+
}
172+
173+
// Find the StatusNotifierItem service for this process
174+
var serviceName string
175+
pid := os.Getpid()
176+
expectedPrefix := fmt.Sprintf("org.kde.StatusNotifierItem-%d-", pid)
177+
for _, name := range names {
178+
if strings.HasPrefix(name, expectedPrefix) {
179+
serviceName = name
180+
break
181+
}
182+
}
183+
184+
if serviceName == "" {
185+
slog.Warn("[X11TRAY] StatusNotifierItem service not found", "pid", pid, "expectedPrefix", expectedPrefix)
186+
return
187+
}
188+
189+
slog.Info("[X11TRAY] Attempting to trigger context menu", "service", serviceName)
190+
191+
// Try different methods to trigger the menu display
192+
obj := conn.Object(serviceName, "/StatusNotifierItem")
193+
194+
// First try: Call ContextMenu method (standard StatusNotifierItem)
195+
call := obj.Call("org.kde.StatusNotifierItem.ContextMenu", 0, int32(0), int32(0))
196+
if call.Err != nil {
197+
slog.Info("[X11TRAY] ContextMenu method failed, trying SecondaryActivate", "error", call.Err)
198+
199+
// Second try: Call SecondaryActivate (right-click equivalent)
200+
call = obj.Call("org.kde.StatusNotifierItem.SecondaryActivate", 0, int32(0), int32(0))
201+
if call.Err != nil {
202+
slog.Warn("[X11TRAY] Both ContextMenu and SecondaryActivate failed", "contextMenuErr", call.Err)
203+
slog.Info("[X11TRAY] Note: Menu should still work with right-click via snixembed")
204+
return
205+
}
206+
slog.Info("[X11TRAY] Successfully triggered SecondaryActivate")
207+
return
208+
}
209+
210+
slog.Info("[X11TRAY] Successfully triggered ContextMenu")
211+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/codeGROOVE-dev/turnclient v0.0.0-20251030022425-bc3b14acf75e
99
github.com/energye/systray v1.0.2
1010
github.com/gen2brain/beeep v0.11.1
11+
github.com/godbus/dbus/v5 v5.1.0
1112
github.com/google/go-github/v57 v57.0.0
1213
golang.org/x/oauth2 v0.33.0
1314
)
@@ -17,7 +18,6 @@ require (
1718
github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4 // indirect
1819
github.com/esiqveland/notify v0.13.3 // indirect
1920
github.com/go-ole/go-ole v1.3.0 // indirect
20-
github.com/godbus/dbus/v5 v5.1.0 // indirect
2121
github.com/google/go-querystring v1.1.0 // indirect
2222
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
2323
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect

0 commit comments

Comments
 (0)