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
first (Chrome wraps JSON in 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)
}