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

View File

@@ -1,10 +1,9 @@
import { useState, useEffect } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl, Tooltip, Box } from "@mantine/core";
import SliderWithInput from '@app/components/shared/sliderWithInput/SliderWithInput';
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import apiClient from "@app/services/apiClient";
import { useGroupEnabled } from "@app/hooks/useGroupEnabled";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
interface CompressSettingsProps {
@@ -15,20 +14,7 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation();
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
useEffect(() => {
const checkImageMagick = async () => {
try {
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
setImageMagickAvailable(response.data);
} catch (error) {
console.error('Failed to check ImageMagick availability:', error);
setImageMagickAvailable(true); // Optimistic fallback
}
};
checkImageMagick();
}, []);
const { enabled: imageMagickAvailable, unavailableReason: imageMagickReason } = useGroupEnabled('ImageMagick');
return (
<Stack gap="md">
@@ -122,17 +108,27 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
/>
</Stack>
<Checkbox
checked={parameters.lineArt}
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
disabled={disabled || imageMagickAvailable === false}
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
description={
imageMagickAvailable === false
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
}
/>
<Tooltip
label={imageMagickReason ?? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")}
disabled={imageMagickAvailable !== false}
multiline
maw={280}
>
<Box style={{ cursor: imageMagickAvailable === false ? 'not-allowed' : undefined }}>
<Checkbox
checked={parameters.lineArt}
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
disabled={disabled || imageMagickAvailable === false}
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
description={
imageMagickAvailable !== false
? t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
: undefined
}
style={{ pointerEvents: imageMagickAvailable === false ? 'none' : undefined }}
/>
</Box>
</Tooltip>
{parameters.lineArt && (
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>

View File

@@ -24,7 +24,7 @@ export const getIconStyle = (): Record<string, string> => {
return {};
};
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | null;
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | 'selfHostedOffline' | null;
export const getToolDisabledReason = (
id: string,
@@ -43,6 +43,9 @@ export const getToolDisabledReason = (
const availabilityInfo = toolAvailability?.[id as ToolId];
if (availabilityInfo && availabilityInfo.available === false) {
if (availabilityInfo.reason === 'selfHostedOffline') {
return 'selfHostedOffline';
}
if (availabilityInfo.reason === 'missingDependency') {
return 'missingDependency';
}
@@ -64,6 +67,12 @@ export const getDisabledLabel = (
fallback: 'Premium feature:'
};
}
if (disabledReason === 'selfHostedOffline') {
return {
key: 'toolPanel.fullscreen.selfHostedOffline',
fallback: 'Requires your Stirling-PDF server (currently offline):'
};
}
if (disabledReason === 'missingDependency') {
return {
key: 'toolPanel.fullscreen.unavailableDependency',

View File

@@ -36,8 +36,8 @@ const OperationButton = ({
'data-tour': dataTour
}: OperationButtonProps) => {
const { t } = useTranslation();
const { isHealthy, message: backendMessage } = useBackendHealth();
const blockedByBackend = !isHealthy;
const { isOnline, message: backendMessage } = useBackendHealth();
const blockedByBackend = !isOnline;
const combinedDisabled = disabled || blockedByBackend;
const tooltipLabel = blockedByBackend
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))

View File

@@ -5,6 +5,6 @@ export function useBackendHealth(): BackendHealthState {
status: 'healthy',
message: null,
error: null,
isHealthy: true,
isOnline: true,
};
}

View File

@@ -0,0 +1,28 @@
import { useState, useEffect, useRef } from 'react';
import apiClient from '@app/services/apiClient';
import type { GroupEnabledResult } from '@app/types/groupEnabled';
export type { GroupEnabledResult };
/**
* Checks whether a named feature group is enabled on the backend.
* Returns { enabled: null } while loading, then true/false with an optional reason.
*/
export function useGroupEnabled(group: string): GroupEnabledResult {
const [result, setResult] = useState<GroupEnabledResult>({ enabled: null, unavailableReason: null });
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => { isMountedRef.current = false; };
}, []);
useEffect(() => {
apiClient
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
.then(res => { if (isMountedRef.current) setResult({ enabled: res.data, unavailableReason: null }); })
.catch(() => { if (isMountedRef.current) setResult({ enabled: false, unavailableReason: null }); });
}, [group]);
return result;
}

View File

@@ -0,0 +1,8 @@
/**
* Stub implementation for web builds.
* In desktop builds this is shadowed by desktop/hooks/useSaaSMode.ts which
* returns whether the app is currently in SaaS connection mode (vs self-hosted).
*/
export function useSaaSMode(): boolean {
return false;
}

View File

@@ -0,0 +1,11 @@
/**
* Stub implementation for web / SaaS builds.
* In self-hosted desktop mode this is shadowed by the desktop override which
* returns the set of tool IDs that are unavailable when the self-hosted server
* is offline (i.e. tools whose endpoints the local bundled backend does not support).
*/
export function useSelfHostedToolAvailability(
_tools: Array<{ id: string; endpoints?: string[] }>
): Set<string> {
return new Set<string>();
}

View File

@@ -3,11 +3,13 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { useSelfHostedToolAvailability } from "@app/hooks/useSelfHostedToolAvailability";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId";
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown' | 'selfHostedOffline';
export interface ToolAvailabilityInfo {
available: boolean;
@@ -28,30 +30,59 @@ interface ToolManagementResult {
export const useToolManagement = (): ToolManagementResult => {
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const { preferences } = usePreferences();
const isSaaSMode = useSaaSMode();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const toolEndpointList = useMemo(
() => (Object.keys(baseRegistry) as ToolId[])
// Exclude coming-soon tools (no component and no link) — they are already
// unavailable regardless of server state and should not appear in the
// self-hosted offline banner.
.filter(id => {
const tool = baseRegistry[id];
return !!(tool?.component ?? tool?.link);
})
.map(id => ({
id,
endpoints: baseRegistry[id]?.endpoints ?? [],
})),
[baseRegistry]
);
const selfHostedOfflineIds = useSelfHostedToolAvailability(toolEndpointList);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
// Self-hosted offline check must come before the loading gate:
// in self-hosted offline mode endpointsLoading stays true indefinitely
// (health check never resolves), so checking it first would wrongly
// keep all tools enabled.
if (selfHostedOfflineIds.has(toolKey)) return false;
// Keep tools enabled while endpoint status is loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
// Tools without endpoints are always available
if (endpoints.length === 0) return true;
// Check if at least one endpoint is enabled
// If endpoint is not in status map, assume enabled (optimistic fallback)
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const hasLocalSupport = endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
// In SaaS mode tools without local support can route to the cloud backend
if (!hasLocalSupport && isSaaSMode) return true;
return hasLocalSupport;
}, [endpointsLoading, endpointStatus, baseRegistry, isSaaSMode, selfHostedOfflineIds]);
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
if (selfHostedOfflineIds.has(toolKey)) {
return 'selfHostedOffline';
}
const tool = baseRegistry[toolKey];
if (!tool) {
return 'unknown';
@@ -71,10 +102,13 @@ export const useToolManagement = (): ToolManagementResult => {
return 'unknown';
}
return 'unknown';
}, [baseRegistry, endpointDetails, endpointStatus]);
}, [baseRegistry, endpointDetails, endpointStatus, selfHostedOfflineIds]);
const toolAvailability = useMemo(() => {
if (endpointsLoading) {
// Skip computation during loading UNLESS some tools are already known offline.
// In self-hosted offline mode endpointsLoading never clears, so we must still
// compute the map to surface the selfHostedOfflineIds set.
if (endpointsLoading && selfHostedOfflineIds.size === 0) {
return {};
}
const availability: ToolAvailabilityMap = {};
@@ -85,7 +119,7 @@ export const useToolManagement = (): ToolManagementResult => {
: { available: false, reason: deriveToolDisableReason(toolKey) };
});
return availability;
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable, selfHostedOfflineIds]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
@@ -115,7 +149,7 @@ export const useToolManagement = (): ToolManagementResult => {
}, [toolRegistry]);
return {
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
selectedTool: getSelectedTool(null),
toolSelectedFileIds,
toolRegistry,
setToolSelectedFileIds,

View File

@@ -4,5 +4,5 @@ export interface BackendHealthState {
status: BackendStatus;
message?: string | null;
error: string | null;
isHealthy: boolean;
isOnline: boolean;
}

View File

@@ -1,4 +1,4 @@
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | 'NOT_SUPPORTED_LOCALLY' | null;
export interface EndpointAvailabilityDetails {
enabled: boolean;

View File

@@ -0,0 +1,5 @@
export interface GroupEnabledResult {
enabled: boolean | null;
/** Human-readable reason shown when the feature is unavailable. Null while loading or when enabled. */
unavailableReason: string | null;
}

View File

@@ -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) {

View File

@@ -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 === ' ') {

View File

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

View File

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

View File

@@ -1,24 +1,63 @@
import { useEffect, useState, useCallback } from 'react';
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { connectionModeService } from '@app/services/connectionModeService';
import type { BackendHealthState } from '@app/types/backendHealth';
/**
* Hook to read the shared backend health monitor state.
* All consumers subscribe to a single poller managed by backendHealthMonitor.
* Hook to read backend health state for UI (Run button, BackendHealthIndicator).
*
* backendHealthMonitor tracks the local bundled backend.
* selfHostedServerMonitor tracks the remote server in self-hosted mode.
*
* isOnline logic:
* - SaaS mode: true when local backend is healthy
* - Self-hosted mode (server online): true (remote is up)
* - Self-hosted mode (server offline, local port known): true so the Run button
* stays enabled — operationRouter routes supported tools to local
* - Self-hosted mode (server offline, local port unknown): false
*/
export function useBackendHealth() {
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
const [serverStatus, setServerStatus] = useState(
() => selfHostedServerMonitor.getSnapshot().status
);
const [localUrl, setLocalUrl] = useState<string | null>(
() => tauriBackendService.getBackendUrl()
);
const [connectionMode, setConnectionMode] = useState<string | null>(null);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
useEffect(() => {
return backendHealthMonitor.subscribe(setHealth);
}, []);
useEffect(() => {
return selfHostedServerMonitor.subscribe(state => setServerStatus(state.status));
}, []);
useEffect(() => {
return tauriBackendService.subscribeToStatus(() => {
setLocalUrl(tauriBackendService.getBackendUrl());
});
}, []);
const checkHealth = useCallback(async () => {
return backendHealthMonitor.checkNow();
}, []);
const isOnline =
connectionMode === 'selfhosted'
? serverStatus !== 'offline' || !!localUrl
: health.isOnline;
return {
...health,
isOnline,
checkHealth,
};
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
import { getEndpointName } from '@app/utils/convertUtils';
@@ -28,16 +29,56 @@ export function useConversionCloudStatus(): ConversionStatus {
useEffect(() => {
const checkConversions = async () => {
// Don't check until backend is healthy
// This prevents showing incorrect status during startup
if (!tauriBackendService.isBackendHealthy()) {
const mode = await connectionModeService.getCurrentMode();
// Self-hosted offline path: server is down but local backend is available.
// Check each conversion against the local backend only (no cloud routing).
if (mode === 'selfhosted') {
const { status } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
if (status === 'offline' && localUrl) {
const pairs: [string, string, string][] = [];
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
const endpointName = getEndpointName(fromExt, toExt);
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
}
}
const availability: Record<string, boolean> = {};
const cloudStatus: Record<string, boolean> = {};
const localOnly: Record<string, boolean> = {};
const results = await Promise.all(
pairs.map(async ([fromExt, toExt, endpointName]) => {
const key = `${fromExt}-${toExt}`;
try {
const supported = await endpointAvailabilityService.isEndpointSupportedLocally(endpointName, localUrl);
return { key, supported };
} catch {
return { key, supported: false };
}
})
);
for (const { key, supported } of results) {
availability[key] = supported;
cloudStatus[key] = false;
localOnly[key] = supported;
}
setStatus({ availability, cloudStatus, localOnly });
return;
}
// Server online or local not ready: let normal endpoint checking handle it
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
// Don't check until backend is healthy (SaaS startup guard)
if (!tauriBackendService.isOnline) {
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'saas') {
// In non-SaaS modes, local endpoint checking handles everything
// Non-SaaS, non-self-hosted: local endpoint checking handles everything
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
@@ -83,14 +124,29 @@ export function useConversionCloudStatus(): ConversionStatus {
// Initial check
checkConversions();
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
// Re-check when SaaS local backend becomes healthy
const unsubLocal = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
checkConversions();
}
});
return unsubscribe;
// Re-check when self-hosted server goes offline or comes back online.
// By the time the server is confirmed offline, the local port is already
// discovered (waitForPort completes in ~500ms vs the 8s server poll timeout).
// selfHostedServerMonitor.subscribe immediately invokes the listener with the
// current state, which would cause a duplicate check alongside the one above.
// Skip the first invocation since checkConversions() was already called above.
let skipFirst = true;
const unsubServer = selfHostedServerMonitor.subscribe(() => {
if (skipFirst) { skipFirst = false; return; }
void checkConversions();
});
return () => {
unsubLocal();
unsubServer();
};
}, []);
return status;

View File

@@ -3,6 +3,8 @@ import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { connectionModeService } from '@app/services/connectionModeService';
@@ -15,6 +17,13 @@ interface EndpointConfig {
const RETRY_DELAY_MS = 2500;
function isSelfHostedOffline(): boolean {
return (
selfHostedServerMonitor.getSnapshot().status === 'offline' &&
!!tauriBackendService.getBackendUrl()
);
}
function getErrorMessage(err: unknown): string {
if (isAxiosError(err)) {
const data = err.response?.data as { message?: string } | undefined;
@@ -146,7 +155,21 @@ export function useEndpointEnabled(endpoint: string): {
return;
}
if (tauriBackendService.isBackendHealthy()) {
// In self-hosted offline mode, enable optimistically when the local backend is ready.
// ConvertSettings already filters unsupported endpoints from the dropdown,
// so by the time the user has a valid endpoint selected it is supported locally.
if (isSelfHostedOffline()) {
setEnabled(true);
setLoading(false);
// Re-evaluate if the server comes back online
return selfHostedServerMonitor.subscribe(() => {
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
fetchEndpointStatus();
}
});
}
if (tauriBackendService.isOnline) {
fetchEndpointStatus();
}
@@ -207,6 +230,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return;
}
// Self-hosted offline: check each endpoint against the local backend directly.
// checkDependenciesReady() would fail here since it hits the offline remote server.
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
if (serverStatus === 'offline' && localUrl) {
const results = await Promise.all(
[...new Set(endpoints)].map(async (ep) => {
try {
const supported = await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl);
return { ep, supported };
} catch {
return { ep, supported: false };
}
})
);
if (!isMountedRef.current) return;
const statusMap: Record<string, boolean> = {};
const details: Record<string, EndpointAvailabilityDetails> = {};
for (const { ep, supported } of results) {
statusMap[ep] = supported;
details[ep] = { enabled: supported, reason: supported ? null : 'NOT_SUPPORTED_LOCALLY' };
}
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
setLoading(false);
return;
}
const dependenciesReady = await checkDependenciesReady();
if (!dependenciesReady) {
return; // Health monitor will trigger retry when truly ready
@@ -313,7 +364,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return;
}
if (tauriBackendService.isBackendHealthy()) {
if (isSelfHostedOffline()) {
fetchAllEndpointStatuses();
const unsubServer = selfHostedServerMonitor.subscribe(() => {
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
fetchAllEndpointStatuses();
}
});
return unsubServer;
}
if (tauriBackendService.isOnline) {
fetchAllEndpointStatuses();
}

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import type { GroupEnabledResult } from '@app/types/groupEnabled';
const OFFLINE_REASON_FALLBACK = 'Requires your Stirling-PDF server (currently offline)';
/**
* Desktop override: skips the network request entirely when the self-hosted
* server is confirmed offline, returning a reason string matching the tool panel.
*/
export function useGroupEnabled(group: string): GroupEnabledResult {
const { t } = useTranslation();
// Initialise synchronously so the first render already reflects offline state —
// avoids a flash where the option appears enabled before the effect runs.
// Use OFFLINE_REASON_FALLBACK directly so unavailableReason is non-null from
// the very first render when offline (t() is not available in useState initialiser).
const [result, setResult] = useState<GroupEnabledResult>(() => {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === 'offline') {
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
}
return { enabled: null, unavailableReason: null };
});
useEffect(() => {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === 'offline') {
setResult({
enabled: false,
unavailableReason: t('toolPanel.fullscreen.selfHostedOffline', OFFLINE_REASON_FALLBACK),
});
return;
}
apiClient
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
.then(res => setResult({ enabled: res.data, unavailableReason: null }))
.catch(() => setResult({ enabled: false, unavailableReason: null }));
}, [group, t]);
return result;
}

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
/**
* Returns whether the app is currently in SaaS connection mode.
* Starts optimistically true (most common for desktop) to avoid tools
* being incorrectly marked unavailable during initial load.
*/
export function useSaaSMode(): boolean {
const [isSaaSMode, setIsSaaSMode] = useState(true);
useEffect(() => {
void connectionModeService.getCurrentMode().then(mode => setIsSaaSMode(mode === 'saas'));
return connectionModeService.subscribeToModeChanges(cfg => setIsSaaSMode(cfg.mode === 'saas'));
}, []);
return isSaaSMode;
}

View File

@@ -0,0 +1,92 @@
import { useState, useEffect, useRef } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
/**
* Desktop implementation of useSelfHostedToolAvailability.
* Returns the set of tool IDs that are unavailable when the self-hosted server
* is offline (tools whose endpoints are not supported by the local bundled backend).
*
* Returns an empty set when:
* - Not in self-hosted mode
* - Self-hosted server is online
* - Local backend port is not yet known
*/
export function useSelfHostedToolAvailability(
tools: Array<{ id: string; endpoints?: string[] }>
): Set<string> {
const [unavailableIds, setUnavailableIds] = useState<Set<string>>(new Set());
// Keep a stable ref to the latest tools list to avoid unnecessary re-subscriptions
const toolsRef = useRef(tools);
toolsRef.current = tools;
useEffect(() => {
let cancelled = false;
const computeUnavailableTools = async () => {
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'selfhosted') {
setUnavailableIds(new Set());
return;
}
const { status } = selfHostedServerMonitor.getSnapshot();
if (status !== 'offline') {
// Idle or checking — not yet confirmed offline; don't mark anything unavailable
if (!cancelled) setUnavailableIds(new Set());
return;
}
const localUrl = tauriBackendService.getBackendUrl();
if (!localUrl) {
// Local backend port not yet known; can't determine unavailable tools yet
if (!cancelled) setUnavailableIds(new Set());
return;
}
// For each tool, check whether at least one of its endpoints is supported locally
const unavailable = new Set<string>();
await Promise.all(
toolsRef.current.map(async tool => {
const endpoints = tool.endpoints ?? [];
if (endpoints.length === 0) return; // No endpoints → always available
const locallySupported = await Promise.all(
endpoints.map(ep =>
endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl)
)
);
if (!locallySupported.some(Boolean)) {
unavailable.add(tool.id);
}
})
);
if (!cancelled) setUnavailableIds(unavailable);
};
// Re-compute when server status changes
const unsubServer = selfHostedServerMonitor.subscribe(() => {
void computeUnavailableTools();
});
// Re-compute when local backend becomes healthy (port discovered)
const unsubBackend = tauriBackendService.subscribeToStatus(() => {
void computeUnavailableTools();
});
// Initial computation
void computeUnavailableTools();
return () => {
cancelled = true;
unsubServer();
unsubBackend();
};
}, []); // tools intentionally omitted — accessed via ref to avoid churn
return unavailableIds;
}

View File

@@ -21,7 +21,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
try {
// Don't show cloud badges until backend is healthy
// This prevents showing incorrect cloud status during startup
if (!tauriBackendService.isBackendHealthy()) {
if (!tauriBackendService.isOnline) {
setUsesCloud(false);
return;
}

View File

@@ -1,174 +0,0 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId";
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
import { connectionModeService } from '@app/services/connectionModeService';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
export interface ToolAvailabilityInfo {
available: boolean;
reason?: ToolDisableCause;
}
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: FileId[];
toolRegistry: Partial<ToolRegistry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
}
/**
* Desktop override of useToolManagement
* Enhances tool availability logic to consider SaaS backend routing
* - Tools not supported locally but available on SaaS are marked as available
* - In SaaS mode, tools can route to cloud backend
*/
export const useToolManagement = (): ToolManagementResult => {
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Start optimistically assuming SaaS mode (most common for desktop)
// This prevents tools from being incorrectly marked unavailable during initial load
const [isSaaSMode, setIsSaaSMode] = useState(true);
// Log that desktop version is being used
useEffect(() => {
console.debug('[useToolManagement] DESKTOP VERSION loaded - SaaS routing enabled');
}, []);
// Check connection mode
useEffect(() => {
connectionModeService.getCurrentMode().then(mode => {
setIsSaaSMode(mode === 'saas');
console.debug('[useToolManagement] Connection mode loaded:', mode);
});
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(config => {
setIsSaaSMode(config.mode === 'saas');
console.debug('[useToolManagement] Connection mode changed:', config.mode);
});
return unsubscribe;
}, []);
// Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
// Tools without endpoints are always available
if (endpoints.length === 0) return true;
// Check if at least one endpoint is enabled locally
const hasLocalSupport = endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
// DESKTOP ENHANCEMENT: In SaaS mode, tools are available even if not supported locally
// They will route to the SaaS backend instead
if (!hasLocalSupport && isSaaSMode) {
console.debug(`[useToolManagement] Tool ${toolKey} not supported locally but available via SaaS routing`);
// In SaaS mode, assume tools can route to cloud if not available locally
// The operation router will handle the actual routing decision
return true;
}
if (!hasLocalSupport) {
console.debug(`[useToolManagement] Tool ${toolKey} not available - no local support and not in SaaS mode`, {
isSaaSMode,
endpoints,
endpointStatus: endpoints.map(e => ({ [e]: endpointStatus[e] }))
});
}
return hasLocalSupport;
}, [endpointsLoading, endpointStatus, baseRegistry, isSaaSMode]);
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
const tool = baseRegistry[toolKey];
if (!tool) {
return 'unknown';
}
const endpoints = tool.endpoints || [];
const disabledReasons: EndpointDisableReason[] = endpoints
.filter(endpoint => endpointStatus[endpoint] === false)
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
return 'missingDependency';
}
if (disabledReasons.some(reason => reason === 'CONFIG')) {
return 'disabledByAdmin';
}
if (disabledReasons.length > 0) {
return 'unknown';
}
return 'unknown';
}, [baseRegistry, endpointDetails, endpointStatus]);
const toolAvailability = useMemo(() => {
if (endpointsLoading) {
return {};
}
const availability: ToolAvailabilityMap = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const available = isToolAvailable(toolKey);
availability[toolKey] = available
? { available: true }
: { available: false, reason: deriveToolDisableReason(toolKey) };
});
return availability;
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const baseTool = baseRegistry[toolKey];
if (!baseTool) return;
const availabilityInfo = toolAvailability[toolKey];
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
// Check if tool is "coming soon" (has no component and no link)
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
}
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
});
return availableToolRegistry;
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
}, [toolRegistry]);
return {
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
toolSelectedFileIds,
toolRegistry,
setToolSelectedFileIds,
getSelectedTool,
toolAvailability,
};
};

