From 76d7307db0528bd247023a6b3d3d3ba312ab1450 Mon Sep 17 00:00:00 2001 From: Jiaqi Liu Date: Sat, 24 Oct 2026 00:28:06 +0800 Subject: [PATCH] fix(cursor): prefer GetCurrentPeriodUsage and label the real window The dashboard usage-summary endpoint can report a stale monthly period with zero usage while Cursor itself shows the current allowance from its application service. Query GetCurrentPeriodUsage first and fall back to the summary, then derive the session window label from the returned billing-cycle bounds so a seven-day window is not displayed as the default five-hour one. Fixes #186 --- src/models.rs | 4 + src/poller/cursor.rs | 321 ++++++++++++++++++++++++++++++++++++-- src/theme_engine.rs | 6 + src/theme_engine/tests.rs | 16 ++ src/window.rs | 36 ++++- 5 files changed, 367 insertions(+), 16 deletions(-) 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)]