From 80749aa045ae96808cdd4a95797f15987a18680d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Nov 2025 16:02:49 +0000 Subject: [PATCH 1/4] Override backend probe --- frontend/src/desktop/hooks/useBackendProbe.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 frontend/src/desktop/hooks/useBackendProbe.ts diff --git a/frontend/src/desktop/hooks/useBackendProbe.ts b/frontend/src/desktop/hooks/useBackendProbe.ts new file mode 100644 index 000000000..8017328d3 --- /dev/null +++ b/frontend/src/desktop/hooks/useBackendProbe.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from 'react'; +import { fetch } from '@tauri-apps/plugin-http'; +import { invoke } from '@tauri-apps/api/core'; +import { connectionModeService } from '@app/services/connectionModeService'; +import { tauriBackendService } from '@app/services/tauriBackendService'; + +type BackendStatus = 'up' | 'starting' | 'down'; + +interface BackendProbeState { + status: BackendStatus; + loginDisabled: boolean; + loading: boolean; +} + +/** + * Desktop override of useBackendProbe. + * Hits the local/remote backend directly via the Tauri HTTP client. + */ +export function useBackendProbe() { + const [state, setState] = useState({ + status: 'starting', + loginDisabled: false, + loading: true, + }); + + const probe = useCallback(async () => { + const baseUrl = await resolveProbeBaseUrl(); + if (!baseUrl) { + const pendingState: BackendProbeState = { + status: 'starting', + loginDisabled: false, + loading: false, + }; + setState(pendingState); + return pendingState; + } + + 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 existing inferred state (down/starting) + } + + setState(next); + return next; + }, []); + + useEffect(() => { + void probe(); + }, [probe]); + + return { + ...state, + probe, + }; +} + +async function resolveProbeBaseUrl(): Promise { + try { + const config = await connectionModeService.getCurrentConfig(); + if (config.mode === 'selfhosted') { + const serverUrl = config.server_config?.url; + if (!serverUrl) { + return null; + } + return stripTrailingSlash(serverUrl); + } + + const directUrl = tauriBackendService.getBackendUrl(); + if (directUrl) { + return stripTrailingSlash(directUrl); + } + + const port = await invoke('get_backend_port').catch(() => null); + if (!port) { + return null; + } + return stripTrailingSlash(`http://localhost:${port}`); + } catch (error) { + console.error('[Desktop useBackendProbe] Failed to resolve backend URL:', error); + return null; + } +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/$/, ''); +} From 8772637328684b9e98a0a02f3764c008d170bfeb Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Nov 2025 16:26:21 +0000 Subject: [PATCH 2/4] Attempt 2 --- frontend/src/desktop/hooks/useBackendProbe.ts | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/frontend/src/desktop/hooks/useBackendProbe.ts b/frontend/src/desktop/hooks/useBackendProbe.ts index 8017328d3..deb7538eb 100644 --- a/frontend/src/desktop/hooks/useBackendProbe.ts +++ b/frontend/src/desktop/hooks/useBackendProbe.ts @@ -6,12 +6,19 @@ import { tauriBackendService } from '@app/services/tauriBackendService'; type BackendStatus = 'up' | 'starting' | 'down'; +type ConnectionMode = 'saas' | 'selfhosted'; + interface BackendProbeState { status: BackendStatus; loginDisabled: boolean; loading: boolean; } +interface ProbeTarget { + baseUrl: string; + mode: ConnectionMode; +} + /** * Desktop override of useBackendProbe. * Hits the local/remote backend directly via the Tauri HTTP client. @@ -24,8 +31,8 @@ export function useBackendProbe() { }); const probe = useCallback(async () => { - const baseUrl = await resolveProbeBaseUrl(); - if (!baseUrl) { + const target = await resolveProbeBaseUrl(); + if (!target) { const pendingState: BackendProbeState = { status: 'starting', loginDisabled: false, @@ -35,6 +42,7 @@ export function useBackendProbe() { return pendingState; } + const { baseUrl, mode } = target; const statusUrl = `${baseUrl}/api/v1/info/status`; const loginUrl = `${baseUrl}/api/v1/proprietary/ui-data/login`; @@ -63,24 +71,28 @@ export function useBackendProbe() { 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) { + // SaaS desktop always runs bundled backend with login disabled, + // so only check the login endpoint in self-hosted mode. + if (mode === 'selfhosted') { + 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'; } - } 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 existing inferred state (down/starting) } - } catch { - // keep existing inferred state (down/starting) } setState(next); @@ -97,7 +109,7 @@ export function useBackendProbe() { }; } -async function resolveProbeBaseUrl(): Promise { +async function resolveProbeBaseUrl(): Promise { try { const config = await connectionModeService.getCurrentConfig(); if (config.mode === 'selfhosted') { @@ -105,19 +117,28 @@ async function resolveProbeBaseUrl(): Promise { if (!serverUrl) { return null; } - return stripTrailingSlash(serverUrl); + return { + baseUrl: stripTrailingSlash(serverUrl), + mode: 'selfhosted', + }; } const directUrl = tauriBackendService.getBackendUrl(); if (directUrl) { - return stripTrailingSlash(directUrl); + return { + baseUrl: stripTrailingSlash(directUrl), + mode: 'saas', + }; } const port = await invoke('get_backend_port').catch(() => null); if (!port) { return null; } - return stripTrailingSlash(`http://localhost:${port}`); + return { + baseUrl: stripTrailingSlash(`http://localhost:${port}`), + mode: 'saas', + }; } catch (error) { console.error('[Desktop useBackendProbe] Failed to resolve backend URL:', error); return null; From 60649c095f484e49b1278dac559286d0ed644166 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Nov 2025 16:54:57 +0000 Subject: [PATCH 3/4] Attempt 3 --- .../src/desktop/contexts/AppConfigContext.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 frontend/src/desktop/contexts/AppConfigContext.tsx 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, + }; +} From 3c8e1054ad1b7dabb9b3be8d565f0b58ebf9b3d6 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Nov 2025 17:14:12 +0000 Subject: [PATCH 4/4] Attempt 4 --- frontend/src/desktop/hooks/useBackendProbe.ts | 169 +++++++++++------- 1 file changed, 103 insertions(+), 66 deletions(-) diff --git a/frontend/src/desktop/hooks/useBackendProbe.ts b/frontend/src/desktop/hooks/useBackendProbe.ts index deb7538eb..e4d497b85 100644 --- a/frontend/src/desktop/hooks/useBackendProbe.ts +++ b/frontend/src/desktop/hooks/useBackendProbe.ts @@ -1,48 +1,98 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { fetch } from '@tauri-apps/plugin-http'; -import { invoke } from '@tauri-apps/api/core'; import { connectionModeService } from '@app/services/connectionModeService'; -import { tauriBackendService } from '@app/services/tauriBackendService'; - -type BackendStatus = 'up' | 'starting' | 'down'; +import { tauriBackendService, type BackendStatus as ServiceBackendStatus } from '@app/services/tauriBackendService'; type ConnectionMode = 'saas' | 'selfhosted'; +type ProbeStatus = 'up' | 'starting' | 'down'; + interface BackendProbeState { - status: BackendStatus; + status: ProbeStatus; loginDisabled: boolean; loading: boolean; } -interface ProbeTarget { - baseUrl: string; - mode: ConnectionMode; -} - /** - * Desktop override of useBackendProbe. - * Hits the local/remote backend directly via the Tauri HTTP client. + * 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: 'starting', + 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 target = await resolveProbeBaseUrl(); - if (!target) { - const pendingState: BackendProbeState = { + 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(pendingState); - return pendingState; + setState(pending); + return pending; } - const { baseUrl, mode } = target; const statusUrl = `${baseUrl}/api/v1/info/status`; const loginUrl = `${baseUrl}/api/v1/proprietary/ui-data/login`; @@ -71,28 +121,24 @@ export function useBackendProbe() { next.status = 'down'; } - // SaaS desktop always runs bundled backend with login disabled, - // so only check the login endpoint in self-hosted mode. - if (mode === 'selfhosted') { - 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'; + 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 === 503) { - next.status = 'starting'; - } else { - next.status = 'down'; } - } catch { - // keep existing inferred state (down/starting) + } 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); @@ -109,38 +155,19 @@ export function useBackendProbe() { }; } -async function resolveProbeBaseUrl(): Promise { +async function resolveRemoteBaseUrl(): Promise { try { const config = await connectionModeService.getCurrentConfig(); - if (config.mode === 'selfhosted') { - const serverUrl = config.server_config?.url; - if (!serverUrl) { - return null; - } - return { - baseUrl: stripTrailingSlash(serverUrl), - mode: 'selfhosted', - }; - } - - const directUrl = tauriBackendService.getBackendUrl(); - if (directUrl) { - return { - baseUrl: stripTrailingSlash(directUrl), - mode: 'saas', - }; - } - - const port = await invoke('get_backend_port').catch(() => null); - if (!port) { + if (config.mode !== 'selfhosted') { return null; } - return { - baseUrl: stripTrailingSlash(`http://localhost:${port}`), - mode: 'saas', - }; + const serverUrl = config.server_config?.url; + if (!serverUrl) { + return null; + } + return stripTrailingSlash(serverUrl); } catch (error) { - console.error('[Desktop useBackendProbe] Failed to resolve backend URL:', error); + console.error('[Desktop useBackendProbe] Failed to resolve remote backend URL:', error); return null; } } @@ -148,3 +175,13 @@ async function resolveProbeBaseUrl(): Promise { 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'; +}