View File

@@ -19,7 +19,7 @@ export function useWillUseCloud(endpoint?: string): boolean {
// Don't show cloud badges until backend is healthy
// This prevents showing incorrect cloud status during startup
if (!tauriBackendService.isBackendHealthy()) {
if (!tauriBackendService.isOnline) {
setWillUseCloud(false);
return;
}

View File

@@ -96,7 +96,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Backend readiness check (for local backend)
const isSaaS = await operationRouter.isSaaSMode();
const backendHealthy = tauriBackendService.isBackendHealthy();
const backendHealthy = tauriBackendService.isOnline;
const backendStatus = tauriBackendService.getBackendStatus();
const backendPort = tauriBackendService.getBackendPort();

View File

@@ -11,7 +11,7 @@ class BackendHealthMonitor {
private state: BackendHealthState = {
status: tauriBackendService.getBackendStatus(),
error: null,
isHealthy: tauriBackendService.getBackendStatus() === 'healthy',
isOnline: tauriBackendService.getBackendStatus() === 'healthy',
};
constructor(pollingInterval = 5000) {
@@ -35,7 +35,7 @@ class BackendHealthMonitor {
...this.state,
...partial,
status: nextStatus,
isHealthy: nextStatus === 'healthy',
isOnline: nextStatus === 'healthy',
};
// Only notify listeners if meaningful state changed

View File

@@ -2,14 +2,22 @@ import i18n from '@app/i18n';
import { alert } from '@app/components/toast';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { operationRouter } from '@app/services/operationRouter';
import { connectionModeService } from '@app/services/connectionModeService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
/**
* Desktop-specific guard that ensures the embedded backend is healthy
* before tools attempt to call any API endpoints.
* Enhanced to skip checks for endpoints routed to SaaS backend.
* Desktop-specific guard that ensures the relevant backend is ready before
* tools attempt to call any API endpoints.
*
* - SaaS mode: checks the local bundled backend via tauriBackendService.isOnline
* - Self-hosted mode (server online/checking): allows through — the operation
* targets the remote server and will surface network errors naturally
* - Self-hosted mode (server confirmed offline): allows through if local port is
* known (operationRouter falls back to local); suppresses toast since
* SelfHostedOfflineBanner already communicates the outage
*
* @param endpoint - Optional endpoint path to check if it needs local backend
* @returns true if backend is ready OR endpoint will be routed to SaaS
@@ -24,14 +32,40 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
}
}
// Check local backend health
if (tauriBackendService.isBackendHealthy()) {
const mode = await connectionModeService.getCurrentMode();
if (mode === 'selfhosted') {
let { status } = selfHostedServerMonitor.getSnapshot();
// 'checking' means the first poll hasn't returned yet. Wait briefly (up to
// 1.5 s) for it to resolve so we don't surface raw network errors during the
// first few seconds after launch. If it doesn't resolve in time we fall
// through and allow the operation — the HTTP layer will handle any error.
if (status === 'checking') {
await Promise.race([
selfHostedServerMonitor.checkNow(),
new Promise<void>(resolve => setTimeout(resolve, 1500)),
]);
status = selfHostedServerMonitor.getSnapshot().status;
}
if (status === 'offline') {
// Server offline: allow through if local backend port is known.
// operationRouter will route to local for supported endpoints.
// Suppress the toast — SelfHostedOfflineBanner communicates the outage.
return !!tauriBackendService.getBackendUrl();
}
// Server online: allow through — the operation targets the remote server.
return true;
}
// Trigger a health check so we get the freshest status
// SaaS mode: check local bundled backend
if (tauriBackendService.isOnline) {
return true;
}
// Trigger a fresh check so we get the latest status
await tauriBackendService.checkBackendHealth();
if (tauriBackendService.isBackendHealthy()) {
if (tauriBackendService.isOnline) {
return true;
}

View File

@@ -1,6 +1,8 @@
import i18n from '@app/i18n';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
import { CONVERSION_ENDPOINTS, ENDPOINT_NAMES } from '@app/constants/convertConstants';
@@ -181,6 +183,35 @@ export class OperationRouter {
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
}
// Self-hosted fallback: when the remote server is offline, route tool endpoints
// to the local bundled backend if it supports them.
if (mode === 'selfhosted' && operation && this.isToolEndpoint(operation)) {
const { status } = selfHostedServerMonitor.getSnapshot();
if (status === 'offline') {
const endpointName = this.extractEndpointName(operation);
const localUrl = tauriBackendService.getBackendUrl();
if (localUrl) {
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
localUrl
);
if (supportedLocally) {
console.debug(
`[operationRouter] Self-hosted server offline, routing ${operation} to local backend`
);
return localUrl.replace(/\/$/, '');
}
}
throw new Error(
i18n.t(
'selfHosted.offline.toolNotAvailableLocally',
'Your Stirling-PDF server is offline and "{{endpoint}}" is not available on the local backend.',
{ endpoint: endpointName }
)
);
}
}
// Existing logic for local/remote routing
const target = await this.getExecutionTarget(operation);

View File

@@ -0,0 +1,133 @@
import { fetch } from '@tauri-apps/plugin-http';
export interface SelfHostedServerState {
status: 'idle' | 'checking' | 'online' | 'offline';
isOnline: boolean;
serverUrl: string | null;
}
type Listener = (state: SelfHostedServerState) => void;
const POLL_INTERVAL_MS = 15_000;
const REQUEST_TIMEOUT_MS = 8_000;
/**
* Singleton service that independently monitors the health of the self-hosted
* Stirling-PDF server. Used in self-hosted connection mode to detect when the
* remote server goes offline so that the operation router can fall back to the
* local bundled backend for supported tools.
*
* This is separate from tauriBackendService / backendHealthMonitor so that the
* local-backend health indicator (BackendHealthIndicator) and the self-hosted
* server status can be tracked independently.
*/
class SelfHostedServerMonitor {
private static instance: SelfHostedServerMonitor;
private state: SelfHostedServerState = {
status: 'idle',
isOnline: false,
serverUrl: null,
};
private listeners = new Set<Listener>();
private intervalId: ReturnType<typeof setInterval> | null = null;
static getInstance(): SelfHostedServerMonitor {
if (!SelfHostedServerMonitor.instance) {
SelfHostedServerMonitor.instance = new SelfHostedServerMonitor();
}
return SelfHostedServerMonitor.instance;
}
/**
* Start polling the given server URL for health.
* Safe to call multiple times; only starts one polling loop at a time.
* Call stop() before calling start() again with a different URL.
*/
start(serverUrl: string): void {
if (this.intervalId !== null && this.state.serverUrl === serverUrl) {
// Already polling this URL
return;
}
this.stop();
this.updateState({ serverUrl, status: 'checking', isOnline: false });
void this.pollOnce(serverUrl);
this.intervalId = setInterval(() => {
void this.pollOnce(serverUrl);
}, POLL_INTERVAL_MS);
}
/** Stop polling. */
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.updateState({ status: 'idle', isOnline: false, serverUrl: null });
}
get isOnline(): boolean {
return this.state.isOnline;
}
getSnapshot(): SelfHostedServerState {
return this.state;
}
/** Subscribe to state changes. Returns an unsubscribe function. */
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
// Emit current state immediately so subscribers are always initialised
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
/** Trigger an immediate health check outside of the scheduled interval. */
async checkNow(): Promise<void> {
if (this.state.serverUrl) {
await this.pollOnce(this.state.serverUrl);
}
}
private async pollOnce(serverUrl: string): Promise<void> {
const healthUrl = `${serverUrl.replace(/\/$/, '')}/api/v1/info/status`;
try {
const response = await fetch(healthUrl, {
method: 'GET',
connectTimeout: REQUEST_TIMEOUT_MS,
});
if (response.ok) {
this.updateState({ status: 'online', isOnline: true });
} else {
this.updateState({ status: 'offline', isOnline: false });
}
} catch {
this.updateState({ status: 'offline', isOnline: false });
}
}
private updateState(partial: Partial<SelfHostedServerState>): void {
const next = { ...this.state, ...partial };
const changed =
next.status !== this.state.status ||
next.isOnline !== this.state.isOnline ||
next.serverUrl !== this.state.serverUrl;
this.state = next;
if (changed) {
this.listeners.forEach(listener => listener(this.state));
}
}
}
export const selfHostedServerMonitor = SelfHostedServerMonitor.getInstance();

View File

@@ -1,7 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { connectionModeService } from '@app/services/connectionModeService';
import { getAuthTokenFromAnySource } from '@app/services/authTokenStore';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
@@ -29,7 +27,7 @@ export class TauriBackendService {
return this.backendStatus;
}
isBackendHealthy(): boolean {
get isOnline(): boolean {
return this.backendStatus === 'healthy';
}
@@ -58,7 +56,9 @@ export class TauriBackendService {
/**
* Initialize health monitoring for an external server (server mode)
* Does not start bundled backend, but enables health checks
* Does not start bundled backend, but enables health checks.
* Also discovers the local bundled backend port so it can be used as a fallback
* when the self-hosted server is offline.
*/
async initializeExternalBackend(): Promise<void> {
if (this.backendStarted) {
@@ -68,6 +68,11 @@ export class TauriBackendService {
this.backendStarted = true; // Mark as active for health checks
this.setStatus('starting');
this.beginHealthMonitoring();
// Discover the local bundled backend port in the background.
// The Rust side always starts the local backend, so we can poll for its port
// even in self-hosted mode. This allows local fallback when the server is offline.
void this.waitForPort();
}
async startBackend(backendUrl?: string): Promise<void> {
@@ -108,6 +113,8 @@ export class TauriBackendService {
const port = await invoke<number | null>('get_backend_port');
if (port) {
this.backendPort = port;
// Notify status listeners so hooks reading getBackendUrl() re-evaluate
this.statusListeners.forEach(listener => listener(this.backendStatus));
return;
}
} catch (error) {
@@ -117,19 +124,6 @@ export class TauriBackendService {
}
}
/**
* Get auth token with expiry validation
* Delegates to authService which handles caching and expiry checking
*/
private async getAuthToken(): Promise<string | null> {
try {
return await getAuthTokenFromAnySource();
} catch (error) {
console.debug('[TauriBackendService] Failed to get auth token:', error);
return null;
}
}
private beginHealthMonitoring() {
if (this.healthMonitor) {
return;
@@ -143,67 +137,31 @@ export class TauriBackendService {
});
}
/** Always checks the local bundled backend at localhost:{port}. */
async checkBackendHealth(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
// Determine base URL based on mode
let baseUrl: string;
if (mode === 'selfhosted') {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.error('[TauriBackendService] Self-hosted mode but no server URL configured');
this.setStatus('unhealthy');
return false;
}
baseUrl = serverConfig.url.replace(/\/$/, '');
} else {
// SaaS mode - check bundled local backend
if (!this.backendStarted) {
console.debug('[TauriBackendService] Health check: backend not started');
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Health check: backend port not available');
return false;
}
baseUrl = `http://localhost:${this.backendPort}`;
if (!this.backendStarted) {
console.debug('[TauriBackendService] Health check: backend not started');
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Health check: backend port not available');
return false;
}
// Check if backend is ready (dependencies checked)
const configUrl = `http://localhost:${this.backendPort}/api/v1/config/app-config`;
console.debug(`[TauriBackendService] Checking local backend health at: ${configUrl}`);
try {
const configUrl = `${baseUrl}/api/v1/config/app-config`;
console.debug(`[TauriBackendService] Checking backend health at: ${configUrl}`);
// For self-hosted mode, include auth token if available
const headers: Record<string, string> = {};
if (mode === 'selfhosted') {
const token = await this.getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
console.debug(`[TauriBackendService] Adding auth token to health check`);
} else {
console.debug(`[TauriBackendService] No auth token available for health check`);
}
}
const response = await fetch(configUrl, {
method: 'GET',
connectTimeout: 5000,
headers,
});
console.debug(`[TauriBackendService] Health check response: status=${response.status}, ok=${response.ok}`);
const response = await fetch(configUrl, { method: 'GET', connectTimeout: 5000 });
if (!response.ok) {
console.warn(`[TauriBackendService] Health check failed: response not ok (status ${response.status})`);
console.warn(`[TauriBackendService] Health check failed: ${response.status}`);
this.setStatus('unhealthy');
return false;
}
const data = await response.json();
console.debug(`[TauriBackendService] Health check data:`, data);
const dependenciesReady = data.dependenciesReady === true;
console.debug(`[TauriBackendService] dependenciesReady=${dependenciesReady}`);