Remove standalone fetcher, add setup tool with install/uninstall workflow
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>
This commit is contained in:
Axel Meyer
2026-02-26 19:11:08 +01:00
parent 5b0366f16b
commit 47165ce02c
15 changed files with 796 additions and 126 deletions

102
internal/browser/login.go Normal file
View File

@@ -0,0 +1,102 @@
package browser
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
"git.davoryn.de/calic/claude-statusline/internal/config"
)
// LoginAndGetSessionKey opens a browser window for the user to log in to
// claude.ai and extracts the httpOnly sessionKey cookie via DevTools protocol.
// The browser uses a persistent profile so the user only needs to log in once.
// Returns the session key or an error (e.g. timeout after 2 minutes).
func LoginAndGetSessionKey() (string, error) {
execPath := findBrowserExec()
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
if err := os.MkdirAll(profileDir, 0o755); err != nil {
return "", fmt.Errorf("create browser profile dir: %w", err)
}
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
chromedp.UserDataDir(profileDir),
)
if execPath != "" {
opts = append(opts, chromedp.ExecPath(execPath))
}
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer allocCancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
// Navigate to login page
if err := chromedp.Run(ctx, chromedp.Navigate("https://claude.ai/login")); err != nil {
return "", fmt.Errorf("navigate to login: %w", err)
}
// Poll for the sessionKey cookie (httpOnly, so only accessible via DevTools)
deadline := time.Now().Add(2 * time.Minute)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Now().After(deadline) {
return "", fmt.Errorf("login timed out after 2 minutes")
}
var cookies []*network.Cookie
if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
var err error
cookies, err = network.GetCookies().Do(ctx)
return err
})); err != nil {
// Browser may have been closed by user
return "", fmt.Errorf("get cookies: %w", err)
}
for _, c := range cookies {
if c.Name == "sessionKey" && (c.Domain == ".claude.ai" || c.Domain == "claude.ai") {
key := c.Value
if err := config.SetSessionKey(key); err != nil {
return "", fmt.Errorf("save session key: %w", err)
}
// Use chromedp.Cancel to close gracefully (flushes cookies to profile)
cancel()
return key, nil
}
}
}
}
}
// findBrowserExec returns the path to a Chromium-based browser, or "" to let
// chromedp use its default detection (Chrome/Chromium on PATH).
func findBrowserExec() string {
if runtime.GOOS == "windows" {
// Prefer Edge (pre-installed on Windows 10+)
candidates := []string{
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "Edge", "Application", "msedge.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "Edge", "Application", "msedge.exe"),
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
// On Linux/macOS, chromedp auto-detects Chrome/Chromium
return ""
}

View File

@@ -2,15 +2,17 @@ package renderer
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"math"
"runtime"
"github.com/fogleman/gg"
)
const iconSize = 256
const iconSize = 64
// Claude orange for the starburst logo.
var claudeOrange = color.RGBA{224, 123, 83, 255}
@@ -93,8 +95,8 @@ func drawArc(dc *gg.Context, pct int) {
cx := float64(iconSize) / 2
cy := float64(iconSize) / 2
radius := float64(iconSize)/2 - 14 // inset from edge
arcWidth := 28.0
radius := float64(iconSize)/2 - 4 // inset from edge
arcWidth := 7.0
startAngle := -math.Pi / 2 // 12 o'clock
endAngle := startAngle + (float64(pct)/100)*2*math.Pi
@@ -114,7 +116,7 @@ func RenderIcon(pct int) image.Image {
return dc.Image()
}
// RenderIconPNG generates the icon as PNG bytes (for systray).
// RenderIconPNG generates the icon as PNG bytes.
func RenderIconPNG(pct int) ([]byte, error) {
img := RenderIcon(pct)
var buf bytes.Buffer
@@ -123,3 +125,54 @@ func RenderIconPNG(pct int) ([]byte, error) {
}
return buf.Bytes(), nil
}
// RenderIconForTray returns icon bytes suitable for systray.SetIcon:
// ICO (PNG-compressed) on Windows, raw PNG on other platforms.
func RenderIconForTray(pct int) ([]byte, error) {
pngData, err := RenderIconPNG(pct)
if err != nil {
return nil, err
}
if runtime.GOOS != "windows" {
return pngData, nil
}
return wrapPNGInICO(pngData, iconSize, iconSize), nil
}
// wrapPNGInICO wraps raw PNG bytes in a minimal ICO container.
// Windows Vista+ supports PNG-compressed ICO entries.
func wrapPNGInICO(pngData []byte, width, height int) []byte {
const headerSize = 6
const entrySize = 16
imageOffset := headerSize + entrySize
buf := new(bytes.Buffer)
// ICONDIR header
binary.Write(buf, binary.LittleEndian, uint16(0)) // Reserved
binary.Write(buf, binary.LittleEndian, uint16(1)) // Type: 1 = icon
binary.Write(buf, binary.LittleEndian, uint16(1)) // Count: 1 image
// ICONDIRENTRY
w := byte(width)
if width >= 256 {
w = 0 // 0 means 256
}
h := byte(height)
if height >= 256 {
h = 0
}
buf.WriteByte(w) // Width
buf.WriteByte(h) // Height
buf.WriteByte(0) // ColorCount (0 = no palette)
buf.WriteByte(0) // Reserved
binary.Write(buf, binary.LittleEndian, uint16(1)) // Planes
binary.Write(buf, binary.LittleEndian, uint16(32)) // BitCount
binary.Write(buf, binary.LittleEndian, uint32(len(pngData))) // BytesInRes
binary.Write(buf, binary.LittleEndian, uint32(imageOffset)) // ImageOffset
// PNG image data
buf.Write(pngData)
return buf.Bytes()
}

View File

@@ -2,11 +2,13 @@ package tray
import (
"fmt"
"os/exec"
"runtime"
"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"
@@ -49,8 +51,11 @@ func (a *App) onReady() {
systray.SetTooltip("Claude Usage: loading...")
// Set initial icon (0%)
if iconData, err := renderer.RenderIconPNG(0); err == nil {
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)
@@ -82,8 +87,9 @@ func (a *App) onReady() {
a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item)
}
// Session key
mSessionKey := systray.AddMenuItem("Session Key...", "Open session key config file")
// 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")
@@ -98,8 +104,10 @@ func (a *App) onReady() {
select {
case <-mRefresh.ClickedCh:
a.bf.Refresh()
case <-mSessionKey.ClickedCh:
a.openSessionKeyFile()
case <-mLogin.ClickedCh:
go a.doLogin()
case <-mLogout.ClickedCh:
a.doLogout()
case <-mQuit.ClickedCh:
systray.Quit()
return
@@ -133,8 +141,12 @@ func (a *App) onUsageUpdate(data fetcher.ParsedUsage) {
if data.Error == "" {
pct = data.FiveHourPct
}
if iconData, err := renderer.RenderIconPNG(pct); err == nil {
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
@@ -200,16 +212,19 @@ func (a *App) setInterval(idx int) {
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)
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
}
_ = cmd.Start()
a.bf.Refresh()
}
func (a *App) doLogout() {
_ = os.Remove(config.SessionKeyPath())
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
_ = os.RemoveAll(profileDir)
a.bf.Refresh()
}