Rewrite in Go: static binaries, zero runtime dependencies
Some checks failed
Release / build (push) Failing after 21s
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>
This commit is contained in:
93
internal/config/config.go
Normal file
93
internal/config/config.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WidgetConfig holds the widget configuration.
|
||||
type WidgetConfig struct {
|
||||
RefreshInterval int `json:"refresh_interval"`
|
||||
OrgID string `json:"org_id"`
|
||||
}
|
||||
|
||||
var defaults = WidgetConfig{
|
||||
RefreshInterval: 300,
|
||||
OrgID: "",
|
||||
}
|
||||
|
||||
// ConfigDir returns the platform-specific config directory.
|
||||
func ConfigDir() string {
|
||||
if v := os.Getenv("CLAUDE_STATUSLINE_CONFIG"); v != "" {
|
||||
return v
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(os.Getenv("LOCALAPPDATA"), "claude-statusline")
|
||||
}
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "claude-statusline")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "claude-statusline")
|
||||
}
|
||||
|
||||
// ConfigPath returns the path to widget-config.json.
|
||||
func ConfigPath() string {
|
||||
return filepath.Join(ConfigDir(), "widget-config.json")
|
||||
}
|
||||
|
||||
// SessionKeyPath returns the path to the session-key file.
|
||||
func SessionKeyPath() string {
|
||||
return filepath.Join(ConfigDir(), "session-key")
|
||||
}
|
||||
|
||||
// Load reads widget-config.json and merges with defaults.
|
||||
func Load() WidgetConfig {
|
||||
cfg := defaults
|
||||
data, err := os.ReadFile(ConfigPath())
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if cfg.RefreshInterval < 60 {
|
||||
cfg.RefreshInterval = 60
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Save writes widget-config.json.
|
||||
func Save(cfg WidgetConfig) error {
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(ConfigPath(), data, 0o644)
|
||||
}
|
||||
|
||||
// GetSessionKey returns the session key from env or file.
|
||||
func GetSessionKey() string {
|
||||
if v := os.Getenv("CLAUDE_SESSION_KEY"); v != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
data, err := os.ReadFile(SessionKeyPath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// SetSessionKey writes the session key to file.
|
||||
func SetSessionKey(key string) error {
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(SessionKeyPath(), []byte(strings.TrimSpace(key)+"\n"), 0o600)
|
||||
}
|
||||
85
internal/fetcher/cache.go
Normal file
85
internal/fetcher/cache.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheData represents the JSON structure written to the cache file.
|
||||
// It mirrors the raw API response (five_hour, seven_day) plus error fields.
|
||||
type CacheData struct {
|
||||
FiveHour *UsageWindow `json:"five_hour,omitempty"`
|
||||
SevenDay *UsageWindow `json:"seven_day,omitempty"`
|
||||
Error string `json:"_error,omitempty"`
|
||||
Status int `json:"_status,omitempty"`
|
||||
Message string `json:"_message,omitempty"`
|
||||
}
|
||||
|
||||
// UsageWindow represents a single usage window from the API.
|
||||
type UsageWindow struct {
|
||||
Utilization float64 `json:"utilization"`
|
||||
ResetsAt string `json:"resets_at"`
|
||||
}
|
||||
|
||||
// CachePath returns the cache file path.
|
||||
func CachePath() string {
|
||||
if v := os.Getenv("CLAUDE_USAGE_CACHE"); v != "" {
|
||||
return v
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if tmp := os.Getenv("TEMP"); tmp != "" {
|
||||
return tmp + `\claude_usage.json`
|
||||
}
|
||||
return os.TempDir() + `\claude_usage.json`
|
||||
}
|
||||
return "/tmp/claude_usage.json"
|
||||
}
|
||||
|
||||
// WriteCache writes data to the cache file as JSON.
|
||||
func WriteCache(data *CacheData) error {
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(CachePath(), b, 0o644)
|
||||
}
|
||||
|
||||
// ReadCache reads and parses the cache file. Returns data and file age.
|
||||
// Returns nil if file doesn't exist or can't be parsed.
|
||||
func ReadCache() (*CacheData, time.Duration) {
|
||||
path := CachePath()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
age := time.Since(info.ModTime())
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
var data CacheData
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
return &data, age
|
||||
}
|
||||
|
||||
// ReadCacheIfFresh reads cache only if it's younger than maxAge.
|
||||
// Error caches are always returned regardless of age.
|
||||
func ReadCacheIfFresh(maxAge time.Duration) *CacheData {
|
||||
data, age := ReadCache()
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
if data.Error != "" {
|
||||
return data
|
||||
}
|
||||
if age > maxAge {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
292
internal/fetcher/fetcher.go
Normal file
292
internal/fetcher/fetcher.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://claude.ai"
|
||||
userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"
|
||||
)
|
||||
|
||||
// ParsedUsage is the display-friendly usage data passed to callbacks.
|
||||
type ParsedUsage struct {
|
||||
FiveHourPct int
|
||||
FiveHourResetsAt string
|
||||
FiveHourResetsIn string
|
||||
SevenDayPct int
|
||||
SevenDayResetsAt string
|
||||
SevenDayResetsIn string
|
||||
Error string
|
||||
}
|
||||
|
||||
// UpdateCallback is called when new usage data is available.
|
||||
type UpdateCallback func(ParsedUsage)
|
||||
|
||||
// doRequest performs an authenticated HTTP GET to the Claude API.
|
||||
func doRequest(url, sessionKey string) ([]byte, int, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Cookie", "sessionKey="+sessionKey)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Referer", "https://claude.ai/")
|
||||
req.Header.Set("Origin", "https://claude.ai")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// DiscoverOrgID fetches the first organization UUID from the API.
|
||||
func DiscoverOrgID(sessionKey string) (string, error) {
|
||||
body, status, err := doRequest(apiBase+"/api/organizations", sessionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", fmt.Errorf("auth_expired")
|
||||
}
|
||||
if status != 200 {
|
||||
return "", fmt.Errorf("HTTP %d", status)
|
||||
}
|
||||
|
||||
var orgs []struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &orgs); err != nil {
|
||||
return "", fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
if len(orgs) == 0 {
|
||||
return "", fmt.Errorf("no organizations found")
|
||||
}
|
||||
return orgs[0].UUID, nil
|
||||
}
|
||||
|
||||
// FetchUsage fetches usage data from the API. If orgID is empty, discovers it.
|
||||
// Returns the raw cache data and the resolved org ID.
|
||||
func FetchUsage(sessionKey, orgID string) (*CacheData, string, error) {
|
||||
if orgID == "" {
|
||||
var err error
|
||||
orgID, err = DiscoverOrgID(sessionKey)
|
||||
if err != nil {
|
||||
if err.Error() == "auth_expired" {
|
||||
return &CacheData{Error: "auth_expired", Status: 401}, "", err
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/organizations/%s/usage", apiBase, orgID)
|
||||
body, status, err := doRequest(url, sessionKey)
|
||||
if err != nil {
|
||||
return &CacheData{Error: "fetch_failed", Message: err.Error()}, orgID, err
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return &CacheData{Error: "auth_expired", Status: status}, orgID, fmt.Errorf("auth_expired")
|
||||
}
|
||||
if status != 200 {
|
||||
return &CacheData{Error: "api_error", Status: status}, orgID, fmt.Errorf("HTTP %d", status)
|
||||
}
|
||||
|
||||
var data CacheData
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, orgID, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return &data, orgID, nil
|
||||
}
|
||||
|
||||
// ParseUsage converts raw cache data into display-friendly format.
|
||||
func ParseUsage(data *CacheData) ParsedUsage {
|
||||
if data == nil {
|
||||
return ParsedUsage{Error: "no data"}
|
||||
}
|
||||
if data.Error != "" {
|
||||
msg := data.Error
|
||||
if msg == "auth_expired" {
|
||||
msg = "session expired"
|
||||
}
|
||||
return ParsedUsage{Error: msg}
|
||||
}
|
||||
|
||||
p := ParsedUsage{}
|
||||
if data.FiveHour != nil {
|
||||
p.FiveHourPct = int(math.Round(data.FiveHour.Utilization))
|
||||
p.FiveHourResetsAt = data.FiveHour.ResetsAt
|
||||
p.FiveHourResetsIn = formatResetsIn(data.FiveHour.ResetsAt)
|
||||
}
|
||||
if data.SevenDay != nil {
|
||||
p.SevenDayPct = int(math.Round(data.SevenDay.Utilization))
|
||||
p.SevenDayResetsAt = data.SevenDay.ResetsAt
|
||||
p.SevenDayResetsIn = formatResetsIn(data.SevenDay.ResetsAt)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// formatResetsIn converts an ISO 8601 timestamp to a human-readable duration.
|
||||
func formatResetsIn(isoStr string) string {
|
||||
if isoStr == "" {
|
||||
return ""
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, isoStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
total := int(math.Max(0, time.Until(t).Seconds()))
|
||||
days := total / 86400
|
||||
hours := (total % 86400) / 3600
|
||||
minutes := (total % 3600) / 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
// BackgroundFetcher runs periodic usage fetches in a goroutine.
|
||||
type BackgroundFetcher struct {
|
||||
onUpdate UpdateCallback
|
||||
mu sync.Mutex
|
||||
interval time.Duration
|
||||
orgID string
|
||||
stopCh chan struct{}
|
||||
forceCh chan struct{}
|
||||
}
|
||||
|
||||
// NewBackgroundFetcher creates a new background fetcher.
|
||||
func NewBackgroundFetcher(onUpdate UpdateCallback) *BackgroundFetcher {
|
||||
cfg := config.Load()
|
||||
return &BackgroundFetcher{
|
||||
onUpdate: onUpdate,
|
||||
interval: time.Duration(cfg.RefreshInterval) * time.Second,
|
||||
orgID: cfg.OrgID,
|
||||
stopCh: make(chan struct{}),
|
||||
forceCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the background fetch loop.
|
||||
func (bf *BackgroundFetcher) Start() {
|
||||
go bf.loop()
|
||||
}
|
||||
|
||||
// Stop signals the background fetcher to stop.
|
||||
func (bf *BackgroundFetcher) Stop() {
|
||||
close(bf.stopCh)
|
||||
}
|
||||
|
||||
// Refresh forces an immediate fetch.
|
||||
func (bf *BackgroundFetcher) Refresh() {
|
||||
select {
|
||||
case bf.forceCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// SetInterval changes the refresh interval.
|
||||
func (bf *BackgroundFetcher) SetInterval(seconds int) {
|
||||
bf.mu.Lock()
|
||||
bf.interval = time.Duration(seconds) * time.Second
|
||||
bf.mu.Unlock()
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.RefreshInterval = seconds
|
||||
_ = config.Save(cfg)
|
||||
|
||||
bf.Refresh()
|
||||
}
|
||||
|
||||
// Interval returns the current refresh interval in seconds.
|
||||
func (bf *BackgroundFetcher) Interval() int {
|
||||
bf.mu.Lock()
|
||||
defer bf.mu.Unlock()
|
||||
return int(bf.interval.Seconds())
|
||||
}
|
||||
|
||||
func (bf *BackgroundFetcher) loop() {
|
||||
// Load from cache immediately for instant display
|
||||
if data, _ := ReadCache(); data != nil {
|
||||
bf.onUpdate(ParseUsage(data))
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
bf.doFetch(false)
|
||||
|
||||
for {
|
||||
bf.mu.Lock()
|
||||
interval := bf.interval
|
||||
bf.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-bf.stopCh:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-bf.forceCh:
|
||||
timer.Stop()
|
||||
bf.doFetch(true)
|
||||
case <-timer.C:
|
||||
bf.doFetch(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (bf *BackgroundFetcher) doFetch(force bool) {
|
||||
bf.mu.Lock()
|
||||
halfInterval := bf.interval / 2
|
||||
bf.mu.Unlock()
|
||||
|
||||
if !force {
|
||||
if cached := ReadCacheIfFresh(halfInterval); cached != nil && cached.Error == "" {
|
||||
bf.onUpdate(ParseUsage(cached))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sessionKey := config.GetSessionKey()
|
||||
if sessionKey == "" {
|
||||
errData := &CacheData{Error: "no_session_key"}
|
||||
_ = WriteCache(errData)
|
||||
bf.onUpdate(ParseUsage(errData))
|
||||
return
|
||||
}
|
||||
|
||||
data, orgID, _ := FetchUsage(sessionKey, bf.orgID)
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = WriteCache(data)
|
||||
|
||||
if orgID != "" && orgID != bf.orgID {
|
||||
bf.mu.Lock()
|
||||
bf.orgID = orgID
|
||||
bf.mu.Unlock()
|
||||
cfg := config.Load()
|
||||
cfg.OrgID = orgID
|
||||
_ = config.Save(cfg)
|
||||
}
|
||||
|
||||
bf.onUpdate(ParseUsage(data))
|
||||
}
|
||||
125
internal/renderer/renderer.go
Normal file
125
internal/renderer/renderer.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
const iconSize = 256
|
||||
|
||||
// Claude orange for the starburst logo.
|
||||
var claudeOrange = color.RGBA{224, 123, 83, 255}
|
||||
|
||||
// arcColors maps usage percentage thresholds to colors.
|
||||
var arcColors = []struct {
|
||||
threshold int
|
||||
color color.RGBA
|
||||
}{
|
||||
{10, color.RGBA{76, 175, 80, 255}}, // green
|
||||
{20, color.RGBA{67, 160, 71, 255}}, // darker green
|
||||
{30, color.RGBA{124, 179, 66, 255}}, // light green
|
||||
{40, color.RGBA{192, 202, 51, 255}}, // lime
|
||||
{50, color.RGBA{253, 216, 53, 255}}, // yellow
|
||||
{60, color.RGBA{255, 193, 7, 255}}, // amber
|
||||
{70, color.RGBA{255, 179, 0, 255}}, // darker amber
|
||||
{80, color.RGBA{255, 152, 0, 255}}, // orange
|
||||
{90, color.RGBA{255, 87, 34, 255}}, // deep orange
|
||||
{100, color.RGBA{244, 67, 54, 255}}, // red
|
||||
}
|
||||
|
||||
func getArcColor(pct int) color.RGBA {
|
||||
for _, ac := range arcColors {
|
||||
if pct <= ac.threshold {
|
||||
return ac.color
|
||||
}
|
||||
}
|
||||
return arcColors[len(arcColors)-1].color
|
||||
}
|
||||
|
||||
// drawStarburst draws the 8-petal Claude logo.
|
||||
func drawStarburst(dc *gg.Context) {
|
||||
cx := float64(iconSize) / 2
|
||||
cy := float64(iconSize) / 2
|
||||
petalLen := float64(iconSize) * 0.38
|
||||
petalWidth := float64(iconSize) * 0.10
|
||||
centerRadius := float64(iconSize) * 0.04
|
||||
|
||||
dc.SetColor(claudeOrange)
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
angle := float64(i) * (2 * math.Pi / 8)
|
||||
|
||||
// Tip of the petal
|
||||
tipX := cx + petalLen*math.Cos(angle)
|
||||
tipY := cy + petalLen*math.Sin(angle)
|
||||
|
||||
// Base points (perpendicular to angle)
|
||||
perpAngle := angle + math.Pi/2
|
||||
baseX1 := cx + petalWidth*math.Cos(perpAngle)
|
||||
baseY1 := cy + petalWidth*math.Sin(perpAngle)
|
||||
baseX2 := cx - petalWidth*math.Cos(perpAngle)
|
||||
baseY2 := cy - petalWidth*math.Sin(perpAngle)
|
||||
|
||||
// Inner point (slightly behind center for petal shape)
|
||||
innerX := cx - petalWidth*0.5*math.Cos(angle)
|
||||
innerY := cy - petalWidth*0.5*math.Sin(angle)
|
||||
|
||||
dc.MoveTo(innerX, innerY)
|
||||
dc.LineTo(baseX1, baseY1)
|
||||
dc.LineTo(tipX, tipY)
|
||||
dc.LineTo(baseX2, baseY2)
|
||||
dc.ClosePath()
|
||||
dc.Fill()
|
||||
}
|
||||
|
||||
// Center dot
|
||||
dc.DrawCircle(cx, cy, centerRadius)
|
||||
dc.Fill()
|
||||
}
|
||||
|
||||
// drawArc draws a circular progress arc.
|
||||
func drawArc(dc *gg.Context, pct int) {
|
||||
if pct <= 0 {
|
||||
return
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
|
||||
cx := float64(iconSize) / 2
|
||||
cy := float64(iconSize) / 2
|
||||
radius := float64(iconSize)/2 - 14 // inset from edge
|
||||
arcWidth := 28.0
|
||||
|
||||
startAngle := -math.Pi / 2 // 12 o'clock
|
||||
endAngle := startAngle + (float64(pct)/100)*2*math.Pi
|
||||
|
||||
dc.SetColor(getArcColor(pct))
|
||||
dc.SetLineWidth(arcWidth)
|
||||
dc.SetLineCap(gg.LineCapButt)
|
||||
dc.DrawArc(cx, cy, radius, startAngle, endAngle)
|
||||
dc.Stroke()
|
||||
}
|
||||
|
||||
// RenderIcon generates a 256x256 PNG icon with starburst and usage arc.
|
||||
func RenderIcon(pct int) image.Image {
|
||||
dc := gg.NewContext(iconSize, iconSize)
|
||||
drawStarburst(dc)
|
||||
drawArc(dc, pct)
|
||||
return dc.Image()
|
||||
}
|
||||
|
||||
// RenderIconPNG generates the icon as PNG bytes (for systray).
|
||||
func RenderIconPNG(pct int) ([]byte, error) {
|
||||
img := RenderIcon(pct)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
215
internal/tray/tray.go
Normal file
215
internal/tray/tray.go
Normal file
@@ -0,0 +1,215 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user