refactor: own daily currency calculation lifetime

This commit is contained in:
Valdemar Lorenzen
2026-10-16 14:59:00 +02:00
parent c8b2e8e0c5
commit a6ea75821b
5 changed files with 177 additions and 59 deletions
+6 -59
View File
@@ -17,11 +17,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
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 let currency = CurrencyCalculation()
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
@@ -104,6 +100,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
else { self.dismiss(restoringFocus: false) }
}
currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) }
restorePosition()
}
@@ -111,10 +108,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
fatalError("Conjure creates its window programmatically")
}
deinit {
currencyTask?.cancel()
}
func present(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return }
if launcherFrameBeforeReview != nil {
@@ -413,28 +406,6 @@ 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)
@@ -457,38 +428,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
private func updateResults(preservingSelection: Bool = false) {
guard !isCommandOpen else { return }
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
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)"
)
}
currency.update(query)
isCurrencyCalculation = currency.isCurrency
calculation = currency.result
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil {
results = []
@@ -0,0 +1,81 @@
import Foundation
/// Owns shared daily rates and calculation state for one launcher lifetime.
@MainActor
public final class CurrencyCalculation {
public private(set) var result: CalculatorResult?
public private(set) var isCurrency = false
public var changed: (() -> Void)?
private let fetch: @MainActor () async throws -> CurrencyRates
private let now: () -> Date
private let calendar: Calendar
private var query = ""
private var rates: CurrencyRates?
private var fetchedAt: Date?
private var task: Task<Void, Never>?
private var error: String?
private var failureQuery: String?
public init(
now: @escaping () -> Date = Date.init,
calendar: Calendar = .current,
fetch: @escaping @MainActor () async throws -> CurrencyRates = { try await CurrencyAPI.fetch() }
) {
self.now = now
self.calendar = calendar
self.fetch = fetch
}
deinit { task?.cancel() }
public func update(_ query: String) {
self.query = query
if failureQuery != query {
error = nil
failureQuery = nil
}
evaluate(allowingFetch: true)
}
private func evaluate(allowingFetch: Bool) {
isCurrency = Calculator.isCurrencyQuery(query, currencyRates: rates)
let stale = fetchedAt.map { !calendar.isDate($0, inSameDayAs: now()) } ?? true
if allowingFetch, isCurrency, stale, error == nil, task == nil { refresh() }
result = Calculator.evaluate(query, currencyRates: rates)
if isCurrency, let error {
let cached = rates.map { " Cached snapshot \($0.date) is not used." } ?? ""
result = CalculatorResult(displayValue: "Currency rates unavailable", copyValue: nil,
detail: "Daily rates: \(error). Change the query to retry.\(cached)")
} else if isCurrency, rates != nil, let answer = result {
let cacheStatus = stale
? (task == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
: "Cached daily rates"
result = CalculatorResult(displayValue: answer.displayValue, copyValue: answer.copyValue,
detail: "\(answer.detail) · \(cacheStatus)")
}
}
private func refresh() {
let fetch = self.fetch
task = Task { [weak self] in
let response: Result<CurrencyRates, any Error>
do { response = .success(try await fetch()) }
catch { response = .failure(error) }
guard !Task.isCancelled, let self else { return }
self.task = nil
switch response {
case .success(let rates):
self.rates = rates
self.fetchedAt = self.now()
self.error = nil
self.failureQuery = nil
case .failure(let error):
self.error = error.localizedDescription
self.failureQuery = self.query
}
self.evaluate(allowingFetch: false)
self.changed?()
}
}
}