From 7057468406a210a2670dd25ea630749ed8adb581 Mon Sep 17 00:00:00 2001 From: Paul Anduschus <157472224+DreamyTalesPAN@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:45:16 +0100 Subject: [PATCH] windsurf: read the Devin Desktop plan cache, no invented expiry date (port of #18 to v0.70.0-vibetv.2) Ports marcus7989/Win-CodexBar#18 (four commits, base `vibetv`) onto the line that VibeTV actually pins (nesszer main + fork patches, v0.70.0-vibetv.2). Only rust/src/providers/windsurf/mod.rs changes. - Reads %APPDATA%\Devin\User\globalStorage\state.vscdb, the flat per-account cachedPlanInfoData: entry (remainingMessages / totalMessages). The Windsurf folder is read only when no Devin database exists. Invalid counters and several cached accounts stay unavailable. - endTimestamp <= 0 means "no end date": no "Expires" text is made up. - A plan cache without any quota is an error, not 0 % (the behaviour of fork PR #15, which this line does not have for Windsurf). This line's infallible snapshot_from_plan became a Result for that. - This line has no ProviderMetadata / Provider::metadata / supports_cli, so those parts of #18 are dropped. Co-Authored-By: Claude Sonnet 5.5 --- rust/src/providers/windsurf/mod.rs | 359 ++++++++++++++++++++++++++--- 1 file changed, 332 insertions(+), 27 deletions(-) diff --git a/rust/src/providers/windsurf/mod.rs b/rust/src/providers/windsurf/mod.rs index a958381c115..7dec04dcdda 100644 --- a/rust/src/providers/windsurf/mod.rs +++ b/rust/src/providers/windsurf/mod.rs @@ -1,14 +1,15 @@ //! Windsurf provider implementation. //! //! Reads Windsurf's cached plan information from its VS Code-style SQLite -//! state database. On Windows this defaults to -//! `%APPDATA%\Windsurf\User\globalStorage\state.vscdb`. +//! state database. Devin Desktop (the renamed Windsurf) writes to +//! `%APPDATA%\Devin\User\globalStorage\state.vscdb`; the old +//! `%APPDATA%\Windsurf\...` database is read only when no Devin one exists. use async_trait::async_trait; use chrono::{DateTime, Utc}; use rusqlite::{Connection, OpenFlags, types::Value as SqlValue}; use serde::Deserialize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, @@ -16,6 +17,8 @@ use crate::core::{ }; const STATE_KEY: &str = "windsurf.settings.cachedPlanInfo"; +/// Devin Desktop keeps one flat plan cache per account under this prefix. +const DEVIN_STATE_KEY_PREFIX: &str = "windsurf.reactSettings.cachedPlanInfoData:"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -24,6 +27,9 @@ struct CachedPlanInfo { end_timestamp: Option, usage: Option, quota_usage: Option, + // Devin Desktop's flat format. + remaining_messages: Option, + total_messages: Option, } #[derive(Debug, Deserialize)] @@ -63,18 +69,28 @@ impl WindsurfProvider { { return Some(PathBuf::from(path)); } - dirs::data_dir().map(|base| { - base.join("Windsurf") + dirs::data_dir().map(|base| Self::db_path_in(&base)) + } + + fn db_path_in(base: &Path) -> PathBuf { + let state_db = |folder: &str| { + base.join(folder) .join("User") .join("globalStorage") .join("state.vscdb") - }) + }; + let devin = state_db("Devin"); + if devin.exists() { + devin + } else { + state_db("Windsurf") + } } fn read_cached_plan(db_path: PathBuf) -> Result { if !db_path.exists() { return Err(ProviderError::NotInstalled(format!( - "Windsurf database not found at {}. Open Windsurf and sign in first.", + "Windsurf database not found at {}. Open Devin Desktop (or Windsurf) and sign in first.", db_path.display() ))); } @@ -86,18 +102,29 @@ impl WindsurfProvider { ProviderError::Other(format!("Failed to configure SQLite timeout: {e}")) })?; - let value: SqlValue = conn - .query_row( - "SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1", - [STATE_KEY], - |row| row.get(0), - ) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => ProviderError::AuthRequired, - other => { - ProviderError::Other(format!("Failed to read Windsurf plan cache: {other}")) - } + let values = conn + .prepare("SELECT value FROM ItemTable WHERE key = ?1 OR key LIKE ?2") + .and_then(|mut stmt| { + stmt.query_map([STATE_KEY, &format!("{DEVIN_STATE_KEY_PREFIX}%")], |row| { + row.get::<_, SqlValue>(0) + })? + .collect::, _>>() + }) + .map_err(|e| { + ProviderError::Other(format!("Failed to read Windsurf plan cache: {e}")) })?; + // Which cached account is signed in is not recorded next to the cache, + // so several of them stay unavailable instead of being guessed. + let [value] = <[SqlValue; 1]>::try_from(values).map_err(|values| { + if values.is_empty() { + ProviderError::AuthRequired + } else { + ProviderError::Other( + "Windsurf plan cache holds several accounts; cannot tell which is active" + .to_string(), + ) + } + })?; let json_text = decode_json_value(value).ok_or_else(|| { ProviderError::Parse("Windsurf plan cache is not valid UTF-8/UTF-16 JSON".to_string()) @@ -107,8 +134,9 @@ impl WindsurfProvider { .map_err(|e| ProviderError::Parse(format!("Failed to parse Windsurf plan cache: {e}"))) } - fn snapshot_from_plan(plan: CachedPlanInfo) -> UsageSnapshot { + fn snapshot_from_plan(plan: CachedPlanInfo) -> Result { let mut primary = None; + let mut primary_label = None; let mut secondary = None; if let Some(quota) = plan.quota_usage { @@ -157,19 +185,37 @@ impl WindsurfProvider { if had_primary { snapshot = snapshot.with_extra_rate_window("flex", "Flex Credits", flex); } - return with_identity(snapshot, plan.plan_name, plan.end_timestamp); + return Ok(with_identity(snapshot, plan.plan_name, plan.end_timestamp)); } } + if primary.is_none() + && let (Some(remaining), Some(total)) = (plan.remaining_messages, plan.total_messages) + && total > 0 + && (0..=total).contains(&remaining) + { + // Use messages to match the cached remainingMessages / totalMessages fields. + // Reject invalid counters before subtraction/clamping can fabricate + // a quota or overflow on malformed cache values. + primary = usage_window(None, Some(remaining), Some(total), "messages"); + primary_label = Some("Cached quota"); + } + + if primary.is_none() && secondary.is_none() { + return Err(ProviderError::Parse( + "Windsurf plan cache has no quota details".to_string(), + )); + } let mut snapshot = UsageSnapshot::new(primary.unwrap_or_else(|| { - let mut window = RateWindow::new(0.0); - window.reset_description = Some("No cached Windsurf quota details".to_string()); - window + RateWindow::informational("No cached Windsurf daily quota details") })); + if let Some(label) = primary_label { + snapshot = snapshot.with_primary_label(label); + } if let Some(secondary) = secondary { snapshot = snapshot.with_secondary(secondary); } - with_identity(snapshot, plan.plan_name, plan.end_timestamp) + Ok(with_identity(snapshot, plan.plan_name, plan.end_timestamp)) } } @@ -189,7 +235,7 @@ impl Provider for WindsurfProvider { })?; let plan = Self::read_cached_plan(path)?; Ok(ProviderFetchResult::new( - Self::snapshot_from_plan(plan), + Self::snapshot_from_plan(plan)?, "local cache", )) } @@ -276,7 +322,10 @@ fn with_identity( if let Some(plan) = plan_name.filter(|v| !v.trim().is_empty()) { snapshot = snapshot.with_login_method(plan); } - if let Some(end) = end_timestamp.and_then(DateTime::::from_timestamp_millis) { + if let Some(end) = end_timestamp + .filter(|ts| *ts > 0) + .and_then(DateTime::::from_timestamp_millis) + { snapshot = snapshot.with_organization(format!("Expires {}", end.format("%Y-%m-%d"))); } snapshot @@ -298,7 +347,10 @@ mod tests { daily_reset_at_unix: None, weekly_reset_at_unix: None, }), - }); + remaining_messages: None, + total_messages: None, + }) + .expect("snapshot"); assert_eq!(snapshot.primary.used_percent, 20.0); assert_eq!( @@ -346,3 +398,256 @@ mod tests { assert_eq!(window.reset_description.as_deref(), Some("5 / 10 messages")); } } + +#[cfg(test)] +mod missing_usage_tests { + use super::*; + + fn plan(usage: Option, quota_usage: Option) -> CachedPlanInfo { + CachedPlanInfo { + plan_name: Some("Pro".into()), + end_timestamp: None, + usage, + quota_usage, + remaining_messages: None, + total_messages: None, + } + } + + #[test] + fn plan_without_a_quota_is_an_error_not_zero_percent() { + assert!(WindsurfProvider::snapshot_from_plan(plan(None, None)).is_err()); + } + + #[test] + fn weekly_only_plan_keeps_daily_unavailable() { + let snapshot = WindsurfProvider::snapshot_from_plan(plan( + None, + Some(QuotaUsage { + daily_remaining_percent: None, + weekly_remaining_percent: Some(25.0), + daily_reset_at_unix: None, + weekly_reset_at_unix: None, + }), + )) + .expect("snapshot"); + assert!(snapshot.primary.is_informational); + assert_eq!(snapshot.secondary.map(|w| w.used_percent), Some(75.0)); + } + + #[test] + fn real_zero_usage_stays_a_quota() { + let snapshot = WindsurfProvider::snapshot_from_plan(plan( + None, + Some(QuotaUsage { + daily_remaining_percent: Some(100.0), + weekly_remaining_percent: None, + daily_reset_at_unix: None, + weekly_reset_at_unix: None, + }), + )) + .expect("snapshot"); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, 0.0); + } +} + +/// The key and flat fields come from the Devin Desktop 3.11.228 package. +/// PR #18 also records one signed-in Windows TEXT/JSON sample with integer +/// counters. Their billing unit and cadence remain unverified; see +/// https://docs.devin.ai/desktop/accounts/quota and +/// https://docs.devin.ai/desktop/accounts/usage. +#[cfg(test)] +mod devin_cache_tests { + use super::*; + use std::path::Path; + + const DEVIN_KEY_PREFIX: &str = "windsurf.reactSettings.cachedPlanInfoData:"; + + fn state_db(base: &Path, folder: &str, rows: &[(&str, &str)]) -> PathBuf { + let dir = base.join(folder).join("User").join("globalStorage"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("state.vscdb"); + let conn = Connection::open(&path).unwrap(); + conn.execute( + "CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)", + [], + ) + .unwrap(); + for (key, value) in rows { + conn.execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + [key, value], + ) + .unwrap(); + } + path + } + + fn read(path: PathBuf) -> Result { + WindsurfProvider::read_cached_plan(path).and_then(WindsurfProvider::snapshot_from_plan) + } + + #[test] + fn flat_cache_counters_use_a_messages_label() { + let base = tempfile::tempdir().unwrap(); + let path = state_db( + base.path(), + "Devin", + &[( + &format!("{DEVIN_KEY_PREFIX}user-1"), + r#"{"planName":"Pro","endTimestamp":1800000000000,"remainingMessages":40,"totalMessages":100}"#, + )], + ); + + let snapshot = read(path).expect("snapshot"); + + assert_eq!(snapshot.primary_label.as_deref(), Some("Cached quota")); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, 60.0); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("60 / 100 messages") + ); + assert!(snapshot.primary.resets_at.is_none()); + assert!(snapshot.secondary.is_none()); + assert_eq!(snapshot.login_method.as_deref(), Some("Pro")); + assert_eq!( + snapshot.account_organization.as_deref(), + Some("Expires 2027-01-15") + ); + } + + #[test] + fn flat_cache_without_valid_counters_is_an_error() { + for json in [ + r#"{"planName":"Pro"}"#, + r#"{"planName":"Pro","remainingMessages":40}"#, + r#"{"planName":"Pro","totalMessages":100}"#, + r#"{"planName":"Pro","totalMessages":0,"remainingMessages":0}"#, + r#"{"planName":"Pro","acuConsumed":3.5,"acuLimit":10,"dollarsConsumed":1,"dollarsLimit":20}"#, + r#"{"planName":"Pro","remainingMessages":-1,"totalMessages":100}"#, + r#"{"planName":"Pro","remainingMessages":101,"totalMessages":100}"#, + ] { + let base = tempfile::tempdir().unwrap(); + let path = state_db( + base.path(), + "Devin", + &[(&format!("{DEVIN_KEY_PREFIX}user-1"), json)], + ); + assert!(read(path).is_err(), "must not invent a quota for {json}"); + } + } + + #[test] + fn flat_cache_with_overflowing_remaining_is_an_error() { + let base = tempfile::tempdir().unwrap(); + let path = state_db( + base.path(), + "Devin", + &[( + &format!("{DEVIN_KEY_PREFIX}user-1"), + r#"{"remainingMessages":-9223372036854775808,"totalMessages":100}"#, + )], + ); + + assert!(read(path).is_err()); + } + + #[test] + fn flat_cache_with_zero_end_timestamp_has_no_expiry() { + let base = tempfile::tempdir().unwrap(); + let path = state_db( + base.path(), + "Devin", + &[( + &format!("{DEVIN_KEY_PREFIX}user-1"), + r#"{"planName":"Pro","endTimestamp":0,"remainingMessages":2500,"totalMessages":2500}"#, + )], + ); + + let snapshot = read(path).expect("snapshot"); + + assert_eq!(snapshot.account_organization, None); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("0 / 2500 messages") + ); + assert!(snapshot.primary.resets_at.is_none()); + assert!(snapshot.primary.window_minutes.is_none()); + } + + #[test] + fn flat_cache_with_negative_end_timestamp_has_no_expiry() { + let base = tempfile::tempdir().unwrap(); + let path = state_db( + base.path(), + "Devin", + &[( + &format!("{DEVIN_KEY_PREFIX}user-1"), + r#"{"planName":"Pro","endTimestamp":-1,"remainingMessages":2500,"totalMessages":2500}"#, + )], + ); + + let snapshot = read(path).expect("snapshot"); + + assert_eq!(snapshot.account_organization, None); + } + + #[test] + fn several_account_caches_are_not_guessed() { + let base = tempfile::tempdir().unwrap(); + let flat = r#"{"planName":"Pro","remainingMessages":40,"totalMessages":100}"#; + let path = state_db( + base.path(), + "Devin", + &[ + (&format!("{DEVIN_KEY_PREFIX}user-1"), flat), + (&format!("{DEVIN_KEY_PREFIX}user-2"), flat), + ], + ); + + assert!(WindsurfProvider::read_cached_plan(path).is_err()); + } + + #[test] + fn devin_database_without_a_plan_cache_needs_sign_in() { + let base = tempfile::tempdir().unwrap(); + let path = state_db(base.path(), "Devin", &[("something.else", "{}")]); + + assert!(matches!( + WindsurfProvider::read_cached_plan(path), + Err(ProviderError::AuthRequired) + )); + } + + #[test] + fn the_legacy_windsurf_cache_still_reads() { + let base = tempfile::tempdir().unwrap(); + let path = state_db( + base.path(), + "Windsurf", + &[( + STATE_KEY, + r#"{"planName":"Pro","usage":{"messages":100,"usedMessages":10}}"#, + )], + ); + + let snapshot = read(path).expect("snapshot"); + + assert_eq!(snapshot.primary.used_percent, 10.0); + assert!(snapshot.primary_label.is_none()); + } + + #[test] + fn the_devin_folder_wins_over_the_legacy_windsurf_folder() { + let base = tempfile::tempdir().unwrap(); + let legacy = state_db(base.path(), "Windsurf", &[]); + assert_eq!(WindsurfProvider::db_path_in(base.path()), legacy); + + let devin = state_db(base.path(), "Devin", &[]); + assert_eq!(WindsurfProvider::db_path_in(base.path()), devin); + } +}