diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce24a0c50..d5bc0b8e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,11 @@ jobs: cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles deb env: NODE_ENV: production + # CI builds should point at staging, not production. + # Without these, APP_ENV is undefined in config.ts and + # DEFAULT_BACKEND_URL falls through to api.tinyhumans.ai. + VITE_OPENHUMAN_APP_ENV: staging + VITE_BACKEND_URL: https://staging-api.tinyhumans.ai CARGO_PROFILE_RELEASE_OPT_LEVEL: "1" CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" CARGO_PROFILE_RELEASE_LTO: "false" diff --git a/src/api/config.rs b/src/api/config.rs index 796a8554b..ebc5c9260 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -11,10 +11,12 @@ pub const VITE_APP_ENV_VAR: &str = "VITE_OPENHUMAN_APP_ENV"; /// Resolves the hosted API base URL (no path suffix). /// -/// Order: non-empty `api_url` from config → [`api_base_from_env`] (`BACKEND_URL`, then `VITE_BACKEND_URL`) -/// → [`default_api_base_url_for_env`](`app_env_from_env().as_deref()`), which selects the -/// environment-aware default host based on the current app environment instead of always using -/// [`DEFAULT_API_BASE_URL`]. +/// Order: +/// 1. Non-empty `api_url` from config (user explicitly set it) +/// 2. `BACKEND_URL` / `VITE_BACKEND_URL` runtime env vars (each checked independently) +/// 3. `BACKEND_URL` / `VITE_BACKEND_URL` baked in at compile time via `option_env!` +/// 4. Environment-aware default: `app_env_from_env()` == `staging` → +/// [`DEFAULT_STAGING_API_BASE_URL`], otherwise [`DEFAULT_API_BASE_URL`] pub fn effective_api_url(api_url: &Option) -> String { if let Some(u) = api_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { return normalize_api_base_url(u); @@ -30,22 +32,67 @@ pub fn normalize_api_base_url(url: &str) -> String { url.trim().trim_end_matches('/').to_string() } -/// Resolve API base from process environment (`BACKEND_URL` first, then `VITE_BACKEND_URL`). +/// Resolve API base URL from the environment. +/// +/// Each key is checked independently so that an empty `BACKEND_URL` does not +/// shadow a valid `VITE_BACKEND_URL`. Runtime vars are checked first, then +/// compile-time values baked in via `option_env!`. The compile-time path is +/// what makes a shipped DMG/installer resolve to the correct environment — +/// at runtime the process has no shell env vars set. pub fn api_base_from_env() -> Option { - std::env::var("BACKEND_URL") - .or_else(|_| std::env::var("VITE_BACKEND_URL")) - .ok() - .map(|s| normalize_api_base_url(&s)) - .filter(|s| !s.is_empty()) + // 1. Runtime — each key checked independently; empty values are skipped + // so VITE_BACKEND_URL is still reachable when BACKEND_URL="" is set. + for key in ["BACKEND_URL", "VITE_BACKEND_URL"] { + if let Ok(v) = std::env::var(key) { + let url = normalize_api_base_url(&v); + if !url.is_empty() { + return Some(url); + } + } + } + // 2. Compile-time fallback — baked in by build-desktop.yml. + // Each key checked independently for the same reason as above. + for v in [option_env!("BACKEND_URL"), option_env!("VITE_BACKEND_URL")] + .into_iter() + .flatten() + { + let url = normalize_api_base_url(v); + if !url.is_empty() { + return Some(url); + } + } + None } -/// Resolve the app environment from process environment. +/// Resolve the app environment, checking runtime env first then compile-time. +/// +/// Each key is checked independently so that an empty primary key does not +/// shadow a valid secondary key. The compile-time fallback (`option_env!`) +/// mirrors what the Tauri shell already does for its Sentry environment tag. pub fn app_env_from_env() -> Option { - std::env::var(APP_ENV_VAR) - .or_else(|_| std::env::var(VITE_APP_ENV_VAR)) - .ok() - .map(|s| s.trim().to_ascii_lowercase()) - .filter(|s| !s.is_empty()) + // 1. Runtime — each key checked independently + for key in [APP_ENV_VAR, VITE_APP_ENV_VAR] { + if let Ok(v) = std::env::var(key) { + let s = v.trim().to_ascii_lowercase(); + if !s.is_empty() { + return Some(s); + } + } + } + // 2. Compile-time fallback — each key checked independently + for v in [ + option_env!("OPENHUMAN_APP_ENV"), + option_env!("VITE_OPENHUMAN_APP_ENV"), + ] + .into_iter() + .flatten() + { + let s = v.trim().to_ascii_lowercase(); + if !s.is_empty() { + return Some(s); + } + } + None } pub fn is_staging_app_env(app_env: Option<&str>) -> bool { @@ -62,8 +109,14 @@ pub fn default_api_base_url_for_env(app_env: Option<&str>) -> &'static str { #[cfg(test)] mod tests { + use std::sync::{Mutex, OnceLock}; + use super::*; + // Serialise all env-mutating tests to prevent flaky failures under + // parallel test execution (std::env is process-global). + static ENV_LOCK: OnceLock> = OnceLock::new(); + #[test] fn staging_app_env_uses_staging_default_api() { assert_eq!( @@ -82,4 +135,70 @@ mod tests { assert_eq!(default_api_base_url_for_env(None), DEFAULT_API_BASE_URL); assert!(!is_staging_app_env(Some("development"))); } + + #[test] + fn app_env_from_env_reads_runtime_var() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + let key = APP_ENV_VAR; + let prev = std::env::var(key).ok(); + std::env::set_var(key, "staging"); + let result = app_env_from_env(); + match prev { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + assert_eq!(result.as_deref(), Some("staging")); + } + + #[test] + fn app_env_from_env_falls_through_empty_primary_to_secondary() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + let prev_primary = std::env::var(APP_ENV_VAR).ok(); + let prev_secondary = std::env::var(VITE_APP_ENV_VAR).ok(); + std::env::set_var(APP_ENV_VAR, ""); // empty — must not block secondary + std::env::set_var(VITE_APP_ENV_VAR, "staging"); + let result = app_env_from_env(); + match prev_primary { + Some(v) => std::env::set_var(APP_ENV_VAR, v), + None => std::env::remove_var(APP_ENV_VAR), + } + match prev_secondary { + Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v), + None => std::env::remove_var(VITE_APP_ENV_VAR), + } + assert_eq!(result.as_deref(), Some("staging")); + } + + #[test] + fn api_base_from_env_reads_runtime_var() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + let key = "BACKEND_URL"; + let prev = std::env::var(key).ok(); + std::env::set_var(key, "https://staging-api.tinyhumans.ai/"); + let result = api_base_from_env(); + match prev { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai")); + } + + #[test] + fn api_base_from_env_falls_through_empty_primary_to_secondary() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + let prev_primary = std::env::var("BACKEND_URL").ok(); + let prev_secondary = std::env::var("VITE_BACKEND_URL").ok(); + std::env::set_var("BACKEND_URL", ""); // empty — must not block secondary + std::env::set_var("VITE_BACKEND_URL", "https://staging-api.tinyhumans.ai/"); + let result = api_base_from_env(); + match prev_primary { + Some(v) => std::env::set_var("BACKEND_URL", v), + None => std::env::remove_var("BACKEND_URL"), + } + match prev_secondary { + Some(v) => std::env::set_var("VITE_BACKEND_URL", v), + None => std::env::remove_var("VITE_BACKEND_URL"), + } + assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai")); + } }