Skip to content
Closed
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
50 changes: 50 additions & 0 deletions Sources/CodexBar/HiddenWindowView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

struct HiddenWindowView: View {
Expand All @@ -9,6 +10,10 @@ struct HiddenWindowView: View {
.onReceive(NotificationCenter.default.publisher(for: .codexbarOpenSettings)) { _ in
Task { @MainActor in
self.openSettings()
// Menu-bar apps don't automatically own keyboard focus.
// Force the Settings window to become key so text fields
// receive keystrokes instead of the previously-active app.
Self.forceSettingsWindowKey()
}
}
.task {
Expand All @@ -17,6 +22,22 @@ struct HiddenWindowView: View {
KeychainMigration.migrateIfNeeded()
}.value
}
// When the last key-capable window closes, revert to menu-bar-only
// so the app disappears from the Dock and Cmd-Tab switcher.
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { note in
guard let window = note.object as? NSWindow,
window.canBecomeKey,
window.title != "CodexBarLifecycleKeepalive"
else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @MainActor in
let hasVisibleKeyWindow = NSApp.windows.contains {
$0.isVisible && $0.canBecomeKey && $0.title != "CodexBarLifecycleKeepalive"
}
if !hasVisibleKeyWindow {
NSApp.setActivationPolicy(.accessory)
}
}
}
.onAppear {
if let window = NSApp.windows.first(where: { $0.title == "CodexBarLifecycleKeepalive" }) {
// Make the keepalive window truly invisible and non-interactive.
Expand All @@ -35,4 +56,33 @@ struct HiddenWindowView: View {
}
}
}

@MainActor
private static func forceSettingsWindowKey() {
NSApp.setActivationPolicy(.regular)
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 Restore accessory activation policy after focusing settings

Calling NSApp.setActivationPolicy(.regular) here permanently promotes this LSUIElement app to a regular app, and this commit never switches it back to .accessory; after opening Settings once, users can be left with a persistent Dock icon/app-switcher presence instead of menu-bar-only behavior. This side effect is triggered on the new settings-focus path and is likely unintended for a “no Dock icon” app.

Useful? React with 👍 / 👎.

// Retry activation several times with increasing delays.
// The SwiftUI Settings window is created asynchronously and may not
// exist on the first attempt; the activation policy change also needs
// a run-loop cycle to take effect before the app can receive focus.
for delay in [0.05, 0.15, 0.3, 0.5, 0.8] as [TimeInterval] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { @MainActor in
Self.activateAndFocusSettingsWindow()
}
}
}

@MainActor
private static func activateAndFocusSettingsWindow() {
if #available(macOS 14.0, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
for window in NSApp.windows where window.isVisible && window.canBecomeKey
&& window.title != "CodexBarLifecycleKeepalive"
{
window.makeKeyAndOrderFront(nil)
break
}
}
}
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ struct MenuContent: View {
}
case .settings:
self.actions.openSettings()
case .settingsProvider:
self.actions.openSettingsProvider()
case .about:
self.actions.openAbout()
case .quit:
Expand All @@ -115,6 +117,7 @@ struct MenuActions {
let switchAccount: (UsageProvider) -> Void
let openTerminal: (String) -> Void
let openSettings: () -> Void
let openSettingsProvider: () -> Void
let openAbout: () -> Void
let quit: () -> Void
let copyError: (String) -> Void
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ struct MenuDescriptor {
case openTerminal(command: String)
case loginToProvider(url: String)
case settings
case settingsProvider
case about
case quit
case copyError(String)
Expand Down Expand Up @@ -450,7 +451,7 @@ private enum AccountFormatter {
extension MenuDescriptor.MenuAction {
var systemImageName: String? {
switch self {
case .installUpdate, .settings, .about, .quit:
case .installUpdate, .settings, .settingsProvider, .about, .quit:
nil
case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/PreferencesProviderSettingsRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,13 @@ struct ProviderSettingsTokenAccountsRowView: View {
}

HStack(spacing: 10) {
if let importFromFile = self.descriptor.importFromFile {
Button("Import from auth.json") {
importFromFile()
}
.buttonStyle(.link)
.controlSize(.small)
}
Button("Open token file") {
self.descriptor.openConfigFile()
}
Expand Down
22 changes: 21 additions & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,27 @@ struct ProvidersPane: View {
await self.store.refreshProvider(provider, allowDisabled: true)
}
}
})
},
importFromFile: provider == .codex ? {
guard let credentials = try? CodexOAuthCredentialsStore.load() else { return }
let email = Self.codexEmailFromCredentials(credentials)
let label = email ?? "Codex (\(DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .none)))"
self.settings.addTokenAccount(provider: .codex, label: label, token: credentials.accessToken)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we double-check what auth behavior we want after import if we only store accessToken here? Where do refresh and account/workspace identity come from in that case?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, I was wondering about that myself, but thinking if I manually add oauth tokens then I've really signed up to refresh them manually. But if there are ways to support refreshing them, then let's explore that as it would remove that burden from the user

Task { @MainActor in
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refreshProvider(.codex, allowDisabled: true)
}
}
} : nil)
}

private static func codexEmailFromCredentials(_ credentials: CodexOAuthCredentials) -> String? {
guard let idToken = credentials.idToken,
let payload = UsageFetcher.parseJWT(idToken)
else { return nil }
let profileDict = payload["https://api.openai.com/profile"] as? [String: Any]
let email = (payload["email"] as? String) ?? (profileDict?["email"] as? String)
return email?.trimmingCharacters(in: .whitespacesAndNewlines)
}

