Files
Axel Meyer 47165ce02c
All checks were successful
Release / build (push) Successful in 1m45s
Remove standalone fetcher, add setup tool with install/uninstall workflow
Drop claude-fetcher binary and cron job — the widget's built-in
BackgroundFetcher is the sole fetcher now. Add cmd/setup with cross-platform
install and uninstall (--uninstall): kills widget, removes binaries + autostart,
cleans Claude Code statusline setting, optionally removes config dir.

Also includes: browser-based login (chromedp), ICO wrapper for Windows tray
icon, and reduced icon size (64px).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:11:08 +01:00

231 lines
5.7 KiB
Go

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()
}