282 lines
9.7 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|