Skip to content
Open
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
6 changes: 6 additions & 0 deletions Sources/CodexBar/PreferencesAdvancedPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
21 changes: 21 additions & 0 deletions Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift
Original file line number Diff line number Diff line change
@@ -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[..<idx])…"
}
}
20 changes: 20 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroLoginFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import CodexBarCore

@MainActor
extension StatusItemController {
func runKiroLoginFlow() async {
self.loginPhase = .requesting
defer { self.loginPhase = .idle }

let result = await KiroLoginRunner.run(timeout: 120)
guard !Task.isCancelled else { return }
if let info = KiroLoginAlertPresentation.alertInfo(for: result) {
self.presentLoginAlert(title: info.title, message: info.message)
}
let length = result.output.count
self.loginLogger.info("Kiro login", metadata: ["outcome": "\(result.outcome)", "length": "\(length)"])
if case .success = result.outcome {
self.postLoginNotification(for: .kiro)
}
}
}
190 changes: 190 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroLoginRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import CodexBarCore
import Darwin
import Foundation

/// Spawns `kiro-cli login`, which opens a browser OAuth flow and blocks until it completes.
/// Mirrors ``CodexLoginRunner`` — same subprocess-lifecycle shape as `codex login`.
struct KiroLoginRunner {
struct Result: Equatable {
enum Outcome: Equatable {
case success
case timedOut
case failed(status: Int32)
case missingBinary
case launchFailed(String)
}

let outcome: Outcome
let output: String
}

static func run(
timeout: TimeInterval = 120,
outputDrainTimeout: TimeInterval = 3,
environment: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current) async -> 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface Kiro device-flow output before waiting

When kiro-cli login uses device flow (the documented fallback for remote/browser-doesn't-open cases, where it prints a device code/URL and then polls), this waits for the subprocess to exit or hit the 120s timeout before combinedOutput is ever shown to the user. In that scenario the user never sees the code while the CLI is still polling, so the new Re-authenticate action times out and kills the login instead of letting them complete it; stream/detect the URL as it appears or run the login in a terminal/PTY.

Useful? React with 👍 / 👎.

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<Bool, Never>?

func resolve(timedOut: Bool) {
let continuation: CheckedContinuation<Bool, Never>?
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)
}
}
28 changes: 28 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
[
Expand Down
13 changes: 13 additions & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
14 changes: 14 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBarCore/PathEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down