Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Sources/CodexBar/CopilotTokenStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
6 changes: 4 additions & 2 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/PreferencesGeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ struct ProvidersPane: View {
case .antigravity:
return "local"
case .factory:
return "web"
return "api"
case .copilot:
return "api"
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/ProviderBrandIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ enum ProviderBrandIcon {
case .factory: "ProviderIcon-factory"
case .gemini: "ProviderIcon-gemini"
case .antigravity: "ProviderIcon-antigravity"
case .copilot: "ProviderIcon-copilot"
}
}
}
106 changes: 106 additions & 0 deletions Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
)
]
}
}
1 change: 1 addition & 0 deletions Sources/CodexBar/Providers/Shared/ProviderCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum ProviderCatalog {
GeminiProviderImplementation(),
AntigravityProviderImplementation(),
FactoryProviderImplementation(),
CopilotProviderImplementation(),
]

/// Lookup for a single provider implementation.
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/Providers/Shared/ProviderLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ extension StatusItemController {
case .factory:
await self.runFactoryLoginFlow()
return true
case .copilot:
await CopilotLoginFlow.run(settings: self.settings)
return true
}
}
}
1 change: 1 addition & 0 deletions Sources/CodexBar/SessionQuotaNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ final class SessionQuotaNotifier {
case .antigravity: "Antigravity"
case .cursor: "Cursor"
case .factory: "Droid"
case .copilot: "Copilot"
}

let (title, body) = switch transition {
Expand Down
Loading