diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 6fb94b479..500821ec0 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() case .perplexity: PerplexityProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift new file mode 100644 index 000000000..04070618d --- /dev/null +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift @@ -0,0 +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/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 9bb258a14..d2945fa9e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1231,7 +1231,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, .perplexity: + .kimik2, .jetbrains, .windsurf, .perplexity: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index d52302847..7afd78b4c 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -184,7 +184,8 @@ struct TokenAccountCLIContext { perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) - 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 236af4bd3..b6469077f 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, .perplexity: PerplexityProviderDescriptor.descriptor, ] private static let bootstrap: Void = { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 7c1d1f786..b3101c366 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -19,6 +19,7 @@ public struct ProviderSettingsSnapshot: Sendable { amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( @@ -39,6 +40,7 @@ public struct ProviderSettingsSnapshot: Sendable { amp: amp, ollama: ollama, jetbrains: jetbrains, + windsurf: windsurf, perplexity: perplexity) } @@ -211,6 +213,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 struct PerplexityProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -238,6 +256,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let amp: AmpProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let windsurf: WindsurfProviderSettings? public let perplexity: PerplexityProviderSettings? public var jetbrainsIDEBasePath: String? { @@ -262,6 +281,7 @@ public struct ProviderSettingsSnapshot: Sendable { amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled @@ -281,6 +301,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.amp = amp self.ollama = ollama self.jetbrains = jetbrains + self.windsurf = windsurf self.perplexity = perplexity } } @@ -301,6 +322,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case amp(ProviderSettingsSnapshot.AmpProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case windsurf(ProviderSettingsSnapshot.WindsurfProviderSettings) case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) } @@ -322,6 +344,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 var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { @@ -346,6 +369,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 case let .perplexity(value): self.perplexity = value } } @@ -369,6 +393,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { amp: self.amp, ollama: self.ollama, jetbrains: self.jetbrains, + windsurf: self.windsurf, perplexity: self.perplexity) } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index ff0f8eeb4..f52ea21a7 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 case perplexity } @@ -55,6 +56,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case windsurf case perplexity case combined } 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 new file mode 100644 index 000000000..c236e9e50 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -0,0 +1,100 @@ +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, .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 + + 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..9061243db --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -0,0 +1,236 @@ +import Foundation + +// MARK: - Cached Plan Info (Codable) + +public struct WindsurfCachedPlanInfo: Codable, Sendable { + public let planName: String? + public let startTimestamp: Int64? + public let endTimestamp: Int64? + public let usage: Usage? + public let quotaUsage: QuotaUsage? + + public struct Usage: Codable, Sendable { + public let messages: Int? + public let usedMessages: Int? + public let remainingMessages: Int? + public let flowActions: Int? + public let usedFlowActions: Int? + public let remainingFlowActions: Int? + } + + public struct QuotaUsage: Codable, Sendable { + public let dailyRemainingPercent: Double? + public let weeklyRemainingPercent: Double? + public let dailyResetAtUnix: Int64? + public let weeklyResetAtUnix: Int64? + } +} + +// MARK: - Errors & Probe + +#if os(macOS) + +import SQLite3 + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case dbNotFound(String) + case sqliteFailed(String) + case noData + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case let .dbNotFound(path): + "Windsurf database not found at \(path). Ensure Windsurf is installed and has been launched at least once." + case let .sqliteFailed(message): + "SQLite error reading Windsurf data: \(message)" + case .noData: + "No plan data found in Windsurf database. Sign in to Windsurf first." + case let .parseFailed(message): + "Could not parse Windsurf plan data: \(message)" + } + } +} + +// MARK: - Probe + +public struct WindsurfStatusProbe: Sendable { + private static let defaultDBPath: String = { + let home = NSHomeDirectory() + return "\(home)/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + }() + + private static let query = "SELECT value FROM ItemTable WHERE key = 'windsurf.settings.cachedPlanInfo' LIMIT 1;" + + private let dbPath: String + + public init(dbPath: String? = nil) { + self.dbPath = dbPath ?? Self.defaultDBPath + } + + public func fetch() throws -> WindsurfCachedPlanInfo { + guard FileManager.default.fileExists(atPath: self.dbPath) else { + throw WindsurfStatusProbeError.dbNotFound(self.dbPath) + } + + var db: OpaquePointer? + guard sqlite3_open_v2(self.dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + + sqlite3_busy_timeout(db, 250) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, Self.query, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { + if stepResult == SQLITE_DONE { + throw WindsurfStatusProbeError.noData + } + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + + guard let jsonString = Self.decodeSQLiteValue(stmt: stmt, index: 0) else { + throw WindsurfStatusProbeError.noData + } + guard let jsonData = jsonString.data(using: .utf8) else { + throw WindsurfStatusProbeError.parseFailed("Invalid UTF-8 encoding") + } + + do { + return try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData) + } catch { + throw WindsurfStatusProbeError.parseFailed(error.localizedDescription) + } + } + + private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + switch sqlite3_column_type(stmt, index) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + // VSCode/Windsurf state.vscdb schema declares value as BLOB; + // 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 { + 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/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/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index f4a3ba8bb..d74c86b12 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, .perplexity: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .windsurf, .perplexity: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c0e600fa9..84ad8a6e3 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 case .perplexity: return nil // Perplexity not yet supported in widgets } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index e08b23367..8f19c0471 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" case .perplexity: "Pplx" } } @@ -622,6 +623,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 case .perplexity: Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal } 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() + } + } +} 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)