diff --git a/Scripts/diagnose_augment_cookies.swift b/Scripts/diagnose_augment_cookies.swift new file mode 100755 index 000000000..b09d67752 --- /dev/null +++ b/Scripts/diagnose_augment_cookies.swift @@ -0,0 +1,84 @@ +#!/usr/bin/env swift + +import Foundation + +#if canImport(AppKit) +import AppKit +#endif + +// Simple diagnostic script to check Augment cookies in browsers +print("\n========== AUGMENT COOKIE DIAGNOSTICS ==========\n") + +let cookieDomains = ["augmentcode.com", "app.augmentcode.com"] +let expectedCookieNames: Set = [ + "session", + "_session", + "web_rpc_proxy_session", + "__Secure-next-auth.session-token", + "next-auth.session-token", + "__Secure-authjs.session-token", + "authjs.session-token", +] + +print("Looking for Augment cookies in browsers...") +print("Expected cookie names: \(expectedCookieNames.sorted().joined(separator: ", "))\n") + +// Check Safari cookies +print("--- Safari ---") +let safariCookiesPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Cookies/Cookies.binarycookies") + +if FileManager.default.fileExists(atPath: safariCookiesPath.path) { + print("Safari cookies file exists at: \(safariCookiesPath.path)") + print("Note: Binary cookies file - cannot easily parse without SweetCookieKit") +} else { + print("Safari cookies file not found") +} + +// Check Chrome cookies +print("\n--- Chrome ---") +let chromeCookiesPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Cookies") + +if FileManager.default.fileExists(atPath: chromeCookiesPath.path) { + print("Chrome cookies database exists at: \(chromeCookiesPath.path)") + print("Note: SQLite database - cannot easily parse without SweetCookieKit") +} else { + print("Chrome cookies database not found") +} + +// Check Arc cookies +print("\n--- Arc ---") +let arcCookiesPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Arc/User Data/Default/Cookies") + +if FileManager.default.fileExists(atPath: arcCookiesPath.path) { + print("Arc cookies database exists at: \(arcCookiesPath.path)") + print("Note: SQLite database - cannot easily parse without SweetCookieKit") +} else { + print("Arc cookies database not found") +} + +print("\n========== INSTRUCTIONS ==========") +print(""" +To fix the "No Augment session found" error: + +1. Open your browser (Safari, Chrome, or Arc) +2. Go to https://app.augmentcode.com +3. Make sure you're logged in +4. Check the browser's cookies: + - Safari: Develop → Show Web Inspector → Storage → Cookies + - Chrome/Arc: DevTools (⌘⌥I) → Application → Cookies +5. Look for one of these cookie names: + \(expectedCookieNames.sorted().joined(separator: "\n ")) +6. If you don't see any of these cookies, you may need to log out and log back in +7. After confirming cookies exist, click "Refresh Session" in CodexBar + +If cookies exist but CodexBar still can't find them: +- Try quitting and reopening your browser +- Browser cookies may take a few seconds to write to disk +- Check that CodexBar has Full Disk Access in System Settings → Privacy & Security +""") + +print("\n========== END DIAGNOSTICS ==========\n") + diff --git a/Scripts/minimize_prompts.sh b/Scripts/minimize_prompts.sh new file mode 100755 index 000000000..f80b50887 --- /dev/null +++ b/Scripts/minimize_prompts.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +echo "🔧 CodexBar Prompt Minimizer" +echo "============================" +echo "" +echo "This script will configure CodexBar to minimize macOS permission prompts." +echo "" + +# Check if app is running +if pgrep -x "CodexBar" > /dev/null; then + echo "⚠️ CodexBar is currently running. Please quit it first." + exit 1 +fi + +echo "📋 Current Configuration:" +echo "" + +# Show current provider toggles +echo "Enabled Providers:" +defaults read com.steipete.codexbar providerToggles 2>/dev/null || echo " (none configured yet)" +echo "" + +# Show current data sources +echo "Data Sources:" +echo " Claude: $(defaults read com.steipete.codexbar claudeUsageDataSource 2>/dev/null || echo 'auto')" +echo " Codex: $(defaults read com.steipete.codexbar codexUsageDataSource 2>/dev/null || echo 'auto')" +echo "" + +echo "🎯 Recommended Configuration (Minimal Prompts):" +echo "" +echo " ✅ Codex (CLI) - No prompts" +echo " ✅ Claude (CLI) - No prompts" +echo " ✅ Gemini (CLI) - No prompts" +echo " ⚠️ Augment - Requires browser cookie prompt (unavoidable)" +echo " ⚠️ Cursor - Requires browser cookie prompt (unavoidable)" +echo " ❌ Antigravity - Disable (experimental)" +echo "" + +read -p "Apply recommended configuration? (y/n) " -n 1 -r +echo "" + +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Cancelled." + exit 0 +fi + +echo "" +echo "🔧 Applying configuration..." + +# Set Claude to CLI-only (avoids keychain) +defaults write com.steipete.codexbar claudeUsageDataSource -string "cli" +echo " ✓ Claude → CLI mode (no keychain)" + +# Set Codex to CLI-only (avoids keychain) +defaults write com.steipete.codexbar codexUsageDataSource -string "cli" +echo " ✓ Codex → CLI mode (no keychain)" + +# Disable Antigravity (experimental, not needed) +defaults write com.steipete.codexbar providerToggles -dict-add antigravity -bool false +echo " ✓ Antigravity → Disabled" + +# Keep Augment enabled (user wants it, accepts browser prompt) +defaults write com.steipete.codexbar providerToggles -dict-add augment -bool true +echo " ✓ Augment → Enabled (will prompt for browser cookies once)" + +# Keep Claude enabled +defaults write com.steipete.codexbar providerToggles -dict-add claude -bool true +echo " ✓ Claude → Enabled" + +# Keep Codex enabled +defaults write com.steipete.codexbar providerToggles -dict-add codex -bool true +echo " ✓ Codex → Enabled" + +# Keep Gemini enabled +defaults write com.steipete.codexbar providerToggles -dict-add gemini -bool true +echo " ✓ Gemini → Enabled" + +# Disable Cursor (requires browser cookies) +defaults write com.steipete.codexbar providerToggles -dict-add cursor -bool false +echo " ✓ Cursor → Disabled (avoids browser cookie prompt)" + +echo "" +echo "✅ Configuration complete!" +echo "" +echo "📝 What to expect:" +echo " 1. First launch: macOS will ask for browser cookie access (for Augment)" +echo " 2. Click 'Allow' ONCE - this is unavoidable for Augment" +echo " 3. No more keychain prompts (Claude/Codex use CLI)" +echo "" +echo "🚀 You can now launch CodexBar." + diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index a6a3754b7..fe2e8c600 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -290,6 +290,9 @@ fi # Embed Sparkle.framework if [[ -d ".build/$CONF/Sparkle.framework" ]]; then cp -R ".build/$CONF/Sparkle.framework" "$APP/Contents/Frameworks/" + # Clean extended attributes immediately after copying + xattr -cr "$APP/Contents/Frameworks/Sparkle.framework" + find "$APP/Contents/Frameworks/Sparkle.framework" -name '._*' -delete chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" # Re-sign Sparkle and all nested components with Developer ID + timestamp @@ -309,6 +312,9 @@ function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; } resign "$SPARKLE" resign "$SPARKLE/Versions/B/Sparkle" resign "$SPARKLE/Versions/B/Autoupdate" + # Clean Updater.app specifically before signing to remove any detritus + xattr -cr "$SPARKLE/Versions/B/Updater.app" 2>/dev/null || true + find "$SPARKLE/Versions/B/Updater.app" -name '._*' -delete 2>/dev/null || true resign "$SPARKLE/Versions/B/Updater.app" resign "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" @@ -320,13 +326,20 @@ function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; } fi if [[ -f "$ICON_TARGET" ]]; then + echo "Copying icon from $ICON_TARGET to $APP/Contents/Resources/Icon.icns" >&2 cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns" +else + echo "WARNING: Icon file not found at $ICON_TARGET" >&2 fi # Bundle app resources (provider icons, etc.). APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources" if [[ -d "$APP_RESOURCES_DIR" ]]; then + echo "Copying app resources from $APP_RESOURCES_DIR to $APP/Contents/Resources/" >&2 cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/" + echo "Copied $(ls -1 "$APP_RESOURCES_DIR" | wc -l) resource files" >&2 +else + echo "WARNING: App resources directory not found at $APP_RESOURCES_DIR" >&2 fi if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2 @@ -350,12 +363,13 @@ if [[ ! -d "$APP/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle" exit 1 fi -# Ensure contents are writable before stripping attributes and signing. -chmod -R u+w "$APP" +# Strip extended attributes FIRST to prevent AppleDouble (._*) files that break code sealing +# This must happen before any chmod or other operations +xattr -cr "$APP" 2>/dev/null || true +find "$APP" -name '._*' -delete 2>/dev/null || true -# Strip extended attributes to prevent AppleDouble (._*) files that break code sealing -xattr -cr "$APP" -find "$APP" -name '._*' -delete +# Ensure contents are writable after cleaning attributes +chmod -R u+w "$APP" # Sign helper binaries if present if [[ -f "${APP}/Contents/Helpers/CodexBarCLI" ]]; then @@ -367,6 +381,9 @@ fi # Sign widget extension if present if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then + # Clean widget extension before signing to remove any detritus + xattr -cr "${APP}/Contents/PlugIns/CodexBarWidget.appex" 2>/dev/null || true + find "${APP}/Contents/PlugIns/CodexBarWidget.appex" -name '._*' -delete 2>/dev/null || true codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" @@ -376,6 +393,21 @@ if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then fi # Finally sign the app bundle itself +# Use ditto to strip resource forks by copying to temp location and back +# This is the most reliable way to remove all Finder info and resource forks +TEMP_APP="${APP}.tmp" +if [[ -d "$TEMP_APP" ]]; then + rm -rf "$TEMP_APP" +fi +# Copy without resource forks (--norsrc strips resource forks and extended attributes) +ditto --norsrc "$APP" "$TEMP_APP" +# Remove old app and move clean copy back +rm -rf "$APP" +mv "$TEMP_APP" "$APP" +# Final cleanup pass +xattr -cr "$APP" 2>/dev/null || true +find "$APP" -name '._*' -delete 2>/dev/null || true +find "$APP" -name '.DS_Store' -delete 2>/dev/null || true codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index 43459fd1f..bbda879ba 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -131,6 +131,11 @@ public struct AugmentCreditsResponse: Codable, Sendable { } public var creditsLimit: Double? { + // Prefer the authoritative total from the API when available. + if let available = self.usageUnitsAvailable, available > 0 { + return available + } + // Fallback: derive from remaining + consumed. guard let remaining = self.usageUnitsRemaining, let consumed = self.usageUnitsConsumedThisBillingCycle else { @@ -685,4 +690,4 @@ public struct AugmentStatusProbe: Sendable { } } -#endif +#endif \ No newline at end of file diff --git a/Sources/CodexBarCore/SessionKeepalive/KeepaliveConfig.swift b/Sources/CodexBarCore/SessionKeepalive/KeepaliveConfig.swift new file mode 100644 index 000000000..2d28cd0f0 --- /dev/null +++ b/Sources/CodexBarCore/SessionKeepalive/KeepaliveConfig.swift @@ -0,0 +1,141 @@ +import Foundation + +/// Configuration for session keepalive behavior. +/// +/// Defines how and when a provider's session should be refreshed to prevent expiration. +public struct KeepaliveConfig: Sendable, Codable, Equatable { + // MARK: - Refresh Mode + + /// Defines when session refresh should occur. + public enum Mode: Sendable, Codable, Equatable { + /// Refresh at regular intervals (e.g., every 30 minutes). + case interval(TimeInterval) + + /// Refresh daily at a specific time (24-hour format). + case daily(hour: Int, minute: Int) + + /// Refresh before session expiry with a buffer time. + case beforeExpiry(buffer: TimeInterval) + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case type + case interval + case hour + case minute + case buffer + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "interval": + let interval = try container.decode(TimeInterval.self, forKey: .interval) + self = .interval(interval) + case "daily": + let hour = try container.decode(Int.self, forKey: .hour) + let minute = try container.decode(Int.self, forKey: .minute) + self = .daily(hour: hour, minute: minute) + case "beforeExpiry": + let buffer = try container.decode(TimeInterval.self, forKey: .buffer) + self = .beforeExpiry(buffer: buffer) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown mode type: \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .interval(let interval): + try container.encode("interval", forKey: .type) + try container.encode(interval, forKey: .interval) + case .daily(let hour, let minute): + try container.encode("daily", forKey: .type) + try container.encode(hour, forKey: .hour) + try container.encode(minute, forKey: .minute) + case .beforeExpiry(let buffer): + try container.encode("beforeExpiry", forKey: .type) + try container.encode(buffer, forKey: .buffer) + } + } + } + + // MARK: - Properties + + /// The refresh mode (interval, daily, or before expiry). + public let mode: Mode + + /// Whether keepalive is enabled for this provider. + public let enabled: Bool + + /// Minimum time between refresh attempts (rate limiting). + /// Default: 120 seconds (2 minutes). + public let minRefreshInterval: TimeInterval + + /// Maximum number of consecutive failures before auto-disabling. + /// Default: 5 failures. + public let maxConsecutiveFailures: Int + + // MARK: - Initialization + + public init( + mode: Mode, + enabled: Bool = true, + minRefreshInterval: TimeInterval = 120, + maxConsecutiveFailures: Int = 5) + { + self.mode = mode + self.enabled = enabled + self.minRefreshInterval = minRefreshInterval + self.maxConsecutiveFailures = maxConsecutiveFailures + } + + // MARK: - Defaults + + /// Default configuration for Augment (refresh 5 minutes before expiry). + public static var augmentDefault: KeepaliveConfig { + KeepaliveConfig(mode: .beforeExpiry(buffer: 300), enabled: true) + } + + /// Default configuration for Claude (refresh every 30 minutes). + public static var claudeDefault: KeepaliveConfig { + KeepaliveConfig(mode: .interval(1800), enabled: true) + } + + /// Default configuration for Codex (refresh every 60 minutes). + public static var codexDefault: KeepaliveConfig { + KeepaliveConfig(mode: .interval(3600), enabled: true) + } + + /// Disabled keepalive configuration. + public static var disabled: KeepaliveConfig { + KeepaliveConfig(mode: .interval(3600), enabled: false) + } +} + +// MARK: - CustomStringConvertible + +extension KeepaliveConfig: CustomStringConvertible { + public var description: String { + let status = self.enabled ? "enabled" : "disabled" + let modeDesc: String + switch self.mode { + case .interval(let seconds): + modeDesc = "every \(Int(seconds))s" + case .daily(let hour, let minute): + modeDesc = "daily at \(String(format: "%02d:%02d", hour, minute))" + case .beforeExpiry(let buffer): + modeDesc = "\(Int(buffer))s before expiry" + } + return "KeepaliveConfig(\(status), \(modeDesc))" + } +} + diff --git a/Sources/CodexBarCore/SessionKeepalive/KeepaliveConfigStore.swift b/Sources/CodexBarCore/SessionKeepalive/KeepaliveConfigStore.swift new file mode 100644 index 000000000..ad5ce612f --- /dev/null +++ b/Sources/CodexBarCore/SessionKeepalive/KeepaliveConfigStore.swift @@ -0,0 +1,149 @@ +import Foundation + +/// Persistent storage for session keepalive configurations. +/// +/// Stores per-provider keepalive settings in UserDefaults, allowing configurations +/// to persist across app launches. +public final class KeepaliveConfigStore: @unchecked Sendable { + // MARK: - Singleton + + public static let shared = KeepaliveConfigStore() + + // MARK: - UserDefaults Keys + + private let userDefaults: UserDefaults + private let keyPrefix = "com.codexbar.keepalive." + + // MARK: - Initialization + + public init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + // MARK: - Public API + + /// Save a keepalive configuration for a provider. + /// + /// - Parameters: + /// - config: The configuration to save + /// - provider: The provider this config applies to + public func save(_ config: KeepaliveConfig, for provider: UsageProvider) { + let key = self.key(for: provider) + do { + let encoder = JSONEncoder() + let data = try encoder.encode(config) + self.userDefaults.set(data, forKey: key) + print("[KeepaliveConfigStore] Saved config for \(provider.rawValue): \(config)") + } catch { + print("[KeepaliveConfigStore] Failed to save config for \(provider.rawValue): \(error)") + } + } + + /// Load a keepalive configuration for a provider. + /// + /// - Parameter provider: The provider to load config for + /// - Returns: The saved configuration, or nil if none exists + public func load(for provider: UsageProvider) -> KeepaliveConfig? { + let key = self.key(for: provider) + guard let data = self.userDefaults.data(forKey: key) else { + return nil + } + + do { + let decoder = JSONDecoder() + let config = try decoder.decode(KeepaliveConfig.self, from: data) + print("[KeepaliveConfigStore] Loaded config for \(provider.rawValue): \(config)") + return config + } catch { + print("[KeepaliveConfigStore] Failed to load config for \(provider.rawValue): \(error)") + return nil + } + } + + /// Load a configuration with a fallback default. + /// + /// - Parameters: + /// - provider: The provider to load config for + /// - defaultConfig: The default to use if no saved config exists + /// - Returns: The saved configuration, or the default if none exists + public func loadOrDefault(for provider: UsageProvider, default defaultConfig: KeepaliveConfig) -> KeepaliveConfig { + self.load(for: provider) ?? defaultConfig + } + + /// Delete the saved configuration for a provider. + /// + /// - Parameter provider: The provider to delete config for + public func delete(for provider: UsageProvider) { + let key = self.key(for: provider) + self.userDefaults.removeObject(forKey: key) + print("[KeepaliveConfigStore] Deleted config for \(provider.rawValue)") + } + + /// Load all saved configurations. + /// + /// - Returns: Dictionary of provider to configuration + public func loadAll() -> [UsageProvider: KeepaliveConfig] { + var configs: [UsageProvider: KeepaliveConfig] = [:] + + for provider in UsageProvider.allCases { + if let config = self.load(for: provider) { + configs[provider] = config + } + } + + return configs + } + + /// Save multiple configurations at once. + /// + /// - Parameter configs: Dictionary of provider to configuration + public func saveAll(_ configs: [UsageProvider: KeepaliveConfig]) { + for (provider, config) in configs { + self.save(config, for: provider) + } + } + + /// Delete all saved configurations. + public func deleteAll() { + for provider in UsageProvider.allCases { + self.delete(for: provider) + } + } + + // MARK: - Private Helpers + + private func key(for provider: UsageProvider) -> String { + "\(self.keyPrefix)\(provider.rawValue)" + } +} + +// MARK: - Default Configurations + +extension KeepaliveConfigStore { + /// Get the recommended default configuration for a provider. + /// + /// - Parameter provider: The provider to get defaults for + /// - Returns: The recommended default configuration + public static func defaultConfig(for provider: UsageProvider) -> KeepaliveConfig { + switch provider { + case .augment: + return .augmentDefault + case .claude: + return .claudeDefault + case .codex: + return .codexDefault + default: + // For other providers, default to disabled until we implement their refresh logic + return .disabled + } + } + + /// Load configuration with provider-specific defaults. + /// + /// - Parameter provider: The provider to load config for + /// - Returns: Saved config, or provider-specific default if none exists + public func loadWithProviderDefaults(for provider: UsageProvider) -> KeepaliveConfig { + self.loadOrDefault(for: provider, default: Self.defaultConfig(for: provider)) + } +} + diff --git a/Sources/CodexBarCore/SessionKeepalive/SessionKeepaliveManager.swift b/Sources/CodexBarCore/SessionKeepalive/SessionKeepaliveManager.swift new file mode 100644 index 000000000..da72cc35c --- /dev/null +++ b/Sources/CodexBarCore/SessionKeepalive/SessionKeepaliveManager.swift @@ -0,0 +1,260 @@ +import Foundation + +#if os(macOS) + +/// Unified session keepalive manager for all providers. +/// +/// This manager coordinates automatic session refresh across multiple providers, +/// preventing session expiration and authentication failures during idle periods. +/// +/// **Design Principles:** +/// - Non-invasive: Runs alongside existing provider-specific keepalive systems +/// - Extensible: Easy to add new providers with custom refresh strategies +/// - Configurable: Per-provider settings with sensible defaults +/// - Safe: Rate limiting, error handling, and automatic disable on repeated failures +/// +/// **Usage:** +/// ```swift +/// let manager = SessionKeepaliveManager.shared +/// await manager.start(provider: .augment, config: .augmentDefault) +/// await manager.start(provider: .claude, config: .claudeDefault) +/// ``` +@MainActor +public final class SessionKeepaliveManager { + // MARK: - Singleton + + public static let shared = SessionKeepaliveManager() + + // MARK: - State + + /// Active keepalive tasks per provider. + private var scheduledTasks: [UsageProvider: Task] = [:] + + /// Current configuration per provider. + private var configs: [UsageProvider: KeepaliveConfig] = [:] + + /// Last refresh attempt timestamp per provider. + private var lastRefreshAttempt: [UsageProvider: Date] = [:] + + /// Last successful refresh timestamp per provider. + private var lastSuccessfulRefresh: [UsageProvider: Date] = [:] + + /// Consecutive failure count per provider. + private var consecutiveFailures: [UsageProvider: Int] = [:] + + /// Whether a refresh is currently in progress per provider. + private var isRefreshing: Set = [] + + /// Optional logger for debugging. + private let logger: ((String) -> Void)? + + // MARK: - Initialization + + private init(logger: ((String) -> Void)? = nil) { + self.logger = logger + } + + deinit { + for task in self.scheduledTasks.values { + task.cancel() + } + } + + // MARK: - Public API + + /// Start keepalive for a provider with the given configuration. + /// + /// If keepalive is already running for this provider, it will be stopped and restarted + /// with the new configuration. + /// + /// - Parameters: + /// - provider: The provider to keep alive + /// - config: Keepalive configuration (mode, intervals, etc.) + public func start(provider: UsageProvider, config: KeepaliveConfig) { + guard config.enabled else { + self.log(provider, "Keepalive disabled in config, not starting") + return + } + + // Stop existing task if running + if self.scheduledTasks[provider] != nil { + self.log(provider, "Stopping existing keepalive before restart") + self.stop(provider: provider) + } + + self.configs[provider] = config + self.consecutiveFailures[provider] = 0 + + self.log(provider, "🚀 Starting session keepalive: \(config)") + + // Create background task based on mode + let task = Task.detached(priority: .utility) { [weak self] in + guard let self = self else { return } + await self.runKeepaliveLoop(provider: provider, config: config) + } + + self.scheduledTasks[provider] = task + self.log(provider, "✅ Keepalive task started") + } + + /// Stop keepalive for a provider. + /// + /// - Parameter provider: The provider to stop keepalive for + public func stop(provider: UsageProvider) { + self.log(provider, "Stopping session keepalive") + self.scheduledTasks[provider]?.cancel() + self.scheduledTasks.removeValue(forKey: provider) + self.configs.removeValue(forKey: provider) + self.isRefreshing.remove(provider) + } + + /// Force an immediate refresh for a provider (bypasses rate limiting). + /// + /// - Parameter provider: The provider to refresh + public func forceRefresh(provider: UsageProvider) async { + self.log(provider, "Force refresh requested") + await self.performRefresh(provider: provider, forced: true) + } + + /// Get the last successful refresh time for a provider. + /// + /// - Parameter provider: The provider to query + /// - Returns: The last successful refresh date, or nil if never refreshed + public func lastRefreshTime(for provider: UsageProvider) -> Date? { + self.lastSuccessfulRefresh[provider] + } + + /// Get the current configuration for a provider. + /// + /// - Parameter provider: The provider to query + /// - Returns: The current config, or nil if not configured + public func configuration(for provider: UsageProvider) -> KeepaliveConfig? { + self.configs[provider] + } + + // MARK: - Private Implementation + + /// Main keepalive loop for a provider. + private func runKeepaliveLoop(provider: UsageProvider, config: KeepaliveConfig) async { + while !Task.isCancelled { + // Calculate next check interval based on mode + let checkInterval = self.calculateCheckInterval(for: config) + + // Wait for the check interval + try? await Task.sleep(for: .seconds(checkInterval)) + + // Check if we should refresh + await self.checkAndRefreshIfNeeded(provider: provider, config: config) + } + } + + /// Calculate the check interval based on the keepalive mode. + private func calculateCheckInterval(for config: KeepaliveConfig) -> TimeInterval { + switch config.mode { + case .interval(let seconds): + return seconds + case .daily: + // Check every hour for daily mode + return 3600 + case .beforeExpiry: + // Check every 5 minutes for expiry-based mode + return 300 + } + } + + /// Check if refresh is needed and perform it if so. + private func checkAndRefreshIfNeeded(provider: UsageProvider, config: KeepaliveConfig) async { + guard !self.isRefreshing.contains(provider) else { + self.log(provider, "Refresh already in progress, skipping check") + return + } + + // Rate limit: don't refresh too frequently + if let lastAttempt = self.lastRefreshAttempt[provider] { + let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt) + if timeSinceLastAttempt < config.minRefreshInterval { + self.log( + provider, + "Skipping refresh (last attempt \(Int(timeSinceLastAttempt))s ago, min interval: \(Int(config.minRefreshInterval))s)") + return + } + } + + // Check if we should refresh based on mode + let shouldRefresh = await self.shouldRefreshSession(provider: provider, config: config) + if shouldRefresh { + await self.performRefresh(provider: provider, forced: false) + } + } + + /// Determine if a session should be refreshed based on the mode. + private func shouldRefreshSession(provider: UsageProvider, config: KeepaliveConfig) async -> Bool { + switch config.mode { + case .interval: + // For interval mode, always refresh when check interval elapses + return true + + case .daily(let hour, let minute): + // For daily mode, check if we're at the scheduled time + let now = Date() + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: now) + + guard let currentHour = components.hour, let currentMinute = components.minute else { + return false + } + + // Check if we're within 5 minutes of the scheduled time + let scheduledMinutes = hour * 60 + minute + let currentMinutes = currentHour * 60 + currentMinute + let diff = abs(scheduledMinutes - currentMinutes) + + if diff <= 5 { + // Check if we already refreshed today + if let lastRefresh = self.lastSuccessfulRefresh[provider] { + let isSameDay = calendar.isDate(lastRefresh, inSameDayAs: now) + return !isSameDay + } + return true + } + return false + + case .beforeExpiry: + // For expiry-based mode, delegate to provider-specific logic + // This will be implemented per-provider in Phase 2 + self.log(provider, "beforeExpiry mode requires provider-specific implementation") + return false + } + } + + /// Perform the actual session refresh. + private func performRefresh(provider: UsageProvider, forced: Bool) async { + self.isRefreshing.insert(provider) + self.lastRefreshAttempt[provider] = Date() + defer { self.isRefreshing.remove(provider) } + + self.log(provider, forced ? "Performing forced session refresh..." : "Performing automatic session refresh...") + + // Provider-specific refresh logic will be implemented in Phase 2 + // For now, just log that we would refresh + self.log(provider, "⚠️ Provider-specific refresh not yet implemented") + + // Simulate success for now + self.lastSuccessfulRefresh[provider] = Date() + self.consecutiveFailures[provider] = 0 + self.log(provider, "✅ Session refresh completed (placeholder)") + } + + private static let log = CodexBarLog.logger("session-keepalive") + + /// Log a message with provider context. + private func log(_ provider: UsageProvider, _ message: String) { + let timestamp = Date().formatted(date: .omitted, time: .standard) + let fullMessage = "[\(timestamp)] [SessionKeepalive:\(provider.rawValue)] \(message)" + self.logger?(fullMessage) + Self.log.debug(fullMessage) + } +} + +#endif + diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7ecbe7b26..87f3de8be 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -11,16 +11,23 @@ struct ProviderIconResourcesTests { let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) let slugs = [ - "codex", + "amp", + "antigravity", + "augment", "claude", - "zai", - "minimax", + "codex", + "copilot", "cursor", - "opencode", - "gemini", - "antigravity", "factory", - "copilot", + "gemini", + "jetbrains", + "kimi", + "kiro", + "minimax", + "opencode", + "synthetic", + "vertexai", + "zai", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/SessionKeepaliveTests.swift b/Tests/CodexBarTests/SessionKeepaliveTests.swift new file mode 100644 index 000000000..fb416f3a0 --- /dev/null +++ b/Tests/CodexBarTests/SessionKeepaliveTests.swift @@ -0,0 +1,353 @@ +import CodexBarCore +import XCTest + +final class KeepaliveConfigTests: XCTestCase { + // MARK: - Mode Encoding/Decoding Tests + + func test_intervalModeEncodingDecoding() throws { + let config = KeepaliveConfig(mode: .interval(1800), enabled: true) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(KeepaliveConfig.self, from: data) + + XCTAssertEqual(decoded, config) + XCTAssertEqual(decoded.enabled, true) + + if case .interval(let seconds) = decoded.mode { + XCTAssertEqual(seconds, 1800) + } else { + XCTFail("Expected interval mode") + } + } + + func test_dailyModeEncodingDecoding() throws { + let config = KeepaliveConfig(mode: .daily(hour: 9, minute: 30), enabled: true) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(KeepaliveConfig.self, from: data) + + XCTAssertEqual(decoded, config) + + if case .daily(let hour, let minute) = decoded.mode { + XCTAssertEqual(hour, 9) + XCTAssertEqual(minute, 30) + } else { + XCTFail("Expected daily mode") + } + } + + func test_beforeExpiryModeEncodingDecoding() throws { + let config = KeepaliveConfig(mode: .beforeExpiry(buffer: 300), enabled: true) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(KeepaliveConfig.self, from: data) + + XCTAssertEqual(decoded, config) + + if case .beforeExpiry(let buffer) = decoded.mode { + XCTAssertEqual(buffer, 300) + } else { + XCTFail("Expected beforeExpiry mode") + } + } + + // MARK: - Default Configuration Tests + + func test_augmentDefaultConfig() { + let config = KeepaliveConfig.augmentDefault + + XCTAssertTrue(config.enabled) + XCTAssertEqual(config.minRefreshInterval, 120) + + if case .beforeExpiry(let buffer) = config.mode { + XCTAssertEqual(buffer, 300) // 5 minutes + } else { + XCTFail("Expected beforeExpiry mode for Augment") + } + } + + func test_claudeDefaultConfig() { + let config = KeepaliveConfig.claudeDefault + + XCTAssertTrue(config.enabled) + + if case .interval(let seconds) = config.mode { + XCTAssertEqual(seconds, 1800) // 30 minutes + } else { + XCTFail("Expected interval mode for Claude") + } + } + + func test_codexDefaultConfig() { + let config = KeepaliveConfig.codexDefault + + XCTAssertTrue(config.enabled) + + if case .interval(let seconds) = config.mode { + XCTAssertEqual(seconds, 3600) // 60 minutes + } else { + XCTFail("Expected interval mode for Codex") + } + } + + func test_disabledConfig() { + let config = KeepaliveConfig.disabled + + XCTAssertFalse(config.enabled) + } + + // MARK: - Description Tests + + func test_intervalModeDescription() { + let config = KeepaliveConfig(mode: .interval(1800), enabled: true) + let description = config.description + + XCTAssertTrue(description.contains("enabled")) + XCTAssertTrue(description.contains("every 1800s")) + } + + func test_dailyModeDescription() { + let config = KeepaliveConfig(mode: .daily(hour: 9, minute: 30), enabled: true) + let description = config.description + + XCTAssertTrue(description.contains("enabled")) + XCTAssertTrue(description.contains("daily at 09:30")) + } + + func test_beforeExpiryModeDescription() { + let config = KeepaliveConfig(mode: .beforeExpiry(buffer: 300), enabled: true) + let description = config.description + + XCTAssertTrue(description.contains("enabled")) + XCTAssertTrue(description.contains("300s before expiry")) + } + + func test_disabledConfigDescription() { + let config = KeepaliveConfig.disabled + let description = config.description + + XCTAssertTrue(description.contains("disabled")) + } + + // MARK: - Custom Configuration Tests + + func test_customMinRefreshInterval() { + let config = KeepaliveConfig( + mode: .interval(1800), + enabled: true, + minRefreshInterval: 60) + + XCTAssertEqual(config.minRefreshInterval, 60) + } + + func test_customMaxConsecutiveFailures() { + let config = KeepaliveConfig( + mode: .interval(1800), + enabled: true, + maxConsecutiveFailures: 10) + + XCTAssertEqual(config.maxConsecutiveFailures, 10) + } +} + +// MARK: - KeepaliveConfigStore Tests + +final class KeepaliveConfigStoreTests: XCTestCase { + var store: KeepaliveConfigStore! + var testDefaults: UserDefaults! + + override func setUp() { + super.setUp() + // Use a test suite name to avoid polluting real UserDefaults + self.testDefaults = UserDefaults(suiteName: "com.codexbar.tests.keepalive")! + self.store = KeepaliveConfigStore(userDefaults: self.testDefaults) + } + + override func tearDown() { + // Clean up test data + self.store.deleteAll() + self.testDefaults.removePersistentDomain(forName: "com.codexbar.tests.keepalive") + super.tearDown() + } + + // MARK: - Save/Load Tests + + func test_saveAndLoadConfig() { + let config = KeepaliveConfig.augmentDefault + self.store.save(config, for: .augment) + + let loaded = self.store.load(for: .augment) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded, config) + } + + func test_loadNonexistentConfig() { + let loaded = self.store.load(for: .claude) + XCTAssertNil(loaded) + } + + func test_loadOrDefaultWithSavedConfig() { + let config = KeepaliveConfig.claudeDefault + self.store.save(config, for: .claude) + + let loaded = self.store.loadOrDefault(for: .claude, default: .disabled) + XCTAssertEqual(loaded, config) + } + + func test_loadOrDefaultWithoutSavedConfig() { + let defaultConfig = KeepaliveConfig.disabled + let loaded = self.store.loadOrDefault(for: .codex, default: defaultConfig) + XCTAssertEqual(loaded, defaultConfig) + } + + // MARK: - Delete Tests + + func test_deleteConfig() { + let config = KeepaliveConfig.augmentDefault + self.store.save(config, for: .augment) + + XCTAssertNotNil(self.store.load(for: .augment)) + + self.store.delete(for: .augment) + XCTAssertNil(self.store.load(for: .augment)) + } + + // MARK: - Bulk Operations Tests + + func test_saveAndLoadAll() { + let configs: [UsageProvider: KeepaliveConfig] = [ + .augment: .augmentDefault, + .claude: .claudeDefault, + .codex: .codexDefault, + ] + + self.store.saveAll(configs) + + let loaded = self.store.loadAll() + XCTAssertEqual(loaded.count, 3) + XCTAssertEqual(loaded[.augment], .augmentDefault) + XCTAssertEqual(loaded[.claude], .claudeDefault) + XCTAssertEqual(loaded[.codex], .codexDefault) + } + + func test_deleteAll() { + let configs: [UsageProvider: KeepaliveConfig] = [ + .augment: .augmentDefault, + .claude: .claudeDefault, + .codex: .codexDefault, + ] + + self.store.saveAll(configs) + XCTAssertEqual(self.store.loadAll().count, 3) + + self.store.deleteAll() + XCTAssertEqual(self.store.loadAll().count, 0) + } + + // MARK: - Provider Defaults Tests + + func test_defaultConfigForAugment() { + let config = KeepaliveConfigStore.defaultConfig(for: .augment) + XCTAssertEqual(config, .augmentDefault) + } + + func test_defaultConfigForClaude() { + let config = KeepaliveConfigStore.defaultConfig(for: .claude) + XCTAssertEqual(config, .claudeDefault) + } + + func test_defaultConfigForCodex() { + let config = KeepaliveConfigStore.defaultConfig(for: .codex) + XCTAssertEqual(config, .codexDefault) + } + + func test_defaultConfigForUnsupportedProvider() { + let config = KeepaliveConfigStore.defaultConfig(for: .gemini) + XCTAssertEqual(config, .disabled) + } + + func test_loadWithProviderDefaults() { + // No saved config - should return provider default + let config = self.store.loadWithProviderDefaults(for: .augment) + XCTAssertEqual(config, .augmentDefault) + + // Save custom config + let customConfig = KeepaliveConfig(mode: .interval(900), enabled: true) + self.store.save(customConfig, for: .augment) + + // Should return saved config, not default + let loaded = self.store.loadWithProviderDefaults(for: .augment) + XCTAssertEqual(loaded, customConfig) + } +} + +// MARK: - SessionKeepaliveManager Tests + +@MainActor +final class SessionKeepaliveManagerTests: XCTestCase { + var manager: SessionKeepaliveManager! + + override func setUp() async throws { + try await super.setUp() + self.manager = SessionKeepaliveManager.shared + } + + override func tearDown() async throws { + // Stop all keepalive tasks + for provider in UsageProvider.allCases { + self.manager.stop(provider: provider) + } + try await super.tearDown() + } + + // MARK: - Start/Stop Tests + + func test_startKeepalive() async { + let config = KeepaliveConfig.augmentDefault + self.manager.start(provider: .augment, config: config) + + let loadedConfig = self.manager.configuration(for: .augment) + XCTAssertNotNil(loadedConfig) + XCTAssertEqual(loadedConfig, config) + } + + func test_stopKeepalive() async { + let config = KeepaliveConfig.augmentDefault + self.manager.start(provider: .augment, config: config) + + XCTAssertNotNil(self.manager.configuration(for: .augment)) + + self.manager.stop(provider: .augment) + XCTAssertNil(self.manager.configuration(for: .augment)) + } + + func test_startWithDisabledConfig() async { + let config = KeepaliveConfig.disabled + self.manager.start(provider: .augment, config: config) + + // Should not start if config is disabled + XCTAssertNil(self.manager.configuration(for: .augment)) + } + + // MARK: - Configuration Tests + + func test_configurationForNonStartedProvider() async { + let config = self.manager.configuration(for: .claude) + XCTAssertNil(config) + } + + func test_lastRefreshTimeInitiallyNil() async { + let time = self.manager.lastRefreshTime(for: .augment) + XCTAssertNil(time) + } +} + diff --git a/docs/configuration 2.md b/docs/configuration 2.md new file mode 100644 index 000000000..020293718 --- /dev/null +++ b/docs/configuration 2.md @@ -0,0 +1,84 @@ +--- +summary: "CodexBar config file layout for CLI + app settings." +read_when: + - "Editing the CodexBar config file or moving settings off Keychain." + - "Adding new provider settings fields or defaults." + - "Explaining CLI/app configuration and security." +--- + +# Configuration + +CodexBar reads a single JSON config file for CLI and app settings. +Secrets (API keys, cookies, tokens) live here; Keychain is not used. + +## Location +- `~/.codexbar/config.json` +- The directory is created if missing. +- Permissions are forced to `0600` on macOS and Linux. + +## Root shape +```json +{ + "version": 1, + "providers": [ + { + "id": "codex", + "enabled": true, + "source": "auto", + "cookieSource": "auto", + "cookieHeader": null, + "apiKey": null, + "region": null, + "workspaceID": null, + "tokenAccounts": null + } + ] +} +``` + +## Provider fields +All provider fields are optional unless noted. + +- `id` (required): provider identifier. +- `enabled`: enable/disable provider (defaults to provider default). +- `source`: preferred source mode. + - `auto|web|cli|oauth|api` + - `auto` uses web where possible, with CLI fallback. + - `api` uses provider API key flow (when supported). +- `apiKey`: raw API token for providers that support direct API usage. +- `cookieSource`: cookie selection policy. + - `auto` (browser import), `manual` (use `cookieHeader`), `off` (disable cookies) +- `cookieHeader`: raw cookie header value (e.g. `key=value; other=...`). +- `region`: provider-specific region (e.g. `zai`, `minimax`). +- `workspaceID`: provider-specific workspace ID (e.g. `opencode`). +- `tokenAccounts`: multi-account tokens for a provider. + +### tokenAccounts +```json +{ + "version": 1, + "activeIndex": 0, + "accounts": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "label": "user@example.com", + "token": "sk-...", + "addedAt": 1735123456, + "lastUsed": 1735220000 + } + ] +} +``` + +## Provider IDs +Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): +`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`. + +## Ordering +The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. + +## Notes +- Fields not relevant to a provider are ignored. +- Omitted providers are appended with defaults during normalization. +- Keep the file private; it contains secrets. +- Validate the file with `codexbar config validate` (JSON output available with `--format json`). diff --git a/docs/keychain-allow 2.png b/docs/keychain-allow 2.png new file mode 100644 index 000000000..ec2befce5 Binary files /dev/null and b/docs/keychain-allow 2.png differ diff --git a/docs/kimi-k2 2.md b/docs/kimi-k2 2.md new file mode 100644 index 000000000..a40aa3566 --- /dev/null +++ b/docs/kimi-k2 2.md @@ -0,0 +1,37 @@ +--- +summary: "Kimi K2 provider data sources: API key + credit endpoint." +read_when: + - Adding or tweaking Kimi K2 usage parsing + - Updating API key handling or config migration + - Documenting new provider behavior +--- + +# Kimi K2 provider + +Kimi K2 is API-only. Usage is reported by the credit counter behind `GET https://kimi-k2.ai/api/user/credits`, +so CodexBar only needs a valid API key to pull your remaining balance and usage. + +## Data sources + fallback order + +1) **API key** stored in `~/.codexbar/config.json` or supplied via `KIMI_K2_API_KEY` / `KIMI_API_KEY` / `KIMI_KEY`. + CodexBar stores the key in config after you paste it in Preferences → Providers → Kimi K2. +2) **Credit endpoint** + - `GET https://kimi-k2.ai/api/user/credits` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Response headers may include `X-Credits-Remaining`. + - JSON payload contains total credits consumed, credits remaining, and optional usage metadata. + CodexBar scans common keys and falls back to the remaining header when JSON omits it. + +## Usage details + +- Credits are the billing unit; CodexBar computes used percent as `consumed / (consumed + remaining)`. +- There is no explicit reset timestamp in the API, so the snapshot has no reset time. +- Environment variables take precedence over config. + +## Key files + +- `Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift` (descriptor + fetch strategy) +- `Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift` (HTTP client + parser) +- `Sources/CodexBarCore/Providers/KimiK2/KimiK2SettingsReader.swift` (env var parsing) +- `Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift` (settings field + activation logic) +- `Sources/CodexBar/KimiK2TokenStore.swift` (legacy migration helper) diff --git a/docs/opencode 2.md b/docs/opencode 2.md new file mode 100644 index 000000000..f4927c5f1 --- /dev/null +++ b/docs/opencode 2.md @@ -0,0 +1,28 @@ +--- +summary: "OpenCode provider notes: browser cookie import, _server endpoints, and usage parsing." +read_when: + - Adding or modifying the OpenCode provider + - Debugging OpenCode usage parsing or cookie import +--- + +# OpenCode provider + +## Data sources +- Browser cookies from `opencode.ai`. +- `POST https://opencode.ai/_server` with server function IDs: + - `workspaces` (`def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f`) + - `subscription.get` (`7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4`) + +## Usage mapping +- Primary window: rolling 5-hour usage (`rollingUsage.usagePercent`, `rollingUsage.resetInSec`). +- Secondary window: weekly usage (`weeklyUsage.usagePercent`, `weeklyUsage.resetInSec`). +- Resets computed as `now + resetInSec`. + +## Notes +- Responses are `text/javascript` with serialized objects; parse via regex. +- Missing workspace ID or usage fields should raise parse errors. +- Cookie import defaults to Chrome-only to avoid extra browser prompts; pass a browser list to override. +- Set `CODEXBAR_OPENCODE_WORKSPACE_ID` to skip workspace lookup and force a specific workspace. +- Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. +- Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser + import only runs when the cached cookie fails. diff --git a/docs/screenshots/claude-extra-usage-bug 2.png b/docs/screenshots/claude-extra-usage-bug 2.png new file mode 100644 index 000000000..3f2beaf8c Binary files /dev/null and b/docs/screenshots/claude-extra-usage-bug 2.png differ