5 Commits

Author SHA1 Message Date
calic
a11bdb7267 Stealth mode: defeat Cloudflare automation detection
Some checks failed
Release / build (push) Failing after 1m5s
Drop DefaultExecAllocatorOptions (includes --enable-automation),
add disable-blink-features=AutomationControlled, patch
navigator.webdriver via JS, and inject sessionKey cookie via CDP
into a dedicated fetch profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:42:03 +01:00
calic
5abdee06ff Add diagnostic logging to Chrome fallback
All checks were successful
Release / build (push) Successful in 1m32s
Log navigation, polling state, and response snippets so we can
diagnose whether the fallback fails due to Cloudflare challenge,
login redirect, profile lock, or other issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:34:41 +01:00
calic
2cb89d3c54 Fix Cloudflare headless detection: use non-headless with hidden window
All checks were successful
Release / build (push) Successful in 1m37s
Cloudflare detects headless Chrome and loops the JS challenge forever.
Switch to non-headless mode with an off-screen window. Also save
Cloudflare cookies (cf_clearance, __cf_bm) after Chrome fallback so
subsequent plain HTTP requests can reuse them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:26:34 +01:00
calic
ba3b73c3dd Add Cloudflare 403 fallback via headless Chrome
All checks were successful
Release / build (push) Successful in 1m40s
Plain HTTP requests to claude.ai get blocked by Cloudflare JS challenges
(403). The fetcher now falls back to headless Chrome using the persistent
browser profile, which can solve challenges natively and reuses existing
cf_clearance cookies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:13:21 +01:00
Axel Meyer
47165ce02c 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>
2026-02-26 19:11:08 +01:00
18 changed files with 1043 additions and 142 deletions

View File

@@ -28,14 +28,14 @@ jobs:
- name: Build Linux binaries
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-statusline ./cmd/statusline
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-fetcher ./cmd/fetcher
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-widget ./cmd/widget
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o setup ./cmd/setup
- name: Build Windows binaries
run: |
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-statusline.exe ./cmd/statusline
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-fetcher.exe ./cmd/fetcher
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui" -o claude-widget.exe ./cmd/widget
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o setup.exe ./cmd/setup
- name: Build .deb package
run: |
@@ -45,17 +45,16 @@ jobs:
- name: Create Linux tarball
run: |
mkdir -p dist/claude-statusline-${{ env.VERSION }}
cp claude-statusline claude-fetcher claude-widget dist/claude-statusline-${{ env.VERSION }}/
cp claude-statusline claude-widget setup dist/claude-statusline-${{ env.VERSION }}/
cp README.md CHANGELOG.md dist/claude-statusline-${{ env.VERSION }}/
cp packaging/linux/claude-widget.desktop dist/claude-statusline-${{ env.VERSION }}/
cp packaging/linux/claude-statusline-fetch dist/claude-statusline-${{ env.VERSION }}/
tar -czf claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz -C dist claude-statusline-${{ env.VERSION }}
- name: Create Windows zip
run: |
apt-get update -qq && apt-get install -y -qq zip >/dev/null 2>&1
mkdir -p dist-win/claude-statusline-${{ env.VERSION }}
cp claude-statusline.exe claude-fetcher.exe claude-widget.exe dist-win/claude-statusline-${{ env.VERSION }}/
cp claude-statusline.exe claude-widget.exe setup.exe dist-win/claude-statusline-${{ env.VERSION }}/
cp README.md CHANGELOG.md dist-win/claude-statusline-${{ env.VERSION }}/
cd dist-win && zip -r ../claude-statusline_${{ env.VERSION }}_windows_amd64.zip claude-statusline-${{ env.VERSION }}

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# Go binaries
claude-statusline
claude-fetcher
claude-widget
/setup
*.exe
# Build output

View File

