diff --git a/CHANGELOG.md b/CHANGELOG.md index c054768f2c..331e7c422c 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ menu-bar layout. - Provider charts: show recent Codex and Claude quota-window history with explicit account scope and estimated-boundary markers; historical data remains display-only. - Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session. - Replicate: cookie-authenticated monthly spend and optional prepaid credit balance from the billing page, with user and organization account isolation. +- Raycast: read monthly AI credits from the signed-in `www.raycast.com` session (Chrome auto-import or a pasted Cookie header), showing credits left, plan, and renewal date. Off makes no request and a manual header never falls back to a browser. - DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards. ### Fixed diff --git a/README.md b/README.md index 2e052ad2f9..e74e3d1dcc 100755 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance | | Grok | Cookies / auth.json | Billing | | Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance | +| Raycast | Cookies (Chrome auto or manual) | Monthly AI credits, plan, renewal | | Replicate | Cookies / token accounts | Monthly spend, credit balance | | ElevenLabs | API Key | Subscription Credits, Voice Slots | | Deepgram | API Key | Project Usage | diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index 07b289498f..3cfd5b2ffc 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -36,6 +36,7 @@ mod tests { window_minutes: Some(300), resets_at: Some(Utc::now().to_rfc3339()), reset_description: None, + detail_description: None, is_exhausted: exhausted, is_informational: informational, reserve_percent: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..fd8fff962c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -19,6 +19,8 @@ pub struct RateWindowSnapshot { pub resets_at: Option, #[serde(default)] pub reset_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail_description: Option, #[serde(default)] pub is_exhausted: bool, #[serde(default)] @@ -47,6 +49,7 @@ impl RateWindowSnapshot { window_minutes: rw.window_minutes, resets_at: rw.resets_at.map(|dt| dt.to_rfc3339()), reset_description: rw.reset_description.clone(), + detail_description: rw.detail_description().map(str::to_owned), is_exhausted: rw.is_exhausted(), is_informational: rw.is_informational, reserve_percent: None, @@ -512,6 +515,7 @@ impl ProviderUsageSnapshot { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, @@ -974,6 +978,7 @@ mod tests { window_minutes, resets_at: resets_at.map(|dt| dt.to_rfc3339()), reset_description, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, @@ -1009,6 +1014,25 @@ mod tests { ); } + #[test] + fn detail_description_crosses_the_bridge_and_is_not_a_reset_phrase() { + let rw = RateWindow::with_details(25.0, None, None, Some("750 / 1000 credits left".into())) + .with_detail_description("750 / 1000 credits left"); + let window = RateWindowSnapshot::from_rate_window(&rw); + + assert_eq!( + window.detail_description.as_deref(), + Some("750 / 1000 credits left") + ); + let json = serde_json::to_value(&window).unwrap(); + assert_eq!(json["detailDescription"], "750 / 1000 credits left"); + assert_eq!(compact_tray_status_label(&window, Language::English), "25%"); + + let plain = RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0)); + let json = serde_json::to_value(&plain).unwrap(); + assert!(json.get("detailDescription").is_none()); + } + #[test] fn japanese_tray_status_label_has_no_english_reset_text() { use codexbar::settings::Language; diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs index 6498eec07d..4c6e6dc6b6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs @@ -31,6 +31,11 @@ fn compact_reset_description( .map(|dt| dt.with_timezone(&chrono::Utc))?; return Some(format_compact_reset_countdown(dt, lang)); } + // A window with its own detail line (e.g. a credit balance) carries that + // text in `reset_description` too; it is not a reset phrase. + if window.detail_description.is_some() { + return None; + } window .reset_description diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index f9344fcfba..a05a960b57 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -224,6 +224,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Notion, "grok" => ProviderId::Grok, "replicate" => ProviderId::Replicate, + "raycast" => ProviderId::Raycast, "helmcode" => ProviderId::Helmcode, "typesafe" => ProviderId::TypeSafe, _ => return None, @@ -749,6 +750,32 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + locale::get_text(lang, locale::LocaleKey::ProviderRaycastAutoImportHelp), + "", + None, + ), + cookie_option( + lang, + "manual", + "", + locale::get_text(lang, locale::LocaleKey::ProviderRaycastManualCookieHelp), + None, + ), + cookie_option( + lang, + "off", + "", + "", + Some( + locale::get_text(lang, locale::LocaleKey::ProviderRaycastCookiesDisabled) + .as_str(), + ), + ), + ], "helmcode" => vec![ cookie_option( lang, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..08e049d6be 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1314,6 +1314,7 @@ mod reset_backfill_tests { window_minutes: Some(300), resets_at: resets_at.map(String::from), reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index d25a0a064a..c8e1b33bdf 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -292,6 +292,20 @@ fn replicate_cookie_source_and_domain_are_exposed() { ); } +#[test] +fn raycast_cookie_source_and_domain_are_exposed() { + let mut settings = Settings::default(); + super::provider_cookie_source_set(&mut settings, "raycast", "manual".to_string()).unwrap(); + assert_eq!( + provider_cookie_source_lookup(&settings, "raycast").as_deref(), + Some("manual") + ); + assert_eq!( + super::provider_cookie_domain(ProviderId::Raycast, &settings), + Some("www.raycast.com") + ); +} + #[test] fn provider_cookie_source_set_rejects_unknown_provider() { let mut s = Settings::default(); @@ -485,6 +499,49 @@ fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { assert!(ctx.manual_cookie_missing); } +#[test] +fn fetch_context_raycast_cookie_sources_never_import_in_the_shell() { + let build = |source: &str, stored: Option<&str>| { + let mut settings = Settings::default(); + settings.set_cookie_source(ProviderId::Raycast, source); + let mut cookies = ManualCookies::default(); + if let Some(stored) = stored { + cookies.set(ProviderId::Raycast.cli_name(), stored); + } + super::build_fetch_context( + ProviderId::Raycast, + &settings, + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ) + }; + + // Manual with no stored header fails closed instead of using a browser. + let empty_manual = build("manual", None); + assert_eq!(empty_manual.source_mode, SourceMode::Web); + assert!(empty_manual.manual_cookie_header.is_none()); + assert!(empty_manual.manual_cookie_missing); + + // Off maps to the source the provider refuses, and never carries a header. + let off = build("off", Some("__raycast_session=stored")); + assert_eq!(off.source_mode, SourceMode::Cli); + assert!(off.manual_cookie_header.is_none()); + + // Auto leaves browser resolution to the provider (Chrome only). + let auto = build("auto", None); + assert_eq!(auto.source_mode, SourceMode::Auto); + assert!(auto.manual_cookie_header.is_none()); + assert!(!auto.manual_cookie_missing); + + let manual = build("manual", Some("__raycast_session=stored")); + assert_eq!(manual.source_mode, SourceMode::Web); + assert_eq!( + manual.manual_cookie_header.as_deref(), + Some("__raycast_session=stored") + ); +} + #[test] fn fetch_context_codex_manual_cookie_never_forces_unsupported_web() { // Default cookie source is "manual". Pasting a chatgpt.com cookie used to flip @@ -1779,6 +1836,13 @@ fn replicate_cookie_options_allow_automatic_and_manual_sessions() { assert_eq!(values, vec!["auto", "manual"]); } +#[test] +fn raycast_cookie_options_include_off_and_a_pinned_manual_session() { + let opts = super::cookie_source_options_for("raycast", Language::English); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); +} + #[test] fn cookie_options_empty_for_providers_without_picker() { assert!(super::cookie_source_options_for("anthropic", Language::English).is_empty()); diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..f8ee258383 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -173,6 +173,7 @@ mod tests { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index aebb7c2115..088f8b250b 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1028,6 +1028,7 @@ mod tests { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, @@ -1042,6 +1043,7 @@ mod tests { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, @@ -1057,6 +1059,7 @@ mod tests { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, @@ -1121,6 +1124,7 @@ mod tests { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, @@ -1531,6 +1535,7 @@ mod tests { window_minutes: Some(10080), resets_at: None, reset_description: None, + detail_description: None, is_exhausted: true, is_informational: false, reserve_percent: None, @@ -1575,6 +1580,7 @@ mod tests { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_exhausted: false, is_informational: false, reserve_percent: None, diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index f6e8f5f532..84b1379968 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -261,6 +261,7 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna window_minutes: None, resets_at, reset_description: None, + detail_description: None, is_exhausted: used_percent >= 100.0, is_informational: false, reserve_percent: None, diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..b4da6f69bd 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -264,6 +264,38 @@ describe("MenuCard", () => { expect(fill?.style.width).toBe("35%"); }); + it("shows an additional balance description below the meter and reset time", async () => { + const snapshot = provider(null, 25, { + resetDescription: "750 / 1000 credits left", + }); + snapshot.primaryLabel = "Credits"; + snapshot.primary.detailDescription = "750 / 1000 credits left"; + snapshot.selectedMetric.detailDescription = "750 / 1000 credits left"; + const resetsAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + snapshot.primary.resetsAt = resetsAt; + snapshot.selectedMetric.resetsAt = resetsAt; + + renderCard(snapshot); + + const description = await screen.findByText("750 / 1000 credits left"); + expect(description).toHaveClass("menu-metric__detail"); + expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + }); + + it("does not repeat a reset-phrase description as an extra detail", async () => { + const snapshot = provider(null, 20, { resetDescription: "Resets in 3h" }); + const resetsAt = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString(); + snapshot.primary.resetsAt = resetsAt; + snapshot.selectedMetric.resetsAt = resetsAt; + + renderCard(snapshot); + + await screen.findByText(/Resets in/); + expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(1); + expect(document.querySelectorAll(".menu-metric__detail")).toHaveLength(0); + expect(screen.queryByText("Resets in 3h")).not.toBeInTheDocument(); + }); + it("displays over-quota usage without overflowing the bar", async () => { renderCard(provider(null, 115, { exhausted: true, resetDescription: "115% used" }), { showAsUsed: true, diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..8a59ab3e2d 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -339,10 +339,11 @@ function MetricRow({ const level = levelOf(remain, snap.isExhausted); const resetText = useFormattedResetTime( snap.resetsAt, - isInformational ? null : snap.resetDescription, + isInformational || snap.detailDescription ? null : snap.resetDescription, resetTimeRelative, resetFormatMode ?? "reset", ); + const detailDescription = snap.detailDescription?.trim() || null; const infoPrimary = snap.resetDescription?.trim() || resetText || "—"; const resetTarget = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; const replacesPercent = @@ -380,6 +381,9 @@ function MetricRow({ {resetText} )} + {!compactOverview && !isInformational && detailDescription && ( +
{detailDescription}
+ )} {!compactOverview && !isInformational && snap.isExhausted && (
{exhaustedLabel}
)} diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-raycast.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-raycast.svg new file mode 100644 index 0000000000..851925356d --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-raycast.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 2fdc4380d7..3998f394f0 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -54,6 +54,7 @@ import opencodego from "./icons/ProviderIcon-opencodego.svg?raw"; import openrouter from "./icons/ProviderIcon-openrouter.svg?raw"; import perplexity from "./icons/ProviderIcon-perplexity.svg?raw"; import qoder from "./icons/ProviderIcon-qoder.svg?raw"; +import raycast from "./icons/ProviderIcon-raycast.svg?raw"; import replicate from "./icons/ProviderIcon-replicate.svg?raw"; import sakana from "./icons/ProviderIcon-sakana.svg?raw"; import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; @@ -142,6 +143,7 @@ const RAW: Record = { openrouter: tint(openrouter), perplexity: tint(perplexity), qoder: tint(qoder), + raycast: tint(raycast), replicate: tint(replicate), sakana: tint(sakana), stepfun: tint(stepfun), @@ -224,6 +226,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { crof: { id: "crof", brandColor: "#7c3aed", fallbackLetter: "C", svgPath: RAW.crof }, crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel }, qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder }, + raycast: { id: "raycast", brandColor: "#FF6363", fallbackLetter: "R", svgPath: RAW.raycast }, replicate: { id: "replicate", brandColor: "#000000", fallbackLetter: "R", svgPath: RAW.replicate }, codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" }, sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana }, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index e8d71af8fe..c1c6552f9f 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -221,10 +221,12 @@ function ProviderPill({ const label = state.isProblem ? stateLabel : `${Math.round(displayPercent)}%`; const resetText = useFormattedResetTime( rateWindow.resetsAt, - rateWindow.resetDescription, + rateWindow.detailDescription ? null : rateWindow.resetDescription, resetRelative, ); + const detailDescription = rateWindow.detailDescription?.trim() || null; const resetSuffix = resetText ? `\n${resetText}` : ""; + const detailSuffix = detailDescription ? `\n${detailDescription}` : ""; const inlineReset = resetText ? inlineResetTime(resetText, rateWindow.resetsAt, resetRelative) : null; @@ -237,7 +239,7 @@ function ProviderPill({ title={ state.isProblem ? `${provider.displayName}: ${stateLabel}` - : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}` + : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}${detailSuffix}` } data-tauri-drag-region style={{ "--brand": brand } as CSSProperties} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..3bf65b3e96 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -297,6 +297,9 @@ export const ALL_LOCALE_KEYS = [ "ProviderCodexNotRunningHelp", "ProviderCookieSource", "CookieSourceManual", + "ProviderRaycastAutoImportHelp", + "ProviderRaycastManualCookieHelp", + "ProviderRaycastCookiesDisabled", "ProviderRegion", "ProviderClaudeCookies", "ProviderClaudeCookiesHelp", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index a41405732c..c55ac477a5 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3779,7 +3779,8 @@ html:has(.menu-surface--tray) { max-width: 100%; } .menu-surface--tray .menu-metric__pct, -.menu-surface--tray .menu-metric__reset { +.menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__detail { font-size: 10px; } .menu-surface--tray .menu-metric__reserve { @@ -4324,6 +4325,12 @@ html:has(.menu-surface--tray) { overflow: hidden; } +.menu-metric__detail { + font-size: 11px; + color: var(--text-secondary); + overflow-wrap: break-word; +} + .menu-metric__exhausted { font-size: 11px; font-weight: 600; @@ -5051,7 +5058,8 @@ html:has(.menu-surface--tray) { } .menu-metric__pct, -.menu-metric__reset { +.menu-metric__reset, +.menu-metric__detail { font-size: var(--font-caption); } .menu-card__cost-line, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index ccc2e8fe2f..cebe12e9bd 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -36,7 +36,7 @@ const HAS_DASHBOARD = new Set([ "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "meta", "muse", "nous", + "xai", "zai", "fireworks", "meta", "muse", "nous", "raycast", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ diff --git a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css index e79ac3293a..7e57ed3dea 100644 --- a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css +++ b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css @@ -61,7 +61,8 @@ } :is(.menu-surface--tray, .menu-surface--popout) .menu-metric__pct, -:is(.menu-surface--tray, .menu-surface--popout) .menu-metric__reset { +:is(.menu-surface--tray, .menu-surface--popout) .menu-metric__reset, +:is(.menu-surface--tray, .menu-surface--popout) .menu-metric__detail { font-size: 11px; line-height: 1.4; } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index b2d24dbdfc..e4a3f62c5f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -123,7 +123,7 @@ function UsageBar({ const isInformational = rate.isInformational === true; const formattedReset = useFormattedResetTime( rate.resetsAt, - rate.resetDescription, + rate.detailDescription ? null : rate.resetDescription, resetTimeRelative, ); const resetHint = formattedReset @@ -131,6 +131,7 @@ function UsageBar({ ? formattedReset : `${t("MetricResetsIn")} ${formattedReset}` : null; + const detailDescription = rate.detailDescription?.trim() || null; return (
@@ -161,6 +162,9 @@ function UsageBar({ {!isInformational && resetHint && ( {resetHint} )} + {!isInformational && detailDescription && ( + {detailDescription} + )}
); -} \ No newline at end of file +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index 48c200779c..81ddb8450f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -223,6 +223,7 @@ function providerSourceHintShort( case "notion": case "t3chat": case "commandcode": + case "raycast": return t("ProviderSourceWebShort"); case "gemini": case "antigravity": diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index dda4c24441..2b19c08973 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -77,4 +77,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["meta", "Meta"], ["muse", "Muse Code"], ["nous", "Nous Portal"], + ["raycast", "Raycast"], ]; diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7d..c8a2667564 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -550,6 +550,7 @@ export interface RateWindowSnapshot { windowMinutes: number | null; resetsAt: string | null; resetDescription: string | null; + detailDescription?: string | null; isExhausted: boolean; isInformational?: boolean; reservePercent: number | null; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..2d1e4d87fd 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -45,6 +45,18 @@ Settings → **Providers** → provider detail → choose browser → Import. Manual cookie header paste is the fallback (required under WSL for Chromium DPAPI). Details: [COOKIES.md](./COOKIES.md). +### Raycast credits + +Raycast reads the unofficial `frontend_api/current_user/ai_credits` route of +`www.raycast.com` with the `__raycast_session` and `csrf_token` cookies only. +Auto imports those cookies from Chrome (all profiles, exact `www.raycast.com` +host first); Windows Chrome App-Bound Encryption can block automatic import, in +which case paste a Cookie header under Manual. A manual header is pinned and +never falls back to a browser, and Off makes no request. When the response has +a positive allowance and a known balance the provider shows one "credits left" +meter with the renewal date; otherwise it shows the balance and total as detail +rows. Top-up packages and credit details are not fetched. + ### Replicate billing Replicate uses the signed-in `replicate.com` session cookie for its billing diff --git a/rust/src/cli/serve/dashboard/icons.rs b/rust/src/cli/serve/dashboard/icons.rs index 6ac230cc63..e341bb920a 100644 --- a/rust/src/cli/serve/dashboard/icons.rs +++ b/rust/src/cli/serve/dashboard/icons.rs @@ -265,6 +265,10 @@ static ICONS: &[(&str, &[u8])] = &[ "ProviderIcon-qwencloud", include_bytes!("icons/ProviderIcon-qwencloud.svg"), ), + ( + "ProviderIcon-raycast", + include_bytes!("icons/ProviderIcon-raycast.svg"), + ), ( "ProviderIcon-replicate", include_bytes!("icons/ProviderIcon-replicate.svg"), diff --git a/rust/src/cli/serve/dashboard/icons/ProviderIcon-raycast.svg b/rust/src/cli/serve/dashboard/icons/ProviderIcon-raycast.svg new file mode 100644 index 0000000000..851925356d --- /dev/null +++ b/rust/src/cli/serve/dashboard/icons/ProviderIcon-raycast.svg @@ -0,0 +1,3 @@ + + + diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index cc954839f7..7b93a9cf0b 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -93,6 +93,7 @@ pub enum ProviderId { Muse, Replicate, Nous, + Raycast, } impl ProviderId { @@ -179,6 +180,7 @@ impl ProviderId { ProviderId::Muse, ProviderId::Replicate, ProviderId::Nous, + ProviderId::Raycast, ] } @@ -226,6 +228,7 @@ impl ProviderId { ProviderId::Meta => "meta", ProviderId::Muse => "muse", ProviderId::Nous => "nous", + ProviderId::Raycast => "raycast", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -313,6 +316,7 @@ impl ProviderId { ProviderId::Meta => "Meta", ProviderId::Muse => "Muse Code", ProviderId::Nous => "Nous Portal", + ProviderId::Raycast => "Raycast", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -395,6 +399,7 @@ impl ProviderId { ProviderId::Sakana => Some("console.sakana.ai"), ProviderId::LongCat => Some("longcat.chat"), ProviderId::Replicate => Some("replicate.com"), + ProviderId::Raycast => Some("www.raycast.com"), // Token-based providers (don't use cookies) ProviderId::Copilot => None, ProviderId::Zai => None, @@ -546,6 +551,7 @@ impl ProviderId { "zoommate" | "zoom-mate" | "zoom mate" => Some(ProviderId::ZoomMate), "notion" | "notion-ai" | "notionai" | "notion ai" => Some(ProviderId::Notion), "replicate" | "r8" => Some(ProviderId::Replicate), + "raycast" | "raycast-ai" => Some(ProviderId::Raycast), _ => None, } } @@ -1106,6 +1112,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Muse => "#0668E1", ProviderId::Replicate => "#000000", ProviderId::Nous => "#D6A55C", + ProviderId::Raycast => "#FF6363", } } @@ -1120,7 +1127,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 80); + assert_eq!(all.len(), 81); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1181,6 +1188,7 @@ mod tests { assert!(all.contains(&ProviderId::Replicate)); assert!(all.contains(&ProviderId::Muse)); assert!(all.contains(&ProviderId::Nous)); + assert!(all.contains(&ProviderId::Raycast)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 712c1d4fb9..3955652442 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -19,10 +19,10 @@ use crate::providers::{ MistralProvider, MuseProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, NousProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PiProvider, PoeProvider, QoderProvider, - QwenCloudProvider, ReplicateProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, - T3ChatProvider, TypeSafeProvider, V0Provider, VeniceProvider, VertexAIProvider, WarpProvider, - WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, - ZoomMateProvider, + QwenCloudProvider, RaycastProvider, ReplicateProvider, SakanaProvider, StepFunProvider, + Sub2ApiProvider, T3ChatProvider, TypeSafeProvider, V0Provider, VeniceProvider, + VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, + ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -111,6 +111,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Meta => Box::new(MetaProvider::new()), ProviderId::Muse => Box::new(MuseProvider::new()), ProviderId::Nous => Box::new(NousProvider::new()), + ProviderId::Raycast => Box::new(RaycastProvider::new()), } } diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index 1e943df983..e2bac44b39 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -88,6 +88,10 @@ pub struct RateWindow { #[serde(skip_serializing_if = "Option::is_none")] pub reset_description: Option, + /// Additional display text that should appear separately from reset time. + #[serde(skip)] + pub(crate) detail_description: Option, + /// Whether this row is an informational value rather than a quota. #[serde(default)] pub is_informational: bool, @@ -111,6 +115,7 @@ impl RateWindow { window_minutes: None, resets_at: None, reset_description: None, + detail_description: None, is_informational: false, usage_known: true, } @@ -151,11 +156,24 @@ impl RateWindow { window_minutes, resets_at, reset_description, + detail_description: None, is_informational: false, usage_known: true, } } + /// Optional detail line that is independent from the reset schedule. + pub fn detail_description(&self) -> Option<&str> { + self.detail_description.as_deref() + } + + /// Attach a detail line (for example a credit balance) that surfaces show + /// under the meter, separately from the reset time. + pub fn with_detail_description(mut self, detail: impl Into) -> Self { + self.detail_description = Some(detail.into()); + self + } + pub(crate) fn with_usage_known(mut self, usage_known: bool) -> Self { self.usage_known = usage_known; self diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 6dedc0fbde..e83354bb86 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -377,6 +377,7 @@ impl TokenAccountSupport { | ProviderId::Fireworks | ProviderId::Meta | ProviderId::Nous + | ProviderId::Raycast | ProviderId::Muse => None, } } diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac4..2377fba405 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -539,6 +539,9 @@ locale_keys! { ProviderCodexNotRunningHelp, ProviderCookieSource, CookieSourceManual, + ProviderRaycastAutoImportHelp, + ProviderRaycastManualCookieHelp, + ProviderRaycastCookiesDisabled, ProviderRegion, ProviderClaudeCookies, ProviderClaudeCookiesHelp, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8..08782345be 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -881,3 +881,6 @@ ClaudeSwapHistoricalUsage = Last known usage ClaudeSwapHistoricalCapturedAt = captured ClaudeSwapSpend = Spend ClaudeSwapDisabled = Disabled in claude-swap +ProviderRaycastAutoImportHelp = Automatically imports the signed-in www.raycast.com session from Chrome. +ProviderRaycastManualCookieHelp = Paste a Cookie header from www.raycast.com/settings. +ProviderRaycastCookiesDisabled = Raycast cookies are disabled. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 38dc0b05ae..a232c42d7c 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -707,3 +707,6 @@ ProviderAccentColor = Color de acento ProviderAccentColorHelper = Anula el color de marca usado en barras de uso y gráficos. Introduce un color hexadecimal como #FF5733. ProviderAccentColorReset = Restablecer predeterminado ProviderAccentColorInvalid = Color hexadecimal no válido. Usa el formato #RRGGBB, por ejemplo #FF5733. +ProviderRaycastAutoImportHelp = Importa automáticamente desde Chrome la sesión iniciada en www.raycast.com. +ProviderRaycastManualCookieHelp = Pega un encabezado Cookie de www.raycast.com/settings. +ProviderRaycastCookiesDisabled = Las cookies de Raycast están desactivadas. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b6c2dc0fc8..504b892427 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -773,3 +773,6 @@ ProviderAccentColor = アクセントカラー ProviderAccentColorHelper = 使用量バーとチャートのブランドカラーを上書きします。#FF5733 のような 16 進数カラーを入力。 ProviderAccentColorReset = デフォルトにリセット ProviderAccentColorInvalid = 無効な 16 進数カラー。#RRGGBB 形式(例: #FF5733)を使用してください。 +ProviderRaycastAutoImportHelp = Chrome からサインイン済みの www.raycast.com セッションを自動でインポートします。 +ProviderRaycastManualCookieHelp = www.raycast.com/settings の Cookie ヘッダーを貼り付けます。 +ProviderRaycastCookiesDisabled = Raycast の Cookie は無効です。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 299dcf1a54..26c8a02e70 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -694,3 +694,6 @@ ProviderAccentColor = 강조 색상 ProviderAccentColorHelper = 사용량 막대와 차트에 사용되는 브랜드 색상을 재정의합니다. #FF5733 같은 16진수 색상을 입력하세요. ProviderAccentColorReset = 기본값으로 재설정 ProviderAccentColorInvalid = 잘못된 16진수 색상입니다. #RRGGBB 형식(예: #FF5733)을 사용하세요. +ProviderRaycastAutoImportHelp = Chrome에서 로그인된 www.raycast.com 세션을 자동으로 가져옵니다. +ProviderRaycastManualCookieHelp = www.raycast.com/settings의 Cookie 헤더를 붙여넣습니다. +ProviderRaycastCookiesDisabled = Raycast 쿠키가 비활성화되었습니다. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index f978d540e7..dbee58bccb 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -751,3 +751,6 @@ ProviderAccentColor = Акцентный цвет ProviderAccentColorHelper = Переопределите фирменный цвет для полос использованя и графиков. Введите HEX-цвет, например #FF5733. ProviderAccentColorReset = Сбросить по умолчанию ProviderAccentColorInvalid = Недопустимый HEX-цвет. Используйте формат #RRGGBB, например #FF5733. +ProviderRaycastAutoImportHelp = Автоматически импортирует сеанс www.raycast.com, открытый в Chrome. +ProviderRaycastManualCookieHelp = Вставьте заголовок Cookie со страницы www.raycast.com/settings. +ProviderRaycastCookiesDisabled = Файлы cookie Raycast отключены. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 08de23e2e3..7da7c1c5ab 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -775,3 +775,6 @@ DeepSeekPricingCurrent = Geçerli yerel saat: DeepSeekPricingNext = Sonraki geçiş: DeepSeekPricingEffective = Geçerli olduğu yerel saat: DeepSeekPricingAdvice = Resmî tarife: yoğun saatler UTC 01:00-04:00 ve 06:00-10:00; yoğun olmayan saatlerde yarı fiyat. +ProviderRaycastAutoImportHelp = Chrome'da açık olan www.raycast.com oturumunu otomatik olarak içe aktarır. +ProviderRaycastManualCookieHelp = www.raycast.com/settings adresinden bir Cookie başlığı yapıştırın. +ProviderRaycastCookiesDisabled = Raycast çerezleri devre dışı. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index daf8e3c9af..745e43a40c 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -772,3 +772,6 @@ ProviderAccentColor = 强调色 ProviderAccentColorHelper = 覆盖用于用量条和图表的品牌颜色。输入十六进制颜色,如 #FF5733。 ProviderAccentColorReset = 恢复默认 ProviderAccentColorInvalid = 无效的十六进制颜色。请使用 #RRGGBB 格式,例如 #FF5733。 +ProviderRaycastAutoImportHelp = 自动从 Chrome 导入已登录的 www.raycast.com 会话。 +ProviderRaycastManualCookieHelp = 粘贴来自 www.raycast.com/settings 的 Cookie 标头。 +ProviderRaycastCookiesDisabled = Raycast Cookie 已禁用。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 31bb3bcf02..e1a039aeff 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -772,3 +772,6 @@ ProviderAccentColor = 強調色 ProviderAccentColorHelper = 覆蓑用於用量條和圖表的品牌顏色。輸入十六進位顏色,如 #FF5733。 ProviderAccentColorReset = 恢復預設 ProviderAccentColorInvalid = 無效的十六進位顏色。請使用 #RRGGBB 格式,例如 #FF5733。 +ProviderRaycastAutoImportHelp = 自動從 Chrome 匯入已登入的 www.raycast.com 工作階段。 +ProviderRaycastManualCookieHelp = 貼上來自 www.raycast.com/settings 的 Cookie 標頭。 +ProviderRaycastCookiesDisabled = Raycast Cookie 已停用。 diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index c1f8793c21..49dd66383d 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -495,6 +495,7 @@ impl UsableQuota { window_minutes: None, resets_at: reset, reset_description, + detail_description: None, is_informational: false, usage_known: true, } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index de9d265f6e..9231b6bf18 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -72,6 +72,7 @@ pub mod pi; pub mod poe; pub mod qoder; pub mod qwencloud; +pub mod raycast; pub mod replicate; pub mod sakana; pub mod stepfun; @@ -154,6 +155,7 @@ pub use pi::PiProvider; pub use poe::PoeProvider; pub use qoder::QoderProvider; pub use qwencloud::QwenCloudProvider; +pub use raycast::RaycastProvider; pub use replicate::ReplicateProvider; pub use sakana::SakanaProvider; pub use stepfun::StepFunProvider; @@ -269,6 +271,18 @@ pub(crate) fn browser_cookies_for_domain( crate::browser::cookies::get_cookies_for_domain(domain).map_err(map_browser_cookie_error) } +/// Cookies for `domain` from one browser only, so a provider can avoid +/// touching unrelated browsers (and their credential prompts). +pub(crate) fn browser_cookies_from_browser( + browser_type: crate::browser::detection::BrowserType, + domain: &str, +) -> Result, crate::core::ProviderError> { + let browser = crate::browser::detection::BrowserDetector::detect(browser_type) + .ok_or(crate::core::ProviderError::NoCookies)?; + crate::browser::cookies::CookieExtractor::extract_for_domain(&browser, domain) + .map_err(map_browser_cookie_error) +} + fn map_browser_cookie_error( error: crate::browser::cookies::CookieError, ) -> crate::core::ProviderError { diff --git a/rust/src/providers/raycast/cookies.rs b/rust/src/providers/raycast/cookies.rs new file mode 100644 index 0000000000..ecc733efa6 --- /dev/null +++ b/rust/src/providers/raycast/cookies.rs @@ -0,0 +1,81 @@ +//! Raycast website session cookie selection. +//! +//! Only `__raycast_session` (required) and `csrf_token` (optional) are ever +//! forwarded to Raycast; every other cookie in a pasted header or a browser +//! profile is dropped before a request is built. + +use crate::browser::cookies::Cookie; + +pub(super) const SESSION_COOKIE: &str = "__raycast_session"; +const CSRF_COOKIE: &str = "csrf_token"; + +/// Domain handed to the browser extractor. It matches `www.raycast.com`, +/// `raycast.com`, and other subdomains; [`host_rank`] then keeps only the two +/// hosts the credits route accepts. +pub(super) const EXTRACT_DOMAIN: &str = "raycast.com"; + +/// Lower is more specific. `None` excludes hosts such as `backend.raycast.com` +/// whose cookies must never reach the website credits route. +fn host_rank(cookie_domain: &str) -> Option { + let host = cookie_domain + .trim() + .trim_start_matches('.') + .trim_end_matches('.') + .to_ascii_lowercase(); + match host.as_str() { + "www.raycast.com" => Some(0), + "raycast.com" => Some(1), + _ => None, + } +} + +fn usable_value(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty() && !value.chars().any(|ch| ch.is_control() || ch == ';')).then_some(value) +} + +fn cookie_header(session: &str, csrf: Option<&str>) -> String { + match csrf { + Some(csrf) => format!("{SESSION_COOKIE}={session}; {CSRF_COOKIE}={csrf}"), + None => format!("{SESSION_COOKIE}={session}"), + } +} + +/// Cookie headers to try, most specific host first, one per distinct session +/// value. An exact-host `www.raycast.com` cookie always outranks a same-name +/// parent-domain cookie, and the CSRF cookie follows the same precedence. +pub(super) fn browser_candidates(cookies: &[Cookie]) -> Vec { + let ranked = |name: &str| { + let mut found: Vec<(u8, &str)> = cookies + .iter() + .filter(|cookie| cookie.name == name) + .filter_map(|cookie| Some((host_rank(&cookie.domain)?, usable_value(&cookie.value)?))) + .collect(); + found.sort_by_key(|(rank, _)| *rank); + found + }; + let csrf = ranked(CSRF_COOKIE).first().map(|(_, value)| *value); + let mut seen: Vec<&str> = Vec::new(); + let mut headers = Vec::new(); + for (_, session) in ranked(SESSION_COOKIE) { + if !seen.contains(&session) { + seen.push(session); + headers.push(cookie_header(session, csrf)); + } + } + headers +} + +/// Reduce a pasted header to the two forwarded cookies. Returns `None` without +/// a non-empty `__raycast_session`. The first occurrence of a name wins, which +/// matches the most specific cookie a browser sends. +pub(super) fn manual_header(raw: &str) -> Option { + let normalized = crate::providers::normalize_cookie_header(raw)?; + let first = |name: &str| { + crate::providers::cookie_values(&normalized, name) + .into_iter() + .next() + }; + let session = first(SESSION_COOKIE)?; + Some(cookie_header(session, first(CSRF_COOKIE))) +} diff --git a/rust/src/providers/raycast/mod.rs b/rust/src/providers/raycast/mod.rs new file mode 100644 index 0000000000..8cd578462a --- /dev/null +++ b/rust/src/providers/raycast/mod.rs @@ -0,0 +1,233 @@ +//! Raycast AI credits provider. +//! +//! Raycast has no public credits API for a website session, so this provider +//! calls the unofficial `frontend_api/current_user/ai_credits` route with the +//! `__raycast_session` and `csrf_token` cookies only. Automatic import reads +//! Chrome alone to avoid unrelated browser prompts; a Manual Cookie header is +//! pinned and never falls back to a browser; Off makes no request. Cookie +//! material stays in memory for the current fetch and is never logged. + +mod cookies; +mod parse; +#[cfg(test)] +mod tests; + +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use std::time::Duration; + +use crate::browser::detection::BrowserType; +use crate::core::{ + FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, + ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const ORIGIN: &str = "https://www.raycast.com"; +const SETTINGS_URL: &str = "https://www.raycast.com/settings"; +const CREDITS_PATH: &str = "/frontend_api/current_user/ai_credits"; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const MIN_TIMEOUT_SECONDS: u64 = 1; +const MAX_TIMEOUT_SECONDS: u64 = 30; +const BROWSER: BrowserType = BrowserType::Chrome; + +const MISSING_SESSION: &str = "No Raycast session cookies found. Sign in at www.raycast.com/settings or paste a Cookie header."; +const SESSION_EXPIRED: &str = "Raycast website session expired. Sign in at www.raycast.com/settings or paste a fresh Cookie header."; +const COOKIES_DISABLED: &str = + "Raycast cookies are disabled. Set the cookie source to Auto or Manual to read credits."; + +pub struct RaycastProvider { + metadata: ProviderMetadata, + client: Client, + origin: String, +} + +impl RaycastProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Raycast, + display_name: "Raycast", + session_label: "Credits", + weekly_label: "Plan", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(SETTINGS_URL), + status_page_url: None, + tertiary_label_key: None, + }, + client: crate::core::credentialed_http_client_builder() + .build() + .unwrap_or_else(|_| Client::new()), + origin: ORIGIN.to_string(), + } + } + + #[cfg(test)] + fn with_origin(origin: &str) -> Self { + Self { + client: crate::core::credentialed_http_client_builder() + .no_proxy() + .build() + .expect("the test client should build"), + origin: origin.to_string(), + ..Self::new() + } + } + + /// Try each cookie header in order. A candidate rejected with HTTP 401 + /// advances to the next one within the same refresh; any other outcome + /// (success or a non-authentication failure) ends the search so a + /// permission, rate-limit, or service error never rejects a session. + async fn fetch_candidates( + &self, + headers: &[String], + source_label: &str, + timeout: Duration, + ) -> Result { + let mut rejected = false; + for header in headers { + let response = self + .client + .get(format!("{}{CREDITS_PATH}", self.origin)) + .timeout(timeout) + .header("Cookie", header) + .header("Accept", "application/json") + .header("Origin", ORIGIN) + .header("Referer", SETTINGS_URL) + .header("User-Agent", USER_AGENT) + .send() + .await?; + let status = response.status(); + if status == StatusCode::UNAUTHORIZED { + rejected = true; + continue; + } + check_status(status)?; + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::TooLarge => { + ProviderError::Parse("Raycast returned an oversized response.".into()) + } + BoundedBodyError::Read(error) => ProviderError::Network(error), + })?; + let body = String::from_utf8(body).map_err(|_| { + ProviderError::Parse("Raycast returned a response that was not valid UTF-8.".into()) + })?; + return Ok(parse::parse_credits(&body)?.into_result(source_label)); + } + Err(ProviderError::Other( + if rejected { + SESSION_EXPIRED + } else { + MISSING_SESSION + } + .to_string(), + )) + } + + async fn fetch_web(&self, ctx: &FetchContext) -> Result { + let timeout = request_timeout(ctx.web_timeout); + if let Some(raw) = ctx.manual_cookie_header.as_deref() { + let headers: Vec = cookies::manual_header(raw).into_iter().collect(); + return self.fetch_candidates(&headers, "manual", timeout).await; + } + // Manual is selected but nothing is stored: fail closed rather than + // importing a browser account the user did not choose. + if ctx.manual_cookie_missing { + return Err(ProviderError::Other(MISSING_SESSION.to_string())); + } + let extracted = tokio::task::spawn_blocking(|| { + crate::providers::browser_cookies_from_browser(BROWSER, cookies::EXTRACT_DOMAIN) + }) + .await + .map_err(|_| ProviderError::Other("Raycast cookie import was interrupted.".into()))?; + let headers = match extracted { + Ok(found) => cookies::browser_candidates(&found), + Err(ProviderError::NoCookies) => Vec::new(), + Err(error) => return Err(error), + }; + self.fetch_candidates(&headers, BROWSER.display_name(), timeout) + .await + } +} + +impl Default for RaycastProvider { + fn default() -> Self { + Self::new() + } +} + +fn request_timeout(web_timeout_seconds: u64) -> Duration { + Duration::from_secs(web_timeout_seconds.clamp(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS)) +} + +fn check_status(status: StatusCode) -> Result<(), ProviderError> { + let message = match status { + StatusCode::OK => return Ok(()), + StatusCode::FORBIDDEN => "Raycast denied access to AI credits for this account.".into(), + StatusCode::TOO_MANY_REQUESTS => "Raycast credits requests are rate limited.".into(), + status if status.is_server_error() => { + format!("Raycast credits service is unavailable (HTTP {status}).") + } + status => format!("Raycast credits API returned HTTP {status}."), + }; + Err(ProviderError::Other(message)) +} + +#[async_trait] +impl Provider for RaycastProvider { + fn id(&self) -> ProviderId { + ProviderId::Raycast + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx).await, + // The shell maps the Off cookie source to Cli: no cookie access and + // no request. + SourceMode::Cli => Err(ProviderError::Other(COOKIES_DISABLED.to_string())), + source => Err(ProviderError::UnsupportedSource(source)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web] + } + + fn supports_web(&self) -> bool { + true + } + + fn manual_cookie_precedes_token_account(&self) -> bool { + true + } + + fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { + ManualEmptyCookiePolicy::FailClosedWeb + } + + fn owns_browser_cookie_resolution(&self) -> bool { + true + } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + match error { + ProviderError::Other(message) if message == MISSING_SESSION => { + ProviderStateKind::NeedsAuthentication + } + ProviderError::Other(message) if message == SESSION_EXPIRED => { + ProviderStateKind::ExpiredSession + } + _ => error.state_kind(), + } + } +} diff --git a/rust/src/providers/raycast/parse.rs b/rust/src/providers/raycast/parse.rs new file mode 100644 index 0000000000..751d699858 --- /dev/null +++ b/rust/src/providers/raycast/parse.rs @@ -0,0 +1,189 @@ +//! Raycast AI credits payload parsing and snapshot mapping. +//! +//! Amounts arrive as numbers or numeric strings. A malformed amount is a parse +//! failure, never an exact zero. + +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use crate::core::{ + ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, SubscriptionMetadata, + UsageSnapshot, +}; + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Credits { + remaining: Option, + total: Option, + renewal: Option>, + plan: Option, +} + +fn invalid(field: &str) -> ProviderError { + ProviderError::Parse(format!("Invalid Raycast credits response: {field}")) +} + +/// Accepts non-negative JSON numbers and strings shaped like +/// `[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?`. The character screen keeps +/// Rust's float parser from accepting `inf` or `nan`. +fn amount(value: Option<&Value>, field: &str) -> Result, ProviderError> { + let parsed = match value { + None | Some(Value::Null) => return Ok(None), + Some(Value::Number(number)) => number.as_f64(), + Some(Value::String(text)) => { + let text = text.trim(); + let plausible = text + .chars() + .all(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E')); + if plausible { + text.parse::().ok() + } else { + None + } + } + Some(_) => None, + }; + parsed + .filter(|number| number.is_finite() && *number >= 0.0) + .map(Some) + .ok_or_else(|| invalid(field)) +} + +fn plan_label(tier: &str) -> String { + match tier { + "pro" => "Pro".to_string(), + "pro_plus" => "Pro+".to_string(), + "max" => "Max".to_string(), + other => other.to_string(), + } +} + +pub(super) fn parse_credits(body: &str) -> Result { + let root: Value = serde_json::from_str(body).map_err(|_| invalid("expected JSON"))?; + let root = root + .as_object() + .ok_or_else(|| invalid("expected an object"))?; + let field = |name: &str| root.get(name); + + let remaining = amount( + field("remaining_balance_credits"), + "remaining_balance_credits", + )?; + let total = amount(field("total_balance_credits"), "total_balance_credits")?; + if remaining.is_none() && total.is_none() { + return Err(invalid("no credit amounts")); + } + + let renewal = match field("next_credits_at") { + None | Some(Value::Null) => None, + Some(Value::String(raw)) => Some( + DateTime::parse_from_rfc3339(raw.trim()) + .map(|date| date.with_timezone(&Utc)) + .map_err(|_| invalid("next_credits_at"))?, + ), + Some(_) => return Err(invalid("next_credits_at")), + }; + + let funding = match field("funding_subscription") { + None | Some(Value::Null) => None, + Some(Value::Object(map)) => Some(map), + Some(_) => return Err(invalid("funding_subscription")), + }; + let plan = funding + .and_then(|map| map.get("tier")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|tier| !tier.is_empty()) + .map(plan_label); + + Ok(Credits { + remaining, + total, + renewal, + plan, + }) +} + +/// No thousands grouping: the meter footnote must match the source amount. +fn format_amount(value: f64) -> String { + if value == 0.0 { + return "0".to_string(); + } + if value.fract() == 0.0 { + return format!("{value}"); + } + let fixed = format!("{value:.2}"); + fixed + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + +impl Credits { + /// A positive total with a known balance is one meter; otherwise Left and + /// Total rows are the only place the amounts appear. + fn meter(&self) -> Option<(f64, f64)> { + match (self.remaining, self.total) { + (Some(remaining), Some(total)) if total > 0.0 => Some((remaining, total)), + _ => None, + } + } + + fn summary(&self) -> String { + match (self.remaining, self.total) { + (Some(remaining), Some(total)) if total > 0.0 => { + format!( + "{} / {} credits left", + format_amount(remaining), + format_amount(total) + ) + } + (Some(remaining), _) => format!("{} credits left", format_amount(remaining)), + (None, Some(total)) => format!("{} credits total", format_amount(total)), + (None, None) => String::new(), + } + } + + pub(super) fn into_result(self, source_label: &str) -> ProviderFetchResult { + let description = self.summary(); + let meter = self.meter(); + let primary = match meter { + Some((remaining, total)) => { + let used = (total - remaining).max(0.0); + RateWindow::with_details( + (used / total * 100.0).clamp(0.0, 100.0), + None, + self.renewal, + Some(description.clone()), + ) + .with_detail_description(description) + } + None => RateWindow::informational(description), + }; + let mut usage = UsageSnapshot::new(primary); + if let Some(plan) = &self.plan { + usage = usage.with_login_method(plan.clone()); + } + if meter.is_none() && self.renewal.is_some() { + usage = + usage.with_subscription(Some(SubscriptionMetadata::new(None, None, self.renewal))); + } + + let mut result = ProviderFetchResult::new(usage, source_label); + if meter.is_none() { + for (id, title, value) in [ + ("credits-left", "Left", self.remaining), + ("credits-total", "Total", self.total), + ] { + if let Some(value) = value { + result = result.with_display_detail(ProviderDisplayDetail::new( + id, + title, + format_amount(value), + )); + } + } + } + result + } +} diff --git a/rust/src/providers/raycast/tests.rs b/rust/src/providers/raycast/tests.rs new file mode 100644 index 0000000000..7ef05233db --- /dev/null +++ b/rust/src/providers/raycast/tests.rs @@ -0,0 +1,708 @@ +use super::*; +use crate::browser::cookies::Cookie; +use chrono::TimeZone; +use parse::parse_credits; + +const CREDITS: &str = r#"{ + "remaining_balance_credits": "125", + "total_balance_credits": "500", + "next_credits_at": "2026-10-18T00:00:00.000Z", + "can_top_up": true, + "can_upgrade_plan": true, + "funding_subscription": { + "tier": "pro", + "source": "personal", + "provider": "stripe", + "status": "active", + "canceled": false + } +}"#; +const FIXTURE_COOKIE: &str = "__raycast_session=fixture-session; csrf_token=fixture-csrf"; + +fn result(body: &str) -> ProviderFetchResult { + parse_credits(body) + .expect("fixture parses") + .into_result("web") +} + +fn cookie(name: &str, value: &str, domain: &str) -> Cookie { + Cookie { + name: name.to_string(), + value: value.to_string(), + domain: domain.to_string(), + path: "/".to_string(), + expires: None, + is_secure: true, + is_http_only: true, + } +} + +fn detail_pairs(result: &ProviderFetchResult) -> Vec<(&str, &str)> { + result + .display_details() + .iter() + .map(|row| (row.title(), row.value())) + .collect() +} + +fn ctx(source_mode: SourceMode, manual: Option<&str>) -> FetchContext { + FetchContext { + source_mode, + manual_cookie_header: manual.map(str::to_string), + ..FetchContext::default() + } +} + +// ---- parsing and mapping ------------------------------------------------- + +#[test] +fn monthly_credits_become_one_meter_with_the_remaining_balance() { + let result = result(CREDITS); + let primary = &result.usage.primary; + assert_eq!(primary.used_percent, 75.0); + assert_eq!(primary.window_minutes, None); + assert!(!primary.is_informational); + assert_eq!( + primary.resets_at, + Some(chrono::Utc.with_ymd_and_hms(2026, 10, 18, 0, 0, 0).unwrap()) + ); + assert_eq!( + primary.reset_description.as_deref(), + Some("125 / 500 credits left") + ); + assert_eq!(primary.detail_description(), Some("125 / 500 credits left")); + assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); + assert!(result.usage.subscription.is_none()); + assert!(result.display_details().is_empty()); + assert!(result.cost.is_none()); +} + +#[test] +fn website_account_payload_maps_left_and_total() { + let result = result( + r#"{"remaining_balance_credits":"337.3751","total_balance_credits":"500.0", + "next_credits_at":"2026-10-18T08:34:44Z", + "funding_subscription":{"tier":"pro","status":"active"}}"#, + ); + assert!((result.usage.primary.used_percent - 32.525).abs() < 0.01); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("337.38 / 500 credits left") + ); + assert!(result.display_details().is_empty()); + assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); +} + +#[test] +fn numeric_amounts_and_plan_labels_are_preserved() { + let result = result( + r#"{"remaining_balance_credits":12.5,"total_balance_credits":50, + "funding_subscription":{"tier":"pro_plus"}}"#, + ); + assert_eq!(result.usage.primary.used_percent, 75.0); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("12.5 / 50 credits left") + ); + assert_eq!(result.usage.login_method.as_deref(), Some("Pro+")); + + let max = self::result( + r#"{"remaining_balance_credits":1,"total_balance_credits":2,"funding_subscription":{"tier":"max"}}"#, + ); + assert_eq!(max.usage.login_method.as_deref(), Some("Max")); + let other = self::result( + r#"{"remaining_balance_credits":1,"total_balance_credits":2,"funding_subscription":{"tier":" team "}}"#, + ); + assert_eq!(other.usage.login_method.as_deref(), Some("team")); + let none = self::result( + r#"{"remaining_balance_credits":1,"total_balance_credits":2,"funding_subscription":{"tier":""}}"#, + ); + assert_eq!(none.usage.login_method, None); +} + +#[test] +fn rollover_above_the_grant_does_not_invent_usage() { + let result = result(r#"{"remaining_balance_credits":"750","total_balance_credits":"500"}"#); + assert_eq!(result.usage.primary.used_percent, 0.0); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("750 / 500 credits left") + ); + assert!(result.display_details().is_empty()); +} + +#[test] +fn zero_allowance_keeps_rows_and_renewal_without_a_meter() { + let result = result( + r#"{"remaining_balance_credits":"0","total_balance_credits":"0", + "next_credits_at":"2026-10-18T00:00:00.000Z","funding_subscription":{"tier":"max"}}"#, + ); + assert!(result.usage.primary.is_informational); + assert_eq!(result.usage.primary.resets_at, None); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("0 credits left") + ); + assert_eq!(result.usage.primary.detail_description(), None); + assert_eq!(detail_pairs(&result), vec![("Left", "0"), ("Total", "0")]); + assert_eq!( + result.usage.subscription.as_ref().and_then(|s| s.renews_at), + Some(chrono::Utc.with_ymd_and_hms(2026, 10, 18, 0, 0, 0).unwrap()) + ); + assert_eq!(result.usage.login_method.as_deref(), Some("Max")); +} + +#[test] +fn a_zero_or_missing_total_does_not_appear_in_the_balance_summary() { + let zero_total = result(r#"{"remaining_balance_credits":"12.5","total_balance_credits":"0"}"#); + assert_eq!( + zero_total.usage.primary.reset_description.as_deref(), + Some("12.5 credits left") + ); + assert_eq!( + detail_pairs(&zero_total), + vec![("Left", "12.5"), ("Total", "0")] + ); + + let missing_total = result(r#"{"remaining_balance_credits":"12.5"}"#); + assert_eq!( + missing_total.usage.primary.reset_description.as_deref(), + Some("12.5 credits left") + ); + assert_eq!(detail_pairs(&missing_total), vec![("Left", "12.5")]); +} + +#[test] +fn a_single_amount_reports_only_that_row() { + let left = result(r#"{"remaining_balance_credits":"12.345"}"#); + assert_eq!(detail_pairs(&left), vec![("Left", "12.35")]); + assert!(left.usage.primary.is_informational); + + let total = result(r#"{"total_balance_credits":500}"#); + assert_eq!(detail_pairs(&total), vec![("Total", "500")]); +} + +#[test] +fn amounts_use_no_thousands_grouping_and_trim_trailing_zeros() { + let result = + result(r#"{"remaining_balance_credits":"1234.50","total_balance_credits":"12345"}"#); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("1234.5 / 12345 credits left") + ); +} + +#[test] +fn invalid_amounts_fail_instead_of_publishing_zero() { + for value in [ + "true", + "false", + "\"NaN\"", + "\"Infinity\"", + "\"inf\"", + "\"\"", + "\" \"", + "[]", + "{}", + "1e400", + "-1", + "\"-0.5\"", + "\"1,000\"", + "\"12abc\"", + "\"0x10\"", + ] { + let body = format!(r#"{{"remaining_balance_credits":{value}}}"#); + assert!( + matches!(parse_credits(&body), Err(ProviderError::Parse(_))), + "{value}" + ); + let body = format!(r#"{{"total_balance_credits":{value}}}"#); + assert!( + matches!(parse_credits(&body), Err(ProviderError::Parse(_))), + "{value}" + ); + } +} + +#[test] +fn numeric_string_forms_match_the_upstream_grammar() { + for (raw, expected) in [ + ("\"7\"", "7"), + ("\" 7.25 \"", "7.25"), + ("\"+3\"", "3"), + ("\".5\"", "0.5"), + ("\"1e2\"", "100"), + ("\"1.5E1\"", "15"), + ] { + let body = format!(r#"{{"remaining_balance_credits":{raw}}}"#); + assert_eq!( + detail_pairs(&result(&body)), + vec![("Left", expected)], + "{raw}" + ); + } +} + +#[test] +fn empty_or_malformed_bodies_are_parse_failures() { + for body in [ + "null", + "[]", + "{}", + r#"{"funding_subscription":{}}"#, + "private-response", + "", + r#"{"remaining_balance_credits":null,"total_balance_credits":null}"#, + ] { + assert!( + matches!(parse_credits(body), Err(ProviderError::Parse(_))), + "{body}" + ); + } +} + +#[test] +fn invalid_dates_and_funding_shapes_are_parse_failures() { + for extra in [ + r#""next_credits_at": 1760745600"#, + r#""next_credits_at": "soon""#, + r#""next_credits_at": {}"#, + r#""funding_subscription": []"#, + r#""funding_subscription": "pro""#, + ] { + let body = + format!(r#"{{"remaining_balance_credits":"1","total_balance_credits":"2",{extra}}}"#); + assert!( + matches!(parse_credits(&body), Err(ProviderError::Parse(_))), + "{extra}" + ); + } + let nulls = result( + r#"{"remaining_balance_credits":"1","total_balance_credits":"2","next_credits_at":null,"funding_subscription":null}"#, + ); + assert_eq!(nulls.usage.primary.resets_at, None); +} + +// ---- cookie selection ---------------------------------------------------- + +#[test] +fn manual_header_forwards_only_the_two_raycast_cookies() { + assert_eq!( + cookies::manual_header( + "Cookie: theme=dark; csrf_token=fixture-csrf; other=1; __raycast_session=fixture-session" + ) + .as_deref(), + Some(FIXTURE_COOKIE) + ); + assert_eq!( + cookies::manual_header("__raycast_session=only").as_deref(), + Some("__raycast_session=only") + ); + assert_eq!( + cookies::manual_header("__raycast_session=first; __raycast_session=second").as_deref(), + Some("__raycast_session=first") + ); +} + +#[test] +fn manual_header_requires_a_nonempty_session() { + for raw in [ + "", + "csrf_token=only", + "__raycast_session=; csrf_token=fixture-csrf", + "other=1", + "__raycast_session=bad\r\nvalue", + ] { + assert_eq!(cookies::manual_header(raw), None, "{raw}"); + } +} + +#[test] +fn exact_host_cookie_wins_over_a_parent_domain_cookie() { + let jar = [ + cookie("__raycast_session", "parent", ".raycast.com"), + cookie("csrf_token", "parent-csrf", ".raycast.com"), + cookie("__raycast_session", "exact", "www.raycast.com"), + cookie("csrf_token", "exact-csrf", "www.raycast.com"), + ]; + assert_eq!( + cookies::browser_candidates(&jar), + vec![ + "__raycast_session=exact; csrf_token=exact-csrf".to_string(), + "__raycast_session=parent; csrf_token=exact-csrf".to_string(), + ] + ); +} + +#[test] +fn parent_only_cookies_are_still_used_and_other_hosts_are_ignored() { + let jar = [ + cookie("__raycast_session", "parent", "raycast.com"), + cookie("__raycast_session", "backend", "backend.raycast.com"), + cookie("csrf_token", "backend-csrf", "backend.raycast.com"), + cookie("__raycast_session", "lookalike", "evilraycast.com"), + cookie("unrelated", "x", "www.raycast.com"), + ]; + assert_eq!( + cookies::browser_candidates(&jar), + vec!["__raycast_session=parent".to_string()] + ); +} + +#[test] +fn browser_candidates_skip_empty_and_duplicate_sessions() { + let jar = [ + cookie("__raycast_session", "", "www.raycast.com"), + cookie("__raycast_session", "same", "www.raycast.com"), + cookie("__raycast_session", "same", ".raycast.com"), + cookie("csrf_token", "bad;value", "www.raycast.com"), + ]; + assert_eq!( + cookies::browser_candidates(&jar), + vec!["__raycast_session=same".to_string()] + ); + assert!( + cookies::browser_candidates(&[cookie("csrf_token", "only", "www.raycast.com")]).is_empty() + ); +} + +// ---- provider surface ---------------------------------------------------- + +#[test] +fn provider_metadata_and_sources_are_cookie_only() { + let provider = RaycastProvider::new(); + assert_eq!(provider.id(), ProviderId::Raycast); + assert_eq!(provider.metadata().display_name, "Raycast"); + assert_eq!(provider.metadata().session_label, "Credits"); + assert_eq!( + provider.metadata().dashboard_url, + Some("https://www.raycast.com/settings") + ); + assert!(!provider.metadata().default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::Web] + ); + assert!(provider.supports_web()); + assert!(!provider.supports_cli()); + assert!(provider.owns_browser_cookie_resolution()); + assert_eq!( + provider.manual_empty_cookie_policy(), + ManualEmptyCookiePolicy::FailClosedWeb + ); + assert_eq!(ProviderId::Raycast.cookie_domain(), Some("www.raycast.com")); + assert_eq!( + ProviderId::from_cli_name("raycast-ai"), + Some(ProviderId::Raycast) + ); +} + +#[test] +fn credential_failures_are_classified_for_the_ui() { + let provider = RaycastProvider::new(); + let missing = ProviderError::Other(MISSING_SESSION.to_string()); + let expired = ProviderError::Other(SESSION_EXPIRED.to_string()); + assert_eq!( + provider.error_state_kind(&missing), + ProviderStateKind::NeedsAuthentication + ); + assert_eq!( + provider.error_state_kind(&expired), + ProviderStateKind::ExpiredSession + ); + assert_eq!( + provider.error_state_kind(&ProviderError::Other("other".into())), + ProviderStateKind::Unknown + ); +} + +#[test] +fn request_timeout_is_clamped_to_one_through_thirty_seconds() { + assert_eq!(request_timeout(0), Duration::from_secs(1)); + assert_eq!(request_timeout(15), Duration::from_secs(15)); + assert_eq!(request_timeout(60), Duration::from_secs(30)); +} + +// ---- HTTP behavior (local mock server, no live calls) --------------------- + +fn headers(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +fn error_message(error: ProviderError) -> String { + match error { + ProviderError::Other(message) => message, + other => other.to_string(), + } +} + +#[tokio::test] +async fn request_uses_the_website_route_cookie_and_site_headers() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", FIXTURE_COOKIE) + .match_header("accept", "application/json") + .match_header("origin", "https://www.raycast.com") + .match_header("referer", "https://www.raycast.com/settings") + .match_header("authorization", mockito::Matcher::Missing) + .match_header("user-agent", mockito::Matcher::Regex("Chrome/".into())) + .with_status(200) + .with_body(CREDITS) + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let result = provider + .fetch_candidates( + &headers(&[FIXTURE_COOKIE]), + "manual", + Duration::from_secs(5), + ) + .await + .expect("credits should parse"); + + mock.assert_async().await; + assert_eq!(result.source_label, "manual"); + assert_eq!(result.usage.primary.used_percent, 75.0); +} + +#[tokio::test] +async fn manual_fetch_forwards_only_the_two_raycast_cookies() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", FIXTURE_COOKIE) + .with_status(200) + .with_body(CREDITS) + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let ctx = ctx( + SourceMode::Web, + Some( + "Cookie: theme=dark; csrf_token=fixture-csrf; __raycast_session=fixture-session; _ga=1", + ), + ); + let result = provider.fetch_usage(&ctx).await.expect("manual fetch"); + + mock.assert_async().await; + assert_eq!(result.source_label, "manual"); +} + +#[tokio::test] +async fn rejected_candidates_advance_within_the_same_refresh() { + let mut server = mockito::Server::new_async().await; + let rejected = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", "__raycast_session=rejected-fixture-session") + .with_status(401) + .expect(1) + .create_async() + .await; + let accepted = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", FIXTURE_COOKIE) + .with_status(200) + .with_body(CREDITS) + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let result = provider + .fetch_candidates( + &headers(&["__raycast_session=rejected-fixture-session", FIXTURE_COOKIE]), + "Google Chrome", + Duration::from_secs(5), + ) + .await + .expect("second candidate should be accepted"); + + rejected.assert_async().await; + accepted.assert_async().await; + assert_eq!(result.usage.primary.used_percent, 75.0); +} + +#[tokio::test] +async fn all_candidates_rejected_means_the_session_expired() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .with_status(401) + .expect(2) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let error = provider + .fetch_candidates( + &headers(&["__raycast_session=a", "__raycast_session=b"]), + "Google Chrome", + Duration::from_secs(5), + ) + .await + .expect_err("all sessions rejected"); + + mock.assert_async().await; + assert_eq!(error_message(error), SESSION_EXPIRED); +} + +#[tokio::test] +async fn manual_stops_after_its_single_rejected_candidate() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .with_status(401) + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let error = provider + .fetch_usage(&ctx( + SourceMode::Web, + Some("__raycast_session=rejected-fixture-session"), + )) + .await + .expect_err("manual session rejected"); + + mock.assert_async().await; + assert_eq!(error_message(error), SESSION_EXPIRED); +} + +#[tokio::test] +async fn no_candidates_is_a_missing_credential_without_a_request() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let error = provider + .fetch_candidates(&[], "Google Chrome", Duration::from_secs(5)) + .await + .expect_err("nothing to try"); + assert_eq!(error_message(error), MISSING_SESSION); + + // A manual header without a session cookie never reaches the network. + for manual in ["csrf_token=only", "__raycast_session=; csrf_token=x"] { + let error = provider + .fetch_usage(&ctx(SourceMode::Web, Some(manual))) + .await + .expect_err("no session cookie"); + assert_eq!(error_message(error), MISSING_SESSION); + } + mock.assert_async().await; +} + +#[tokio::test] +async fn empty_manual_selection_fails_closed_without_browser_access() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let mut fetch_context = ctx(SourceMode::Web, None); + fetch_context.manual_cookie_missing = true; + let error = provider + .fetch_usage(&fetch_context) + .await + .expect_err("manual without a cookie"); + + mock.assert_async().await; + assert_eq!(error_message(error), MISSING_SESSION); +} + +#[tokio::test] +async fn off_makes_no_request_and_no_cookie_access() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + // The shell maps the Off cookie source to Cli, and a stored header must + // not be used while cookies are disabled. + let error = provider + .fetch_usage(&ctx(SourceMode::Cli, Some(FIXTURE_COOKIE))) + .await + .expect_err("cookies are disabled"); + + mock.assert_async().await; + assert_eq!(error_message(error), COOKIES_DISABLED); + assert!(matches!( + provider.fetch_usage(&ctx(SourceMode::OAuth, None)).await, + Err(ProviderError::UnsupportedSource(SourceMode::OAuth)) + )); +} + +#[tokio::test] +async fn non_authentication_failures_stop_retries_and_keep_the_session() { + for (status, expected) in [ + (403, "Raycast denied access to AI credits for this account."), + (429, "Raycast credits requests are rate limited."), + (503, "Raycast credits service is unavailable"), + (400, "Raycast credits API returned HTTP 400"), + (204, "Raycast credits API returned HTTP 204"), + ] { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .with_status(status) + .with_body("invalid JSON") + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let error = provider + .fetch_candidates( + &headers(&[FIXTURE_COOKIE, "__raycast_session=other"]), + "Google Chrome", + Duration::from_secs(5), + ) + .await + .expect_err("failure status"); + + mock.assert_async().await; + let message = error_message(error); + assert!(message.contains(expected), "{status}: {message}"); + assert_ne!(message, SESSION_EXPIRED, "{status}"); + } +} + +#[tokio::test] +async fn malformed_success_body_is_a_parse_failure_that_stops_retries() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .with_status(200) + .with_body("invalid JSON") + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let error = provider + .fetch_candidates( + &headers(&[FIXTURE_COOKIE, "__raycast_session=other"]), + "Google Chrome", + Duration::from_secs(5), + ) + .await + .expect_err("malformed body"); + + mock.assert_async().await; + assert!(matches!(error, ProviderError::Parse(_))); +}