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:
This commit is contained in:
ConnorYoh
2026-03-10 10:04:56 +00:00
committed by GitHub
parent 6d9fc59bc5
commit 8bc37bf5ae
31 changed files with 1003 additions and 343 deletions
@@ -9,6 +9,7 @@ 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';
@@ -64,7 +65,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
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)
@@ -72,38 +83,31 @@ export function AppProviders({ children }: { children: ReactNode }) {
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability after backend is healthy
// 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 (!shouldMonitorBackend) {
return; // Only preload in SaaS mode with bundled backend
}
if (!shouldPreloadLocalEndpoints) return;
const preloadEndpoints = async () => {
const backendHealthy = tauriBackendService.isBackendHealthy();
if (backendHealthy) {
console.debug('[AppProviders] Preloading common tool endpoints');
await endpointAvailabilityService.preloadEndpoints(
COMMON_TOOL_ENDPOINTS,
tauriBackendService.getBackendUrl()
);
console.debug('[AppProviders] Endpoint preloading complete');
}
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);
};
// Subscribe to backend status changes
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
preloadEndpoints();
}
});
// Also check immediately in case backend is already healthy
if (tauriBackendService.isBackendHealthy()) {
preloadEndpoints();
}
const unsubscribe = tauriBackendService.subscribeToStatus(() => tryPreload());
tryPreload();
return unsubscribe;
}, [shouldMonitorBackend]);
}, [shouldPreloadLocalEndpoints, connectionMode]);
useEffect(() => {
if (!authChecked) {
@@ -13,29 +13,29 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
const { t } = useTranslation();
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme('light');
const { status, isHealthy, checkHealth } = useBackendHealth();
const { status, isOnline, checkHealth } = useBackendHealth();
const label = useMemo(() => {
if (status === 'starting') {
return t('backendHealth.checking', 'Checking backend status...');
}
if (isHealthy) {
if (isOnline) {
return t('backendHealth.online', 'Backend Online');
}
return t('backendHealth.offline', 'Backend Offline');
}, [status, isHealthy, t]);
}, [status, isOnline, t]);
const dotColor = useMemo(() => {
if (status === 'starting') {
return theme.colors.yellow?.[5] ?? '#fcc419';
}
if (isHealthy) {
if (isOnline) {
return theme.colors.green?.[5] ?? '#37b24d';
}
return theme.colors.red?.[6] ?? '#e03131';
}, [status, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]);
}, [status, isOnline, theme.colors.green, theme.colors.red, theme.colors.yellow]);
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -3,6 +3,7 @@ import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
import { TeamInvitationBanner } from '@app/components/shared/TeamInvitationBanner';
import { SelfHostedOfflineBanner } from '@app/components/shared/SelfHostedOfflineBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
@@ -10,6 +11,7 @@ export function DesktopBannerInitializer() {
useEffect(() => {
setBanner(
<>
<SelfHostedOfflineBanner />
<TeamInvitationBanner />
<UpgradeBanner />
<DefaultAppBanner />
@@ -0,0 +1,236 @@
import { useState, useEffect, useMemo } from 'react';
import { Paper, Group, Text, ActionIcon, UnstyledButton, Popover, List, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useConversionCloudStatus } from '@app/hooks/useConversionCloudStatus';
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
import { ENDPOINTS as SPLIT_ENDPOINTS } from '@app/constants/splitConstants';
import type { ToolId } from '@app/types/toolId';
const BANNER_BG = 'var(--mantine-color-gray-1)';
const BANNER_BORDER = 'var(--mantine-color-gray-3)';
const BANNER_TEXT = 'var(--mantine-color-gray-7)';
const BANNER_ICON = 'var(--mantine-color-gray-5)';
const BANNER_LINK = 'var(--mantine-color-gray-6)';
/** Maps split endpoint → [i18n key, English fallback] for the method name */
const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
'split-pages': ['split.methods.byPages.name', 'Pages'],
'split-pdf-by-sections': ['split.methods.bySections.name', 'Sections'],
'split-by-size-or-count': ['split.methods.bySize.name', 'File Size'],
'split-pdf-by-chapters': ['split.methods.byChapters.name', 'Chapters'],
'auto-split-pdf': ['split.methods.byPageDivider.name', 'Page Divider'],
'split-for-poster-print': ['split.methods.byPoster.name', 'Printable Chunks'],
};
/** Conversion endpoint keys that have translations under selfHosted.offline.conversionTypes */
const KNOWN_CONVERSION_ENDPOINTS = new Set([
'file-to-pdf', 'pdf-to-img', 'img-to-pdf', 'svg-to-pdf', 'cbz-to-pdf',
'pdf-to-cbz', 'pdf-to-word', 'pdf-to-presentation', 'pdf-to-text', 'pdf-to-csv',
'pdf-to-xlsx', 'pdf-to-markdown', 'pdf-to-html', 'pdf-to-xml', 'pdf-to-pdfa',
'html-to-pdf', 'markdown-to-pdf', 'eml-to-pdf', 'cbr-to-pdf', 'pdf-to-cbr',
'ebook-to-pdf', 'pdf-to-epub',
]);
/**
* Desktop-only banner shown when the user is in self-hosted mode and the
* configured Stirling-PDF server is unreachable.
*
* - Warns the user their server is offline
* - Explains whether local fallback is active
* - Shows an expandable list of tools that are unavailable locally
* - Session-dismissable (reappears on next launch if server still offline)
*/
export function SelfHostedOfflineBanner() {
const { t } = useTranslation();
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [serverState, setServerState] = useState<SelfHostedServerState>(
() => selfHostedServerMonitor.getSnapshot()
);
const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [localBackendReady, setLocalBackendReady] = useState(
() => !!tauriBackendService.getBackendUrl()
);
// Load connection mode once on mount
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
// Subscribe to self-hosted server status changes
useEffect(() => {
const unsub = selfHostedServerMonitor.subscribe(state => {
setServerState(state);
// Auto-collapse tool list when server comes back online
if (state.isOnline) setExpanded(false);
});
return unsub;
}, []);
// React to local backend port being discovered
useEffect(() => {
return tauriBackendService.subscribeToStatus(() => {
setLocalBackendReady(!!tauriBackendService.getBackendUrl());
});
}, []);
// Re-use the toolAvailability already computed by useToolManagement —
// tools with reason 'selfHostedOffline' are the ones unavailable locally.
const { toolAvailability, toolRegistry } = useToolWorkflow();
// Re-use conversion availability already computed by useConversionCloudStatus.
const { availability: conversionAvailability } = useConversionCloudStatus();
const [splitAvailability, setSplitAvailability] = useState<Record<string, boolean>>({});
useEffect(() => {
if (serverState.status !== 'offline') {
setSplitAvailability({});
return;
}
const localUrl = tauriBackendService.getBackendUrl();
if (!localUrl) {
setSplitAvailability({});
return;
}
const uniqueEndpoints = [...new Set(Object.values(SPLIT_ENDPOINTS))] as string[];
void Promise.all(
uniqueEndpoints.map(async ep => ({
ep,
supported: await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl).catch(() => false),
}))
).then(results => {
const map: Record<string, boolean> = {};
for (const { ep, supported } of results) map[ep] = supported;
setSplitAvailability(map);
});
}, [serverState.status]);
const allUnavailableNames = useMemo(() => {
// Top-level tools unavailable in self-hosted offline mode
const toolNames = (Object.keys(toolAvailability) as ToolId[])
.filter(id => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === 'selfHostedOffline')
.map(id => toolRegistry[id]?.name ?? id)
.filter(Boolean) as string[];
// Use translated tool names from the registry as prefixes
const convertPrefix = toolRegistry['convert' as ToolId]?.name ?? 'Convert';
const splitPrefix = toolRegistry['split' as ToolId]?.name ?? 'Split';
// Conversion types unavailable locally — deduplicated by endpoint
const unavailableEndpoints = new Set<string>();
for (const [key, available] of Object.entries(conversionAvailability)) {
if (!available) {
const dashIdx = key.indexOf('-');
const fromExt = key.slice(0, dashIdx);
const toExt = key.slice(dashIdx + 1);
const endpoint = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (endpoint) unavailableEndpoints.add(endpoint);
}
}
const conversionNames = [...unavailableEndpoints]
.map(ep => {
const suffix = KNOWN_CONVERSION_ENDPOINTS.has(ep)
? t(`selfHosted.offline.conversionTypes.${ep}`, ep)
: ep;
return `${convertPrefix}: ${suffix}`;
})
.filter(Boolean);
// Split methods unavailable locally
const unavailableSplitNames = Object.entries(splitAvailability)
.filter(([, available]) => !available)
.map(([ep]) => {
const i18n = SPLIT_ENDPOINT_I18N[ep];
const suffix = i18n ? t(i18n[0], i18n[1]) : ep;
return `${splitPrefix}: ${suffix}`;
})
.filter(Boolean);
return [...toolNames, ...conversionNames, ...unavailableSplitNames].sort();
}, [toolAvailability, toolRegistry, conversionAvailability, splitAvailability, t]);
// Only show when in self-hosted mode, server confirmed offline, and not dismissed
const show =
!dismissed &&
connectionMode === 'selfhosted' &&
serverState.status === 'offline';
if (!show) return null;
const messageText = localBackendReady
? t('selfHosted.offline.messageWithFallback', 'Some tools require a server connection.')
: t('selfHosted.offline.messageNoFallback', 'Tools are unavailable until your server comes back online.');
return (
<Paper
radius={0}
style={{
background: BANNER_BG,
borderBottom: `1px solid ${BANNER_BORDER}`,
}}
>
<Group gap="xs" align="center" wrap="nowrap" justify="space-between" px="sm" py={6}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<LocalIcon
icon="warning-rounded"
width="1rem"
height="1rem"
style={{ color: BANNER_ICON, flexShrink: 0 }}
/>
<Text size="xs" fw={600} style={{ color: BANNER_TEXT, flexShrink: 0 }}>
{t('selfHosted.offline.title', 'Server unreachable')}
</Text>
<Text size="xs" style={{ color: BANNER_TEXT, opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{messageText}
</Text>
</Group>
{allUnavailableNames.length > 0 && (
<Popover
opened={expanded}
onClose={() => setExpanded(false)}
position="bottom-end"
withinPortal
shadow="md"
width={260}
>
<Popover.Target>
<UnstyledButton
onClick={() => setExpanded(e => !e)}
style={{ color: BANNER_LINK, fontSize: 'var(--mantine-font-size-xs)', fontWeight: 500, flexShrink: 0, whiteSpace: 'nowrap' }}
>
{expanded
? t('selfHosted.offline.hideTools', 'Hide unavailable tools ▴')
: t('selfHosted.offline.showTools', 'View unavailable tools ▾')}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p="xs">
<ScrollArea.Autosize mah={300}>
<List size="xs" spacing={2}>
{allUnavailableNames.map(name => (
<List.Item key={name}>{name}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
)}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => setDismissed(true)}
aria-label={t('close', 'Close')}
style={{ color: BANNER_TEXT }}
>
<LocalIcon icon="close-rounded" width="0.8rem" height="0.8rem" />
</ActionIcon>
</Group>
</Paper>
);
}