Remove standalone fetcher, add setup tool with install/uninstall workflow
All checks were successful
Release / build (push) Successful in 1m45s
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:
102
internal/browser/login.go
Normal file
102
internal/browser/login.go
Normal 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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user