diff --git a/README.md b/README.md index 757fbf030..449cab65b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Windsurf, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -36,6 +36,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [Cursor](docs/cursor.md) β€” Browser session cookies for plan + usage + billing resets. - [Gemini](docs/gemini.md) β€” OAuth-backed quota API using Gemini CLI credentials (no browser cookies). - [Antigravity](docs/antigravity.md) β€” Local language server probe (experimental); no external auth. +- [Windsurf](docs/windsurf.md) β€” Local Windsurf plan cache (state.vscdb); messages/actions/credits. - [Droid](docs/factory.md) β€” Browser cookies + WorkOS token flows for Factory usage + billing. - [Copilot](docs/copilot.md) β€” GitHub device flow + Copilot internal usage API. - [z.ai](docs/zai.md) β€” API token (Keychain) for quota + MCP windows. diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 648a3fc5f..84cc0cd10 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -662,51 +662,39 @@ enum IconRenderer { remaining: topValue, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: bottomValue) - } else if !hasWeekly || warpNoBonus { - if style == .warp { - // Warp: no bonus or bonus exhausted -> top=monthly credits, bottom=dimmed track + } else if !hasWeekly { + // Weekly missing (e.g. Claude enterprise): keep normal layout but + // dim the bottom track to indicate N/A. + if topValue == nil, let ratio = creditsRatio { + // Credits-only: show credits prominently (e.g. credits loaded before usage). + drawBar( + rectPx: creditsRectPx, + remaining: ratio, + alpha: creditsAlpha, + addNotches: style == .claude, + addFace: style == .codex, + addGeminiTwist: style == .gemini || style == .antigravity, + addAntigravityTwist: style == .antigravity, + addFactoryTwist: style == .factory, + blink: blink) + drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) + } else { drawBar( rectPx: topRectPx, remaining: topValue, - addWarpTwist: true, + addNotches: style == .claude, + addFace: style == .codex, + addGeminiTwist: style == .gemini || style == .antigravity, + addAntigravityTwist: style == .antigravity, + addFactoryTwist: style == .factory, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) - } else { - // Weekly missing (e.g. Claude enterprise): keep normal layout but - // dim the bottom track to indicate N/A. - if topValue == nil, let ratio = creditsRatio { - // Credits-only: show credits prominently (e.g. credits loaded before usage). - drawBar( - rectPx: creditsRectPx, - remaining: ratio, - alpha: creditsAlpha, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, - blink: blink) - drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) - } else { - drawBar( - rectPx: topRectPx, - remaining: topValue, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, - blink: blink) - drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) - } } } else { // Weekly exhausted/missing: show credits on top (thicker), weekly (likely 0) on bottom. @@ -717,8 +705,8 @@ enum IconRenderer { alpha: creditsAlpha, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) @@ -729,8 +717,8 @@ enum IconRenderer { remaining: topValue, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index fef37cef9..d95d8d6f9 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -19,6 +19,7 @@ enum ProviderImplementationRegistry { case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() + case .windsurf: WindsurfProviderImplementation() case .copilot: CopilotProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() 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 a763642dd..c3d916696 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1150,6 +1150,8 @@ extension UsageStore { let keepCLISessionsAlive = self.settings.debugKeepCLISessionsAlive let cursorCookieSource = self.settings.cursorCookieSource let cursorCookieHeader = self.settings.cursorCookieHeader + let ampCookieSource = self.settings.ampCookieSource + let ampCookieHeader = self.settings.ampCookieHeader return await Task.detached(priority: .utility) { () -> String in switch provider { case .codex: @@ -1179,32 +1181,12 @@ extension UsageStore { let text = "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" await MainActor.run { self.probeLogs[.synthetic] = text } return text - case .gemini: - let text = "Gemini debug log not yet implemented" - await MainActor.run { self.probeLogs[.gemini] = text } - return text - case .antigravity: - let text = "Antigravity debug log not yet implemented" - await MainActor.run { self.probeLogs[.antigravity] = text } - return text case .cursor: let text = await self.debugCursorLog( cursorCookieSource: cursorCookieSource, cursorCookieHeader: cursorCookieHeader) await MainActor.run { self.probeLogs[.cursor] = text } return text - case .opencode: - let text = "OpenCode debug log not yet implemented" - await MainActor.run { self.probeLogs[.opencode] = text } - return text - case .factory: - let text = "Droid debug log not yet implemented" - await MainActor.run { self.probeLogs[.factory] = text } - return text - case .copilot: - let text = "Copilot debug log not yet implemented" - await MainActor.run { self.probeLogs[.copilot] = text } - return text case .minimax: let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() @@ -1215,36 +1197,16 @@ extension UsageStore { "source=\(cookieSource)" await MainActor.run { self.probeLogs[.minimax] = text } return text - case .vertexai: - let text = "Vertex AI debug log not yet implemented" - await MainActor.run { self.probeLogs[.vertexai] = text } - return text - case .kiro: - let text = "Kiro debug log not yet implemented" - await MainActor.run { self.probeLogs[.kiro] = text } - return text case .augment: let text = await self.debugAugmentLog() await MainActor.run { self.probeLogs[.augment] = text } return text - case .kimi: - let text = "Kimi debug log not yet implemented" - await MainActor.run { self.probeLogs[.kimi] = text } - return text - case .kimik2: - let text = "Kimi K2 debug log not yet implemented" - await MainActor.run { self.probeLogs[.kimik2] = text } - return text case .amp: let text = await self.debugAmpLog( - ampCookieSource: self.settings.ampCookieSource, - ampCookieHeader: self.settings.ampCookieHeader) + ampCookieSource: ampCookieSource, + ampCookieHeader: ampCookieHeader) await MainActor.run { self.probeLogs[.amp] = text } return text - case .jetbrains: - let text = "JetBrains AI debug log not yet implemented" - await MainActor.run { self.probeLogs[.jetbrains] = text } - return text case .warp: let resolution = ProviderTokenResolver.warpResolution() let hasAny = resolution != nil @@ -1252,10 +1214,44 @@ extension UsageStore { let text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" await MainActor.run { self.probeLogs[.warp] = text } return text + case .gemini, .antigravity, .windsurf, .opencode, .factory, .copilot, .vertexai, .kiro, .kimi, .kimik2, + .jetbrains: + let text = Self.notImplementedDebugLogText(for: provider) + await MainActor.run { self.probeLogs[provider] = text } + return text } }.value } + private nonisolated static func notImplementedDebugLogText(for provider: UsageProvider) -> String { + switch provider { + case .gemini: + "Gemini debug log not yet implemented" + case .antigravity: + "Antigravity debug log not yet implemented" + case .windsurf: + "Windsurf debug log not yet implemented" + case .opencode: + "OpenCode debug log not yet implemented" + case .factory: + "Droid debug log not yet implemented" + case .copilot: + "Copilot debug log not yet implemented" + case .vertexai: + "Vertex AI debug log not yet implemented" + case .kiro: + "Kiro debug log not yet implemented" + case .kimi: + "Kimi debug log not yet implemented" + case .kimik2: + "Kimi K2 debug log not yet implemented" + case .jetbrains: + "JetBrains AI debug log not yet implemented" + case .codex, .claude, .cursor, .zai, .minimax, .augment, .amp, .synthetic, .warp: + "Debug log not yet implemented" + } + } + private func debugClaudeLog( claudeWebExtrasEnabled: Bool, claudeUsageDataSource: ClaudeUsageDataSource, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 1b0bc5c8a..864f6f78a 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -147,7 +147,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp: + case .gemini, .antigravity, .windsurf, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp: return nil } } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 80e552223..964bf6a0c 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -60,6 +60,7 @@ public enum ProviderDescriptorRegistry { .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, + .windsurf: WindsurfProviderDescriptor.descriptor, .copilot: CopilotProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 3fc0de98c..3bbe37671 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -10,6 +10,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case factory case gemini case antigravity + case windsurf case copilot case zai case minimax @@ -33,6 +34,7 @@ public enum IconStyle: Sendable, CaseIterable { case minimax case gemini case antigravity + case windsurf case cursor case opencode case factory diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift new file mode 100644 index 000000000..c2dc7ff53 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift @@ -0,0 +1,164 @@ +import Foundation + +#if os(macOS) +import SQLite3 +#endif + +public enum WindsurfUsageError: LocalizedError, Sendable, Equatable { + case unsupportedPlatform + case dbMissing(URL) + case sqliteFailed(String) + case cachedPlanMissing + case decodeFailed + + public var errorDescription: String? { + switch self { + case .unsupportedPlatform: + "Windsurf usage tracking is only supported on macOS." + case let .dbMissing(url): + "Windsurf data not found at \(url.path). Launch Windsurf once and sign in, then refresh." + case let .sqliteFailed(message): + "Failed to read Windsurf usage: \(message)" + case .cachedPlanMissing: + "Windsurf cached plan usage is missing. Open Windsurf, then refresh." + case .decodeFailed: + "Could not decode Windsurf usage. Update Windsurf and retry." + } + } +} + +public enum WindsurfLocalStorageReader { + public static let envStateDBKey = "CODEXBAR_WINDSURF_STATE_DB" + public static let envStateDBFallbackKey = "WINDSURF_STATE_DB" + + /// Default: ~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb + public static func stateDBURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = cleaned(environment[envStateDBKey]) ?? cleaned(environment[envStateDBFallbackKey]) { + return URL(fileURLWithPath: override) + } + let home = FileManager.default.homeDirectoryForCurrentUser + return home + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Windsurf", isDirectory: true) + .appendingPathComponent("User", isDirectory: true) + .appendingPathComponent("globalStorage", isDirectory: true) + .appendingPathComponent("state.vscdb", isDirectory: false) + } + + public static func loadCachedPlanInfo(environment: [String: String]) throws -> WindsurfCachedPlanInfo { + #if os(macOS) + let dbURL = self.stateDBURL(environment: environment) + guard FileManager.default.fileExists(atPath: dbURL.path) else { + throw WindsurfUsageError.dbMissing(dbURL) + } + + var db: OpaquePointer? + let open = sqlite3_open_v2(dbURL.path, &db, SQLITE_OPEN_READONLY, nil) + guard open == SQLITE_OK, let db else { + let msg = db.flatMap { sqlite3_errmsg($0) }.map { String(cString: $0) } ?? "sqlite open failed" + throw WindsurfUsageError.sqliteFailed(msg) + } + defer { sqlite3_close(db) } + + sqlite3_busy_timeout(db, 250) + + let sql = "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + let msg = sqlite3_errmsg(db).map { String(cString: $0) } ?? "sqlite prepare failed" + throw WindsurfUsageError.sqliteFailed(msg) + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, "windsurf.settings.cachedPlanInfo", -1, transient) + + let step = sqlite3_step(stmt) + guard step == SQLITE_ROW else { + if step != SQLITE_DONE { + let msg = sqlite3_errmsg(db).map { String(cString: $0) } ?? "sqlite step failed" + throw WindsurfUsageError.sqliteFailed(msg) + } + throw WindsurfUsageError.cachedPlanMissing + } + + guard let bytes = sqlite3_column_blob(stmt, 0) else { + throw WindsurfUsageError.cachedPlanMissing + } + let byteCount = Int(sqlite3_column_bytes(stmt, 0)) + guard byteCount > 0 else { throw WindsurfUsageError.cachedPlanMissing } + + let data = Data(bytes: bytes, count: byteCount) + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { + throw WindsurfUsageError.decodeFailed + } + let decoded = try? JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(text.utf8)) + guard let decoded else { throw WindsurfUsageError.decodeFailed } + return decoded + #else + _ = environment + throw WindsurfUsageError.unsupportedPlatform + #endif + } + + public static func parseEpoch(_ value: Int?) -> Date? { + guard let value else { return nil } + if value >= 1_000_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(value) / 1000.0) + } + if value > 0 { + return Date(timeIntervalSince1970: TimeInterval(value)) + } + return nil + } + + public static func makeUsageSnapshot(info: WindsurfCachedPlanInfo, now: Date = Date()) throws -> UsageSnapshot { + guard let usage = info.usage else { + throw WindsurfUsageError.cachedPlanMissing + } + + let resetsAt = Self.parseEpoch(info.endTimestamp) + + func window(total: Int?, used: Int?) -> RateWindow? { + guard let total, total > 0, let used else { return nil } + let ratio = max(0, min(1.0, Double(used) / Double(total))) + return RateWindow( + usedPercent: ratio * 100.0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: nil) + } + + let primary = window(total: usage.messages, used: usage.usedMessages) + let secondary = window(total: usage.flowActions, used: usage.usedFlowActions) + let tertiary = window(total: usage.flexCredits, used: usage.usedFlexCredits) + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: nil, + loginMethod: info.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + updatedAt: now, + identity: identity) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift new file mode 100644 index 000000000..e725fea19 --- /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: "Messages", + weeklyLabel: "Actions", + opusLabel: "Credits", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Windsurf usage", + cliName: "windsurf", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: nil, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .windsurf, + iconResourceName: "ProviderIcon-windsurf", + color: ProviderColor(red: 14 / 255, green: 165 / 255, blue: 166 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Windsurf cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WindsurfLocalStorageFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "windsurf", + aliases: ["ws"], + versionDetector: nil)) + } +} + +struct WindsurfLocalStorageFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let info = try WindsurfLocalStorageReader.loadCachedPlanInfo(environment: context.env) + let usage = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info) + return self.makeResult( + usage: usage, + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift new file mode 100644 index 000000000..e84cae73a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct WindsurfCachedPlanInfo: Codable, Sendable, Equatable { + public let planName: String? + public let startTimestamp: Int? + public let endTimestamp: Int? + public let usage: WindsurfPlanUsage? + public let hasBillingWritePermissions: Bool? + + public init( + planName: String?, + startTimestamp: Int?, + endTimestamp: Int?, + usage: WindsurfPlanUsage?, + hasBillingWritePermissions: Bool?) + { + self.planName = planName + self.startTimestamp = startTimestamp + self.endTimestamp = endTimestamp + self.usage = usage + self.hasBillingWritePermissions = hasBillingWritePermissions + } +} + +public struct WindsurfPlanUsage: Codable, Sendable, Equatable { + public let duration: Int? + public let messages: Int? + public let flowActions: Int? + public let flexCredits: Int? + public let usedMessages: Int? + public let usedFlowActions: Int? + public let usedFlexCredits: Int? + public let remainingMessages: Int? + public let remainingFlowActions: Int? + public let remainingFlexCredits: Int? + + public init( + duration: Int?, + messages: Int?, + flowActions: Int?, + flexCredits: Int?, + usedMessages: Int?, + usedFlowActions: Int?, + usedFlexCredits: Int?, + remainingMessages: Int?, + remainingFlowActions: Int?, + remainingFlexCredits: Int?) + { + self.duration = duration + self.messages = messages + self.flowActions = flowActions + self.flexCredits = flexCredits + self.usedMessages = usedMessages + self.usedFlowActions = usedFlowActions + self.usedFlexCredits = usedFlexCredits + self.remainingMessages = remainingMessages + self.remainingFlowActions = remainingFlowActions + self.remainingFlexCredits = remainingFlexCredits + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 1936d7eff..b463b010b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -63,43 +63,14 @@ enum CostUsageScanner { return self.loadCodexDaily(range: range, now: now, options: options) case .claude: return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) - case .zai: - return CostUsageDailyReport(data: [], summary: nil) - case .gemini: - return CostUsageDailyReport(data: [], summary: nil) - case .antigravity: - return CostUsageDailyReport(data: [], summary: nil) - case .cursor: - return CostUsageDailyReport(data: [], summary: nil) - case .opencode: - return CostUsageDailyReport(data: [], summary: nil) - case .factory: - return CostUsageDailyReport(data: [], summary: nil) - case .copilot: - return CostUsageDailyReport(data: [], summary: nil) - case .minimax: - return CostUsageDailyReport(data: [], summary: nil) case .vertexai: var filtered = options if filtered.claudeLogProviderFilter == .all { filtered.claudeLogProviderFilter = .vertexAIOnly } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .kiro: - return CostUsageDailyReport(data: [], summary: nil) - case .kimi: - return CostUsageDailyReport(data: [], summary: nil) - case .kimik2: - return CostUsageDailyReport(data: [], summary: nil) - case .augment: - return CostUsageDailyReport(data: [], summary: nil) - case .jetbrains: - return CostUsageDailyReport(data: [], summary: nil) - case .amp: - return CostUsageDailyReport(data: [], summary: nil) - case .synthetic: - return CostUsageDailyReport(data: [], summary: nil) - case .warp: + case .zai, .gemini, .antigravity, .windsurf, .cursor, .opencode, .factory, .copilot, .minimax, .kiro, + .kimi, .kimik2, .augment, .jetbrains, .amp, .synthetic, .warp: return CostUsageDailyReport(data: [], summary: nil) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 0d46a0510..dd4117c6a 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -45,6 +45,7 @@ enum ProviderChoice: String, AppEnum { case .claude: self = .claude case .gemini: self = .gemini case .antigravity: self = .antigravity + case .windsurf: return nil // Windsurf not yet supported in widgets case .cursor: return nil // Cursor not yet supported in widgets case .opencode: self = .opencode case .zai: self = .zai diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 85ae62f42..2f270f048 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -261,6 +261,7 @@ private struct ProviderSwitchChip: View { case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" + case .windsurf: "Windsurf" case .cursor: "Cursor" case .opencode: "OpenCode" case .zai: "z.ai" @@ -578,6 +579,8 @@ enum WidgetColors { Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) case .antigravity: Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255) + case .windsurf: + Color(red: 14 / 255, green: 165 / 255, blue: 166 / 255) case .cursor: Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .opencode: diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index 66b3afa22..ac4f5d248 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -17,6 +17,7 @@ struct CLIProviderSelectionTests { "|cursor|", "|gemini|", "|antigravity|", + "|windsurf|", "|copilot|", "|synthetic|", "|kiro|", diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index e5c35d3b4..848bb3b6b 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -74,6 +74,8 @@ struct ClaudeOAuthCredentialsStoreTests { func loadRecord_nonInteractiveRepairCanBeDisabled() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try KeychainCacheStore.withServiceOverrideForTesting(service) { + // Make the test independent of any persisted debugDisableKeychainAccess UserDefaults value. + try KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -127,6 +129,7 @@ struct ClaudeOAuthCredentialsStoreTests { } } } + } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 1953257b2..13845623d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -157,6 +157,8 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try await KeychainCacheStore.withServiceOverrideForTesting(service) { + // Make the test independent of any persisted debugDisableKeychainAccess UserDefaults value. + try await KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -187,6 +189,7 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { #expect(available == true) } + } } } } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7ecbe7b26..97abcb92c 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -19,6 +19,7 @@ struct ProviderIconResourcesTests { "opencode", "gemini", "antigravity", + "windsurf", "factory", "copilot", ] diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 77e1b767c..c1b29522e 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -353,6 +353,7 @@ struct SettingsStoreTests { .opencode, .factory, .antigravity, + .windsurf, .copilot, .zai, .minimax, diff --git a/Tests/CodexBarTests/WindsurfUsageTests.swift b/Tests/CodexBarTests/WindsurfUsageTests.swift new file mode 100644 index 000000000..bf2e803f6 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfUsageTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct WindsurfUsageTests { + @Test + func decodesCachedPlanInfoAndBuildsUsageSnapshot() throws { + // Build representative JSON without embedding large numeric literals in a multiline string, + // which SwiftFormat can lint for grouping. + let obj: [String: Any] = [ + "planName": "Pro", + "startTimestamp": 1_735_689_600, + "endTimestamp": 1_738_368_000, + "usage": [ + "messages": 500, + "flowActions": 200, + "flexCredits": 1000, + "usedMessages": 125, + "usedFlowActions": 50, + "usedFlexCredits": 250, + "remainingMessages": 375, + "remainingFlowActions": 150, + "remainingFlexCredits": 750, + ], + "hasBillingWritePermissions": true, + ] + + let jsonData = try JSONSerialization.data(withJSONObject: obj) + let info = try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData) + let snap = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info, now: Date(timeIntervalSince1970: 0)) + + #expect(snap.primary?.usedPercent.rounded() == 25) + #expect(snap.secondary?.usedPercent.rounded() == 25) + #expect(snap.tertiary?.usedPercent.rounded() == 25) + #expect(snap.loginMethod(for: .windsurf) == "Pro") + #expect(snap.accountEmail(for: .windsurf) == nil) + } + + @Test + func parseEpochSupportsSecondsAndMilliseconds() { + let seconds = WindsurfLocalStorageReader.parseEpoch(1_738_368_000) + let millis = WindsurfLocalStorageReader.parseEpoch(1_738_368_000_000) + #expect(seconds != nil) + #expect(millis != nil) + #expect(abs((seconds?.timeIntervalSince1970 ?? 0) - (millis?.timeIntervalSince1970 ?? 0)) < 1.0) + } + + @Test + func omitsWindowsWhenTotalsAreZero() throws { + let info = WindsurfCachedPlanInfo( + planName: "Free", + startTimestamp: 1, + endTimestamp: 2, + usage: WindsurfPlanUsage( + duration: nil, + messages: 0, + flowActions: 0, + flexCredits: 0, + usedMessages: 0, + usedFlowActions: 0, + usedFlexCredits: 0, + remainingMessages: 0, + remainingFlowActions: 0, + remainingFlexCredits: 0), + hasBillingWritePermissions: nil) + + let snap = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info, now: Date()) + #expect(snap.primary == nil) + #expect(snap.secondary == nil) + #expect(snap.tertiary == nil) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 467207988..f2f4958af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,7 +72,7 @@ All provider fields are optional unless noted. ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`. +`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `windsurf`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/providers.md b/docs/providers.md index dd21169bf..df1563c40 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -22,6 +22,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Claude | App Auto: OAuth API (`oauth`) β†’ CLI PTY (`claude`) β†’ Web API (`web`). CLI Auto: Web API (`web`) β†’ CLI PTY (`claude`). | | Gemini | OAuth API via Gemini CLI credentials (`api`). | | Antigravity | Local LSP/HTTP probe (`local`). | +| Windsurf | Local storage plan cache from `state.vscdb` (`local`). | | Cursor | Web API via cookies β†’ stored WebKit session (`web`). | | OpenCode | Web dashboard via cookies (`web`). | | Droid/Factory | Web cookies β†’ stored tokens β†’ local storage β†’ WorkOS cookies (`web`). | @@ -91,6 +92,14 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: Google Workspace incidents (Gemini product). - Details: `docs/antigravity.md`. +## Windsurf +- Reads Windsurf’s local VS Code-style global storage DB: + - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` + - Key: `windsurf.settings.cachedPlanInfo` +- Shows up to three meters (Messages, Flow Actions, Flex Credits) and uses `endTimestamp` as reset time. +- No network requests and no cookies required. +- Details: `docs/windsurf.md`. + ## Cursor - Web API via browser cookies (`cursor.com` + `cursor.sh`). - Fallback: stored WebKit session. diff --git a/docs/windsurf.md b/docs/windsurf.md new file mode 100644 index 000000000..32e2bde1f --- /dev/null +++ b/docs/windsurf.md @@ -0,0 +1,53 @@ +--- +summary: "Windsurf provider notes: local state.vscdb parsing, fields, and troubleshooting." +read_when: + - Adding or modifying the Windsurf provider + - Debugging missing Windsurf usage + - Explaining Windsurf usage data sources +--- + +# Windsurf provider + +Windsurf usage tracking is **local-only**. CodexBar reads Windsurf's cached plan usage from Windsurf's VS Code-style +global storage SQLite database. + +## Data source + +- Default path (macOS): + - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` +- Table: `ItemTable` +- Key: `windsurf.settings.cachedPlanInfo` + +The value is JSON that includes: +- `planName` +- `startTimestamp` / `endTimestamp` (epoch seconds or milliseconds) +- `usage` totals and used counts for: + - messages + - flowActions + - flexCredits + +CodexBar maps these into: +- Primary: Messages +- Secondary: Flow Actions +- Tertiary: Flex Credits +- Reset time: `endTimestamp` + +## Overrides + +For debugging/tests you can override the DB path: +- `CODEXBAR_WINDSURF_STATE_DB=/absolute/path/to/state.vscdb` +- `WINDSURF_STATE_DB=/absolute/path/to/state.vscdb` (fallback) + +## Troubleshooting + +If CodexBar shows "Windsurf data not found" or "cached plan usage is missing": +1. Launch Windsurf. +2. Sign in (if needed). +3. Open the Windsurf settings/plan page once so the plan cache is populated. +4. Refresh CodexBar. + +## Privacy + +CodexBar reads a single SQLite value from a local DB. It does not send this data anywhere and does not write back to +Windsurf's files. +