@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.5.0] — 2026-03-21
### Fixed
- **Cloudflare 403 bypass** — API requests blocked by Cloudflare JS challenges now fall back to headless Chrome with the persistent browser profile, which can solve the challenges natively
### Added
- `internal/browser/fetch.go` — headless Chrome API fetcher using chromedp with the existing browser profile (reuses Cloudflare clearance cookies)
- `fetchWithFallback()` in fetcher — tries plain HTTP first, falls back to headless Chrome on 403
## [0.3.0] — 2026-02-26
Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies.
@@ -44,5 +53,6 @@ First tagged release. Includes the CLI statusline, standalone usage fetcher, cro
- Tray icon visibility — switched to Claude orange with full opacity at larger size
- Block comment syntax error in cron example
[0.5.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.5.0
[0.3.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.3.0
[0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0

View File

@@ -17,7 +17,7 @@
## Overview
Three static binaries built from one Go codebase. No runtime dependencies — no Node.js, Python, or system packages needed.
Two static binaries built from one Go codebase. No runtime dependencies — no Node.js, Python, or system packages needed. A `setup` tool handles installation and uninstallation on both platforms.
### CLI Statusline
@@ -27,47 +27,49 @@ Headless status bar for Claude Code. Shows context window utilization and token
Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M
```
### Usage Fetcher
Standalone binary for cron. Fetches token usage from the Claude API and writes a shared JSON cache.
### Desktop Widget
System tray icon showing 5-hour usage as a circular progress bar on a Claude starburst logo. Color shifts from green through amber to red as usage increases. Right-click menu shows detailed stats and configuration.
System tray icon showing 5-hour usage as a circular progress bar on a Claude starburst logo. Color shifts from green through amber to red as usage increases. Has a built-in background fetcher that writes a shared JSON cache. Right-click menu shows detailed stats and configuration.
## Topology
```
claude.ai API
──► claude-fetcher (cron) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
└──► claude-widget (built-in fetcher) ──┘──► System tray icon
──► claude-widget (background fetcher) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
──► System tray icon
```
Only one fetcher needs to run. The widget has a built-in fetcher; the standalone `claude-fetcher` is for headless/cron setups. Both write the same cache format.
## Installation
### Windows / Linux (setup tool)
Extract the archive and run the setup tool. It copies binaries to the install directory, enables autostart for the widget, and configures Claude Code's statusline setting.
```bash
# Windows — double-click setup.exe, or from a terminal:
setup.exe
# Linux
./setup
```
### Debian/Ubuntu (.deb)
```bash
sudo dpkg -i claude-statusline_0.3.0_amd64.deb
```
Installs all three binaries to `/usr/bin/`, sets up autostart for the widget, and adds a cron job for the fetcher.
Installs binaries to `/usr/bin/` and sets up autostart for the widget.
### Linux (tar.gz)
### Linux (manual)
```bash
tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz
sudo cp claude-statusline-0.3.0/claude-{statusline,fetcher,widget} /usr/local/bin/
cp claude-statusline-0.3.0/claude-{statusline,widget} ~/.local/bin/
```
### Windows
Extract the `.zip` and place the `.exe` files anywhere on your PATH.
### Session Key Setup
After installing, paste your claude.ai session key:
@@ -98,6 +100,26 @@ Add to your Claude Code settings (`~/.claude/settings.json`):
}
```
## Uninstall
### Windows / Linux (setup tool)
```bash
# Windows
setup.exe --uninstall
# Linux
./setup --uninstall
```
Stops the widget, removes binaries and autostart entry, and cleans the `statusLine` setting from Claude Code. Optionally removes the config directory (interactive prompt, default: keep).
### Debian/Ubuntu
```bash
sudo dpkg -r claude-statusline
```
## Configuration
### Environment Variables
@@ -136,12 +158,12 @@ Right-click the tray icon to access:
```bash
# All binaries
go build ./cmd/statusline && go build ./cmd/fetcher && go build ./cmd/widget
go build ./cmd/statusline && go build ./cmd/widget && go build ./cmd/setup
# Cross-compile for Windows
GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o claude-widget.exe ./cmd/widget
GOOS=windows GOARCH=amd64 go build -o claude-statusline.exe ./cmd/statusline
GOOS=windows GOARCH=amd64 go build -o claude-fetcher.exe ./cmd/fetcher
GOOS=windows GOARCH=amd64 go build -o setup.exe ./cmd/setup
# Build .deb
VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb
@@ -152,17 +174,17 @@ VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb
```
cmd/
statusline/main.go # CLI statusline (reads stdin + cache)
fetcher/main.go # Standalone cron fetcher (writes cache)
widget/main.go # Desktop tray widget entry point
widget/main.go # Desktop tray widget with built-in fetcher
setup/main.go # Cross-platform install/uninstall tool
internal/
config/config.go # Shared config (session key, org ID, intervals)
fetcher/fetcher.go # HTTP fetch logic (shared between widget + standalone)
fetcher/fetcher.go # HTTP fetch logic (used by widget)
fetcher/cache.go # JSON cache read/write (/tmp/claude_usage.json)
renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg)
tray/tray.go # System tray setup + menu (fyne-io/systray)
packaging/
nfpm.yaml # .deb packaging config
linux/ # .desktop file, cron, install scripts
linux/ # .desktop file, install scripts
```
## License

View File

@@ -1,55 +0,0 @@
package main
import (
"fmt"
"os"
"git.davoryn.de/calic/claude-statusline/internal/config"
"git.davoryn.de/calic/claude-statusline/internal/fetcher"
)
func main() {
sessionKey := config.GetSessionKey()
if sessionKey == "" {
fmt.Fprintln(os.Stderr, "error: no session key (set CLAUDE_SESSION_KEY or write to "+config.SessionKeyPath()+")")
os.Exit(1)
}
cfg := config.Load()
data, orgID, err := fetcher.FetchUsage(sessionKey, cfg.OrgID)
if err != nil {
if data != nil {
// Write error state to cache so statusline can display it
_ = fetcher.WriteCache(data)
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if err := fetcher.WriteCache(data); err != nil {
fmt.Fprintf(os.Stderr, "error writing cache: %v\n", err)
os.Exit(1)
}
// Persist discovered org ID
if orgID != "" && orgID != cfg.OrgID {
cfg.OrgID = orgID
_ = config.Save(cfg)
}
parsed := fetcher.ParseUsage(data)
if parsed.FiveHourPct > 0 {
fmt.Printf("5h: %d%%", parsed.FiveHourPct)
if parsed.FiveHourResetsIn != "" {
fmt.Printf(" (resets in %s)", parsed.FiveHourResetsIn)
}
fmt.Println()
}
if parsed.SevenDayPct > 0 {
fmt.Printf("7d: %d%%", parsed.SevenDayPct)
if parsed.SevenDayResetsIn != "" {
fmt.Printf(" (resets in %s)", parsed.SevenDayResetsIn)
}
fmt.Println()
}
}

479
cmd/setup/main.go Normal file
View File

@@ -0,0 +1,479 @@
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"git.davoryn.de/calic/claude-statusline/internal/config"
)
func main() {
uninstall := flag.Bool("uninstall", false, "Remove installed files and autostart entry")
flag.Parse()
if *uninstall {
runUninstall()
} else {
runInstall()
}
}
// installDir returns the platform-specific install directory.
func installDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "claude-statusline")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "bin")
}
// binaryNames returns the binaries to install (excluding setup itself).
func binaryNames() []string {
if runtime.GOOS == "windows" {
return []string{"claude-widget.exe", "claude-statusline.exe"}
}
return []string{"claude-widget", "claude-statusline"}
}
// sourceDir returns the directory where the setup binary resides.
func sourceDir() string {
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: cannot determine executable path: %v\n", err)
os.Exit(1)
}
return filepath.Dir(exe)
}
// findSourceBinary looks for a binary in dir by its canonical install name
// (e.g. "claude-widget.exe"), falling back to the bare name without the
// "claude-" prefix (e.g. "widget.exe") for local go build output.
func findSourceBinary(dir, name string) string {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
bare := strings.TrimPrefix(name, "claude-")
p = filepath.Join(dir, bare)
if _, err := os.Stat(p); err == nil {
return p
}
return ""
}
// askYesNo prompts the user with a Y/n question. Default is yes.
func askYesNo(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [Y/n] ", prompt)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "" || answer == "y" || answer == "yes"
}
// askNo prompts the user with a y/N question. Default is no.
func askNo(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N] ", prompt)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "y" || answer == "yes"
}
func runInstall() {
fmt.Println("claude-statusline setup")
fmt.Println(strings.Repeat("=", 40))
fmt.Println()
src := sourceDir()
dst := installDir()
var errors []string
fmt.Printf("Install directory: %s\n\n", dst)
// Step 1 — Create install directory
if err := os.MkdirAll(dst, 0o755); err != nil {
msg := "Could not create install directory: " + err.Error()
fmt.Fprintln(os.Stderr, msg)
showMessage("Claude Statusline — Setup", "Something went wrong.\n\n"+msg, true)
os.Exit(1)
}
// Step 2 — Copy binaries
fmt.Println("Installing binaries...")
binaries := binaryNames()
installed := 0
for _, name := range binaries {
srcPath := findSourceBinary(src, name)
dstPath := filepath.Join(dst, name)
if srcPath == "" {
fmt.Printf(" SKIP %s (not found in source directory)\n", name)
continue
}
err := copyFile(srcPath, dstPath)
if err != nil {
if runtime.GOOS == "windows" && isFileInUse(err) {
if isInteractive() {
fmt.Printf(" BUSY %s — please close the widget and press Enter to retry: ", name)
bufio.NewReader(os.Stdin).ReadString('\n')
err = copyFile(srcPath, dstPath)
}
}
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
fmt.Fprintf(os.Stderr, " FAIL %s: %v\n", name, err)
continue
}
}
fmt.Printf(" OK %s\n", name)
installed++
}
fmt.Println()
if installed == 0 {
msg := "No binaries found next to setup. Make sure setup is in the same directory as the binaries."
fmt.Fprintln(os.Stderr, msg)
showMessage("Claude Statusline — Setup", "Something went wrong.\n\n"+msg, true)
os.Exit(1)
}
// Step 3 — Autostart (auto-yes on Windows, interactive on Linux)
enableAutostart := true
if isInteractive() {
enableAutostart = askYesNo("Enable autostart for claude-widget?")
}
if enableAutostart {
if err := createAutostart(dst); err != nil {
errors = append(errors, "autostart: "+err.Error())
fmt.Fprintf(os.Stderr, " Warning: could not set up autostart: %v\n", err)
} else {
fmt.Println(" Autostart enabled.")
}
}
fmt.Println()
// Step 4 — Config directory
cfgDir := config.ConfigDir()
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create config directory: %v\n", err)
} else {
skPath := config.SessionKeyPath()
if _, err := os.Stat(skPath); os.IsNotExist(err) {
if err := os.WriteFile(skPath, []byte(""), 0o600); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create session-key file: %v\n", err)
}
}
}
// Step 5 — Configure Claude Code statusline
statuslineName := "claude-statusline"
if runtime.GOOS == "windows" {
statuslineName = "claude-statusline.exe"
}
statuslinePath := filepath.Join(dst, statuslineName)
if err := configureClaudeCode(statuslinePath); err != nil {
errors = append(errors, "Claude Code settings: "+err.Error())
fmt.Fprintf(os.Stderr, " Warning: could not configure Claude Code statusline: %v\n", err)
} else {
fmt.Println(" Claude Code statusline configured.")
}
fmt.Println()
// Step 6 — Launch widget on Windows
widgetName := "claude-widget"
if runtime.GOOS == "windows" {
widgetName = "claude-widget.exe"
}
widgetPath := filepath.Join(dst, widgetName)
if runtime.GOOS == "windows" {
exec.Command(widgetPath).Start()
}
// Step 7 — Summary
fmt.Println(strings.Repeat("-", 40))
fmt.Println("Installation complete!")
fmt.Println()
fmt.Printf(" Binaries: %s\n", dst)
fmt.Printf(" Config: %s\n", cfgDir)
fmt.Println()
fmt.Println("To log in, launch the widget and use the")
fmt.Println("\"Login in Browser\" menu item from the tray icon.")
fmt.Println()
fmt.Printf("Launch the widget: %s\n", widgetPath)
// Show result dialog on Windows
if len(errors) > 0 {
showMessage("Claude Statusline — Setup",
"Setup completed with warnings:\n\n"+strings.Join(errors, "\n")+
"\n\nSuccessfully installed "+fmt.Sprint(installed)+" binaries to:\n"+dst, true)
} else {
showMessage("Claude Statusline — Setup",
"Setup complete!\n\nWidget is running and set to autostart.\n\nTo log in, use the \"Login in Browser\"\nmenu item from the tray icon.", false)
}
}
func runUninstall() {
fmt.Println("claude-statusline uninstall")
fmt.Println(strings.Repeat("=", 40))
fmt.Println()
dst := installDir()
var errors []string
// Step 1 — Kill running widget
fmt.Println("Stopping widget...")
if runtime.GOOS == "windows" {
exec.Command("taskkill", "/F", "/IM", "claude-widget.exe").Run()
} else {
exec.Command("pkill", "-f", "claude-widget").Run()
}
fmt.Println()
// Step 2 — Remove binaries
fmt.Println("Removing binaries...")
for _, name := range binaryNames() {
p := filepath.Join(dst, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
continue
}
if err := os.Remove(p); err != nil {
if runtime.GOOS == "windows" && isFileInUse(err) {
if isInteractive() {
fmt.Printf(" BUSY %s — please close the widget and press Enter to retry: ", name)
bufio.NewReader(os.Stdin).ReadString('\n')
err = os.Remove(p)
}
}
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
fmt.Fprintf(os.Stderr, " FAIL %s: %v\n", name, err)
continue
}
}
fmt.Printf(" Removed %s\n", name)
}
fmt.Println()
// Step 3 — Remove autostart
fmt.Println("Removing autostart entry...")
if err := removeAutostart(); err != nil {
errors = append(errors, "autostart: "+err.Error())
fmt.Fprintf(os.Stderr, " Warning: %v\n", err)
} else {
fmt.Println(" Autostart removed.")
}
fmt.Println()
// Step 4 — Remove Claude Code statusline setting
fmt.Println("Removing Claude Code statusline setting...")
if err := removeClaudeCodeSetting(); err != nil {
errors = append(errors, "Claude Code settings: "+err.Error())
fmt.Fprintf(os.Stderr, " Warning: %v\n", err)
} else {
fmt.Println(" Claude Code setting removed.")
}
fmt.Println()
// Step 5 — Optionally remove config (only ask in interactive mode)
cfgDir := config.ConfigDir()
if _, err := os.Stat(cfgDir); err == nil {
deleteConfig := false
if isInteractive() {
deleteConfig = askNo("Also delete config directory (" + cfgDir + ")?")
}
if deleteConfig {
if err := os.RemoveAll(cfgDir); err != nil {
fmt.Fprintf(os.Stderr, " Error removing config: %v\n", err)
} else {
fmt.Println(" Config directory removed.")
}
} else {
fmt.Println(" Config directory kept.")
}
}
fmt.Println()
fmt.Println("Uninstall complete.")
if len(errors) > 0 {
showMessage("Claude Statusline — Uninstall",
"Uninstall completed with warnings:\n\n"+strings.Join(errors, "\n"), true)
} else {
showMessage("Claude Statusline — Uninstall",
"Uninstall complete.\n\nAll binaries and autostart entry have been removed.", false)
}
}
// isInteractive returns true if stdin is likely a terminal (not double-clicked on Windows).
// On Linux this always returns true. On Windows it checks if stdin is a valid console.
func isInteractive() bool {
if runtime.GOOS != "windows" {
return true
}
// When double-clicked from Explorer, stdin is not a pipe but reads will
// return immediately with EOF or block forever. Check if we have a real
// console by testing if stdin is a character device (terminal).
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
// copyFile reads src and writes to dst, preserving executable permission.
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o755)
}
// isFileInUse checks if an error indicates the file is locked (Windows).
func isFileInUse(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "Access is denied") ||
strings.Contains(msg, "being used by another process")
}
// createAutostart sets up autostart for the widget.
func createAutostart(installDir string) error {
if runtime.GOOS == "windows" {
return createAutostartWindows(installDir)
}
return createAutostartLinux(installDir)
}
func createAutostartWindows(dir string) error {
startupDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
lnkPath := filepath.Join(startupDir, "Claude Widget.lnk")
target := filepath.Join(dir, "claude-widget.exe")
script := fmt.Sprintf(
`$s = (New-Object -COM WScript.Shell).CreateShortcut('%s'); $s.TargetPath = '%s'; $s.WorkingDirectory = '%s'; $s.Save()`,
lnkPath, target, dir,
)
cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
cmd.Stderr = os.Stderr
return cmd.Run()
}
func createAutostartLinux(dir string) error {
autostartDir := filepath.Join(os.Getenv("HOME"), ".config", "autostart")
if err := os.MkdirAll(autostartDir, 0o755); err != nil {
return err
}
desktopEntry := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=Claude Widget
Exec=%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, filepath.Join(dir, "claude-widget"))
return os.WriteFile(filepath.Join(autostartDir, "claude-widget.desktop"), []byte(desktopEntry), 0o644)
}
// removeAutostart removes the autostart entry.
func removeAutostart() error {
if runtime.GOOS == "windows" {
return removeAutostartWindows()
}
return removeAutostartLinux()
}
func removeAutostartWindows() error {
lnkPath := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "Claude Widget.lnk")
if _, err := os.Stat(lnkPath); os.IsNotExist(err) {
return nil
}
return os.Remove(lnkPath)
}
func removeAutostartLinux() error {
desktopPath := filepath.Join(os.Getenv("HOME"), ".config", "autostart", "claude-widget.desktop")
if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
return nil
}
return os.Remove(desktopPath)
}
// claudeSettingsPath returns the path to Claude Code's settings.json.
func claudeSettingsPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".claude", "settings.json")
}
// configureClaudeCode updates ~/.claude/settings.json to use the installed
// statusline binary. Preserves all existing settings.
func configureClaudeCode(statuslinePath string) error {
settingsPath := claudeSettingsPath()
// Read existing settings (or start fresh)
settings := make(map[string]any)
if data, err := os.ReadFile(settingsPath); err == nil {
if err := json.Unmarshal(data, &settings); err != nil {
return fmt.Errorf("parse %s: %w", settingsPath, err)
}
}
// Set statusLine command
settings["statusLine"] = map[string]any{
"type": "command",
"command": statuslinePath,
}
// Write back
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(settingsPath, append(data, '\n'), 0o644)
}
// removeClaudeCodeSetting removes the statusLine key from ~/.claude/settings.json.
func removeClaudeCodeSetting() error {
settingsPath := claudeSettingsPath()
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return nil // nothing to do
}
return err
}
settings := make(map[string]any)
if err := json.Unmarshal(data, &settings); err != nil {
return fmt.Errorf("parse %s: %w", settingsPath, err)
}
if _, ok := settings["statusLine"]; !ok {
return nil // key not present
}
delete(settings, "statusLine")
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
}

5
cmd/setup/ui_other.go Normal file
View File

@@ -0,0 +1,5 @@
//go:build !windows
package main
func showMessage(_, _ string, _ bool) {}

23
cmd/setup/ui_windows.go Normal file
View File

@@ -0,0 +1,23 @@
//go:build windows
package main
import (
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procMsgBox = user32.NewProc("MessageBoxW")
)
func showMessage(title, text string, isError bool) {
var flags uintptr = 0x00000040 // MB_OK | MB_ICONINFORMATION
if isError {
flags = 0x00000010 // MB_OK | MB_ICONERROR
}
tPtr, _ := syscall.UTF16PtrFromString(title)
mPtr, _ := syscall.UTF16PtrFromString(text)
procMsgBox.Call(0, uintptr(unsafe.Pointer(mPtr)), uintptr(unsafe.Pointer(tPtr)), flags)
}

View File

@@ -1,9 +1,23 @@
package main
import (
"log"
"os"
"path/filepath"
"git.davoryn.de/calic/claude-statusline/internal/tray"
)
func main() {
tray.Run()
// Log to file next to the executable
exe, _ := os.Executable()
logPath := filepath.Join(filepath.Dir(exe), "widget.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err == nil {
log.SetOutput(f)
defer f.Close()
}
log.Println("widget starting")
tray.Run()
log.Println("widget exited")
}

11
go.mod
View File

@@ -1,15 +1,22 @@
module git.davoryn.de/calic/claude-statusline
go 1.21
go 1.24
require (
fyne.io/systray v1.11.0
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/fogleman/gg v1.3.0
)
require (
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/sys v0.34.0 // indirect
)

23
go.sum
View File

@@ -1,12 +1,31 @@
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

172
internal/browser/fetch.go Normal file
View File

@@ -0,0 +1,172 @@
package browser
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
"git.davoryn.de/calic/claude-statusline/internal/config"
)
// FetchViaChrome navigates to a URL using Chrome with a dedicated browser
// profile and returns the response body. Uses stealth flags to avoid
// Cloudflare's automation detection (navigator.webdriver, etc.).
func FetchViaChrome(url string) ([]byte, error) {
// Use a dedicated fetch profile separate from the login profile.
profileDir := filepath.Join(config.ConfigDir(), "fetch-profile")
if err := os.MkdirAll(profileDir, 0o755); err != nil {
return nil, fmt.Errorf("create fetch profile dir: %w", err)
}
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
execPath := findBrowserExec()
// Start with minimal options — NOT DefaultExecAllocatorOptions, which
// includes flags that Cloudflare detects (--enable-automation, etc.).
opts := []chromedp.ExecAllocatorOption{
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.UserDataDir(profileDir),
// Stealth: disable automation indicators
chromedp.Flag("disable-blink-features", "AutomationControlled"),
chromedp.Flag("enable-automation", false),
chromedp.Flag("disable-infobars", true),
// Window: off-screen so it doesn't flash
chromedp.Flag("window-position", "-32000,-32000"),
chromedp.Flag("window-size", "1,1"),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-extensions", true),
chromedp.Flag("no-first-run", true),
// Use non-headless — Cloudflare detects headless mode
chromedp.Flag("headless", false),
}
if execPath != "" {
opts = append(opts, chromedp.ExecPath(execPath))
}
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer allocCancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
defer timeoutCancel()
// Inject the session key cookie before navigating
sessionKey := config.GetSessionKey()
if sessionKey != "" {
err := chromedp.Run(ctx,
chromedp.Navigate("about:blank"),
setCookieAction("sessionKey", sessionKey, ".claude.ai"),
)
if err != nil {
log.Printf("chrome-fetch: cookie injection failed: %v", err)
}
}
// Patch navigator.webdriver to false via CDP
err := chromedp.Run(ctx,
chromedp.ActionFunc(func(ctx context.Context) error {
_, _, _, err := chromedp.Targets(ctx)
_ = err
return nil
}),
chromedp.Evaluate(`Object.defineProperty(navigator, 'webdriver', {get: () => undefined})`, nil),
)
if err != nil {
log.Printf("chrome-fetch: webdriver patch failed: %v", err)
}
log.Printf("chrome-fetch: navigating to %s (exec: %q)", url, execPath)
if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
log.Printf("chrome-fetch: navigate failed: %v", err)
return nil, fmt.Errorf("chromedp navigate: %w", err)
}
log.Printf("chrome-fetch: navigation complete, polling for JSON...")
// Poll for JSON response — Cloudflare challenge takes a few seconds
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
case <-ticker.C:
var body string
err := chromedp.Run(ctx,
chromedp.Text("pre", &body, chromedp.ByQuery),
)
if err != nil || body == "" {
_ = chromedp.Run(ctx,
chromedp.Text("body", &body, chromedp.ByQuery),
)
}
body = strings.TrimSpace(body)
if body == "" {
log.Printf("chrome-fetch: page body empty, waiting...")
continue
}
if body[0] == '[' || body[0] == '{' {
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
_ = extractAndSaveCookies(ctx)
cancel()
return []byte(body), nil
}
snippet := body
if len(snippet) > 200 {
snippet = snippet[:200]
}
log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet)
}
}
}
// setCookieAction sets a cookie via the DevTools protocol.
func setCookieAction(name, value, domain string) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
expr := network.SetCookie(name, value).
WithDomain(domain).
WithPath("/").
WithHTTPOnly(true).
WithSecure(true)
return expr.Do(ctx)
})
}
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
// so plain HTTP requests can try them on subsequent polls.
func extractAndSaveCookies(ctx context.Context) error {
cookies, err := network.GetCookies().Do(ctx)
if err != nil {
return err
}
var parts []string
for _, c := range cookies {
if c.Domain == ".claude.ai" || c.Domain == "claude.ai" {
if c.Name == "cf_clearance" || c.Name == "__cf_bm" || c.Name == "_cfuvid" {
parts = append(parts, c.Name+"="+c.Value)
}
}
}
if len(parts) == 0 {
return nil
}
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
}

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

@@ -4,11 +4,16 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.davoryn.de/calic/claude-statusline/internal/browser"
"git.davoryn.de/calic/claude-statusline/internal/config"
)
@@ -32,13 +37,21 @@ type ParsedUsage struct {
type UpdateCallback func(ParsedUsage)
// doRequest performs an authenticated HTTP GET to the Claude API.
// Includes any saved Cloudflare cookies from previous Chrome fallbacks.
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)
cookie := "sessionKey=" + sessionKey
// Append Cloudflare cookies if available (saved by Chrome fallback)
if cfCookies := loadCFCookies(); cfCookies != "" {
cookie += "; " + cfCookies
}
req.Header.Set("Cookie", cookie)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", "https://claude.ai/")
@@ -56,17 +69,56 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
return body, resp.StatusCode, nil
}
// loadCFCookies reads saved Cloudflare cookies from the cf-cookies file.
func loadCFCookies() string {
data, err := os.ReadFile(filepath.Join(config.ConfigDir(), "cf-cookies"))
if err != nil {
return ""
}
// File has one cookie per line (name=value), join with "; "
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
var valid []string
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" {
valid = append(valid, l)
}
}
return strings.Join(valid, "; ")
}
// fetchWithFallback tries a plain HTTP request first, then falls back to
// headless Chrome (which can solve Cloudflare JS challenges) on 403.
func fetchWithFallback(url, sessionKey string) ([]byte, error) {
body, status, err := doRequest(url, sessionKey)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if status == 200 {
return body, nil
}
if status == 401 {
return nil, fmt.Errorf("auth_expired")
}
if status == 403 {
// Likely a Cloudflare JS challenge — fall back to headless Chrome
log.Printf("HTTP 403 for %s, falling back to Chrome", url)
chromeBody, chromeErr := browser.FetchViaChrome(url)
if chromeErr != nil {
log.Printf("Chrome fallback failed: %v", chromeErr)
return nil, fmt.Errorf("auth_expired")
}
log.Printf("Chrome fallback succeeded (%d bytes)", len(chromeBody))
return chromeBody, nil
}
return nil, fmt.Errorf("HTTP %d", status)
}
// DiscoverOrgID fetches the first organization UUID from the API.
func DiscoverOrgID(sessionKey string) (string, error) {
body, status, err := doRequest(apiBase+"/api/organizations", sessionKey)
body, err := fetchWithFallback(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)
return "", err
}
var orgs []struct {
@@ -96,16 +148,13 @@ func FetchUsage(sessionKey, orgID string) (*CacheData, string, error) {
}
url := fmt.Sprintf("%s/api/organizations/%s/usage", apiBase, orgID)
body, status, err := doRequest(url, sessionKey)
body, err := fetchWithFallback(url, sessionKey)
if err != nil {
if err.Error() == "auth_expired" {
return &CacheData{Error: "auth_expired", Status: 403}, orgID, err
}
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 {

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()
}

View File

@@ -1,3 +0,0 @@
# Fetch Claude API usage every 5 minutes (all users with a session key)
# Installed by claude-statusline package
*/5 * * * * root /usr/bin/claude-fetcher 2>/dev/null

View File

@@ -6,7 +6,7 @@ section: utils
priority: optional
maintainer: Axel Meyer <axel.meyer@durania.net>
description: |
Claude API usage monitor — CLI statusline, cron fetcher, and desktop tray widget.
Claude API usage monitor — CLI statusline and desktop tray widget.
Shows 5-hour and 7-day token usage from claude.ai in your terminal or system tray.
vendor: davoryn.de
homepage: https://git.davoryn.de/calic/claude-statusline
@@ -18,11 +18,6 @@ contents:
file_info:
mode: 0755
- src: ./claude-fetcher
dst: /usr/bin/claude-fetcher
file_info:
mode: 0755
- src: ./claude-widget
dst: /usr/bin/claude-widget
file_info:
@@ -33,11 +28,6 @@ contents:
file_info:
mode: 0644
- src: ./packaging/linux/claude-statusline-fetch
dst: /etc/cron.d/claude-statusline-fetch
file_info:
mode: 0644
scripts:
postinstall: ./packaging/linux/postinstall.sh
preremove: ./packaging/linux/preremove.sh