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
This commit is contained in:
James Brunton 2025-11-25 13:15:30 +00:00 committed by GitHub
parent 2d8b0ff08c
commit 80f2980755
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 126 additions and 132 deletions

View File

@ -25,6 +25,7 @@ public class ExternalAppDepConfig {
private final String weasyprintPath;
private final String unoconvPath;
private final Map<String, List<String>> 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");
}
}

View File

@ -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<String, Object> configData = new HashMap<>();
try {
// Add dependency check status
configData.put("dependenciesReady", externalAppDepConfig.isDependenciesChecked());
// Get AppConfig bean
AppConfig appConfig = applicationContext.getBean(AppConfig.class);

View File

@ -1,16 +0,0 @@
use reqwest;
#[tauri::command]
pub async fn check_backend_health(port: u16) -> Result<bool, String> {
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
}
}

View File

@ -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;

View File

@ -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,

View File

@ -42,6 +42,7 @@ export interface AppConfig {
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
dependenciesReady?: boolean;
error?: string;
}

View File

@ -4,8 +4,6 @@ export function useBackendHealth(): BackendHealthState {
return {
status: 'healthy',
message: null,
isChecking: false,
lastChecked: Date.now(),
error: null,
isHealthy: true,
};

View File

@ -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;
}

View File

@ -13,10 +13,10 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
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<BackendHealthIndicatorProps> = ({
}
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<HTMLSpanElement>) => {
if (event.key === 'Enter' || event.key === ' ') {

View File

@ -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<boolean> {
try {
const response = await apiClient.get<AppConfig>('/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<void>;
} {
const { t } = useTranslation();
const [enabled, setEnabled] = useState<boolean | null>(() => (endpoint ? true : null));
const [enabled, setEnabled] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<void>;
} {
const { t } = useTranslation();
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(() => {
if (!endpoints || endpoints.length === 0) return {};
return endpoints.reduce((acc, endpointName) => {
acc[endpointName] = true;
return acc;
}, {} as Record<string, boolean>);
});
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
params: { endpoints: endpointsParam },
suppressErrorToast: true,
});
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
`/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<string, boolean>);
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<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
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<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
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) {

View File

@ -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<BackendHealthState>) {
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<boolean> {
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;
}

View File

@ -133,7 +133,8 @@ export class TauriBackendService {
async checkBackendHealth(): Promise<boolean> {
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<boolean>('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;
}