mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Merge branch 'feature/onboardingSlides' of github.com:Stirling-Tools/Stirling-PDF into feature/onboardingSlides
This commit is contained in:
2
.github/workflows/tauri-build.yml
vendored
2
.github/workflows/tauri-build.yml
vendored
@@ -312,6 +312,8 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
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' }}
|
||||
CI: true
|
||||
with:
|
||||
|
||||
@@ -5661,15 +5661,23 @@
|
||||
"description": "Enter credentials"
|
||||
},
|
||||
"mode": {
|
||||
"offline": {
|
||||
"title": "Use Offline",
|
||||
"description": "Run locally without an internet connection"
|
||||
"saas": {
|
||||
"title": "Stirling Cloud",
|
||||
"description": "Sign in with your Stirling account"
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"description": "Connect to a remote Stirling PDF server"
|
||||
"selfhosted": {
|
||||
"title": "Self-Hosted 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": {
|
||||
"title": "Connect to Server",
|
||||
"subtitle": "Enter your self-hosted server URL",
|
||||
@@ -5711,16 +5719,12 @@
|
||||
"connection": {
|
||||
"title": "Connection Mode",
|
||||
"mode": {
|
||||
"offline": "Offline",
|
||||
"server": "Server"
|
||||
"saas": "Stirling Cloud",
|
||||
"selfhosted": "Self-Hosted"
|
||||
},
|
||||
"server": "Server",
|
||||
"user": "Logged in as",
|
||||
"switchToServer": "Connect to Server",
|
||||
"switchToOffline": "Switch to Offline",
|
||||
"logout": "Logout",
|
||||
"selectServer": "Select Server",
|
||||
"login": "Login"
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
|
||||
@@ -127,7 +127,7 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Response types for Spring Boot login
|
||||
// Response types for Spring Boot login (self-hosted)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootSession {
|
||||
access_token: String,
|
||||
@@ -145,6 +145,24 @@ struct SpringBootLoginResponse {
|
||||
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)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
@@ -153,7 +171,7 @@ pub struct LoginResponse {
|
||||
}
|
||||
|
||||
/// 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]
|
||||
pub async fn login(
|
||||
server_url: String,
|
||||
@@ -162,54 +180,124 @@ pub async fn login(
|
||||
) -> Result<LoginResponse, String> {
|
||||
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
||||
|
||||
// Build login URL
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Login URL: {}", login_url);
|
||||
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
|
||||
// Compare against the configured SaaS server URL from environment
|
||||
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
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Make login request
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
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,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if 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)
|
||||
});
|
||||
|
||||
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
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Spring Boot login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if 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 Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
|
||||
|
||||
log::info!("Spring Boot login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
log::info!("Login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,11 +349,11 @@ pub async fn start_backend(
|
||||
};
|
||||
|
||||
match mode {
|
||||
ConnectionMode::Offline => {
|
||||
add_log("🔌 Running in Offline mode - starting local backend".to_string());
|
||||
ConnectionMode::SaaS => {
|
||||
add_log("☁️ Running in SaaS mode - starting local backend".to_string());
|
||||
}
|
||||
ConnectionMode::Server => {
|
||||
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
|
||||
ConnectionMode::SelfHosted => {
|
||||
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ pub async fn get_connection_config(
|
||||
let mode = store
|
||||
.get(CONNECTION_MODE_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(ConnectionMode::Offline);
|
||||
.unwrap_or(ConnectionMode::SaaS);
|
||||
|
||||
let server_config: Option<ServerConfig> = store
|
||||
.get(SERVER_CONFIG_KEY)
|
||||
@@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
set_connection_mode,
|
||||
};
|
||||
pub use auth::{
|
||||
|
||||
@@ -19,6 +19,7 @@ use commands::{
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
login,
|
||||
reset_setup_completion,
|
||||
save_auth_token,
|
||||
save_user_info,
|
||||
set_connection_mode,
|
||||
@@ -85,6 +86,7 @@ pub fn run() {
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
check_backend_health,
|
||||
login,
|
||||
save_auth_token,
|
||||
|
||||
@@ -4,13 +4,6 @@ use std::sync::Mutex;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionMode {
|
||||
Offline,
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ServerType {
|
||||
SaaS,
|
||||
SelfHosted,
|
||||
}
|
||||
@@ -18,7 +11,6 @@ pub enum ServerType {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub url: String,
|
||||
pub server_type: ServerType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -30,7 +22,7 @@ pub struct ConnectionState {
|
||||
impl Default for ConnectionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ConnectionMode::Offline,
|
||||
mode: ConnectionMode::SaaS,
|
||||
server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
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
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
// Initialize backend health monitoring for server mode
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
|
||||
console.log('[AppProviders] Initializing external backend monitoring for server mode');
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
}
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Only start bundled backend if in offline mode and setup is complete
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
|
||||
// Only start bundled backend if in SaaS mode (local backend) and setup is complete
|
||||
// Self-hosted mode connects to remote server so doesn't need local backend
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldStartBackend);
|
||||
|
||||
// Show setup wizard on first launch
|
||||
@@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
// Reload the page to reinitialize with new connection config
|
||||
onComplete={async () => {
|
||||
// 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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
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 {
|
||||
connectionModeService,
|
||||
ConnectionConfig,
|
||||
ServerConfig,
|
||||
} from '@app/services/connectionModeService';
|
||||
import { connectionModeService, ConnectionConfig } 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(() => {
|
||||
@@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(currentConfig);
|
||||
|
||||
if (currentConfig.mode === 'server') {
|
||||
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
}
|
||||
@@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => {
|
||||
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();
|
||||
// Switch to SaaS mode
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
||||
|
||||
// Reset setup completion to force login screen on reload
|
||||
await connectionModeService.resetSetupCompletion();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
@@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => {
|
||||
<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 color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
|
||||
{config.mode === 'saas'
|
||||
? t('settings.connection.mode.saas', 'Stirling Cloud')
|
||||
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{config.mode === 'server' && config.server_config && (
|
||||
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('settings.connection.server', 'Server')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{config.server_config.url}
|
||||
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Log Out')}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginFormProps {
|
||||
serverUrl: string;
|
||||
isSaaS?: boolean;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
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 [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -36,7 +37,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loadin
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<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>
|
||||
|
||||
<TextInput
|
||||
|
||||
@@ -5,7 +5,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface ModeSelectionProps {
|
||||
onSelect: (mode: 'offline' | 'server') => void;
|
||||
onSelect: (mode: 'saas' | 'selfhosted') => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@@ -17,31 +17,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
<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')}
|
||||
onClick={() => onSelect('saas')}
|
||||
disabled={loading}
|
||||
leftSection={<CloudIcon />}
|
||||
styles={{
|
||||
@@ -52,12 +28,42 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
{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>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
// Connection successful
|
||||
onSelect({
|
||||
url,
|
||||
server_type: 'selfhosted',
|
||||
});
|
||||
} catch (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 { authService } from '@app/services/authService';
|
||||
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';
|
||||
|
||||
enum SetupStep {
|
||||
ModeSelection,
|
||||
SaaSLogin,
|
||||
ServerSelection,
|
||||
Login,
|
||||
SelfHostedLogin,
|
||||
}
|
||||
|
||||
interface SetupWizardProps {
|
||||
@@ -22,34 +24,42 @@ interface SetupWizardProps {
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const logoPath = useLogoPath();
|
||||
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);
|
||||
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
|
||||
setError(null);
|
||||
|
||||
if (mode === 'offline') {
|
||||
handleOfflineSetup();
|
||||
if (mode === 'saas') {
|
||||
// For SaaS, go directly to login screen with SaaS URL
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
} else {
|
||||
// For self-hosted, show server selection first
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOfflineSetup = async () => {
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await connectionModeService.switchToOffline();
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
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');
|
||||
console.error('SaaS login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'SaaS login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -57,10 +67,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.Login);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
};
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
const handleSelfHostedLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No server configured');
|
||||
return;
|
||||
@@ -71,23 +81,25 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
setError(null);
|
||||
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToServer(serverConfig);
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
console.error('Self-hosted login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Self-hosted login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.Login) {
|
||||
if (activeStep === SetupStep.SaaSLogin) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
} else if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setSelectedMode(null);
|
||||
setServerConfig(null);
|
||||
}
|
||||
};
|
||||
@@ -96,10 +108,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.title', 'Sign in to Stirling Cloud');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.title', 'Connect to Server');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.title', 'Sign In');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.title', 'Sign in to Server');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -109,10 +123,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
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:
|
||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.subtitle', 'Enter your credentials to continue');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -126,9 +142,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
{/* Logo Header */}
|
||||
<Stack gap="xs" align="center">
|
||||
<Image
|
||||
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
|
||||
src={logoPath}
|
||||
alt="Stirling PDF"
|
||||
h={32}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
<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} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={true}
|
||||
onLogin={handleSaaSLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.Login && (
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
onLogin={handleLogin}
|
||||
isSaaS={false}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
* 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 () => {
|
||||
try {
|
||||
console.log('[BackendInitializer] Starting backend...');
|
||||
await tauriBackendService.startBackend(backendUrl);
|
||||
console.log('[BackendInitializer] Backend started successfully');
|
||||
|
||||
// Begin health checks after a short delay
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -267,8 +267,8 @@ const DEFAULT_BACKEND_URL =
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - SaaS mode: Uses local bundled backend (from env vars)
|
||||
* - Self-hosted mode: Uses configured server URL from connection config
|
||||
*/
|
||||
export function useEndpointConfig(): EndpointConfig {
|
||||
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
|
||||
@@ -276,10 +276,10 @@ export function useEndpointConfig(): EndpointConfig {
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentConfig()
|
||||
.then((config) => {
|
||||
if (config.mode === 'server' && config.server_config?.url) {
|
||||
if (config.mode === 'selfhosted' && config.server_config?.url) {
|
||||
setBackendUrl(config.server_config.url);
|
||||
} else {
|
||||
// Offline mode - use default from env vars
|
||||
// SaaS mode - use default from env vars (local backend)
|
||||
setBackendUrl(DEFAULT_BACKEND_URL);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
// Add auth token for remote requests
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
const token = await authService.getAuthToken();
|
||||
if (token) {
|
||||
@@ -59,9 +59,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// Backend readiness check (for local backend)
|
||||
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();
|
||||
if (method !== 'get') {
|
||||
const now = Date.now();
|
||||
@@ -93,7 +93,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (serverConfig) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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 type ConnectionMode = 'saas' | 'selfhosted';
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
server_type: ServerType;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
@@ -31,7 +29,7 @@ export class ConnectionModeService {
|
||||
if (!this.configLoadedOnce) {
|
||||
await this.loadConfig();
|
||||
}
|
||||
return this.currentConfig || { mode: 'offline', server_config: null };
|
||||
return this.currentConfig || { mode: 'saas', server_config: null };
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<ConnectionMode> {
|
||||
@@ -64,38 +62,40 @@ export class ConnectionModeService {
|
||||
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 };
|
||||
// Default to SaaS mode on error
|
||||
this.currentConfig = { mode: 'saas', server_config: null };
|
||||
this.configLoadedOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
async switchToOffline(): Promise<void> {
|
||||
console.log('Switching to offline mode');
|
||||
async switchToSaaS(saasServerUrl: string): Promise<void> {
|
||||
console.log('Switching to SaaS mode');
|
||||
|
||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||
|
||||
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',
|
||||
mode: 'saas',
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'server', server_config: serverConfig };
|
||||
this.currentConfig = { mode: 'saas', server_config: serverConfig };
|
||||
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> {
|
||||
@@ -126,6 +126,16 @@ export class ConnectionModeService {
|
||||
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();
|
||||
|
||||
@@ -22,14 +22,16 @@ export class OperationRouter {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
// 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
|
||||
// Example future logic:
|
||||
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) {
|
||||
// if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) {
|
||||
// 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();
|
||||
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();
|
||||
return mode === 'offline';
|
||||
return mode === 'saas';
|
||||
}
|
||||
|
||||
// Future enhancement: operation classification
|
||||
|
||||
@@ -64,7 +64,6 @@ export class TauriBackendService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TauriBackendService] Initializing external backend monitoring');
|
||||
this.backendStarted = true; // Mark as active for health checks
|
||||
this.setStatus('starting');
|
||||
this.beginHealthMonitoring();
|
||||
@@ -82,19 +81,17 @@ export class TauriBackendService {
|
||||
this.setStatus('starting');
|
||||
|
||||
this.startPromise = invoke('start_backend', { backendUrl })
|
||||
.then(async (result) => {
|
||||
console.log('Backend started:', result);
|
||||
.then(async () => {
|
||||
this.backendStarted = true;
|
||||
this.setStatus('starting');
|
||||
|
||||
// Poll for the dynamically assigned port
|
||||
await this.waitForPort();
|
||||
|
||||
this.beginHealthMonitoring();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setStatus('unhealthy');
|
||||
console.error('Failed to start backend:', error);
|
||||
console.error('[TauriBackendService] Failed to start backend:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -105,13 +102,11 @@ export class TauriBackendService {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -138,11 +133,11 @@ export class TauriBackendService {
|
||||
async checkBackendHealth(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// For remote server mode, check the configured server
|
||||
if (mode !== 'offline') {
|
||||
// For self-hosted mode, check the configured remote server
|
||||
if (mode === 'selfhosted') {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
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');
|
||||
return false;
|
||||
}
|
||||
@@ -161,21 +156,20 @@ export class TauriBackendService {
|
||||
} 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);
|
||||
console.error('[TauriBackendService] Self-hosted server health check failed:', error);
|
||||
}
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For offline mode, check the bundled backend via Rust
|
||||
// For SaaS mode, check the bundled local backend via Rust
|
||||
if (!this.backendStarted) {
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.backendPort) {
|
||||
console.debug('[TauriBackendService] Backend port not available yet');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -197,7 +191,6 @@ export class TauriBackendService {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const isHealthy = await this.checkBackendHealth();
|
||||
if (isHealthy) {
|
||||
console.log('Backend is healthy');
|
||||
return;
|
||||
}
|
||||
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(): void {
|
||||
console.log('[TauriBackendService] Resetting backend state');
|
||||
this.backendStarted = false;
|
||||
this.backendPort = null;
|
||||
this.setStatus('stopped');
|
||||
|
||||
Reference in New Issue
Block a user