mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Add infrastructure for using multiple backends
This commit is contained in:
parent
be78c72887
commit
93c3f9e84b
46
frontend/src-tauri/Cargo.lock
generated
46
frontend/src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"] }
|
||||
|
||||
128
frontend/src-tauri/src/commands/auth.rs
Normal file
128
frontend/src-tauri/src/commands/auth.rs
Normal 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(())
|
||||
}
|
||||
@ -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);
|
||||
|
||||
136
frontend/src-tauri/src/commands/connection.rs
Normal file
136
frontend/src-tauri/src/commands/connection.rs
Normal 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)
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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| {
|
||||
|
||||
45
frontend/src-tauri/src/state/connection_state.rs
Normal file
45
frontend/src-tauri/src/state/connection_state.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
1
frontend/src-tauri/src/state/mod.rs
Normal file
1
frontend/src-tauri/src/state/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod connection_state;
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
196
frontend/src/desktop/services/authService.ts
Normal file
196
frontend/src/desktop/services/authService.ts
Normal 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();
|
||||
121
frontend/src/desktop/services/connectionModeService.ts
Normal file
121
frontend/src/desktop/services/connectionModeService.ts
Normal 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();
|
||||
91
frontend/src/desktop/services/operationRouter.ts
Normal file
91
frontend/src/desktop/services/operationRouter.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user