diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 6e206e16b9..bfbd442e14 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5726,6 +5726,40 @@ placeholder = "Enter search term..." searching = "Searching..." title = "Search PDF" +[selfHosted] + +[selfHosted.offline] +hideTools = "Hide unavailable tools ▴" +messageNoFallback = "Tools are unavailable until your server comes back online." +messageWithFallback = "Some tools require a server connection." +showTools = "View unavailable tools ▾" +title = "Your Stirling-PDF server is unreachable" +toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint}}\" is not available on the local backend." + +[selfHosted.offline.conversionTypes] +cbr-to-pdf = "CBR → PDF" +cbz-to-pdf = "CBZ → PDF" +eml-to-pdf = "Email → PDF" +ebook-to-pdf = "eBook → PDF" +file-to-pdf = "Office/Document → PDF" +html-to-pdf = "HTML → PDF" +img-to-pdf = "Image → PDF" +markdown-to-pdf = "Markdown → PDF" +pdf-to-cbr = "PDF → CBR" +pdf-to-cbz = "PDF → CBZ" +pdf-to-csv = "PDF → CSV" +pdf-to-epub = "PDF → EPUB" +pdf-to-html = "PDF → HTML" +pdf-to-img = "PDF → Image" +pdf-to-markdown = "PDF → Markdown" +pdf-to-pdfa = "PDF → PDF/A" +pdf-to-presentation = "PDF → Presentation" +pdf-to-text = "PDF → Text" +pdf-to-word = "PDF → Word" +pdf-to-xml = "PDF → XML" +pdf-to-xlsx = "PDF → XLSX" +svg-to-pdf = "SVG → PDF" + [session] expired = "Your session has expired. Please refresh the page and try again." refreshPage = "Refresh Page" @@ -6531,6 +6565,7 @@ favorites = "Favourites" heading = "All tools (fullscreen view)" noResults = "Try adjusting your search or toggle descriptions to find what you need." recommended = "Recommended" +selfHostedOffline = "Requires your Stirling-PDF server (currently offline):" showDetails = "Show Details" unavailable = "Disabled by server administrator:" unavailableDependency = "Unavailable - required tool missing on server:" diff --git a/frontend/src/core/components/tools/compress/CompressSettings.tsx b/frontend/src/core/components/tools/compress/CompressSettings.tsx index 6ee5948160..a7f9949b7c 100644 --- a/frontend/src/core/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/core/components/tools/compress/CompressSettings.tsx @@ -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(null); - - useEffect(() => { - const checkImageMagick = async () => { - try { - const response = await apiClient.get('/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 ( @@ -122,17 +108,27 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C /> - 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.") - } - /> + + + 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 }} + /> + + {parameters.lineArt && ( {t('compress.lineArt.detailLevel', 'Detail level')} diff --git a/frontend/src/core/components/tools/fullscreen/shared.ts b/frontend/src/core/components/tools/fullscreen/shared.ts index 7e19c685c5..7de37c3e41 100644 --- a/frontend/src/core/components/tools/fullscreen/shared.ts +++ b/frontend/src/core/components/tools/fullscreen/shared.ts @@ -24,7 +24,7 @@ export const getIconStyle = (): Record => { 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', diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index 45ccf5fbb1..ef0a0814e0 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -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...')) diff --git a/frontend/src/core/hooks/useBackendHealth.ts b/frontend/src/core/hooks/useBackendHealth.ts index 16ffea6747..baeab6dbea 100644 --- a/frontend/src/core/hooks/useBackendHealth.ts +++ b/frontend/src/core/hooks/useBackendHealth.ts @@ -5,6 +5,6 @@ export function useBackendHealth(): BackendHealthState { status: 'healthy', message: null, error: null, - isHealthy: true, + isOnline: true, }; } diff --git a/frontend/src/core/hooks/useGroupEnabled.ts b/frontend/src/core/hooks/useGroupEnabled.ts new file mode 100644 index 0000000000..6c389f55f6 --- /dev/null +++ b/frontend/src/core/hooks/useGroupEnabled.ts @@ -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({ enabled: null, unavailableReason: null }); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { isMountedRef.current = false; }; + }, []); + + useEffect(() => { + apiClient + .get(`/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; +} diff --git a/frontend/src/core/hooks/useSaaSMode.ts b/frontend/src/core/hooks/useSaaSMode.ts new file mode 100644 index 0000000000..b1053a90d0 --- /dev/null +++ b/frontend/src/core/hooks/useSaaSMode.ts @@ -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; +} diff --git a/frontend/src/core/hooks/useSelfHostedToolAvailability.ts b/frontend/src/core/hooks/useSelfHostedToolAvailability.ts new file mode 100644 index 0000000000..b232b3b7d9 --- /dev/null +++ b/frontend/src/core/hooks/useSelfHostedToolAvailability.ts @@ -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 { + return new Set(); +} diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx index a53fe67356..8a05e1b454 100644 --- a/frontend/src/core/hooks/useToolManagement.tsx +++ b/frontend/src/core/hooks/useToolManagement.tsx @@ -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([]); - // 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 = useMemo(() => { const availableToolRegistry: Partial = {}; @@ -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, diff --git a/frontend/src/core/types/backendHealth.ts b/frontend/src/core/types/backendHealth.ts index 7720d99502..18fbad4dbe 100644 --- a/frontend/src/core/types/backendHealth.ts +++ b/frontend/src/core/types/backendHealth.ts @@ -4,5 +4,5 @@ export interface BackendHealthState { status: BackendStatus; message?: string | null; error: string | null; - isHealthy: boolean; + isOnline: boolean; } diff --git a/frontend/src/core/types/endpointAvailability.ts b/frontend/src/core/types/endpointAvailability.ts index c0f37d8312..aae1a05ec4 100644 --- a/frontend/src/core/types/endpointAvailability.ts +++ b/frontend/src/core/types/endpointAvailability.ts @@ -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; diff --git a/frontend/src/core/types/groupEnabled.ts b/frontend/src/core/types/groupEnabled.ts new file mode 100644 index 0000000000..c987583272 --- /dev/null +++ b/frontend/src/core/types/groupEnabled.ts @@ -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; +} diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index f795b50fdf..30d84fd68d 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -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) { diff --git a/frontend/src/desktop/components/BackendHealthIndicator.tsx b/frontend/src/desktop/components/BackendHealthIndicator.tsx index d2abb2318f..5bc12f405b 100644 --- a/frontend/src/desktop/components/BackendHealthIndicator.tsx +++ b/frontend/src/desktop/components/BackendHealthIndicator.tsx @@ -13,29 +13,29 @@ export const BackendHealthIndicator: React.FC = ({ 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) => { if (event.key === 'Enter' || event.key === ' ') { diff --git a/frontend/src/desktop/components/DesktopBannerInitializer.tsx b/frontend/src/desktop/components/DesktopBannerInitializer.tsx index 68d2728c14..c649576f8b 100644 --- a/frontend/src/desktop/components/DesktopBannerInitializer.tsx +++ b/frontend/src/desktop/components/DesktopBannerInitializer.tsx @@ -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( <> + diff --git a/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx b/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx new file mode 100644 index 0000000000..9011ea77e8 --- /dev/null +++ b/frontend/src/desktop/components/shared/SelfHostedOfflineBanner.tsx @@ -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 = { + '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(null); + const [serverState, setServerState] = useState( + () => 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>({}); + 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 = {}; + 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(); + 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 ( + + + + + + {t('selfHosted.offline.title', 'Server unreachable')} + + + {messageText} + + + {allUnavailableNames.length > 0 && ( + setExpanded(false)} + position="bottom-end" + withinPortal + shadow="md" + width={260} + > + + 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 ▾')} + + + + + + {allUnavailableNames.map(name => ( + {name} + ))} + + + + + )} + setDismissed(true)} + aria-label={t('close', 'Close')} + style={{ color: BANNER_TEXT }} + > + + + + + ); +} diff --git a/frontend/src/desktop/hooks/useBackendHealth.ts b/frontend/src/desktop/hooks/useBackendHealth.ts index 557cde7979..19ccefc271 100644 --- a/frontend/src/desktop/hooks/useBackendHealth.ts +++ b/frontend/src/desktop/hooks/useBackendHealth.ts @@ -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(() => backendHealthMonitor.getSnapshot()); + const [serverStatus, setServerStatus] = useState( + () => selfHostedServerMonitor.getSnapshot().status + ); + const [localUrl, setLocalUrl] = useState( + () => tauriBackendService.getBackendUrl() + ); + const [connectionMode, setConnectionMode] = useState(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, }; } diff --git a/frontend/src/desktop/hooks/useConversionCloudStatus.ts b/frontend/src/desktop/hooks/useConversionCloudStatus.ts index c5ca962cae..a86d574eb0 100644 --- a/frontend/src/desktop/hooks/useConversionCloudStatus.ts +++ b/frontend/src/desktop/hooks/useConversionCloudStatus.ts @@ -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 = {}; + const cloudStatus: Record = {}; + const localOnly: Record = {}; + 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; diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index de4a504f1b..a4f093f266 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -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 = {}; + const details: Record = {}; + 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(); } diff --git a/frontend/src/desktop/hooks/useGroupEnabled.ts b/frontend/src/desktop/hooks/useGroupEnabled.ts new file mode 100644 index 0000000000..91f9ccc951 --- /dev/null +++ b/frontend/src/desktop/hooks/useGroupEnabled.ts @@ -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(() => { + 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(`/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; +} diff --git a/frontend/src/desktop/hooks/useSaaSMode.ts b/frontend/src/desktop/hooks/useSaaSMode.ts new file mode 100644 index 0000000000..9b7833fdd4 --- /dev/null +++ b/frontend/src/desktop/hooks/useSaaSMode.ts @@ -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; +} diff --git a/frontend/src/desktop/hooks/useSelfHostedToolAvailability.ts b/frontend/src/desktop/hooks/useSelfHostedToolAvailability.ts new file mode 100644 index 0000000000..43fceb7088 --- /dev/null +++ b/frontend/src/desktop/hooks/useSelfHostedToolAvailability.ts @@ -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 { + const [unavailableIds, setUnavailableIds] = useState>(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(); + 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; +} diff --git a/frontend/src/desktop/hooks/useToolCloudStatus.ts b/frontend/src/desktop/hooks/useToolCloudStatus.ts index fbe0806bb4..18636a8b7c 100644 --- a/frontend/src/desktop/hooks/useToolCloudStatus.ts +++ b/frontend/src/desktop/hooks/useToolCloudStatus.ts @@ -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; } diff --git a/frontend/src/desktop/hooks/useToolManagement.tsx b/frontend/src/desktop/hooks/useToolManagement.tsx deleted file mode 100644 index 2f1d9879e7..0000000000 --- a/frontend/src/desktop/hooks/useToolManagement.tsx +++ /dev/null @@ -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>; - -interface ToolManagementResult { - selectedTool: ToolRegistryEntry | null; - toolSelectedFileIds: FileId[]; - toolRegistry: Partial; - 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([]); - // 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 = useMemo(() => { - const availableToolRegistry: Partial = {}; - (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, - }; -}; diff --git a/frontend/src/desktop/hooks/useWillUseCloud.ts b/frontend/src/desktop/hooks/useWillUseCloud.ts index 39d8382108..754c2a9e49 100644 --- a/frontend/src/desktop/hooks/useWillUseCloud.ts +++ b/frontend/src/desktop/hooks/useWillUseCloud.ts @@ -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; } diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index cb923b0169..1b44a98ed4 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -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(); diff --git a/frontend/src/desktop/services/backendHealthMonitor.ts b/frontend/src/desktop/services/backendHealthMonitor.ts index 6504ceb2ec..f53cecd90b 100644 --- a/frontend/src/desktop/services/backendHealthMonitor.ts +++ b/frontend/src/desktop/services/backendHealthMonitor.ts @@ -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 diff --git a/frontend/src/desktop/services/backendReadinessGuard.ts b/frontend/src/desktop/services/backendReadinessGuard.ts index 1742835d2d..c69590ef06 100644 --- a/frontend/src/desktop/services/backendReadinessGuard.ts +++ b/frontend/src/desktop/services/backendReadinessGuard.ts @@ -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 { } } - // 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(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; } diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts index 420f3ddb02..8ac2aedf2f 100644 --- a/frontend/src/desktop/services/operationRouter.ts +++ b/frontend/src/desktop/services/operationRouter.ts @@ -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); diff --git a/frontend/src/desktop/services/selfHostedServerMonitor.ts b/frontend/src/desktop/services/selfHostedServerMonitor.ts new file mode 100644 index 0000000000..f453193783 --- /dev/null +++ b/frontend/src/desktop/services/selfHostedServerMonitor.ts @@ -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(); + private intervalId: ReturnType | 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 { + if (this.state.serverUrl) { + await this.pollOnce(this.state.serverUrl); + } + } + + private async pollOnce(serverUrl: string): Promise { + 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): 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(); diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index bf36e5e5b8..4ac5a70b3e 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -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 { 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 { @@ -108,6 +113,8 @@ export class TauriBackendService { const port = await invoke('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 { - 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 { - 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 = {}; - 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}`);