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,4 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { connectionModeService } from '@app/services/connectionModeService';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
@@ -6,6 +8,7 @@ export class TauriBackendService {
private static instance: TauriBackendService;
private backendStarted = false;
private backendStatus: BackendStatus = 'stopped';
private backendPort: number | null = null;
private healthMonitor: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private statusListeners = new Set<(status: BackendStatus) => void>();
@@ -29,6 +32,14 @@ export class TauriBackendService {
return this.backendStatus === 'healthy';
}
getBackendPort(): number | null {
return this.backendPort;
}
getBackendUrl(): string | null {
return this.backendPort ? `http://localhost:${this.backendPort}` : null;
}
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
this.statusListeners.add(listener);
return () => {
@@ -44,6 +55,21 @@ export class TauriBackendService {
this.statusListeners.forEach(listener => listener(status));
}
/**
* Initialize health monitoring for an external server (server mode)
* Does not start bundled backend, but enables health checks
*/
async initializeExternalBackend(): Promise<void> {
if (this.backendStarted) {
return;
}
console.log('[TauriBackendService] Initializing external backend monitoring');
this.backendStarted = true; // Mark as active for health checks
this.setStatus('starting');
this.beginHealthMonitoring();
}
async startBackend(backendUrl?: string): Promise<void> {
if (this.backendStarted) {
return;
@@ -56,10 +82,14 @@ export class TauriBackendService {
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then((result) => {
.then(async (result) => {
console.log('Backend started:', result);
this.backendStarted = true;
this.setStatus('starting');
// Poll for the dynamically assigned port
await this.waitForPort();
this.beginHealthMonitoring();
})
.catch((error) => {
@@ -74,6 +104,24 @@ export class TauriBackendService {
return this.startPromise;
}
private async waitForPort(maxAttempts = 30): Promise<void> {
console.log('[TauriBackendService] Waiting for backend port assignment...');
for (let i = 0; i < maxAttempts; i++) {
try {
const port = await invoke<number | null>('get_backend_port');
if (port) {
this.backendPort = port;
console.log(`[TauriBackendService] Backend port detected: ${port}`);
return;
}
} catch (error) {
console.error('Failed to get backend port:', error);
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('Failed to detect backend port after 15 seconds');
}
private beginHealthMonitoring() {
if (this.healthMonitor) {
return;
@@ -88,16 +136,58 @@ export class TauriBackendService {
}
async checkBackendHealth(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
// For remote server mode, check the configured server
if (mode !== 'offline') {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.error('[TauriBackendService] Server mode but no server URL configured');
this.setStatus('unhealthy');
return false;
}
try {
const baseUrl = serverConfig.url.replace(/\/$/, '');
const healthUrl = `${baseUrl}/api/v1/info/status`;
const response = await fetch(healthUrl, {
method: 'GET',
connectTimeout: 5000,
});
const isHealthy = response.ok;
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Server health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
}
// For offline mode, check the bundled backend via Rust
if (!this.backendStarted) {
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Backend port not available yet');
return false;
}
try {
const isHealthy = await invoke<boolean>('check_backend_health');
const isHealthy = await invoke<boolean>('check_backend_health', { port: this.backendPort });
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
console.error('Health check failed:', error);
const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Bundled backend health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
@@ -115,6 +205,18 @@ export class TauriBackendService {
this.setStatus('unhealthy');
throw new Error('Backend failed to become healthy after 60 seconds');
}
/**
* Reset backend state (used when switching from external to local backend)
*/
reset(): void {
console.log('[TauriBackendService] Resetting backend state');
this.backendStarted = false;
this.backendPort = null;
this.setStatus('stopped');
this.healthMonitor = null;
this.startPromise = null;
}
}
export const tauriBackendService = TauriBackendService.getInstance();