import AppKit import CodexBarCore import SwiftUI @MainActor struct CopilotLoginFlow { static func run(settings: SettingsStore) async { let enterpriseHost = settings.copilotEnterpriseHost let flow = CopilotDeviceFlow(enterpriseHost: enterpriseHost.isEmpty ? nil : enterpriseHost) 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 = L("GitHub Copilot Login") alert.informativeText = String(format: L("copilot_device_code"), code.userCode, code.verificationUri) alert.addButton(withTitle: L("Open Browser")) alert.addButton(withTitle: L("Cancel")) let response = alert.runModal() if response == .alertSecondButtonReturn { return // Cancelled } if let url = URL(string: code.verificationURLToOpen) { 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 = L("Waiting for Authentication...") waitingAlert.informativeText = L("copilot_waiting_text") waitingAlert.addButton(withTitle: L("Cancel")) let parentWindow = Self.resolveWaitingParentWindow() let hostWindow = parentWindow ?? Self.makeWaitingHostWindow() let shouldCloseHostWindow = parentWindow == nil let tokenTask = Task.detached(priority: .userInitiated) { try await flow.pollForToken(deviceCode: code.deviceCode, interval: code.interval) } let waitTask = Task { @MainActor in let response = await Self.presentWaitingAlert(waitingAlert, parentWindow: hostWindow) if response == .alertFirstButtonReturn { tokenTask.cancel() } return response } let tokenResult: Result do { let token = try await tokenTask.value tokenResult = .success(token) } catch { tokenResult = .failure(error) } Self.dismissWaitingAlert(waitingAlert, parentWindow: hostWindow, closeHost: shouldCloseHostWindow) let waitResponse = await waitTask.value if waitResponse == .alertFirstButtonReturn { return } switch tokenResult { case let .success(token): // Fetch username for account label. // If accounts already exist, fail closed when identity lookup fails so re-auth cannot create // an anonymous duplicate with stale credentials left on the original account. let existingAccounts = settings.tokenAccounts(for: .copilot) let label: String let identity: CopilotUsageFetcher.GitHubUserIdentity? do { let resolvedIdentity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) let resolvedUsername = resolvedIdentity.login let planSuffix: String do { let fetcher = CopilotUsageFetcher( token: token, enterpriseHost: enterpriseHost.isEmpty ? nil : enterpriseHost) let usage = try await fetcher.fetch() let plan = usage.identity(for: .copilot)?.loginMethod ?? "" planSuffix = plan.isEmpty ? "" : " (\(plan))" } catch { planSuffix = "" } identity = resolvedIdentity label = "\(resolvedUsername)\(planSuffix)" } catch { guard existingAccounts.isEmpty else { let err = NSAlert() err.messageText = "Could Not Identify GitHub Account" err.informativeText = "GitHub login succeeded, but CodexBar could not verify which " + "account it belongs to. Please try again." err.runModal() return } identity = nil label = "Account 1" } // Match existing account by stable GitHub user ID. For legacy accounts that pre-date stable // identifiers, also accept login-based externalIdentifier values and resolve stored token identity // before falling back to labels. let matchedExisting = await Self.matchExistingAccount( existingAccounts: existingAccounts, identity: identity, label: label) let externalIdentifier = identity.map(Self.externalIdentifier) let wasRefresh = matchedExisting != nil if let existing = matchedExisting { settings.updateTokenAccount( provider: .copilot, accountID: existing.id, label: label, token: token, externalIdentifier: .some(externalIdentifier)) } else { settings.addTokenAccount( provider: .copilot, label: label, token: token, externalIdentifier: externalIdentifier) } settings.setProviderEnabled( provider: .copilot, metadata: ProviderRegistry.shared.metadata[.copilot]!, enabled: true) let success = NSAlert() success.messageText = wasRefresh ? "Token Refreshed" : "Account Added" success.informativeText = label success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } let err = NSAlert() err.messageText = "Login Failed" err.informativeText = error.localizedDescription err.runModal() } } catch { let err = NSAlert() err.messageText = "Login Failed" err.informativeText = error.localizedDescription err.runModal() } } static func matchExistingAccount( existingAccounts: [ProviderTokenAccount], identity: CopilotUsageFetcher.GitHubUserIdentity?, label: String, legacyIdentityResolver: @escaping @Sendable (ProviderTokenAccount) async -> CopilotUsageFetcher.GitHubUserIdentity? = { account in try? await CopilotUsageFetcher.fetchGitHubIdentity(token: account.token) }) async -> ProviderTokenAccount? { guard let identity, !existingAccounts.isEmpty else { return nil } let stableIdentifier = self.externalIdentifier(for: identity) let login = self.normalizedGitHubLogin(identity.login) if let byID = existingAccounts.first(where: { account in self.normalizedExternalIdentifier(account.externalIdentifier) == stableIdentifier }) { return byID } // Previous PR revisions stored GitHub login in externalIdentifier. Keep matching those // accounts case-insensitively, then write back the stable ID on update. if let byLegacyLogin = existingAccounts.first(where: { account in self.normalizedGitHubLogin(account.externalIdentifier) == login }) { return byLegacyLogin } let legacyAccounts = existingAccounts.filter { $0.externalIdentifier == nil } for account in legacyAccounts { guard let resolvedIdentity = await legacyIdentityResolver(account) else { continue } if resolvedIdentity.id == identity.id || self.normalizedGitHubLogin(resolvedIdentity.login) == login { return account } } let usernamePrefix = self.displayLabelPrefix(label) return legacyAccounts.first { account in self.displayLabelPrefix(account.label) == usernamePrefix } } static func externalIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String { "github:user:\(identity.id)" } private static func normalizedExternalIdentifier(_ identifier: String?) -> String? { let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed.lowercased() } private static func normalizedGitHubLogin(_ login: String?) -> String? { let trimmed = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { return nil } // Stable IDs are not valid GitHub logins; do not let a numeric-looking login fallback // match the "github:user:" identifier path accidentally. guard !trimmed.lowercased().hasPrefix("github:user:") else { return nil } return trimmed.lowercased() } private static func displayLabelPrefix(_ label: String) -> String { (label.components(separatedBy: " (").first ?? label) .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() } @MainActor private static func presentWaitingAlert( _ alert: NSAlert, parentWindow: NSWindow) async -> NSApplication.ModalResponse { await withCheckedContinuation { continuation in alert.beginSheetModal(for: parentWindow) { response in continuation.resume(returning: response) } } } @MainActor private static func dismissWaitingAlert( _ alert: NSAlert, parentWindow: NSWindow, closeHost: Bool) { let alertWindow = alert.window if alertWindow.sheetParent != nil { parentWindow.endSheet(alertWindow) } else { alertWindow.orderOut(nil) } guard closeHost else { return } parentWindow.orderOut(nil) parentWindow.close() } @MainActor private static func resolveWaitingParentWindow() -> NSWindow? { if let window = NSApp.keyWindow ?? NSApp.mainWindow { return window } if let window = NSApp.windows.first(where: { $0.isVisible && !$0.ignoresMouseEvents }) { return window } return NSApp.windows.first } @MainActor private static func makeWaitingHostWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 1), styleMask: [.titled, .fullSizeContentView], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.standardWindowButton(.closeButton)?.isHidden = true window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true window.backgroundColor = .clear window.isOpaque = false window.hasShadow = false window.level = .floating window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.center() window.makeKeyAndOrderFront(nil) return window } }