All checks were successful
Release / build (push) Successful in 1m45s
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>
231 lines
5.7 KiB
Go
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()
|
|
}
|