mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-19 05:54:30 +00:00
The CLI already supports any ISO 4217 code dynamically via Intl.NumberFormat,
but the macOS menubar app (SupportedCurrency enum) and GNOME extension
(hardcoded CURRENCIES array) had fixed lists. RON was missing from both.
- Add RON case to SupportedCurrency enum + displayName in AppStore.swift
- Add RON symbol override ('lei') in CurrencyState.swift
- Add RON entry to CURRENCIES array in gnome/indicator.js
281 lines
9.8 KiB
Swift
281 lines
9.8 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",
|
|
"RON": "lei"
|
|
]
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CodeburnCLIConfigStore {
|
|
let homeDirectory: String
|
|
|
|
init(homeDirectory: String = NSHomeDirectory()) {
|
|
self.homeDirectory = homeDirectory
|
|
}
|
|
|
|
private var configDir: String {
|
|
(homeDirectory as NSString).appendingPathComponent(".config/codeburn")
|
|
}
|
|
private var configPath: String {
|
|
(configDir as NSString).appendingPathComponent("config.json")
|
|
}
|
|
private var lockPath: String {
|
|
(configDir as NSString).appendingPathComponent(".config.lock")
|
|
}
|
|
|
|
func loadDevinAcuUsdRate() -> Double? {
|
|
guard
|
|
let data = try? SafeFile.read(from: configPath),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let devin = json["devin"] as? [String: Any],
|
|
let rate = devin["acuUsdRate"] as? Double,
|
|
rate.isFinite,
|
|
rate > 0
|
|
else {
|
|
return nil
|
|
}
|
|
return rate
|
|
}
|
|
|
|
func persistDevinAcuUsdRate(_ rate: Double) {
|
|
guard rate.isFinite, rate > 0 else { return }
|
|
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
|
|
}
|
|
|
|
var devin = existing["devin"] as? [String: Any] ?? [:]
|
|
devin["acuUsdRate"] = rate
|
|
existing["devin"] = devin
|
|
|
|
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 Devin ACU config: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CLIDevinConfig {
|
|
private static let store = CodeburnCLIConfigStore()
|
|
|
|
static func loadAcuUsdRate() -> Double? {
|
|
store.loadDevinAcuUsdRate()
|
|
}
|
|
|
|
static func persistAcuUsdRate(_ rate: Double) {
|
|
store.persistDevinAcuUsdRate(rate)
|
|
}
|
|
}
|