Allow desktop app to connect to selfhosted servers (#4902)

# Description of Changes
Changes the desktop app to allow connections to self-hosted servers on
first startup. This was quite involved and hit loads of CORS issues all
through the stack, but I think it's working now. This also changes the
bundled backend to spawn on an OS-decided port rather than always
spawning on `8080`, which means that the user can have other things
running on port `8080` now and the app will still work fine. There were
quite a few places that needed to be updated to decouple the app from
explicitly using `8080` and I was originally going to split those
changes out into another PR (#4939), but I couldn't get it working
independently in the time I had, so the diff here is just going to be
complex and contian two distinct changes - sorry 🙁
This commit is contained in:
James Brunton
2025-11-20 10:03:34 +00:00
committed by GitHub
parent 75414b89f9
commit f4725b98b0
43 changed files with 3209 additions and 218 deletions
@@ -1,19 +1,15 @@
import { useEffect } from 'react';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { useEffect, useState } from 'react';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
import { useFileManagement } from '@app/contexts/file/fileHooks';
/**
* App initialization hook
* Desktop version: Handles Tauri-specific initialization
* - Starts the backend on app startup
* Desktop version: Handles Tauri-specific file initialization
* Requires FileContext - must be used inside FileContextProvider
* - Handles files opened with the app (adds directly to FileContext)
*/
export function useAppInitialization(): void {
// Initialize backend on app startup
useBackendInitializer();
// Get file management actions
const { addFiles } = useFileManagement();
@@ -59,3 +55,11 @@ export function useAppInitialization(): void {
loadOpenedFiles();
}, [openedFilePaths, openedFileLoading, addFiles]);
}
export function useSetupCompletion(): (completed: boolean) => void {
const [, setSetupComplete] = useState(false);
return (completed: boolean) => {
setSetupComplete(completed);
};
}
@@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Hook to initialize backend and monitor health
* @param enabled - Whether to initialize the backend (default: true)
*/
export function useBackendInitializer() {
export function useBackendInitializer(enabled = true) {
const { status, checkHealth } = useBackendHealth();
const { backendUrl } = useEndpointConfig();
useEffect(() => {
// Skip if disabled
if (!enabled) {
return;
}
// Skip if backend already running
if (tauriBackendService.isBackendRunning()) {
void checkHealth();
@@ -36,5 +42,5 @@ export function useBackendInitializer() {
if (status !== 'healthy' && status !== 'starting') {
void initializeBackend();
}
}, [status, backendUrl, checkHealth]);
}, [enabled, status, backendUrl, checkHealth]);
}
@@ -1,9 +1,10 @@
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import { connectionModeService } from '@desktop/services/connectionModeService';
interface EndpointConfig {
backendUrl: string;
@@ -235,17 +236,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
};
}
// Default backend URL from environment variables
const DEFAULT_BACKEND_URL =
import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL
|| '';
/**
* Desktop override exposing the backend URL used by the embedded server.
* Desktop override exposing the backend URL based on connection mode.
* - Offline mode: Uses local bundled backend (from env vars)
* - Server mode: Uses configured server URL from connection config
*/
export function useEndpointConfig(): EndpointConfig {
const backendUrl = useMemo(() => {
const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined;
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
return runtimeEnv?.STIRLING_BACKEND_URL
|| import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL
|| 'http://localhost:8080';
useEffect(() => {
connectionModeService.getCurrentConfig()
.then((config) => {
if (config.mode === 'server' && config.server_config?.url) {
setBackendUrl(config.server_config.url);
} else {
// Offline mode - use default from env vars
setBackendUrl(DEFAULT_BACKEND_URL);
}
})
.catch((err) => {
console.error('Failed to get connection config:', err);
// Keep current URL on error
});
}, []);
return { backendUrl };
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
/**
* First launch check hook
* Checks if this is the first time the app is being launched
* Does not require FileContext - can be used early in the provider hierarchy
*/
export function useFirstLaunchCheck(): { isFirstLaunch: boolean; setupComplete: boolean } {
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCheckCompleteRef = useRef(false);
// Check if this is first launch
useEffect(() => {
const checkFirstLaunch = async () => {
try {
const firstLaunch = await connectionModeService.isFirstLaunch();
setIsFirstLaunch(firstLaunch);
if (!firstLaunch) {
// Not first launch - initialize auth state
await authService.initializeAuthState();
setSetupComplete(true);
}
setupCheckCompleteRef.current = true;
} catch (error) {
console.error('Failed to check first launch:', error);
// On error, assume not first launch and proceed
setIsFirstLaunch(false);
setSetupComplete(true);
setupCheckCompleteRef.current = true;
}
};
if (!setupCheckCompleteRef.current) {
checkFirstLaunch();
}
}, []);
return { isFirstLaunch, setupComplete };
}