diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index 8ec536263..1bca71d72 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -217,6 +217,10 @@ impl CoreProcessHandle { // the same env, matching what a child sidecar would have // received via Command::env. std::env::set_var("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str()); + // Surface the Tauri shell version to the in-process core so + // backend-bound HTTP requests can attach `x-tauri-version` + // analytics headers alongside `x-core-version`. + std::env::set_var("OPENHUMAN_TAURI_VERSION", env!("CARGO_PKG_VERSION")); *self.active_port.write() = port; *self.last_port_fallback.write() = None; diff --git a/app/src/services/__tests__/apiClient.test.ts b/app/src/services/__tests__/apiClient.test.ts index f2f0e3865..89f3f6d55 100644 --- a/app/src/services/__tests__/apiClient.test.ts +++ b/app/src/services/__tests__/apiClient.test.ts @@ -2,7 +2,10 @@ import { getVersion } from '@tauri-apps/api/app'; import { isTauri } from '@tauri-apps/api/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { callCoreRpc } from '../coreRpcClient'; + vi.mock('@tauri-apps/api/app', () => ({ getVersion: vi.fn() })); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() })); const configMock = vi.hoisted(() => ({ isDev: true })); @@ -19,6 +22,7 @@ describe('apiClient version headers', () => { vi.clearAllMocks(); configMock.isDev = true; vi.mocked(isTauri).mockReturnValue(false); + vi.mocked(callCoreRpc).mockResolvedValue({ result: { version: '0.0.0-core' } }); vi.stubGlobal('fetch', vi.fn()); }); @@ -39,9 +43,10 @@ describe('apiClient version headers', () => { expect(headers).not.toHaveProperty('x-tauri-version'); }); - it('adds sanitized x-tauri-version on Tauri backend requests', async () => { + it('adds sanitized x-tauri-version and x-core-version on Tauri backend requests', async () => { vi.mocked(isTauri).mockReturnValue(true); vi.mocked(getVersion).mockResolvedValue(' 1.2.3 (desktop)+build!? '); + vi.mocked(callCoreRpc).mockResolvedValue({ result: { version: ' 4.5.6 (core)+abc ' } }); const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ @@ -56,6 +61,7 @@ describe('apiClient version headers', () => { const requestInit = fetchMock.mock.calls[0][1] as RequestInit; const headers = requestInit.headers as Record; expect(headers['x-tauri-version']).toBe('1.2.3desktop+build'); + expect(headers['x-core-version']).toBe('4.5.6core+abc'); expect(headers).not.toHaveProperty('x-web-version'); }); @@ -112,11 +118,15 @@ describe('apiClient version headers', () => { vi.mocked(getVersion) .mockRejectedValueOnce(new Error('transient failure')) .mockResolvedValueOnce('2.3.4'); + vi.mocked(callCoreRpc).mockResolvedValue({ result: { version: '4.5.6' } }); const { getClientVersionHeaders } = await import('../clientVersionHeaders'); - await expect(getClientVersionHeaders()).resolves.toEqual({}); - await expect(getClientVersionHeaders()).resolves.toEqual({ 'x-tauri-version': '2.3.4' }); + await expect(getClientVersionHeaders()).resolves.toEqual({ 'x-core-version': '4.5.6' }); + await expect(getClientVersionHeaders()).resolves.toEqual({ + 'x-tauri-version': '2.3.4', + 'x-core-version': '4.5.6', + }); expect(getVersion).toHaveBeenCalledTimes(2); }); }); diff --git a/app/src/services/clientVersionHeaders.ts b/app/src/services/clientVersionHeaders.ts index 60c12e849..be0ead0cf 100644 --- a/app/src/services/clientVersionHeaders.ts +++ b/app/src/services/clientVersionHeaders.ts @@ -2,10 +2,12 @@ import { getVersion } from '@tauri-apps/api/app'; import { APP_VERSION } from '../utils/config'; import { isTauri } from '../utils/tauriCommands/common'; +import { callCoreRpc } from './coreRpcClient'; const CLIENT_VERSION_MAX_LENGTH = 64; let tauriVersionPromise: Promise | null = null; +let coreVersionPromise: Promise | null = null; export function sanitizeClientVersion(raw: string | null | undefined): string | null { const sanitized = String(raw ?? '') @@ -33,10 +35,37 @@ async function getTauriClientVersion(): Promise { return tauriVersionPromise; } +async function getCoreClientVersion(): Promise { + if (!isTauri()) { + return null; + } + + if (!coreVersionPromise) { + coreVersionPromise = callCoreRpc<{ result?: { version?: string } }>({ + method: 'openhuman.update_version', + params: {}, + }) + .then(response => sanitizeClientVersion(response?.result?.version)) + .catch(() => { + // Clear the cached promise so a later call can retry once core is up. + coreVersionPromise = null; + return null; + }); + } + + return coreVersionPromise; +} + export async function getClientVersionHeaders(): Promise> { if (isTauri()) { - const tauriVersion = await getTauriClientVersion(); - return tauriVersion ? { 'x-tauri-version': tauriVersion } : {}; + const [tauriVersion, coreVersion] = await Promise.all([ + getTauriClientVersion(), + getCoreClientVersion(), + ]); + const headers: Record = {}; + if (tauriVersion) headers['x-tauri-version'] = tauriVersion; + if (coreVersion) headers['x-core-version'] = coreVersion; + return headers; } const webVersion = sanitizeClientVersion(APP_VERSION); diff --git a/src/api/rest.rs b/src/api/rest.rs index 74bf02c6d..920c09450 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -77,6 +77,17 @@ fn build_backend_reqwest_client() -> Result { HeaderValue::from_str(&version).context("invalid x-core-version header value")?, ); } + // The Tauri shell sets `OPENHUMAN_TAURI_VERSION` to its own package version + // before spawning the in-process core, so backend analytics can attribute + // core-originated requests to the desktop shell build that hosts them. + if let Ok(raw) = std::env::var("OPENHUMAN_TAURI_VERSION") { + if let Some(version) = sanitize_client_version(&raw) { + default_headers.insert( + HeaderName::from_static("x-tauri-version"), + HeaderValue::from_str(&version).context("invalid x-tauri-version header value")?, + ); + } + } // Platform-appropriate TLS backend: Windows → schannel (honors the OS // cert store, required for corporate TLS-inspection proxies); macOS / diff --git a/src/api/rest_tests.rs b/src/api/rest_tests.rs index 31654375b..cc6cf1c91 100644 --- a/src/api/rest_tests.rs +++ b/src/api/rest_tests.rs @@ -219,6 +219,31 @@ async fn backend_client_sends_x_core_version_on_auth_requests() { ); } +#[tokio::test] +async fn backend_client_sends_x_tauri_version_when_env_set() { + // Serialize against any concurrent test that also touches this env var. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap(); + + std::env::set_var("OPENHUMAN_TAURI_VERSION", "9.8.7-shell+test"); + let (base_url, captured) = spawn_header_capture_server().await; + let client = BackendOAuthClient::new(&base_url).unwrap(); + let url = client.url_for("/probe").unwrap(); + let response = client.raw_client().get(url).send().await.unwrap(); + assert!(response.status().is_success()); + std::env::remove_var("OPENHUMAN_TAURI_VERSION"); + + let headers = captured.take(); + let request_headers = headers.last().unwrap(); + let tauri_version = request_headers + .get("x-tauri-version") + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert_eq!(tauri_version, "9.8.7-shell+test"); + // Core version still flows alongside the new tauri version header. + assert!(request_headers.get("x-core-version").is_some()); +} + // Regression: OPENHUMAN-TAURI-8K / Sentry issue 7473650958. // When config.api_url is a full LLM completions URL (e.g. /v1/chat/completions), // Url::join used to produce wrong paths like /v1/chat/teams/me/usage instead of