Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ jobs:
- name: Build CodexBarCLI (release, static Swift stdlib)
run: swift build -c release --product CodexBarCLI --static-swift-stdlib

- name: Build CodexBarLinuxTray (release)
run: swift build -c release --product CodexBarLinuxTray

- name: Swift Test (Linux only)
run: swift test --parallel

Expand All @@ -83,3 +86,15 @@ jobs:
fi
"$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true
grep -q "macOS" /tmp/codexbarcli-stderr.txt

- name: Smoke test CodexBarLinuxTray
shell: bash
run: |
set -euo pipefail
BIN_DIR="$(swift build -c release --product CodexBarLinuxTray --show-bin-path)"
BIN="$BIN_DIR/CodexBarLinuxTray"
timeout 5s env CODEXBAR_TRAY_STDOUT_ONLY=1 "$BIN" || rc=$?
if [[ "${rc:-0}" -ne 0 && "${rc:-0}" -ne 124 ]]; then
echo "CodexBarLinuxTray exited unexpectedly: ${rc}"
exit "${rc}"
fi
12 changes: 12 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,17 @@ let package = Package(
]))
#endif

#if os(Linux)
targets.append(.executableTarget(
name: "CodexBarLinuxTray",
dependencies: [
"CodexBarCore",
],
path: "Sources/CodexBarLinuxTray",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]))
#endif

return targets
}())
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ Download: <https://github.com/steipete/CodexBar/releases>
brew install --cask steipete/tap/codexbar
```

### Linux (CLI only)
### Linux (CLI + native tray MVP)
```bash
brew install steipete/tap/codexbar
```
Or download `CodexBarCLI-v<tag>-linux-<arch>.tar.gz` from GitHub Releases.
Linux support via Omarchy: community Waybar module and TUI, driven by the `codexbar` executable.

Build native tray MVP from source:
```bash
swift build -c release --product CodexBarLinuxTray
./.build/release/CodexBarLinuxTray
```

### First run
- Open Settings → Providers and enable what you use.
- Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running).
Expand Down Expand Up @@ -63,6 +69,7 @@ The menu bar icon is a tiny two-bar meter:
- Merge Icons mode to combine providers into one status item + switcher.
- Refresh cadence presets (manual, 1m, 2m, 5m, 15m).
- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex|claude` for local cost usage); Linux CLI builds available.
- Linux tray host executable (`CodexBarLinuxTray`) for native tray + tooltip refresh view.
- WidgetKit widget mirrors the menu card snapshot.
- Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored).

Expand Down Expand Up @@ -92,6 +99,7 @@ Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it re
- Providers overview: [docs/providers.md](docs/providers.md)
- Provider authoring: [docs/provider.md](docs/provider.md)
- UI & icon notes: [docs/ui.md](docs/ui.md)
- Linux tray notes: [docs/linux-tray.md](docs/linux-tray.md)
- CLI reference: [docs/cli.md](docs/cli.md)
- Architecture: [docs/architecture.md](docs/architecture.md)
- Refresh loop: [docs/refresh-loop.md](docs/refresh-loop.md)
Expand Down
101 changes: 101 additions & 0 deletions Sources/CodexBarCore/Credentials/CredentialStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

public struct ProviderCredentialKey: Hashable, Sendable {
public let provider: UsageProvider

public init(provider: UsageProvider) {
self.provider = provider
}
}

public protocol ProviderCredentialStore: Sendable {
func apiKey(for key: ProviderCredentialKey) -> String?
}

public struct EnvironmentProviderCredentialStore: ProviderCredentialStore {
private let env: [String: String]

public init(env: [String: String] = ProcessInfo.processInfo.environment) {
self.env = env
}

public func apiKey(for key: ProviderCredentialKey) -> String? {
let names = Self.environmentNames(for: key.provider)
for name in names {
if let value = self.env[name]?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
return value
}
}
return nil
}

private static func environmentNames(for provider: UsageProvider) -> [String] {
switch provider {
case .zai:
[ZaiSettingsReader.apiTokenKey]
case .copilot:
["COPILOT_API_TOKEN"]
case .minimax:
[MiniMaxAPISettingsReader.apiTokenKey]
case .kimik2:
KimiK2SettingsReader.apiKeyEnvironmentKeys
case .synthetic:
[SyntheticSettingsReader.apiKeyKey]
case .warp:
WarpSettingsReader.apiKeyEnvironmentKeys
default:
[]
}
}
}

public struct FileProviderCredentialStore: ProviderCredentialStore {
public let fileURL: URL
private let fileManager: FileManager
private let decoder: JSONDecoder

public init(
fileURL: URL = Self.defaultURL(),
fileManager: FileManager = .default,
decoder: JSONDecoder = JSONDecoder())
{
self.fileURL = fileURL
self.fileManager = fileManager
self.decoder = decoder
}

public func apiKey(for key: ProviderCredentialKey) -> String? {
guard let map = self.loadMap() else { return nil }
guard let value = map[key.provider.rawValue]?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil }
return value.isEmpty ? nil : value
}

public static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
home
.appendingPathComponent(".codexbar", isDirectory: true)
.appendingPathComponent("credentials.json")
}

private func loadMap() -> [String: String]? {
guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return nil }
guard let data = try? Data(contentsOf: self.fileURL) else { return nil }
return try? self.decoder.decode([String: String].self, from: data)
}
}

