mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-01-14 20:11:17 +01:00
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:
parent
2d8b0ff08c
commit
80f2980755
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -42,6 +42,7 @@ export interface AppConfig {
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
dependenciesReady?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,6 @@ export function useBackendHealth(): BackendHealthState {
|
||||
return {
|
||||
status: 'healthy',
|
||||
message: null,
|
||||
isChecking: false,
|
||||
lastChecked: Date.now(),
|
||||
error: null,
|
||||
isHealthy: true,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 === ' ') {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user