From 80f298075539456285fb2a33d9e45e632f8fb45a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 25 Nov 2025 13:15:30 +0000 Subject: [PATCH] Fix backend issues in desktop app (#4995) # Description of Changes Fixes two distinct but related issues in the backend of the desktop app: - Correctly shows tools as unavaialable when the backend doesn't have the dependencies or has disabled them etc. (same as web version - this primarily didn't work on desktop because the app spawns before the backend is running) - Fixes infinite re-rendering issues caused by the app polling whether the backend is healthy or not --- .../SPDF/config/ExternalAppDepConfig.java | 7 ++ .../controller/api/misc/ConfigController.java | 8 +- frontend/src-tauri/src/commands/health.rs | 16 --- frontend/src-tauri/src/commands/mod.rs | 2 - frontend/src-tauri/src/lib.rs | 2 - .../src/core/contexts/AppConfigContext.tsx | 1 + frontend/src/core/hooks/useBackendHealth.ts | 2 - frontend/src/core/types/backendHealth.ts | 2 - .../components/BackendHealthIndicator.tsx | 10 +- .../src/desktop/hooks/useEndpointConfig.ts | 116 ++++++++++-------- .../desktop/services/backendHealthMonitor.ts | 29 ++--- .../desktop/services/tauriBackendService.ts | 63 ++++------ 12 files changed, 126 insertions(+), 132 deletions(-) delete mode 100644 frontend/src-tauri/src/commands/health.rs diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 8c3a046f4..59c8825fc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -25,6 +25,7 @@ public class ExternalAppDepConfig { private final String weasyprintPath; private final String unoconvPath; private final Map> commandToGroupMapping; + private volatile boolean dependenciesChecked = false; public ExternalAppDepConfig( EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) { @@ -111,6 +112,10 @@ public class ExternalAppDepConfig { } } + public boolean isDependenciesChecked() { + return dependenciesChecked; + } + @PostConstruct public void checkDependencies() { // Check core dependencies @@ -162,5 +167,7 @@ public class ExternalAppDepConfig { } } endpointConfiguration.logDisabledEndpointsSummary(); + dependenciesChecked = true; + log.info("Dependency checks completed"); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 750bfab48..4f31b83ef 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -35,6 +35,7 @@ public class ConfigController { private final EndpointConfiguration endpointConfiguration; private final ServerCertificateServiceInterface serverCertificateService; private final UserServiceInterface userService; + private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig; public ConfigController( ApplicationProperties applicationProperties, @@ -43,12 +44,14 @@ public class ConfigController { @org.springframework.beans.factory.annotation.Autowired(required = false) ServerCertificateServiceInterface serverCertificateService, @org.springframework.beans.factory.annotation.Autowired(required = false) - UserServiceInterface userService) { + UserServiceInterface userService, + stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) { this.applicationProperties = applicationProperties; this.applicationContext = applicationContext; this.endpointConfiguration = endpointConfiguration; this.serverCertificateService = serverCertificateService; this.userService = userService; + this.externalAppDepConfig = externalAppDepConfig; } @GetMapping("/app-config") @@ -56,6 +59,9 @@ public class ConfigController { Map configData = new HashMap<>(); try { + // Add dependency check status + configData.put("dependenciesReady", externalAppDepConfig.isDependenciesChecked()); + // Get AppConfig bean AppConfig appConfig = applicationContext.getBean(AppConfig.class); diff --git a/frontend/src-tauri/src/commands/health.rs b/frontend/src-tauri/src/commands/health.rs deleted file mode 100644 index e807df7ef..000000000 --- a/frontend/src-tauri/src/commands/health.rs +++ /dev/null @@ -1,16 +0,0 @@ -use reqwest; - -#[tauri::command] -pub async fn check_backend_health(port: u16) -> Result { - let url = format!("http://localhost:{}/api/v1/info/status", port); - - match reqwest::Client::new() - .get(&url) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(response) => Ok(response.status().is_success()), - Err(_) => Ok(false), // Return false instead of error for connection failures - } -} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index ceca51dd8..6e058a5be 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -3,7 +3,6 @@ pub mod files; pub mod connection; pub mod auth; pub mod default_app; -pub mod health; pub use backend::{cleanup_backend, get_backend_port, start_backend}; pub use files::{add_opened_file, clear_opened_files, get_opened_files}; @@ -24,4 +23,3 @@ pub use auth::{ start_oauth_login, }; pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler}; -pub use health::check_backend_health; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 34e49c7be..e4f15ddc9 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -6,7 +6,6 @@ mod state; use commands::{ add_opened_file, - check_backend_health, cleanup_backend, clear_auth_token, clear_opened_files, @@ -94,7 +93,6 @@ pub fn run() { set_as_default_pdf_handler, is_first_launch, reset_setup_completion, - check_backend_health, login, save_auth_token, get_auth_token, diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 73e50e6ed..c543e5700 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -42,6 +42,7 @@ export interface AppConfig { appVersion?: string; machineType?: string; activeSecurity?: boolean; + dependenciesReady?: boolean; error?: string; } diff --git a/frontend/src/core/hooks/useBackendHealth.ts b/frontend/src/core/hooks/useBackendHealth.ts index ab331c6cd..16ffea674 100644 --- a/frontend/src/core/hooks/useBackendHealth.ts +++ b/frontend/src/core/hooks/useBackendHealth.ts @@ -4,8 +4,6 @@ export function useBackendHealth(): BackendHealthState { return { status: 'healthy', message: null, - isChecking: false, - lastChecked: Date.now(), error: null, isHealthy: true, }; diff --git a/frontend/src/core/types/backendHealth.ts b/frontend/src/core/types/backendHealth.ts index 0bcfb710a..7720d9950 100644 --- a/frontend/src/core/types/backendHealth.ts +++ b/frontend/src/core/types/backendHealth.ts @@ -3,8 +3,6 @@ export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy'; export interface BackendHealthState { status: BackendStatus; message?: string | null; - lastChecked?: number; - isChecking: boolean; error: string | null; isHealthy: boolean; } diff --git a/frontend/src/desktop/components/BackendHealthIndicator.tsx b/frontend/src/desktop/components/BackendHealthIndicator.tsx index 5fa1e3eb4..d2abb2318 100644 --- a/frontend/src/desktop/components/BackendHealthIndicator.tsx +++ b/frontend/src/desktop/components/BackendHealthIndicator.tsx @@ -13,10 +13,10 @@ export const BackendHealthIndicator: React.FC = ({ const { t } = useTranslation(); const theme = useMantineTheme(); const colorScheme = useComputedColorScheme('light'); - const { isHealthy, isChecking, checkHealth } = useBackendHealth(); + const { status, isHealthy, checkHealth } = useBackendHealth(); const label = useMemo(() => { - if (isChecking) { + if (status === 'starting') { return t('backendHealth.checking', 'Checking backend status...'); } @@ -25,17 +25,17 @@ export const BackendHealthIndicator: React.FC = ({ } return t('backendHealth.offline', 'Backend Offline'); - }, [isChecking, isHealthy, t]); + }, [status, isHealthy, t]); const dotColor = useMemo(() => { - if (isChecking) { + if (status === 'starting') { return theme.colors.yellow?.[5] ?? '#fcc419'; } if (isHealthy) { return theme.colors.green?.[5] ?? '#37b24d'; } return theme.colors.red?.[6] ?? '#e03131'; - }, [isChecking, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]); + }, [status, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 529d3f0f5..bd50cd531 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -6,6 +6,7 @@ import { tauriBackendService } from '@app/services/tauriBackendService'; import { isBackendNotReadyError } from '@app/constants/backendErrors'; import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability'; import { connectionModeService } from '@desktop/services/connectionModeService'; +import type { AppConfig } from '@app/contexts/AppConfigContext'; interface EndpointConfig { @@ -28,6 +29,17 @@ function getErrorMessage(err: unknown): string { return 'Unknown error occurred'; } +async function checkDependenciesReady(): Promise { + try { + const response = await apiClient.get('/api/v1/config/app-config', { + suppressErrorToast: true, + }); + return response.data?.dependenciesReady ?? false; + } catch { + return false; + } +} + /** * Desktop-specific endpoint checker that hits the backend directly via axios. */ @@ -38,7 +50,7 @@ export function useEndpointEnabled(endpoint: string): { refetch: () => Promise; } { const { t } = useTranslation(); - const [enabled, setEnabled] = useState(() => (endpoint ? true : null)); + const [enabled, setEnabled] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const isMountedRef = useRef(true); @@ -68,6 +80,11 @@ export function useEndpointEnabled(endpoint: string): { return; } + const dependenciesReady = await checkDependenciesReady(); + if (!dependenciesReady) { + return; // Health monitor will trigger retry when truly ready + } + try { setError(null); @@ -76,27 +93,27 @@ export function useEndpointEnabled(endpoint: string): { suppressErrorToast: true, }); - if (!isMountedRef.current) return; setEnabled(response.data); } catch (err: unknown) { const isBackendStarting = isBackendNotReadyError(err); const message = getErrorMessage(err); - if (!isMountedRef.current) return; - setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message); - setEnabled(true); - if (!retryTimeoutRef.current) { - retryTimeoutRef.current = setTimeout(() => { - retryTimeoutRef.current = null; - fetchEndpointStatus(); - }, RETRY_DELAY_MS); + if (isBackendStarting) { + setError(t('backendHealth.starting', 'Backend starting up...')); + if (!retryTimeoutRef.current) { + retryTimeoutRef.current = setTimeout(() => { + retryTimeoutRef.current = null; + fetchEndpointStatus(); + }, RETRY_DELAY_MS); + } + } else { + setError(message); + setEnabled(false); } } finally { - if (isMountedRef.current) { - setLoading(false); - } + setLoading(false); } - }, [endpoint, clearRetryTimeout]); + }, [endpoint, clearRetryTimeout, t]); useEffect(() => { if (!endpoint) { @@ -136,15 +153,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { refetch: () => Promise; } { const { t } = useTranslation(); - const [endpointStatus, setEndpointStatus] = useState>(() => { - if (!endpoints || endpoints.length === 0) return {}; - return endpoints.reduce((acc, endpointName) => { - acc[endpointName] = true; - return acc; - }, {} as Record); - }); + const [endpointStatus, setEndpointStatus] = useState>({}); const [endpointDetails, setEndpointDetails] = useState>({}); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const isMountedRef = useRef(true); const retryTimeoutRef = useRef | null>(null); @@ -167,26 +178,31 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { clearRetryTimeout(); if (!endpoints || endpoints.length === 0) { - if (!isMountedRef.current) return; setEndpointStatus({}); setLoading(false); return; } + const dependenciesReady = await checkDependenciesReady(); + if (!dependenciesReady) { + return; // Health monitor will trigger retry when truly ready + } + try { setError(null); const endpointsParam = endpoints.join(','); - const response = await apiClient.get>('/api/v1/config/endpoints-availability', { - params: { endpoints: endpointsParam }, - suppressErrorToast: true, - }); + const response = await apiClient.get>( + `/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`, + { + suppressErrorToast: true, + } + ); - if (!isMountedRef.current) return; const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => { acc[endpointName] = { - enabled: detail?.enabled ?? true, + enabled: detail?.enabled ?? false, reason: detail?.reason ?? null, }; return acc; @@ -198,34 +214,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { }, {} as Record); setEndpointDetails(prev => ({ ...prev, ...details })); - setEndpointStatus(statusMap); + setEndpointStatus(prev => ({ ...prev, ...statusMap })); } catch (err: unknown) { const isBackendStarting = isBackendNotReadyError(err); const message = getErrorMessage(err); - if (!isMountedRef.current) return; - setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message); - const fallbackStatus = endpoints.reduce((acc, endpointName) => { - const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null }; - acc.status[endpointName] = true; - acc.details[endpointName] = fallbackDetail; - return acc; - }, { status: {} as Record, details: {} as Record }); - setEndpointStatus(fallbackStatus.status); - setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details })); - - if (!retryTimeoutRef.current) { - retryTimeoutRef.current = setTimeout(() => { - retryTimeoutRef.current = null; - fetchAllEndpointStatuses(); - }, RETRY_DELAY_MS); + if (isBackendStarting) { + setError(t('backendHealth.starting', 'Backend starting up...')); + if (!retryTimeoutRef.current) { + retryTimeoutRef.current = setTimeout(() => { + retryTimeoutRef.current = null; + fetchAllEndpointStatuses(); + }, RETRY_DELAY_MS); + } + } else { + setError(message); + const fallbackStatus = endpoints.reduce((acc, endpointName) => { + const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: 'UNKNOWN' }; + acc.status[endpointName] = false; + acc.details[endpointName] = fallbackDetail; + return acc; + }, { status: {} as Record, details: {} as Record }); + setEndpointStatus(fallbackStatus.status); + setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details })); } } finally { - if (isMountedRef.current) { - setLoading(false); - } + setLoading(false); } - }, [endpoints, clearRetryTimeout]); + }, [endpoints, clearRetryTimeout, t]); useEffect(() => { if (!endpoints || endpoints.length === 0) { diff --git a/frontend/src/desktop/services/backendHealthMonitor.ts b/frontend/src/desktop/services/backendHealthMonitor.ts index 4773227a1..6504ceb2e 100644 --- a/frontend/src/desktop/services/backendHealthMonitor.ts +++ b/frontend/src/desktop/services/backendHealthMonitor.ts @@ -10,7 +10,6 @@ class BackendHealthMonitor { private readonly intervalMs: number; private state: BackendHealthState = { status: tauriBackendService.getBackendStatus(), - isChecking: false, error: null, isHealthy: tauriBackendService.getBackendStatus() === 'healthy', }; @@ -26,20 +25,30 @@ class BackendHealthMonitor { message: status === 'healthy' ? i18n.t('backendHealth.online', 'Backend Online') : this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'), - isChecking: status === 'healthy' ? false : this.state.isChecking, }); }); } private updateState(partial: Partial) { const nextStatus = partial.status ?? this.state.status; - this.state = { + const nextState = { ...this.state, ...partial, status: nextStatus, isHealthy: nextStatus === 'healthy', }; - this.listeners.forEach((listener) => listener(this.state)); + + // Only notify listeners if meaningful state changed + const meaningfulChange = + this.state.status !== nextState.status || + this.state.error !== nextState.error || + this.state.message !== nextState.message; + + this.state = nextState; + + if (meaningfulChange) { + this.listeners.forEach((listener) => listener(this.state)); + } } private ensurePolling() { @@ -60,29 +69,19 @@ class BackendHealthMonitor { } private async pollOnce(): Promise { - this.updateState({ - isChecking: true, - lastChecked: Date.now(), - error: this.state.error ?? 'Backend offline', - }); - try { const healthy = await tauriBackendService.checkBackendHealth(); if (healthy) { this.updateState({ status: 'healthy', - isChecking: false, message: i18n.t('backendHealth.online', 'Backend Online'), error: null, - lastChecked: Date.now(), }); } else { this.updateState({ status: 'unhealthy', - isChecking: false, message: i18n.t('backendHealth.offline', 'Backend Offline'), error: i18n.t('backendHealth.offline', 'Backend Offline'), - lastChecked: Date.now(), }); } return healthy; @@ -90,10 +89,8 @@ class BackendHealthMonitor { console.error('[BackendHealthMonitor] Health check failed:', error); this.updateState({ status: 'unhealthy', - isChecking: false, message: 'Backend is unavailable', error: 'Backend offline', - lastChecked: Date.now(), }); return false; } diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index 9840d93da..f78c72b8f 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -133,7 +133,8 @@ export class TauriBackendService { async checkBackendHealth(): Promise { const mode = await connectionModeService.getCurrentMode(); - // For self-hosted mode, check the configured remote server + // Determine base URL based on mode + let baseUrl: string; if (mode === 'selfhosted') { const serverConfig = await connectionModeService.getServerConfig(); if (!serverConfig) { @@ -141,47 +142,37 @@ export class TauriBackendService { this.setStatus('unhealthy'); return false; } + baseUrl = serverConfig.url.replace(/\/$/, ''); + } else { + // SaaS mode - check bundled local backend + if (!this.backendStarted) { + this.setStatus('stopped'); + return false; + } + if (!this.backendPort) { + return false; + } + baseUrl = `http://localhost:${this.backendPort}`; + } - try { - const baseUrl = serverConfig.url.replace(/\/$/, ''); - const healthUrl = `${baseUrl}/api/v1/info/status`; - const response = await fetch(healthUrl, { - method: 'GET', - connectTimeout: 5000, - }); + // Check if backend is ready (dependencies checked) + try { + const configUrl = `${baseUrl}/api/v1/config/app-config`; + const response = await fetch(configUrl, { + method: 'GET', + connectTimeout: 5000, + }); - const isHealthy = response.ok; - this.setStatus(isHealthy ? 'healthy' : 'unhealthy'); - return isHealthy; - } catch (error) { - const errorStr = String(error); - if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) { - console.error('[TauriBackendService] Self-hosted server health check failed:', error); - } + if (!response.ok) { this.setStatus('unhealthy'); return false; } - } - // For SaaS mode, check the bundled local backend via Rust - if (!this.backendStarted) { - this.setStatus('stopped'); - return false; - } - - if (!this.backendPort) { - return false; - } - - try { - const isHealthy = await invoke('check_backend_health', { port: this.backendPort }); - this.setStatus(isHealthy ? 'healthy' : 'unhealthy'); - return isHealthy; - } catch (error) { - const errorStr = String(error); - if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) { - console.error('[TauriBackendService] Bundled backend health check failed:', error); - } + const data = await response.json(); + const dependenciesReady = data.dependenciesReady === true; + this.setStatus(dependenciesReady ? 'healthy' : 'starting'); + return dependenciesReady; + } catch { this.setStatus('unhealthy'); return false; }