Files
2026-07-13 12:22:33 +08:00

282 lines
9.7 KiB
Swift

import Foundation
// MARK: - Cached Plan Info (Codable)
public struct WindsurfCachedPlanInfo: Codable, Sendable {
public let planName: String?
public let startTimestamp: Int64?
public let endTimestamp: Int64?
public let usage: Usage?
public let quotaUsage: QuotaUsage?
public struct Usage: Codable, Sendable {
public let messages: Int?
public let usedMessages: Int?
public let remainingMessages: Int?
public let flowActions: Int?
public let usedFlowActions: Int?
public let remainingFlowActions: Int?
public let flexCredits: Int?
public let usedFlexCredits: Int?
public let remainingFlexCredits: Int?
}
public struct QuotaUsage: Codable, Sendable {
public let dailyRemainingPercent: Double?
public let weeklyRemainingPercent: Double?
public let dailyResetAtUnix: Int64?
public let weeklyResetAtUnix: Int64?
}
}
// MARK: - Errors & Probe
#if os(macOS)
import SQLite3
public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable {
case dbNotFound(String)
case sqliteFailed(String)
case noData
case parseFailed(String)
public var errorDescription: String? {
switch self {
case let .dbNotFound(path):
"Windsurf database not found at \(path). Ensure Windsurf is installed and has been launched at least once."
case let .sqliteFailed(message):
"SQLite error reading Windsurf data: \(message)"
case .noData:
"No plan data found in Windsurf database. Sign in to Windsurf first."
case let .parseFailed(message):
"Could not parse Windsurf plan data: \(message)"
}
}
}
// MARK: - Probe
public struct WindsurfStatusProbe: Sendable {
private static let defaultDBPath: String = {
let home = NSHomeDirectory()
return "\(home)/Library/Application Support/Windsurf/User/globalStorage/state.vscdb"
}()
private static let query = "SELECT value FROM ItemTable WHERE key = 'windsurf.settings.cachedPlanInfo' LIMIT 1;"
private let dbPath: String
public init(dbPath: String? = nil) {
self.dbPath = dbPath ?? Self.defaultDBPath
}
public func fetch() throws -> WindsurfCachedPlanInfo {
guard FileManager.default.fileExists(atPath: self.dbPath) else {
throw WindsurfStatusProbeError.dbNotFound(self.dbPath)
}
var db: OpaquePointer?
guard sqlite3_open_v2(self.dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"
sqlite3_close(db)
throw WindsurfStatusProbeError.sqliteFailed(message)
}
defer { sqlite3_close(db) }
sqlite3_busy_timeout(db, 250)
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, Self.query, -1, &stmt, nil) == SQLITE_OK else {
let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"
throw WindsurfStatusProbeError.sqliteFailed(message)
}
defer { sqlite3_finalize(stmt) }
let stepResult = sqlite3_step(stmt)
guard stepResult == SQLITE_ROW else {
if stepResult == SQLITE_DONE {
throw WindsurfStatusProbeError.noData
}
let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"
throw WindsurfStatusProbeError.sqliteFailed(message)
}
guard let jsonString = Self.decodeSQLiteValue(stmt: stmt, index: 0) else {
throw WindsurfStatusProbeError.noData
}
guard let jsonData = jsonString.data(using: .utf8) else {
throw WindsurfStatusProbeError.parseFailed("Invalid UTF-8 encoding")
}
do {
return try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData)
} catch {
throw WindsurfStatusProbeError.parseFailed(error.localizedDescription)
}
}
private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? {
switch sqlite3_column_type(stmt, index) {
case SQLITE_TEXT:
guard let c = sqlite3_column_text(stmt, index) else { return nil }
return String(cString: c)
case SQLITE_BLOB:
guard let bytes = sqlite3_column_blob(stmt, index) else { return nil }
let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index)))
// VSCode/Windsurf state.vscdb schema declares value as BLOB;
// only accept decodes that still parse as JSON to avoid UTF-16 mojibake.
return self.decodeJSONBlob(data)
default:
return nil
}
}
private static func decodeJSONBlob(_ data: Data) -> String? {
for encoding in [String.Encoding.utf8, .utf16LittleEndian] {
guard let decoded = String(data: data, encoding: encoding) else { continue }
let trimmed = decoded.trimmingCharacters(in: .controlCharacters)
guard let jsonData = trimmed.data(using: .utf8),
(try? JSONSerialization.jsonObject(with: jsonData)) != nil
else {
continue
}
return trimmed
}
return nil
}
}
#else
// MARK: - Windsurf (Unsupported)
public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable {
case notSupported
public var errorDescription: String? {
"Windsurf is only supported on macOS."
}
}
public struct WindsurfStatusProbe: Sendable {
public init(dbPath _: String? = nil) {}
public func fetch() throws -> WindsurfCachedPlanInfo {
throw WindsurfStatusProbeError.notSupported
}
}
#endif
// MARK: - Conversion to UsageSnapshot
extension WindsurfCachedPlanInfo {
public func toUsageSnapshot() -> UsageSnapshot {
var primary: RateWindow?
var secondary: RateWindow?
if let quota = self.quotaUsage {
// Primary: daily usage (usedPercent = 100 - dailyRemainingPercent)
if let daily = quota.dailyRemainingPercent {
let resetDate = quota.dailyResetAtUnix.map {
Date(timeIntervalSince1970: TimeInterval($0))
}
primary = RateWindow(
usedPercent: max(0, min(100, 100 - daily)),
windowMinutes: nil,
resetsAt: resetDate,
resetDescription: Self.formatResetDescription(resetDate))
}
// Secondary: weekly usage
if let weekly = quota.weeklyRemainingPercent {
let resetDate = quota.weeklyResetAtUnix.map {
Date(timeIntervalSince1970: TimeInterval($0))
}
secondary = RateWindow(
usedPercent: max(0, min(100, 100 - weekly)),
windowMinutes: nil,
resetsAt: resetDate,
resetDescription: Self.formatResetDescription(resetDate))
}
}
if primary == nil, let usage = self.usage {
primary = Self.makeUsageWindow(
used: usage.usedMessages,
remaining: usage.remainingMessages,
total: usage.messages,
unit: "messages")
}
if secondary == nil, let usage = self.usage {
secondary = Self.makeUsageWindow(
used: usage.usedFlowActions,
remaining: usage.remainingFlowActions,
total: usage.flowActions,
unit: "flow actions")
}
// Identity
var orgDescription: String?
if let endTimestamp = self.endTimestamp {
let endDate = Date(timeIntervalSince1970: TimeInterval(endTimestamp) / 1000)
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
orgDescription = "Expires \(formatter.string(from: endDate))"
}
let identity = ProviderIdentitySnapshot(
providerID: .windsurf,
accountEmail: nil,
accountOrganization: orgDescription,
loginMethod: self.planName)
return UsageSnapshot(
primary: primary,
secondary: secondary,
updatedAt: Date(),
identity: identity)
}
private static func makeUsageWindow(
used rawUsed: Int?,
remaining rawRemaining: Int?,
total rawTotal: Int?,
unit: String) -> RateWindow?
{
guard let total = rawTotal, total > 0 else { return nil }
let inferredUsed = rawUsed ?? rawRemaining.map { max(0, total - $0) }
guard let used = inferredUsed else { return nil }
let clampedUsed = max(0, min(total, used))
let usedPercent = max(0, min(100, Double(clampedUsed) / Double(total) * 100))
return RateWindow(
usedPercent: usedPercent,
windowMinutes: nil,
resetsAt: nil,
resetDescription: "\(clampedUsed) / \(total) \(unit)")
}
private static func formatResetDescription(_ date: Date?) -> String? {
guard let date else { return nil }
let now = Date()
let interval = date.timeIntervalSince(now)
guard interval > 0 else { return "Expired" }
let hours = Int(interval / 3600)
let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60)
if hours > 24 {
let days = hours / 24
let remainingHours = hours % 24
return "Resets in \(days)d \(remainingHours)h"
} else if hours > 0 {
return "Resets in \(hours)h \(minutes)m"
} else {
return "Resets in \(minutes)m"
}
}
}