From e32f49a3d182d14f2fb9ff6d5485aff2730ebd2c Mon Sep 17 00:00:00 2001 From: roshan-c Date: Sat, 27 Dec 2025 11:27:18 +0000 Subject: [PATCH] feat: implement Copilot provider integration with token storage and login flow --- Sources/CodexBar/CopilotTokenStore.swift | 110 ++++++++++++++++++ .../CodexBar/CostHistoryChartMenuView.swift | 2 + Sources/CodexBar/IconRenderer.swift | 6 +- Sources/CodexBar/MenuCardView.swift | 6 +- Sources/CodexBar/MenuDescriptor.swift | 4 + Sources/CodexBar/PreferencesGeneralPane.swift | 3 + .../CodexBar/PreferencesProvidersPane.swift | 4 +- Sources/CodexBar/ProviderBrandIcon.swift | 1 + .../Providers/Copilot/CopilotLoginFlow.swift | 106 +++++++++++++++++ .../CopilotProviderImplementation.swift | 54 +++++++++ .../Providers/Shared/ProviderCatalog.swift | 1 + .../Providers/Shared/ProviderLoginFlow.swift | 3 + .../CodexBar/SessionQuotaNotifications.swift | 1 + Sources/CodexBar/SettingsStore.swift | 41 ++++++- .../StatusItemController+Actions.swift | 1 + .../CodexBar/StatusItemController+Menu.swift | 3 + Sources/CodexBar/StatusItemController.swift | 1 + Sources/CodexBar/UsageStore+TokenCost.swift | 2 + Sources/CodexBar/UsageStore.swift | 18 ++- Sources/CodexBarCLI/CLIEntry.swift | 17 ++- Sources/CodexBarCore/CopilotUsageModels.swift | 39 +++++++ .../Providers/Copilot/CopilotDeviceFlow.swift | 101 ++++++++++++++++ .../Copilot/CopilotUsageFetcher.swift | 69 +++++++++++ .../CodexBarCore/Providers/Providers.swift | 15 +++ .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 4 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + 27 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 Sources/CodexBar/CopilotTokenStore.swift create mode 100644 Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift create mode 100644 Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift create mode 100644 Sources/CodexBarCore/CopilotUsageModels.swift create mode 100644 Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift create mode 100644 Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift diff --git a/Sources/CodexBar/CopilotTokenStore.swift b/Sources/CodexBar/CopilotTokenStore.swift new file mode 100644 index 00000000..bd877c54 --- /dev/null +++ b/Sources/CodexBar/CopilotTokenStore.swift @@ -0,0 +1,110 @@ +import CodexBarCore +import Foundation +import Security + +protocol CopilotTokenStoring: Sendable { + func loadToken() throws -> String? + func storeToken(_ token: String?) throws +} + +enum CopilotTokenStoreError: LocalizedError { + case keychainStatus(OSStatus) + case invalidData + + var errorDescription: String? { + switch self { + case let .keychainStatus(status): + "Keychain error: \(status)" + case .invalidData: + "Keychain returned invalid data." + } + } +} + +struct KeychainCopilotTokenStore: CopilotTokenStoring { + private static let log = CodexBarLog.logger("copilot-token-store") + + private let service = "com.steipete.CodexBar" + private let account = "copilot-api-token" + + func loadToken() throws -> String? { + var result: CFTypeRef? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.service, + kSecAttrAccount as String: self.account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + Self.log.error("Keychain read failed: \(status)") + throw CopilotTokenStoreError.keychainStatus(status) + } + + guard let data = result as? Data else { + throw CopilotTokenStoreError.invalidData + } + let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let token, !token.isEmpty { + return token + } + return nil + } + + func storeToken(_ token: String?) throws { + let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned == nil || cleaned?.isEmpty == true { + try self.deleteTokenIfPresent() + return + } + + let data = cleaned!.data(using: .utf8)! + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.service, + kSecAttrAccount as String: self.account, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { + return + } + if updateStatus != errSecItemNotFound { + Self.log.error("Keychain update failed: \(updateStatus)") + throw CopilotTokenStoreError.keychainStatus(updateStatus) + } + + var addQuery = query + for (key, value) in attributes { + addQuery[key] = value + } + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + Self.log.error("Keychain add failed: \(addStatus)") + throw CopilotTokenStoreError.keychainStatus(addStatus) + } + } + + private func deleteTokenIfPresent() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.service, + kSecAttrAccount as String: self.account, + ] + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + return + } + Self.log.error("Keychain delete failed: \(status)") + throw CopilotTokenStoreError.keychainStatus(status) + } +} diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 84e78174..96609647 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -200,6 +200,8 @@ struct CostHistoryChartMenuView: View { Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .factory: Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange + case .copilot: + Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple } } diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 7979a048..53c96f9d 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -685,9 +685,11 @@ enum IconRenderer { case .gemini: 3 case .antigravity: 4 case .cursor: 5 - case .combined: 6 - case .factory: 7 + case .factory: 6 + case .copilot: 7 + case .combined: 99 } + } private static func indicatorKey(_ indicator: ProviderStatusIndicator) -> Int { diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5d7c5b28..30f3495a 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -592,7 +592,7 @@ extension UsageMenuCardView.Model { case .codex: if let email = snapshot?.accountEmail, !email.isEmpty { return email } if let email = account.email, !email.isEmpty { return email } - case .claude, .zai, .gemini, .antigravity, .cursor, .factory: + case .claude, .zai, .gemini, .antigravity, .cursor, .factory, .copilot: if let email = snapshot?.accountEmail, !email.isEmpty { return email } } return "" @@ -603,7 +603,7 @@ extension UsageMenuCardView.Model { case .codex: if let plan = snapshot?.loginMethod, !plan.isEmpty { return self.planDisplay(plan) } if let plan = account.plan, !plan.isEmpty { return Self.planDisplay(plan) } - case .claude, .zai, .gemini, .antigravity, .cursor, .factory: + case .claude, .zai, .gemini, .antigravity, .cursor, .factory, .copilot: if let plan = snapshot?.loginMethod, !plan.isEmpty { return self.planDisplay(plan) } } return nil @@ -787,6 +787,8 @@ extension UsageMenuCardView.Model { Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .factory: Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange + case .copilot: + Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple } } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index c541f075..25f291bf 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -87,8 +87,12 @@ struct MenuDescriptor { case .factory?: sections.append(Self.usageSection(for: .factory, store: store, settings: settings)) sections.append(Self.accountSectionForSnapshot(store.snapshot(for: .factory))) + case .copilot?: + sections.append(Self.usageSection(for: .copilot, store: store, settings: settings)) + sections.append(Self.accountSectionForSnapshot(store.snapshot(for: .copilot))) case nil: var addedUsage = false + for enabledProvider in store.enabledProviders() { sections.append(Self.usageSection(for: enabledProvider, store: store, settings: settings)) addedUsage = true diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 86dac814..a29e15a6 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -115,7 +115,10 @@ struct GeneralPane: View { "Cursor" case .factory: "Droid" + case .copilot: + "Copilot" } + guard provider == .claude || provider == .codex else { return Text("\(name): unsupported") .font(.footnote) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 5b84b7e8..a23ca73d 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -137,7 +137,9 @@ struct ProvidersPane: View { case .antigravity: return "local" case .factory: - return "web" + return "api" + case .copilot: + return "api" } } diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index 2bc2d837..22e5af61 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -44,6 +44,7 @@ enum ProviderBrandIcon { case .factory: "ProviderIcon-factory" case .gemini: "ProviderIcon-gemini" case .antigravity: "ProviderIcon-antigravity" + case .copilot: "ProviderIcon-copilot" } } } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift new file mode 100644 index 00000000..49747dac --- /dev/null +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -0,0 +1,106 @@ +import AppKit +import CodexBarCore +import SwiftUI + +@MainActor +struct CopilotLoginFlow { + static func run(settings: SettingsStore) async { + let flow = CopilotDeviceFlow() + + do { + let code = try await flow.requestDeviceCode() + + // Copy code to clipboard + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(code.userCode, forType: .string) + + let alert = NSAlert() + alert.messageText = "GitHub Copilot Login" + alert.informativeText = "A device code has been copied to your clipboard: \(code.userCode)\n\nPlease verify it at: \(code.verificationUri)" + alert.addButton(withTitle: "Open Browser") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertSecondButtonReturn { + return // Cancelled + } + + if let url = URL(string: code.verificationUri) { + NSWorkspace.shared.open(url) + } + + // Poll in background (modal blocks, but we need to wait for token effectively) + // Ideally we'd show a "Waiting..." modal or spinner. + // For simplicity, we can use a non-modal window or just block a Task? + // `runModal` blocks the thread. We need to poll while the user is doing auth in browser. + // But we already returned from runModal to open the browser. + // We need a secondary "Waiting for confirmation..." alert or state. + + // Let's show a "Waiting" alert that can be cancelled + let waitingAlert = NSAlert() + waitingAlert.messageText = "Waiting for Authentication..." + waitingAlert.informativeText = "Please complete the login in your browser.\nThis window will close automatically when finished." + waitingAlert.addButton(withTitle: "Cancel") + + // Poll in a detached task + let tokenTask = Task { + try await flow.pollForToken(deviceCode: code.deviceCode, interval: code.interval) + } + + // Show the modal. If user clicks Cancel, we cancel the task. + // We need a way to close the modal programmatically when task finishes. + // NSAlert doesn't support programmatic closing easily in runModal. + // We'll use a custom window or just hope the user waits? + // Actually, we can use `beginSheetModal` but we are not attached to a window necessarily. + + // Hack: Poll loop checks `tokenTask` status? No. + // Better: Just loop polling here (blocking) but that freezes UI? + // No, `await` doesn't freeze UI if on MainActor? `alert.runModal` DOES freeze UI loop. + + // Alternative: Don't use a second modal. Just set status in Settings? + // But we want to confirm success. + + // Let's try: + // 1. Alert 1: "Copy Code & Open Browser". Buttons: "Open & Wait", "Cancel". + // 2. If "Open & Wait": Launch browser, then show Alert 2: "Waiting... [Cancel]". + // 3. Background task polls. If success, it uses `NSApp.abortModal` to close Alert 2? + + // Implementing `abortModal` logic: + + Task { + do { + let token = try await flow.pollForToken(deviceCode: code.deviceCode, interval: code.interval) + await MainActor.run { + NSApp.stopModal(withCode: .alertFirstButtonReturn) // Success + settings.copilotAPIToken = token + settings.setProviderEnabled( + provider: .copilot, + metadata: ProviderRegistry.shared.metadata[.copilot]!, + enabled: true) + + let success = NSAlert() + success.messageText = "Login Successful" + success.runModal() + } + } catch { + await MainActor.run { + NSApp.stopModal(withCode: .alertSecondButtonReturn) // Failure + // Don't show error if just cancelled, but here we might want to. + } + } + } + + let waitResponse = waitingAlert.runModal() + if waitResponse == .alertFirstButtonReturn { // Cancel button (it's the only one) + tokenTask.cancel() + } + + } catch { + let err = NSAlert() + err.messageText = "Login Failed" + err.informativeText = error.localizedDescription + err.runModal() + } + } +} diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift new file mode 100644 index 00000000..16174ecf --- /dev/null +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -0,0 +1,54 @@ +import AppKit +import CodexBarCore +import SwiftUI + +struct CopilotProviderImplementation: ProviderImplementation { + let id: UsageProvider = .copilot + let style: IconStyle = .copilot + + func makeFetch(context: ProviderBuildContext) -> @Sendable () async throws -> UsageSnapshot { + let settings = context.settings + return { + let token = await settings.copilotAPIToken + guard !token.isEmpty else { + throw URLError(.userAuthenticationRequired) + } + return try await CopilotUsageFetcher(token: token).fetch() + } + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "copilot-api-token", + title: "GitHub Login", + subtitle: "Requires authentication via GitHub Device Flow.", + kind: .secure, + placeholder: "Sign in via button below", + binding: context.stringBinding(\.copilotAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "copilot-login", + title: "Sign in with GitHub", + style: .bordered, + isVisible: { context.settings.copilotAPIToken.isEmpty }, + perform: { + await CopilotLoginFlow.run(settings: context.settings) + } + ), + ProviderSettingsActionDescriptor( + id: "copilot-relogin", + title: "Sign in again", + style: .link, + isVisible: { !context.settings.copilotAPIToken.isEmpty }, + perform: { + await CopilotLoginFlow.run(settings: context.settings) + } + ) + ], + isVisible: nil + ) + ] + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderCatalog.swift b/Sources/CodexBar/Providers/Shared/ProviderCatalog.swift index 693ddc55..07ebb5eb 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCatalog.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCatalog.swift @@ -14,6 +14,7 @@ enum ProviderCatalog { GeminiProviderImplementation(), AntigravityProviderImplementation(), FactoryProviderImplementation(), + CopilotProviderImplementation(), ] /// Lookup for a single provider implementation. diff --git a/Sources/CodexBar/Providers/Shared/ProviderLoginFlow.swift b/Sources/CodexBar/Providers/Shared/ProviderLoginFlow.swift index 62c75eeb..a994b2e8 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderLoginFlow.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderLoginFlow.swift @@ -27,6 +27,9 @@ extension StatusItemController { case .factory: await self.runFactoryLoginFlow() return true + case .copilot: + await CopilotLoginFlow.run(settings: self.settings) + return true } } } diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 614b426e..c348ca82 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -46,6 +46,7 @@ final class SessionQuotaNotifier { case .antigravity: "Antigravity" case .cursor: "Cursor" case .factory: "Droid" + case .copilot: "Copilot" } let (title, body) = switch transition { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 7c016554..6201f37d 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -132,6 +132,11 @@ final class SettingsStore { didSet { self.schedulePersistZaiAPIToken() } } + /// Copilot API token (stored in Keychain). + var copilotAPIToken: String { + didSet { self.schedulePersistCopilotAPIToken() } + } + private var selectedMenuProviderRaw: String? { didSet { if let raw = self.selectedMenuProviderRaw { @@ -183,6 +188,7 @@ final class SettingsStore { _ = self.mergeIcons _ = self.switcherShowsIcons _ = self.zaiAPIToken + _ = self.copilotAPIToken _ = self.debugLoadingPattern _ = self.selectedMenuProvider _ = self.providerToggleRevision @@ -197,11 +203,18 @@ final class SettingsStore { @ObservationIgnored private let toggleStore: ProviderToggleStore @ObservationIgnored private let zaiTokenStore: any ZaiTokenStoring @ObservationIgnored private var zaiTokenPersistTask: Task? + @ObservationIgnored private let copilotTokenStore: any CopilotTokenStoring + @ObservationIgnored private var copilotTokenPersistTask: Task? private var providerToggleRevision: Int = 0 - init(userDefaults: UserDefaults = .standard, zaiTokenStore: any ZaiTokenStoring = KeychainZaiTokenStore()) { + init( + userDefaults: UserDefaults = .standard, + zaiTokenStore: any ZaiTokenStoring = KeychainZaiTokenStore(), + copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore()) + { self.userDefaults = userDefaults self.zaiTokenStore = zaiTokenStore + self.copilotTokenStore = copilotTokenStore self.providerOrderRaw = userDefaults.stringArray(forKey: "providerOrder") ?? [] let raw = userDefaults.string(forKey: "refreshFrequency") ?? RefreshFrequency.fiveMinutes.rawValue self.refreshFrequency = RefreshFrequency(rawValue: raw) ?? .fiveMinutes @@ -229,6 +242,7 @@ final class SettingsStore { self.mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true self.switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true self.zaiAPIToken = (try? zaiTokenStore.loadToken()) ?? "" + self.copilotAPIToken = (try? copilotTokenStore.loadToken()) ?? "" self.selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") self.providerDetectionCompleted = userDefaults.object( forKey: "providerDetectionCompleted") as? Bool ?? false @@ -438,6 +452,31 @@ final class SettingsStore { } } } + + private func schedulePersistCopilotAPIToken() { + self.copilotTokenPersistTask?.cancel() + let token = self.copilotAPIToken + let tokenStore = self.copilotTokenStore + self.copilotTokenPersistTask = Task { @MainActor in + do { + try await Task.sleep(nanoseconds: 350_000_000) + } catch { + return + } + guard !Task.isCancelled else { return } + let error: (any Error)? = await Task.detached(priority: .utility) { () -> (any Error)? in + do { + try tokenStore.storeToken(token) + return nil + } catch { + return error + } + }.value + if let error { + CodexBarLog.logger("copilot-token-store").error("Failed to persist Copilot token: \(error)") + } + } + } } enum LaunchAtLoginManager { diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 685983d6..d2a974cb 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -246,6 +246,7 @@ extension StatusItemController { case .antigravity: "Antigravity login successful" case .cursor: "Cursor login successful" case .factory: "Droid login successful" + case .copilot: "Copilot login successful" } let body = "You can return to the app; authentication finished." AppNotifications.shared.post(idPrefix: "login-\(provider.rawValue)", title: title, body: body) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index c4d07bf4..f3f25b2b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1323,6 +1323,8 @@ private final class ProviderSwitcherView: NSView { NSColor(deviceRed: 0 / 255, green: 191 / 255, blue: 165 / 255, alpha: 1) // #00BFA5 case .factory: NSColor(deviceRed: 255 / 255, green: 107 / 255, blue: 53 / 255, alpha: 1) // Factory orange + case .copilot: + NSColor(deviceRed: 168 / 255, green: 85 / 255, blue: 247 / 255, alpha: 1) // Purple } } @@ -1335,6 +1337,7 @@ private final class ProviderSwitcherView: NSView { case .antigravity: "Antigravity" case .cursor: "Cursor" case .factory: "Droid" + case .copilot: "Copilot" } } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 80cb736d..5c99e33b 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -310,6 +310,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin case .antigravity: "Antigravity" case .cursor: "Cursor" case .factory: "Droid" + case .copilot: "Copilot" } return "\(prefix): \(base)" } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 6aa5377c..371837f6 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -60,6 +60,8 @@ extension UsageStore { return "Cursor cost summary is not supported." case .factory: return "Droid cost summary is not supported." + case .copilot: + return "Copilot cost summary is not supported." } } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index ed84e5c7..0d99f905 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -11,6 +11,7 @@ enum IconStyle { case antigravity case cursor case factory + case copilot case combined } @@ -268,6 +269,7 @@ final class UsageStore { case .antigravity: self.antigravityVersion case .cursor: self.cursorVersion case .factory: nil + case .copilot: nil } } @@ -290,6 +292,12 @@ final class UsageStore { if self.isEnabled(.cursor), let snap = self.snapshots[.cursor] { return snap } + if self.isEnabled(.factory), let snap = self.snapshots[.factory] { + return snap + } + if self.isEnabled(.copilot), let snap = self.snapshots[.copilot] { + return snap + } return nil } @@ -301,6 +309,8 @@ final class UsageStore { if self.isEnabled(.gemini) { return .gemini } if self.isEnabled(.zai) { return .zai } if self.isEnabled(.claude) { return .claude } + if self.isEnabled(.factory) { return .factory } + if self.isEnabled(.copilot) { return .copilot } return .codex } @@ -310,7 +320,9 @@ final class UsageStore { (self.isEnabled(.zai) && self.errors[.zai] != nil) || (self.isEnabled(.gemini) && self.errors[.gemini] != nil) || (self.isEnabled(.antigravity) && self.errors[.antigravity] != nil) || - (self.isEnabled(.cursor) && self.errors[.cursor] != nil) + (self.isEnabled(.cursor) && self.errors[.cursor] != nil) || + (self.isEnabled(.factory) && self.errors[.factory] != nil) || + (self.isEnabled(.copilot) && self.errors[.copilot] != nil) } func enabledProviders() -> [UsageProvider] { @@ -1145,6 +1157,10 @@ extension UsageStore { 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 } }.value } diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 1b1f4a6c..1fef067f 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -251,6 +251,8 @@ enum CodexBarCLI { nil case .factory: nil + case .copilot: + nil } } @@ -263,6 +265,7 @@ enum CodexBarCLI { case .antigravity: "antigravity" case .cursor: "cursor" case .factory: "factory" + case .copilot: "copilot" } guard let raw, !raw.isEmpty else { return (nil, source) } if let match = raw.range(of: #"(\d+(?:\.\d+)+)"#, options: .regularExpression) { @@ -504,6 +507,14 @@ enum CodexBarCLI { let probe = FactoryStatusProbe() let snap = try await probe.fetch() return .success((usage: snap.toUsageSnapshot(), credits: nil)) + case .copilot: + let env = ProcessInfo.processInfo.environment + guard let token = env["COPILOT_API_TOKEN"], !token.isEmpty else { + return .failure(URLError(.userAuthenticationRequired)) // Or custom error + } + let fetcher = CopilotUsageFetcher(token: token) + let snap = try await fetcher.fetch() + return .success((usage: snap, credits: nil)) } } catch { return .failure(error) @@ -917,6 +928,7 @@ enum ProviderSelection: Sendable, ExpressibleFromArgument { case antigravity case cursor case factory + case copilot case both case all case custom([UsageProvider]) @@ -930,6 +942,7 @@ enum ProviderSelection: Sendable, ExpressibleFromArgument { case "antigravity": self = .antigravity case "cursor": self = .cursor case "factory": self = .factory + case "copilot": self = .copilot case "both": self = .both case "all": self = .all default: return nil @@ -945,6 +958,7 @@ enum ProviderSelection: Sendable, ExpressibleFromArgument { case .antigravity: self = .antigravity case .cursor: self = .cursor case .factory: self = .factory + case .copilot: self = .copilot // Custom case not directly supported by arg parser unless we add it } } @@ -957,8 +971,9 @@ enum ProviderSelection: Sendable, ExpressibleFromArgument { case .antigravity: [.antigravity] case .cursor: [.cursor] case .factory: [.factory] + case .copilot: [.copilot] case .both: [.codex, .claude] - case .all: [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory] + case .all: [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot] case let .custom(providers): providers } } diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift new file mode 100644 index 00000000..cd43664f --- /dev/null +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct CopilotUsageResponse: Sendable, Decodable { + public struct QuotaSnapshot: Sendable, Decodable { + public let entitlement: Double + public let remaining: Double + public let percentRemaining: Double + public let quotaId: String + + private enum CodingKeys: String, CodingKey { + case entitlement + case remaining + case percentRemaining = "percent_remaining" + case quotaId = "quota_id" + } + } + + public struct QuotaSnapshots: Sendable, Decodable { + public let premiumInteractions: QuotaSnapshot? + public let chat: QuotaSnapshot? + + private enum CodingKeys: String, CodingKey { + case premiumInteractions = "premium_interactions" + case chat + } + } + + public let quotaSnapshots: QuotaSnapshots + public let copilotPlan: String + public let assignedDate: String + public let quotaResetDate: String + + private enum CodingKeys: String, CodingKey { + case quotaSnapshots = "quota_snapshots" + case copilotPlan = "copilot_plan" + case assignedDate = "assigned_date" + case quotaResetDate = "quota_reset_date" + } +} diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift new file mode 100644 index 00000000..8886bee6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift @@ -0,0 +1,101 @@ +import Foundation + +public struct CopilotDeviceFlow: Sendable { + private let clientID = "Iv1.b507a08c87ecfe98" // VS Code Client ID + private let scopes = "read:user" + + public struct DeviceCodeResponse: Decodable, Sendable { + public let deviceCode: String + public let userCode: String + public let verificationUri: String + public let expiresIn: Int + public let interval: Int + + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationUri = "verification_uri" + case expiresIn = "expires_in" + case interval + } + } + + public struct AccessTokenResponse: Decodable, Sendable { + public let accessToken: String + public let tokenType: String + public let scope: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case scope + } + } + + public init() {} + + public func requestDeviceCode() async throws -> DeviceCodeResponse { + var components = URLComponents(string: "https://github.com/login/device/code")! + let request = URLRequest(url: components.url!) + + var postRequest = request + postRequest.httpMethod = "POST" + postRequest.setValue("application/json", forHTTPHeaderField: "Accept") + postRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = [ + "client_id": self.clientID, + "scope": self.scopes + ] + postRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: postRequest) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + return try JSONDecoder().decode(DeviceCodeResponse.self, from: data) + } + + public func pollForToken(deviceCode: String, interval: Int) async throws -> String { + let url = URL(string: "https://github.com/login/oauth/access_token")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = [ + "client_id": self.clientID, + "device_code": deviceCode, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code" + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + while true { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + + let (data, _) = try await URLSession.shared.data(for: request) + + // Check for error in JSON + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? String { + if error == "authorization_pending" { + continue + } + if error == "slow_down" { + try await Task.sleep(nanoseconds: 5_000_000_000) // Add 5s + continue + } + if error == "expired_token" { + throw URLError(.timedOut) + } + throw URLError(.userAuthenticationRequired) // Generic failure + } + + if let tokenResponse = try? JSONDecoder().decode(AccessTokenResponse.self, from: data) { + return tokenResponse.accessToken + } + } + } +} diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift new file mode 100644 index 00000000..1561b7dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -0,0 +1,69 @@ +import Foundation + +public struct CopilotUsageFetcher: Sendable { + private let token: String + + public init(token: String) { + self.token = token + } + + public func fetch() async throws -> UsageSnapshot { + guard let url = URL(string: "https://api.github.com/copilot_internal/user") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + // Use the GitHub OAuth token directly, not the Copilot token. + request.setValue("token \(self.token)", forHTTPHeaderField: "Authorization") + self.addCommonHeaders(to: &request) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } + + guard httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: data) + + let primary = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions) + let secondary = self.makeRateWindow(from: usage.quotaSnapshots.chat) + + return UsageSnapshot( + primary: primary ?? .init(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + accountEmail: nil, + accountOrganization: nil, + loginMethod: usage.copilotPlan.capitalized) + } + + private func addCommonHeaders(to request: inout URLRequest) { + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version") + request.setValue("copilot-chat/0.26.7", forHTTPHeaderField: "Editor-Plugin-Version") + request.setValue("GitHubCopilotChat/0.26.7", forHTTPHeaderField: "User-Agent") + request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version") + } + + private func makeRateWindow(from snapshot: CopilotUsageResponse.QuotaSnapshot?) -> RateWindow? { + guard let snapshot else { return nil } + // percent_remaining is 0-100 based on the JSON example in the web app source + let usedPercent = max(0, 100 - snapshot.percentRemaining) + + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, // Not provided + resetsAt: nil, // Not provided per-quota in the simplified snapshot + resetDescription: nil) + } +} diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index bca077ae..5d71f29c 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -8,6 +8,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case cursor case gemini case antigravity + case copilot } public struct ProviderMetadata: Sendable { @@ -166,6 +167,20 @@ public enum ProviderDefaults { statusPageURL: nil, statusLinkURL: "https://www.google.com/appsstatus/dashboard/products/npdyhgECDJ6tB66MxXyo/history", statusWorkspaceProductID: "npdyhgECDJ6tB66MxXyo"), + .copilot: ProviderMetadata( + id: .copilot, + displayName: "Copilot", + sessionLabel: "Premium", + weeklyLabel: "Chat", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Copilot usage", + cliName: "copilot", + defaultEnabled: false, + dashboardURL: "https://github.com/settings/copilot", + statusPageURL: "https://www.githubstatus.com/"), .factory: ProviderMetadata( id: .factory, displayName: "Droid", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 45601c9b..e2bdac94 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -54,6 +54,8 @@ enum CostUsageScanner { return CCUsageDailyReport(data: [], summary: nil) case .factory: return CCUsageDailyReport(data: [], summary: nil) + case .copilot: + return CCUsageDailyReport(data: [], summary: nil) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 8fdfd9e2..60a537be 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -9,6 +9,7 @@ enum ProviderChoice: String, AppEnum { case gemini case antigravity case zai + case copilot static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -18,6 +19,7 @@ enum ProviderChoice: String, AppEnum { .gemini: DisplayRepresentation(title: "Gemini"), .antigravity: DisplayRepresentation(title: "Antigravity"), .zai: DisplayRepresentation(title: "z.ai"), + .copilot: DisplayRepresentation(title: "Copilot"), ] var provider: UsageProvider { @@ -27,6 +29,7 @@ enum ProviderChoice: String, AppEnum { case .gemini: .gemini case .antigravity: .antigravity case .zai: .zai + case .copilot: .copilot } } @@ -39,6 +42,7 @@ enum ProviderChoice: String, AppEnum { case .cursor: return nil // Cursor not yet supported in widgets case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets + case .copilot: self = .copilot } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 007ffcdd..8506f770 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -264,6 +264,7 @@ private struct ProviderSwitchChip: View { case .cursor: "Cursor" case .zai: "z.ai" case .factory: "Droid" + case .copilot: "Copilot" } } } @@ -572,6 +573,8 @@ enum WidgetColors { Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange + case .copilot: + Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple } } }