diff --git a/src/models.rs b/src/models.rs index b278d83e..3134d78f 100644 --- a/src/models.rs +++ b/src/models.rs @@ -74,6 +74,10 @@ pub struct CreditsSection { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct UsageData { pub session: UsageSection, + /// Short label for the session window length (e.g. "5h", "7d"), when the + /// provider reports a period different from the default session window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_label: Option, pub weekly: UsageSection, #[serde(default, skip_serializing_if = "Option::is_none")] pub weekly_label: Option, diff --git a/src/poller/cursor.rs b/src/poller/cursor.rs index bf1e514a..6839bc70 100644 --- a/src/poller/cursor.rs +++ b/src/poller/cursor.rs @@ -1,18 +1,30 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use super::{build_agent, parse_iso8601, PollError}; use crate::diagnose; use crate::models::{UsageData, UsageSection}; const CURSOR_USAGE_SUMMARY_URL: &str = "https://cursor.com/api/usage-summary"; +/// Cursor's own client reads the allowance it displays from this internal +/// application service. The dashboard summary above can describe a different +/// (stale) period for the same account, so prefer this endpoint and fall back. +const CURSOR_CURRENT_PERIOD_URL: &str = + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage"; const CURSOR_SESSION_TOKEN_ENV: &str = "CURSOR_SESSION_TOKEN"; const CURSOR_ACCESS_TOKEN_KEY: &str = "cursorAuth/accessToken"; +/// Bearer token plus the dashboard cookie, both derived from the same local +/// login. Each Cursor endpoint authenticates differently. +struct CursorAuth { + access_token: String, + cookie: String, +} + #[derive(Deserialize)] struct CursorUsageSummaryResponse { #[serde(rename = "billingCycleEnd")] @@ -28,22 +40,37 @@ struct CursorIndividualUsage { #[derive(Deserialize)] struct CursorPlanUsage { - #[serde(rename = "autoPercentUsed")] + #[serde( + default, + rename = "autoPercentUsed", + alias = "auto_percent_used", + deserialize_with = "deserialize_optional_f64" + )] auto_percent_used: Option, - #[serde(rename = "apiPercentUsed")] + #[serde( + default, + rename = "apiPercentUsed", + alias = "api_percent_used", + deserialize_with = "deserialize_optional_f64" + )] api_percent_used: Option, - #[serde(rename = "totalPercentUsed")] + #[serde( + default, + rename = "totalPercentUsed", + alias = "total_percent_used", + deserialize_with = "deserialize_optional_f64" + )] total_percent_used: Option, } pub(super) fn poll_cursor() -> Result { - let cookie = read_cursor_session_cookie().ok_or_else(|| { + let auth = read_cursor_auth().ok_or_else(|| { diagnose::log( "Cursor usage poll failed: no Cursor session found (sign in to Cursor or set CURSOR_SESSION_TOKEN)", ); PollError::NoCredentials })?; - fetch_cursor_usage(&cookie) + fetch_cursor_usage(&auth) } pub(super) fn credential_watch_snapshot(_all_sources: bool) -> Vec { @@ -56,15 +83,30 @@ pub(super) fn credential_watch_snapshot(_all_sources: bool) -> Vec { vec![environment, database] } -/// Resolve a Cursor dashboard session cookie. An explicit environment value -/// takes priority over the access token persisted by Cursor itself. -fn read_cursor_session_cookie() -> Option { +/// Resolve a Cursor login. An explicit environment value takes priority over +/// the access token persisted by Cursor itself. The environment accepts a raw +/// access token, a `WorkosCursorSessionToken` cookie value, or the combined +/// `user_id%3A%3Aaccess_token` form. +fn read_cursor_auth() -> Option { if let Some(token) = non_empty_environment(CURSOR_SESSION_TOKEN_ENV) { - return normalize_cursor_session_cookie(&token); + return cursor_auth_from_environment(&token); } let access_token = read_cursor_access_token_from_state_db()?; - cursor_cookie_from_access_token(&access_token) + let cookie = cursor_cookie_from_access_token(&access_token)?; + Some(CursorAuth { + access_token, + cookie, + }) +} + +fn cursor_auth_from_environment(token: &str) -> Option { + let normalized = normalize_cursor_session_cookie(token)?; + let access_token = access_token_from_cookie(&normalized).unwrap_or_else(|| normalized.clone()); + Some(CursorAuth { + access_token, + cookie: normalized, + }) } fn normalize_cursor_session_cookie(token: &str) -> Option { @@ -87,6 +129,14 @@ fn normalize_cursor_session_cookie(token: &str) -> Option { } } +fn access_token_from_cookie(cookie: &str) -> Option { + cookie + .split_once("%3A%3A") + .map(|(_, token)| token.to_string()) + .or_else(|| cookie.split_once("::").map(|(_, token)| token.to_string())) + .filter(|token| !token.is_empty()) +} + fn cursor_cookie_from_access_token(access_token: &str) -> Option { let user_id = extract_cursor_user_id(access_token)?; Some(format!("{user_id}%3A%3A{access_token}")) @@ -187,7 +237,70 @@ fn query_cursor_access_token(path: &Path) -> Result, crate::winsq .map(|token| token.filter(|token| !token.is_empty())) } -fn fetch_cursor_usage(cookie: &str) -> Result { +fn fetch_cursor_usage(auth: &CursorAuth) -> Result { + // Prefer the application service window: it matches the allowance Cursor + // itself displays. The dashboard summary is kept as a fallback because the + // application service is internal and may reject some accounts. + match fetch_cursor_usage_current_period(auth) { + Ok(data) => Ok(data), + Err(current_error) => { + diagnose::log(format!( + "Cursor GetCurrentPeriodUsage unavailable; falling back to usage-summary ({current_error:?})" + )); + match fetch_cursor_usage_summary(&auth.cookie) { + Ok(data) => Ok(data), + Err(summary_error) => { + if current_error.is_auth() || summary_error.is_auth() { + Err(PollError::AuthRequired) + } else { + Err(summary_error) + } + } + } + } + } +} + +fn fetch_cursor_usage_current_period(auth: &CursorAuth) -> Result { + let body = serde_json::json!({}); + let mut response = match build_agent()? + .post(CURSOR_CURRENT_PERIOD_URL) + .header("Authorization", &format!("Bearer {}", auth.access_token)) + .header("Content-Type", "application/json") + .header("User-Agent", "Mozilla/5.0") + .send_json(&body) + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return Err(PollError::AuthRequired), + Err(ureq::Error::StatusCode(code)) => return Err(PollError::HttpStatus(code)), + Err(error) => { + diagnose::log_error("Cursor GetCurrentPeriodUsage request failed", error); + return Err(PollError::RequestFailed); + } + }; + + // The endpoint is undocumented and has drifted before, so parse loosely + // and report the top-level keys when the expected shape is missing. + let value: serde_json::Value = response.body_mut().read_json().map_err(|error| { + diagnose::log_error( + "unable to parse Cursor GetCurrentPeriodUsage response", + error, + ); + PollError::UnexpectedResponse + })?; + cursor_usage_from_current_period(&value).ok_or_else(|| { + diagnose::log(format!( + "Cursor GetCurrentPeriodUsage response missing plan usage; keys: {:?}", + value + .as_object() + .map(|object| { object.keys().map(String::as_str).collect::>() }) + )); + PollError::UnexpectedResponse + }) +} + +fn fetch_cursor_usage_summary(cookie: &str) -> Result { let cookie_header = format!("WorkosCursorSessionToken={cookie}"); let mut response = match build_agent()? .get(CURSOR_USAGE_SUMMARY_URL) @@ -215,9 +328,35 @@ fn fetch_cursor_usage(cookie: &str) -> Result { }) } +fn cursor_usage_from_current_period(value: &serde_json::Value) -> Option { + if value.get("enabled").and_then(|value| value.as_bool()) == Some(false) { + return None; + } + let plan_value = value + .get("planUsage") + .or_else(|| value.get("plan")) + .or_else(|| value.pointer("/individualUsage/plan"))?; + let plan: CursorPlanUsage = serde_json::from_value(plan_value.clone()).ok()?; + let start_ms = epoch_millis_from_value(value.get("billingCycleStart")); + let end_ms = epoch_millis_from_value(value.get("billingCycleEnd")); + cursor_usage_from_plan( + plan, + epoch_millis_to_system_time(end_ms), + period_label(start_ms, end_ms), + ) +} + fn cursor_usage_from_summary(response: CursorUsageSummaryResponse) -> Option { let plan = response.individual_usage?.plan?; let reset = parse_iso8601(response.billing_cycle_end.as_deref()); + cursor_usage_from_plan(plan, reset, None) +} + +fn cursor_usage_from_plan( + plan: CursorPlanUsage, + reset: Option, + session_label: Option, +) -> Option { let section = |percentage: Option| { percentage .map(|percentage| UsageSection { @@ -230,6 +369,7 @@ fn cursor_usage_from_summary(response: CursorUsageSummaryResponse) -> Option Option, end_ms: Option) -> Option { + let duration_ms = end_ms?.checked_sub(start_ms?)?; + if duration_ms <= 0 { + return None; + } + let duration_secs = duration_ms / 1000; + if duration_secs < 60 { + return None; + } + // Whole units only: a seven-day window reads "7d", not "168h". + let days = duration_secs / 86_400; + if days >= 1 { + return Some(format!("{days}d")); + } + let hours = duration_secs / 3_600; + if hours >= 1 { + return Some(format!("{hours}h")); + } + Some(format!("{}m", duration_secs / 60)) +} + +fn epoch_millis_to_system_time(millis: Option) -> Option { + let millis = millis?; + if millis < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_millis(millis as u64)) +} + +fn epoch_millis_from_value(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Number(number) => number.as_i64(), + serde_json::Value::String(text) => text.parse().ok(), + _ => None, + } +} + +fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.and_then(|value| match value { + serde_json::Value::Number(number) => number.as_f64(), + serde_json::Value::String(text) => text.parse().ok(), + _ => None, + })) +} + fn non_empty_environment(name: &str) -> Option { std::env::var(name) .ok() @@ -313,6 +503,7 @@ mod tests { assert_eq!(data.weekly_label.as_deref(), Some("API")); assert!(data.session.resets_at.is_some()); assert_eq!(data.session.resets_at, data.weekly.resets_at); + assert!(data.session_label.is_none()); } #[test] @@ -335,4 +526,108 @@ mod tests { } } } + + #[test] + fn current_period_maps_plan_usage_and_derives_the_window_label() { + // Shapes observed against the live application service: epoch-millisecond + // bounds as strings and percentages nested under `planUsage`. + let value: serde_json::Value = serde_json::from_str( + r#"{ + "enabled": true, + "billingCycleStart": "1792405460651", + "billingCycleEnd": "1793010260651", + "planUsage": { + "autoPercentUsed": 7.085590465872156, + "apiPercentUsed": 0, + "totalPercentUsed": 7.085590465872156 + } + }"#, + ) + .unwrap(); + + let data = cursor_usage_from_current_period(&value).unwrap(); + assert!(data.session.available); + assert!((data.session.percentage - 7.085590465872156).abs() < 1e-9); + assert!(data.weekly.available); + assert_eq!(data.weekly.percentage, 0.0); + assert_eq!(data.weekly_label.as_deref(), Some("API")); + // 1792405460651..1793010260651 is exactly a seven-day window. + assert_eq!(data.session_label.as_deref(), Some("7d")); + let reset = data.session.resets_at.unwrap(); + assert_eq!( + reset.duration_since(UNIX_EPOCH).unwrap().as_millis(), + 1_793_010_260_651 + ); + } + + #[test] + fn current_period_rejects_disabled_or_empty_payloads_so_summary_can_fallback() { + for json in [ + r#"{"enabled": false, "planUsage": {"autoPercentUsed": 7}}"#, + r#"{"enabled": true, "billingCycleEnd": "1793010260651"}"#, + r#"{}"#, + ] { + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + assert!(cursor_usage_from_current_period(&value).is_none()); + } + } + + #[test] + fn current_period_accepts_numeric_millis_and_string_percentages() { + let value: serde_json::Value = serde_json::from_str( + r#"{ + "billingCycleStart": 0, + "billingCycleEnd": 18000000, + "planUsage": { + "auto_percent_used": "12.5", + "api_percent_used": 2 + } + }"#, + ) + .unwrap(); + let data = cursor_usage_from_current_period(&value).unwrap(); + assert_eq!(data.session.percentage, 12.5); + assert_eq!(data.weekly.percentage, 2.0); + // 18000 seconds is a five-hour session window. + assert_eq!(data.session_label.as_deref(), Some("5h")); + } + + #[test] + fn period_label_reflects_the_reported_bounds() { + let start = 1_792_405_460_651_i64; + let week = start + 7 * 86_400_000; + assert_eq!(period_label(Some(start), Some(week)).as_deref(), Some("7d")); + assert_eq!( + period_label(Some(start), Some(start + 5 * 3_600_000)).as_deref(), + Some("5h") + ); + assert_eq!( + period_label(Some(start), Some(start + 30 * 86_400_000)).as_deref(), + Some("30d") + ); + assert_eq!( + period_label(Some(start), Some(start + 45 * 60_000)).as_deref(), + Some("45m") + ); + assert!(period_label(Some(start), Some(start)).is_none()); + assert!(period_label(None, Some(week)).is_none()); + assert!(period_label(Some(start), None).is_none()); + } + + #[test] + fn environment_auth_extracts_a_bearer_token_from_cookie_forms() { + let jwt = "header.eyJzdWIiOiJhdXRoMHx1c2VyXzEyMyJ9.signature"; + let cookie = format!("user_123%3A%3A{jwt}"); + let auth = cursor_auth_from_environment(&cookie).unwrap(); + assert_eq!(auth.access_token, jwt); + assert_eq!(auth.cookie, cookie); + + let auth = cursor_auth_from_environment(jwt).unwrap(); + assert_eq!(auth.access_token, jwt); + assert_eq!(auth.cookie, format!("user_123%3A%3A{jwt}")); + + let auth = + cursor_auth_from_environment(&format!("WorkosCursorSessionToken={cookie}")).unwrap(); + assert_eq!(auth.access_token, jwt); + } } diff --git a/src/theme_engine.rs b/src/theme_engine.rs index d09e4bcf..118b2680 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1653,6 +1653,12 @@ impl DataContext { .unwrap_or("7d") .to_string(); self.insert_string(&format!("{name}.weekly.label"), weekly_label); + let session_label = usage + .and_then(|usage| usage.session_label.as_deref()) + .or_else(|| self.get_string("i18n.session_window")) + .unwrap_or("5h") + .to_string(); + self.insert_string(&format!("{name}.session.label"), session_label); let (five_hour, weekly) = usage .map(|usage| (usage.session.percentage, usage.weekly.percentage)) .unwrap_or((0.0, 0.0)); diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index a8bc40b9..767f4de1 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -1041,6 +1041,22 @@ fn provider_specific_long_window_labels_are_available_to_templates() { assert_eq!(format_template("{claude.weekly.label}", &context), "7d"); } +#[test] +fn provider_specific_session_window_labels_are_available_to_templates() { + // Cursor reports a seven-day included-usage window rather than the default + // five-hour session, so themes can show the real period length. + let usage = crate::models::AppUsageData::from_iter([( + ProviderId::Cursor, + crate::models::UsageData { + session_label: Some("7d".into()), + ..Default::default() + }, + )]); + let context = DataContext::from_usage(Some(&usage), &Canvas::default()); + assert_eq!(format_template("{cursor.session.label}", &context), "7d"); + assert_eq!(format_template("{claude.session.label}", &context), "5h"); +} + #[test] fn opencode_monthly_window_is_available_to_templates_when_present() { let reset = std::time::SystemTime::now() + std::time::Duration::from_secs(1_713_621); diff --git a/src/window.rs b/src/window.rs index e9eff271..b0e745d9 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1169,6 +1169,10 @@ fn tray_usage_summary_lines_with_pools( .weekly_label .as_deref() .unwrap_or(strings.weekly_window); + let session_label = usage + .session_label + .as_deref() + .unwrap_or(strings.session_window); let label = match acct { Some(name) => format!("{base_name} (Gemini, {name})"), None => format!("{base_name} (Gemini)"), @@ -1176,7 +1180,7 @@ fn tray_usage_summary_lines_with_pools( lines.push(format!( "{} {}: {} | {}: {}", label, - strings.session_window, + session_label, window_text(&usage.session), weekly_label, window_text(&usage.weekly), @@ -1196,6 +1200,10 @@ fn tray_usage_summary_lines_with_pools( .weekly_label .as_deref() .unwrap_or(strings.weekly_window); + let session_label = usage + .session_label + .as_deref() + .unwrap_or(strings.session_window); let label = match acct { Some(name) => format!("{base_name} (Claude/GPT, {name})"), None => format!("{base_name} (Claude/GPT)"), @@ -1203,7 +1211,7 @@ fn tray_usage_summary_lines_with_pools( lines.push(format!( "{} {}: {} | {}: {}", label, - strings.session_window, + session_label, window_text(&usage.session), weekly_label, window_text(&usage.weekly), @@ -1219,13 +1227,17 @@ fn tray_usage_summary_lines_with_pools( .weekly_label .as_deref() .unwrap_or(strings.weekly_window); + let session_label = usage + .session_label + .as_deref() + .unwrap_or(strings.session_window); lines.push(format!( "{} {}: {:.0}% | {}: {:.0}%", match data.selected_account_name(provider) { Some(name) => format!("{} ({name})", language.text(descriptor.display_name)), None => language.text(descriptor.display_name).to_string(), }, - strings.session_window, + session_label, shown(usage.session.percentage), weekly_label, shown(usage.weekly.percentage), @@ -3759,6 +3771,24 @@ mod tray_usage_summary_tests { ["OpenCode 5h: 30% | 30d: 40%"] ); } + + #[test] + fn tray_summary_prefers_the_reported_session_window_label() { + // Cursor's included-usage window is not the default five-hour session. + let mut data = usage(7.1, 0.0, Some("API")); + data.session_label = Some("7d".into()); + let data = [(ProviderId::Cursor, data)].into_iter().collect(); + + assert_eq!( + tray_usage_summary_lines( + &data, + ProviderSet::from_enabled([ProviderId::Cursor]), + LanguageId::English, + false, + ), + ["Cursor 7d: 7% | API: 0%"] + ); + } } #[cfg(test)]