diff --git a/CHANGELOG.md b/CHANGELOG.md index e344af2..6c35d92 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/internal/browser/fetch.go b/internal/browser/fetch.go new file mode 100644 index 0000000..47e517c --- /dev/null +++ b/internal/browser/fetch.go @@ -0,0 +1,76 @@ +package browser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/chromedp/chromedp" + + "git.davoryn.de/calic/claude-statusline/internal/config" +) + +// FetchViaChrome navigates to a URL using headless Chrome with the persistent +// browser profile (which has Cloudflare clearance cookies) and returns the +// response body. This bypasses Cloudflare JS challenges because Chrome runs +// real JavaScript. Falls back to non-headless if headless fails. +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() + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", 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() + + // Set a total timeout for the operation + ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) + defer timeoutCancel() + + var body string + err := chromedp.Run(ctx, + chromedp.Navigate(url), + // Wait for the body to have content (Cloudflare challenge resolves via JS) + chromedp.WaitReady("body"), + // Chrome renders JSON API responses inside a
tag
+ chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible),
+ )
+ if err != nil {
+ // Fallback: try extracting from body directly (some responses may not use )
+ var bodyFallback string
+ errFb := chromedp.Run(ctx,
+ chromedp.Text("body", &bodyFallback, chromedp.ByQuery),
+ )
+ if errFb == nil && bodyFallback != "" {
+ return []byte(bodyFallback), nil
+ }
+ return nil, fmt.Errorf("chromedp fetch: %w", err)
+ }
+
+ if body == "" {
+ return nil, fmt.Errorf("chromedp fetch: empty response body")
+ }
+
+ // Gracefully close to flush cookies (including refreshed cf_clearance)
+ cancel()
+
+ return []byte(body), nil
+}
diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go
index 9670891..1ef62d9 100644
--- a/internal/fetcher/fetcher.go
+++ b/internal/fetcher/fetcher.go
@@ -4,11 +4,13 @@ import (
"encoding/json"
"fmt"
"io"
+ "log"
"math"
"net/http"
"sync"
"time"
+ "git.davoryn.de/calic/claude-statusline/internal/browser"
"git.davoryn.de/calic/claude-statusline/internal/config"
)
@@ -56,17 +58,36 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
return body, resp.StatusCode, nil
}
+// 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 +117,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 {