refactor: own daily currency calculation lifetime
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Conjure
|
||||||
|
|
||||||
|
A native macOS launcher for Applications, Commands, and calculations.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**Currency calculation**:
|
||||||
|
A conversion of an amount between fiat currencies or cryptocurrencies using daily rates, not a live market quote.
|
||||||
|
|
||||||
|
**Daily rate snapshot**:
|
||||||
|
A provider's dated collection of exchange rates. Its snapshot date identifies the rates, not when Conjure retrieved them.
|
||||||
|
_Avoid_: Live rates
|
||||||
@@ -85,6 +85,10 @@ Convert length, mass, temperature, volume, duration, speed, area, and data with
|
|||||||
|
|
||||||
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
|
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
|
||||||
|
|
||||||
|
Rates are shared across queries and refreshed on the next currency query after the local day changes. While refreshing, a cached answer remains copyable and is labeled stale. Leaving the query or dismissing the launcher does not cancel that shared refresh.
|
||||||
|
|
||||||
|
`ConjureKit.CurrencyCalculation` owns this lifetime policy; the launcher renders its result. Its checks use an injected clock and scripted rate fetches.
|
||||||
|
|
||||||
## Emoji picker
|
## Emoji picker
|
||||||
|
|
||||||
Search for **Conjure Emoji**, identified by its centered 🤔 icon, and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji.
|
Search for **Conjure Emoji**, identified by its centered 🤔 icon, and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji.
|
||||||
|
|||||||
@@ -17,11 +17,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private var results: [Application] = []
|
private var results: [Application] = []
|
||||||
private var calculation: CalculatorResult?
|
private var calculation: CalculatorResult?
|
||||||
private var isCurrencyCalculation = false
|
private var isCurrencyCalculation = false
|
||||||
private var currencyRates: CurrencyRates?
|
private let currency = CurrencyCalculation()
|
||||||
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 locationLabels: [URL: String] = [:]
|
||||||
private var discoveryTask: Task<Void, Never>?
|
private var discoveryTask: Task<Void, Never>?
|
||||||
private var isLaunching = false
|
private var isLaunching = false
|
||||||
@@ -104,6 +100,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
|
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
|
||||||
else { self.dismiss(restoringFocus: false) }
|
else { self.dismiss(restoringFocus: false) }
|
||||||
}
|
}
|
||||||
|
currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) }
|
||||||
restorePosition()
|
restorePosition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +108,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
fatalError("Conjure creates its window programmatically")
|
fatalError("Conjure creates its window programmatically")
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
currencyTask?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
func present(command: Application.Command? = nil) {
|
func present(command: Application.Command? = nil) {
|
||||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||||
if launcherFrameBeforeReview != nil {
|
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() {
|
private func updateLocationLabels() {
|
||||||
var nameCounts: [String: Int] = [:]
|
var nameCounts: [String: Int] = [:]
|
||||||
nameCounts.reserveCapacity(applications.count)
|
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 }
|
guard !isCommandOpen else { return }
|
||||||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||||||
? results[tableView.selectedRow].id : nil
|
? results[tableView.selectedRow].id : nil
|
||||||
let query = searchField.stringValue
|
let query = searchField.stringValue
|
||||||
if currencyFailureQuery != query {
|
currency.update(query)
|
||||||
currencyError = nil
|
isCurrencyCalculation = currency.isCurrency
|
||||||
currencyFailureQuery = nil
|
calculation = currency.result
|
||||||
}
|
|
||||||
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 { (window as? LauncherPanel)?.resetNumberHints() }
|
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
|
||||||
if calculation != nil {
|
if calculation != nil {
|
||||||
results = []
|
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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import ConjureKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class RateRequests {
|
||||||
|
var pending: [CheckedContinuation<CurrencyRates, any Error>] = []
|
||||||
|
func fetch() async throws -> CurrencyRates {
|
||||||
|
try await withCheckedThrowingContinuation { pending.append($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func waitForRates(_ condition: () -> Bool) async throws {
|
||||||
|
// Scheduling deadline only; calculation freshness uses the injected clock.
|
||||||
|
for _ in 0..<1_000 {
|
||||||
|
if condition() { return }
|
||||||
|
try await Task.sleep(for: .milliseconds(1))
|
||||||
|
}
|
||||||
|
try #require(condition(), "Currency calculation did not reach the expected state")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func currencyLifetimeSharesRefreshAndPreservesStaleCopyUntilFailure() async throws {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
|
var now = calendar.date(from: DateComponents(year: 2026, month: 1, day: 2, hour: 12))!
|
||||||
|
let requests = RateRequests()
|
||||||
|
let calculation = CurrencyCalculation(now: { now }, calendar: calendar, fetch: { try await requests.fetch() })
|
||||||
|
var completions = 0
|
||||||
|
calculation.changed = { completions += 1 }
|
||||||
|
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "usd": 2, "dkk": 8])
|
||||||
|
|
||||||
|
calculation.update("100 USD to DKK")
|
||||||
|
#expect(calculation.isCurrency)
|
||||||
|
#expect(calculation.result?.copyValue == nil)
|
||||||
|
try await waitForRates { requests.pending.count == 1 }
|
||||||
|
calculation.update("50 USD to DKK")
|
||||||
|
requests.pending[0].resume(returning: snapshot)
|
||||||
|
try await waitForRates { completions == 1 }
|
||||||
|
#expect(calculation.result?.copyValue == "200 DKK")
|
||||||
|
calculation.update("100 USD to DKK")
|
||||||
|
#expect(calculation.result?.copyValue == "400 DKK")
|
||||||
|
#expect(requests.pending.count == 1)
|
||||||
|
|
||||||
|
now = calendar.date(byAdding: .day, value: 1, to: now)!
|
||||||
|
calculation.update("100 USD to DKK")
|
||||||
|
#expect(calculation.result?.copyValue == "400 DKK")
|
||||||
|
#expect(calculation.result?.detail.contains("Stale cache; refreshing") == true)
|
||||||
|
try await waitForRates { requests.pending.count == 2 }
|
||||||
|
calculation.update("50 USD to DKK")
|
||||||
|
requests.pending[1].resume(throwing: URLError(.notConnectedToInternet))
|
||||||
|
try await waitForRates { completions == 2 }
|
||||||
|
#expect(calculation.result?.copyValue == nil)
|
||||||
|
#expect(calculation.result?.displayValue == "Currency rates unavailable")
|
||||||
|
calculation.update("50 USD to DKK")
|
||||||
|
#expect(calculation.result?.copyValue == nil)
|
||||||
|
#expect(requests.pending.count == 2)
|
||||||
|
|
||||||
|
calculation.update("25 USD to DKK")
|
||||||
|
try await waitForRates { requests.pending.count == 3 }
|
||||||
|
calculation.update("Safari") // Leaving currency does not cancel shared work.
|
||||||
|
#expect(!calculation.isCurrency)
|
||||||
|
#expect(calculation.result == nil)
|
||||||
|
requests.pending[2].resume(returning: snapshot)
|
||||||
|
try await waitForRates { completions == 3 }
|
||||||
|
#expect(calculation.result == nil)
|
||||||
|
calculation.update("25 USD to DKK")
|
||||||
|
#expect(calculation.result?.copyValue == "100 DKK")
|
||||||
|
#expect(requests.pending.count == 3) // Fetch day, not snapshot metadata, controls freshness.
|
||||||
|
calculation.update("1 km to mi")
|
||||||
|
#expect(!calculation.isCurrency)
|
||||||
|
#expect(calculation.result?.copyValue == "0.621371192237 mi")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user