From ba15392f0c149c9f4efb247ea582a0bc2b42d9b1 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Sat, 21 Mar 2026 21:40:43 +0800 Subject: [PATCH 1/3] Add Windsurf provider Read cached plan info from Windsurf's local SQLite database (state.vscdb) and surface daily/weekly quota usage. - Decode WindsurfCachedPlanInfo with plan name, timestamps, message/flow-action counts, and daily/weekly quota percentages - Convert quota data to UsageSnapshot with proper nil handling: missing quotaUsage or individual percentage fields yield nil RateWindow instead of fabricating 0% usage - Support both .auto and .cli source modes (consistent with other localProbe providers) - Register provider across app, CLI, widget, and cost scanner - Add 10 tests covering JSON decoding, snapshot conversion, edge cases, and error handling --- .../ProviderImplementationRegistry.swift | 1 + .../WindsurfProviderImplementation.swift | 8 + .../Resources/ProviderIcon-windsurf.svg | 3 + Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 3 +- .../Providers/ProviderDescriptor.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Windsurf/WindsurfProviderDescriptor.swift | 62 ++++++ .../Windsurf/WindsurfStatusProbe.swift | 186 +++++++++++++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../WindsurfStatusProbeTests.swift | 195 ++++++++++++++++++ 13 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-windsurf.svg create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift create mode 100644 Tests/CodexBarTests/WindsurfStatusProbeTests.swift diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 9cb99850b..51756884b 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -36,6 +36,7 @@ enum ProviderImplementationRegistry { case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() + case .windsurf: WindsurfProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift new file mode 100644 index 000000000..763e72a6e --- /dev/null +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift @@ -0,0 +1,8 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct WindsurfProviderImplementation: ProviderImplementation { + let id: UsageProvider = .windsurf +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg new file mode 100644 index 000000000..3bc424679 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 35fdd234d..5b62ab5ab 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1261,7 +1261,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains: + .kimik2, .jetbrains, .windsurf: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 937b37aa0..8bed2b50f 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -177,7 +177,8 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, + .windsurf: return nil } } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 0596617b7..36bf5b552 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -76,6 +76,7 @@ public enum ProviderDescriptorRegistry { .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .windsurf: WindsurfProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index e39bb40a8..4ec2b067b 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -26,6 +26,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case synthetic case warp case openrouter + case windsurf } // swiftformat:enable sortDeclarations @@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case windsurf case combined } diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift new file mode 100644 index 000000000..11d3fa94e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -0,0 +1,62 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WindsurfProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .windsurf, + metadata: ProviderMetadata( + id: .windsurf, + displayName: "Windsurf", + sessionLabel: "Daily", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Windsurf usage", + cliName: "windsurf", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://windsurf.com/subscription/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .windsurf, + iconResourceName: "ProviderIcon-windsurf", + color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Windsurf cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WindsurfLocalFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "windsurf", + versionDetector: nil)) + } +} + +struct WindsurfLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let probe = WindsurfStatusProbe() + let planInfo = try probe.fetch() + let usage = planInfo.toUsageSnapshot() + return self.makeResult( + usage: usage, + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift new file mode 100644 index 000000000..6ddd646be --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -0,0 +1,186 @@ +import Foundation +import SQLite3 + +// 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 struct QuotaUsage: Codable, Sendable { + public let dailyRemainingPercent: Double? + public let weeklyRemainingPercent: Double? + public let dailyResetAtUnix: Int64? + public let weeklyResetAtUnix: Int64? + } +} + +// MARK: - Errors + +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) } + + guard sqlite3_step(stmt) == SQLITE_ROW else { + throw WindsurfStatusProbeError.noData + } + + guard let cString = sqlite3_column_text(stmt, 0) else { + throw WindsurfStatusProbeError.noData + } + + let jsonString = String(cString: cString) + 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) + } + } +} + +// 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)) + } + } + + // 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 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" + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 0e6767e15..443d28d44 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -72,7 +72,7 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .windsurf: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c828a2695..b86903e43 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -71,6 +71,7 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .windsurf: return nil // Windsurf not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 3b0dd2d27..0ed942fab 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -280,6 +280,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" + case .windsurf: "Windsurf" } } } @@ -621,6 +622,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .windsurf: + Color(red: 52 / 255, green: 232 / 255, blue: 187 / 255) // Windsurf #34e8bb } } } diff --git a/Tests/CodexBarTests/WindsurfStatusProbeTests.swift b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift new file mode 100644 index 000000000..bdb37a3d7 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift @@ -0,0 +1,195 @@ +import CodexBarCore +import Foundation +import Testing + +struct WindsurfStatusProbeTests { + // MARK: - Helper + + private static func decode(_ json: String) throws -> WindsurfCachedPlanInfo { + try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(json.utf8)) + } + + // MARK: - JSON Decoding + + @Test + func `decodes full plan info`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, + "usedMessages": 35650, + "remainingMessages": 14350, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, + "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, + "weeklyResetAtUnix": 1774166400 + } + } + """) + + #expect(info.planName == "Pro") + #expect(info.startTimestamp == 1_771_610_750_000) + #expect(info.endTimestamp == 1_774_029_950_000) + #expect(info.usage?.messages == 50000) + #expect(info.usage?.usedMessages == 35650) + #expect(info.usage?.remainingMessages == 14350) + #expect(info.usage?.flowActions == 150_000) + #expect(info.usage?.usedFlowActions == 0) + #expect(info.usage?.remainingFlowActions == 150_000) + #expect(info.quotaUsage?.dailyRemainingPercent == 9) + #expect(info.quotaUsage?.weeklyRemainingPercent == 54) + #expect(info.quotaUsage?.dailyResetAtUnix == 1_774_080_000) + #expect(info.quotaUsage?.weeklyResetAtUnix == 1_774_166_400) + } + + @Test + func `decodes minimal plan info`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + #expect(info.planName == "Free") + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + #expect(info.endTimestamp == nil) + } + + @Test + func `decodes empty object`() throws { + let info = try Self.decode("{}") + + #expect(info.planName == nil) + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + } + + // MARK: - toUsageSnapshot Conversion + + @Test + func `converts full plan to usage snapshot`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, "usedMessages": 35650, "remainingMessages": 14350, + "flowActions": 150000, "usedFlowActions": 0, "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + // Primary = daily: usedPercent = 100 - 9 = 91 + #expect(snapshot.primary?.usedPercent == 91) + #expect(snapshot.primary?.resetsAt != nil) + + // Secondary = weekly: usedPercent = 100 - 54 = 46 + #expect(snapshot.secondary?.usedPercent == 46) + #expect(snapshot.secondary?.resetsAt != nil) + + // Identity + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.identity?.accountOrganization != nil) + } + + @Test + func `converts minimal plan to usage snapshot`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + let snapshot = info.toUsageSnapshot() + + // Without quotaUsage, primary and secondary should be nil + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Free") + #expect(snapshot.identity?.accountOrganization == nil) + } + + @Test + func `daily at zero remaining shows 100 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 0, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `weekly at full remaining shows 0 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 100, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `reset dates are correctly converted from unix timestamps`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": { + "dailyRemainingPercent": 50, "weeklyRemainingPercent": 50, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.resetsAt == Date(timeIntervalSince1970: 1_774_080_000)) + #expect(snapshot.secondary?.resetsAt == Date(timeIntervalSince1970: 1_774_166_400)) + } + + @Test + func `end timestamp converts to expiry description`() throws { + let futureMs = Int64(Date().addingTimeInterval(86400 * 30).timeIntervalSince1970 * 1000) + let info = try Self.decode(""" + {"planName": "Pro", "endTimestamp": \(futureMs)} + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.identity?.accountOrganization?.hasPrefix("Expires ") == true) + } + + // MARK: - Probe Error Cases + + @Test + func `probe throws dbNotFound for missing file`() { + let probe = WindsurfStatusProbe(dbPath: "/nonexistent/path/state.vscdb") + + #expect(throws: WindsurfStatusProbeError.self) { + _ = try probe.fetch() + } + } +} From 4cabd1a6181450602675d79ddafc8b3af5ccf38f Mon Sep 17 00:00:00 2001 From: Coolfan Date: Sat, 21 Mar 2026 22:13:11 +0800 Subject: [PATCH 2/3] fix: harden WindsurfStatusProbe SQLite reading - Distinguish SQLITE_DONE (no data) from SQLITE_BUSY/ERROR (actual SQLite failure) instead of treating all non-ROW as noData - Decode column value by type (TEXT vs BLOB with UTF-16LE/UTF-8 fallback) to handle VSCode-style BLOB storage - Wrap SQLite-dependent code in #if os(macOS) with a stub for non-macOS builds --- .../Windsurf/WindsurfStatusProbe.swift | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift index 6ddd646be..9061243db 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -1,5 +1,4 @@ import Foundation -import SQLite3 // MARK: - Cached Plan Info (Codable) @@ -27,7 +26,11 @@ public struct WindsurfCachedPlanInfo: Codable, Sendable { } } -// MARK: - Errors +// MARK: - Errors & Probe + +#if os(macOS) + +import SQLite3 public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { case dbNotFound(String) @@ -87,15 +90,18 @@ public struct WindsurfStatusProbe: Sendable { } defer { sqlite3_finalize(stmt) } - guard sqlite3_step(stmt) == SQLITE_ROW else { - throw WindsurfStatusProbeError.noData + 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 cString = sqlite3_column_text(stmt, 0) else { + guard let jsonString = Self.decodeSQLiteValue(stmt: stmt, index: 0) else { throw WindsurfStatusProbeError.noData } - - let jsonString = String(cString: cString) guard let jsonData = jsonString.data(using: .utf8) else { throw WindsurfStatusProbeError.parseFailed("Invalid UTF-8 encoding") } @@ -106,8 +112,52 @@ public struct WindsurfStatusProbe: Sendable { 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; + // try UTF-16LE first (common for VSCode derivatives), then UTF-8. + if let decoded = String(data: data, encoding: .utf16LittleEndian) { + return decoded.trimmingCharacters(in: .controlCharacters) + } + if let decoded = String(data: data, encoding: .utf8) { + return decoded.trimmingCharacters(in: .controlCharacters) + } + return nil + default: + 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 { From 7e5e65c91fe660e7de4282329c27812ca93b204a Mon Sep 17 00:00:00 2001 From: Coolfan Date: Sun, 22 Mar 2026 15:51:45 +0800 Subject: [PATCH 3/3] feat: add Windsurf web dashboard fetching and settings UI Add web API support for the Windsurf provider via Firebase token extraction from browser IndexedDB, token refresh, and ConnectRPC GetPlanStatus API. Implement full settings UI with Usage source (Auto/Web/Local) and Cookie source (Auto/Manual/Off) pickers, manual token input supporting both refresh and access tokens, and proper Keychain access gating. --- .../WindsurfProviderImplementation.swift | 103 ++++++ .../Windsurf/WindsurfSettingsStore.swift | 93 ++++++ .../Providers/ProviderSettingsSnapshot.swift | 33 +- .../WindsurfFirebaseTokenImporter.swift | 237 ++++++++++++++ .../Windsurf/WindsurfProviderDescriptor.swift | 42 ++- .../Windsurf/WindsurfUsageDataSource.swift | 27 ++ .../Windsurf/WindsurfWebFetcher.swift | 308 ++++++++++++++++++ docs/windsurf.md | 165 ++++++++++ 8 files changed, 1002 insertions(+), 6 deletions(-) create mode 100644 Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfFirebaseTokenImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift create mode 100644 docs/windsurf.md diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift index 763e72a6e..04070618d 100644 --- a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift @@ -1,8 +1,111 @@ import CodexBarCore import CodexBarMacroSupport import Foundation +import SwiftUI @ProviderImplementationRegistration struct WindsurfProviderImplementation: ProviderImplementation { let id: UsageProvider = .windsurf + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.windsurfUsageDataSource + _ = settings.windsurfCookieSource + _ = settings.windsurfCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .windsurf(context.settings.windsurfSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.windsurfUsageDataSource { + case .auto: .auto + case .web: .web + case .cli: .cli + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + // Usage source picker + let usageBinding = Binding( + get: { context.settings.windsurfUsageDataSource.rawValue }, + set: { raw in + context.settings.windsurfUsageDataSource = WindsurfUsageDataSource(rawValue: raw) ?? .auto + }) + let usageOptions = WindsurfUsageDataSource.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + + // Cookie source picker + let cookieBinding = Binding( + get: { context.settings.windsurfCookieSource.rawValue }, + set: { raw in + context.settings.windsurfCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.windsurfCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports Firebase tokens from browser IndexedDB.", + manual: "Paste a Firebase access token for windsurf.com.", + off: "Windsurf web API access is disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "windsurf-usage-source", + title: "Usage source", + subtitle: "Auto falls back to the next source if the preferred one fails.", + binding: usageBinding, + options: usageOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard context.settings.windsurfUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .windsurf) + return label == "auto" ? nil : label + }), + ProviderSettingsPickerDescriptor( + id: "windsurf-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports Firebase tokens from browser IndexedDB.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard !context.settings.debugDisableKeychainAccess else { return nil } + guard let entry = CookieHeaderCache.load(provider: .windsurf) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "windsurf-cookie-header", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Firebase refresh token (AMf-vB…) or access token (eyJ…)", + binding: context.stringBinding(\.windsurfCookieHeader), + actions: [], + isVisible: { + context.settings.windsurfCookieSource == .manual + }, + onActivate: nil), + ] + } } diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift new file mode 100644 index 000000000..683e97c7e --- /dev/null +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift @@ -0,0 +1,93 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var windsurfUsageDataSource: WindsurfUsageDataSource { + get { + let source = self.configSnapshot.providerConfig(for: .windsurf)?.source + return Self.windsurfUsageDataSource(from: source) + } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .web: .web + case .cli: .cli + } + self.updateProviderConfig(provider: .windsurf) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .windsurf, field: "usageSource", value: newValue.rawValue) + } + } + + var windsurfCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .windsurf, fallback: .auto) } + set { + self.updateProviderConfig(provider: .windsurf) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .windsurf, field: "cookieSource", value: newValue.rawValue) + } + } + + var windsurfCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .windsurf)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .windsurf) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .windsurf, field: "cookieHeader", value: newValue) + } + } +} + +extension SettingsStore { + func windsurfSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.WindsurfProviderSettings + { + ProviderSettingsSnapshot.WindsurfProviderSettings( + usageDataSource: self.windsurfUsageDataSource, + cookieSource: self.windsurfSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.windsurfSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private static func windsurfUsageDataSource(from source: ProviderSourceMode?) -> WindsurfUsageDataSource { + guard let source else { return .auto } + switch source { + case .auto, .oauth, .api: + return .auto + case .web: + return .web + case .cli: + return .cli + } + } + + private func windsurfSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.windsurfCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .windsurf), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .windsurf, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func windsurfSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.windsurfCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .windsurf), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .windsurf).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index c5d9af4f7..7acff3f0e 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -18,7 +18,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, - jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -37,7 +38,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + windsurf: windsurf) } public struct CodexProviderSettings: Sendable { @@ -209,6 +211,22 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct WindsurfProviderSettings: Sendable { + public let usageDataSource: WindsurfUsageDataSource + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init( + usageDataSource: WindsurfUsageDataSource, + cookieSource: ProviderCookieSource, + manualCookieHeader: String?) + { + self.usageDataSource = usageDataSource + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -226,6 +244,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let amp: AmpProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let windsurf: WindsurfProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -248,7 +267,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings?, amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, - jetbrains: JetBrainsProviderSettings? = nil) + jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -267,6 +287,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.amp = amp self.ollama = ollama self.jetbrains = jetbrains + self.windsurf = windsurf } } @@ -286,6 +307,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case amp(ProviderSettingsSnapshot.AmpProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case windsurf(ProviderSettingsSnapshot.WindsurfProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -306,6 +328,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var windsurf: ProviderSettingsSnapshot.WindsurfProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -329,6 +352,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .amp(value): self.amp = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .windsurf(value): self.windsurf = value } } @@ -350,6 +374,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { augment: self.augment, amp: self.amp, ollama: self.ollama, - jetbrains: self.jetbrains) + jetbrains: self.jetbrains, + windsurf: self.windsurf) } } diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfFirebaseTokenImporter.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfFirebaseTokenImporter.swift new file mode 100644 index 000000000..73c09f144 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfFirebaseTokenImporter.swift @@ -0,0 +1,237 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +enum WindsurfFirebaseTokenImporter { + struct TokenInfo { + let refreshToken: String + let accessToken: String? + let sourceLabel: String + } + + static func importFirebaseTokens( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [TokenInfo] + { + let log: (String) -> Void = { msg in logger?("[windsurf-firebase] \(msg)") } + var tokens: [TokenInfo] = [] + + let candidates = self.chromeIndexedDBCandidates(browserDetection: browserDetection) + if !candidates.isEmpty { + log("IndexedDB candidates: \(candidates.count)") + } + + for candidate in candidates { + let extracted = self.readFirebaseTokens(from: candidate.url, logger: log) + for token in extracted { + log("Found Firebase refresh token in \(candidate.label)") + tokens.append(TokenInfo( + refreshToken: token.refreshToken, + accessToken: token.accessToken, + sourceLabel: candidate.label)) + } + } + + if tokens.isEmpty { + log("No Firebase refresh token found in browser IndexedDB") + } + + return tokens + } + + // MARK: - IndexedDB discovery (follows MiniMax chromeProfileIndexedDBDirs pattern) + + private struct IndexedDBCandidate { + let label: String + let url: URL + } + + private static func chromeIndexedDBCandidates(browserDetection: BrowserDetection) -> [IndexedDBCandidate] { + let browsers: [Browser] = [ + .chrome, + .chromeBeta, + .chromeCanary, + .edge, + .edgeBeta, + .edgeCanary, + .brave, + .braveBeta, + .braveNightly, + .vivaldi, + .arc, + .arcBeta, + .arcCanary, + .dia, + .chatgptAtlas, + .chromium, + .helium, + ] + + let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection) + + let roots = ChromiumProfileLocator + .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + .map { (url: $0.url, labelPrefix: $0.labelPrefix) } + + var candidates: [IndexedDBCandidate] = [] + for root in roots { + candidates.append(contentsOf: self.chromeProfileIndexedDBDirs( + root: root.url, + labelPrefix: root.labelPrefix)) + } + return candidates + } + + private static let indexedDBPrefix = "https_windsurf.com_" + + private static func chromeProfileIndexedDBDirs(root: URL, labelPrefix: String) -> [IndexedDBCandidate] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let profileDirs = entries.filter { url in + guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + return false + } + let name = url.lastPathComponent + return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + + var candidates: [IndexedDBCandidate] = [] + for dir in profileDirs { + let indexedDBRoot = dir.appendingPathComponent("IndexedDB") + guard let dbEntries = try? FileManager.default.contentsOfDirectory( + at: indexedDBRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { continue } + for entry in dbEntries { + guard let isDir = (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + continue + } + let name = entry.lastPathComponent + guard name.hasPrefix(self.indexedDBPrefix), + name.hasSuffix(".indexeddb.leveldb") + else { continue } + let label = "\(labelPrefix) \(dir.lastPathComponent)" + candidates.append(IndexedDBCandidate(label: label, url: entry)) + } + } + return candidates + } + + // MARK: - Token extraction (follows Factory readWorkOSToken pattern) + + private static func readFirebaseTokens( + from levelDBURL: URL, + logger: ((String) -> Void)? = nil) -> [TokenInfo] + { + // Try structured reading first via SweetCookieKit + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( + in: levelDBURL, + logger: logger) + var tokens: [TokenInfo] = [] + var seenRefresh = Set() + + for entry in textEntries { + if let token = self.extractFirebaseTokens(from: entry.value), !seenRefresh.contains(token.refreshToken) { + seenRefresh.insert(token.refreshToken) + tokens.append(token) + } + } + + if tokens.isEmpty { + let rawCandidates = SweetCookieKit.ChromiumLocalStorageReader.readTokenCandidates( + in: levelDBURL, + minimumLength: 40, + logger: logger) + for candidate in rawCandidates { + if let token = self.extractFirebaseTokens(from: candidate), + !seenRefresh.contains(token.refreshToken) + { + seenRefresh.insert(token.refreshToken) + tokens.append(token) + } + } + } + + // Fallback: scan raw .ldb/.log files (Factory readWorkOSToken pattern) + if tokens.isEmpty { + if let token = self.scanLevelDBFiles(at: levelDBURL) { + tokens.append(token) + } + } + + return tokens + } + + private static func scanLevelDBFiles(at levelDBURL: URL) -> TokenInfo? { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: levelDBURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { return nil } + + let files = entries.filter { url in + let ext = url.pathExtension.lowercased() + return ext == "ldb" || ext == "log" + } + .sorted { lhs, rhs in + let left = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + let right = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + return (left ?? .distantPast) > (right ?? .distantPast) + } + + for file in files { + guard let data = try? Data(contentsOf: file, options: [.mappedIfSafe]) else { continue } + guard let contents = String(data: data, encoding: .utf8) ?? + String(data: data, encoding: .isoLatin1) + else { continue } + if let token = self.extractFirebaseTokens(from: contents) { + return token + } + } + return nil + } + + private static func extractFirebaseTokens(from value: String) -> TokenInfo? { + // Firebase refresh tokens start with AMf-vB (Google Identity Toolkit) + let refreshToken = self.matchToken( + in: value, + pattern: #"refreshToken.{1,20}(AMf-vB[A-Za-z0-9_-]{20,})"#) + ?? self.matchToken( + in: value, + pattern: #"refresh_token.{1,20}(AMf-vB[A-Za-z0-9_-]{20,})"#) + ?? self.matchToken( + in: value, + pattern: #"(AMf-vB[A-Za-z0-9_-]{40,})"#) + + guard let refreshToken else { return nil } + + // Firebase access tokens are JWTs (eyJ...) + let accessToken = self.matchToken( + in: value, + pattern: #"accessToken.{1,20}(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)"#) + ?? self.matchToken( + in: value, + pattern: #"access_token.{1,20}(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)"#) + + return TokenInfo(refreshToken: refreshToken, accessToken: accessToken, sourceLabel: "browser") + } + + private static func matchToken(in contents: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(contents.startIndex.. 1, + let tokenRange = Range(match.range(at: 1), in: contents) + else { return nil } + return String(contents[tokenRange]) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift index 11d3fa94e..c236e9e50 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -31,14 +31,52 @@ public enum WindsurfProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Windsurf cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .cli], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WindsurfLocalFetchStrategy()] })), + sourceModes: [.auto, .web, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [WindsurfWebFetchStrategy(), WindsurfLocalFetchStrategy()] + })), cli: ProviderCLIConfig( name: "windsurf", versionDetector: nil)) } } +struct WindsurfWebFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.sourceMode.usesWeb else { return false } + guard context.settings?.windsurf?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + #if os(macOS) + let cookieSource = context.settings?.windsurf?.cookieSource ?? .auto + let manualToken = Self.manualToken(from: context) + let usage = try await WindsurfWebFetcher.fetchUsage( + browserDetection: context.browserDetection, + cookieSource: cookieSource, + manualAccessToken: manualToken, + logger: context.verbose ? { print($0) } : nil) + return self.makeResult(usage: usage, sourceLabel: "windsurf-web") + #else + throw WindsurfStatusProbeError.notSupported + #endif + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + + private static func manualToken(from context: ProviderFetchContext) -> String? { + guard context.settings?.windsurf?.cookieSource == .manual else { return nil } + let header = context.settings?.windsurf?.manualCookieHeader ?? "" + return header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : header + } +} + struct WindsurfLocalFetchStrategy: ProviderFetchStrategy { let id: String = "windsurf.local" let kind: ProviderFetchKind = .localProbe diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift new file mode 100644 index 000000000..1330be598 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum WindsurfUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case web + case cli + + public var id: String { + self.rawValue + } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .web: "Web API (IndexedDB)" + case .cli: "Local (SQLite cache)" + } + } + + public var sourceLabel: String { + switch self { + case .auto: "auto" + case .web: "web" + case .cli: "cli" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift new file mode 100644 index 000000000..ed57a2ad1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift @@ -0,0 +1,308 @@ +import Foundation + +// MARK: - API Response Model + +public struct WindsurfGetPlanStatusResponse: Codable, Sendable { + public let planStatus: PlanStatus? + + public struct PlanStatus: Codable, Sendable { + public let planInfo: PlanInfo? + public let planStart: String? + public let planEnd: String? + public let availablePromptCredits: Int? + public let availableFlowCredits: Int? + public let dailyQuotaRemainingPercent: Double? + public let weeklyQuotaRemainingPercent: Double? + public let dailyQuotaResetAtUnix: String? + public let weeklyQuotaResetAtUnix: String? + public let topUpStatus: TopUpStatus? + public let gracePeriodStatus: String? + + public struct PlanInfo: Codable, Sendable { + public let planName: String? + public let teamsTier: String? + } + + public struct TopUpStatus: Codable, Sendable { + public let topUpTransactionStatus: String? + } + } +} + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfGetPlanStatusResponse { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let status = self.planStatus { + if let daily = status.dailyQuotaRemainingPercent { + let resetDate = status.dailyQuotaResetAtUnix.flatMap { Int64($0) }.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - daily)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + if let weekly = status.weeklyQuotaRemainingPercent { + let resetDate = status.weeklyQuotaResetAtUnix.flatMap { Int64($0) }.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - weekly)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + var orgDescription: String? + if let planEnd = self.planStatus?.planEnd { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let endDate = isoFormatter.date(from: planEnd) + ?? ISO8601DateFormatter().date(from: planEnd) + if let endDate { + 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.planStatus?.planInfo?.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + 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" + } + } +} + +// MARK: - Web Fetcher + +#if os(macOS) + +public enum WindsurfWebFetcherError: LocalizedError, Sendable { + case noFirebaseToken + case tokenRefreshFailed(String) + case apiCallFailed(String) + + public var errorDescription: String? { + switch self { + case .noFirebaseToken: + "No Firebase token found in browser IndexedDB. Sign in to windsurf.com in Chrome or Edge first." + case let .tokenRefreshFailed(message): + "Firebase token refresh failed: \(message)" + case let .apiCallFailed(message): + "Windsurf API call failed: \(message)" + } + } +} + +public enum WindsurfWebFetcher { + // Public Firebase API key (embedded in windsurf.com frontend) + private static let firebaseAPIKey = "AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY" + private static let getPlanStatusURL = "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus" + + public static func fetchUsage( + browserDetection: BrowserDetection, + cookieSource: ProviderCookieSource = .auto, + manualAccessToken: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil) async throws -> UsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[windsurf-web] \(msg)") } + let useKeychain = cookieSource == .auto + + // 0. Manual token override (cookie source = manual) + // Accepts either a refresh token (AMf-vB prefix, long-lived) or access token (eyJ prefix, ~1h) + if let manualAccessToken, !manualAccessToken.isEmpty { + let token = manualAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) + if token.hasPrefix("AMf-vB") { + log("Using manual refresh token → exchanging for access token") + let accessToken = try await self.refreshFirebaseToken(token, timeout: timeout) + let response = try await self.fetchPlanStatus(accessToken: accessToken, timeout: timeout) + return response.toUsageSnapshot() + } else { + log("Using manual access token") + let response = try await self.fetchPlanStatus(accessToken: token, timeout: timeout) + return response.toUsageSnapshot() + } + } + + // 1. Try cached token from CookieHeaderCache (only when auto / Keychain allowed) + if useKeychain, let cached = CookieHeaderCache.load(provider: .windsurf) { + log("Trying cached Firebase access token") + do { + let response = try await self.fetchPlanStatus(accessToken: cached.cookieHeader, timeout: timeout) + return response.toUsageSnapshot() + } catch { + log("Cached token failed: \(error.localizedDescription)") + CookieHeaderCache.clear(provider: .windsurf) + } + } + + // 2. Import Firebase tokens from browser IndexedDB + let tokenInfos = WindsurfFirebaseTokenImporter.importFirebaseTokens( + browserDetection: browserDetection, + logger: logger) + guard !tokenInfos.isEmpty else { + throw WindsurfWebFetcherError.noFirebaseToken + } + + var lastError: Error? + + for tokenInfo in tokenInfos { + // 2a. Try existing access token first (if not expired) + if let accessToken = tokenInfo.accessToken { + log("Trying access token from \(tokenInfo.sourceLabel)") + do { + let response = try await self.fetchPlanStatus(accessToken: accessToken, timeout: timeout) + if useKeychain { + CookieHeaderCache.store( + provider: .windsurf, + cookieHeader: accessToken, + sourceLabel: tokenInfo.sourceLabel) + } + return response.toUsageSnapshot() + } catch { + log("Access token failed: \(error.localizedDescription)") + lastError = error + } + } + + // 2b. Refresh token to get new access token + log("Refreshing Firebase token from \(tokenInfo.sourceLabel)") + do { + let accessToken = try await self.refreshFirebaseToken( + tokenInfo.refreshToken, + timeout: timeout) + let response = try await self.fetchPlanStatus(accessToken: accessToken, timeout: timeout) + if useKeychain { + CookieHeaderCache.store( + provider: .windsurf, + cookieHeader: accessToken, + sourceLabel: tokenInfo.sourceLabel) + } + return response.toUsageSnapshot() + } catch { + log("Token refresh/API call failed: \(error.localizedDescription)") + lastError = error + } + } + + throw lastError ?? WindsurfWebFetcherError.noFirebaseToken + } + + // MARK: - Firebase Token Refresh + + private static func refreshFirebaseToken( + _ refreshToken: String, + timeout: TimeInterval) async throws -> String + { + guard let url = URL(string: "https://securetoken.googleapis.com/v1/token?key=\(self.firebaseAPIKey)") else { + throw WindsurfWebFetcherError.tokenRefreshFailed("Invalid Firebase token URL") + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let body = "grant_type=refresh_token&refresh_token=\(refreshToken)" + request.httpBody = body.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WindsurfWebFetcherError.tokenRefreshFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let snippet = body.isEmpty ? "" : ": \(body.prefix(200))" + throw WindsurfWebFetcherError.tokenRefreshFailed("HTTP \(httpResponse.statusCode)\(snippet)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String + else { + throw WindsurfWebFetcherError.tokenRefreshFailed("Missing access_token in response") + } + + return accessToken + } + + // MARK: - GetPlanStatus API + + private static func fetchPlanStatus( + accessToken: String, + timeout: TimeInterval) async throws -> WindsurfGetPlanStatusResponse + { + guard let url = URL(string: self.getPlanStatusURL) else { + throw WindsurfWebFetcherError.apiCallFailed("Invalid GetPlanStatus URL") + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + + let body: [String: Any] = [ + "authToken": accessToken, + "includeTopUpStatus": true, + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WindsurfWebFetcherError.apiCallFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let snippet = body.isEmpty ? "" : ": \(body.prefix(200))" + throw WindsurfWebFetcherError.apiCallFailed("HTTP \(httpResponse.statusCode)\(snippet)") + } + + do { + return try JSONDecoder().decode(WindsurfGetPlanStatusResponse.self, from: data) + } catch { + throw WindsurfWebFetcherError.apiCallFailed("Parse error: \(error.localizedDescription)") + } + } +} + +#endif diff --git a/docs/windsurf.md b/docs/windsurf.md new file mode 100644 index 000000000..c30369a6f --- /dev/null +++ b/docs/windsurf.md @@ -0,0 +1,165 @@ +--- +summary: "Windsurf provider data sources: Firebase IndexedDB tokens, local SQLite cache, and GetPlanStatus API." +read_when: + - Debugging Windsurf usage fetch + - Updating Windsurf Firebase token import or API handling + - Adjusting Windsurf provider UI/menu behavior +--- + +# Windsurf provider + +Windsurf supports two data sources: a web API (via Firebase tokens from browser IndexedDB) and a local SQLite cache. + +## Data sources + fallback order + +Usage source picker: +- Preferences → Providers → Windsurf → Usage source (Auto / Web API / Local). + +### Auto mode (default) +1) **Web API** (preferred) — real-time data from windsurf.com API. +2) **Local SQLite cache** (fallback) — reads from Windsurf's `state.vscdb`. + +### Web API fetch order +1) **Manual token** (when Cookie source = Manual). +2) **Cached Firebase access token** (Keychain cache `com.steipete.codexbar.cache`, account `cookie.windsurf`). +3) **Browser IndexedDB import** — extracts Firebase tokens from Chromium browsers. + +### Local SQLite cache +- File: `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb`. +- Key: `windsurf.settings.cachedPlanInfo` in `ItemTable`. +- Limitation: only updates when Windsurf is launched; can be significantly stale. + +## Cookie source settings + +Preferences → Providers → Windsurf → Cookie source: + +- **Automatic** (default): imports Firebase tokens from browser IndexedDB, caches access tokens in Keychain. +- **Manual**: paste a Firebase refresh token or access token directly (see below). +- **Off**: disables web API access entirely; only local SQLite cache is used. + +### How to get a manual token + +1. Open `https://windsurf.com/subscription/usage` in Chrome or Edge and sign in. +2. Open Developer Tools (F12 or Cmd+Option+I). +3. Go to the **Console** tab. +4. Paste the following JavaScript and press Enter: + +```javascript +(async () => { + const dbs = await indexedDB.databases(); + const fbDb = dbs.find(d => d.name === 'firebaseLocalStorageDb'); + if (!fbDb) { console.log('Not signed in'); return; } + const db = await new Promise((r, e) => { + const req = indexedDB.open(fbDb.name); + req.onsuccess = () => r(req.result); + req.onerror = () => e(req.error); + }); + const tx = db.transaction('firebaseLocalStorage', 'readonly'); + const store = tx.objectStore('firebaseLocalStorage'); + const all = await new Promise(r => { + const req = store.getAll(); + req.onsuccess = () => r(req.result); + }); + const entry = all.find(e => e.value?.stsTokenManager); + if (entry) { + const mgr = entry.value.stsTokenManager; + console.log('=== Refresh token (long-lived, recommended) ==='); + console.log(mgr.refreshToken); + console.log('=== Access token (expires ~1h) ==='); + console.log(mgr.accessToken); + } else { + console.log('No Firebase token found. Sign in to windsurf.com first.'); + } +})(); +``` + +5. Copy the **refresh token** (starts with `AMf-vB`, long-lived) from the console output. +6. In CodexBar: Providers → Windsurf → Cookie source → Manual → paste the token. + +**Note**: Both token types are accepted. Refresh tokens (`AMf-vB…`) are recommended — they persist across sessions and CodexBar will automatically exchange them for short-lived access tokens. Access tokens (`eyJ…`) expire after ~1 hour. + +## Authentication flow (Automatic mode) + +``` +Browser IndexedDB (LevelDB on disk) + ↓ extract Firebase refreshToken (prefix: AMf-vB...) +POST https://securetoken.googleapis.com/v1/token + ↓ exchange for accessToken (JWT, ~1h expiry) +POST https://windsurf.com/_backend/.../GetPlanStatus + ↓ body: { authToken, includeTopUpStatus: true } +UsageSnapshot (daily/weekly quota %) +``` + +## Firebase token extraction + +- **Browsers scanned**: Chrome, Edge, Brave, Arc, Vivaldi, Chromium, and other Chromium forks. +- **IndexedDB path**: `~/Library/Application Support///IndexedDB/https_windsurf.com_*.indexeddb.leveldb/` +- **Token patterns**: + - Refresh token: `AMf-vB` prefix (Google Identity Toolkit format). + - Access token: `eyJ` prefix (JWT). +- **Extraction methods** (in order): + 1. `ChromiumLocalStorageReader.readTextEntries()` (structured LevelDB read). + 2. `ChromiumLocalStorageReader.readTokenCandidates()` (raw token scan). + 3. Direct `.ldb`/`.log` file scan with regex (fallback). + +## API endpoints + +### Firebase token refresh +- `POST https://securetoken.googleapis.com/v1/token?key=AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY` +- Content-Type: `application/x-www-form-urlencoded` +- Body: `grant_type=refresh_token&refresh_token=` +- Returns: `{ "access_token": "...", "expires_in": "3600", ... }` + +### GetPlanStatus (ConnectRPC over JSON) +- `POST https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus` +- Headers: + - `Content-Type: application/json` + - `Connect-Protocol-Version: 1` +- Body: `{ "authToken": "", "includeTopUpStatus": true }` +- Response: +```json +{ + "planStatus": { + "planInfo": { "planName": "Pro", "teamsTier": "TEAMS_TIER_PRO" }, + "planStart": "2026-03-20T18:05:50Z", + "planEnd": "2026-04-20T18:05:50Z", + "dailyQuotaRemainingPercent": 68, + "weeklyQuotaRemainingPercent": 84, + "dailyQuotaResetAtUnix": "1774166400", + "weeklyQuotaResetAtUnix": "1774166400" + } +} +``` + +## Snapshot mapping +- Primary: daily usage percent (100 - dailyQuotaRemainingPercent). +- Secondary: weekly usage percent (100 - weeklyQuotaRemainingPercent). +- Reset: daily/weekly reset timestamps (Unix seconds as string). +- Plan: planName from planInfo. +- Expiry: planEnd date. + +## Troubleshooting + +### "No Firebase token found in browser IndexedDB" +- Sign in to `https://windsurf.com` in Chrome, Edge, or another Chromium browser. +- Grant Full Disk Access to CodexBar (System Settings → Privacy & Security → Full Disk Access). +- Try Manual mode and paste the token directly. + +### "Firebase token refresh failed" +- Your refresh token may have expired. Sign in to windsurf.com again in your browser. +- Check your internet connection. + +### "Windsurf API call failed: HTTP 401" +- The access token has expired. CodexBar will automatically refresh it on the next fetch. +- If using Manual mode, paste a fresh access token. + +### Stale data with Local mode +- The local SQLite cache only updates when Windsurf is launched. Switch to Auto or Web API mode for real-time data. + +## Key files +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift` (local SQLite) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfFirebaseTokenImporter.swift` (IndexedDB extraction) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift` (token refresh + API) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift` (fetch strategies) +- `Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift` (settings UI) +- `Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift` (settings persistence)