Files
Stirling-PDF/frontend/src/desktop/components/AppProviders.tsx
T
ConnorYohandGitHub 8bc37bf5ae Desktop: Fallback to local backend if self-hosted server is offline (#5880)
* Adds a fallback mechanism so the desktop app routes tool operations to
the local bundled backend when the user's self-hosted Stirling-PDF
server goes offline, and disables tools in the UI that aren't supported
locally.

* `selfHostedServerMonitor.ts` independently polls the self-hosted
server every 15s and exposes which tool endpoints are unavailable when
it goes offline
* `operationRouter.ts` intercepts operations destined for the
self-hosted server and reroutes them to the local bundled backend when
the monitor reports it offline
* `useSelfHostedToolAvailability.ts` feeds the offline tool set into
useToolManagement, disabling affected tools in the UI with a
selfHostedOffline reason and banner warning

- `SelfHostedOfflineBanner `is a dismissable (session-only) gray bar
shown at the top of the UI when in self-hosted mode and the server goes
offline. It shows:
2026-03-10 10:04:56 +00:00

221 lines
7.8 KiB
TypeScript

import { ReactNode, useEffect, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
import { SetupWizard } from '@app/components/SetupWizard';
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { authService } from '@app/services/authService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauri } from '@tauri-apps/api/core';
import { SaaSTeamProvider } from '@app/contexts/SaaSTeamContext';
import { SaasBillingProvider } from '@app/contexts/SaasBillingContext';
import { SaaSCheckoutProvider } from '@app/contexts/SaaSCheckoutContext';
import { CreditModalBootstrap } from '@app/components/shared/modals/CreditModalBootstrap';
// Common tool endpoints to preload for faster first-use
const COMMON_TOOL_ENDPOINTS = [
'/api/v1/misc/compress-pdf',
'/api/v1/general/merge-pdfs',
'/api/v1/general/split-pages',
'/api/v1/convert/pdf/img',
'/api/v1/convert/img/pdf',
'/api/v1/general/rotate-pdf',
'/api/v1/misc/add-watermark',
'/api/v1/security/add-password',
'/api/v1/security/remove-password',
'/api/v1/general/extract-pages',
];
/**
* Desktop application providers
* Wraps proprietary providers and adds desktop-specific configuration
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
* - Shows setup wizard on first launch
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Load connection mode on mount
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
useEffect(() => {
if (!isFirstLaunch && setupComplete) {
authService.isAuthenticated()
.then(setIsAuthenticated)
.catch(() => setIsAuthenticated(false))
.finally(() => setAuthChecked(true));
} else if (isFirstLaunch && !setupComplete) {
setAuthChecked(true);
setIsAuthenticated(false);
}
}, [isFirstLaunch, setupComplete]);
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
void tauriBackendService.initializeExternalBackend();
// Also start the self-hosted server monitor so the operation router and UI
// can detect when the remote server goes offline and fall back to local backend.
connectionModeService.getServerConfig().then(cfg => {
if (cfg?.url) {
selfHostedServerMonitor.start(cfg.url);
}
});
}
return () => {
selfHostedServerMonitor.stop();
};
}, [setupComplete, isFirstLaunch, connectionMode]);
// Initialize monitoring for bundled backend (already started in Rust)
// This sets up port detection and health checks
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability for the local bundled backend.
// SaaS mode: triggers when the bundled backend reports healthy.
// Self-hosted mode: triggers when the local bundled backend port is discovered
// (so useSelfHostedToolAvailability can use the cache instead of making
// individual requests per-tool when the remote server goes offline).
const shouldPreloadLocalEndpoints =
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
useEffect(() => {
if (!shouldPreloadLocalEndpoints) return;
const tryPreload = () => {
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) return;
// tauriBackendService.isOnline now always reflects the local backend.
// Wait for it to be healthy before preloading in both modes.
if (!tauriBackendService.isOnline) return;
console.debug('[AppProviders] Preloading common tool endpoints for local backend');
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
};
const unsubscribe = tauriBackendService.subscribeToStatus(() => tryPreload());
tryPreload();
return unsubscribe;
}, [shouldPreloadLocalEndpoints, connectionMode]);
useEffect(() => {
if (!authChecked) {
return;
}
if (!isTauri()) {
return;
}
const currentWindow = getCurrentWindow();
currentWindow
.show()
.then(() => currentWindow.unminimize().catch(() => {}))
.then(() => currentWindow.setFocus().catch(() => {}))
.then(() => currentWindow.requestUserAttention(1).catch(() => {}))
.catch(() => {});
}, [authChecked]);
if (!authChecked) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<div style={{ minHeight: '100vh' }} />
</ProprietaryAppProviders>
);
}
// Show setup wizard on first launch
if (isFirstLaunch && !setupComplete) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SetupWizard
onComplete={() => {
window.location.reload();
}}
/>
</ProprietaryAppProviders>
);
}
// Show setup wizard when not authenticated (desktop login flow).
if (authChecked && !isAuthenticated) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SetupWizard
onComplete={() => {
window.location.reload();
}}
/>
</ProprietaryAppProviders>
);
}
// Normal app flow
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000, // 1 second, with exponential backoff
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SaaSTeamProvider>
<SaasBillingProvider>
<SaaSCheckoutProvider>
<DesktopConfigSync />
<DesktopBannerInitializer />
<SaveShortcutListener />
<CreditModalBootstrap />
{children}
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider>
</ProprietaryAppProviders>
);
}