From cf2f311d59904e042e5bf151b01d7e8b91db9066 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 22 Sep 2026 14:55:36 +0900 Subject: [PATCH] Revert "feat(macos-usage): add gateway quota menu bar app" This reverts commit affdef5b1edc8f8c2c8fbfaef389e6677b30efc8. --- 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 9222 -> 0 bytes apps/macos-usage/scripts/build-app.sh | 56 ---- .../scripts/fill-logo-background.swift | 104 ------ 12 files changed, 1198 deletions(-) delete mode 100644 apps/macos-usage/.gitignore delete mode 100644 apps/macos-usage/DESIGN.md delete mode 100644 apps/macos-usage/Package.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift delete mode 100644 apps/macos-usage/assets/litellm_logo.jpg delete mode 100755 apps/macos-usage/scripts/build-app.sh delete mode 100644 apps/macos-usage/scripts/fill-logo-background.swift diff --git a/apps/macos-usage/.gitignore b/apps/macos-usage/.gitignore deleted file mode 100644 index 564c4754447..00000000000 --- a/apps/macos-usage/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.build/ -LiteLLMUsage.app/ -LiteLLMUsage.dmg diff --git a/apps/macos-usage/DESIGN.md b/apps/macos-usage/DESIGN.md deleted file mode 100644 index 62b0d981133..00000000000 --- a/apps/macos-usage/DESIGN.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 deleted file mode 100644 index feb3f12f3c0..00000000000 --- a/apps/macos-usage/Package.swift +++ /dev/null @@ -1,9 +0,0 @@ -// 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 deleted file mode 100644 index 67efc11ffce..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index aa976a19408..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index cc0d2400c43..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index a2501916e3a..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index 40eb7e8e3d0..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift +++ /dev/null @@ -1,311 +0,0 @@ -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 deleted file mode 100644 index 99c7927a0bb..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index 6fe96e2ed3567525bfc6487534fc1fb82a9fbf06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 deleted file mode 100644 index ce2dd011762..00000000000 --- a/apps/macos-usage/scripts/fill-logo-background.swift +++ /dev/null @@ -1,104 +0,0 @@ -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) -}