package tray import ( "fmt" "log" "os" "path/filepath" "sync" "fyne.io/systray" "git.davoryn.de/calic/claude-statusline/internal/browser" "git.davoryn.de/calic/claude-statusline/internal/config" "git.davoryn.de/calic/claude-statusline/internal/fetcher" "git.davoryn.de/calic/claude-statusline/internal/renderer" ) type interval struct { label string seconds int } var intervals = []interval{ {"1 min", 60}, {"5 min", 300}, {"15 min", 900}, {"30 min", 1800}, } // App manages the tray icon, fetcher, and menu state. type App struct { mu sync.Mutex usage fetcher.ParsedUsage bf *fetcher.BackgroundFetcher menuItems struct { fiveHourText *systray.MenuItem fiveHourReset *systray.MenuItem sevenDayText *systray.MenuItem sevenDayReset *systray.MenuItem intervalRadio []*systray.MenuItem } } // Run starts the tray application (blocking). func Run() { app := &App{} systray.Run(app.onReady, app.onExit) } func (a *App) onReady() { systray.SetTitle("Claude Usage") systray.SetTooltip("Claude Usage: loading...") // Set initial icon (0%) iconData, err := renderer.RenderIconForTray(0) log.Printf("initial icon: %d bytes, render err=%v", len(iconData), err) if err == nil { systray.SetIcon(iconData) log.Println("SetIcon called (initial)") } // Usage display items (non-clickable info) a.menuItems.fiveHourText = systray.AddMenuItem("5h Usage: loading...", "") a.menuItems.fiveHourText.Disable() a.menuItems.fiveHourReset = systray.AddMenuItem("Resets in: —", "") a.menuItems.fiveHourReset.Disable() systray.AddSeparator() a.menuItems.sevenDayText = systray.AddMenuItem("7d Usage: —", "") a.menuItems.sevenDayText.Disable() a.menuItems.sevenDayReset = systray.AddMenuItem("Resets in: —", "") a.menuItems.sevenDayReset.Disable() systray.AddSeparator() // Refresh button mRefresh := systray.AddMenuItem("Refresh Now", "Force refresh usage data") // Interval submenu mInterval := systray.AddMenuItem("Refresh Interval", "Change refresh interval") currentInterval := a.getCurrentInterval() for _, iv := range intervals { item := mInterval.AddSubMenuItem(iv.label, fmt.Sprintf("Refresh every %s", iv.label)) if iv.seconds == currentInterval { item.Check() } a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item) } // Login / logout mLogin := systray.AddMenuItem("Login in Browser", "Open browser to log in to claude.ai") mLogout := systray.AddMenuItem("Logout", "Clear session key and browser profile") systray.AddSeparator() mQuit := systray.AddMenuItem("Quit", "Exit Claude Usage Widget") // Start background fetcher a.bf = fetcher.NewBackgroundFetcher(a.onUsageUpdate) a.bf.Start() // Handle menu clicks go func() { for { select { case <-mRefresh.ClickedCh: a.bf.Refresh() case <-mLogin.ClickedCh: go a.doLogin() case <-mLogout.ClickedCh: a.doLogout() case <-mQuit.ClickedCh: systray.Quit() return } } }() // Handle interval radio clicks for i, item := range a.menuItems.intervalRadio { go func(idx int, mi *systray.MenuItem) { for range mi.ClickedCh { a.setInterval(idx) } }(i, item) } } func (a *App) onExit() { if a.bf != nil { a.bf.Stop() } } func (a *App) onUsageUpdate(data fetcher.ParsedUsage) { a.mu.Lock() a.usage = data a.mu.Unlock() // Update icon pct := 0 if data.Error == "" { pct = data.FiveHourPct } log.Printf("onUsageUpdate: pct=%d, error=%q", pct, data.Error) if iconData, err := renderer.RenderIconForTray(pct); err == nil { systray.SetIcon(iconData) log.Printf("SetIcon called (pct=%d, %d bytes)", pct, len(iconData)) } else { log.Printf("RenderIconPNG error: %v", err) } // Update tooltip if data.Error != "" { systray.SetTooltip(fmt.Sprintf("Claude Usage: %s", data.Error)) } else { systray.SetTooltip(fmt.Sprintf("Claude Usage: %d%%", data.FiveHourPct)) } // Update menu text a.updateMenuText(data) } func (a *App) updateMenuText(data fetcher.ParsedUsage) { if data.Error != "" { a.menuItems.fiveHourText.SetTitle(fmt.Sprintf("5h Usage: %s", data.Error)) a.menuItems.fiveHourReset.SetTitle("Resets in: —") a.menuItems.sevenDayText.SetTitle("7d Usage: —") a.menuItems.sevenDayReset.SetTitle("Resets in: —") return } if data.FiveHourPct > 0 { a.menuItems.fiveHourText.SetTitle(fmt.Sprintf("5h Usage: %d%%", data.FiveHourPct)) } else { a.menuItems.fiveHourText.SetTitle("5h Usage: 0%") } if data.FiveHourResetsIn != "" { a.menuItems.fiveHourReset.SetTitle(fmt.Sprintf("Resets in: %s", data.FiveHourResetsIn)) } else { a.menuItems.fiveHourReset.SetTitle("Resets in: —") } if data.SevenDayPct > 0 { a.menuItems.sevenDayText.SetTitle(fmt.Sprintf("7d Usage: %d%%", data.SevenDayPct)) } else { a.menuItems.sevenDayText.SetTitle("7d Usage: 0%") } if data.SevenDayResetsIn != "" { a.menuItems.sevenDayReset.SetTitle(fmt.Sprintf("Resets in: %s", data.SevenDayResetsIn)) } else { a.menuItems.sevenDayReset.SetTitle("Resets in: —") } } func (a *App) getCurrentInterval() int { cfg := config.Load() return cfg.RefreshInterval } func (a *App) setInterval(idx int) { if idx < 0 || idx >= len(intervals) { return } // Update radio check marks for i, item := range a.menuItems.intervalRadio { if i == idx { item.Check() } else { item.Uncheck() } } a.bf.SetInterval(intervals[idx].seconds) } func (a *App) doLogin() { systray.SetTooltip("Claude Usage: logging in...") _, err := browser.LoginAndGetSessionKey() if err != nil { systray.SetTooltip(fmt.Sprintf("Claude Usage: login failed — %s", err)) return } a.bf.Refresh() } func (a *App) doLogout() { _ = os.Remove(config.SessionKeyPath()) profileDir := filepath.Join(config.ConfigDir(), "browser-profile") _ = os.RemoveAll(profileDir) a.bf.Refresh() }