public struct CompositeProviderCredentialStore: ProviderCredentialStore {
private let stores: [any ProviderCredentialStore]

public init(stores: [any ProviderCredentialStore]) {
self.stores = stores
}

public func apiKey(for key: ProviderCredentialKey) -> String? {
for store in self.stores {
if let value = store.apiKey(for: key), !value.isEmpty {
return value
}
}
return nil
}
}
84 changes: 84 additions & 0 deletions Sources/CodexBarLinuxTray/LinuxTrayConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import CodexBarCore
import Foundation

struct LinuxTrayRuntimeConfig: Codable, Sendable {
var refreshSeconds: Int
var staleAfterRefreshes: Int
var iconName: String

static let `default` = LinuxTrayRuntimeConfig(
refreshSeconds: 120,
staleAfterRefreshes: 3,
iconName: "utilities-terminal")

var normalized: LinuxTrayRuntimeConfig {
LinuxTrayRuntimeConfig(
refreshSeconds: max(30, self.refreshSeconds),
staleAfterRefreshes: max(2, self.staleAfterRefreshes),
iconName: self.iconName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? Self.default.iconName
: self.iconName)
}
}

struct LinuxTrayRuntimeConfigStore {
let fileURL: URL

init(fileURL: URL = Self.defaultURL()) {
self.fileURL = fileURL
}

func load() -> LinuxTrayRuntimeConfig {
guard let data = try? Data(contentsOf: self.fileURL),
let decoded = try? JSONDecoder().decode(LinuxTrayRuntimeConfig.self, from: data)
else {
return .default
}
return decoded.normalized
}

static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
home
.appendingPathComponent(".codexbar", isDirectory: true)
.appendingPathComponent("linux-tray.json")
}
}

struct ProviderRuntimeSettings: Sendable {
let sourceMode: ProviderSourceMode
let env: [String: String]
}

struct ProviderRuntimeResolver {
let credentials: any ProviderCredentialStore
let baseEnv: [String: String]

init(
credentials: any ProviderCredentialStore,
baseEnv: [String: String] = ProcessInfo.processInfo.environment)
{
self.credentials = credentials
self.baseEnv = baseEnv
}

func resolve(provider: UsageProvider, providerConfig: ProviderConfig?) -> ProviderRuntimeSettings {
let preferred = providerConfig?.source ?? .auto
let sourceMode = preferred.usesWeb ? .cli : preferred

var env = ProviderConfigEnvironment.applyAPIKeyOverride(
base: self.baseEnv,
provider: provider,
config: providerConfig)
let configHasAPIKey = providerConfig?.sanitizedAPIKey != nil
if !configHasAPIKey,
let apiKey = self.credentials.apiKey(for: .init(provider: provider))
{
env = ProviderConfigEnvironment.applyAPIKeyOverride(
base: env,
provider: provider,
config: ProviderConfig(id: provider, apiKey: apiKey))
}

return ProviderRuntimeSettings(sourceMode: sourceMode, env: env)
}
}
112 changes: 112 additions & 0 deletions Sources/CodexBarLinuxTray/LinuxTrayHost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation

protocol LinuxTrayHost: Sendable {
func start(onActivate: (@Sendable () -> Void)?) async
func update(summary: String, tooltip: String, iconName: String) async
func stop() async
}

actor ZenityTrayHost: LinuxTrayHost {
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutTask: Task<Void, Never>?
private var onActivate: (@Sendable () -> Void)?
private var activeIconName = "utilities-terminal"

func start(onActivate: (@Sendable () -> Void)?) async {
self.onActivate = onActivate
await self.ensureStarted()
}

func update(summary: String, tooltip: String, iconName: String) async {
self.activeIconName = iconName
await self.ensureStarted()
self.sendLine("icon:\(self.escapeLine(iconName))")
self.sendLine("message:\(self.escapeLine(summary))")
self.sendLine("tooltip:\(self.escapeLine(tooltip))")
self.sendLine("visible:true")
}

func stop() async {
self.stdoutTask?.cancel()
self.stdoutTask = nil
self.process?.terminate()
self.process = nil
self.stdinPipe = nil
}

private func ensureStarted() async {
if let process = self.process, process.isRunning { return }
self.stdoutTask?.cancel()
self.stdoutTask = nil

let process = Process()
let input = Pipe()
let output = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zenity")
process.arguments = [
"--notification",
"--listen",
"--text=CodexBar",
"--icon-name=\(self.activeIconName)",
]
process.standardInput = input
process.standardOutput = output
process.standardError = Pipe()

do {
try process.run()
self.process = process
self.stdinPipe = input
self.stdoutTask = Task.detached(priority: .utility) { [weak self] in
await self?.readEvents(from: output.fileHandleForReading)
}
} catch {
self.process = nil
self.stdinPipe = nil
}
}

private func readEvents(from handle: FileHandle) async {
while !Task.isCancelled {
let data = handle.availableData
if data.isEmpty { return }
guard let line = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty
else {
continue
}

// zenity emits a line when the tray icon is activated.
self.onActivate?()
if line == "quit" {
return
}
}
}

private func sendLine(_ line: String) {
guard let writer = self.stdinPipe?.fileHandleForWriting else { return }
guard let data = "\(line)\n".data(using: .utf8) else { return }
writer.write(data)
}

private func escapeLine(_ value: String) -> String {
value
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
}
}

actor StdoutTrayHost: LinuxTrayHost {
func start(onActivate: (@Sendable () -> Void)?) async {
_ = onActivate
}

func update(summary: String, tooltip: String, iconName: String) async {
print("[\(iconName)] \(summary)\n\(tooltip)\n")
}

func stop() async {}
}
Loading