diff --git a/frontend/src/desktop/contexts/AppConfigContext.tsx b/frontend/src/desktop/contexts/AppConfigContext.tsx new file mode 100644 index 000000000..304762c5d --- /dev/null +++ b/frontend/src/desktop/contexts/AppConfigContext.tsx @@ -0,0 +1,64 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AppConfigProvider as CoreAppConfigProvider, + useAppConfig as useCoreAppConfig, +} from '@core/contexts/AppConfigContext'; +import type { ConnectionMode } from '@app/services/connectionModeService'; +import { connectionModeService } from '@app/services/connectionModeService'; + +export type { AppConfig, AppConfigProviderProps, AppConfigRetryOptions } from '@core/contexts/AppConfigContext'; +export const AppConfigProvider = CoreAppConfigProvider; + +/** + * Desktop override that forces login-enabled behavior while in SaaS mode. + * The bundled backend always reports enableLogin=false, but SaaS desktop + * still requires the proprietary login flow. Override the config so UI + * routes continue to show the login experience. + */ +export function useAppConfig() { + const value = useCoreAppConfig(); + const [mode, setMode] = useState('saas'); + + useEffect(() => { + let unsubscribe: (() => void) | null = null; + + const init = async () => { + try { + const current = await connectionModeService.getCurrentConfig(); + setMode(current.mode); + unsubscribe = connectionModeService.subscribeToModeChanges((config) => { + setMode(config.mode); + }); + } catch (error) { + console.error('[Desktop AppConfig] Failed to load connection mode:', error); + } + }; + + void init(); + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, []); + + const effectiveConfig = useMemo(() => { + if (!value.config) { + return value.config; + } + + if (mode === 'saas') { + return { + ...value.config, + enableLogin: true, + }; + } + + return value.config; + }, [mode, value.config]); + + return { + ...value, + config: effectiveConfig, + }; +} diff --git a/frontend/src/desktop/hooks/useBackendProbe.ts b/frontend/src/desktop/hooks/useBackendProbe.ts new file mode 100644 index 000000000..e4d497b85 --- /dev/null +++ b/frontend/src/desktop/hooks/useBackendProbe.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { fetch } from '@tauri-apps/plugin-http'; +import { connectionModeService } from '@app/services/connectionModeService'; +import { tauriBackendService, type BackendStatus as ServiceBackendStatus } from '@app/services/tauriBackendService'; + +type ConnectionMode = 'saas' | 'selfhosted'; + +type ProbeStatus = 'up' | 'starting' | 'down'; + +interface BackendProbeState { + status: ProbeStatus; + loginDisabled: boolean; + loading: boolean; +} + +/** + * Desktop-specific backend probe. + * + * In SaaS mode we trust the embedded backend's health status exposed by tauriBackendService, + * avoiding redundant HTTP polling that was preventing the Login/Landing screens from advancing. + * In self-hosted mode we still hit the remote server directly to detect login-disabled state. + */ +export function useBackendProbe() { + const initialStatus = useMemo(() => mapServiceStatus(tauriBackendService.getBackendStatus()), []); + const [state, setState] = useState({ + status: initialStatus, + loginDisabled: false, + loading: true, + }); + const modeRef = useRef('saas'); + + // Track connection mode so the probe knows when to fall back to remote HTTP checks. + useEffect(() => { + let unsubscribe: (() => void) | null = null; + connectionModeService.getCurrentConfig() + .then((config) => { + modeRef.current = config.mode; + unsubscribe = connectionModeService.subscribeToModeChanges((nextConfig) => { + modeRef.current = nextConfig.mode; + }); + }) + .catch((error) => { + console.error('[Desktop useBackendProbe] Failed to load connection mode:', error); + }); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, []); + + // React to backend health updates coming from the shared tauriBackendService. + useEffect(() => { + const unsubscribe = tauriBackendService.subscribeToStatus((serviceStatus) => { + setState((prev) => ({ + ...prev, + status: mapServiceStatus(serviceStatus), + loading: false, + })); + }); + return () => { + unsubscribe(); + }; + }, []); + + const probe = useCallback(async () => { + const mode = modeRef.current; + const serviceStatus = tauriBackendService.getBackendStatus(); + + if (mode === 'saas') { + // SaaS desktop always relies on the embedded backend. Kick off a health check if needed. + if (serviceStatus !== 'healthy') { + void tauriBackendService.checkBackendHealth(); + } + const nextState: BackendProbeState = { + status: mapServiceStatus(serviceStatus), + loginDisabled: false, + loading: false, + }; + setState(nextState); + return nextState; + } + + const baseUrl = await resolveRemoteBaseUrl(); + if (!baseUrl) { + const pending: BackendProbeState = { + status: 'starting', + loginDisabled: false, + loading: false, + }; + setState(pending); + return pending; + } + + const statusUrl = `${baseUrl}/api/v1/info/status`; + const loginUrl = `${baseUrl}/api/v1/proprietary/ui-data/login`; + + const next: BackendProbeState = { + status: 'starting', + loginDisabled: false, + loading: false, + }; + + try { + const res = await fetch(statusUrl, { method: 'GET', connectTimeout: 5000 }); + if (res.ok) { + const data = await res.json().catch(() => null); + if (data && data.status === 'UP') { + next.status = 'up'; + setState(next); + return next; + } + next.status = 'starting'; + } else if (res.status === 404 || res.status === 503) { + next.status = 'starting'; + } else { + next.status = 'down'; + } + } catch { + next.status = 'down'; + } + + try { + const res = await fetch(loginUrl, { method: 'GET', connectTimeout: 5000 }); + if (res.ok) { + next.status = 'up'; + const data = await res.json().catch(() => null); + if (data && data.enableLogin === false) { + next.loginDisabled = true; + } + } else if (res.status === 404) { + next.status = 'up'; + next.loginDisabled = true; + } else if (res.status === 503) { + next.status = 'starting'; + } else { + next.status = 'down'; + } + } catch { + // keep inferred state + } + + setState(next); + return next; + }, []); + + useEffect(() => { + void probe(); + }, [probe]); + + return { + ...state, + probe, + }; +} + +async function resolveRemoteBaseUrl(): Promise { + try { + const config = await connectionModeService.getCurrentConfig(); + if (config.mode !== 'selfhosted') { + return null; + } + const serverUrl = config.server_config?.url; + if (!serverUrl) { + return null; + } + return stripTrailingSlash(serverUrl); + } catch (error) { + console.error('[Desktop useBackendProbe] Failed to resolve remote backend URL:', error); + return null; + } +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/$/, ''); +} + +function mapServiceStatus(status: ServiceBackendStatus): ProbeStatus { + if (status === 'healthy') { + return 'up'; + } + if (status === 'unhealthy') { + return 'down'; + } + return 'starting'; +}