Skip to content

Commit 7341f25

Browse files
authored
Merge pull request #58 from tstromberg/linux
Improve X11/Wayland compatibility
2 parents 5bad27f + 436d755 commit 7341f25

File tree

12 files changed

+410
-179
lines changed

12 files changed

+410
-179
lines changed

cmd/goose/icons.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package main
2+
3+
import (
4+
_ "embed"
5+
"log/slog"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// Embed icon files at compile time for better distribution
11+
//
12+
//go:embed icons/goose.png
13+
var iconGoose []byte
14+
15+
//go:embed icons/popper.png
16+
var iconPopper []byte
17+
18+
//go:embed icons/smiling-face.png
19+
var iconSmiling []byte
20+
21+
//go:embed icons/lock.png
22+
var iconLock []byte
23+
24+
//go:embed icons/warning.png
25+
var iconWarning []byte
26+
27+
// IconType represents different icon states
28+
type IconType int
29+
30+
const (
31+
IconSmiling IconType = iota // No blocked PRs
32+
IconGoose // Incoming PRs blocked
33+
IconPopper // Outgoing PRs blocked
34+
IconBoth // Both incoming and outgoing blocked
35+
IconWarning // General error/warning
36+
IconLock // Authentication error
37+
)
38+
39+
// getIcon returns the icon bytes for the given type
40+
func getIcon(iconType IconType) []byte {
41+
switch iconType {
42+
case IconGoose:
43+
return iconGoose
44+
case IconPopper:
45+
return iconPopper
46+
case IconSmiling:
47+
return iconSmiling
48+
case IconWarning:
49+
return iconWarning
50+
case IconLock:
51+
return iconLock
52+
case IconBoth:
53+
// For both, we'll use the goose icon as primary
54+
return iconGoose
55+
default:
56+
return iconSmiling
57+
}
58+
}
59+
60+
// loadIconFromFile loads an icon from the filesystem (fallback if embed fails)
61+
func loadIconFromFile(filename string) []byte {
62+
iconPath := filepath.Join("icons", filename)
63+
data, err := os.ReadFile(iconPath)
64+
if err != nil {
65+
slog.Warn("Failed to load icon file", "path", iconPath, "error", err)
66+
return nil
67+
}
68+
return data
69+
}
70+
71+
// setTrayIcon updates the system tray icon based on PR counts
72+
func (app *App) setTrayIcon(iconType IconType) {
73+
iconBytes := getIcon(iconType)
74+
if iconBytes == nil || len(iconBytes) == 0 {
75+
slog.Warn("Icon bytes are empty, skipping icon update", "type", iconType)
76+
return
77+
}
78+
79+
app.systrayInterface.SetIcon(iconBytes)
80+
slog.Debug("[TRAY] Setting icon", "type", iconType)
81+
}

cmd/goose/icons/goose.png

16 KB
Loading

cmd/goose/icons/lock.png

9.6 KB
Loading

cmd/goose/icons/popper.png

30.8 KB
Loading

cmd/goose/icons/smiling-face.png

50.2 KB
Loading

cmd/goose/icons/warning.png

9.72 KB
Loading

cmd/goose/loginitem_darwin.go

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func appPath() (string, error) {
142142
}
143143

144144
// addLoginItemUI adds the login item menu option (macOS only).
145-
func addLoginItemUI(ctx context.Context, _ *App) {
145+
func addLoginItemUI(ctx context.Context, app *App) {
146146
// Check if we're running from an app bundle
147147
execPath, err := os.Executable()
148148
if err != nil {
@@ -163,33 +163,28 @@ func addLoginItemUI(ctx context.Context, _ *App) {
163163
return
164164
}
165165

166-
loginItem := systray.AddMenuItem("Start at Login", "Automatically start when you log in")
167-
168-
// Set initial state
166+
// Add text checkmark for consistency with other menu items
167+
var loginText string
169168
if isLoginItem(ctx) {
170-
loginItem.Check()
169+
loginText = "✓ Start at Login"
170+
} else {
171+
loginText = "Start at Login"
171172
}
173+
loginItem := systray.AddMenuItem(loginText, "Automatically start when you log in")
172174

173175
loginItem.Click(func() {
174176
isEnabled := isLoginItem(ctx)
175177
newState := !isEnabled
176178

177179
if err := setLoginItem(ctx, newState); err != nil {
178180
slog.Error("Failed to set login item", "error", err)
179-
// Revert the UI state on error
180-
if isEnabled {
181-
loginItem.Check()
182-
} else {
183-
loginItem.Uncheck()
184-
}
185181
return
186182
}
187183

188184
// Update UI state
189-
if newState {
190-
loginItem.Check()
191-
} else {
192-
loginItem.Uncheck()
193-
}
185+
slog.Info("[SETTINGS] Start at Login toggled", "enabled", newState)
186+
187+
// Rebuild menu to update checkmark
188+
app.rebuildMenu(ctx)
194189
})
195190
}

cmd/goose/main.go

Lines changed: 125 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"log/slog"
1212
"os"
1313
"path/filepath"
14+
"runtime"
1415
"slices"
1516
"strings"
1617
"sync"
@@ -90,6 +91,7 @@ type App struct {
9091
updateInterval time.Duration
9192
consecutiveFailures int
9293
mu sync.RWMutex
94+
menuMutex sync.Mutex // Mutex to prevent concurrent menu rebuilds
9395
enableAutoBrowser bool
9496
hideStaleIncoming bool
9597
hasPerformedInitialDiscovery bool // Track if we've done the first poll to distinguish from real state changes
@@ -157,7 +159,7 @@ func main() {
157159
Level: logLevel,
158160
}
159161
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, opts)))
160-
slog.Info("Starting GitHub PR Monitor", "version", version, "commit", commit, "date", date)
162+
slog.Info("Starting Goose", "version", version, "commit", commit, "date", date)
161163
slog.Info("Configuration", "update_interval", updateInterval, "max_retries", maxRetries, "max_delay", maxRetryDelay)
162164
slog.Info("Browser auto-open configuration", "startup_delay", browserOpenDelay, "max_per_minute", maxBrowserOpensMinute, "max_per_day", maxBrowserOpensDay)
163165

@@ -286,23 +288,111 @@ func main() {
286288
func (app *App) onReady(ctx context.Context) {
287289
slog.Info("System tray ready")
288290

291+
// On Linux, immediately build a minimal menu to ensure it's visible
292+
if runtime.GOOS == "linux" {
293+
slog.Info("[LINUX] Building initial minimal menu")
294+
app.systrayInterface.ResetMenu()
295+
placeholderItem := app.systrayInterface.AddMenuItem("Loading...", "Goose is starting up")
296+
if placeholderItem != nil {
297+
placeholderItem.Disable()
298+
}
299+
app.systrayInterface.AddSeparator()
300+
quitItem := app.systrayInterface.AddMenuItem("Quit", "Quit Goose")
301+
if quitItem != nil {
302+
quitItem.Click(func() {
303+
slog.Info("Quit clicked")
304+
systray.Quit()
305+
})
306+
}
307+
}
308+
289309
// Set up click handlers first (needed for both success and error states)
290310
systray.SetOnClick(func(menu systray.IMenu) {
291311
slog.Debug("Icon clicked")
292312

293-
// Check if we can perform a forced refresh (rate limited to every 10 seconds)
313+
// Check if we're in auth error state and should retry
294314
app.mu.RLock()
295-
timeSinceLastSearch := time.Since(app.lastSearchAttempt)
315+
hasAuthError := app.authError != ""
296316
app.mu.RUnlock()
297317

298-
if timeSinceLastSearch >= minUpdateInterval {
299-
slog.Info("[CLICK] Forcing search refresh", "lastSearchAgo", timeSinceLastSearch)
318+
if hasAuthError {
319+
slog.Info("[CLICK] Auth error detected, attempting to re-authenticate")
300320
go func() {
301-
app.updatePRs(ctx)
321+
// Try to reinitialize clients which will attempt to get token via gh auth token
322+
if err := app.initClients(ctx); err != nil {
323+
slog.Warn("[CLICK] Re-authentication failed", "error", err)
324+
app.mu.Lock()
325+
app.authError = err.Error()
326+
app.mu.Unlock()
327+
} else {
328+
// Success! Clear auth error and reload user
329+
slog.Info("[CLICK] Re-authentication successful")
330+
app.mu.Lock()
331+
app.authError = ""
332+
app.mu.Unlock()
333+
334+
// Load current user
335+
if app.client != nil {
336+
var user *github.User
337+
err := retry.Do(func() error {
338+
var retryErr error
339+
user, _, retryErr = app.client.Users.Get(ctx, "")
340+
if retryErr != nil {
341+
slog.Warn("GitHub Users.Get failed (will retry)", "error", retryErr)
342+
return retryErr
343+
}
344+
return nil
345+
},
346+
retry.Attempts(maxRetries),
347+
retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)),
348+
retry.MaxDelay(maxRetryDelay),
349+
retry.OnRetry(func(n uint, err error) {
350+
slog.Debug("[RETRY] Retrying GitHub API call", "attempt", n, "error", err)
351+
}),
352+
)
353+
if err == nil && user != nil {
354+
if app.targetUser == "" {
355+
app.targetUser = user.GetLogin()
356+
slog.Info("Set target user to current user", "user", app.targetUser)
357+
}
358+
}
359+
}
360+
361+
// Update tooltip
362+
tooltip := "Goose - Loading PRs..."
363+
if app.targetUser != "" {
364+
tooltip = fmt.Sprintf("Goose - Loading PRs... (@%s)", app.targetUser)
365+
}
366+
systray.SetTooltip(tooltip)
367+
368+
// Rebuild menu to remove error state
369+
app.rebuildMenu(ctx)
370+
371+
// Start update loop if not already running
372+
if !app.menuInitialized {
373+
app.menuInitialized = true
374+
go app.updateLoop(ctx)
375+
} else {
376+
// Just do a single update to refresh data
377+
go app.updatePRs(ctx)
378+
}
379+
}
302380
}()
303381
} else {
304-
remainingTime := minUpdateInterval - timeSinceLastSearch
305-
slog.Debug("[CLICK] Rate limited", "lastSearchAgo", timeSinceLastSearch, "remaining", remainingTime)
382+
// Normal operation - check if we can perform a forced refresh
383+
app.mu.RLock()
384+
timeSinceLastSearch := time.Since(app.lastSearchAttempt)
385+
app.mu.RUnlock()
386+
387+
if timeSinceLastSearch >= minUpdateInterval {
388+
slog.Info("[CLICK] Forcing search refresh", "lastSearchAgo", timeSinceLastSearch)
389+
go func() {
390+
app.updatePRs(ctx)
391+
}()
392+
} else {
393+
remainingTime := minUpdateInterval - timeSinceLastSearch
394+
slog.Debug("[CLICK] Rate limited", "lastSearchAgo", timeSinceLastSearch, "remaining", remainingTime)
395+
}
306396
}
307397

308398
if menu != nil {
@@ -323,21 +413,23 @@ func (app *App) onReady(ctx context.Context) {
323413

324414
// Check if we have an auth error
325415
if app.authError != "" {
326-
systray.SetTitle("⚠️")
327-
systray.SetTooltip("GitHub PR Monitor - Authentication Error")
416+
systray.SetTitle("")
417+
app.setTrayIcon(IconLock)
418+
systray.SetTooltip("Goose - Authentication Error")
328419
// Create initial error menu
329420
app.rebuildMenu(ctx)
330421
// Clean old cache on startup
331422
app.cleanupOldCache()
332423
return
333424
}
334425

335-
systray.SetTitle("Loading PRs...")
426+
systray.SetTitle("")
427+
app.setTrayIcon(IconSmiling) // Start with smiling icon while loading
336428

337429
// Set tooltip based on whether we're using a custom user
338-
tooltip := "GitHub PR Monitor"
430+
tooltip := "Goose - Loading PRs..."
339431
if app.targetUser != "" {
340-
tooltip = fmt.Sprintf("GitHub PR Monitor - @%s", app.targetUser)
432+
tooltip = fmt.Sprintf("Goose - Loading PRs... (@%s)", app.targetUser)
341433
}
342434
systray.SetTooltip(tooltip)
343435

@@ -355,8 +447,9 @@ func (app *App) updateLoop(ctx context.Context) {
355447
slog.Error("PANIC in update loop", "panic", r)
356448

357449
// Set error state in UI
358-
systray.SetTitle("💥")
359-
systray.SetTooltip("GitHub PR Monitor - Critical error")
450+
systray.SetTitle("")
451+
app.setTrayIcon(IconWarning)
452+
systray.SetTooltip("Goose - Critical error")
360453

361454
// Update failure count
362455
app.mu.Lock()
@@ -424,23 +517,19 @@ func (app *App) updatePRs(ctx context.Context) {
424517
app.mu.Unlock()
425518

426519
// Progressive degradation based on failure count
427-
var title, tooltip string
520+
var tooltip string
521+
var iconType IconType
428522
switch {
429-
case failureCount == 1:
430-
title = "⚠️"
431-
tooltip = "GitHub PR Monitor - Temporary error, retrying..."
432523
case failureCount <= minorFailureThreshold:
433-
title = "⚠️"
434-
tooltip = fmt.Sprintf("GitHub PR Monitor - %d consecutive failures", failureCount)
435-
case failureCount <= majorFailureThreshold:
436-
title = "❌"
437-
tooltip = "GitHub PR Monitor - Multiple failures, check connection"
524+
iconType = IconWarning
525+
tooltip = fmt.Sprintf("Goose - %d consecutive failures", failureCount)
438526
default:
439-
title = "💀"
440-
tooltip = "GitHub PR Monitor - Service degraded, check authentication"
527+
iconType = IconWarning
528+
tooltip = "Goose - Connection failures, check network/auth"
441529
}
442530

443-
systray.SetTitle(title)
531+
systray.SetTitle("")
532+
app.setTrayIcon(iconType)
444533

445534
// Include time since last success and user info
446535
timeSinceSuccess := "never"
@@ -602,23 +691,19 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
602691
app.mu.Unlock()
603692

604693
// Progressive degradation based on failure count
605-
var title, tooltip string
694+
var tooltip string
695+
var iconType IconType
606696
switch {
607-
case failureCount == 1:
608-
title = "⚠️"
609-
tooltip = "GitHub PR Monitor - Temporary error, retrying..."
610697
case failureCount <= minorFailureThreshold:
611-
title = "⚠️"
612-
tooltip = fmt.Sprintf("GitHub PR Monitor - %d consecutive failures", failureCount)
613-
case failureCount <= majorFailureThreshold:
614-
title = "❌"
615-
tooltip = "GitHub PR Monitor - Multiple failures, check connection"
698+
iconType = IconWarning
699+
tooltip = fmt.Sprintf("Goose - %d consecutive failures", failureCount)
616700
default:
617-
title = "💀"
618-
tooltip = "GitHub PR Monitor - Service degraded, check authentication"
701+
iconType = IconWarning
702+
tooltip = "Goose - Connection failures, check network/auth"
619703
}
620704

621-
systray.SetTitle(title)
705+
systray.SetTitle("")
706+
app.setTrayIcon(iconType)
622707
systray.SetTooltip(tooltip)
623708

624709
// Create or update menu to show error state
@@ -740,7 +825,7 @@ func (app *App) tryAutoOpenPR(ctx context.Context, pr PR, autoBrowserEnabled boo
740825
slog.Warn("Auto-open strict validation failed", "url", sanitizeForLog(pr.URL), "error", err)
741826
return
742827
}
743-
if err := openURL(ctx, pr.URL, pr.ActionKind); err != nil {
828+
if err := openURL(ctx, pr.URL); err != nil {
744829
slog.Error("[BROWSER] Failed to auto-open PR", "url", pr.URL, "error", err)
745830
} else {
746831
app.browserRateLimiter.RecordOpen(pr.URL)

0 commit comments

Comments
 (0)