This commit is contained in:
James Brunton 2025-12-18 17:21:48 +00:00 committed by GitHub
commit 8046113bd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 251 additions and 0 deletions

View File

@ -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<ConnectionMode>('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,
};
}

View File

@ -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<BackendProbeState>({
status: initialStatus,
loginDisabled: false,
loading: true,
});
const modeRef = useRef<ConnectionMode>('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<string | null> {
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';
}