import Foundation import Observation private let fxCacheTTLSeconds: TimeInterval = 24 * 3600 private let frankfurterBaseURL = "https://api.frankfurter.app/latest?from=USD&to=" /// Defensive bounds on any fetched FX rate. Real-world USD→X rates sit in [0.0001, 200000] /// for every ISO 4217 pair; anything outside is either a parser bug or a MITM poisoning /// attempt. We clamp hard so UI can't render NaN, negative, or astronomical numbers. private let minValidFXRate: Double = 0.0001 private let maxValidFXRate: Double = 1_000_000 private let fxFetchTimeoutSeconds: TimeInterval = 10 @MainActor @Observable final class CurrencyState: Sendable { static let shared = CurrencyState() var code: String = "USD" var rate: Double = 1.0 var symbol: String = "$" private init() {} /// Applies a new currency context. Callers must invoke on the main actor so @Observable /// view updates run on the UI thread. Rejects non-finite or out-of-band rates so a /// poisoned Frankfurter response can't corrupt displayed costs. func apply(code: String, rate: Double?, symbol: String) { self.code = code self.symbol = symbol if let r = rate, r.isFinite, r >= minValidFXRate, r <= maxValidFXRate { self.rate = r } } nonisolated static func symbolForCode(_ code: String) -> String { // Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the // plain glyph form everyone recognises. if let override = symbolOverrides[code] { return override } let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = code formatter.locale = Locale(identifier: "en_\(code.prefix(2))") return formatter.currencySymbol ?? code } nonisolated private static let symbolOverrides: [String: String] = [ "USD": "$", "CAD": "$", "AUD": "$", "NZD": "$", "HKD": "$", "SGD": "$", "MXN": "$", "EUR": "\u{20AC}", "GBP": "\u{00A3}", "JPY": "\u{00A5}", "CNY": "\u{00A5}", "KRW": "\u{20A9}", "INR": "\u{20B9}", "BRL": "R$", "CHF": "CHF", "SEK": "kr", "DKK": "kr", "ZAR": "R" ] } actor FXRateCache { static let shared = FXRateCache() private struct Entry: Codable { let rate: Double let savedAt: TimeInterval } private var entries: [String: Entry] = [:] private var loaded = false private var cacheFilePath: String { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] return base .appendingPathComponent("codeburn-mac", isDirectory: true) .appendingPathComponent("fx-rates.json") .path } private func loadIfNeeded() { guard !loaded else { return } loaded = true do { let data = try SafeFile.read(from: cacheFilePath) let decoded = try JSONDecoder().decode([String: Entry].self, from: data) // Drop any persisted entries whose rate violates the sanity bounds -- covers an // old cache that was written before the clamp was introduced. entries = decoded.filter { _, entry in entry.rate.isFinite && entry.rate >= minValidFXRate && entry.rate <= maxValidFXRate } } catch { entries = [:] } } private func persist() { guard let data = try? JSONEncoder().encode(entries) else { return } try? SafeFile.write(data, to: cacheFilePath) } /// Returns a cached rate regardless of freshness. Nil if never fetched. func cachedRate(for code: String) -> Double? { if code == "USD" { return 1.0 } loadIfNeeded() return entries[code]?.rate } /// Returns a fresh rate, fetching from Frankfurter when cache is stale or absent. Nil on /// failure. The returned rate is always finite, positive, and within the sanity bounds. func rate(for code: String) async -> Double? { if code == "USD" { return 1.0 } loadIfNeeded() if let entry = entries[code], Date().timeIntervalSince1970 - entry.savedAt < fxCacheTTLSeconds { return entry.rate } guard let url = URL(string: "\(frankfurterBaseURL)\(code)") else { return entries[code]?.rate } let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = fxFetchTimeoutSeconds config.tlsMinimumSupportedProtocolVersion = .TLSv12 let session = URLSession(configuration: config) do { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return entries[code]?.rate } struct Response: Decodable { let rates: [String: Double] } let decoded = try JSONDecoder().decode(Response.self, from: data) guard let fresh = decoded.rates[code], fresh.isFinite, fresh >= minValidFXRate, fresh <= maxValidFXRate else { NSLog("CodeBurn: discarding out-of-band FX rate for \(code)") return entries[code]?.rate } entries[code] = Entry(rate: fresh, savedAt: Date().timeIntervalSince1970) persist() return fresh } catch { return entries[code]?.rate } } } /// Reads and writes the CLI's persisted currency config (~/.config/codeburn/config.json). /// Uses an on-disk flock so a concurrent `codeburn currency ...` invocation from a terminal /// can't race the menubar and silently drop each other's writes (TOCTOU on config.json). enum CLICurrencyConfig { private static var configDir: String { (NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn") } private static var configPath: String { (configDir as NSString).appendingPathComponent("config.json") } private static var lockPath: String { (configDir as NSString).appendingPathComponent(".config.lock") } static func loadCode() -> String? { guard let data = try? SafeFile.read(from: configPath), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let currency = json["currency"] as? [String: Any], let code = currency["code"] as? String else { return nil } return code.uppercased() } static func persist(code: String) { do { try SafeFile.withExclusiveLock(at: lockPath) { var existing: [String: Any] = [:] if let data = try? SafeFile.read(from: configPath), let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { existing = parsed } if code == "USD" { existing.removeValue(forKey: "currency") } else { existing["currency"] = [ "code": code, "symbol": CurrencyState.symbolForCode(code) ] } guard let data = try? JSONSerialization.data( withJSONObject: existing, options: [.prettyPrinted, .sortedKeys] ) else { return } try SafeFile.write(data, to: configPath, mode: 0o600) } } catch { NSLog("CodeBurn: failed to persist currency config: \(error)") } } }