mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -5,7 +5,6 @@ import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
|
||||
import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters';
|
||||
import type { ResponseType } from 'axios';
|
||||
|
||||
export interface ShowJSOperationHook extends ToolOperationHook<ShowJSParameters> {
|
||||
scriptText: string | null;
|
||||
@@ -71,8 +70,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => {
|
||||
|
||||
const response = await apiClient.post('/api/v1/misc/show-javascript', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'text' as ResponseType,
|
||||
transformResponse: [(data) => data],
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
const text: string = typeof response.data === 'string' ? response.data : '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||
import { SetupWizard } from '@app/components/SetupWizard';
|
||||
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
|
||||
/**
|
||||
* Desktop application providers
|
||||
* Wraps proprietary providers and adds desktop-specific configuration
|
||||
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
|
||||
* - Shows setup wizard on first launch
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
|
||||
|
||||
// Load connection mode on mount
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
// Initialize backend health monitoring for server mode
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
|
||||
console.log('[AppProviders] Initializing external backend monitoring for server mode');
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
}
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Only start bundled backend if in offline mode and setup is complete
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
|
||||
useBackendInitializer(shouldStartBackend);
|
||||
|
||||
// Show setup wizard on first launch
|
||||
if (isFirstLaunch && !setupComplete) {
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
// Reload the page to reinitialize with new connection config
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal app flow
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
connectionModeService,
|
||||
ConnectionConfig,
|
||||
ServerConfig,
|
||||
} from '@app/services/connectionModeService';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
export const ConnectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<ConnectionConfig | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showServerModal, setShowServerModal] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
|
||||
|
||||
// Load current config on mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(currentConfig);
|
||||
|
||||
if (currentConfig.mode === 'server') {
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleSwitchToOffline = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await connectionModeService.switchToOffline();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Reload the page to start the local backend
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to switch to offline:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToServer = () => {
|
||||
setShowServerModal(true);
|
||||
};
|
||||
|
||||
const handleServerConfigSubmit = (serverConfig: ServerConfig) => {
|
||||
setNewServerConfig(serverConfig);
|
||||
setShowServerModal(false);
|
||||
setShowLoginModal(true);
|
||||
};
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
if (!newServerConfig) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Login
|
||||
await authService.login(newServerConfig.url, username, password);
|
||||
|
||||
// Switch to server mode
|
||||
await connectionModeService.switchToServer(newServerConfig);
|
||||
|
||||
// Reload config and user info
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
|
||||
setShowLoginModal(false);
|
||||
setNewServerConfig(null);
|
||||
|
||||
// Reload the page to stop local backend and initialize external backend monitoring
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error; // Let LoginForm handle the error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await authService.logout();
|
||||
|
||||
// Switch to offline mode
|
||||
await connectionModeService.switchToOffline();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Reload the page to clear all state and reconnect to local backend
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return <Text>{t('common.loading', 'Loading...')}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
|
||||
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
|
||||
{config.mode === 'offline'
|
||||
? t('settings.connection.mode.offline', 'Offline')
|
||||
: t('settings.connection.mode.server', 'Server')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{config.mode === 'server' && config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('settings.connection.server', 'Server')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{config.server_config.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{userInfo && (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('settings.connection.user', 'Logged in as')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{userInfo.username}
|
||||
{userInfo.email && ` (${userInfo.email})`}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group mt="md">
|
||||
{config.mode === 'offline' ? (
|
||||
<Button onClick={handleSwitchToServer} disabled={loading}>
|
||||
{t('settings.connection.switchToServer', 'Connect to Server')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
|
||||
{t('settings.connection.switchToOffline', 'Switch to Offline')}
|
||||
</Button>
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Logout')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Server selection modal */}
|
||||
<Modal
|
||||
opened={showServerModal}
|
||||
onClose={() => setShowServerModal(false)}
|
||||
title={t('settings.connection.selectServer', 'Select Server')}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
|
||||
</Modal>
|
||||
|
||||
{/* Login modal */}
|
||||
<Modal
|
||||
opened={showLoginModal}
|
||||
onClose={() => {
|
||||
setShowLoginModal(false);
|
||||
setNewServerConfig(null);
|
||||
}}
|
||||
title={t('settings.connection.login', 'Login')}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{newServerConfig && (
|
||||
<LoginForm
|
||||
serverUrl={newServerConfig.url}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Mini server selection component for settings
|
||||
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
|
||||
|
||||
if (!url) {
|
||||
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isReachable = await connectionModeService.testConnection(url);
|
||||
|
||||
if (!isReachable) {
|
||||
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
url,
|
||||
server_type: serverType,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
|
||||
<Stack gap="xs">
|
||||
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
|
||||
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{serverType === 'selfhosted' && (
|
||||
<TextInput
|
||||
label={t('setup.server.url.label', 'Server URL')}
|
||||
placeholder="https://your-server.com"
|
||||
value={customUrl}
|
||||
onChange={(e) => {
|
||||
setCustomUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={testing}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && !customUrl && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSubmit} loading={testing} fullWidth>
|
||||
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginFormProps {
|
||||
serverUrl: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t('setup.login.username.label', 'Username')}
|
||||
placeholder={t('setup.login.username.placeholder', 'Enter your username')}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('setup.login.password.label', 'Password')}
|
||||
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<Text c="red" size="sm">
|
||||
{validationError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
mt="md"
|
||||
fullWidth
|
||||
color="#AF3434"
|
||||
>
|
||||
{t('setup.login.submit', 'Login')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Stack, Button, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface ModeSelectionProps {
|
||||
onSelect: (mode: 'offline' | 'server') => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('offline')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.offline.title', 'Use Offline')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('server')}
|
||||
disabled={loading}
|
||||
leftSection={<CloudIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Button, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerConfig } from '@app/services/connectionModeService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
|
||||
interface ServerSelectionProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = customUrl.trim();
|
||||
|
||||
if (!url) {
|
||||
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection before proceeding
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
|
||||
try {
|
||||
const isReachable = await connectionModeService.testConnection(url);
|
||||
|
||||
if (!isReachable) {
|
||||
setTestError(t('setup.server.error.unreachable', 'Could not connect to server'));
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection successful
|
||||
onSelect({
|
||||
url,
|
||||
server_type: 'selfhosted',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
setTestError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('setup.server.error.testFailed', 'Connection test failed')
|
||||
);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('setup.server.url.label', 'Server URL')}
|
||||
placeholder="https://your-server.com"
|
||||
value={customUrl}
|
||||
onChange={(e) => {
|
||||
setCustomUrl(e.target.value);
|
||||
setTestError(null);
|
||||
}}
|
||||
disabled={loading || testing}
|
||||
error={testError}
|
||||
description={t(
|
||||
'setup.server.url.description',
|
||||
'Enter the full URL of your self-hosted Stirling PDF server'
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={testing || loading}
|
||||
disabled={loading}
|
||||
mt="md"
|
||||
fullWidth
|
||||
color="#AF3434"
|
||||
>
|
||||
{testing
|
||||
? t('setup.server.testing', 'Testing connection...')
|
||||
: t('common.continue', 'Continue')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
.setup-container {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.setup-wrapper {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background-color: white;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
|
||||
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import '@app/components/SetupWizard/SetupWizard.css';
|
||||
|
||||
enum SetupStep {
|
||||
ModeSelection,
|
||||
ServerSelection,
|
||||
Login,
|
||||
}
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
|
||||
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleModeSelection = (mode: 'offline' | 'server') => {
|
||||
setSelectedMode(mode);
|
||||
setError(null);
|
||||
|
||||
if (mode === 'offline') {
|
||||
handleOfflineSetup();
|
||||
} else {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOfflineSetup = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await connectionModeService.switchToOffline();
|
||||
await tauriBackendService.startBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to set up offline mode:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.Login);
|
||||
};
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No server configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToServer(serverConfig);
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.Login) {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setSelectedMode(null);
|
||||
setServerConfig(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepTitle = () => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.title', 'Connect to Server');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.title', 'Sign In');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepSubtitle = () => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.subtitle', 'Enter your credentials to continue');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="setup-container">
|
||||
<Container size="sm" className="setup-wrapper">
|
||||
<Paper shadow="xl" p="xl" radius="lg" className="setup-card">
|
||||
<Stack gap="lg">
|
||||
{/* Logo Header */}
|
||||
<Stack gap="xs" align="center">
|
||||
<Image
|
||||
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
|
||||
alt="Stirling PDF"
|
||||
h={32}
|
||||
fit="contain"
|
||||
/>
|
||||
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
||||
{getStepTitle()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{getStepSubtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Paper p="md" bg="red.0" style={{ border: '1px solid var(--mantine-color-red-3)' }}>
|
||||
<Text size="sm" c="red.7" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
{activeStep === SetupStep.ModeSelection && (
|
||||
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.Login && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
{activeStep > SetupStep.ModeSelection && !loading && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleBack}
|
||||
fullWidth
|
||||
mt="md"
|
||||
>
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createConfigNavSections as createProprietaryConfigNavSections } from '@proprietary/components/shared/config/configNavSections';
|
||||
import { ConfigNavSection } from '@core/components/shared/config/configNavSections';
|
||||
import { ConnectionSettings } from '@app/components/ConnectionSettings';
|
||||
|
||||
/**
|
||||
* Desktop extension of createConfigNavSections that adds connection settings
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||
const sections = createProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
|
||||
// Add Connection section at the beginning (after Preferences)
|
||||
sections.splice(1, 0, {
|
||||
title: 'Connection',
|
||||
items: [
|
||||
{
|
||||
key: 'connectionMode',
|
||||
label: 'Connection Mode',
|
||||
icon: 'cloud-rounded',
|
||||
component: <ConnectionSettings />,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return sections;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/types';
|
||||
|
||||
export const VALID_NAV_KEYS = [
|
||||
...CORE_NAV_KEYS,
|
||||
'connectionMode',
|
||||
] as const;
|
||||
|
||||
export type NavKey = typeof VALID_NAV_KEYS[number];
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Connection-related constants for desktop app
|
||||
*/
|
||||
|
||||
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Desktop-specific API client using Tauri's native HTTP client
|
||||
* This file overrides @core/services/apiClient.ts for desktop builds
|
||||
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
|
||||
*/
|
||||
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { create } from '@app/services/tauriHttpClient';
|
||||
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||
import { setupApiInterceptors } from '@app/services/apiClientSetup';
|
||||
import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
||||
|
||||
// Create Tauri HTTP client with default config
|
||||
const apiClient = create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Setup interceptors (desktop-specific auth and backend ready checks)
|
||||
// Cast to AxiosInstance - Tauri client has compatible API
|
||||
setupApiInterceptors(apiClient as unknown as AxiosInstance);
|
||||
|
||||
// ---------- Install error interceptor ----------
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ---------- Exports ----------
|
||||
export default apiClient;
|
||||
@@ -2,16 +2,19 @@ import { isTauri } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* Desktop override: Determine base URL depending on Tauri environment
|
||||
*
|
||||
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
|
||||
* based on connection mode and backend port. This initial baseURL is overridden
|
||||
* by request interceptors in apiClientSetup.ts.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (!isTauri()) {
|
||||
return import.meta.env.VITE_API_BASE_URL || '/';
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight
|
||||
return '/';
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080';
|
||||
// In Tauri mode, return empty string as placeholder
|
||||
// The actual URL will be set dynamically by operationRouter based on:
|
||||
// - Offline mode: dynamic port from tauriBackendService
|
||||
// - Server mode: configured server URL from connectionModeService
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,44 +1,134 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { createBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import { operationRouter } from '@app/services/operationRouter';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||
let lastBackendToast = 0;
|
||||
|
||||
// Extended config for custom properties
|
||||
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
||||
operationName?: string;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-specific API interceptors
|
||||
* - Reuses the core interceptors
|
||||
* - Blocks API calls while the bundled backend is still starting and shows
|
||||
* a friendly toast for user-initiated requests (non-GET)
|
||||
* - Dynamically sets base URL based on connection mode
|
||||
* - Adds auth token for remote server requests
|
||||
* - Blocks API calls while the bundled backend is still starting
|
||||
* - Handles auth token refresh on 401 errors
|
||||
*/
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
coreSetup(client);
|
||||
|
||||
// Request interceptor: Set base URL and auth headers dynamically
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const skipCheck = config?.skipBackendReadyCheck === true;
|
||||
if (skipCheck || tauriBackendService.isBackendHealthy()) {
|
||||
return config;
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const extendedConfig = config as ExtendedRequestConfig;
|
||||
|
||||
// Get the operation name from config if provided
|
||||
const operation = extendedConfig.operationName;
|
||||
|
||||
// Get the appropriate base URL for this operation
|
||||
const baseUrl = await operationRouter.getBaseUrl(operation);
|
||||
|
||||
// Build the full URL
|
||||
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
|
||||
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
||||
}
|
||||
|
||||
const method = (config.method || 'get').toLowerCase();
|
||||
if (method !== 'get') {
|
||||
const now = Date.now();
|
||||
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||
lastBackendToast = now;
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
// Debug logging
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
// Add auth token for remote requests
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
if (isRemote) {
|
||||
const token = await authService.getAuthToken();
|
||||
if (token) {
|
||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(createBackendNotReadyError());
|
||||
// Backend readiness check (for local backend)
|
||||
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
||||
const isOffline = await operationRouter.isOfflineMode();
|
||||
|
||||
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
|
||||
const method = (extendedConfig.method || 'get').toLowerCase();
|
||||
if (method !== 'get') {
|
||||
const now = Date.now();
|
||||
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||
lastBackendToast = now;
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.reject(createBackendNotReadyError());
|
||||
}
|
||||
|
||||
return extendedConfig;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor: Handle auth errors
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config as ExtendedRequestConfig;
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
if (isRemote) {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (serverConfig) {
|
||||
const refreshed = await authService.refreshToken(serverConfig.url);
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
const token = await authService.getAuthToken();
|
||||
if (token) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return client(originalRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed or not in remote mode - user needs to login again
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('auth.sessionExpired', 'Session Expired'),
|
||||
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden - unauthorized access
|
||||
if (error.response?.status === 403) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: i18n.t('auth.accessDenied', 'Access Denied'),
|
||||
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing';
|
||||
|
||||
export class AuthService {
|
||||
private static instance: AuthService;
|
||||
private authStatus: AuthStatus = 'unauthenticated';
|
||||
private userInfo: UserInfo | null = null;
|
||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||
|
||||
static getInstance(): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
AuthService.instance = new AuthService();
|
||||
}
|
||||
return AuthService.instance;
|
||||
}
|
||||
|
||||
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
|
||||
this.authListeners.add(listener);
|
||||
// Immediately notify new listener of current state
|
||||
listener(this.authStatus, this.userInfo);
|
||||
return () => {
|
||||
this.authListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
|
||||
}
|
||||
|
||||
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
|
||||
this.authStatus = status;
|
||||
this.userInfo = userInfo;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
|
||||
try {
|
||||
console.log('Logging in to:', serverUrl);
|
||||
|
||||
// Call Rust login command (bypasses CORS)
|
||||
const response = await invoke<LoginResponse>('login', {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
const { token, username: returnedUsername, email } = response;
|
||||
|
||||
// Save the token to keyring
|
||||
await invoke('save_auth_token', { token });
|
||||
|
||||
// Save user info to store
|
||||
await invoke('save_user_info', {
|
||||
username: returnedUsername || username,
|
||||
email,
|
||||
});
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
username: returnedUsername || username,
|
||||
email: email || undefined,
|
||||
};
|
||||
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
|
||||
console.log('Login successful');
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
|
||||
// Rust commands return string errors
|
||||
if (typeof error === 'string') {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
throw new Error('Login failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
console.log('Logging out');
|
||||
|
||||
// Clear token from keyring
|
||||
await invoke('clear_auth_token');
|
||||
|
||||
// Clear user info from store
|
||||
await invoke('clear_user_info');
|
||||
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
|
||||
console.log('Logged out successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Still set status to unauthenticated even if clear fails
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
try {
|
||||
const token = await invoke<string | null>('get_auth_token');
|
||||
return token || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const token = await this.getAuthToken();
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
async getUserInfo(): Promise<UserInfo | null> {
|
||||
if (this.userInfo) {
|
||||
return this.userInfo;
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = await invoke<UserInfo | null>('get_user_info');
|
||||
this.userInfo = userInfo;
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user info:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('Refreshing auth token');
|
||||
this.setAuthStatus('refreshing', this.userInfo);
|
||||
|
||||
const currentToken = await this.getAuthToken();
|
||||
if (!currentToken) {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Call the server's refresh endpoint
|
||||
const response = await axios.post(
|
||||
`${serverUrl}/api/v1/auth/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { token } = response.data;
|
||||
|
||||
// Save the new token
|
||||
await invoke('save_auth_token', { token });
|
||||
|
||||
const userInfo = await this.getUserInfo();
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
|
||||
console.log('Token refreshed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
|
||||
// Clear stored credentials on refresh failure
|
||||
await this.logout();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initializeAuthState(): Promise<void> {
|
||||
const token = await this.getAuthToken();
|
||||
const userInfo = await this.getUserInfo();
|
||||
|
||||
if (token && userInfo) {
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
} else {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = AuthService.getInstance();
|
||||
@@ -0,0 +1,131 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export type ConnectionMode = 'offline' | 'server';
|
||||
export type ServerType = 'saas' | 'selfhosted';
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
server_type: ServerType;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
mode: ConnectionMode;
|
||||
server_config: ServerConfig | null;
|
||||
}
|
||||
|
||||
export class ConnectionModeService {
|
||||
private static instance: ConnectionModeService;
|
||||
private currentConfig: ConnectionConfig | null = null;
|
||||
private configLoadedOnce = false;
|
||||
private modeListeners = new Set<(config: ConnectionConfig) => void>();
|
||||
|
||||
static getInstance(): ConnectionModeService {
|
||||
if (!ConnectionModeService.instance) {
|
||||
ConnectionModeService.instance = new ConnectionModeService();
|
||||
}
|
||||
return ConnectionModeService.instance;
|
||||
}
|
||||
|
||||
async getCurrentConfig(): Promise<ConnectionConfig> {
|
||||
if (!this.configLoadedOnce) {
|
||||
await this.loadConfig();
|
||||
}
|
||||
return this.currentConfig || { mode: 'offline', server_config: null };
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<ConnectionMode> {
|
||||
const config = await this.getCurrentConfig();
|
||||
return config.mode;
|
||||
}
|
||||
|
||||
async getServerConfig(): Promise<ServerConfig | null> {
|
||||
const config = await this.getCurrentConfig();
|
||||
return config.server_config;
|
||||
}
|
||||
|
||||
subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void {
|
||||
this.modeListeners.add(listener);
|
||||
return () => {
|
||||
this.modeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
if (this.currentConfig) {
|
||||
this.modeListeners.forEach(listener => listener(this.currentConfig!));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await invoke<ConnectionConfig>('get_connection_config');
|
||||
this.currentConfig = config;
|
||||
this.configLoadedOnce = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load connection config:', error);
|
||||
// Default to offline mode on error
|
||||
this.currentConfig = { mode: 'offline', server_config: null };
|
||||
this.configLoadedOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
async switchToOffline(): Promise<void> {
|
||||
console.log('Switching to offline mode');
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'offline',
|
||||
serverConfig: null,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'offline', server_config: null };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to offline mode successfully');
|
||||
}
|
||||
|
||||
async switchToServer(serverConfig: ServerConfig): Promise<void> {
|
||||
console.log('Switching to server mode:', serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'server',
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'server', server_config: serverConfig };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to server mode successfully');
|
||||
}
|
||||
|
||||
async testConnection(url: string): Promise<boolean> {
|
||||
console.log(`[ConnectionModeService] Testing connection to: ${url}`);
|
||||
try {
|
||||
// Test connection by hitting the health/status endpoint
|
||||
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
const isOk = response.ok;
|
||||
console.log(`[ConnectionModeService] Server connection test result: ${isOk}`);
|
||||
return isOk;
|
||||
} catch (error) {
|
||||
console.warn('[ConnectionModeService] Server connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isFirstLaunch(): Promise<boolean> {
|
||||
try {
|
||||
const result = await invoke<boolean>('is_first_launch');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to check first launch:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionModeService = ConnectionModeService.getInstance();
|
||||
@@ -0,0 +1,99 @@
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
|
||||
export type ExecutionTarget = 'local' | 'remote';
|
||||
|
||||
export class OperationRouter {
|
||||
private static instance: OperationRouter;
|
||||
|
||||
static getInstance(): OperationRouter {
|
||||
if (!OperationRouter.instance) {
|
||||
OperationRouter.instance = new OperationRouter();
|
||||
}
|
||||
return OperationRouter.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines where an operation should execute
|
||||
* @param _operation - The operation name (for future operation classification)
|
||||
* @returns 'local' or 'remote'
|
||||
*/
|
||||
async getExecutionTarget(_operation?: string): Promise<ExecutionTarget> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Current implementation: simple mode-based routing
|
||||
if (mode === 'offline') {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// In server mode, currently all operations go to remote
|
||||
// Future enhancement: check if operation is "simple" and route to local if so
|
||||
// Example future logic:
|
||||
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) {
|
||||
// return 'local';
|
||||
// }
|
||||
|
||||
return 'remote';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base URL for an operation based on execution target
|
||||
* @param _operation - The operation name (for future operation classification)
|
||||
* @returns Base URL for API calls
|
||||
*/
|
||||
async getBaseUrl(_operation?: string): Promise<string> {
|
||||
const target = await this.getExecutionTarget(_operation);
|
||||
|
||||
if (target === 'local') {
|
||||
// Use dynamically assigned port from backend service
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
if (!backendUrl) {
|
||||
throw new Error('Backend URL not available - backend may still be starting');
|
||||
}
|
||||
// Strip trailing slash to avoid double slashes in URLs
|
||||
return backendUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Remote: get from server config
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
console.warn('No server config found');
|
||||
throw new Error('Server configuration not found');
|
||||
}
|
||||
|
||||
// Strip trailing slash to avoid double slashes in URLs
|
||||
return serverConfig.url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently in remote mode
|
||||
*/
|
||||
async isRemoteMode(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
return mode === 'server';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently in offline mode
|
||||
*/
|
||||
async isOfflineMode(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
return mode === 'offline';
|
||||
}
|
||||
|
||||
// Future enhancement: operation classification
|
||||
// private isSimpleOperation(operation: string): boolean {
|
||||
// const simpleOperations = [
|
||||
// 'rotate',
|
||||
// 'merge',
|
||||
// 'split',
|
||||
// 'extract-pages',
|
||||
// 'remove-pages',
|
||||
// 'reorder-pages',
|
||||
// 'metadata',
|
||||
// ];
|
||||
// return simpleOperations.includes(operation);
|
||||
// }
|
||||
}
|
||||
|
||||
export const operationRouter = OperationRouter.getInstance();
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
/**
|
||||
* Tauri HTTP Client - wrapper around Tauri's native HTTP client
|
||||
* Provides axios-compatible API while bypassing CORS restrictions
|
||||
*/
|
||||
|
||||
export interface TauriHttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
config: TauriHttpRequestConfig;
|
||||
}
|
||||
|
||||
export interface TauriHttpRequestConfig {
|
||||
url?: string;
|
||||
method?: string;
|
||||
baseURL?: string;
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, string | number | boolean> | any;
|
||||
data?: any;
|
||||
timeout?: number;
|
||||
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
|
||||
withCredentials?: boolean;
|
||||
// Custom properties for desktop
|
||||
operationName?: string;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
// Axios compatibility properties (ignored by Tauri HTTP)
|
||||
suppressErrorToast?: boolean;
|
||||
cancelToken?: any;
|
||||
}
|
||||
|
||||
export interface TauriHttpError extends Error {
|
||||
config?: TauriHttpRequestConfig;
|
||||
code?: string;
|
||||
request?: unknown;
|
||||
response?: TauriHttpResponse;
|
||||
isAxiosError: boolean;
|
||||
toJSON: () => object;
|
||||
}
|
||||
|
||||
type RequestInterceptor = (config: TauriHttpRequestConfig) => Promise<TauriHttpRequestConfig> | TauriHttpRequestConfig;
|
||||
type ResponseInterceptor<T = any> = (response: TauriHttpResponse<T>) => Promise<TauriHttpResponse<T>> | TauriHttpResponse<T>;
|
||||
type ErrorInterceptor = (error: any) => Promise<any>;
|
||||
|
||||
interface Interceptors {
|
||||
request: {
|
||||
handlers: RequestInterceptor[];
|
||||
use: (onFulfilled: RequestInterceptor, onRejected?: ErrorInterceptor) => number;
|
||||
};
|
||||
response: {
|
||||
handlers: { fulfilled: ResponseInterceptor; rejected?: ErrorInterceptor }[];
|
||||
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => number;
|
||||
};
|
||||
}
|
||||
|
||||
class TauriHttpClient {
|
||||
public defaults: TauriHttpRequestConfig = {
|
||||
baseURL: '',
|
||||
headers: {},
|
||||
timeout: 120000,
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
};
|
||||
|
||||
public interceptors: Interceptors = {
|
||||
request: {
|
||||
handlers: [],
|
||||
use: (onFulfilled: RequestInterceptor, _onRejected?: ErrorInterceptor) => {
|
||||
this.interceptors.request.handlers.push(onFulfilled);
|
||||
return this.interceptors.request.handlers.length - 1;
|
||||
},
|
||||
},
|
||||
response: {
|
||||
handlers: [],
|
||||
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => {
|
||||
this.interceptors.response.handlers.push({ fulfilled: onFulfilled, rejected: onRejected });
|
||||
return this.interceptors.response.handlers.length - 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
constructor(config?: TauriHttpRequestConfig) {
|
||||
if (config) {
|
||||
this.defaults = { ...this.defaults, ...config };
|
||||
}
|
||||
}
|
||||
|
||||
private createError(message: string, config?: TauriHttpRequestConfig, code?: string, response?: TauriHttpResponse): TauriHttpError {
|
||||
const error = new Error(message) as TauriHttpError;
|
||||
error.config = config;
|
||||
error.code = code;
|
||||
error.response = response;
|
||||
error.isAxiosError = true;
|
||||
error.toJSON = () => ({
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
config: error.config,
|
||||
code: error.code,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
private buildUrl(config: TauriHttpRequestConfig): string {
|
||||
let url = config.url || '';
|
||||
|
||||
// If URL is already absolute, use it as-is
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Prepend baseURL if present
|
||||
const baseURL = config.baseURL || this.defaults.baseURL || '';
|
||||
if (baseURL) {
|
||||
url = baseURL + url;
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
if (config.params && typeof config.params === 'object') {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(config.params as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = searchParams.toString();
|
||||
if (queryString) {
|
||||
url += (url.includes('?') ? '&' : '?') + queryString;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private async executeRequest<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
// Merge with defaults
|
||||
const mergedConfig: TauriHttpRequestConfig = {
|
||||
...this.defaults,
|
||||
...config,
|
||||
headers: {
|
||||
...this.defaults.headers,
|
||||
...config.headers,
|
||||
},
|
||||
};
|
||||
|
||||
// Run request interceptors
|
||||
let finalConfig = mergedConfig;
|
||||
for (const interceptor of this.interceptors.request.handlers) {
|
||||
finalConfig = await Promise.resolve(interceptor(finalConfig));
|
||||
}
|
||||
|
||||
const url = this.buildUrl(finalConfig);
|
||||
const method = (finalConfig.method || 'GET').toUpperCase();
|
||||
|
||||
// Prepare request body and headers
|
||||
let body: BodyInit | undefined;
|
||||
const headers: Record<string, string> = { ...(finalConfig.headers || {}) };
|
||||
|
||||
if (finalConfig.data) {
|
||||
if (finalConfig.data instanceof FormData) {
|
||||
// FormData can be passed directly
|
||||
body = finalConfig.data;
|
||||
} else if (typeof finalConfig.data === 'object') {
|
||||
// Serialize as JSON
|
||||
body = JSON.stringify(finalConfig.data);
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
} else {
|
||||
body = String(finalConfig.data);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Debug logging
|
||||
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
|
||||
|
||||
// Make the request using Tauri's native HTTP client (standard Fetch API)
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
// Parse response based on responseType
|
||||
let data: T;
|
||||
const responseType = finalConfig.responseType || 'json';
|
||||
|
||||
if (responseType === 'json') {
|
||||
data = await response.json() as T;
|
||||
} else if (responseType === 'text') {
|
||||
data = (await response.text()) as T;
|
||||
} else if (responseType === 'blob') {
|
||||
// Standard fetch doesn't set blob.type from Content-Type header (unlike axios)
|
||||
// Set it manually to match axios behavior
|
||||
const blob = await response.blob();
|
||||
if (!blob.type) {
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
data = new Blob([blob], { type: contentType }) as T;
|
||||
} else {
|
||||
data = blob as T;
|
||||
}
|
||||
} else if (responseType === 'arraybuffer') {
|
||||
data = (await response.arrayBuffer()) as T;
|
||||
} else {
|
||||
data = await response.json() as T;
|
||||
}
|
||||
|
||||
// Convert Headers to plain object
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
const httpResponse: TauriHttpResponse<T> = {
|
||||
data,
|
||||
status: response.status,
|
||||
statusText: response.statusText || '',
|
||||
headers: responseHeaders,
|
||||
config: finalConfig,
|
||||
};
|
||||
|
||||
// Check for HTTP errors
|
||||
if (!response.ok) {
|
||||
const error = this.createError(
|
||||
`Request failed with status code ${response.status}`,
|
||||
finalConfig,
|
||||
'ERR_BAD_REQUEST',
|
||||
httpResponse
|
||||
);
|
||||
|
||||
// Run error interceptors
|
||||
let finalError: unknown = error;
|
||||
for (const handler of this.interceptors.response.handlers) {
|
||||
if (handler.rejected) {
|
||||
try {
|
||||
finalError = await Promise.resolve(handler.rejected(finalError));
|
||||
} catch (e) {
|
||||
finalError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Run response interceptors
|
||||
let finalResponse = httpResponse;
|
||||
for (const handler of this.interceptors.response.handlers) {
|
||||
finalResponse = await Promise.resolve(handler.fulfilled(finalResponse)) as TauriHttpResponse<T>;
|
||||
}
|
||||
|
||||
return finalResponse;
|
||||
} catch (error: unknown) {
|
||||
// If it's already a TauriHttpError with interceptors run, re-throw
|
||||
if (error && typeof error === 'object' && 'isAxiosError' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create new error for network/other failures
|
||||
const errorMessage = error instanceof Error ? error.message : 'Network Error';
|
||||
const httpError = this.createError(
|
||||
errorMessage,
|
||||
finalConfig,
|
||||
'ERR_NETWORK'
|
||||
);
|
||||
|
||||
// Run error interceptors
|
||||
let finalError: unknown = httpError;
|
||||
for (const handler of this.interceptors.response.handlers) {
|
||||
if (handler.rejected) {
|
||||
try {
|
||||
finalError = await Promise.resolve(handler.rejected(finalError));
|
||||
} catch (e) {
|
||||
finalError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw finalError;
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>(config);
|
||||
}
|
||||
|
||||
async get<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'GET', url });
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'DELETE', url });
|
||||
}
|
||||
|
||||
async head<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'HEAD', url });
|
||||
}
|
||||
|
||||
async options<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'OPTIONS', url });
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'POST', url, data });
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'PUT', url, data });
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
return this.executeRequest<T>({ ...config, method: 'PATCH', url, data });
|
||||
}
|
||||
|
||||
// Axios compatibility methods
|
||||
create(config?: TauriHttpRequestConfig): TauriHttpClient {
|
||||
return new TauriHttpClient({ ...this.defaults, ...config });
|
||||
}
|
||||
|
||||
getUri(config?: TauriHttpRequestConfig): string {
|
||||
return this.buildUrl({ ...this.defaults, ...config });
|
||||
}
|
||||
|
||||
async postForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
const formData = data instanceof FormData ? data : new FormData();
|
||||
if (!(data instanceof FormData) && data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
return this.post<T>(url, formData, config);
|
||||
}
|
||||
|
||||
async putForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
const formData = data instanceof FormData ? data : new FormData();
|
||||
if (!(data instanceof FormData) && data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
return this.put<T>(url, formData, config);
|
||||
}
|
||||
|
||||
async patchForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||
const formData = data instanceof FormData ? data : new FormData();
|
||||
if (!(data instanceof FormData) && data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
return this.patch<T>(url, formData, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function matching axios.create()
|
||||
export function create(config?: TauriHttpRequestConfig): TauriHttpClient {
|
||||
return new TauriHttpClient(config);
|
||||
}
|
||||
|
||||
// Default instance
|
||||
export default new TauriHttpClient();
|
||||
Reference in New Issue
Block a user