Allow login to SaaS for desktop instead of offline mode (#4941)

# Description of Changes
Makes the desktop options to sign in with your Stirling account, or sign
into self-hosted:

<img width="608" height="456" alt="image"
src="https://github.com/user-attachments/assets/a49988ab-db3f-4333-b242-790aee5c07c6"
/>

The first option still runs everything locally, just enforces that
you've signed in for now. Future work will enable sending operations
that can't be run locally to the server.
This commit is contained in:
James Brunton
2025-11-22 00:38:59 +00:00
committed by GitHub
parent e1a879a5f6
commit e8e98128d2
21 changed files with 382 additions and 397 deletions
+2
View File
@@ -312,6 +312,8 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }} SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true CI: true
with: with:
+17 -13
View File
@@ -5623,15 +5623,23 @@
"description": "Enter credentials" "description": "Enter credentials"
}, },
"mode": { "mode": {
"offline": { "saas": {
"title": "Use Offline", "title": "Stirling Cloud",
"description": "Run locally without an internet connection" "description": "Sign in with your Stirling account"
}, },
"server": { "selfhosted": {
"title": "Connect to Server", "title": "Self-Hosted Server",
"description": "Connect to a remote Stirling PDF server" "description": "Connect to your own Stirling PDF server"
} }
}, },
"saas": {
"title": "Sign in to Stirling",
"subtitle": "Sign in with your Stirling account"
},
"selfhosted": {
"title": "Sign in to Server",
"subtitle": "Enter your server credentials"
},
"server": { "server": {
"title": "Connect to Server", "title": "Connect to Server",
"subtitle": "Enter your self-hosted server URL", "subtitle": "Enter your self-hosted server URL",
@@ -5673,16 +5681,12 @@
"connection": { "connection": {
"title": "Connection Mode", "title": "Connection Mode",
"mode": { "mode": {
"offline": "Offline", "saas": "Stirling Cloud",
"server": "Server" "selfhosted": "Self-Hosted"
}, },
"server": "Server", "server": "Server",
"user": "Logged in as", "user": "Logged in as",
"switchToServer": "Connect to Server", "logout": "Log Out"
"switchToOffline": "Switch to Offline",
"logout": "Logout",
"selectServer": "Select Server",
"login": "Login"
}, },
"general": { "general": {
"title": "General", "title": "General",
+98 -10
View File
@@ -127,7 +127,7 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
// Response types for Spring Boot login // Response types for Spring Boot login (self-hosted)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct SpringBootSession { struct SpringBootSession {
access_token: String, access_token: String,
@@ -145,6 +145,24 @@ struct SpringBootLoginResponse {
user: SpringBootUser, user: SpringBootUser,
} }
// Response types for Supabase login (SaaS)
#[derive(Debug, Deserialize)]
struct SupabaseUserMetadata {
full_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SupabaseUser {
email: Option<String>,
user_metadata: Option<SupabaseUserMetadata>,
}
#[derive(Debug, Deserialize)]
struct SupabaseLoginResponse {
access_token: String,
user: SupabaseUser,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct LoginResponse { pub struct LoginResponse {
pub token: String, pub token: String,
@@ -153,7 +171,7 @@ pub struct LoginResponse {
} }
/// Login command - makes HTTP request from Rust to bypass CORS /// Login command - makes HTTP request from Rust to bypass CORS
/// Supports Spring Boot authentication (self-hosted) /// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted)
#[tauri::command] #[tauri::command]
pub async fn login( pub async fn login(
server_url: String, server_url: String,
@@ -162,14 +180,83 @@ pub async fn login(
) -> Result<LoginResponse, String> { ) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url); log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Build login URL // Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/')); // Compare against the configured SaaS server URL from environment
log::debug!("Login URL: {}", login_url); let saas_server_url = env!("VITE_SAAS_SERVER_URL");
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
// Create HTTP client // Create HTTP client
let client = reqwest::Client::new(); let client = reqwest::Client::new();
// Make login request if is_supabase {
// Supabase authentication flow
let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/'));
// Supabase public API key from environment variable (required at compile time)
// Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building
let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY");
let request_body = serde_json::json!({
"email": username,
"password": password,
});
let response = client
.post(&login_url)
.header("Content-Type", "application/json;charset=UTF-8")
.header("apikey", supabase_key)
.header("Authorization", format!("Bearer {}", supabase_key))
.header("X-Client-Info", "supabase-js-web/2.58.0")
.header("X-Supabase-Api-Version", "2024-01-01")
.json(&request_body)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Supabase login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 400 || status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Supabase response format
let login_response: SupabaseLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Supabase response: {}", e))?;
let email = login_response.user.email.clone();
let username = login_response.user.user_metadata
.as_ref()
.and_then(|m| m.full_name.clone())
.or_else(|| email.clone())
.unwrap_or_else(|| username);
log::info!("Supabase login successful for user: {}", username);
Ok(LoginResponse {
token: login_response.access_token,
username,
email,
})
} else {
// Spring Boot authentication flow
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Spring Boot login URL: {}", login_url);
let response = client let response = client
.post(&login_url) .post(&login_url)
.json(&serde_json::json!({ .json(&serde_json::json!({
@@ -181,14 +268,14 @@ pub async fn login(
.map_err(|e| format!("Network error: {}", e))?; .map_err(|e| format!("Network error: {}", e))?;
let status = response.status(); let status = response.status();
log::debug!("Login response status: {}", status); log::debug!("Spring Boot login response status: {}", status);
if !status.is_success() { if !status.is_success() {
let error_text = response let error_text = response
.text() .text()
.await .await
.unwrap_or_else(|_| "Unknown error".to_string()); .unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Login failed with status {}: {}", status, error_text); log::error!("Spring Boot login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 401 { return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string() "Invalid username or password".to_string()
@@ -203,13 +290,14 @@ pub async fn login(
let login_response: SpringBootLoginResponse = response let login_response: SpringBootLoginResponse = response
.json() .json()
.await .await
.map_err(|e| format!("Failed to parse response: {}", e))?; .map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
log::info!("Login successful for user: {}", login_response.user.username); log::info!("Spring Boot login successful for user: {}", login_response.user.username);
Ok(LoginResponse { Ok(LoginResponse {
token: login_response.session.access_token, token: login_response.session.access_token,
username: login_response.user.username, username: login_response.user.username,
email: login_response.user.email, email: login_response.user.email,
}) })
}
} }
+4 -4
View File
@@ -349,11 +349,11 @@ pub async fn start_backend(
}; };
match mode { match mode {
ConnectionMode::Offline => { ConnectionMode::SaaS => {
add_log("🔌 Running in Offline mode - starting local backend".to_string()); add_log("☁️ Running in SaaS mode - starting local backend".to_string());
} }
ConnectionMode::Server => { ConnectionMode::SelfHosted => {
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string()); add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
} }
} }
+20 -1
View File
@@ -31,7 +31,7 @@ pub async fn get_connection_config(
let mode = store let mode = store
.get(CONNECTION_MODE_KEY) .get(CONNECTION_MODE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok()) .and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ConnectionMode::Offline); .unwrap_or(ConnectionMode::SaaS);
let server_config: Option<ServerConfig> = store let server_config: Option<ServerConfig> = store
.get(SERVER_CONFIG_KEY) .get(SERVER_CONFIG_KEY)
@@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
Ok(!setup_completed) Ok(!setup_completed)
} }
#[tauri::command]
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
log::info!("Resetting setup completion flag");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
// Reset setup completion flag to force SetupWizard on next launch
store.set(FIRST_LAUNCH_KEY, serde_json::json!(false));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Setup completion flag reset successfully");
Ok(())
}
+1
View File
@@ -10,6 +10,7 @@ pub use files::{add_opened_file, clear_opened_files, get_opened_files};
pub use connection::{ pub use connection::{
get_connection_config, get_connection_config,
is_first_launch, is_first_launch,
reset_setup_completion,
set_connection_mode, set_connection_mode,
}; };
pub use auth::{ pub use auth::{
+2
View File
@@ -19,6 +19,7 @@ use commands::{
get_user_info, get_user_info,
is_first_launch, is_first_launch,
login, login,
reset_setup_completion,
save_auth_token, save_auth_token,
save_user_info, save_user_info,
set_connection_mode, set_connection_mode,
@@ -85,6 +86,7 @@ pub fn run() {
is_default_pdf_handler, is_default_pdf_handler,
set_as_default_pdf_handler, set_as_default_pdf_handler,
is_first_launch, is_first_launch,
reset_setup_completion,
check_backend_health, check_backend_health,
login, login,
save_auth_token, save_auth_token,
@@ -4,13 +4,6 @@ use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ConnectionMode { pub enum ConnectionMode {
Offline,
Server,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ServerType {
SaaS, SaaS,
SelfHosted, SelfHosted,
} }
@@ -18,7 +11,6 @@ pub enum ServerType {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig { pub struct ServerConfig {
pub url: String, pub url: String,
pub server_type: ServerType,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -30,7 +22,7 @@ pub struct ConnectionState {
impl Default for ConnectionState { impl Default for ConnectionState {
fn default() -> Self { fn default() -> Self {
Self { Self {
mode: ConnectionMode::Offline, mode: ConnectionMode::SaaS,
server_config: None, server_config: None,
} }
} }
@@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
*/ */
export function AppProviders({ children }: { children: ReactNode }) { export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck(); const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null); const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
// Load connection mode on mount // Load connection mode on mount
useEffect(() => { useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode); void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []); }, []);
// Initialize backend health monitoring for server mode // Initialize backend health monitoring for self-hosted mode
useEffect(() => { useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'server') { if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
console.log('[AppProviders] Initializing external backend monitoring for server mode');
void tauriBackendService.initializeExternalBackend(); void tauriBackendService.initializeExternalBackend();
} }
}, [setupComplete, isFirstLaunch, connectionMode]); }, [setupComplete, isFirstLaunch, connectionMode]);
// Only start bundled backend if in offline mode and setup is complete // Only start bundled backend if in SaaS mode (local backend) and setup is complete
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline'; // Self-hosted mode connects to remote server so doesn't need local backend
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldStartBackend); useBackendInitializer(shouldStartBackend);
// Show setup wizard on first launch // Show setup wizard on first launch
@@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) {
}} }}
> >
<SetupWizard <SetupWizard
onComplete={() => { onComplete={async () => {
// Reload the page to reinitialize with new connection config // Wait for backend to become healthy before reloading
// This prevents reloading mid-startup which would interrupt the backend
const maxWaitTime = 60000; // 60 seconds max
const checkInterval = 1000; // Check every second
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
if (tauriBackendService.isBackendHealthy()) {
window.location.reload();
return;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
// If we timeout, reload anyway
console.warn('[AppProviders] Backend health check timeout, reloading anyway...');
window.location.reload(); window.location.reload();
}} }}
/> />
@@ -1,24 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core'; import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
connectionModeService,
ConnectionConfig,
ServerConfig,
} from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService'; import { authService, UserInfo } from '@app/services/authService';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { STIRLING_SAAS_URL } from '@app/constants/connection'; import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
export const ConnectionSettings: React.FC = () => { export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [config, setConfig] = useState<ConnectionConfig | null>(null); const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null); const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false); 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 // Load current config on mount
useEffect(() => { useEffect(() => {
@@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
const currentConfig = await connectionModeService.getCurrentConfig(); const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig); setConfig(currentConfig);
if (currentConfig.mode === 'server') { if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
const user = await authService.getUserInfo(); const user = await authService.getUserInfo();
setUserInfo(user); setUserInfo(user);
} }
@@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => {
loadConfig(); 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 () => { const handleLogout = async () => {
try { try {
setLoading(true); setLoading(true);
await authService.logout(); await authService.logout();
// Switch to offline mode // Switch to SaaS mode
await connectionModeService.switchToOffline(); await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
// Reset setup completion to force login screen on reload
await connectionModeService.resetSetupCompletion();
// Reload config // Reload config
const newConfig = await connectionModeService.getCurrentConfig(); const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig); setConfig(newConfig);
setUserInfo(null); setUserInfo(null);
// Reload the page to clear all state and reconnect to local backend // Clear URL to home page before reload so we don't return to settings after re-login
window.history.replaceState({}, '', '/');
// Reload the page to clear all state and show login screen
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
@@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => {
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between"> <Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text> <Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light"> <Badge color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
{config.mode === 'offline' {config.mode === 'saas'
? t('settings.connection.mode.offline', 'Offline') ? t('settings.connection.mode.saas', 'Stirling Cloud')
: t('settings.connection.mode.server', 'Server')} : t('settings.connection.mode.selfhosted', 'Self-Hosted')}
</Badge> </Badge>
</Group> </Group>
{config.mode === 'server' && config.server_config && ( {(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
<> <>
<div> <div>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{t('settings.connection.server', 'Server')} {t('settings.connection.server', 'Server')}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{config.server_config.url} {config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
</Text> </Text>
</div> </div>
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => {
)} )}
<Group mt="md"> <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}> <Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Logout')} {t('settings.connection.logout', 'Log Out')}
</Button> </Button>
</>
)}
</Group> </Group>
</Stack> </Stack>
</Card> </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>
);
};
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
interface LoginFormProps { interface LoginFormProps {
serverUrl: string; serverUrl: string;
isSaaS?: boolean;
onLogin: (username: string, password: string) => Promise<void>; onLogin: (username: string, password: string) => Promise<void>;
loading: boolean; loading: boolean;
} }
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => { export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -36,7 +37,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loadin
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong> {t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
</Text> </Text>
<TextInput <TextInput
@@ -5,7 +5,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
import ComputerIcon from '@mui/icons-material/Computer'; import ComputerIcon from '@mui/icons-material/Computer';
interface ModeSelectionProps { interface ModeSelectionProps {
onSelect: (mode: 'offline' | 'server') => void; onSelect: (mode: 'saas' | 'selfhosted') => void;
loading: boolean; loading: boolean;
} }
@@ -17,31 +17,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
<Button <Button
size="xl" size="xl"
variant="default" variant="default"
onClick={() => onSelect('offline')} onClick={() => onSelect('saas')}
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} disabled={loading}
leftSection={<CloudIcon />} leftSection={<CloudIcon />}
styles={{ styles={{
@@ -52,12 +28,42 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
inner: { inner: {
justifyContent: 'flex-start', justifyContent: 'flex-start',
}, },
section: {
marginRight: '1rem',
},
}} }}
> >
<div style={{ textAlign: 'left', flex: 1 }}> <div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text> <Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
<Text size="sm" c="dimmed" fw={400}> <Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')} {t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
</Text>
</div>
</Button>
<Button
size="xl"
variant="default"
onClick={() => onSelect('selfhosted')}
disabled={loading}
leftSection={<ComputerIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
section: {
marginRight: '1rem',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
</Text> </Text>
</div> </div>
</Button> </Button>
@@ -41,7 +41,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Connection successful // Connection successful
onSelect({ onSelect({
url, url,
server_type: 'selfhosted',
}); });
} catch (error) { } catch (error) {
console.error('Connection test failed:', error); console.error('Connection test failed:', error);
@@ -7,13 +7,15 @@ import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService'; import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService'; import { authService } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService'; import { tauriBackendService } from '@app/services/tauriBackendService';
import { BASE_PATH } from '@app/constants/app'; import { useLogoPath } from '@app/hooks/useLogoPath';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import '@app/components/SetupWizard/SetupWizard.css'; import '@app/components/SetupWizard/SetupWizard.css';
enum SetupStep { enum SetupStep {
ModeSelection, ModeSelection,
SaaSLogin,
ServerSelection, ServerSelection,
Login, SelfHostedLogin,
} }
interface SetupWizardProps { interface SetupWizardProps {
@@ -22,34 +24,42 @@ interface SetupWizardProps {
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => { export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const logoPath = useLogoPath();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection); const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null); const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleModeSelection = (mode: 'offline' | 'server') => { const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
setSelectedMode(mode);
setError(null); setError(null);
if (mode === 'offline') { if (mode === 'saas') {
handleOfflineSetup(); // For SaaS, go directly to login screen with SaaS URL
setServerConfig({ url: STIRLING_SAAS_URL });
setActiveStep(SetupStep.SaaSLogin);
} else { } else {
// For self-hosted, show server selection first
setActiveStep(SetupStep.ServerSelection); setActiveStep(SetupStep.ServerSelection);
} }
}; };
const handleOfflineSetup = async () => { const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No SaaS server configured');
return;
}
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
await connectionModeService.switchToOffline(); await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToSaaS(serverConfig.url);
await tauriBackendService.startBackend(); await tauriBackendService.startBackend();
onComplete(); onComplete();
} catch (err) { } catch (err) {
console.error('Failed to set up offline mode:', err); console.error('SaaS login failed:', err);
setError(err instanceof Error ? err.message : 'Failed to set up offline mode'); setError(err instanceof Error ? err.message : 'SaaS login failed');
setLoading(false); setLoading(false);
} }
}; };
@@ -57,10 +67,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleServerSelection = (config: ServerConfig) => { const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config); setServerConfig(config);
setError(null); setError(null);
setActiveStep(SetupStep.Login); setActiveStep(SetupStep.SelfHostedLogin);
}; };
const handleLogin = async (username: string, password: string) => { const handleSelfHostedLogin = async (username: string, password: string) => {
if (!serverConfig) { if (!serverConfig) {
setError('No server configured'); setError('No server configured');
return; return;
@@ -71,23 +81,25 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setError(null); setError(null);
await authService.login(serverConfig.url, username, password); await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToServer(serverConfig); await connectionModeService.switchToSelfHosted(serverConfig);
await tauriBackendService.initializeExternalBackend(); await tauriBackendService.initializeExternalBackend();
onComplete(); onComplete();
} catch (err) { } catch (err) {
console.error('Login failed:', err); console.error('Self-hosted login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed'); setError(err instanceof Error ? err.message : 'Self-hosted login failed');
setLoading(false); setLoading(false);
} }
}; };
const handleBack = () => { const handleBack = () => {
setError(null); setError(null);
if (activeStep === SetupStep.Login) { if (activeStep === SetupStep.SaaSLogin) {
setActiveStep(SetupStep.ModeSelection);
setServerConfig(null);
} else if (activeStep === SetupStep.SelfHostedLogin) {
setActiveStep(SetupStep.ServerSelection); setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) { } else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.ModeSelection); setActiveStep(SetupStep.ModeSelection);
setSelectedMode(null);
setServerConfig(null); setServerConfig(null);
} }
}; };
@@ -96,10 +108,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
switch (activeStep) { switch (activeStep) {
case SetupStep.ModeSelection: case SetupStep.ModeSelection:
return t('setup.welcome', 'Welcome to Stirling PDF'); return t('setup.welcome', 'Welcome to Stirling PDF');
case SetupStep.SaaSLogin:
return t('setup.saas.title', 'Sign in to Stirling Cloud');
case SetupStep.ServerSelection: case SetupStep.ServerSelection:
return t('setup.server.title', 'Connect to Server'); return t('setup.server.title', 'Connect to Server');
case SetupStep.Login: case SetupStep.SelfHostedLogin:
return t('setup.login.title', 'Sign In'); return t('setup.selfhosted.title', 'Sign in to Server');
default: default:
return ''; return '';
} }
@@ -109,10 +123,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
switch (activeStep) { switch (activeStep) {
case SetupStep.ModeSelection: case SetupStep.ModeSelection:
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF'); return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
case SetupStep.SaaSLogin:
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
case SetupStep.ServerSelection: case SetupStep.ServerSelection:
return t('setup.server.subtitle', 'Enter your self-hosted server URL'); return t('setup.server.subtitle', 'Enter your self-hosted server URL');
case SetupStep.Login: case SetupStep.SelfHostedLogin:
return t('setup.login.subtitle', 'Enter your credentials to continue'); return t('setup.selfhosted.subtitle', 'Enter your server credentials');
default: default:
return ''; return '';
} }
@@ -126,9 +142,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
{/* Logo Header */} {/* Logo Header */}
<Stack gap="xs" align="center"> <Stack gap="xs" align="center">
<Image <Image
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} src={logoPath}
alt="Stirling PDF" alt="Stirling PDF"
h={32} h={64}
fit="contain" fit="contain"
/> />
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}> <Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
@@ -153,14 +169,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
<ModeSelection onSelect={handleModeSelection} loading={loading} /> <ModeSelection onSelect={handleModeSelection} loading={loading} />
)} )}
{activeStep === SetupStep.SaaSLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
isSaaS={true}
onLogin={handleSaaSLogin}
loading={loading}
/>
)}
{activeStep === SetupStep.ServerSelection && ( {activeStep === SetupStep.ServerSelection && (
<ServerSelection onSelect={handleServerSelection} loading={loading} /> <ServerSelection onSelect={handleServerSelection} loading={loading} />
)} )}
{activeStep === SetupStep.Login && ( {activeStep === SetupStep.SelfHostedLogin && (
<LoginForm <LoginForm
serverUrl={serverConfig?.url || ''} serverUrl={serverConfig?.url || ''}
onLogin={handleLogin} isSaaS={false}
onLogin={handleSelfHostedLogin}
loading={loading} loading={loading}
/> />
)} )}
+8 -1
View File
@@ -2,4 +2,11 @@
* Connection-related constants for desktop app * Connection-related constants for desktop app
*/ */
export const STIRLING_SAAS_URL = 'https://stirling.com/app'; // SaaS server URL from environment variable (required)
// The SaaS authentication server
// Will throw error if VITE_SAAS_SERVER_URL is not set
if (!import.meta.env.VITE_SAAS_SERVER_URL) {
throw new Error('VITE_SAAS_SERVER_URL environment variable is required');
}
export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL;
@@ -25,9 +25,7 @@ export function useBackendInitializer(enabled = true) {
const initializeBackend = async () => { const initializeBackend = async () => {
try { try {
console.log('[BackendInitializer] Starting backend...');
await tauriBackendService.startBackend(backendUrl); await tauriBackendService.startBackend(backendUrl);
console.log('[BackendInitializer] Backend started successfully');
// Begin health checks after a short delay // Begin health checks after a short delay
setTimeout(() => { setTimeout(() => {
@@ -267,8 +267,8 @@ const DEFAULT_BACKEND_URL =
/** /**
* Desktop override exposing the backend URL based on connection mode. * Desktop override exposing the backend URL based on connection mode.
* - Offline mode: Uses local bundled backend (from env vars) * - SaaS mode: Uses local bundled backend (from env vars)
* - Server mode: Uses configured server URL from connection config * - Self-hosted mode: Uses configured server URL from connection config
*/ */
export function useEndpointConfig(): EndpointConfig { export function useEndpointConfig(): EndpointConfig {
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL); const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
@@ -276,10 +276,10 @@ export function useEndpointConfig(): EndpointConfig {
useEffect(() => { useEffect(() => {
connectionModeService.getCurrentConfig() connectionModeService.getCurrentConfig()
.then((config) => { .then((config) => {
if (config.mode === 'server' && config.server_config?.url) { if (config.mode === 'selfhosted' && config.server_config?.url) {
setBackendUrl(config.server_config.url); setBackendUrl(config.server_config.url);
} else { } else {
// Offline mode - use default from env vars // SaaS mode - use default from env vars (local backend)
setBackendUrl(DEFAULT_BACKEND_URL); setBackendUrl(DEFAULT_BACKEND_URL);
} }
}) })
@@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`); console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Add auth token for remote requests // Add auth token for remote requests
const isRemote = await operationRouter.isRemoteMode(); const isRemote = await operationRouter.isSelfHostedMode();
if (isRemote) { if (isRemote) {
const token = await authService.getAuthToken(); const token = await authService.getAuthToken();
if (token) { if (token) {
@@ -59,9 +59,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Backend readiness check (for local backend) // Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true; const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isOffline = await operationRouter.isOfflineMode(); const isSaaS = await operationRouter.isSaaSMode();
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) { if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) {
const method = (extendedConfig.method || 'get').toLowerCase(); const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') { if (method !== 'get') {
const now = Date.now(); const now = Date.now();
@@ -93,7 +93,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
const isRemote = await operationRouter.isRemoteMode(); const isRemote = await operationRouter.isSelfHostedMode();
if (isRemote) { if (isRemote) {
const serverConfig = await connectionModeService.getServerConfig(); const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) { if (serverConfig) {
@@ -1,12 +1,10 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http'; import { fetch } from '@tauri-apps/plugin-http';
export type ConnectionMode = 'offline' | 'server'; export type ConnectionMode = 'saas' | 'selfhosted';
export type ServerType = 'saas' | 'selfhosted';
export interface ServerConfig { export interface ServerConfig {
url: string; url: string;
server_type: ServerType;
} }
export interface ConnectionConfig { export interface ConnectionConfig {
@@ -31,7 +29,7 @@ export class ConnectionModeService {
if (!this.configLoadedOnce) { if (!this.configLoadedOnce) {
await this.loadConfig(); await this.loadConfig();
} }
return this.currentConfig || { mode: 'offline', server_config: null }; return this.currentConfig || { mode: 'saas', server_config: null };
} }
async getCurrentMode(): Promise<ConnectionMode> { async getCurrentMode(): Promise<ConnectionMode> {
@@ -64,38 +62,40 @@ export class ConnectionModeService {
this.configLoadedOnce = true; this.configLoadedOnce = true;
} catch (error) { } catch (error) {
console.error('Failed to load connection config:', error); console.error('Failed to load connection config:', error);
// Default to offline mode on error // Default to SaaS mode on error
this.currentConfig = { mode: 'offline', server_config: null }; this.currentConfig = { mode: 'saas', server_config: null };
this.configLoadedOnce = true; this.configLoadedOnce = true;
} }
} }
async switchToOffline(): Promise<void> { async switchToSaaS(saasServerUrl: string): Promise<void> {
console.log('Switching to offline mode'); console.log('Switching to SaaS mode');
const serverConfig: ServerConfig = { url: saasServerUrl };
await invoke('set_connection_mode', { await invoke('set_connection_mode', {
mode: 'offline', mode: 'saas',
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, serverConfig,
}); });
this.currentConfig = { mode: 'server', server_config: serverConfig }; this.currentConfig = { mode: 'saas', server_config: serverConfig };
this.notifyListeners(); this.notifyListeners();
console.log('Switched to server mode successfully'); console.log('Switched to SaaS mode successfully');
}
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
console.log('Switching to self-hosted mode:', serverConfig);
await invoke('set_connection_mode', {
mode: 'selfhosted',
serverConfig,
});
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig };
this.notifyListeners();
console.log('Switched to self-hosted mode successfully');
} }
async testConnection(url: string): Promise<boolean> { async testConnection(url: string): Promise<boolean> {
@@ -126,6 +126,16 @@ export class ConnectionModeService {
return false; return false;
} }
} }
async resetSetupCompletion(): Promise<void> {
try {
await invoke('reset_setup_completion');
console.log('Setup completion flag reset successfully');
} catch (error) {
console.error('Failed to reset setup completion:', error);
throw error;
}
}
} }
export const connectionModeService = ConnectionModeService.getInstance(); export const connectionModeService = ConnectionModeService.getInstance();
@@ -22,14 +22,16 @@ export class OperationRouter {
const mode = await connectionModeService.getCurrentMode(); const mode = await connectionModeService.getCurrentMode();
// Current implementation: simple mode-based routing // Current implementation: simple mode-based routing
if (mode === 'offline') { if (mode === 'saas') {
// SaaS mode: For now, all operations run locally
// Future enhancement: complex operations will be sent to SaaS server
return 'local'; return 'local';
} }
// In server mode, currently all operations go to remote // In self-hosted mode, currently all operations go to remote
// Future enhancement: check if operation is "simple" and route to local if so // Future enhancement: check if operation is "simple" and route to local if so
// Example future logic: // Example future logic:
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) { // if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) {
// return 'local'; // return 'local';
// } // }
@@ -66,19 +68,19 @@ export class OperationRouter {
} }
/** /**
* Checks if we're currently in remote mode * Checks if we're currently in self-hosted mode
*/ */
async isRemoteMode(): Promise<boolean> { async isSelfHostedMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode(); const mode = await connectionModeService.getCurrentMode();
return mode === 'server'; return mode === 'selfhosted';
} }
/** /**
* Checks if we're currently in offline mode * Checks if we're currently in SaaS mode
*/ */
async isOfflineMode(): Promise<boolean> { async isSaaSMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode(); const mode = await connectionModeService.getCurrentMode();
return mode === 'offline'; return mode === 'saas';
} }
// Future enhancement: operation classification // Future enhancement: operation classification
@@ -64,7 +64,6 @@ export class TauriBackendService {
return; return;
} }
console.log('[TauriBackendService] Initializing external backend monitoring');
this.backendStarted = true; // Mark as active for health checks this.backendStarted = true; // Mark as active for health checks
this.setStatus('starting'); this.setStatus('starting');
this.beginHealthMonitoring(); this.beginHealthMonitoring();
@@ -82,19 +81,17 @@ export class TauriBackendService {
this.setStatus('starting'); this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl }) this.startPromise = invoke('start_backend', { backendUrl })
.then(async (result) => { .then(async () => {
console.log('Backend started:', result);
this.backendStarted = true; this.backendStarted = true;
this.setStatus('starting'); this.setStatus('starting');
// Poll for the dynamically assigned port // Poll for the dynamically assigned port
await this.waitForPort(); await this.waitForPort();
this.beginHealthMonitoring(); this.beginHealthMonitoring();
}) })
.catch((error) => { .catch((error) => {
this.setStatus('unhealthy'); this.setStatus('unhealthy');
console.error('Failed to start backend:', error); console.error('[TauriBackendService] Failed to start backend:', error);
throw error; throw error;
}) })
.finally(() => { .finally(() => {
@@ -105,13 +102,11 @@ export class TauriBackendService {
} }
private async waitForPort(maxAttempts = 30): Promise<void> { private async waitForPort(maxAttempts = 30): Promise<void> {
console.log('[TauriBackendService] Waiting for backend port assignment...');
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
const port = await invoke<number | null>('get_backend_port'); const port = await invoke<number | null>('get_backend_port');
if (port) { if (port) {
this.backendPort = port; this.backendPort = port;
console.log(`[TauriBackendService] Backend port detected: ${port}`);
return; return;
} }
} catch (error) { } catch (error) {
@@ -138,11 +133,11 @@ export class TauriBackendService {
async checkBackendHealth(): Promise<boolean> { async checkBackendHealth(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode(); const mode = await connectionModeService.getCurrentMode();
// For remote server mode, check the configured server // For self-hosted mode, check the configured remote server
if (mode !== 'offline') { if (mode === 'selfhosted') {
const serverConfig = await connectionModeService.getServerConfig(); const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) { if (!serverConfig) {
console.error('[TauriBackendService] Server mode but no server URL configured'); console.error('[TauriBackendService] Self-hosted mode but no server URL configured');
this.setStatus('unhealthy'); this.setStatus('unhealthy');
return false; return false;
} }
@@ -161,21 +156,20 @@ export class TauriBackendService {
} catch (error) { } catch (error) {
const errorStr = String(error); const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) { if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Server health check failed:', error); console.error('[TauriBackendService] Self-hosted server health check failed:', error);
} }
this.setStatus('unhealthy'); this.setStatus('unhealthy');
return false; return false;
} }
} }
// For offline mode, check the bundled backend via Rust // For SaaS mode, check the bundled local backend via Rust
if (!this.backendStarted) { if (!this.backendStarted) {
this.setStatus('stopped'); this.setStatus('stopped');
return false; return false;
} }
if (!this.backendPort) { if (!this.backendPort) {
console.debug('[TauriBackendService] Backend port not available yet');
return false; return false;
} }
@@ -197,7 +191,6 @@ export class TauriBackendService {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
const isHealthy = await this.checkBackendHealth(); const isHealthy = await this.checkBackendHealth();
if (isHealthy) { if (isHealthy) {
console.log('Backend is healthy');
return; return;
} }
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
@@ -210,7 +203,6 @@ export class TauriBackendService {
* Reset backend state (used when switching from external to local backend) * Reset backend state (used when switching from external to local backend)
*/ */
reset(): void { reset(): void {
console.log('[TauriBackendService] Resetting backend state');
this.backendStarted = false; this.backendStarted = false;
this.backendPort = null; this.backendPort = null;
this.setStatus('stopped'); this.setStatus('stopped');