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

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

let result = await KiroLoginRunner.run(timeout: 120) { [weak self] progressOutput in
Task { @MainActor in
self?.presentLoginAlert(
title: L("Complete Kiro login in your browser"),
message: progressOutput)
}
}
guard !Task.isCancelled else { return false }
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)"])
guard case .success = result.outcome else { return false }
self.postLoginNotification(for: .kiro)
return true
}
}
226 changes: 226 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroLoginRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
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
}

/// Polling cadence while the login subprocess is still running, used to surface a
/// device-flow URL/code before the process exits (`kiro-cli login` prints them, then blocks
/// while polling for the browser approval).
private static let progressPollInterval: TimeInterval = 0.5

static func run(
timeout: TimeInterval = 120,
outputDrainTimeout: TimeInterval = 3,
environment: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current,
onProgress: (@Sendable (String) -> Void)? = nil) 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 progressTask = onProgress.map { onProgress in
Task.detached(priority: .userInitiated) {
await Self.pollProgress(
stdout: stdoutCapture,
stderr: stderrCapture,
interval: self.progressPollInterval,
onProgress: onProgress)
}
}

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 👍 / 👎.

progressTask?.cancel()
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)
}

/// Polls the still-running subprocess's pipes for a device-flow URL/code and reports it once,
/// so the UI can show it before the timeout kills a login that's waiting on browser approval.
private static func pollProgress(
stdout: ProcessPipeCapture,
stderr: ProcessPipeCapture,
interval: TimeInterval,
onProgress: @escaping @Sendable (String) -> Void) async
{
while !Task.isCancelled {
let combined = self.decode(stdout.currentSnapshot()) + self.decode(stderr.currentSnapshot())
let trimmed = combined.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.contains("http://") || trimmed.contains("https://") {
onProgress(trimmed)
return
}
try? await Task.sleep(nanoseconds: UInt64(max(0, interval) * 1_000_000_000))
}
}
}
27 changes: 27 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ import SwiftUI

struct KiroProviderImplementation: ProviderImplementation {
let id: UsageProvider = .kiro
let supportsLoginFlow: Bool = true

@MainActor
func runLoginFlow(context: ProviderLoginContext) async -> Bool {
await context.controller.runKiroLoginFlow()
}

@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
11 changes: 11 additions & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,17 @@
"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.";
"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
8 changes: 8 additions & 0 deletions Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ package final class ProcessPipeCapture: @unchecked Sendable {
return self.didReachEOF
}

/// Snapshot of the bytes captured so far, without stopping the capture. Lets a caller
/// poll for interactive output (e.g. a device-flow URL/code) while the process is still running.
package func currentSnapshot() -> Data {
self.condition.lock()
defer { self.condition.unlock() }
return self.data
}

package static func decodeUTF8(_ data: Data) -> String {
// A byte cap can split the final scalar; lossy decoding preserves the valid captured prefix.
// swiftlint:disable:next optional_data_string_conversion
Expand Down
Loading