Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ fn default_cost_period() -> String {
/// otherwise falling back to the currency-code prefix. Used by tray surfaces
/// that render a spend amount without a rate-window percent (MonthlyPlan).
pub(crate) fn format_cost_amount(cost: &CostSnapshotBridge) -> String {
if let Some((amount, currency)) =
crate::commands::convert_preferred_amount(cost.used, &cost.currency_code)
{
let symbol = match currency.as_str() {
"USD" => Some("$"),
"EUR" => Some("€"),
"GBP" => Some("£"),
"TRY" => Some("₺"),
_ => None,
};
return symbol.map_or_else(
|| format!("{amount:.2} {currency}"),
|symbol| format!("{symbol}{amount:.2}"),
);
}
if !cost.formatted_used.is_empty() {
return cost.formatted_used.clone();
}
if let Some(ref symbol) = cost.currency_symbol {
format!("{}{:.2}", symbol, cost.used)
} else {
Expand Down Expand Up @@ -641,6 +659,7 @@ pub struct ProviderCatalogEntry {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsSnapshot {
preferred_currency_code: String,
enabled_providers: Vec<String>,
provider_order: Vec<String>,
refresh_interval_secs: u64,
Expand Down Expand Up @@ -763,6 +782,7 @@ impl From<Settings> for SettingsSnapshot {
.collect();

Self {
preferred_currency_code: settings.preferred_currency_code,
enabled_providers,
provider_order,
refresh_interval_secs: settings.refresh_interval_secs,
Expand Down
185 changes: 185 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/currency.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use std::{collections::HashMap, path::PathBuf};

use codexbar::currency::{
SUPPORTED_CURRENCY_CODES, convert_amount, fallback_rates, fetch_exchange_rates,
normalize_preferred_currency,
};
use serde::{Deserialize, Serialize};
use tauri::State;
use tokio::sync::Mutex;

const CACHE_MAX_AGE_SECS: i64 = 24 * 60 * 60;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedRates {
fetched_at_unix: i64,
rates: HashMap<String, f64>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrencyRatesSnapshot {
pub rates: HashMap<String, f64>,
}

#[derive(Default)]
pub struct CurrencyRateCache {
inner: Mutex<CacheState>,
}

#[derive(Default)]
struct CacheState {
loaded: bool,
persisted: Option<PersistedRates>,
}

#[tauri::command]
pub async fn get_currency_rates(
app: tauri::AppHandle,
cache: State<'_, CurrencyRateCache>,
preferred_currency_code: String,
) -> Result<CurrencyRatesSnapshot, String> {
let preferred = normalize_preferred_currency(&preferred_currency_code);
let mut state = cache.inner.lock().await;
if !state.loaded {
state.persisted = read_persisted_rates();
state.loaded = true;
}

if preferred != "AUTO" {
let now = unix_now();
let fresh = state.persisted.as_ref().is_some_and(|cached| {
let age = now.saturating_sub(cached.fetched_at_unix);
(0..CACHE_MAX_AGE_SECS).contains(&age)
});
if !fresh {
match fetch_exchange_rates().await {
Ok(rates) => {
let entry = PersistedRates {
fetched_at_unix: now,
rates,
};
persist_rates(&entry);
state.persisted = Some(entry);
crate::tray_bridge::refresh_tray_presentation(&app);
}
Err(error) => {
tracing::debug!(%error, "currency rates unavailable; using cached or offline rates")
}
}
}
}

Ok(CurrencyRatesSnapshot {
rates: merged_rates(state.persisted.as_ref()),
})
}

pub(crate) fn convert_preferred_amount(amount: f64, source_code: &str) -> Option<(f64, String)> {
let preferred =
normalize_preferred_currency(&codexbar::settings::Settings::load().preferred_currency_code);
if preferred == "AUTO" {
return None;
}
let cached = read_persisted_rates();
let rates = merged_rates(cached.as_ref());
convert_amount(amount, source_code, &preferred, &rates).map(|converted| (converted, preferred))
}

fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs().min(i64::MAX as u64) as i64)
.unwrap_or_default()
}

fn cache_path() -> Option<PathBuf> {
codexbar::settings::Settings::settings_path()?
.parent()
.map(|parent| parent.join("currency-rates.json"))
}

fn clean_rates(rates: HashMap<String, f64>) -> HashMap<String, f64> {
let mut clean = HashMap::new();
for code in SUPPORTED_CURRENCY_CODES {
if let Some(rate) = rates.get(*code).copied()
&& rate.is_finite()
&& rate > 0.0
{
clean.insert((*code).to_string(), rate);
}
}
if clean
.get("USD")
.is_none_or(|rate| (*rate - 1.0).abs() > f64::EPSILON)
{
return HashMap::new();
}
clean
}

fn read_persisted_rates() -> Option<PersistedRates> {
let path = cache_path()?;
let bytes = std::fs::read(path).ok()?;
let mut cached: PersistedRates = serde_json::from_slice(&bytes).ok()?;
cached.rates = clean_rates(cached.rates);
(!cached.rates.is_empty()).then_some(cached)
}

fn merged_rates(cached: Option<&PersistedRates>) -> HashMap<String, f64> {
let mut rates = fallback_rates();
if let Some(cached) = cached {
for (code, rate) in clean_rates(cached.rates.clone()) {
rates.insert(code, rate);
}
}
rates
}

fn persist_rates(cached: &PersistedRates) {
let Some(path) = cache_path() else { return };
let Some(parent) = path.parent() else { return };
let write_result = (|| -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir_all(parent)?;
let bytes = serde_json::to_vec(cached)?;
codexbar::atomic_file::write_atomic(&path, &bytes)?;
Ok(())
})();
if let Err(error) = write_result {
tracing::debug!(%error, "could not persist currency rate cache");
}
}

#[cfg(test)]
mod tests {
use super::*;
use codexbar::currency::FALLBACK_RATES;

#[test]
fn fallback_table_covers_every_preferred_currency() {
let rates = fallback_rates();
assert_eq!(rates.len(), FALLBACK_RATES.len());
for code in SUPPORTED_CURRENCY_CODES {
assert!(
rates
.get(*code)
.is_some_and(|rate| rate.is_finite() && *rate > 0.0)
);
}
}

#[test]
fn persisted_rates_are_sanitized_before_merging() {
let rates = HashMap::from([
("USD".to_string(), 1.0),
("TRY".to_string(), 48.0),
("EUR".to_string(), f64::NAN),
("BTC".to_string(), 100.0),
]);
let clean = clean_rates(rates);
assert_eq!(clean.get("TRY"), Some(&48.0));
assert!(!clean.contains_key("EUR"));
assert!(!clean.contains_key("BTC"));
}
}
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use crate::surface::SurfaceMode;
use crate::surface_target::SurfaceTarget;

mod chart;
mod currency;
mod spend_contract;
mod tokens;
mod updater;
Expand Down Expand Up @@ -79,6 +80,7 @@ pub(crate) use usage_items::*;
mod tests;

pub use chart::*;
pub use currency::*;
pub use spend_contract::*;
pub use tokens::*;
pub use updater::*;
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use super::*;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SettingsUpdate {
pub preferred_currency_code: Option<String>,
pub enabled_providers: Option<Vec<String>>,
pub refresh_interval_secs: Option<u64>,
pub adaptive_refresh: Option<bool>,
Expand Down Expand Up @@ -130,6 +131,7 @@ impl SettingsUpdate {
|| self.overview_layout.is_some()
|| self.provider_metrics.is_some()
|| self.provider_hidden_usage_item_ids.is_some()
|| self.preferred_currency_code.is_some()
|| self.codex_spark_usage_visible.is_some()
|| self.copilot_seat_credit_entitlement.is_some()
|| self.enabled_providers.is_some()
Expand Down Expand Up @@ -200,6 +202,13 @@ impl SettingsUpdate {
}

fn apply_general_settings(self, settings: &mut Settings) -> Result<Self, String> {
if let Some(value) = self.preferred_currency_code.as_deref() {
let normalized = codexbar::currency::normalize_preferred_currency(value);
if !value.trim().eq_ignore_ascii_case("AUTO") && normalized == "AUTO" {
return Err(format!("Unsupported preferred currency: {value}"));
}
settings.preferred_currency_code = normalized;
}
if let Some(v) = self.start_at_login {
settings.set_start_at_login(v).map_err(|e| e.to_string())?;
}
Expand Down Expand Up @@ -572,6 +581,25 @@ pub async fn update_settings(
mod tests {
use super::*;

#[test]
fn preferred_currency_patch_accepts_supported_codes_and_rejects_unknown_codes() {
let mut settings = Settings::default();
SettingsUpdate {
preferred_currency_code: Some("try".to_string()),
..SettingsUpdate::default()
}
.apply_to(&mut settings)
.expect("TRY is supported");
assert_eq!(settings.preferred_currency_code, "TRY");

let result = SettingsUpdate {
preferred_currency_code: Some("BTC".to_string()),
..SettingsUpdate::default()
}
.apply_to(&mut settings);
assert!(matches!(result, Err(error) if error.contains("Unsupported preferred currency")));
}

#[test]
fn only_data_affecting_settings_refresh_providers() {
assert!(
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ fn main() {

tauri::Builder::default()
.manage(Mutex::new(initial_state))
.manage(commands::CurrencyRateCache::default())
.plugin(shortcut_bridge::plugin())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
Expand All @@ -159,6 +160,7 @@ fn main() {
commands::get_bootstrap_state,
commands::get_provider_catalog,
commands::get_settings_snapshot,
commands::get_currency_rates,
commands::list_agent_sessions,
commands::focus_agent_session,
commands::update_settings,
Expand Down
6 changes: 1 addition & 5 deletions apps/desktop-tauri/src-tauri/src/tray_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,11 +564,7 @@ fn provider_status_label(
if preference == MetricPreference::MonthlyPlan
&& let Some(cost) = snapshot.cost.as_ref()
{
let amount = if !cost.formatted_used.is_empty() {
cost.formatted_used.clone()
} else {
crate::commands::format_cost_amount(cost)
};
let amount = crate::commands::format_cost_amount(cost);
return (
snapshot.provider_id.clone(),
format!("{} {}", snapshot.display_name, amount),
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop-tauri/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { LocaleProvider } from "./i18n/LocaleProvider";
import type { BootstrapState, ThemePreference } from "./types/bridge";
import type { SurfaceSnapshot } from "./hooks/useSurfaceSnapshot";
import { useDeepSeekPricingStatus } from "./hooks/useDeepSeekPricingStatus";
import { CurrencyProvider } from "./hooks/CurrencyProvider";

const Settings = lazy(() => import("./surfaces/Settings"));
const PopOutPanel = lazy(() => import("./surfaces/PopOutPanel"));
Expand Down Expand Up @@ -50,7 +51,9 @@ function initialSettingsTab(): string {
export default function App() {
return (
<LocaleProvider>
<AppInner />
<CurrencyProvider>
<AppInner />
</CurrencyProvider>
</LocaleProvider>
);
}
Expand Down
Loading