private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ struct CodexProviderImplementation: ProviderImplementation {
}
}

@MainActor
func loginMenuAction(context: ProviderMenuLoginContext)
-> (label: String, action: MenuDescriptor.MenuAction)?
{
guard TokenAccountSupportCatalog.support(for: .codex) != nil else { return nil }
return ("Add Account...", .settingsProvider)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we walk through the first-time-user path here? If someone has no ~/.codex/auth.json yet, where in this flow do they actually run codex login?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Great question :-) I was coming to this from "CodexBar works great, let me add another subscription to monitor" where that subscription was a second OpenAI subscription. I'm not sure I'd put that as part of a first-time onboarding flow.

}

@MainActor
func runLoginFlow(context: ProviderLoginContext) async -> Bool {
await context.controller.runCodexLoginFlow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable {
let removeAccount: (_ accountID: UUID) -> Void
let openConfigFile: () -> Void
let reloadFromDisk: () -> Void
var importFromFile: (() -> Void)?
}

/// Shared picker descriptor rendered in the Providers settings pane.
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ extension StatusItemController {
self.openSettings(tab: .general)
}

@objc func showSettingsProviders() {
self.openSettings(tab: .providers)
}

@objc func showSettingsAbout() {
self.openSettings(tab: .about)
}
Expand All @@ -151,6 +155,9 @@ extension StatusItemController {
private func openSettings(tab: PreferencesTab) {
DispatchQueue.main.async {
self.preferencesSelection.tab = tab
// Promote from LSUIElement/accessory to regular app BEFORE
// opening the window so macOS allows keyboard focus transfer.
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NotificationCenter.default.post(
name: .codexbarOpenSettings,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,7 @@ extension StatusItemController {
case let .openTerminal(command): (#selector(self.openTerminalCommand(_:)), command)
case let .loginToProvider(url): (#selector(self.openLoginToProvider(_:)), url)
case .settings: (#selector(self.showSettingsGeneral), nil)
case .settingsProvider: (#selector(self.showSettingsProviders), nil)
case .about: (#selector(self.showSettingsAbout), nil)
case .quit: (#selector(self.quit), nil)
case let .copyError(message): (#selector(self.copyError(_:)), message)
Expand Down
30 changes: 26 additions & 4 deletions Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy {
let id: String = "codex.oauth"
let kind: ProviderFetchKind = .oauth

func isAvailable(_: ProviderFetchContext) async -> Bool {
(try? CodexOAuthCredentialsStore.load()) != nil
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
Self.resolveAccessToken(context.env) != nil
}

func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
var credentials = try CodexOAuthCredentialsStore.load()
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
var credentials = try Self.resolveCredentials(context.env)

if credentials.needsRefresh, !credentials.refreshToken.isEmpty {
credentials = try await CodexTokenRefresher.refresh(credentials)
Expand All @@ -156,6 +156,28 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy {
sourceLabel: "oauth")
}

private static func resolveAccessToken(_ env: [String: String]) -> String? {
if let envToken = ProviderTokenResolver.codexOAuthToken(environment: env) {
return envToken
}
return (try? CodexOAuthCredentialsStore.load())?.accessToken
}

private static func resolveCredentials(_ env: [String: String]) throws -> CodexOAuthCredentials {
if let envToken = ProviderTokenResolver.codexOAuthToken(environment: env) {
// Pass the access token as idToken so resolveAccountEmail can
// attempt JWT parsing — OpenAI access tokens are JWTs that
// typically carry email claims.
return CodexOAuthCredentials(
accessToken: envToken,
refreshToken: "",
idToken: envToken,
accountId: nil,
lastRefresh: nil)
}
return try CodexOAuthCredentialsStore.load()
}

func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
guard context.sourceMode == .auto else { return false }
return true
Expand Down
28 changes: 28 additions & 0 deletions Sources/CodexBarCore/Providers/Codex/CodexSettingsReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

public struct CodexSettingsReader: Sendable {
public static let oauthAccessTokenKey = "CODEX_OAUTH_ACCESS_TOKEN"

public static func oauthAccessToken(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
if let token = self.cleaned(environment[oauthAccessTokenKey]) { return token }
return nil
}

static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}

if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
(value.hasPrefix("'") && value.hasSuffix("'"))
{
value.removeFirst()
value.removeLast()
}

value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
10 changes: 10 additions & 0 deletions Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ public enum ProviderTokenResolver {
self.openRouterResolution(environment: environment)?.token
}

public static func codexOAuthToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
self.codexOAuthResolution(environment: environment)?.token
}

public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
Expand Down Expand Up @@ -141,6 +145,12 @@ public enum ProviderTokenResolver {
self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
}

public static func codexOAuthResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
self.resolveEnv(CodexSettingsReader.oauthAccessToken(environment: environment))
}

private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: "sessionKey"),
.codex: TokenAccountSupport(
title: "OAuth tokens",
subtitle: "Store Codex/OpenAI OAuth access tokens from auth.json.",
placeholder: "Paste access_token…",
injection: .environment(key: CodexSettingsReader.oauthAccessTokenKey),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In Usage source = Auto, how do we ensure the selected Codex token account cannot end up showing usage fetched from a fallback CLI account when this OAuth token is stale or invalid?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, I expect that can happen now you point it out. Let me look into it and get back to you.

requiresManualCookieSource: false,
cookieName: nil),
.zai: TokenAccountSupport(
title: "API tokens",
subtitle: "Stored in the CodexBar config file.",
Expand Down