Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.50.1 — Unreleased

### Added
- Kiro: add an explicit Re-authenticate action that runs `kiro-cli login`, surfaces device-flow instructions, and refreshes usage only after a successful login (#2340). Thanks @Vit129!
- Providers: add a per-provider accent color override with a hex field, a color well, and a reset to the shipped color; it applies to usage bars, charts, switcher tabs, widgets, and the `codexbar serve` dashboard, and syncs across Macs (#2972). Thanks @urda!
- Usage bars: make workday tick marks configurable with hidden, subtle, and high-contrast appearances (#2904, #2950). Thanks @dstier-git!
- Settings: allow minimizing the Settings window while keeping its Dock tile available for restoring it (#2945). Thanks @Yuxin-Qiao!
Expand Down
37 changes: 37 additions & 0 deletions Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 Terminate the login subprocess when its task is cancelled

If CodexBar shuts down while Kiro login is polling, cancelShutdownTasks() cancels loginTask, but this continuation wait never observes task cancellation and only calls terminate after the 120-second timeout. Since application termination destroys that timeout task as well, the independently launched kiro-cli process group can remain orphaned after CodexBar exits; make cancellation resolve the wait and run the existing process-group teardown.

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
9 changes: 9 additions & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,15 @@
"keychain_access_caption" = "قم بتعطيل جميع Keychain القراءة والكتابة. استخدم هذا إذا استمر macOS في طلب 'Chrome/Brave/Edge التخزين الآمن' حتى بعد الضغط على 'دائما السماح'. استيراد ملفات تعريف الارتباط من المتصفح غير متاح أثناء تفعيله؛ الصق رؤوس الكوكيز يدويا في المزودين. Claude/Codex OAuth عبر CLI لا يزال يعمل.";
"disable_keychain_access_title" = "تعطيل Keychain الوصول";
"disable_keychain_access_subtitle" = "يمنع أي وصول Keychain أثناء التفعيل.";
"Re-authenticate" = "إعادة المصادقة";
"kiro_reauthenticate_title" = "تسجيل الدخول إلى Kiro CLI";
"kiro_reauthenticate_subtitle" = "يشغّل «kiro-cli login» لتجديد جلستك عند انتهاء صلاحيتها.";
"Kiro CLI not found" = "لم يتم العثور على Kiro CLI";
"Install kiro-cli and try again." = "ثبّت kiro-cli ثم حاول مرة أخرى.";
"Could not start kiro-cli login" = "تعذر بدء kiro-cli login";
"Kiro login timed out" = "انتهت مهلة تسجيل الدخول إلى Kiro";
"kiro-cli login exited with status %d." = "خرج kiro-cli login بالحالة %d.";
"Kiro login failed" = "فشل تسجيل الدخول إلى Kiro";

/* About Pane */
"about_tagline" = "عسى أن لا تنفد رموزك أبدا—حافظ على حدود الوكلاء في مرآه.";
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/Resources/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,15 @@
"keychain_access_caption" = "Desactiveu totes les lectures i escriptures del Clauer. Feu-ho si macOS continua mostrant sol·licituds de «Chrome/Brave/Edge Safe Storage» fins i tot després de triar «Permet sempre». La importació de galetes del navegador no estarà disponible mentre aquesta opció estigui activada; enganxeu manualment les capçaleres Cookie a Proveïdors. L'OAuth de Claude/Codex mitjançant la CLI continuarà funcionant.";
"disable_keychain_access_title" = "Desactiveu l'accés al Clauer";
"disable_keychain_access_subtitle" = "Impedeix qualsevol accés al Clauer mentre estigui activat.";
"Re-authenticate" = "Torna a autenticar";
"kiro_reauthenticate_title" = "Inici de sessió de Kiro CLI";
"kiro_reauthenticate_subtitle" = "Executa «kiro-cli login» per renovar la sessió quan hagi caducat.";
"Kiro CLI not found" = "No s'ha trobat Kiro CLI";
"Install kiro-cli and try again." = "Instal·leu kiro-cli i torneu-ho a provar.";
"Could not start kiro-cli login" = "No s'ha pogut iniciar kiro-cli login";
"Kiro login timed out" = "S'ha esgotat el temps d'inici de sessió de Kiro";
"kiro-cli login exited with status %d." = "kiro-cli login ha finalitzat amb l'estat %d.";
"Kiro login failed" = "Ha fallat l'inici de sessió de Kiro";

/* About Pane */
"about_tagline" = "Que els vostres tokens no s'esgotin mai: mantingueu els límits dels vostres agents a la vista.";
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,15 @@
"keychain_access_caption" = "Deaktivieren Sie alle Lese- und Schreibvorgänge im Schlüsselbund. Verwenden Sie diese Option, wenn macOS weiterhin nach \"Chrome/Brave/Edge Safe Storage\" fragt, auch nachdem Sie auf \"Immer zulassen\" geklickt haben. Der Browser-Cookie-Import ist nicht verfügbar, solange er aktiviert ist. Fügen Sie Cookie-Header manuell in Provider ein. Claude/Codex OAuth über die CLI funktioniert weiterhin.";
"disable_keychain_access_title" = "Deaktivieren Sie den Schlüsselbundzugriff";
"disable_keychain_access_subtitle" = "Verhindert jeglichen Zugriff auf den Schlüsselbund, solange diese Option aktiviert ist.";
"Re-authenticate" = "Erneut authentifizieren";
"kiro_reauthenticate_title" = "Kiro-CLI-Anmeldung";
"kiro_reauthenticate_subtitle" = "Führt „kiro-cli login“ aus, um deine Sitzung zu erneuern, wenn sie abgelaufen ist.";
"Kiro CLI not found" = "Kiro CLI nicht gefunden";
"Install kiro-cli and try again." = "Installiere kiro-cli und versuche es erneut.";
"Could not start kiro-cli login" = "kiro-cli login konnte nicht gestartet werden";
"Kiro login timed out" = "Zeitüberschreitung bei der Kiro-Anmeldung";
"kiro-cli login exited with status %d." = "kiro-cli login wurde mit Status %d beendet.";
"Kiro login failed" = "Kiro-Anmeldung fehlgeschlagen";

/* About Pane */
"about_tagline" = "Mögen Ihre Token nie ausgehen – behalten Sie die Agentenlimits im Blick.";
Expand Down
9 changes: 9 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,15 @@
"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";
"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
Loading