mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
- Fix refresh loop: proper while loop with 30s sleep and force:true instead of single-fire Task that never repeated - Fix loading overlay: counter-based isLoading so concurrent fetches don't flicker the overlay on/off - Fix rapid tab switching: cancel previous switchTask, check Task.isCancelled after CLI returns to discard stale results - Fix tab strip vs hero desync: fetch provider-specific and all-provider data in parallel so costs arrive from same data snapshot - Fix stale menubar icon after wake: forceRefresh now fetches today/all in parallel alongside the current selection - Fix accent color: ThemeState is now @Observable so color changes propagate via observation, removing .id() view hierarchy teardown - Fix currency flash: defer store.currency and symbol update until a rate is available so symbol and rate apply atomically - Fix export: terminationHandler instead of waitUntilExit (no UI freeze), HHmmss in filename to prevent overwrite on double-export - Fix CurrencyState: @MainActor isolation with proper Sendable conformance, nonisolated on pure static functions - Fix streak count: iterate calendar days instead of sparse history entries so gaps are counted as streak-breakers - Fix TrendBar identity: stable date-based id instead of UUID - Add GPT-5.3 and DeepSeek model display names
209 lines
7.6 KiB
Swift
209 lines
7.6 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|