Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3b73c3dd |
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
|
||||
|
||||
76
internal/browser/fetch.go
Normal file
76
internal/browser/fetch.go
Normal file
@@ -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 <pre> tag
|
||||
chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible),
|
||||
)
|
||||
if err != nil {
|
||||
// Fallback: try extracting from body directly (some responses may not use <pre>)
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user