All checks were successful
Release / build (push) Successful in 1m45s
Drop claude-fetcher binary and cron job — the widget's built-in BackgroundFetcher is the sole fetcher now. Add cmd/setup with cross-platform install and uninstall (--uninstall): kills widget, removes binaries + autostart, cleans Claude Code statusline setting, optionally removes config dir. Also includes: browser-based login (chromedp), ICO wrapper for Windows tray icon, and reduced icon size (64px). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
103 lines
3.0 KiB
Go
103 lines
3.0 KiB
Go
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 ""
|
|
}
|