223 lines
8.9 KiB
Swift
223 lines
8.9 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import SweetCookieKit
|
|
|
|
private enum KeychainPromptMessage {
|
|
static let browserCookie =
|
|
"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies " +
|
|
"and authenticate your account. Click OK to continue."
|
|
|
|
static let claudeOAuth =
|
|
"CodexBar will ask macOS Keychain for the Claude Code OAuth token " +
|
|
"so it can fetch your Claude usage. Click OK to continue."
|
|
static let codexCookie =
|
|
"CodexBar will ask macOS Keychain for your OpenAI cookie header " +
|
|
"so it can fetch Codex dashboard extras. Click OK to continue."
|
|
static let claudeCookie =
|
|
"CodexBar will ask macOS Keychain for your Claude cookie header " +
|
|
"so it can fetch Claude web usage. Click OK to continue."
|
|
static let cursorCookie =
|
|
"CodexBar will ask macOS Keychain for your Cursor cookie header " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let openCodeCookie =
|
|
"CodexBar will ask macOS Keychain for your OpenCode cookie header " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let factoryCookie =
|
|
"CodexBar will ask macOS Keychain for your Factory cookie header " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let zaiToken =
|
|
"CodexBar will ask macOS Keychain for your z.ai API token " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let syntheticToken =
|
|
"CodexBar will ask macOS Keychain for your Synthetic API key " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let copilotToken =
|
|
"CodexBar will ask macOS Keychain for your GitHub Copilot token " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let kimiToken =
|
|
"CodexBar will ask macOS Keychain for your Kimi auth token " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let kimiK2Token =
|
|
"CodexBar will ask macOS Keychain for your Kimi K2 API key " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let minimaxCookie =
|
|
"CodexBar will ask macOS Keychain for your MiniMax cookie header " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let minimaxToken =
|
|
"CodexBar will ask macOS Keychain for your MiniMax API token " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let augmentCookie =
|
|
"CodexBar will ask macOS Keychain for your Augment cookie header " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
static let ampCookie =
|
|
"CodexBar will ask macOS Keychain for your Amp cookie header " +
|
|
"so it can fetch usage. Click OK to continue."
|
|
}
|
|
|
|
struct KeychainPromptAlertModel: Equatable {
|
|
let title: String
|
|
let message: String
|
|
let primaryButtonTitle: String
|
|
let learnMoreButtonTitle: String
|
|
let documentationURL: String
|
|
}
|
|
|
|
@MainActor
|
|
private final class KeychainPromptLearnMoreTarget: NSObject {
|
|
private let documentationURL: String
|
|
|
|
init(documentationURL: String) {
|
|
self.documentationURL = documentationURL
|
|
}
|
|
|
|
@objc func openDocumentation() {
|
|
guard let url = URL(string: self.documentationURL) else { return }
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
enum KeychainPromptCoordinator {
|
|
private static let promptLock = NSLock()
|
|
private static let log = CodexBarLog.logger(LogCategories.keychainPrompt)
|
|
private static let documentationURL =
|
|
"https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md"
|
|
|
|
static func install() {
|
|
KeychainPromptHandler.handler = { context in
|
|
self.presentKeychainPrompt(context)
|
|
}
|
|
BrowserCookieKeychainPromptHandler.handler = { context in
|
|
self.presentBrowserCookiePrompt(context)
|
|
}
|
|
self.disableKeychainForUnbundledExecutableIfNeeded()
|
|
}
|
|
|
|
private static let unbundledExecutableCheckLock = NSLock()
|
|
private nonisolated(unsafe) static var didCheckUnbundledExecutable = false
|
|
|
|
static func disableKeychainForUnbundledExecutableIfNeeded() {
|
|
self.unbundledExecutableCheckLock.lock()
|
|
guard !self.didCheckUnbundledExecutable else {
|
|
self.unbundledExecutableCheckLock.unlock()
|
|
return
|
|
}
|
|
self.didCheckUnbundledExecutable = true
|
|
self.unbundledExecutableCheckLock.unlock()
|
|
|
|
let executablePath = Bundle.main.executableURL?.path ?? ""
|
|
guard Self.isUnbundledCodexBarExecutable(executablePath) else { return }
|
|
KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable")
|
|
Self.log.warning(
|
|
"Unbundled CodexBar executable detected; disabling keychain access to avoid repeated prompts",
|
|
metadata: ["doc": "docs/DEVELOPMENT_SETUP.md"])
|
|
}
|
|
|
|
static func isUnbundledCodexBarExecutable(_ executablePath: String) -> Bool {
|
|
guard executablePath.hasPrefix("/") else { return false }
|
|
let executableURL = URL(fileURLWithPath: executablePath).standardizedFileURL
|
|
return executableURL.lastPathComponent == "CodexBar"
|
|
&& !executableURL.pathComponents.contains(where: { $0.hasSuffix(".app") })
|
|
}
|
|
|
|
private static func presentKeychainPrompt(_ context: KeychainPromptContext) {
|
|
let model = self.alertModel(for: context)
|
|
self.log.info("Keychain prompt requested", metadata: ["kind": "\(context.kind)"])
|
|
self.presentAlert(model)
|
|
}
|
|
|
|
private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) {
|
|
let model = self.browserCookieAlertModel(label: context.label)
|
|
self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label])
|
|
self.presentAlert(model)
|
|
}
|
|
|
|
static func alertModel(for context: KeychainPromptContext) -> KeychainPromptAlertModel {
|
|
let purpose = switch context.kind {
|
|
case .claudeOAuth:
|
|
L(KeychainPromptMessage.claudeOAuth)
|
|
case .codexCookie:
|
|
L(KeychainPromptMessage.codexCookie)
|
|
case .claudeCookie:
|
|
L(KeychainPromptMessage.claudeCookie)
|
|
case .cursorCookie:
|
|
L(KeychainPromptMessage.cursorCookie)
|
|
case .opencodeCookie:
|
|
L(KeychainPromptMessage.openCodeCookie)
|
|
case .factoryCookie:
|
|
L(KeychainPromptMessage.factoryCookie)
|
|
case .zaiToken:
|
|
L(KeychainPromptMessage.zaiToken)
|
|
case .syntheticToken:
|
|
L(KeychainPromptMessage.syntheticToken)
|
|
case .copilotToken:
|
|
L(KeychainPromptMessage.copilotToken)
|
|
case .kimiToken:
|
|
L(KeychainPromptMessage.kimiToken)
|
|
case .kimiK2Token:
|
|
L(KeychainPromptMessage.kimiK2Token)
|
|
case .minimaxCookie:
|
|
L(KeychainPromptMessage.minimaxCookie)
|
|
case .minimaxToken:
|
|
L(KeychainPromptMessage.minimaxToken)
|
|
case .augmentCookie:
|
|
L(KeychainPromptMessage.augmentCookie)
|
|
case .ampCookie:
|
|
L(KeychainPromptMessage.ampCookie)
|
|
}
|
|
return self.alertModel(purpose: purpose)
|
|
}
|
|
|
|
static func browserCookieAlertModel(label: String) -> KeychainPromptAlertModel {
|
|
self.alertModel(purpose: L(KeychainPromptMessage.browserCookie, label))
|
|
}
|
|
|
|
private static func alertModel(purpose: String) -> KeychainPromptAlertModel {
|
|
KeychainPromptAlertModel(
|
|
title: L("Keychain Access Required"),
|
|
message: "\(purpose)\n\n\(L("keychain_prompt_privacy_note"))",
|
|
primaryButtonTitle: L("OK"),
|
|
learnMoreButtonTitle: L("keychain_prompt_learn_more"),
|
|
documentationURL: self.documentationURL)
|
|
}
|
|
|
|
private static func presentAlert(_ model: KeychainPromptAlertModel) {
|
|
self.promptLock.lock()
|
|
defer { self.promptLock.unlock() }
|
|
|
|
if Thread.isMainThread {
|
|
MainActor.assumeIsolated {
|
|
self.showAlert(model)
|
|
}
|
|
return
|
|
}
|
|
DispatchQueue.main.sync {
|
|
MainActor.assumeIsolated {
|
|
self.showAlert(model)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private static func showAlert(_ model: KeychainPromptAlertModel) {
|
|
let alert = NSAlert()
|
|
alert.messageText = model.title
|
|
alert.informativeText = model.message
|
|
alert.addButton(withTitle: model.primaryButtonTitle)
|
|
|
|
let learnMoreTarget = KeychainPromptLearnMoreTarget(documentationURL: model.documentationURL)
|
|
let learnMoreButton = NSButton(
|
|
title: model.learnMoreButtonTitle,
|
|
target: learnMoreTarget,
|
|
action: #selector(KeychainPromptLearnMoreTarget.openDocumentation))
|
|
learnMoreButton.isBordered = false
|
|
learnMoreButton.contentTintColor = .linkColor
|
|
learnMoreButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
learnMoreButton.sizeToFit()
|
|
alert.accessoryView = learnMoreButton
|
|
|
|
withExtendedLifetime(learnMoreTarget) {
|
|
_ = alert.runModal()
|
|
}
|
|
}
|
|
}
|