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 6fe96e2ed35..00000000000
Binary files a/apps/macos-usage/assets/litellm_logo.jpg and /dev/null differ
diff --git a/apps/macos-usage/scripts/build-app.sh b/apps/macos-usage/scripts/build-app.sh
deleted file mode 100755
index 0184ca8cbd6..00000000000
--- a/apps/macos-usage/scripts/build-app.sh
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/bin/sh
-set -eu
-
-ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
-BUILD_DIR="$ROOT/.build/release"
-APP_DIR="$ROOT/LiteLLMUsage.app"
-DMG_PATH="$ROOT/LiteLLMUsage.dmg"
-ICON_SOURCE="$ROOT/assets/litellm_logo.jpg"
-ICON_RENDER_DIR="$ROOT/.build/icon-render"
-ICONSET_DIR="$ROOT/.build/LiteLLMUsage.iconset"
-
-swift build -c release --package-path "$ROOT"
-rm -rf "$APP_DIR"
-mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
-cp "$BUILD_DIR/LiteLLMUsageMenuBar" "$APP_DIR/Contents/MacOS/LiteLLMUsageMenuBar"
-rm -rf "$ICON_RENDER_DIR" "$ICONSET_DIR"
-mkdir -p "$ICON_RENDER_DIR" "$ICONSET_DIR"
-swift "$ROOT/scripts/fill-logo-background.swift" "$ICON_SOURCE" "$ICON_RENDER_DIR/logo-mark.png"
-ICON_PNG="$ICON_RENDER_DIR/logo-mark.png"
-sips -z 16 16 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16.png" >/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)
-}