Add infrastructure for using multiple backends

This commit is contained in:
James Brunton 2025-11-14 16:57:21 +00:00
parent be78c72887
commit 93c3f9e84b
13 changed files with 955 additions and 26 deletions

View File

@ -2048,6 +2048,16 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"log",
"zeroize",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@ -3941,6 +3951,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"keyring",
"log",
"reqwest 0.11.27",
"serde",
@ -3951,6 +3962,7 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tokio",
]
@ -4352,6 +4364,22 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"tokio",
"tracing",
]
[[package]]
name = "tauri-runtime"
version = "2.9.1"
@ -4585,9 +4613,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2 0.6.1",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@ -5951,6 +5991,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.2"

View File

@ -29,5 +29,7 @@ tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0"
keyring = "3.6.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }

View File

@ -0,0 +1,128 @@
use keyring::Entry;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub username: String,
pub email: Option<String>,
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving auth token to keyring");
let entry = get_keyring_entry()?;
entry
.set_password(&token)
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
log::info!("Auth token saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
log::debug!("Retrieving auth token from keyring");
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
}
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
#[tauri::command]
pub async fn save_user_info(
app_handle: AppHandle,
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
USER_INFO_KEY,
serde_json::to_value(&user_info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
log::debug!("Retrieving user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let user_info: Option<UserInfo> = store
.get(USER_INFO_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(user_info)
}
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.delete(USER_INFO_KEY);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}

View File

@ -3,6 +3,7 @@ use tauri::Manager;
use std::sync::Mutex;
use std::path::PathBuf;
use crate::utils::add_log;
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
// Store backend process handle globally
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
@ -308,9 +309,31 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
// Command to start the backend with bundled JRE
#[tauri::command]
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
pub async fn start_backend(
app: tauri::AppHandle,
connection_state: tauri::State<'_, AppConnectionState>,
) -> Result<String, String> {
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
// Check connection mode
let mode = {
let state = connection_state.0.lock().map_err(|e| {
let error_msg = format!("❌ Failed to access connection state: {}", e);
add_log(error_msg.clone());
error_msg
})?;
state.mode.clone()
};
match mode {
ConnectionMode::Offline => {
add_log("🔌 Running in Offline mode - starting local backend".to_string());
}
ConnectionMode::Server => {
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
}
}
// Check if backend is already running or starting
if let Err(msg) = check_backend_status() {
return Ok(msg);

View File

@ -0,0 +1,136 @@
use crate::state::connection_state::{
AppConnectionState,
ConnectionMode,
ServerConfig,
};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tauri::{AppHandle, State};
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const FIRST_LAUNCH_KEY: &str = "setup_completed";
const CONNECTION_MODE_KEY: &str = "connection_mode";
const SERVER_CONFIG_KEY: &str = "server_config";
#[derive(Debug, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub mode: ConnectionMode,
pub server_config: Option<ServerConfig>,
}
#[tauri::command]
pub async fn get_connection_config(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
) -> Result<ConnectionConfig, String> {
// Try to load from store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let mode = store
.get(CONNECTION_MODE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ConnectionMode::Offline);
let server_config: Option<ServerConfig> = store
.get(SERVER_CONFIG_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
conn_state.server_config = server_config.clone();
}
Ok(ConnectionConfig {
mode,
server_config,
})
}
#[tauri::command]
pub async fn set_connection_mode(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
mode: ConnectionMode,
server_config: Option<ServerConfig>,
) -> Result<(), String> {
log::info!("Setting connection mode: {:?}", mode);
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
conn_state.server_config = server_config.clone();
}
// Save to store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
CONNECTION_MODE_KEY,
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
);
if let Some(config) = &server_config {
store.set(
SERVER_CONFIG_KEY,
serde_json::to_value(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?,
);
} else {
store.delete(SERVER_CONFIG_KEY);
}
// Mark setup as completed
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Connection mode saved successfully");
Ok(())
}
#[tauri::command]
pub async fn test_server_connection(url: String) -> Result<bool, String> {
log::info!("Testing connection to: {}", url);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// Try to hit the health/status endpoint
let health_url = format!("{}/api/v1/info/status", url.trim_end_matches('/'));
match client.get(&health_url).send().await {
Ok(response) => {
let is_ok = response.status().is_success();
log::info!("Server connection test result: {}", is_ok);
Ok(is_ok)
}
Err(e) => {
log::warn!("Server connection test failed: {}", e);
Err(format!("Connection failed: {}", e))
}
}
}
#[tauri::command]
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let setup_completed = store
.get(FIRST_LAUNCH_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(!setup_completed)
}

View File

@ -1,7 +1,23 @@
pub mod backend;
pub mod health;
pub mod files;
pub mod connection;
pub mod auth;
pub use backend::{start_backend, cleanup_backend};
pub use backend::{cleanup_backend, start_backend};
pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
pub use connection::{
get_connection_config,
is_first_launch,
set_connection_mode,
test_server_connection,
};
pub use auth::{
clear_auth_token,
clear_user_info,
get_auth_token,
get_user_info,
save_auth_token,
save_user_info,
};

View File

@ -1,9 +1,28 @@
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
mod utils;
mod commands;
mod state;
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
use commands::{
add_opened_file,
check_backend_health,
cleanup_backend,
clear_auth_token,
clear_opened_files,
clear_user_info,
get_auth_token,
get_connection_config,
get_opened_files,
get_user_info,
is_first_launch,
save_auth_token,
save_user_info,
set_connection_mode,
start_backend,
test_server_connection,
};
use state::connection_state::AppConnectionState;
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -11,6 +30,8 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_store::Builder::new().build())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
add_log(format!("📂 Second instance detected with args: {:?}", args));
@ -39,7 +60,23 @@ pub fn run() {
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
.invoke_handler(tauri::generate_handler![
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
get_tauri_logs,
get_connection_config,
set_connection_mode,
test_server_connection,
is_first_launch,
save_auth_token,
get_auth_token,
clear_auth_token,
save_user_info,
get_user_info,
clear_user_info,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {

View File

@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub url: String,
pub server_type: ServerType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionState {
pub mode: ConnectionMode,
pub server_config: Option<ServerConfig>,
}
impl Default for ConnectionState {
fn default() -> Self {
Self {
mode: ConnectionMode::Offline,
server_config: None,
}
}
}
pub struct AppConnectionState(pub Mutex<ConnectionState>);
impl Default for AppConnectionState {
fn default() -> Self {
Self(Mutex::new(ConnectionState::default()))
}
}

View File

@ -0,0 +1 @@
pub mod connection_state;

View File

@ -1,44 +1,131 @@
import { AxiosInstance } from 'axios';
import { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { alert } from '@app/components/toast';
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { createBackendNotReadyError } from '@app/constants/backendErrors';
import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
// Extend Axios config to include our custom properties
interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
_retry?: boolean;
}
/**
* Desktop-specific API interceptors
* - Reuses the core interceptors
* - Blocks API calls while the bundled backend is still starting and shows
* a friendly toast for user-initiated requests (non-GET)
* - Dynamically sets base URL based on connection mode
* - Adds auth token for remote server requests
* - Blocks API calls while the bundled backend is still starting
* - Handles auth token refresh on 401 errors
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
// Request interceptor: Set base URL and auth headers dynamically
client.interceptors.request.use(
(config) => {
const skipCheck = config?.skipBackendReadyCheck === true;
if (skipCheck || tauriBackendService.isBackendHealthy()) {
return config;
async (config: InternalAxiosRequestConfig) => {
const extendedConfig = config as ExtendedAxiosRequestConfig;
// Get the operation name from config if provided
const operation = extendedConfig.operationName;
// Get the appropriate base URL for this operation
const baseUrl = await operationRouter.getBaseUrl(operation);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
const method = (config.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
// Add auth token for remote requests
const isRemote = await operationRouter.isRemoteMode();
if (isRemote) {
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
}
}
return Promise.reject(createBackendNotReadyError());
// Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isOffline = await operationRouter.isOfflineMode();
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
return extendedConfig;
},
(error) => Promise.reject(error)
);
// Response interceptor: Handle auth errors
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as ExtendedAxiosRequestConfig;
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const isRemote = await operationRouter.isRemoteMode();
if (isRemote) {
const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) {
const refreshed = await authService.refreshToken(serverConfig.url);
if (refreshed) {
// Retry the original request with new token
const token = await authService.getAuthToken();
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return client(originalRequest);
}
}
}
// Refresh failed or not in remote mode - user needs to login again
alert({
alertType: 'error',
title: i18n.t('auth.sessionExpired', 'Session Expired'),
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
isPersistentPopup: false,
});
}
// Handle 403 Forbidden - unauthorized access
if (error.response?.status === 403) {
alert({
alertType: 'error',
title: i18n.t('auth.accessDenied', 'Access Denied'),
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
isPersistentPopup: false,
});
}
return Promise.reject(error);
}
);
}

View File

@ -0,0 +1,196 @@
import { invoke } from '@tauri-apps/api/core';
import axios from 'axios';
export interface UserInfo {
username: string;
email?: string;
}
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing';
export class AuthService {
private static instance: AuthService;
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
this.authListeners.add(listener);
// Immediately notify new listener of current state
listener(this.authStatus, this.userInfo);
return () => {
this.authListeners.delete(listener);
};
}
private notifyListeners() {
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
}
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
this.authStatus = status;
this.userInfo = userInfo;
this.notifyListeners();
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
try {
console.log('Logging in to:', serverUrl);
// Call the server's login endpoint
const response = await axios.post(`${serverUrl}/api/v1/auth/login`, {
username,
password,
});
const { token, username: returnedUsername, email } = response.data;
// Save the token to keyring
await invoke('save_auth_token', { token });
// Save user info to store
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
const userInfo: UserInfo = {
username: returnedUsername || username,
email,
};
this.setAuthStatus('authenticated', userInfo);
console.log('Login successful');
return userInfo;
} catch (error) {
console.error('Login failed:', error);
this.setAuthStatus('unauthenticated', null);
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
throw new Error('Invalid username or password');
} else if (error.response?.status === 403) {
throw new Error('Access denied');
} else if (error.code === 'ERR_NETWORK') {
throw new Error('Network error - could not connect to server');
}
}
throw new Error('Login failed. Please try again.');
}
}
async logout(): Promise<void> {
try {
console.log('Logging out');
// Clear token from keyring
await invoke('clear_auth_token');
// Clear user info from store
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
this.setAuthStatus('unauthenticated', null);
}
}
async getAuthToken(): Promise<string | null> {
try {
const token = await invoke<string | null>('get_auth_token');
return token || null;
} catch (error) {
console.error('Failed to get auth token:', error);
return null;
}
}
async isAuthenticated(): Promise<boolean> {
const token = await this.getAuthToken();
return token !== null;
}
async getUserInfo(): Promise<UserInfo | null> {
if (this.userInfo) {
return this.userInfo;
}
try {
const userInfo = await invoke<UserInfo | null>('get_user_info');
this.userInfo = userInfo;
return userInfo;
} catch (error) {
console.error('Failed to get user info:', error);
return null;
}
}
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
if (!currentToken) {
this.setAuthStatus('unauthenticated', null);
return false;
}
// Call the server's refresh endpoint
const response = await axios.post(
`${serverUrl}/api/v1/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${currentToken}`,
},
}
);
const { token } = response.data;
// Save the new token
await invoke('save_auth_token', { token });
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
await this.logout();
return false;
}
}
async initializeAuthState(): Promise<void> {
const token = await this.getAuthToken();
const userInfo = await this.getUserInfo();
if (token && userInfo) {
this.setAuthStatus('authenticated', userInfo);
} else {
this.setAuthStatus('unauthenticated', null);
}
}
}
export const authService = AuthService.getInstance();

View File

@ -0,0 +1,121 @@
import { invoke } from '@tauri-apps/api/core';
export type ConnectionMode = 'offline' | 'server';
export type ServerType = 'saas' | 'selfhosted';
export interface ServerConfig {
url: string;
server_type: ServerType;
}
export interface ConnectionConfig {
mode: ConnectionMode;
server_config: ServerConfig | null;
}
export class ConnectionModeService {
private static instance: ConnectionModeService;
private currentConfig: ConnectionConfig | null = null;
private configLoadedOnce = false;
private modeListeners = new Set<(config: ConnectionConfig) => void>();
static getInstance(): ConnectionModeService {
if (!ConnectionModeService.instance) {
ConnectionModeService.instance = new ConnectionModeService();
}
return ConnectionModeService.instance;
}
async getCurrentConfig(): Promise<ConnectionConfig> {
if (!this.configLoadedOnce) {
await this.loadConfig();
}
return this.currentConfig || { mode: 'offline', server_config: null };
}
async getCurrentMode(): Promise<ConnectionMode> {
const config = await this.getCurrentConfig();
return config.mode;
}
async getServerConfig(): Promise<ServerConfig | null> {
const config = await this.getCurrentConfig();
return config.server_config;
}
subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void {
this.modeListeners.add(listener);
return () => {
this.modeListeners.delete(listener);
};
}
private notifyListeners() {
if (this.currentConfig) {
this.modeListeners.forEach(listener => listener(this.currentConfig!));
}
}
private async loadConfig(): Promise<void> {
try {
const config = await invoke<ConnectionConfig>('get_connection_config');
this.currentConfig = config;
this.configLoadedOnce = true;
} catch (error) {
console.error('Failed to load connection config:', error);
// Default to offline mode on error
this.currentConfig = { mode: 'offline', server_config: null };
this.configLoadedOnce = true;
}
}
async switchToOffline(): Promise<void> {
console.log('Switching to offline mode');
await invoke('set_connection_mode', {
mode: 'offline',
serverConfig: null,
});
this.currentConfig = { mode: 'offline', server_config: null };
this.notifyListeners();
console.log('Switched to offline mode successfully');
}
async switchToServer(serverConfig: ServerConfig): Promise<void> {
console.log('Switching to server mode:', serverConfig);
await invoke('set_connection_mode', {
mode: 'server',
serverConfig,
});
this.currentConfig = { mode: 'server', server_config: serverConfig };
this.notifyListeners();
console.log('Switched to server mode successfully');
}
async testConnection(url: string): Promise<boolean> {
try {
const result = await invoke<boolean>('test_server_connection', { url });
return result;
} catch (error) {
console.error('Connection test failed:', error);
return false;
}
}
async isFirstLaunch(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_first_launch');
return result;
} catch (error) {
console.error('Failed to check first launch:', error);
return false;
}
}
}
export const connectionModeService = ConnectionModeService.getInstance();

View File

@ -0,0 +1,91 @@
import { connectionModeService, ConnectionMode } from './connectionModeService';
export type ExecutionTarget = 'local' | 'remote';
export class OperationRouter {
private static instance: OperationRouter;
static getInstance(): OperationRouter {
if (!OperationRouter.instance) {
OperationRouter.instance = new OperationRouter();
}
return OperationRouter.instance;
}
/**
* Determines where an operation should execute
* @param operation - The operation name (for future operation classification)
* @returns 'local' or 'remote'
*/
async getExecutionTarget(operation?: string): Promise<ExecutionTarget> {
const mode = await connectionModeService.getCurrentMode();
// Current implementation: simple mode-based routing
if (mode === 'offline') {
return 'local';
}
// In server mode, currently all operations go to remote
// Future enhancement: check if operation is "simple" and route to local if so
// Example future logic:
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) {
// return 'local';
// }
return 'remote';
}
/**
* Gets the base URL for an operation based on execution target
* @param operation - The operation name (for future operation classification)
* @returns Base URL for API calls
*/
async getBaseUrl(operation?: string): Promise<string> {
const target = await this.getExecutionTarget(operation);
if (target === 'local') {
return 'http://localhost:8080';
}
// Remote: get from server config
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.warn('No server config found, falling back to local');
return 'http://localhost:8080';
}
return serverConfig.url;
}
/**
* Checks if we're currently in remote mode
*/
async isRemoteMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'server';
}
/**
* Checks if we're currently in offline mode
*/
async isOfflineMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'offline';
}
// Future enhancement: operation classification
// private isSimpleOperation(operation: string): boolean {
// const simpleOperations = [
// 'rotate',
// 'merge',
// 'split',
// 'extract-pages',
// 'remove-pages',
// 'reorder-pages',
// 'metadata',
// ];
// return simpleOperations.includes(operation);
// }
}
export const operationRouter = OperationRouter.getInstance();