Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5abdee06ff | ||
|
|
2cb89d3c54 | ||
|
|
ba3b73c3dd | ||
|
|
47165ce02c |
@@ -28,14 +28,14 @@ jobs:
|
|||||||
- name: Build Linux binaries
|
- name: Build Linux binaries
|
||||||
run: |
|
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-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 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
|
- name: Build Windows binaries
|
||||||
run: |
|
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-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 -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
|
- name: Build .deb package
|
||||||
run: |
|
run: |
|
||||||
@@ -45,17 +45,16 @@ jobs:
|
|||||||
- name: Create Linux tarball
|
- name: Create Linux tarball
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/claude-statusline-${{ env.VERSION }}
|
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 README.md CHANGELOG.md dist/claude-statusline-${{ env.VERSION }}/
|
||||||
cp packaging/linux/claude-widget.desktop 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 }}
|
tar -czf claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz -C dist claude-statusline-${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Create Windows zip
|
- name: Create Windows zip
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq && apt-get install -y -qq zip >/dev/null 2>&1
|
apt-get update -qq && apt-get install -y -qq zip >/dev/null 2>&1
|
||||||
mkdir -p dist-win/claude-statusline-${{ env.VERSION }}
|
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 }}/
|
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 }}
|
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
|
# Go binaries
|
||||||
claude-statusline
|
claude-statusline
|
||||||
claude-fetcher
|
|
||||||
claude-widget
|
claude-widget
|
||||||
|
/setup
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# Build output
|
# 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/).
|
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
|
## [0.3.0] — 2026-02-26
|
||||||
|
|
||||||
Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies.
|
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
|
- Tray icon visibility — switched to Claude orange with full opacity at larger size
|
||||||
- Block comment syntax error in cron example
|
- 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.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
|
[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
|
## 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
|
### CLI Statusline
|
||||||
|
|
||||||
@@ -27,47 +27,49 @@ Headless status bar for Claude Code. Shows context window utilization and token
|
|||||||
Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M
|
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
|
### 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
|
## Topology
|
||||||
|
|
||||||
```
|
```
|
||||||
claude.ai API
|
claude.ai API
|
||||||
│
|
│
|
||||||
├──► claude-fetcher (cron) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
|
└──► claude-widget (background fetcher) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
|
||||||
│ │
|
│
|
||||||
└──► claude-widget (built-in fetcher) ──┘──► System tray icon
|
└──► 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
|
## 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)
|
### Debian/Ubuntu (.deb)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dpkg -i claude-statusline_0.3.0_amd64.deb
|
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
|
```bash
|
||||||
tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz
|
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
|
### Session Key Setup
|
||||||
|
|
||||||
After installing, paste your claude.ai session key:
|
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
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
@@ -136,12 +158,12 @@ Right-click the tray icon to access:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All binaries
|
# 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
|
# 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 -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-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
|
# Build .deb
|
||||||
VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager 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/
|
cmd/
|
||||||
statusline/main.go # CLI statusline (reads stdin + cache)
|
statusline/main.go # CLI statusline (reads stdin + cache)
|
||||||
fetcher/main.go # Standalone cron fetcher (writes cache)
|
widget/main.go # Desktop tray widget with built-in fetcher
|
||||||
widget/main.go # Desktop tray widget entry point
|
setup/main.go # Cross-platform install/uninstall tool
|
||||||
internal/
|
internal/
|
||||||
config/config.go # Shared config (session key, org ID, intervals)
|
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)
|
fetcher/cache.go # JSON cache read/write (/tmp/claude_usage.json)
|
||||||
renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg)
|
renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg)
|
||||||
tray/tray.go # System tray setup + menu (fyne-io/systray)
|
tray/tray.go # System tray setup + menu (fyne-io/systray)
|
||||||
packaging/
|
packaging/
|
||||||
nfpm.yaml # .deb packaging config
|
nfpm.yaml # .deb packaging config
|
||||||
linux/ # .desktop file, cron, install scripts
|
linux/ # .desktop file, install scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"git.davoryn.de/calic/claude-statusline/internal/tray"
|
"git.davoryn.de/calic/claude-statusline/internal/tray"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
tray.Run()
|
||||||
|
log.Println("widget exited")
|
||||||
}
|
}
|
||||||
|
|||||||
11
go.mod
11
go.mod
@@ -1,15 +1,22 @@
|
|||||||
module git.davoryn.de/calic/claude-statusline
|
module git.davoryn.de/calic/claude-statusline
|
||||||
|
|
||||||
go 1.21
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.11.0
|
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
|
github.com/fogleman/gg v1.3.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
golang.org/x/image v0.18.0 // 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 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||||
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
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 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
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 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
|||||||
133
internal/browser/fetch.go
Normal file
133
internal/browser/fetch.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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 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.
|
||||||
|
log.Printf("chrome-fetch: navigating to %s (profile: %s)", url, profileDir)
|
||||||
|
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 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 == "" {
|
||||||
|
log.Printf("chrome-fetch: page body empty, waiting...")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if we got actual JSON (starts with [ or {), not a challenge page
|
||||||
|
if body[0] == '[' || body[0] == '{' {
|
||||||
|
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
|
||||||
|
// Also extract any fresh cookies for future plain HTTP attempts
|
||||||
|
_ = extractAndSaveCookies(ctx)
|
||||||
|
cancel() // graceful close, flushes cookies to profile
|
||||||
|
return []byte(body), nil
|
||||||
|
}
|
||||||
|
// Log a snippet of what we got (challenge page, login redirect, etc.)
|
||||||
|
snippet := body
|
||||||
|
if len(snippet) > 200 {
|
||||||
|
snippet = snippet[:200]
|
||||||
|
}
|
||||||
|
log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.davoryn.de/calic/claude-statusline/internal/browser"
|
||||||
"git.davoryn.de/calic/claude-statusline/internal/config"
|
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,13 +37,21 @@ type ParsedUsage struct {
|
|||||||
type UpdateCallback func(ParsedUsage)
|
type UpdateCallback func(ParsedUsage)
|
||||||
|
|
||||||
// doRequest performs an authenticated HTTP GET to the Claude API.
|
// 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) {
|
func doRequest(url, sessionKey string) ([]byte, int, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
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("User-Agent", userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("Referer", "https://claude.ai/")
|
req.Header.Set("Referer", "https://claude.ai/")
|
||||||
@@ -56,17 +69,56 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
|
|||||||
return body, resp.StatusCode, nil
|
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.
|
// DiscoverOrgID fetches the first organization UUID from the API.
|
||||||
func DiscoverOrgID(sessionKey string) (string, error) {
|
func DiscoverOrgID(sessionKey string) (string, error) {
|
||||||
body, status, err := doRequest(apiBase+"/api/organizations", sessionKey)
|
body, err := fetchWithFallback(apiBase+"/api/organizations", sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return "", err
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return "", fmt.Errorf("auth_expired")
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgs []struct {
|
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)
|
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 != 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
|
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
|
var data CacheData
|
||||||
if err := json.Unmarshal(body, &data); err != nil {
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ package renderer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
"math"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/fogleman/gg"
|
"github.com/fogleman/gg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const iconSize = 256
|
const iconSize = 64
|
||||||
|
|
||||||
// Claude orange for the starburst logo.
|
// Claude orange for the starburst logo.
|
||||||
var claudeOrange = color.RGBA{224, 123, 83, 255}
|
var claudeOrange = color.RGBA{224, 123, 83, 255}
|
||||||
@@ -93,8 +95,8 @@ func drawArc(dc *gg.Context, pct int) {
|
|||||||
|
|
||||||
cx := float64(iconSize) / 2
|
cx := float64(iconSize) / 2
|
||||||
cy := float64(iconSize) / 2
|
cy := float64(iconSize) / 2
|
||||||
radius := float64(iconSize)/2 - 14 // inset from edge
|
radius := float64(iconSize)/2 - 4 // inset from edge
|
||||||
arcWidth := 28.0
|
arcWidth := 7.0
|
||||||
|
|
||||||
startAngle := -math.Pi / 2 // 12 o'clock
|
startAngle := -math.Pi / 2 // 12 o'clock
|
||||||
endAngle := startAngle + (float64(pct)/100)*2*math.Pi
|
endAngle := startAngle + (float64(pct)/100)*2*math.Pi
|
||||||
@@ -114,7 +116,7 @@ func RenderIcon(pct int) image.Image {
|
|||||||
return dc.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) {
|
func RenderIconPNG(pct int) ([]byte, error) {
|
||||||
img := RenderIcon(pct)
|
img := RenderIcon(pct)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -123,3 +125,54 @@ func RenderIconPNG(pct int) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"log"
|
||||||
"runtime"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"fyne.io/systray"
|
"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/config"
|
||||||
"git.davoryn.de/calic/claude-statusline/internal/fetcher"
|
"git.davoryn.de/calic/claude-statusline/internal/fetcher"
|
||||||
"git.davoryn.de/calic/claude-statusline/internal/renderer"
|
"git.davoryn.de/calic/claude-statusline/internal/renderer"
|
||||||
@@ -49,8 +51,11 @@ func (a *App) onReady() {
|
|||||||
systray.SetTooltip("Claude Usage: loading...")
|
systray.SetTooltip("Claude Usage: loading...")
|
||||||
|
|
||||||
// Set initial icon (0%)
|
// 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)
|
systray.SetIcon(iconData)
|
||||||
|
log.Println("SetIcon called (initial)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage display items (non-clickable info)
|
// Usage display items (non-clickable info)
|
||||||
@@ -82,8 +87,9 @@ func (a *App) onReady() {
|
|||||||
a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item)
|
a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session key
|
// Login / logout
|
||||||
mSessionKey := systray.AddMenuItem("Session Key...", "Open session key config file")
|
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()
|
systray.AddSeparator()
|
||||||
mQuit := systray.AddMenuItem("Quit", "Exit Claude Usage Widget")
|
mQuit := systray.AddMenuItem("Quit", "Exit Claude Usage Widget")
|
||||||
@@ -98,8 +104,10 @@ func (a *App) onReady() {
|
|||||||
select {
|
select {
|
||||||
case <-mRefresh.ClickedCh:
|
case <-mRefresh.ClickedCh:
|
||||||
a.bf.Refresh()
|
a.bf.Refresh()
|
||||||
case <-mSessionKey.ClickedCh:
|
case <-mLogin.ClickedCh:
|
||||||
a.openSessionKeyFile()
|
go a.doLogin()
|
||||||
|
case <-mLogout.ClickedCh:
|
||||||
|
a.doLogout()
|
||||||
case <-mQuit.ClickedCh:
|
case <-mQuit.ClickedCh:
|
||||||
systray.Quit()
|
systray.Quit()
|
||||||
return
|
return
|
||||||
@@ -133,8 +141,12 @@ func (a *App) onUsageUpdate(data fetcher.ParsedUsage) {
|
|||||||
if data.Error == "" {
|
if data.Error == "" {
|
||||||
pct = data.FiveHourPct
|
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)
|
systray.SetIcon(iconData)
|
||||||
|
log.Printf("SetIcon called (pct=%d, %d bytes)", pct, len(iconData))
|
||||||
|
} else {
|
||||||
|
log.Printf("RenderIconPNG error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tooltip
|
// Update tooltip
|
||||||
@@ -200,16 +212,19 @@ func (a *App) setInterval(idx int) {
|
|||||||
a.bf.SetInterval(intervals[idx].seconds)
|
a.bf.SetInterval(intervals[idx].seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) openSessionKeyFile() {
|
func (a *App) doLogin() {
|
||||||
path := config.SessionKeyPath()
|
systray.SetTooltip("Claude Usage: logging in...")
|
||||||
var cmd *exec.Cmd
|
_, err := browser.LoginAndGetSessionKey()
|
||||||
switch runtime.GOOS {
|
if err != nil {
|
||||||
case "windows":
|
systray.SetTooltip(fmt.Sprintf("Claude Usage: login failed — %s", err))
|
||||||
cmd = exec.Command("notepad", path)
|
return
|
||||||
case "darwin":
|
|
||||||
cmd = exec.Command("open", "-t", path)
|
|
||||||
default:
|
|
||||||
cmd = exec.Command("xdg-open", path)
|
|
||||||
}
|
}
|
||||||
_ = 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
|
priority: optional
|
||||||
maintainer: Axel Meyer <axel.meyer@durania.net>
|
maintainer: Axel Meyer <axel.meyer@durania.net>
|
||||||
description: |
|
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.
|
Shows 5-hour and 7-day token usage from claude.ai in your terminal or system tray.
|
||||||
vendor: davoryn.de
|
vendor: davoryn.de
|
||||||
homepage: https://git.davoryn.de/calic/claude-statusline
|
homepage: https://git.davoryn.de/calic/claude-statusline
|
||||||
@@ -18,11 +18,6 @@ contents:
|
|||||||
file_info:
|
file_info:
|
||||||
mode: 0755
|
mode: 0755
|
||||||
|
|
||||||
- src: ./claude-fetcher
|
|
||||||
dst: /usr/bin/claude-fetcher
|
|
||||||
file_info:
|
|
||||||
mode: 0755
|
|
||||||
|
|
||||||
- src: ./claude-widget
|
- src: ./claude-widget
|
||||||
dst: /usr/bin/claude-widget
|
dst: /usr/bin/claude-widget
|
||||||
file_info:
|
file_info:
|
||||||
@@ -33,11 +28,6 @@ contents:
|
|||||||
file_info:
|
file_info:
|
||||||
mode: 0644
|
mode: 0644
|
||||||
|
|
||||||
- src: ./packaging/linux/claude-statusline-fetch
|
|
||||||
dst: /etc/cron.d/claude-statusline-fetch
|
|
||||||
file_info:
|
|
||||||
mode: 0644
|
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: ./packaging/linux/postinstall.sh
|
postinstall: ./packaging/linux/postinstall.sh
|
||||||
preremove: ./packaging/linux/preremove.sh
|
preremove: ./packaging/linux/preremove.sh
|
||||||
|
|||||||
Reference in New Issue
Block a user