From affdef5b1edc8f8c2c8fbfaef389e6677b30efc8 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 22 Sep 2026 13:24:16 +0900 Subject: [PATCH] feat(macos-usage): add gateway quota menu bar app --- apps/macos-usage/.gitignore | 3 + apps/macos-usage/DESIGN.md | 84 +++++ apps/macos-usage/Package.swift | 9 + .../LiteLLMUsageMenuBar/GatewayClient.swift | 143 ++++++++ .../LiteLLMUsageMenuBar/KeychainStore.swift | 80 +++++ .../LiteLLMUsageMenuBarApp.swift | 34 ++ .../Sources/LiteLLMUsageMenuBar/Models.swift | 176 ++++++++++ .../LiteLLMUsageMenuBar/UsageStore.swift | 311 ++++++++++++++++++ .../Sources/LiteLLMUsageMenuBar/Views.swift | 198 +++++++++++ apps/macos-usage/assets/litellm_logo.jpg | Bin 0 -> 9222 bytes apps/macos-usage/scripts/build-app.sh | 56 ++++ .../scripts/fill-logo-background.swift | 104 ++++++ 12 files changed, 1198 insertions(+) create mode 100644 apps/macos-usage/.gitignore create mode 100644 apps/macos-usage/DESIGN.md create mode 100644 apps/macos-usage/Package.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift create mode 100644 apps/macos-usage/assets/litellm_logo.jpg create mode 100755 apps/macos-usage/scripts/build-app.sh create mode 100644 apps/macos-usage/scripts/fill-logo-background.swift diff --git a/apps/macos-usage/.gitignore b/apps/macos-usage/.gitignore new file mode 100644 index 00000000000..564c4754447 --- /dev/null +++ b/apps/macos-usage/.gitignore @@ -0,0 +1,3 @@ +.build/ +LiteLLMUsage.app/ +LiteLLMUsage.dmg diff --git a/apps/macos-usage/DESIGN.md b/apps/macos-usage/DESIGN.md new file mode 100644 index 00000000000..62b0d981133 --- /dev/null +++ b/apps/macos-usage/DESIGN.md @@ -0,0 +1,84 @@ +# LiteLLM Usage Menu Bar Design System + +## 0. Research Log +- Embedded refs: operational menu bar surface, using the taste-skill restraint rather than a marketing layout +- Lazyweb: skipped, this is a native macOS utility with no web surface +- Imagen drafts: skipped, a system utility has no image-led visual contract +- Apple platform reference: SwiftUI `MenuBarExtra` window style, available on macOS 13+ + +## 1. Atmosphere & Identity + +A quiet glanceable utility. The signature is a single signal, the current personal quota percentage, supported by a small amount of operational detail when the menu opens + +## 2. Color + +| Role | Token | Value | Usage | +|------|------|------|------| +| Surface | surface | system background | Menu popover | +| Text | text | primary | Main labels | +| Muted | muted | secondary | Supporting facts | +| Accent | accent | system blue | Refresh and settings actions | +| Success | success | system green | Healthy quota state | +| Warning | warning | system orange | High quota state | +| Error | error | system red | Failure state | + +## 3. Typography + +Primary: macOS system font. Mono: system monospaced font for amounts and percentages. Body text is at least 13pt + +## 4. Spacing & Layout + +Spacing derives from a 4pt base. The popover uses a 320pt width, 16pt outer padding, 12pt section gaps, and 8pt inline gaps + +## 5. Components + +### Menu bar status +- Structure: compact text label with percentage +- Variants: loading, healthy, warning, error, no quota +- States: default, disabled while loading +- Accessibility: label includes the full percentage and state + +### App icon +- Motif: the official LiteLLM logo aligned with the menu bar's πŸš… identity +- Source: the official square `assets/litellm_logo.jpg` mark with its sky-blue background extended across the canvas, generated into a standard macOS `.icns` bundle resource + +### Usage popover +- Structure: title, quota signal, usage facts, status line, action row +- Variants: configured, needs key, loading, success, failure, unavailable quota +- States: default, loading, error +- Accessibility: buttons have labels and visible text; color is never the only status signal + +### Settings form + - Structure: auto-refresh picker, gateway URL field, username field, secure password field, explanatory label, save action +- Variants: empty, configured +- States: focused, saving, saved; successful save dismisses the settings window +- Accessibility: secure text entry and explicit save action + +### Settings window + - Size: 380pt wide by 360pt high so the refresh picker, gateway URL, fields, explanation, and save action remain visible +- Presentation: a single independent window opened explicitly by the usage popover's settings action, never a sheet attached to the menu bar +- Focus: opening settings activates the app for keyboard input; closing the popover must not close settings +- Storage: username and password share one Keychain item; session tokens remain in memory for the running app + +### Usage scope picker +- Placement: top of the usage popover, above the quota signal +- Options: team total plus the authenticated user's named keys +- Fallback: a key without its own budget uses the team's monthly budget as its limit + +### Auto refresh +- Options: off, adaptive, 1 minute, 5 minutes, 15 minutes, 30 minutes, or 1 hour +- Default: adaptive +- Adaptive behavior: uses current utilization, recent spend rate, estimated time to limit, and reset time +- Behavior: refreshes the selected team or key budget while the menu bar app is running + +## 6. Motion & Interaction + +No decorative animation. Native popover transitions and button press feedback are retained. Refresh is explicit and periodic refresh does not steal focus + +## 7. Depth & Surface + +Use native macOS popover materials and tonal hierarchy. Do not add custom shadows or gradients + +## 8. Accessibility Constraints & Accepted Debt + +Percentage text remains readable without color. The app does not expose a custom chart or notification surface in this version diff --git a/apps/macos-usage/Package.swift b/apps/macos-usage/Package.swift new file mode 100644 index 00000000000..feb3f12f3c0 --- /dev/null +++ b/apps/macos-usage/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "LiteLLMUsageMenuBar", + platforms: [.macOS(.v13)], + products: [.executable(name: "LiteLLMUsageMenuBar", targets: ["LiteLLMUsageMenuBar"])], + targets: [.executableTarget(name: "LiteLLMUsageMenuBar")] +) diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift new file mode 100644 index 00000000000..67efc11ffce --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift @@ -0,0 +1,143 @@ +import Foundation + +struct GatewayClient: Sendable { + let baseURL: URL + + static let defaultBaseURL = URL(string: "https://api.litellm.ai")! + + static func baseURL(from input: String) -> URL? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil else { return nil } + while components.path.count > 1, components.path.hasSuffix("/") { + components.path.removeLast() + } + return components.url + } + + func login(username: String, password: String) async throws -> AuthSession { + let url = baseURL.appending(path: "/v2/login") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["username": username, "password": password]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 { throw GatewayError.unauthorized } + throw GatewayError.server(httpResponse.statusCode) + } + let payload = try JSONDecoder().decode(LoginResponse.self, from: data) + guard let token = payload.token, let session = AuthSession(token: token) else { + throw GatewayError.invalidLoginResponse + } + return session + } + + func fetchUserInfo(apiKey: String) async throws -> UserInfoResponse { + let url = baseURL.appending(path: "/v2/user/info") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + + return try decoder.decode(UserInfoResponse.self, from: data) + } + + func fetchTeamInfo(apiKey: String, teamID: String) async throws -> TeamBudget { + var components = URLComponents(url: baseURL.appending(path: "/team/info"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "team_id", value: teamID)] + guard let url = components?.url else { throw GatewayError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(TeamInfoResponse.self, from: data).teamInfo + } + + func fetchKeyList(apiKey: String) async throws -> [String] { + let url = baseURL.appending(path: "/key/list") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(KeyListResponse.self, from: data).keys + } + + func fetchKeyInfo(apiKey: String, key: String) async throws -> KeyBudget { + var components = URLComponents(url: baseURL.appending(path: "/key/info"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "key", value: key)] + guard let url = components?.url else { throw GatewayError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(KeyInfoResponse.self, from: data).info + } + + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let value = try decoder.singleValueContainer().decode(String.self) + if let date = ISO8601DateFormatter().date(from: value) { return date } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX" + guard let date = formatter.date(from: value) else { throw GatewayError.invalidResponse } + return date + } + return decoder + } + +} + +private struct LoginResponse: Decodable { + let token: String? +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift new file mode 100644 index 00000000000..aa976a19408 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift @@ -0,0 +1,80 @@ +import Foundation +import Security +import LocalAuthentication + +struct KeychainStore: Sendable { + private let service = "com.litellm.usage-menubar" + private let credentialsAccount = "ui-credentials" + + func readCredentials() throws -> (username: String, password: String)? { + guard let value = try read(account: credentialsAccount) else { return nil } + return try JSONDecoder().decode(StoredCredentials.self, from: Data(value.utf8)).tuple + } + + func saveCredentials(username: String, password: String) throws { + let value = try JSONEncoder().encode(StoredCredentials(username: username, password: password)) + try save(String(data: value, encoding: .utf8) ?? "", account: credentialsAccount) + } + + private func read(account: String) throws -> String? { + let context = LAContext() + context.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainError(status) + } + return String(data: data, encoding: .utf8) + } + + private func save(_ value: String, account: String) throws { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + var item = query + item[kSecValueData as String] = data + let addStatus = SecItemAdd(item as CFDictionary, nil) + if addStatus == errSecSuccess { return } + guard addStatus == errSecDuplicateItem else { + throw KeychainError(addStatus) + } + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus != errSecSuccess { + throw KeychainError(updateStatus) + } + } +} + +private struct StoredCredentials: Codable { + let username: String + let password: String + + var tuple: (username: String, password: String) { + (username, password) + } +} + +struct KeychainError: LocalizedError, Equatable { + let status: OSStatus + + init(_ status: OSStatus) { self.status = status } + + var errorDescription: String? { + if status == errSecInteractionNotAllowed || status == errSecAuthFailed || status == errSecUserCanceled { + return "μ €μž₯된 둜그인 정보에 μ ‘κ·Όν•  수 μ—†μŠ΅λ‹ˆλ‹€. μ„€μ •μ—μ„œ λ‹€μ‹œ μ €μž₯ν•˜μ„Έμš”. Keychain μŠΉμΈμ€ μ €μž₯ν•  λ•Œλ§Œ μš”μ²­ν•©λ‹ˆλ‹€" + } + return "Keychain μ²˜λ¦¬μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€ (\(status))" + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift new file mode 100644 index 00000000000..cc0d2400c43 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift @@ -0,0 +1,34 @@ +import SwiftUI + +@main +struct LiteLLMUsageMenuBarApp: App { + @StateObject private var store = UsageStore() + + var body: some Scene { + MenuBarExtra { + UsagePopover().environmentObject(store) + } label: { + MenuBarLabel().environmentObject(store) + } + .menuBarExtraStyle(.window) + + Window("둜그인 μ„€μ •", id: "login-settings") { + SettingsView().environmentObject(store) + } + .windowResizability(.contentSize) + .defaultPosition(.center) + } +} + +private struct MenuBarLabel: View { + @EnvironmentObject private var store: UsageStore + + var body: some View { + switch store.state { + case let .ready(snapshot): Text("πŸš… \(snapshot.percentage.formatted(.number.precision(.fractionLength(0))))%") + case .loading: Text("πŸš… ...") + case .needsKey: Text("πŸš… --") + case .unavailable, .failure: Text("πŸš… !") + } + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift new file mode 100644 index 00000000000..a2501916e3a --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift @@ -0,0 +1,176 @@ +import Foundation + +struct UserInfoResponse: Decodable, Sendable { + let userID: String + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + let teams: [String] + + enum CodingKeys: String, CodingKey { + case userID = "user_id" + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + case teams + } +} + +struct TeamInfoResponse: Decodable, Sendable { + let teamInfo: TeamBudget + + enum CodingKeys: String, CodingKey { + case teamInfo = "team_info" + } +} + +struct TeamBudget: Decodable, Sendable { + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + + enum CodingKeys: String, CodingKey { + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + } +} + +struct KeyListResponse: Decodable, Sendable { + let keys: [String] +} + +struct KeyInfoResponse: Decodable, Sendable { + let info: KeyBudget + + enum CodingKeys: String, CodingKey { + case info + } +} + +struct KeyBudget: Decodable, Sendable { + let keyAlias: String? + let keyName: String + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + let teamID: String? + + enum CodingKeys: String, CodingKey { + case keyAlias = "key_alias" + case keyName = "key_name" + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + case teamID = "team_id" + } +} + +struct GatewayKey: Identifiable, Equatable, Sendable { + let value: String + let keyName: String + let alias: String? + + var id: String { keyName } + var displayName: String { alias ?? keyName } +} + +struct AuthSession: Sendable { + let token: String + let apiKey: String + let expiresAt: Date + + init?(token: String) { + let segments: [Substring] = token.split(separator: ".") + guard segments.count == 3 else { return nil } + var encoded = String(segments[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let data = Data(base64Encoded: encoded), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let apiKey = payload["key"] as? String, + let exp = payload["exp"] as? TimeInterval else { return nil } + self.token = token + self.apiKey = apiKey + self.expiresAt = Date(timeIntervalSince1970: exp) + } +} + +enum UsageState: Equatable, Sendable { + case needsKey + case loading + case ready(UsageSnapshot) + case unavailable(String) + case failure(String) +} + +enum AutoRefreshInterval: Int, CaseIterable, Identifiable, Sendable { + case off = 0 + case adaptive = -1 + case oneMinute = 60 + case fiveMinutes = 300 + case fifteenMinutes = 900 + case thirtyMinutes = 1800 + case oneHour = 3600 + + var id: Self { self } + + var fixedSeconds: TimeInterval? { + switch self { + case .off, .adaptive: nil + case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: + TimeInterval(rawValue) + } + } + + var title: String { + switch self { + case .off: "끔" + case .adaptive: "μ μ‘ν˜•" + case .oneMinute: "1λΆ„" + case .fiveMinutes: "5λΆ„" + case .fifteenMinutes: "15λΆ„" + case .thirtyMinutes: "30λΆ„" + case .oneHour: "1μ‹œκ°„" + } + } +} + +struct UsageSnapshot: Equatable, Sendable { + let spend: Double + let limit: Double + let percentage: Double + let budgetDuration: String + let resetAt: Date? +} + +enum GatewayError: LocalizedError { + case invalidResponse + case unauthorized + case forbidden + case notFound + case server(Int) + case noUserBudget + case notMonthlyBudget(String) + case invalidLoginResponse + case invalidGatewayURL + + var errorDescription: String? { + switch self { + case .invalidResponse: "κ²Œμ΄νŠΈμ›¨μ΄ 응닡을 읽을 수 μ—†μŠ΅λ‹ˆλ‹€" + case .unauthorized: "둜그인 정보가 μ˜¬λ°”λ₯΄μ§€ μ•Šκ±°λ‚˜ μ„Έμ…˜μ΄ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€" + case .forbidden: "개인 μ‚¬μš©λŸ‰ μ‘°νšŒκ°€ κ±°λΆ€λ˜μ—ˆμŠ΅λ‹ˆλ‹€ (HTTP 403)" + case .notFound: "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€" + case let .server(code): "κ²Œμ΄νŠΈμ›¨μ΄ 였λ₯˜ (HTTP \(code))" + case .noUserBudget: "개인 μ˜ˆμ‚° ν•œλ„κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€" + case let .notMonthlyBudget(duration): "개인 μ˜ˆμ‚°μ΄ μ›” λ‹¨μœ„κ°€ μ•„λ‹™λ‹ˆλ‹€ (\(duration))" + case .invalidLoginResponse: "둜그인 응닡을 읽을 수 μ—†μŠ΅λ‹ˆλ‹€" + case .invalidGatewayURL: "μ˜¬λ°”λ₯Έ κ²Œμ΄νŠΈμ›¨μ΄ URL을 μž…λ ₯ν•˜μ„Έμš” (예: https://gateway.example.com)" + } + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift new file mode 100644 index 00000000000..40eb7e8e3d0 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift @@ -0,0 +1,311 @@ +import Foundation +import Combine + +@MainActor +final class UsageStore: ObservableObject { + @Published private(set) var state: UsageState = .needsKey + @Published private(set) var lastUpdated: Date? + @Published private(set) var availableKeys: [GatewayKey] = [] + @Published private(set) var selectedKeyName: String? + @Published private(set) var keyListMessage: String? + @Published private(set) var isLoadingKeys = false + @Published private(set) var autoRefreshInterval: AutoRefreshInterval + private let keychain: KeychainStore + private var client: GatewayClient + private(set) var gatewayURL: URL + private var credentials: (username: String, password: String)? + private var session: AuthSession? + private var isRefreshing = false + private var refreshTimer: Timer? + private var lastSpendSample: SpendSample? + private var smoothedSpendPerDay: Double? + private let selectedKeyDefaultsKey = "selected-key-name" + private let refreshIntervalDefaultsKey = "auto-refresh-interval" + private let gatewayURLDefaultsKey = "gateway-url" + + init(keychain: KeychainStore = KeychainStore(), client: GatewayClient? = nil) { + let configuredURL = client?.baseURL ?? Self.loadGatewayURL() + self.keychain = keychain + self.client = client ?? GatewayClient(baseURL: configuredURL) + gatewayURL = configuredURL + selectedKeyName = UserDefaults.standard.string(forKey: selectedKeyDefaultsKey) + let storedInterval = UserDefaults.standard.object(forKey: refreshIntervalDefaultsKey) as? Int + autoRefreshInterval = AutoRefreshInterval(rawValue: storedInterval ?? AutoRefreshInterval.adaptive.rawValue) ?? .adaptive + do { + credentials = try keychain.readCredentials() + state = hasCredentials ? .loading : .needsKey + } catch { + state = .failure(error.localizedDescription) + } + configureRefreshTimer() + Task { [weak self] in + await self?.loadKeys() + await self?.refresh() + } + } + + var hasCredentials: Bool { credentials != nil } + + var savedUsername: String { credentials?.username ?? "" } + var savedPassword: String { credentials?.password ?? "" } + + func saveSettings(gatewayURL input: String, username: String, password: String) throws { + guard let newGatewayURL = GatewayClient.baseURL(from: input) else { + throw GatewayError.invalidGatewayURL + } + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUsername.isEmpty, !password.isEmpty else { throw KeychainError(errSecParam) } + try keychain.saveCredentials(username: trimmedUsername, password: password) + UserDefaults.standard.set(newGatewayURL.absoluteString, forKey: gatewayURLDefaultsKey) + gatewayURL = newGatewayURL + client = GatewayClient(baseURL: newGatewayURL) + credentials = (trimmedUsername, password) + session = nil + availableKeys = [] + keyListMessage = nil + lastSpendSample = nil + smoothedSpendPerDay = nil + configureRefreshTimer() + Task { + while isRefreshing { + try? await Task.sleep(for: .milliseconds(50)) + } + await loadKeys() + await refresh() + } + } + + func setAutoRefreshInterval(_ interval: AutoRefreshInterval) { + guard autoRefreshInterval != interval else { return } + autoRefreshInterval = interval + UserDefaults.standard.set(interval.rawValue, forKey: refreshIntervalDefaultsKey) + configureRefreshTimer() + } + + func refresh() async { + guard !isRefreshing, credentials != nil else { return } + isRefreshing = true + defer { + isRefreshing = false + scheduleNextRefresh() + } + do { + let budget: TeamBudget + do { + let auth = try await authenticatedSession() + budget = try await fetchCurrentBudget(apiKey: auth.apiKey) + } catch GatewayError.unauthorized { + session = nil + let auth = try await authenticatedSession() + budget = try await fetchCurrentBudget(apiKey: auth.apiKey) + } + guard let limit = budget.maxBudget, limit > 0 else { throw GatewayError.noUserBudget } + guard let duration = budget.budgetDuration, isMonthly(duration) else { + throw GatewayError.notMonthlyBudget(budget.budgetDuration ?? "μ—†μŒ") + } + let now = Date() + updateSpendRate(spend: budget.spend, at: now) + state = .ready(UsageSnapshot( + spend: budget.spend, + limit: limit, + percentage: max(0, budget.spend / limit * 100), + budgetDuration: duration, + resetAt: budget.budgetResetAt + )) + lastUpdated = now + } catch let error as GatewayError { + state = .failure(error.localizedDescription) + } catch { + state = .failure("κ²Œμ΄νŠΈμ›¨μ΄μ— μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€") + } + } + + func loadKeys() async { + guard !isLoadingKeys, hasCredentials, availableKeys.isEmpty else { return } + isLoadingKeys = true + keyListMessage = nil + defer { isLoadingKeys = false } + do { + let auth: AuthSession + do { + auth = try await authenticatedSession() + } catch GatewayError.unauthorized { + session = nil + auth = try await authenticatedSession() + } + let keys = try await fetchKeyOptions(apiKey: auth.apiKey) + availableKeys = keys + if let selectedKeyName, !keys.contains(where: { $0.keyName == selectedKeyName }) { + self.selectedKeyName = nil + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + } + } catch let error as GatewayError { + keyListMessage = error.localizedDescription + } catch { + keyListMessage = "ν‚€ λͺ©λ‘μ„ 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€" + } + } + + func selectKey(named keyName: String?) { + guard selectedKeyName != keyName else { return } + selectedKeyName = keyName + lastSpendSample = nil + smoothedSpendPerDay = nil + if let keyName { + UserDefaults.standard.set(keyName, forKey: selectedKeyDefaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + } + Task { await refresh() } + } + + private func configureRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + scheduleNextRefresh() + } + + private func scheduleNextRefresh() { + refreshTimer?.invalidate() + guard hasCredentials, let delay = nextRefreshDelay() else { + refreshTimer = nil + return + } + refreshTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.refresh() + } + } + } + + private func nextRefreshDelay() -> TimeInterval? { + switch autoRefreshInterval { + case .off: + return nil + case .adaptive: + guard case let .ready(snapshot) = state else { return 900 } + return adaptiveRefreshDelay(for: snapshot) + case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: + return autoRefreshInterval.fixedSeconds + } + } + + private func adaptiveRefreshDelay(for snapshot: UsageSnapshot) -> TimeInterval { + if snapshot.percentage >= 100 { + guard let resetAt = snapshot.resetAt else { return 86_400 } + return min(86_400, max(60, resetAt.timeIntervalSinceNow - 60)) + } + + let remaining = max(0, snapshot.limit - snapshot.spend) + if let smoothedSpendPerDay, smoothedSpendPerDay > 0 { + let secondsUntilLimit = remaining / smoothedSpendPerDay * 86_400 + if secondsUntilLimit <= 6 * 3_600 { return 60 } + if secondsUntilLimit <= 24 * 3_600 { return 300 } + if secondsUntilLimit <= 3 * 86_400 { return 900 } + } + + if snapshot.percentage >= 95 { return 300 } + if snapshot.percentage >= 80 { return 900 } + if snapshot.percentage >= 50 { return 3_600 } + return 14_400 + } + + private func updateSpendRate(spend: Double, at date: Date) { + if let lastSpendSample { + let elapsed = date.timeIntervalSince(lastSpendSample.date) + if elapsed > 0 { + let spendDelta = spend - lastSpendSample.spend + if spendDelta < 0 { + smoothedSpendPerDay = nil + } else { + let spendPerDay = spendDelta / elapsed * 86_400 + smoothedSpendPerDay = smoothedSpendPerDay.map { $0 * 0.7 + spendPerDay * 0.3 } ?? spendPerDay + } + } + } + lastSpendSample = SpendSample(spend: spend, date: date) + } + + private struct SpendSample { + let spend: Double + let date: Date + } + + private func authenticatedSession() async throws -> AuthSession { + if let session, session.expiresAt.timeIntervalSinceNow > 300 { return session } + guard let credentials else { throw GatewayError.unauthorized } + let refreshed = try await client.login(username: credentials.username, password: credentials.password) + session = refreshed + return refreshed + } + + private func fetchBudget(apiKey: String) async throws -> TeamBudget { + let userInfo = try await client.fetchUserInfo(apiKey: apiKey) + if userInfo.maxBudget != nil { + return TeamBudget( + spend: userInfo.spend, + maxBudget: userInfo.maxBudget, + budgetDuration: userInfo.budgetDuration, + budgetResetAt: userInfo.budgetResetAt + ) + } + guard let teamID = userInfo.teams.first else { throw GatewayError.noUserBudget } + return try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) + } + + private func fetchCurrentBudget(apiKey: String) async throws -> TeamBudget { + guard let selectedKeyName else { + return try await fetchBudget(apiKey: apiKey) + } + + let keys = availableKeys.isEmpty ? try await fetchKeyOptions(apiKey: apiKey) : availableKeys + availableKeys = keys + guard let selectedKey = keys.first(where: { $0.keyName == selectedKeyName }) else { + self.selectedKeyName = nil + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + return try await fetchBudget(apiKey: apiKey) + } + + let keyInfo = try await client.fetchKeyInfo(apiKey: apiKey, key: selectedKey.value) + if let maxBudget = keyInfo.maxBudget { + return TeamBudget( + spend: keyInfo.spend, + maxBudget: maxBudget, + budgetDuration: keyInfo.budgetDuration, + budgetResetAt: keyInfo.budgetResetAt + ) + } + guard let teamID = keyInfo.teamID else { throw GatewayError.noUserBudget } + let teamBudget = try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) + return TeamBudget( + spend: keyInfo.spend, + maxBudget: teamBudget.maxBudget, + budgetDuration: teamBudget.budgetDuration, + budgetResetAt: teamBudget.budgetResetAt + ) + } + + private func fetchKeyOptions(apiKey: String) async throws -> [GatewayKey] { + let keys = try await client.fetchKeyList(apiKey: apiKey) + var options: [GatewayKey] = [] + for key in keys { + let info = try await client.fetchKeyInfo(apiKey: apiKey, key: key) + options.append(GatewayKey(value: key, keyName: info.keyName, alias: info.keyAlias)) + } + return options + } + + private func isMonthly(_ duration: String) -> Bool { + let normalized = duration.lowercased() + return ["1mo", "1month", "30d", "monthly"].contains(normalized) + } + + private static func loadGatewayURL() -> URL { + guard let savedURL = UserDefaults.standard.string(forKey: "gateway-url"), + let gatewayURL = GatewayClient.baseURL(from: savedURL) else { + return GatewayClient.defaultBaseURL + } + return gatewayURL + } + +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift new file mode 100644 index 00000000000..99c7927a0bb --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct UsagePopover: View { + @EnvironmentObject private var store: UsageStore + @Environment(\.openWindow) private var openWindow + private let teamSelection = "__team__" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + scopePicker + content + Divider() + actions + } + .padding(16) + .frame(width: 320) + .task { + await store.loadKeys() + await store.refresh() + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("LiteLLM μ‚¬μš©λŸ‰").font(.headline) + } + Spacer() + statusIcon + } + } + + private var scopePicker: some View { + VStack(alignment: .leading, spacing: 4) { + Picker("쑰회 λŒ€μƒ", selection: Binding( + get: { store.selectedKeyName ?? teamSelection }, + set: { store.selectKey(named: $0 == teamSelection ? nil : $0) } + )) { + Text("νŒ€ 전체").tag(teamSelection) + ForEach(store.availableKeys) { key in + Text(key.displayName).tag(key.keyName) + } + } + .pickerStyle(.menu) + .disabled(!store.hasCredentials || store.isLoadingKeys) + if let keyListMessage = store.keyListMessage, !store.isLoadingKeys { + Text(keyListMessage).font(.caption).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var content: some View { + switch store.state { + case .needsKey: + MessageView(title: "둜그인 정보λ₯Ό μ„€μ •ν•˜μ„Έμš”", detail: "μ„€μ •μ—μ„œ κ²Œμ΄νŠΈμ›¨μ΄ ID와 λΉ„λ°€λ²ˆν˜Έλ₯Ό μž…λ ₯ν•˜λ©΄ μ‚¬μš©λŸ‰μ„ μ‘°νšŒν•©λ‹ˆλ‹€", symbol: "person.crop.circle.badge.key") + case .loading: + HStack(spacing: 8) { ProgressView(); Text("μ‚¬μš©λŸ‰μ„ ν™•μΈν•˜λŠ” 쀑...").foregroundStyle(.secondary) } + case let .ready(snapshot): + VStack(alignment: .leading, spacing: 10) { + Text(snapshot.percentage.formatted(.number.precision(.fractionLength(0))) + "%") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .foregroundStyle(color(for: snapshot.percentage)) + ProgressView(value: min(snapshot.percentage, 100), total: 100) + .tint(color(for: snapshot.percentage)) + HStack { + Fact(label: "μ‚¬μš©μ•‘", value: currency(snapshot.spend)) + Spacer() + Fact(label: "ν•œλ„", value: currency(snapshot.limit)) + } + if let resetAt = snapshot.resetAt { + Text("λ‹€μŒ μ΄ˆκΈ°ν™” " + resetAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption).foregroundStyle(.secondary) + } + } + case let .unavailable(message), let .failure(message): + MessageView(title: "μ‚¬μš©λŸ‰μ„ ν‘œμ‹œν•  수 μ—†μŠ΅λ‹ˆλ‹€", detail: message, symbol: "exclamationmark.triangle") + } + } + + private var actions: some View { + HStack { + Button("μƒˆλ‘œκ³ μΉ¨") { Task { await store.refresh() } } + .buttonStyle(.borderless) + .disabled(store.state == .loading || !store.hasCredentials) + Spacer() + Button("μ„€μ •") { + openWindow(id: "login-settings") + NSApplication.shared.activate(ignoringOtherApps: true) + } + .buttonStyle(.borderless) + Button("μ’…λ£Œ") { NSApplication.shared.terminate(nil) } + .buttonStyle(.borderless) + } + .font(.callout) + } + + @ViewBuilder + private var statusIcon: some View { + switch store.state { + case .ready(let snapshot): Image(systemName: "circle.fill").foregroundStyle(color(for: snapshot.percentage)) + case .loading: ProgressView().controlSize(.small) + case .needsKey: Image(systemName: "key.fill").foregroundStyle(.secondary) + case .unavailable, .failure: Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + } + } + + private func color(for percentage: Double) -> Color { + percentage >= 90 ? .red : percentage >= 75 ? .orange : .green + } + + private func currency(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } +} + +struct SettingsView: View { + @EnvironmentObject private var store: UsageStore + @Environment(\.dismiss) private var dismiss + @State private var gatewayURL = "" + @State private var username = "" + @State private var password = "" + @State private var message: String? + + var body: some View { + Form { + Section("μ‚¬μš©λŸ‰") { + Picker("μžλ™ μƒˆλ‘œκ³ μΉ¨", selection: Binding( + get: { store.autoRefreshInterval }, + set: { store.setAutoRefreshInterval($0) } + )) { + ForEach(AutoRefreshInterval.allCases) { interval in + Text(interval.title).tag(interval) + } + } + Text("νŒμ˜€λ²„κ°€ λ‹«ν˜€ μžˆμ–΄λ„ 메뉴바 μ‚¬μš©λŸ‰μ„ κ°±μ‹ ν•©λ‹ˆλ‹€") + .font(.caption).foregroundStyle(.secondary) + } + Section("κ²Œμ΄νŠΈμ›¨μ΄") { + TextField("μ„œλ²„ μ£Όμ†Œ", text: $gatewayURL) + TextField("아이디", text: $username) + SecureField("λΉ„λ°€λ²ˆν˜Έ", text: $password) + Text("LiteLLM ν”„λ‘μ‹œ μ„œλ²„ μ£Όμ†Œλ₯Ό μž…λ ₯ν•˜μ„Έμš”") + .font(.caption).foregroundStyle(.secondary) + Text("둜그인 μ •λ³΄λŠ” macOS Keychain에 μ €μž₯되며 μ„Έμ…˜μ€ μ•± μ‹€ν–‰ 쀑 λ©”λͺ¨λ¦¬μ—λ§Œ μœ μ§€λ©λ‹ˆλ‹€") + .font(.caption).foregroundStyle(.secondary) + Button("μ €μž₯") { + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { + message = "아이디와 λΉ„λ°€λ²ˆν˜Έλ₯Ό λͺ¨λ‘ μž…λ ₯ν•˜μ„Έμš”" + return + } + do { + try store.saveSettings(gatewayURL: gatewayURL, username: username, password: password) + username = "" + password = "" + dismiss() + } catch { message = error.localizedDescription } + } + .disabled(gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || password.isEmpty) + if let message { Text(message).font(.caption).foregroundStyle(.secondary) } + } + } + .formStyle(.grouped) + .frame(width: 380, height: 360) + .padding() + .onAppear { + gatewayURL = store.gatewayURL.absoluteString + username = store.savedUsername + password = store.savedPassword + } + } +} + +private struct MessageView: View { + let title: String + let detail: String + let symbol: String + + var body: some View { + Label { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.subheadline.weight(.semibold)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } icon: { Image(systemName: symbol).foregroundStyle(.secondary) } + } +} + +private struct Fact: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.system(.callout, design: .monospaced)) + } + } +} diff --git a/apps/macos-usage/assets/litellm_logo.jpg b/apps/macos-usage/assets/litellm_logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6fe96e2ed3567525bfc6487534fc1fb82a9fbf06 GIT binary patch literal 9222 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/dev/null +sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null +sips -z 64 64 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null +sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null +sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null +sips -z 1024 1024 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null +iconutil -c icns "$ICONSET_DIR" -o "$APP_DIR/Contents/Resources/LiteLLMUsage.icns" +cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' + + + +CFBundleDisplayNameLiteLLM Usage +CFBundleExecutableLiteLLMUsageMenuBar +CFBundleIdentifiercom.litellm.usage-menubar +CFBundleInfoDictionaryVersion6.0 +CFBundleNameLiteLLM Usage +CFBundlePackageTypeAPPL +CFBundleShortVersionString0.1.0 +CFBundleVersion1 +CFBundleIconFileLiteLLMUsage.icns +LSMinimumSystemVersion13.0 +LSUIElement + +PLIST +SIGNING_IDENTITY="${CODESIGN_IDENTITY:-LiteLLM Usage Development}" +if ! security find-identity -v -p codesigning | grep -Fq "\"$SIGNING_IDENTITY\""; then + printf 'Missing valid codesigning identity: %s\n' "$SIGNING_IDENTITY" >&2 + exit 1 +fi +codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null +rm -f "$DMG_PATH" +hdiutil create -volname "LiteLLM Usage" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" >/dev/null +printf '%s\n%s\n' "$APP_DIR" "$DMG_PATH" diff --git a/apps/macos-usage/scripts/fill-logo-background.swift b/apps/macos-usage/scripts/fill-logo-background.swift new file mode 100644 index 00000000000..ce2dd011762 --- /dev/null +++ b/apps/macos-usage/scripts/fill-logo-background.swift @@ -0,0 +1,104 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +guard CommandLine.arguments.count == 3 else { + fputs("usage: fill-logo-background.swift input.jpg output.png\n", stderr) + exit(2) +} + +let inputURL = URL(fileURLWithPath: CommandLine.arguments[1]) +let outputURL = URL(fileURLWithPath: CommandLine.arguments[2]) +let targetColor = (red: UInt8(135), green: UInt8(206), blue: UInt8(234)) + +guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + fputs("could not read logo\n", stderr) + exit(1) +} + +let imageWidth = image.width +let imageHeight = image.height +let canvasSize = max(imageWidth, imageHeight) +let scale = 1.16 +let scaledWidth = Int((Double(imageWidth) * scale).rounded()) +let scaledHeight = Int((Double(imageHeight) * scale).rounded()) +let origin = CGPoint(x: (canvasSize - scaledWidth) / 2, y: (canvasSize - scaledHeight) / 2) +let colorSpace = CGColorSpaceCreateDeviceRGB() +var pixels = [UInt8](repeating: 0, count: canvasSize * canvasSize * 4) + +guard let context = CGContext( + data: &pixels, + width: canvasSize, + height: canvasSize, + bitsPerComponent: 8, + bytesPerRow: canvasSize * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue +) else { + fputs("could not create image context\n", stderr) + exit(1) +} + +context.setFillColor(red: CGFloat(targetColor.red) / 255, green: CGFloat(targetColor.green) / 255, blue: CGFloat(targetColor.blue) / 255, alpha: 1) +context.fill(CGRect(x: 0, y: 0, width: canvasSize, height: canvasSize)) +context.draw(image, in: CGRect(origin: origin, size: CGSize(width: scaledWidth, height: scaledHeight))) + +func isBackgroundPixel(_ offset: Int) -> Bool { + let red = pixels[offset] + let green = pixels[offset + 1] + let blue = pixels[offset + 2] + let brightest = max(red, max(green, blue)) + let darkest = min(red, min(green, blue)) + return brightest >= 225 && brightest - darkest <= 24 +} + +var background = [Bool](repeating: false, count: canvasSize * canvasSize) +var queue: [Int] = [] + +func seed(_ index: Int) { + guard !background[index], isBackgroundPixel(index * 4) else { return } + background[index] = true + queue.append(index) +} + +for x in 0.. 0 { seed(index - 1) } + if x + 1 < canvasSize { seed(index + 1) } + if y > 0 { seed(index - canvasSize) } + if y + 1 < canvasSize { seed(index + canvasSize) } +} + +for index in queue { + let offset = index * 4 + pixels[offset] = targetColor.red + pixels[offset + 1] = targetColor.green + pixels[offset + 2] = targetColor.blue + pixels[offset + 3] = 255 +} + +guard let result = context.makeImage(), + let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else { + fputs("could not write logo\n", stderr) + exit(1) +} +CGImageDestinationAddImage(destination, result, nil) +guard CGImageDestinationFinalize(destination) else { + fputs("could not finalize logo\n", stderr) + exit(1) +}