From 4463a9b51dd5a9773ddb9354054e1fba54a5e9f8 Mon Sep 17 00:00:00 2001 From: ocd Date: Thu, 30 Jul 2026 01:22:56 +0300 Subject: [PATCH] Send browser cookies/UA for live auth Client builds the Cookie header as 'auth=; oc_locale=en' from the bare auth value the config supplies, so oc_locale defaults to English and the scrape selectors match. Legacy full-header configs pass through. Default User-Agent is a desktop Chrome string; --user-agent still overrides. Fix relative-time past tense ('1m ago' not 'ago1m'). Verified live against a real OpenCode account: balance, three usage frames, and reset times all parse. --- src/client.rs | 23 +++++++++++++++++++++-- src/main.rs | 7 ++++++- src/timefmt.rs | 3 +-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/client.rs b/src/client.rs index 1f38f90..5b8abad 100644 --- a/src/client.rs +++ b/src/client.rs @@ -22,6 +22,24 @@ pub fn go_url(workspace: &str) -> String { GO_BASE_URL.replace("{workspace}", workspace) } +/// Build the Cookie header from the bare `auth` value. OpenCode expects an +/// `auth` cookie plus an `oc_locale` cookie. We pin `oc_locale=en` so the page +/// renders in English and the scrape selectors match the captured pages. +/// +/// If `cookie` already looks like a full header (contains `=` and `;`), pass +/// it through unchanged so legacy configs keep working. +fn cookie_header(cookie: &str) -> String { + let trimmed = cookie.trim(); + if trimmed.contains('=') && (trimmed.contains(';') || trimmed.starts_with("auth=")) { + // Caller supplied a full header. Use as-is, but ensure oc_locale. + if trimmed.contains("oc_locale") { + return trimmed.to_string(); + } + return format!("{trimmed}; oc_locale=en"); + } + format!("auth={trimmed}; oc_locale=en") +} + pub struct HttpClient { client: reqwest::Client, retries: u8, @@ -59,8 +77,9 @@ impl HttpClient { let resp = self .client .get(url) - .header("cookie", cookie) - .header("accept", "text/html,application/xhtml+xml,*/*;q=0.8") + .header("cookie", cookie_header(cookie)) + .header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("accept-language", "en-US,en;q=0.9") .send() .await .map_err(|e| { diff --git a/src/main.rs b/src/main.rs index c6f58dc..964e857 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,11 @@ use clap::Parser; use cli::{CacheAction, Cli, Command, ConfigAction, RawFormat}; use model::{AccountReport, ReportStatus}; +/// User-Agent sent on every request. Looks like a normal desktop browser so +/// OpenCode serves the same HTML the scrape selectors were built against. The +/// user can override with `--user-agent`. +const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + fn main() -> ExitCode { let cli = Cli::parse(); let rt = match tokio::runtime::Builder::new_multi_thread() @@ -131,7 +136,7 @@ fn load_and_options(cli: &Cli) -> anyhow::Result<(config::Config, fetch::FetchOp let user_agent = cli .user_agent .clone() - .unwrap_or_else(|| format!("ocd/{}", env!("CARGO_PKG_VERSION"))); + .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); let ttl = cli.ttl.or(cfg.default_ttl_secs).unwrap_or(180); let opts = fetch::FetchOptions { ttl_secs: ttl, diff --git a/src/timefmt.rs b/src/timefmt.rs index 4d7f298..6b0b8e2 100644 --- a/src/timefmt.rs +++ b/src/timefmt.rs @@ -14,9 +14,8 @@ pub fn relative(iso: &str) -> String { let now = Utc::now(); let d = t.signed_duration_since(now); let secs = d.num_seconds(); - let sign = if secs >= 0 { "in " } else { " ago" }; let body = dur_words(secs.abs()); - format!("{sign}{body}") + if secs >= 0 { format!("in {body}") } else { format!("{body} ago") } } /// Absolute UTC string like "2025-08-31 17:00 UTC".