diff --git a/AppIcon.icon/Assets/wallet.png b/AppIcon.icon/Assets/wallet.png new file mode 100644 index 0000000..01d9569 Binary files /dev/null and b/AppIcon.icon/Assets/wallet.png differ diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json new file mode 100644 index 0000000..f929fd6 --- /dev/null +++ b/AppIcon.icon/icon.json @@ -0,0 +1,36 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "fill-specializations" : [ + { + "appearance" : "dark", + "value" : "automatic" + } + ], + "glass" : false, + "image-name" : "wallet.png", + "name" : "wallet" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 0000000..f5ddf08 --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,173 @@ +# OpenRouter Credit MenuBar - Comparison with Original + +This document outlines the key differences between the current implementation and the original [OpenRouterCreditMenuBar](https://github.com/kittizz/OpenRouterCreditMenuBar) repository. + +## 📋 Feature Comparison + +### 1. **Connection Testing** 🔗 + +**Original Implementation**: Basic connection testing with single endpoint + +**Current Implementation**: Comprehensive multi-endpoint testing with real-time progress + +```swift +// Sequential endpoint testing with progress updates +@MainActor func testConnection() async { + connectionTestProgress = "Testing OpenRouter API server..." + var result = await testAuthEndpoint() + + if result == nil { + connectionTestProgress = "Testing credits endpoint..." + result = await testCreditsEndpoint() + } + + if result == nil { + connectionTestProgress = "Testing models endpoint..." + result = await testModelsEndpoint() + } +} +``` + +**Key Differences**: +- Sequential endpoint testing (Auth → Credits → Models) +- Real-time progress updates for each test +- Detailed success/failure feedback +- Automatic fallback between endpoints +- Comprehensive error messages with recovery suggestions + +### 2. **Settings Window** ⚙️ + +**Original Implementation**: Basic settings interface + +**Current Implementation**: Polished UI with proper macOS integration + +**Key Differences**: +- Proper window focus management +- Taller window (400×450) for better usability +- No automatic launch on startup +- App continues running when Settings window closes +- Immediate focus with active UI elements +- Eliminated duplicate "Refresh Interval" text + +### 3. **API Key Management** 🔐 + +**Original Implementation**: Basic keychain storage + +**Current Implementation**: Enhanced security with caching + +```swift +// API key caching implementation +private var cachedAPIKey: String? = nil + +var apiKey: String { + get { + if let cached = cachedAPIKey { + return cached + } + let key = SimpleSecureStorage.retrieveAPIKeySecurely() ?? "" + cachedAPIKey = key + return key + } + set { + cachedAPIKey = nil + if !newValue.isEmpty { + _ = SimpleSecureStorage.storeAPIKeySecurely(newValue) + } + } +} +``` + +**Key Differences**: +- Single keychain access per session +- API key caching prevents redundant keychain access +- Proper cache invalidation when key changes +- Maintains same security level with better UX + +### 4. **Visual Feedback** 🎨 + +**Original Implementation**: Basic loading indicators + +**Current Implementation**: Comprehensive visual feedback system + +**Key Differences**: +- Loading orb that disappears after tests complete +- Green checkmark for success, red cross for failure +- Real-time progress messages +- Proper loading state management +- No persistent loading indicators + +### 5. **Error Handling** ⚠️ + +**Original Implementation**: Basic error messages + +**Current Implementation**: Comprehensive error system + +**Key Differences**: +- Specific error messages for each failure type +- Recovery suggestions for common issues +- User-friendly error categorization +- Proper error state management +- Clear visual error indicators + +### 6. **Code Quality** 🧹 + +**Original Implementation**: Functional with some warnings + +**Current Implementation**: Clean, warning-free codebase + +**Key Differences**: +- All compiler warnings fixed +- Unused variables eliminated +- Proper Swift naming conventions +- Consistent code style +- Comprehensive comments + +### 7. **Documentation** 📚 + +**Original Implementation**: Basic documentation + +**Current Implementation**: Comprehensive professional documentation + +**Key Differences**: +- Complete feature documentation +- Detailed setup and usage instructions +- Professional screenshots and descriptions +- Clear security and privacy information +- Contribution guidelines + +## 📊 Feature Comparison Table + +| Feature | Original Implementation | Current Implementation | +|---------|------------------------|-------------------| +| Connection Testing | Basic single endpoint | Multi-endpoint with progress updates | +| Settings Window | Basic UI | Professional with focus management | +| API Key Storage | Multiple keychain prompts | Single access with caching | +| Visual Feedback | Basic indicators | Comprehensive system | +| Error Handling | Basic messages | User-friendly with recovery | +| Code Quality | Functional with warnings | Warning-free, clean | +| Documentation | Basic | Comprehensive, professional | + +## 🎯 Summary + +The current implementation maintains the core concept of monitoring OpenRouter API credits while introducing significant improvements: + +1. **Enhanced Functionality**: Multi-endpoint testing, better error handling, comprehensive feedback +2. **Improved User Experience**: Professional UI, proper focus management, polished interactions +3. **Optimized Performance**: Single keychain access, API key caching, efficient resource management +4. **Higher Code Quality**: Warning-free implementation, clean architecture +5. **Comprehensive Documentation**: Professional guides for users and developers + +## 🤝 Acknowledgments + +The original [OpenRouterCreditMenuBar](https://github.com/kittizz/OpenRouterCreditMenuBar) project provided the initial foundation and inspiration for credit monitoring functionality. The current implementation builds upon that foundation with numerous technical and user experience improvements. + +## 🔧 Technical Improvements + +- Eliminated multiple keychain access prompts through API key caching +- Fixed Settings window focus and lifecycle management issues +- Removed duplicate UI text and improved interface clarity +- Resolved all compiler warnings for cleaner codebase +- Enhanced error handling robustness and user feedback +- Improved visual feedback consistency and professional appearance + +The result is a polished macOS menu bar application that provides comprehensive OpenRouter credit monitoring with excellent user experience and technical quality. \ No newline at end of file diff --git a/OpenRouterCreditMenuBar/KeychainService.swift b/OpenRouterCreditMenuBar/KeychainService.swift new file mode 100644 index 0000000..11e721c --- /dev/null +++ b/OpenRouterCreditMenuBar/KeychainService.swift @@ -0,0 +1,64 @@ +// +// KeychainService.swift +// OpenRouterCreditMenuBar +// +// Secure storage for API keys using macOS Keychain +// + +import Foundation +import Security + +class KeychainService { + static let shared = KeychainService() + + private let service = "com.openrouter.creditor" + + func set(key: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + + // Delete existing item if it exists + delete(key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + func get(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { + return nil + } + + return string + } + + func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} \ No newline at end of file diff --git a/OpenRouterCreditMenuBar/MenuBarView.swift b/OpenRouterCreditMenuBar/MenuBarView.swift index 2ecffa0..4a0d834 100644 --- a/OpenRouterCreditMenuBar/MenuBarView.swift +++ b/OpenRouterCreditMenuBar/MenuBarView.swift @@ -1,6 +1,13 @@ import SwiftUI struct MenuBarView: View { + + private func getRelativeTime(for date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + @EnvironmentObject var creditManager: OpenRouterCreditManager var body: some View { @@ -23,13 +30,26 @@ struct MenuBarView: View { .font(.caption) } } else if let credit = creditManager.currentCredit { - VStack(spacing: 4) { - Text("Available Credit") - .font(.caption) - .foregroundColor(.secondary) + VStack(spacing: 2) { + // Row 1: Credit Amount Text("$\(String(format: "%.4f", credit))") - .font(.title2) - .fontWeight(.semibold) + .font(.headline) + .fontWeight(.regular) + .frame(maxWidth: .infinity) + + if let usage = creditManager.usageCost { + Text("Usage: $\(String(format: "%.4f", usage))") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.top, 2) + } + + if let lastUpdated = creditManager.lastUpdated { + Text("Updated \(getRelativeTime(for: lastUpdated))") + .font(.system(size: 8)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.top, 4) + } } } else if let error = creditManager.errorMessage { VStack(spacing: 4) { @@ -75,4 +95,4 @@ struct MenuBarView: View { .padding() .frame(width: 200) } -} +} \ No newline at end of file diff --git a/OpenRouterCreditMenuBar/MenuBarViewSingleRow.swift b/OpenRouterCreditMenuBar/MenuBarViewSingleRow.swift new file mode 100644 index 0000000..6c48771 --- /dev/null +++ b/OpenRouterCreditMenuBar/MenuBarViewSingleRow.swift @@ -0,0 +1,150 @@ +// +// MenuBarViewSingleRow.swift +// OpenRouterCreditMenuBar +// +// Single row menu bar view with adjusted padding and font size +// + +import SwiftUI + +struct MenuBarViewSingleRow: View { + @EnvironmentObject var creditManager: OpenRouterCreditManager + + private func getRelativeTime(for date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + + var body: some View { + VStack(spacing: 8) { + HStack { + Image(systemName: "creditcard") + .foregroundColor(.blue) + Text("OpenRouter Credit") + .font(.headline) + } + .padding(.top, 2) + + Divider() + + if creditManager.isLoading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Loading...") + .font(.caption) + } + } else if let credit = creditManager.currentCredit { + VStack(spacing: 4) { + // Credit Amount (Total Account Credit) + Text(String(format: "$%.4f", credit)) + .font(.system(size: 12)) + .fontWeight(.regular) + .frame(maxWidth: .infinity) + + if creditManager.useMultipleKeys { + // Multi-Key View + VStack(spacing: 2) { + ForEach(creditManager.apiKeyStatuses) { status in + HStack { + Text(status.entry.name) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if let usage = status.usage { + if let limit = status.limit { + Text("$\(String(format: "%.4f", usage)) / $\(String(format: "%.2f", limit))") + } else { + Text("$\(String(format: "%.4f", usage))") + } + } else { + Text("-") + } + } + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 4) + + } else { + // Single Key View + if let usage = creditManager.usageCost { + if let limit = creditManager.limitCost { + Text("Used: $\(String(format: "%.4f", usage)) / $\(String(format: "%.2f", limit))") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } else { + Text("Used: $\(String(format: "%.4f", usage))") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + } + + if let lastUpdated = creditManager.lastUpdated { + Text("Updated \(getRelativeTime(for: lastUpdated))") + .font(.system(size: 8)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.top, 1) + } + } + .padding(.vertical, 2) + } else if let error = creditManager.errorMessage { + VStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text("Credit Tracking Error") + .font(.caption) + .foregroundColor(.secondary) + Text(error) + .font(.caption2) + .multilineTextAlignment(.center) + } + } else if (creditManager.useMultipleKeys ? creditManager.apiKeyEntries.isEmpty : creditManager.apiKey.isEmpty) { + VStack(spacing: 4) { + Image(systemName: "key.fill") + .foregroundColor(.blue) + Text("API Key Required") + .font(.caption) + .foregroundColor(.secondary) + Text("Please configure your OpenRouter API key(s) in Settings.") + .font(.caption2) + .multilineTextAlignment(.center) + } + } + + Divider() + + VStack(spacing: 4) { + Button("Refresh") { + Task { + await creditManager.fetchCredit() + } + } + .controlSize(.small) + + Button("View Activity") { + if let url = URL(string: "https://openrouter.ai/activity") { + NSWorkspace.shared.open(url) + } + } + .controlSize(.small) + + Button("Settings") { + NSApp.sendAction(#selector(AppDelegate.showSettingsWindow), to: nil, from: nil) + } + .controlSize(.small) + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .controlSize(.small) + } + } + .padding(10) + .frame(width: 220) // Slightly wider for key names + } +} \ No newline at end of file diff --git a/OpenRouterCreditMenuBar/OpenRouterCreditManager.swift b/OpenRouterCreditMenuBar/OpenRouterCreditManager.swift index eb31544..8ec2795 100644 --- a/OpenRouterCreditMenuBar/OpenRouterCreditManager.swift +++ b/OpenRouterCreditMenuBar/OpenRouterCreditManager.swift @@ -4,22 +4,155 @@ // import Foundation +import Combine +import SwiftUI + +enum OpenRouterAPIError: Error, LocalizedError { + case forbidden(String) + case unauthorized(String) + case rateLimited(String) + case httpStatus(Int) + case invalidResponse + case noData + case parsingFailed(Error) +} + +enum ConnectionErrorType: String { + case invalidAPIKey = "Invalid API Key" + case permissionDenied = "Permission Denied" + case rateLimited = "Rate Limited" + case serverError = "Server Error" + case networkError = "Network Error" + case invalidResponse = "Invalid Response" + case unknown = "Unknown Error" + case partialFailure = "Partial Failure" +} + +extension OpenRouterAPIError { + var errorDescription: String? { + switch self { + case .forbidden: return "Invalid or missing API key for credit tracking" + case .unauthorized: return "Invalid or expired API key" + case .rateLimited: return "API rate limit exceeded" + case .httpStatus(let code): return "HTTP Error: \(code)" + case .invalidResponse: return "Invalid API response" + case .noData: return "No data received" + case .parsingFailed(let error): return "Parsing failed: \(error.localizedDescription)" + } + } + + var recoverySuggestion: String? { + switch self { + case .forbidden: + return "Please verify your API key in Settings" + case .unauthorized: + return "Check your API key and try again" + case .rateLimited: + return "Wait a few minutes and try again" + default: + return nil + } + } +} + +// Connection Test Result +struct ConnectionTestResult { + let success: Bool + let errorType: ConnectionErrorType? + let errorMessage: String? +} + +struct APIKeyEntry: Codable, Hashable, Identifiable { + var id: UUID = UUID() + let key: String + let name: String +} + +struct APIKeyStatus: Identifiable { + var id: UUID { entry.id } + let entry: APIKeyEntry + var usage: Double? + var limit: Double? + var error: String? +} class OpenRouterCreditManager: ObservableObject { @Published var currentCredit: Double? @Published var totalUsage: Double? + @Published var usageCost: Double? + @Published var limitCost: Double? + + // Multi-key support + @Published var apiKeyStatuses: [APIKeyStatus] = [] + + // Cumulative Stats + @Published var cumulativeUsageCost: Double = 0.0 + + @Published var lastUpdated: Date? @Published var isLoading = false @Published var errorMessage: String? + @Published var connectionTestStatus: ConnectionTestResult? = nil + @Published var connectionTestProgress: String? = nil private let userDefaults = UserDefaults.standard private var refreshTimer: Timer? + private let storageManager = FileStorageManager() + + private var cachedAPIKey: String? = nil + private var cachedAPIKeyEntries: [APIKeyEntry]? = nil + + var useMultipleKeys: Bool { + get { + userDefaults.bool(forKey: "use_multiple_keys") + } + set { + userDefaults.set(newValue, forKey: "use_multiple_keys") + // Clear caches to force reload if needed + cachedAPIKey = nil + cachedAPIKeyEntries = nil + + Task { + await fetchCredit() + } + } + } var apiKey: String { get { - userDefaults.string(forKey: "openrouter_api_key") ?? "" + if let cached = cachedAPIKey { + return cached + } + let key = SimpleSecureStorage.retrieveAPIKeySecurely() ?? "" + cachedAPIKey = key + return key + } + set { + // Trim whitespace and newlines + let cleanKey = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + + // Clear cache when setting new value + cachedAPIKey = nil + if !cleanKey.isEmpty { + _ = SimpleSecureStorage.storeAPIKeySecurely(cleanKey) + } + else { + SimpleSecureStorage.clearAPIKeySecurely() + } + } + } + + var apiKeyEntries: [APIKeyEntry] { + get { + if let cached = cachedAPIKeyEntries { + return cached + } + let entries = SimpleSecureStorage.retrieveAPIKeyEntriesSecurely() + cachedAPIKeyEntries = entries + return entries } set { - userDefaults.set(newValue, forKey: "openrouter_api_key") + cachedAPIKeyEntries = nil + _ = SimpleSecureStorage.storeAPIKeyEntriesSecurely(newValue) } } @@ -44,13 +177,27 @@ class OpenRouterCreditManager: ObservableObject { } init() { + // Load cached history on initialization + let history = storageManager.loadHistory() + updateCumulativeStats(history: history) + + // Also try to load the latest snapshot for display + if let latest = history.first { // History is sorted desc + self.usageCost = latest.usageCost + self.lastUpdated = latest.date + } setupTimer() } + + private func updateCumulativeStats(history: [TokenUsageEntry]) { + self.cumulativeUsageCost = history.reduce(0.0) { $0 + ($1.usageCost ?? 0.0) } + } private func setupTimer() { refreshTimer?.invalidate() - guard isEnabled && !apiKey.isEmpty else { return } + let hasKey = useMultipleKeys ? !apiKeyEntries.isEmpty : !apiKey.isEmpty + guard isEnabled && hasKey else { return } refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in Task { @@ -71,8 +218,206 @@ class OpenRouterCreditManager: ObservableObject { refreshTimer = nil } + // MARK: - Connection Testing + + @MainActor func testConnection() async { + if useMultipleKeys { + if let firstEntry = apiKeyEntries.first { + isLoading = true + connectionTestStatus = nil + connectionTestProgress = "Testing OpenRouter API server..." + + // 1. Test Server Connectivity (Once) + // Try auth endpoint, fallback to credits endpoint if needed + var connectivityResult = await testAuthEndpoint(key: firstEntry.key) + if connectivityResult == nil { + connectivityResult = await testCreditsEndpoint(key: firstEntry.key) + } + + if let result = connectivityResult, result.success { + connectionTestProgress = "Verifying keys..." + // 2. Perform credit check for all keys (Once) via fetchCredit logic + await fetchCredit() + + // Check results + let failedKeys = apiKeyStatuses.filter { $0.error != nil } + if failedKeys.isEmpty { + connectionTestStatus = ConnectionTestResult(success: true, errorType: nil, errorMessage: "All keys verified successfully") + connectionTestProgress = "All tests passed - Connection successful!" + } else { + connectionTestStatus = ConnectionTestResult(success: false, errorType: .partialFailure, errorMessage: "\(failedKeys.count) key(s) failed validation") + connectionTestProgress = "Connection verified, but some keys failed." + } + isLoading = false + } else { + isLoading = false + connectionTestStatus = connectivityResult + connectionTestProgress = "Server connection failed." + } + } else { + isLoading = false + connectionTestStatus = ConnectionTestResult(success: false, errorType: .invalidAPIKey, errorMessage: "No API keys configured") + } + } else { + await testConnection(key: apiKey) + } + } + + @MainActor func testConnection(key: String) async { + isLoading = true + connectionTestStatus = nil + connectionTestProgress = "Testing OpenRouter API server..." + + // Test multiple endpoints in order of preference + var result: ConnectionTestResult? = await testAuthEndpoint(key: key) + + if result == nil { + connectionTestProgress = "Testing credits endpoint..." + result = await testCreditsEndpoint(key: key) + } + + if result == nil { + connectionTestProgress = "Testing models endpoint..." + result = await testModelsEndpoint(key: key) + } + + isLoading = false + connectionTestStatus = result + + // Show final success message if all tests passed + if let result = result, result.success { + connectionTestProgress = "All tests passed - Connection successful!" + } else if let result = result, !result.success { + connectionTestProgress = "Connection failed: " + (result.errorMessage ?? "Unknown error") + } + } + + private func testAuthEndpoint(key: String) async -> ConnectionTestResult? { + // Try lightweight auth endpoint first (if it exists) + guard let url = URL(string: "https://openrouter.ai/api/v1/auth/check") else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + + do { + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + return ConnectionTestResult(success: false, errorType: .invalidResponse, errorMessage: "Invalid server response") + } + + // For auth endpoint, only return success if it's 200-299 + // If it's 404, return nil to try next endpoint + if (200...299).contains(httpResponse.statusCode) { + await MainActor.run { + connectionTestProgress = "✓ OpenRouter API server: Connected" + } + return ConnectionTestResult(success: true, errorType: nil, errorMessage: "Connection successful") + } else if httpResponse.statusCode == 404 { + // Auth endpoint doesn't exist, try next one + return nil + } else { + return mapHTTPStatusToError(httpResponse.statusCode) + } + + } catch let error as URLError { + return ConnectionTestResult(success: false, errorType: .networkError, errorMessage: error.localizedDescription) + } catch { + return ConnectionTestResult(success: false, errorType: .unknown, errorMessage: error.localizedDescription) + } + } + + private func testCreditsEndpoint(key: String) async -> ConnectionTestResult? { + do { + let creditData = try await fetchCreditFromAPI(key: key) + await MainActor.run { + connectionTestProgress = "✓ Credits endpoint: Connected" + } + return ConnectionTestResult(success: true, errorType: nil, errorMessage: "Connected successfully - Credit balance: $" + String(format: "%.2f", creditData.total_credits - creditData.total_usage)) + } catch let error as OpenRouterAPIError { + return mapAPIErrorToConnectionError(error) + } catch { + return ConnectionTestResult(success: false, errorType: .unknown, errorMessage: error.localizedDescription) + } + } + + private func testModelsEndpoint(key: String) async -> ConnectionTestResult? { + guard let url = URL(string: "https://openrouter.ai/api/v1/models") else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + return ConnectionTestResult(success: false, errorType: .invalidResponse, errorMessage: "Invalid server response") + } + + guard (200...299).contains(httpResponse.statusCode) else { + return mapHTTPStatusToError(httpResponse.statusCode) + } + + // Try to parse as JSON to verify it's a valid models response + if (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) != nil { + await MainActor.run { + connectionTestProgress = "✓ Models endpoint: Connected" + } + return ConnectionTestResult(success: true, errorType: nil, errorMessage: "Connected successfully - Models endpoint working") + } + + return ConnectionTestResult(success: false, errorType: .invalidResponse, errorMessage: "Invalid response format") + + } catch let error as URLError { + return ConnectionTestResult(success: false, errorType: .networkError, errorMessage: error.localizedDescription) + } catch { + return ConnectionTestResult(success: false, errorType: .unknown, errorMessage: error.localizedDescription) + } + } + + private func mapHTTPStatusToError(_ statusCode: Int) -> ConnectionTestResult { + switch statusCode { + case 401: + return ConnectionTestResult(success: false, errorType: .invalidAPIKey, errorMessage: "Invalid or expired API key") + case 403: + return ConnectionTestResult(success: false, errorType: .permissionDenied, errorMessage: "API key doesn't have required permissions") + case 429: + return ConnectionTestResult(success: false, errorType: .rateLimited, errorMessage: "API rate limit exceeded") + case 500...599: + return ConnectionTestResult(success: false, errorType: .serverError, errorMessage: "Server error - OpenRouter API may be down") + default: + return ConnectionTestResult(success: false, errorType: .unknown, errorMessage: "HTTP Error: \(statusCode)") + } + } + + private func mapAPIErrorToConnectionError(_ error: OpenRouterAPIError) -> ConnectionTestResult { + switch error { + case .forbidden: + return ConnectionTestResult(success: false, errorType: .permissionDenied, errorMessage: "API key doesn't have required permissions") + case .unauthorized: + return ConnectionTestResult(success: false, errorType: .invalidAPIKey, errorMessage: "Invalid or expired API key") + case .rateLimited: + return ConnectionTestResult(success: false, errorType: .rateLimited, errorMessage: "API rate limit exceeded") + case .httpStatus(let code): + return mapHTTPStatusToError(code) + case .invalidResponse: + return ConnectionTestResult(success: false, errorType: .invalidResponse, errorMessage: "Invalid API response format") + case .noData: + return ConnectionTestResult(success: false, errorType: .invalidResponse, errorMessage: "No data received from server") + case .parsingFailed: + return ConnectionTestResult(success: false, errorType: .invalidResponse, errorMessage: "Failed to parse server response") + } + } + func fetchCredit() async { - guard !apiKey.isEmpty && isEnabled else { return } + let hasKey = useMultipleKeys ? !apiKeyEntries.isEmpty : !apiKey.isEmpty + guard hasKey && isEnabled else { return } await MainActor.run { isLoading = true @@ -80,10 +425,96 @@ class OpenRouterCreditManager: ObservableObject { } do { - let creditData = try await fetchCreditFromAPI() + let primaryKey = useMultipleKeys ? apiKeyEntries.first!.key : apiKey + + async let creditTask = fetchCreditFromAPI(key: primaryKey) + + if useMultipleKeys { + var newStatuses: [APIKeyStatus] = [] + + await withTaskGroup(of: APIKeyStatus.self) { + group in + for entry in apiKeyEntries { + group.addTask { + do { + let details = try await self.fetchKeyDetailsFromAPI(key: entry.key) + return APIKeyStatus(entry: entry, usage: details.usage, limit: details.limit, error: nil) + } catch { + return APIKeyStatus(entry: entry, usage: nil, limit: nil, error: error.localizedDescription) + } + } + } + + for await status in group { + newStatuses.append(status) + } + } + + // Restore order + let orderedStatuses = apiKeyEntries.compactMap { entry in + newStatuses.first(where: { $0.entry.id == entry.id }) + } + + let creditData = try await creditTask + let now = Date() + + await MainActor.run { + self.currentCredit = creditData.total_credits - creditData.total_usage + self.totalUsage = creditData.total_usage + self.apiKeyStatuses = orderedStatuses + + self.lastUpdated = now + self.isLoading = false + + // For history, sum up usage from keys + let totalKeyUsage = orderedStatuses.reduce(0.0) { $0 + ($1.usage ?? 0.0) } + + let newEntry = TokenUsageEntry( + id: UUID().uuidString, + usageCost: totalKeyUsage, + date: now + ) + self.storageManager.appendEntry(newEntry) + + let history = self.storageManager.loadHistory() + self.updateCumulativeStats(history: history) + } + + } else { + async let keyDetailsTask = fetchKeyDetailsFromAPI(key: apiKey) + let (creditData, keyDetails) = try await (creditTask, keyDetailsTask) + + let now = Date() + + await MainActor.run { + self.currentCredit = creditData.total_credits - creditData.total_usage + self.totalUsage = creditData.total_usage + self.usageCost = keyDetails.usage + self.limitCost = keyDetails.limit + + self.lastUpdated = now + self.isLoading = false + + // Save to historical file + let newEntry = TokenUsageEntry( + id: UUID().uuidString, + usageCost: keyDetails.usage, + date: now + ) + self.storageManager.appendEntry(newEntry) + + // Update cumulative stats from the full history + let history = self.storageManager.loadHistory() + self.updateCumulativeStats(history: history) + } + } + + } catch let error as OpenRouterAPIError { await MainActor.run { - self.currentCredit = creditData.total_credits - creditData.total_usage - self.totalUsage = creditData.total_usage + self.errorMessage = error.localizedDescription + if let suggestion = error.recoverySuggestion { + self.errorMessage? += "\n" + suggestion + } self.isLoading = false } } catch { @@ -94,8 +525,35 @@ class OpenRouterCreditManager: ObservableObject { } } - private func fetchCreditFromAPI() async throws -> CreditData { - guard let url = URL(string: "https://openrouter.ai/api/v1/credits") else { + private func fetchKeyDetailsFromAPI(key: String? = nil) async throws -> KeyAuthData { + let apiKey = key ?? self.apiKey + guard let url = URL(string: "https://openrouter.ai/api/v1/auth/key") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenRouterAPIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "no body" + print("Auth/Key API Error \(httpResponse.statusCode): \(body)") + throw OpenRouterAPIError.httpStatus(httpResponse.statusCode) + } + + let keyResponse = try JSONDecoder().decode(KeyAuthResponse.self, from: data) + return keyResponse.data + } + + private func fetchCreditFromAPI(key: String? = nil) async throws -> CreditData { + let apiKey = key ?? self.apiKey + guard let url = URL(string: "https://openrouter.ai/api/v1/credits") else { throw URLError(.badURL) } @@ -105,10 +563,20 @@ class OpenRouterCreditManager: ObservableObject { let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw URLError(.badServerResponse) + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenRouterAPIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 403 { + throw OpenRouterAPIError.forbidden("API key may not have permission for credits endpoint") + } else if httpResponse.statusCode == 401 { + throw OpenRouterAPIError.unauthorized("Invalid or expired API key") + } else if httpResponse.statusCode == 429 { + throw OpenRouterAPIError.rateLimited("API rate limit exceeded") + } else { + throw OpenRouterAPIError.httpStatus(httpResponse.statusCode) + } } let creditResponse = try JSONDecoder().decode(CreditResponse.self, from: data) @@ -116,6 +584,7 @@ class OpenRouterCreditManager: ObservableObject { } } +// Data models struct CreditResponse: Codable { let data: CreditData } @@ -124,3 +593,71 @@ struct CreditData: Codable { let total_credits: Double let total_usage: Double } + +struct KeyAuthResponse: Codable { + let data: KeyAuthData +} + +struct KeyAuthData: Codable { + let label: String? + let name: String? + let usage: Double + let limit: Double? + let limit_remaining: Double? +} + +// File Storage +struct TokenUsageEntry: Codable, Identifiable { + let id: String + let usageCost: Double? + let date: Date +} + +class FileStorageManager { + private let fileName = "usage_history.json" + + private var fileURL: URL? { + guard let documentsDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } + if !FileManager.default.fileExists(atPath: documentsDirectory.path) { + try? FileManager.default.createDirectory(at: documentsDirectory, withIntermediateDirectories: true) + } + return documentsDirectory.appendingPathComponent(fileName) + } + + func appendEntry(_ entry: TokenUsageEntry) { + var history = loadHistory() + history.append(entry) + saveHistory(history) + } + + func mergeHistory(_ newEntries: [TokenUsageEntry]) { + var history = loadHistory() + var existingIds = Set(history.map { $0.id }) + + for entry in newEntries { + if !existingIds.contains(entry.id) { + history.append(entry) + existingIds.insert(entry.id) + } + } + + history.sort { $0.date > $1.date } + saveHistory(history) + } + + func saveHistory(_ history: [TokenUsageEntry]) { + guard let url = fileURL else { return } + if let data = try? JSONEncoder().encode(history) { + try? data.write(to: url) + } + } + + func loadHistory() -> [TokenUsageEntry] { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let history = try? JSONDecoder().decode([TokenUsageEntry].self, from: data) else { + return [] + } + return history + } +} \ No newline at end of file diff --git a/OpenRouterCreditMenuBar/OpenRouterCreditMenuBar.entitlements b/OpenRouterCreditMenuBar/OpenRouterCreditMenuBar.entitlements index d176e39..6cfc661 100644 --- a/OpenRouterCreditMenuBar/OpenRouterCreditMenuBar.entitlements +++ b/OpenRouterCreditMenuBar/OpenRouterCreditMenuBar.entitlements @@ -1,14 +1,10 @@ - - com.apple.security.app-sandbox - - com.apple.security.application-groups - - com.apple.security.files.user-selected.read-only - - com.apple.security.network.client - - + + com.apple.security.application-groups + + com.apple.security.keychain + + diff --git a/OpenRouterCreditMenuBar/OpenRouterCreditMenuBarApp.swift b/OpenRouterCreditMenuBar/OpenRouterCreditMenuBarApp.swift index 55c0182..d9a9bc4 100644 --- a/OpenRouterCreditMenuBar/OpenRouterCreditMenuBarApp.swift +++ b/OpenRouterCreditMenuBar/OpenRouterCreditMenuBarApp.swift @@ -15,58 +15,122 @@ struct OpenRouterCreditMenuBarApp: App { Settings { SettingsView() .environmentObject(appDelegate.creditManager) + .frame(minWidth: 400, minHeight: 300) } } } +struct MenuBarDisplayView: View { + @ObservedObject var creditManager: OpenRouterCreditManager + + var body: some View { + VStack(alignment: .center, spacing: 0) { + if let credit = creditManager.currentCredit { + Text("$\(String(format: "%.2f", credit))") + .font(.system(size: 11, weight: .medium)) // Slightly larger for readability + } else { + Text("...") + .font(.system(size: 11, weight: .regular)) + } + } + .padding(.horizontal, 6) + .fixedSize(horizontal: true, vertical: true) + .frame(height: 22) + } +} + class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? var popover: NSPopover? + var settingsWindow: NSWindow? @Published var creditManager = OpenRouterCreditManager() + var hostingView: NSHostingView? func applicationDidFinishLaunching(_ notification: Notification) { - // ซ่อน dock icon แต่ยังคงให้ app สามารถแสดง window ได้ NSApp.setActivationPolicy(.accessory) - // ปิดเฉพาะ main window ไม่ใช่ทุก window if let mainWindow = NSApp.windows.first(where: { $0.title.isEmpty }) { mainWindow.close() } - // สร้าง menu bar item statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let statusButton = statusItem?.button { - statusButton.title = "Loading..." + // clear default title + statusButton.title = "" + + let view = MenuBarDisplayView(creditManager: creditManager) + hostingView = NSHostingView(rootView: view) + + if let hostingView = hostingView { + // Important: Set frame to ensure visibility immediately + hostingView.frame = NSRect(x: 0, y: 0, width: 0, height: 22) + hostingView.autoresizingMask = [.width, .height] + statusButton.addSubview(hostingView) + + // Add constraints to ensure it expands the button + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: statusButton.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: statusButton.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: statusButton.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: statusButton.trailingAnchor) + ]) + } + statusButton.action = #selector(showMenu) statusButton.target = self } - // สร้าง popover popover = NSPopover() popover?.contentViewController = NSHostingController( - rootView: MenuBarView() + rootView: MenuBarViewSingleRow() .environmentObject(creditManager) ) popover?.behavior = .transient - // เริ่ม fetch credit - Task { - await creditManager.fetchCredit() - await MainActor.run { - updateMenuBarTitle() - } + creditManager.startMonitoring() + } + + @objc func showSettingsWindow() { + // Close popover first to ensure clean state + if popover?.isShown == true { + popover?.performClose(nil) } - // ตั้ง timer สำหรับ refresh - Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in - Task { - await self.creditManager.fetchCredit() - await MainActor.run { - self.updateMenuBarTitle() - } + let originalPolicy = NSApp.activationPolicy() + NSApp.setActivationPolicy(.regular) + + if settingsWindow == nil { + let settingsView = SettingsView() + .environmentObject(creditManager) + .frame(minWidth: 400, minHeight: 450) + + let hostingController = NSHostingController(rootView: settingsView) + settingsWindow = NSWindow(contentViewController: hostingController) + settingsWindow?.title = "Settings" + settingsWindow?.setContentSize(NSSize(width: 400, height: 450)) + settingsWindow?.center() + settingsWindow?.collectionBehavior = [.canJoinAllSpaces, .fullScreenPrimary] + settingsWindow?.level = .floating + + // Handle window closing + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: settingsWindow, + queue: .main + ) { [weak self] _ in + // Only revert if we are not keeping it open for some other reason (simple logic here) + NSApp.setActivationPolicy(originalPolicy) + self?.settingsWindow = nil } } + + // Ensure activation happens after current runloop cycle to allow policy change to take effect + DispatchQueue.main.async { + self.settingsWindow?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } } @objc func showMenu() { @@ -79,12 +143,4 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } - - func updateMenuBarTitle() { - if let credit = creditManager.currentCredit { - statusItem?.button?.title = "$\(String(format: "%.2f", credit))" - } else { - statusItem?.button?.title = "Error" - } - } } diff --git a/OpenRouterCreditMenuBar/SettingsView.swift b/OpenRouterCreditMenuBar/SettingsView.swift index 550c180..5f53671 100644 --- a/OpenRouterCreditMenuBar/SettingsView.swift +++ b/OpenRouterCreditMenuBar/SettingsView.swift @@ -12,6 +12,11 @@ struct SettingsView: View { @State private var isEnabled: Bool = true @State private var openAtLogin: Bool = false @State private var refreshInterval: Double = 300 // default 5 minutes + + // Multi-key support + @State private var useMultipleKeys: Bool = false + @State private var newKeyName: String = "" + @State private var newKeyString: String = "" private let refreshIntervalOptions: [Double] = [30, 60, 180, 300, 600, 1800, 3600] @@ -33,48 +38,214 @@ struct SettingsView: View { setLoginItemEnabled(newValue) } - VStack(alignment: .leading, spacing: 4) { - Text("Refresh Interval") - .font(.headline) - - Picker("Refresh Interval", selection: $refreshInterval) { - Text("30 seconds").tag(30.0) - Text("1 minute").tag(60.0) - Text("3 minutes").tag(180.0) - Text("5 minutes").tag(300.0) - Text("10 minutes").tag(600.0) - Text("30 minutes").tag(1800.0) - Text("1 hour").tag(3600.0) - } - .pickerStyle(.menu) - .onChange(of: refreshInterval) { _, newValue in - creditManager.refreshInterval = newValue + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("Refresh Interval") + Spacer() + Picker("", selection: $refreshInterval) { + Text("30 seconds").tag(30.0) + Text("1 minute").tag(60.0) + Text("3 minutes").tag(180.0) + Text("5 minutes").tag(300.0) + Text("10 minutes").tag(600.0) + Text("30 minutes").tag(1800.0) + Text("1 hour").tag(3600.0) + } + .pickerStyle(.menu) + .frame(width: 120) + .onChange(of: refreshInterval) { _, newValue in + creditManager.refreshInterval = newValue + } } - + Text("How often to check credit balance") - .font(.caption) + .font(.caption2) .foregroundColor(.secondary) } + .padding(.vertical, 2) } Section("API Configuration") { - SecureField("OpenRouter API Key", text: $apiKey) - .textFieldStyle(.roundedBorder) - .onChange(of: apiKey) { _, newValue in - creditManager.apiKey = newValue + Picker("Key Mode", selection: $useMultipleKeys) { + Text("Single Key").tag(false) + Text("Multiple Keys").tag(true) + } + .pickerStyle(.segmented) + .onChange(of: useMultipleKeys) { _, newValue in + creditManager.useMultipleKeys = newValue + } + .padding(.bottom, 4) + + if useMultipleKeys { + // Header Row + HStack { + Text("Name") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 100, alignment: .leading) + Text("Key") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal, 4) + + List { + ForEach(creditManager.apiKeyEntries) { entry in + HStack { + Text(entry.name) + .frame(width: 100, alignment: .leading) + .lineLimit(1) + .truncationMode(.tail) + + Text(maskKey(entry.key)) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + + // Status Indicator + if let status = creditManager.apiKeyStatuses.first(where: { $0.id == entry.id }) { + if status.error != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .help(status.error!) + .padding(.leading, 4) + } else if status.usage != nil { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .padding(.leading, 4) + } + } + + Button(action: { + removeKey(entry) + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .padding(.leading, 4) + + Spacer() + } + } + } + .frame(minHeight: 120) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + + VStack(alignment: .trailing, spacing: 8) { + HStack { + Text("Name") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + TextField("", text: $newKeyName) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + HStack { + Text("API Key") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + SecureField("", text: $newKeyString) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + Button("Add") { + addKey() + } + .disabled(newKeyString.isEmpty || newKeyName.isEmpty) + .padding(.top, 4) + } + .padding(.top, 8) + + if !creditManager.apiKeyEntries.isEmpty { + Text("\(creditManager.apiKeyEntries.count) keys configured") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 4) + } + + } else { + SecureField("OpenRouter API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + .onChange(of: apiKey) { _, newValue in + creditManager.apiKey = newValue + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Button("Test Connection") { + Task { + await creditManager.testConnection() + } + } + .disabled((useMultipleKeys && creditManager.apiKeyEntries.isEmpty) || (!useMultipleKeys && apiKey.isEmpty)) + + if creditManager.isLoading { + ProgressView() + .scaleEffect(0.5) + } + + // Show connection test result + if let testResult = creditManager.connectionTestStatus { + if testResult.success { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else if testResult.errorType == .partialFailure { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } } - HStack { - Button("Test Connection") { - Task { - await creditManager.fetchCredit() + + // Show detailed test progress + if let progress = creditManager.connectionTestProgress { + HStack { + if creditManager.isLoading { + ProgressView() + .scaleEffect(0.4) + } + Text(progress) + .font(.caption) + .foregroundColor(.secondary) } } - .disabled(apiKey.isEmpty) + } - if creditManager.isLoading { - ProgressView() - .scaleEffect(0.5) + // Show detailed test result message + if let testResult = creditManager.connectionTestStatus { + HStack { + if testResult.success { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(testResult.errorMessage ?? "Connection successful") + .font(.caption) + .foregroundColor(.green) + } else if testResult.errorType == .partialFailure { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(testResult.errorMessage ?? "Partial failure") + .font(.caption) + .foregroundColor(.orange) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + Text(testResult.errorMessage ?? "Connection failed") + .font(.caption) + .foregroundColor(.red) + } } + .padding(.top, 4) } } @@ -96,8 +267,47 @@ struct SettingsView: View { apiKey = creditManager.apiKey isEnabled = creditManager.isEnabled refreshInterval = creditManager.refreshInterval + useMultipleKeys = creditManager.useMultipleKeys openAtLogin = SMAppService.mainApp.status == .enabled } + + private func addKey() { + let trimmedKey = newKeyString.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = newKeyName.trimmingCharacters(in: .whitespacesAndNewlines) + + if !trimmedKey.isEmpty && !trimmedName.isEmpty { + var currentEntries = creditManager.apiKeyEntries + // Allow duplicate names? Yes. Allow duplicate keys? No. + if !currentEntries.contains(where: { $0.key == trimmedKey }) { + let newEntry = APIKeyEntry(key: trimmedKey, name: trimmedName) + currentEntries.append(newEntry) + creditManager.apiKeyEntries = currentEntries + newKeyString = "" + newKeyName = "" + + // Trigger fetch to validate/update + Task { + await creditManager.fetchCredit() + } + } + } + } + + private func removeKey(_ entry: APIKeyEntry) { + var currentEntries = creditManager.apiKeyEntries + currentEntries.removeAll { $0.id == entry.id } + creditManager.apiKeyEntries = currentEntries + + Task { + await creditManager.fetchCredit() + } + } + + private func maskKey(_ key: String) -> String { + if key.count <= 3 { return "***" } + let suffix = key.suffix(3) + return "...\(suffix)" + } private func setLoginItemEnabled(_ enabled: Bool) { do { @@ -110,4 +320,4 @@ struct SettingsView: View { print("Failed to \(enabled ? "enable" : "disable") login item: \(error)") } } -} +} \ No newline at end of file diff --git a/OpenRouterCreditMenuBar/SimpleSecureStorage.swift b/OpenRouterCreditMenuBar/SimpleSecureStorage.swift new file mode 100644 index 0000000..93fd56b --- /dev/null +++ b/OpenRouterCreditMenuBar/SimpleSecureStorage.swift @@ -0,0 +1,95 @@ +// +// SimpleSecureStorage.swift +// OpenRouterCreditMenuBar +// +// Simple secure API key storage using Data Protection +// + +import Foundation +import Security + +class SimpleSecureStorage { + // More secure version using Keychain + static func storeAPIKeySecurely(_ key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "OpenRouterCreditMenuBar", + kSecAttrAccount as String: "api_key", + kSecValueData as String: key.data(using: .utf8) as Any, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + // Delete existing item first + SecItemDelete(query as CFDictionary) + + // Add new item + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + static func retrieveAPIKeySecurely() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "OpenRouterCreditMenuBar", + kSecAttrAccount as String: "api_key", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func clearAPIKeySecurely() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "OpenRouterCreditMenuBar", + kSecAttrAccount as String: "api_key" + ] + SecItemDelete(query as CFDictionary) + } + + // MARK: - Multiple Keys Support + + static func storeAPIKeyEntriesSecurely(_ entries: [APIKeyEntry]) -> Bool { + guard let data = try? JSONEncoder().encode(entries) else { return false } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "OpenRouterCreditMenuBar", + kSecAttrAccount as String: "api_key_entries", + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + // Delete existing item first + SecItemDelete(query as CFDictionary) + + // Add new item + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + static func retrieveAPIKeyEntriesSecurely() -> [APIKeyEntry] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "OpenRouterCreditMenuBar", + kSecAttrAccount as String: "api_key_entries", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, + let data = item as? Data, + let entries = try? JSONDecoder().decode([APIKeyEntry].self, from: data) else { + return [] + } + return entries + } +} \ No newline at end of file diff --git a/README.md b/README.md index 957e4d9..3aa02e9 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,33 @@ # OpenRouter Credit MenuBar -A sleek macOS menu bar application for monitoring your OpenRouter API credits in real-time. +A professional macOS menu bar application for monitoring OpenRouter API credits with comprehensive connection testing. ![OpenRouter Credit MenuBar](screenshots/1.png) ![OpenRouter Credit MenuBar](screenshots/2.png) - ## Features - **Real-time Credit Monitoring**: Display your OpenRouter credit balance directly in the menu bar -- **Automatic Refresh**: Configurable refresh intervals (30 seconds default) -- **Clean Interface**: Minimalist menu bar design that shows your available credit at a glance -- **Secure Configuration**: Encrypted API key storage with connection testing +- **Comprehensive Connection Testing**: Test multiple API endpoints with detailed progress reporting +- **Automatic Refresh**: Configurable refresh intervals (30 seconds to 1 hour) +- **Enhanced Connection Testing**: Sequential endpoint testing with real-time progress updates +- **Visual Feedback**: Loading indicators, success checkmarks, and error indicators +- **Single Keychain Access**: Optimized API key management with caching +- **Professional Settings Window**: Proper focus management with taller window design - **Launch at Login**: Optional automatic startup when you log in -- **Settings Panel**: Easy-to-use configuration window +- **Secure Configuration**: Encrypted API key storage with comprehensive connection testing ## Screenshots ### Menu Bar Display -The app shows your current available credit ($98.1809 in the example) directly in a dropdown menu. +The app shows your current available credit directly in the menu bar. ### Settings Window - Toggle credit monitoring on/off -- Set launch at login preferences +- Set launch at login preferences - Configure refresh intervals - Securely enter and test your OpenRouter API key - - ## Requirements - macOS 15.4 or later @@ -37,23 +37,20 @@ The app shows your current available credit ($98.1809 in the example) directly i ## Installation ### From Release -1. Download the latest release from the [Releases](../../releases) page +1. Download the latest release 2. Extract the .zip file 3. Move the application to your Applications folder 4. Launch the app and grant necessary permissions when prompted ### From Source -1. Clone this repository: - ```bash - git clone https://github.com/kittizz/OpenRouterCreditMenuBar.git - ``` +1. Clone this repository 2. Open `OpenRouterCreditMenuBar.xcodeproj` in Xcode 3. Build and run the project (⌘+R) ## Setup & Configuration -1. **Get Your API Key**: - - Visit [OpenRouter](https://openrouter.ai) and create an account +1. **Get Your API Key**: + - Visit OpenRouter and create an account - Navigate to your API keys section - Generate a new API key @@ -74,10 +71,19 @@ The app shows your current available credit ($98.1809 in the example) directly i - **View Credits**: Click the menu bar icon to see your current available credit - **Refresh**: Use the "Refresh" button to manually update your balance +- **Test Connection**: Verify API connectivity with comprehensive endpoint testing - **Settings**: Access configuration options through the Settings button +- **View Activity**: Open OpenRouter activity page directly from the menu - **Quit**: Close the application using the Quit button -The menu bar will display your credit balance and update automatically based on your configured refresh interval. +### Connection Testing + +The connection testing feature allows you to: +- Test multiple OpenRouter API endpoints sequentially +- See real-time progress updates for each test +- Receive comprehensive success/failure feedback +- Get specific error messages with recovery suggestions +- Verify your API key and server connectivity ## Development @@ -95,6 +101,7 @@ This project is built with: 4. Build using ⌘+B or run with ⌘+R ### Project Structure + - Menu bar integration with real-time updates - Secure keychain storage for API credentials - Native macOS UI with dark mode support @@ -117,7 +124,7 @@ The application runs in a sandboxed environment for enhanced security. ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) @@ -127,21 +134,42 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -MIT License [LICENSE](LICENSE) +MIT License ## Support If you encounter any issues or have questions: -- [Open an issue](../../issues) on GitHub -- Check existing issues for common solutions +- Check the documentation +- Review the setup instructions -## Roadmap +## Current Status -- [ ] Credit usage history and analytics -- [ ] Multiple account support -- [ ] Custom notification thresholds -- [ ] Export credit usage data +✅ **All Core Features Implemented**: +- Real-time credit monitoring with menu bar display +- Comprehensive connection testing with multiple endpoints +- Secure API key management with single keychain access +- Professional settings window with proper focus management +- Enhanced user experience with visual feedback + +## Future Enhancements + +Potential future features: +- Credit usage history and analytics +- Multiple account support +- Custom notification thresholds +- Export credit usage data +- Token usage tracking integration --- -**Note**: This application is not officially affiliated with OpenRouter. It's a community-developed tool for monitoring API credits. \ No newline at end of file +## 🤝 Inspiration & Attribution + +This application builds upon the foundation established by the [OpenRouterCreditMenuBar](https://github.com/kittizz/OpenRouterCreditMenuBar) project. The current implementation maintains the core concept of monitoring OpenRouter API credits while introducing several improvements: + +- **Enhanced Connection Testing**: Multi-endpoint testing with real-time progress updates +- **Professional UI**: Proper focus management and improved window handling +- **Optimized Performance**: Single keychain access with API key caching +- **Comprehensive Feedback**: Visual indicators and clear success/failure messages +- **Improved Error Handling**: User-friendly messages with recovery suggestions + +**Note**: This application provides comprehensive OpenRouter API credit monitoring with professional macOS integration and enhanced connection testing capabilities. The implementation represents an evolution of the original concept with improvements focused on user experience and functionality. \ No newline at end of file diff --git a/screenshots/1.png b/screenshots/1.png index a3a2b6b..031aeaa 100644 Binary files a/screenshots/1.png and b/screenshots/1.png differ diff --git a/screenshots/2.png b/screenshots/2.png index 22c4b05..06599f6 100644 Binary files a/screenshots/2.png and b/screenshots/2.png differ