diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index ab21733bdf..0c8bb1f201 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -43,6 +43,12 @@ struct AdvancedPane: View { L("disable_keychain_access_title"), subtitle: L("disable_keychain_access_subtitle")) } + + Toggle(isOn: self.$settings.claudeOAuthBackgroundRepairEnabled) { + SettingsRowLabel( + L("claude_oauth_background_repair_title"), + subtitle: L("claude_oauth_background_repair_subtitle")) + } } header: { Text(L("section_privacy")) } footer: { diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 5744287c09..190a8ede25 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -57,6 +57,27 @@ struct CodexProviderImplementation: ProviderImplementation { CodexProviderRuntime() } + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + [ + ProviderSettingsActionsDescriptor( + id: "codex-oauth", + title: L("codex_reauthenticate_title"), + subtitle: L("codex_reauthenticate_subtitle"), + actions: [ + ProviderSettingsActionDescriptor( + id: "codex-oauth-reauthenticate", + title: L("Re-authenticate"), + style: .bordered, + isVisible: nil, + perform: { + await context.runLoginFlow() + }), + ], + isVisible: nil), + ] + } + @MainActor func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { let extrasBinding = Binding( diff --git a/Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift b/Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift new file mode 100644 index 0000000000..edbda15400 --- /dev/null +++ b/Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift @@ -0,0 +1,33 @@ +import Foundation + +enum KiroLoginAlertPresentation { + static func alertInfo(for result: KiroLoginRunner.Result) -> CodexLoginAlertInfo? { + switch result.outcome { + case .success: + return nil + case .missingBinary: + return CodexLoginAlertInfo( + title: L("Kiro CLI not found"), + message: L("Install kiro-cli and try again.")) + case let .launchFailed(message): + return CodexLoginAlertInfo(title: L("Could not start kiro-cli login"), message: message) + case .timedOut: + return CodexLoginAlertInfo( + title: L("Kiro login timed out"), + message: self.trimmedOutput(result.output)) + case let .failed(status): + let statusLine = String(format: L("kiro-cli login exited with status %d."), status) + let message = self.trimmedOutput(result.output.isEmpty ? statusLine : result.output) + return CodexLoginAlertInfo(title: L("Kiro login failed"), message: message) + } + } + + private static func trimmedOutput(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let limit = 600 + if trimmed.isEmpty { return L("No output captured.") } + if trimmed.count <= limit { return trimmed } + let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) + return "\(trimmed[.. Result + { + await Task(priority: .userInitiated) { + var env = environment + env["PATH"] = PathBuilder.effectivePATH( + purposes: [.rpc, .tty, .nodeTooling], + env: env, + loginPATH: loginPATH) + + guard let executable = BinaryLocator.resolveKiroCLIBinary(env: env, loginPATH: loginPATH) else { + return Result(outcome: .missingBinary, output: "") + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable, "login"] + process.environment = env + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + let stdoutCapture = ProcessPipeCapture(pipe: stdout) + let stderrCapture = ProcessPipeCapture(pipe: stderr) + + let termination = ProcessTermination() + process.terminationHandler = { _ in + termination.resolve(timedOut: false) + } + + var processGroup: pid_t? + do { + try process.run() + processGroup = self.attachProcessGroup(process) + } catch { + return Result(outcome: .launchFailed(error.localizedDescription), output: "") + } + stdoutCapture.start() + stderrCapture.start() + + let timedOut = await self.wait(timeout: timeout, termination: termination) + if timedOut { + self.terminate(process, processGroup: processGroup) + } + + let output = await self.combinedOutput( + stdout: stdoutCapture, + stderr: stderrCapture, + timeout: outputDrainTimeout) + if timedOut { + return Result(outcome: .timedOut, output: output) + } + + let status = process.terminationStatus + if status == 0 { + return Result(outcome: .success, output: output) + } + return Result(outcome: .failed(status: status), output: output) + }.value + } + + private final class ProcessTermination: @unchecked Sendable { + private let lock = NSLock() + private var timedOut: Bool? + private var continuation: CheckedContinuation? + + func resolve(timedOut: Bool) { + let continuation: CheckedContinuation? + self.lock.lock() + guard self.timedOut == nil else { + self.lock.unlock() + return + } + self.timedOut = timedOut + continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: timedOut) + } + + func wait() async -> Bool { + await withCheckedContinuation { continuation in + let timedOut: Bool? + self.lock.lock() + timedOut = self.timedOut + if timedOut == nil { + self.continuation = continuation + } + self.lock.unlock() + + if let timedOut { + continuation.resume(returning: timedOut) + } + } + } + } + + private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool { + let timeoutTask = Task.detached(priority: .userInitiated) { + try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout)) + if Task.isCancelled == false { + termination.resolve(timedOut: true) + } + } + let timedOut = await termination.wait() + timeoutTask.cancel() + return timedOut + } + + private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 { + guard timeout.isFinite else { return UInt64.max } + let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000)) + return UInt64(seconds * 1_000_000_000) + } + + private static func terminate(_ process: Process, processGroup: pid_t?) { + if let pgid = processGroup { + kill(-pgid, SIGTERM) + } + if process.isRunning { + process.terminate() + } + + let deadline = Date().addingTimeInterval(2.0) + while process.isRunning, Date() < deadline { + usleep(100_000) + } + + if process.isRunning { + if let pgid = processGroup { + kill(-pgid, SIGKILL) + } + kill(process.processIdentifier, SIGKILL) + } + } + + private static func attachProcessGroup(_ process: Process) -> pid_t? { + let pid = process.processIdentifier + return setpgid(pid, pid) == 0 ? pid : nil + } + + private static func combinedOutput( + stdout: ProcessPipeCapture, + stderr: ProcessPipeCapture, + timeout: TimeInterval) async -> String + { + let drainTimeout = Duration.seconds(max(0, timeout)) + async let outData = stdout.finish(timeout: drainTimeout) + async let errData = stderr.finish(timeout: drainTimeout) + let out = await self.decode(outData) + let err = await self.decode(errData) + + let merged: String = if !out.isEmpty, !err.isEmpty { + [out, err].joined(separator: "\n") + } else { + out + err + } + let trimmed = merged.trimmingCharacters(in: .whitespacesAndNewlines) + let limited = trimmed.prefix(4000) + return limited.isEmpty ? L("No output captured.") : String(limited) + } + + private static func decode(_ data: Data) -> String { + ProcessPipeCapture.decodeUTF8(data) + } +} diff --git a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift index 383c4b1a59..aca5a006bd 100644 --- a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift @@ -4,6 +4,34 @@ import SwiftUI struct KiroProviderImplementation: ProviderImplementation { let id: UsageProvider = .kiro + let supportsLoginFlow: Bool = true + + @MainActor + func runLoginFlow(context: ProviderLoginContext) async -> Bool { + await context.controller.runKiroLoginFlow() + return true + } + + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + [ + ProviderSettingsActionsDescriptor( + id: "kiro-cli-login", + title: L("kiro_reauthenticate_title"), + subtitle: L("kiro_reauthenticate_subtitle"), + actions: [ + ProviderSettingsActionDescriptor( + id: "kiro-cli-login-reauthenticate", + title: L("Re-authenticate"), + style: .bordered, + isVisible: nil, + perform: { + await context.runLoginFlow() + }), + ], + isVisible: nil), + ] + } func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { [ diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5cc6b2defb..05b26f1092 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -632,6 +632,19 @@ "keychain_access_caption" = "Disable all Keychain reads and writes. Use this if macOS keeps prompting for 'Chrome/Brave/Edge Safe Storage' even after clicking Always Allow. Browser cookie import is unavailable while enabled; paste Cookie headers manually in Providers. Claude/Codex OAuth via the CLI still works."; "disable_keychain_access_title" = "Disable Keychain access"; "disable_keychain_access_subtitle" = "Prevents any Keychain access while enabled."; +"claude_oauth_background_repair_title" = "Auto-repair expired Claude token"; +"claude_oauth_background_repair_subtitle" = "Lets CodexBar re-prompt the Keychain in the background instead of requiring a manual Refresh click when your Claude OAuth token expires."; +"Re-authenticate" = "Re-authenticate"; +"codex_reauthenticate_title" = "Codex OAuth"; +"codex_reauthenticate_subtitle" = "Runs 'codex login' to refresh your session when the OAuth token has expired."; +"kiro_reauthenticate_title" = "Kiro CLI login"; +"kiro_reauthenticate_subtitle" = "Runs 'kiro-cli login' to refresh your session when it has expired."; +"Kiro CLI not found" = "Kiro CLI not found"; +"Install kiro-cli and try again." = "Install kiro-cli and try again."; +"Could not start kiro-cli login" = "Could not start kiro-cli login"; +"Kiro login timed out" = "Kiro login timed out"; +"kiro-cli login exited with status %d." = "kiro-cli login exited with status %d."; +"Kiro login failed" = "Kiro login failed"; /* About Pane */ "about_tagline" = "May your tokens never run out—keep agent limits in view."; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0624723be2..04e5c0e49e 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -625,6 +625,20 @@ extension SettingsStore { } } + /// Lets Claude's OAuth token repair prompt the Keychain outside a user-initiated action + /// (menu open/refresh), instead of surfacing "background repair is suppressed" until the + /// user manually clicks Refresh. + var claudeOAuthBackgroundRepairEnabled: Bool { + get { self.claudeOAuthKeychainPromptMode == .always } + set { + if newValue { + self.claudeOAuthKeychainPromptMode = .always + } else if self.claudeOAuthKeychainPromptMode == .always { + self.claudeOAuthKeychainPromptMode = .onlyOnUserAction + } + } + } + var claudeWebExtrasEnabled: Bool { get { self.claudeWebExtrasEnabledRaw } set { self.claudeWebExtrasEnabledRaw = newValue } diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 5310e2767d..f7941b66b8 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -318,6 +318,31 @@ public enum BinaryLocator { home: home) } + public static func resolveKiroCLIBinary( + env: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current, + commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, + aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator + .resolveAlias, + fileManager: FileManager = .default, + home: String = NSHomeDirectory()) -> String? + { + self.resolveBinary( + name: "kiro-cli", + overrideKey: "KIRO_CLI_PATH", + env: env, + loginPATH: loginPATH, + commandV: commandV, + aliasResolver: aliasResolver, + wellKnownPaths: [ + "\(home)/.local/bin/kiro-cli", + "/opt/homebrew/bin/kiro-cli", + "/usr/local/bin/kiro-cli", + ], + fileManager: fileManager, + home: home) + } + // swiftlint:disable function_parameter_count private static func resolveBinary( name: String,