diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..dfa7cb6 --- /dev/null +++ b/CONTEXT.md @@ -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 diff --git a/README.md b/README.md index 1bc3953..ee8d18c 100644 --- a/README.md +++ b/README.md @@ -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. +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 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. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index ee118a4..2171244 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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? - private var currencyError: String? - private var currencyFailureQuery: String? + private let currency = CurrencyCalculation() private var locationLabels: [URL: String] = [:] private var discoveryTask: Task? 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 - 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 = [] diff --git a/Sources/ConjureKit/CurrencyCalculation.swift b/Sources/ConjureKit/CurrencyCalculation.swift new file mode 100644 index 0000000..ca54940 --- /dev/null +++ b/Sources/ConjureKit/CurrencyCalculation.swift @@ -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? + 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 + 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?() + } + } +} diff --git a/Tests/ConjureKitTests/CurrencyCalculationTests.swift b/Tests/ConjureKitTests/CurrencyCalculationTests.swift new file mode 100644 index 0000000..517d0d2 --- /dev/null +++ b/Tests/ConjureKitTests/CurrencyCalculationTests.swift @@ -0,0 +1,74 @@ +import ConjureKit +import Foundation +import Testing + +@MainActor +private final class RateRequests { + var pending: [CheckedContinuation] = [] + 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") +}