Some checks failed
Release / build (push) Failing after 21s
Replace Node.js + Python codebase with three Go binaries: - claude-statusline: CLI status bar for Claude Code - claude-fetcher: standalone cron job for API usage - claude-widget: system tray icon (fyne-io/systray + fogleman/gg) All CGO-free for trivial cross-compilation. Add nfpm .deb packaging with autostart and cron. CI pipeline produces Linux + Windows binaries, .deb, .tar.gz, and .zip release assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
5.1 KiB
Go
216 lines
5.1 KiB
Go
package tray
|
|
|
|
import (
|
|
"fmt"
|
|
"os/exec"
|
|
"runtime"
|
|
"sync"
|
|
|
|
"fyne.io/systray"
|
|
"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%)
|
|
if iconData, err := renderer.RenderIconPNG(0); err == nil {
|
|
systray.SetIcon(iconData)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Session key
|
|
mSessionKey := systray.AddMenuItem("Session Key...", "Open session key config file")
|
|
|
|
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 <-mSessionKey.ClickedCh:
|
|
a.openSessionKeyFile()
|
|
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
|
|
}
|
|
if iconData, err := renderer.RenderIconPNG(pct); err == nil {
|
|
systray.SetIcon(iconData)
|
|
}
|
|
|
|
// 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) openSessionKeyFile() {
|
|
path := config.SessionKeyPath()
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
cmd = exec.Command("notepad", path)
|
|
case "darwin":
|
|
cmd = exec.Command("open", "-t", path)
|
|
default:
|
|
cmd = exec.Command("xdg-open", path)
|
|
}
|
|
_ = cmd.Start()
|
|
}
|