feat: add daily fiat and crypto currency conversion
This commit is contained in:
@@ -13,6 +13,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var applications: [Application] = []
|
||||
private var results: [Application] = []
|
||||
private var calculation: CalculatorResult?
|
||||
private var isCurrencyCalculation = false
|
||||
private var currencyRates: CurrencyRates?
|
||||
private var currencyFetchedAt: Date?
|
||||
private var currencyTask: Task<Void, Never>?
|
||||
private var currencyError: String?
|
||||
private var currencyFailureQuery: String?
|
||||
private var locationLabels: [URL: String] = [:]
|
||||
private var discoveryTask: Task<Void, Never>?
|
||||
private var isLaunching = false
|
||||
@@ -73,6 +79,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
fatalError("Conjure creates its window programmatically")
|
||||
}
|
||||
|
||||
deinit {
|
||||
currencyTask?.cancel()
|
||||
}
|
||||
|
||||
func present() {
|
||||
if launcherFrameBeforeReview != nil {
|
||||
showWindow(nil)
|
||||
@@ -181,7 +191,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let searchFont = NSFont.systemFont(ofSize: 20)
|
||||
searchField.font = searchFont
|
||||
searchField.placeholderAttributedString = NSAttributedString(
|
||||
string: "Search apps, calculate, or convert units…",
|
||||
string: "Search apps, calculate, or convert…",
|
||||
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
|
||||
)
|
||||
searchField.textColor = SunsetPalette.text
|
||||
@@ -195,7 +205,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
searchField.sendsSearchStringImmediately = true
|
||||
searchField.sendsWholeSearchString = false
|
||||
searchField.delegate = self
|
||||
searchField.setAccessibilityLabel("Search applications, calculate, or convert units")
|
||||
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
|
||||
|
||||
let symbol = NSImageView()
|
||||
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
||||
@@ -348,6 +358,28 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCurrencyRates() {
|
||||
guard currencyTask == nil else { return }
|
||||
currencyTask = Task { [weak self] in
|
||||
let result: Result<CurrencyRates, any Error>
|
||||
do { result = .success(try await CurrencyAPI.fetch()) }
|
||||
catch { result = .failure(error) }
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.currencyTask = nil
|
||||
switch result {
|
||||
case .success(let rates):
|
||||
self.currencyRates = rates
|
||||
self.currencyFetchedAt = Date()
|
||||
self.currencyError = nil
|
||||
self.currencyFailureQuery = nil
|
||||
case .failure(let error):
|
||||
self.currencyError = error.localizedDescription
|
||||
self.currencyFailureQuery = self.searchField.stringValue
|
||||
}
|
||||
self.updateResults(preservingSelection: true, allowingCurrencyFetch: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLocationLabels() {
|
||||
var nameCounts: [String: Int] = [:]
|
||||
nameCounts.reserveCapacity(applications.count)
|
||||
@@ -370,10 +402,37 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
}
|
||||
|
||||
private func updateResults(preservingSelection: Bool = false) {
|
||||
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
|
||||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||||
? results[tableView.selectedRow].id : nil
|
||||
calculation = Calculator.evaluate(searchField.stringValue)
|
||||
let query = searchField.stringValue
|
||||
if currencyFailureQuery != query {
|
||||
currencyError = nil
|
||||
currencyFailureQuery = nil
|
||||
}
|
||||
isCurrencyCalculation = Calculator.isCurrencyQuery(query, currencyRates: currencyRates)
|
||||
let needsCurrencyRefresh = isCurrencyCalculation && (currencyFetchedAt.map {
|
||||
!Calendar.current.isDate($0, inSameDayAs: Date())
|
||||
} ?? true)
|
||||
if allowingCurrencyFetch, needsCurrencyRefresh, currencyError == nil {
|
||||
refreshCurrencyRates()
|
||||
}
|
||||
calculation = Calculator.evaluate(query, currencyRates: currencyRates)
|
||||
if isCurrencyCalculation, let currencyError {
|
||||
let cached = currencyRates.map { " Cached snapshot \($0.date) is not used." } ?? ""
|
||||
calculation = CalculatorResult(
|
||||
displayValue: "Currency rates unavailable", copyValue: nil,
|
||||
detail: "Daily rates: \(currencyError). Change the query to retry.\(cached)"
|
||||
)
|
||||
} else if isCurrencyCalculation, currencyRates != nil, let answer = calculation {
|
||||
let cacheStatus = needsCurrencyRefresh
|
||||
? (currencyTask == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
|
||||
: "Cached daily rates"
|
||||
calculation = CalculatorResult(
|
||||
displayValue: answer.displayValue, copyValue: answer.copyValue,
|
||||
detail: "\(answer.detail) · \(cacheStatus)"
|
||||
)
|
||||
}
|
||||
if calculation != nil {
|
||||
results = []
|
||||
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
@@ -381,8 +440,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
} else {
|
||||
results = history.results(for: searchField.stringValue, in: applications)
|
||||
}
|
||||
sectionLabel.stringValue = calculation == nil ? "Applications" : "Calculator"
|
||||
tableView.setAccessibilityLabel(calculation == nil ? "Applications" : "Calculator result")
|
||||
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
|
||||
: calculation == nil ? "Applications" : "Calculator"
|
||||
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
|
||||
: calculation == nil ? "Applications" : "Calculator result")
|
||||
tableView.reloadData()
|
||||
if resultCount > 0 {
|
||||
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
|
||||
@@ -457,7 +518,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
if let calculation {
|
||||
cell.textField?.stringValue = calculation.displayValue
|
||||
cell.locationLabel.isHidden = true
|
||||
cell.kindLabel.stringValue = calculation.copyValue == nil ? "Error" : "Calculator"
|
||||
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
|
||||
: calculation.copyValue == nil ? "Error" : "Calculator"
|
||||
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)?
|
||||
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
|
||||
cell.imageView?.contentTintColor = SunsetPalette.accent
|
||||
|
||||
@@ -4,6 +4,12 @@ public struct CalculatorResult: Sendable {
|
||||
public let displayValue: String
|
||||
public let copyValue: String?
|
||||
public let detail: String
|
||||
|
||||
public init(displayValue: String, copyValue: String?, detail: String) {
|
||||
self.displayValue = displayValue
|
||||
self.copyValue = copyValue
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
public enum Calculator {
|
||||
@@ -15,9 +21,34 @@ public enum Calculator {
|
||||
private static let maximumDepth = 16
|
||||
|
||||
public static func evaluate(_ query: String) -> CalculatorResult? {
|
||||
evaluate(query, currencyRates: nil)
|
||||
}
|
||||
|
||||
public static func isCurrencyQuery(_ query: String, currencyRates: CurrencyRates? = nil) -> Bool {
|
||||
currencyQuery(query, rates: currencyRates) != nil
|
||||
}
|
||||
|
||||
public static func evaluate(_ query: String, currencyRates: CurrencyRates?) -> CalculatorResult? {
|
||||
guard query.utf8.count <= 512 else { return nil }
|
||||
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return nil }
|
||||
if let currency = currencyQuery(query, rates: currencyRates) {
|
||||
let detail = "\(currency.expression) \(currency.source.uppercased()) → \(currency.target.uppercased())"
|
||||
if let error = currency.amount.error { return failure(error, detail: detail) }
|
||||
guard let currencyRates else { return failure("Loading currency rates…", detail: detail) }
|
||||
let datedDetail = "\(detail) · Daily snapshot \(currencyRates.date)"
|
||||
guard let source = currencyRates.rates[currency.source],
|
||||
let target = currencyRates.rates[currency.target] else {
|
||||
return failure("Unsupported currency", detail: datedDetail)
|
||||
}
|
||||
guard source.isFinite, source > 0, target.isFinite, target > 0 else {
|
||||
return failure("Invalid currency rates", detail: datedDetail)
|
||||
}
|
||||
let value = currency.amount.value * target / source
|
||||
guard value.isFinite else { return failure("Result is not finite", detail: datedDetail) }
|
||||
let text = "\(format(value)) \(currency.target.uppercased())"
|
||||
return CalculatorResult(displayValue: text, copyValue: text, detail: datedDetail)
|
||||
}
|
||||
let words = query.split(whereSeparator: \.isWhitespace).map(String.init)
|
||||
if words.count >= 3 {
|
||||
for separator in 1..<(words.count - 1)
|
||||
@@ -57,6 +88,52 @@ public enum Calculator {
|
||||
return CalculatorResult(displayValue: text, copyValue: text, detail: query)
|
||||
}
|
||||
|
||||
private struct CurrencyQuery {
|
||||
let expression: String
|
||||
let source: String
|
||||
let target: String
|
||||
let amount: Parser
|
||||
}
|
||||
|
||||
private static let cryptoCodes: Set<String> = [
|
||||
"1inch", "aave", "atom", "avax", "bake", "btcb", "busd", "cake", "celo", "comp",
|
||||
"dash", "doge", "dydx", "egld", "eurc", "flow", "frax", "gala", "gusd", "hbar",
|
||||
"kava", "klay", "link", "luna", "lunc", "mana", "mina", "near", "nexo", "ordi",
|
||||
"paxg", "pepe", "qtum", "rune", "sand", "shib", "theta", "tusd", "usdc", "usdt",
|
||||
"wbtc", "weth",
|
||||
]
|
||||
|
||||
private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? {
|
||||
let code = text.lowercased()
|
||||
guard (2...12).contains(code.utf8.count),
|
||||
code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }),
|
||||
ConversionUnit.parse(text) == nil else { return nil }
|
||||
let conventional = code.utf8.count == 3 && code.utf8.allSatisfy { (97...122).contains($0) }
|
||||
return conventional || cryptoCodes.contains(code) || rates?.rates[code] != nil ? code : nil
|
||||
}
|
||||
|
||||
private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? {
|
||||
guard query.utf8.count <= 512 else { return nil }
|
||||
let words = query.split(whereSeparator: \.isWhitespace)
|
||||
guard words.count >= 3,
|
||||
["to", "in", "as"].contains(words[words.count - 2].lowercased()),
|
||||
let target = currencyCode(String(words[words.count - 1]), rates: rates) else { return nil }
|
||||
let sourceText = words.dropLast(2).joined(separator: " ")
|
||||
let characters = Array(sourceText)
|
||||
guard characters.count > 1 else { return nil }
|
||||
for length in stride(from: min(12, characters.count - 1), through: 2, by: -1) {
|
||||
let suffix = String(characters.suffix(length))
|
||||
guard let source = currencyCode(suffix, rates: rates) else { continue }
|
||||
let boundary = characters[characters.count - length - 1]
|
||||
// Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH.
|
||||
guard boundary.isWhitespace || (suffix.allSatisfy(\.isLetter) && !boundary.isLetter) else { continue }
|
||||
let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces)
|
||||
guard let amount = parse(expression) else { continue }
|
||||
return CurrencyQuery(expression: expression, source: source, target: target, amount: amount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func failure(_ error: String, detail: String) -> CalculatorResult {
|
||||
CalculatorResult(displayValue: error, copyValue: nil, detail: detail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
public struct CurrencyRates: Sendable {
|
||||
/// The provider's daily snapshot date, not a market quote timestamp.
|
||||
public let date: String
|
||||
/// Units of each lowercase currency code per EUR.
|
||||
public let rates: [String: Double]
|
||||
|
||||
public init(date: String, rates: [String: Double]) {
|
||||
self.date = date
|
||||
self.rates = rates
|
||||
}
|
||||
}
|
||||
|
||||
public enum CurrencyAPI {
|
||||
private struct Snapshot: Decodable {
|
||||
let date: String
|
||||
let eur: [String: Double]
|
||||
}
|
||||
|
||||
private enum FetchError: LocalizedError {
|
||||
case invalidResponse
|
||||
case http(Int)
|
||||
case invalidSnapshot
|
||||
case unavailable(String, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: "Currency provider returned a non-HTTP response."
|
||||
case .http(let status): "Currency provider returned HTTP \(status)."
|
||||
case .invalidSnapshot: "Currency provider returned an invalid daily snapshot."
|
||||
case .unavailable(let primary, let fallback):
|
||||
"Currency rates unavailable. CDN: \(primary) Fallback: \(fallback)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func fetch() async throws -> CurrencyRates {
|
||||
try await fetch { request in
|
||||
try await URLSession.shared.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider documents this CDN → Cloudflare fallback:
|
||||
// https://github.com/fawazahmed0/exchange-api#additional-fallback-url-on-cloudflare
|
||||
// Inject only the network boundary so tests never make real requests.
|
||||
static func fetch(
|
||||
load: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
) async throws -> CurrencyRates {
|
||||
let endpoints = [
|
||||
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json",
|
||||
"https://latest.currency-api.pages.dev/v1/currencies/eur.json",
|
||||
]
|
||||
var primaryFailure = ""
|
||||
for (index, endpoint) in endpoints.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let request = URLRequest(url: URL(string: endpoint)!, cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 12)
|
||||
do {
|
||||
let (data, response) = try await load(request)
|
||||
try Task.checkCancellation()
|
||||
guard let response = response as? HTTPURLResponse else { throw FetchError.invalidResponse }
|
||||
guard (200...299).contains(response.statusCode) else { throw FetchError.http(response.statusCode) }
|
||||
let snapshot: Snapshot
|
||||
do { snapshot = try JSONDecoder().decode(Snapshot.self, from: data) }
|
||||
catch { throw FetchError.invalidSnapshot }
|
||||
guard validDate(snapshot.date), snapshot.eur["eur"] == 1,
|
||||
snapshot.eur.allSatisfy({ code, rate in
|
||||
!code.isEmpty && code.utf8.allSatisfy {
|
||||
(97...122).contains($0) || (48...57).contains($0)
|
||||
} && rate.isFinite && rate > 0
|
||||
}) else { throw FetchError.invalidSnapshot }
|
||||
return CurrencyRates(date: snapshot.date, rates: snapshot.eur)
|
||||
} catch {
|
||||
try Task.checkCancellation()
|
||||
if error is CancellationError || (error as? URLError)?.code == .cancelled { throw error }
|
||||
if index == 0 { primaryFailure = error.localizedDescription }
|
||||
else { throw FetchError.unavailable(primaryFailure, error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
throw FetchError.invalidResponse
|
||||
}
|
||||
|
||||
private static func validDate(_ text: String) -> Bool {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45,
|
||||
bytes.enumerated().allSatisfy({ index, byte in
|
||||
index == 4 || index == 7 || (48...57).contains(byte)
|
||||
}), let year = Int(text.prefix(4)), year > 0,
|
||||
let month = Int(text.dropFirst(5).prefix(2)),
|
||||
let day = Int(text.suffix(2)) else { return false }
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let components = DateComponents(year: year, month: month, day: day)
|
||||
guard let date = calendar.date(from: components) else { return false }
|
||||
let actual = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return actual.year == year && actual.month == month && actual.day == day
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user