Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb89d3c54 | ||
|
|
ba3b73c3dd | ||
|
|
47165ce02c |
@@ -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
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# Go binaries
|
||||
claude-statusline
|
||||
claude-fetcher
|
||||
claude-widget
|
||||
/setup
|
||||
*.exe
|
||||
|
||||
# Build output
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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
|
||||
|
||||
70
README.md
70
README.md
@@ -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
|
||||
|
||||
@@ -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
479
cmd/setup/main.go
Normal 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
5
cmd/setup/ui_other.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func showMessage(_, _ string, _ bool) {}
|
||||
23
cmd/setup/ui_windows.go
Normal file
23
cmd/setup/ui_windows.go
Normal 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)
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/tray"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 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
11
go.mod
@@ -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
23
go.sum
@@ -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=
|
||||
|
||||
122
internal/browser/fetch.go
Normal file
122
internal/browser/fetch.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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 the persistent browser
|
||||
// profile (which has Cloudflare clearance cookies) and returns the response
|
||||
// body. Uses non-headless mode with a minimized/hidden window to avoid
|
||||
// Cloudflare's headless detection, which causes infinite challenge loops.
|
||||
func FetchViaChrome(url string) ([]byte, error) {
|
||||
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
||||
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create browser profile dir: %w", err)
|
||||
}
|
||||
|
||||
// Remove stale lock file from unclean shutdown
|
||||
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
||||
|
||||
execPath := findBrowserExec()
|
||||
|
||||
// Use non-headless mode: Cloudflare detects headless Chrome and loops
|
||||
// the JS challenge forever. A real (but hidden) browser window passes.
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", false),
|
||||
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
|
||||
chromedp.Flag("window-size", "1,1"),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-first-run", true),
|
||||
chromedp.Flag("disable-extensions", true),
|
||||
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()
|
||||
|
||||
// Total timeout for the operation
|
||||
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer timeoutCancel()
|
||||
|
||||
// Navigate and wait for Cloudflare challenge to resolve.
|
||||
// Poll the page content until we get valid JSON (not the challenge page).
|
||||
if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
|
||||
return nil, fmt.Errorf("chromedp navigate: %w", err)
|
||||
}
|
||||
|
||||
// Poll for JSON response — Cloudflare challenge takes a few seconds to clear
|
||||
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
|
||||
// Try <pre> first (Chrome wraps JSON in <pre> tags)
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Text("pre", &body, chromedp.ByQuery),
|
||||
)
|
||||
if err != nil || body == "" {
|
||||
// Fallback: try body directly
|
||||
_ = chromedp.Run(ctx,
|
||||
chromedp.Text("body", &body, chromedp.ByQuery),
|
||||
)
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
// Check if we got actual JSON (starts with [ or {), not a challenge page
|
||||
if body[0] == '[' || body[0] == '{' {
|
||||
// Also extract any fresh cookies for future plain HTTP attempts
|
||||
_ = extractAndSaveCookies(ctx)
|
||||
cancel() // graceful close, flushes cookies to profile
|
||||
return []byte(body), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
|
||||
// alongside the session key, so plain HTTP requests can try them next time.
|
||||
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
|
||||
}
|
||||
|
||||
// Write Cloudflare cookies to a file the fetcher can read
|
||||
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
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 ""
|
||||
}
|
||||
@@ -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,54 @@ 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 headless Chrome", url)
|
||||
chromeBody, chromeErr := browser.FetchViaChrome(url)
|
||||
if chromeErr != nil {
|
||||
return nil, fmt.Errorf("auth_expired") // treat as auth failure if Chrome also fails
|
||||
}
|
||||
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 +146,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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user