@@ -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() {
286288func (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