Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 269 additions & 19 deletions rust/src/providers/windsurf/mod.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
//! 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, ProviderMetadata,
RateWindow, SourceMode, UsageSnapshot,
};

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")]
Expand All @@ -24,6 +27,9 @@ struct CachedPlanInfo {
end_timestamp: Option<i64>,
usage: Option<WindsurfUsage>,
quota_usage: Option<QuotaUsage>,
// Devin Desktop's flat format.
remaining_messages: Option<i64>,
total_messages: Option<i64>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -77,18 +83,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<CachedPlanInfo, ProviderError> {
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()
)));
}
Expand All @@ -100,18 +116,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::<Result<Vec<_>, _>>()
})
.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())
Expand All @@ -123,6 +150,7 @@ impl WindsurfProvider {

fn snapshot_from_plan(plan: CachedPlanInfo) -> Result<UsageSnapshot, ProviderError> {
let mut primary = None;
let mut primary_label = None;
let mut secondary = None;

if let Some(quota) = plan.quota_usage {
Expand Down Expand Up @@ -175,6 +203,18 @@ impl WindsurfProvider {
}
}

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(),
Expand All @@ -183,6 +223,9 @@ impl WindsurfProvider {
let mut snapshot = UsageSnapshot::new(primary.unwrap_or_else(|| {
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);
}
Expand Down Expand Up @@ -307,7 +350,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::<Utc>::from_timestamp_millis) {
if let Some(end) = end_timestamp
.filter(|ts| *ts > 0)
.and_then(DateTime::<Utc>::from_timestamp_millis)
{
snapshot = snapshot.with_organization(format!("Expires {}", end.format("%Y-%m-%d")));
}
snapshot
Expand All @@ -329,6 +375,8 @@ mod tests {
daily_reset_at_unix: None,
weekly_reset_at_unix: None,
}),
remaining_messages: None,
total_messages: None,
})
.expect("snapshot");

Expand Down Expand Up @@ -389,6 +437,8 @@ mod missing_usage_tests {
end_timestamp: None,
usage,
quota_usage,
remaining_messages: None,
total_messages: None,
}
}

Expand Down Expand Up @@ -429,3 +479,203 @@ mod missing_usage_tests {
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<UsageSnapshot, ProviderError> {
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);
}
}