Merge branch 'V2' into feature/V2/ShowJavascript

This commit is contained in:
EthanHealy01 2025-11-12 11:22:33 +00:00 committed by GitHub
commit 985253e40b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 724 additions and 428 deletions

View File

@ -8,4 +8,6 @@ public interface UserServiceInterface {
long getTotalUsersCount();
boolean isCurrentUserAdmin();
boolean isCurrentUserFirstLogin();
}

View File

@ -28,6 +28,8 @@ public class InitialSetup {
private final ApplicationProperties applicationProperties;
private static boolean isNewServer = false;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
@ -88,6 +90,13 @@ public class InitialSetup {
}
public void initSetAppVersion() throws IOException {
// Check if this is a new server before setting the version
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
@ -99,4 +108,8 @@ public class InitialSetup {
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
public static boolean isNewServer() {
return isNewServer;
}
}

View File

@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@ -78,6 +79,22 @@ public class ConfigController {
}
configData.put("isAdmin", isAdmin);
// Check if this is a new server (version was 0.0.0 before initialization)
configData.put("isNewServer", InitialSetup.isNewServer());
// Check if the current user is a first-time user
boolean isNewUser =
false; // Default to false when security is disabled or user not found
if (userService != null) {
try {
isNewUser = userService.isCurrentUserFirstLogin();
} catch (Exception e) {
// If there's an error, assume not new user for safety
isNewUser = false;
}
}
configData.put("isNewUser", isNewUser);
// System settings
configData.put(
"enableAlphaFunctionality",

View File

@ -742,4 +742,31 @@ public class UserController {
return errorMessage;
}
}
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
User user = userOpt.get();
user.setHasCompletedInitialSetup(true);
userRepository.save(user);
log.info("User {} completed initial setup", username);
return ResponseEntity.ok().body(Map.of("success", true));
} catch (Exception e) {
log.error("Error completing initial setup", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to complete initial setup");
}
}
}

View File

@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "roleName")
private String roleName;
@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable {
this.isFirstLogin = isFirstLogin;
}
public boolean hasCompletedInitialSetup() {
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
}
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}

View File

@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface {
return false;
}
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
if (username != null) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
return !userOpt.get().hasCompletedInitialSetup();
}
}
} catch (Exception e) {
log.debug("Error checking first login status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {

View File

@ -66,6 +66,7 @@
"preview": "vite preview",
"tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",

View File

@ -5152,6 +5152,8 @@
"backendHealth": {
"checking": "Checking backend status...",
"online": "Backend Online",
"offline": "Backend Offline"
"offline": "Backend Offline",
"starting": "Backend starting up...",
"wait": "Please wait for the backend to finish launching and try again."
}
}

View File

@ -155,12 +155,6 @@ dependencies = [
"wyz",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -420,36 +414,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -502,19 +466,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@ -523,22 +474,11 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-graphics-types 0.2.0",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@ -1992,15 +1932,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@ -2200,15 +2131,6 @@ dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@ -3689,7 +3611,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"js-sys",
"log",
@ -3739,10 +3661,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"cocoa",
"log",
"objc",
"once_cell",
"reqwest 0.11.27",
"serde",
"serde_json",
@ -3887,7 +3806,7 @@ dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",

View File

@ -10,6 +10,9 @@ rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
warnings = "deny"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@ -27,9 +30,3 @@ tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
# macOS-specific dependencies for native file opening
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
cocoa = "0.24"
once_cell = "1.19"

View File

@ -5,6 +5,7 @@ use std::sync::Mutex;
static OPENED_FILE: Mutex<Option<String>> = Mutex::new(None);
// Set the opened file path (called by macOS file open events)
#[cfg(target_os = "macos")]
pub fn set_opened_file(file_path: String) {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = Some(file_path.clone());

View File

@ -4,4 +4,6 @@ pub mod files;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_file, clear_opened_file, set_opened_file};
pub use files::{get_opened_file, clear_opened_file};
#[cfg(target_os = "macos")]
pub use files::set_opened_file;

View File

@ -1,189 +0,0 @@
/// Multi-platform file opening handler
///
/// This module provides unified file opening support across platforms:
/// - macOS: Uses native NSApplication delegate (proper Apple Events)
/// - Windows/Linux: Uses command line arguments (fallback approach)
/// - All platforms: Runtime event handling via Tauri events
use crate::utils::add_log;
use crate::commands::set_opened_file;
use tauri::AppHandle;
/// Initialize file handling for the current platform
pub fn initialize_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Initializing file handler...".to_string());
// Platform-specific initialization
#[cfg(target_os = "macos")]
{
add_log("🍎 Using macOS native file handler".to_string());
macos_native::register_open_file_handler(app);
}
#[cfg(not(target_os = "macos"))]
{
add_log("🖥️ Using command line argument file handler".to_string());
let _ = app; // Suppress unused variable warning
}
// Universal: Check command line arguments (works on all platforms)
check_command_line_args();
}
/// Early initialization for macOS delegate registration
pub fn early_init() {
#[cfg(target_os = "macos")]
{
add_log("🔄 Early macOS initialization...".to_string());
macos_native::register_delegate_early();
}
}
/// Check command line arguments for file paths (universal fallback)
fn check_command_line_args() {
let args: Vec<String> = std::env::args().collect();
add_log(format!("🔍 DEBUG: All command line args: {:?}", args));
// Check command line arguments for file opening
for (i, arg) in args.iter().enumerate() {
add_log(format!("🔍 DEBUG: Arg {}: {}", i, arg));
if i > 0 && arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 File argument detected: {}", arg));
set_opened_file(arg.clone());
break; // Only handle the first PDF file
}
}
}
/// Handle runtime file open events (for future single-instance support)
#[allow(dead_code)]
pub fn handle_runtime_file_open(file_path: String) {
if file_path.ends_with(".pdf") && std::path::Path::new(&file_path).exists() {
add_log(format!("📂 Runtime file open: {}", file_path));
set_opened_file(file_path);
}
}
#[cfg(target_os = "macos")]
mod macos_native {
use objc::{class, msg_send, sel, sel_impl};
use objc::runtime::{Class, Object, Sel};
use cocoa::appkit::NSApplication;
use cocoa::base::{id, nil};
use once_cell::sync::Lazy;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
use crate::utils::add_log;
use crate::commands::set_opened_file;
// Static app handle storage
static APP_HANDLE: Lazy<Mutex<Option<AppHandle<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
// Store files opened during launch
static LAUNCH_FILES: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
extern "C" fn open_files(_self: &Object, _cmd: Sel, _sender: id, filenames: id) {
unsafe {
add_log(format!("📂 macOS native openFiles event called"));
// filenames is an NSArray of NSString objects
let count: usize = msg_send![filenames, count];
add_log(format!("📂 Number of files to open: {}", count));
for i in 0..count {
let filename: id = msg_send![filenames, objectAtIndex: i];
let cstr = {
let bytes: *const std::os::raw::c_char = msg_send![filename, UTF8String];
std::ffi::CStr::from_ptr(bytes)
};
if let Ok(path) = cstr.to_str() {
add_log(format!("📂 macOS file open: {}", path));
if path.ends_with(".pdf") {
// Always set the opened file for command-line interface
set_opened_file(path.to_string());
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
// App is running, emit event immediately
add_log(format!("✅ App running, emitting file event: {}", path));
let _ = app.emit("macos://open-file", path.to_string());
} else {
// App not ready yet, store for later processing
add_log(format!("🚀 App not ready, storing file for later: {}", path));
LAUNCH_FILES.lock().unwrap().push(path.to_string());
}
}
}
}
}
}
// Register the delegate immediately when the module loads
pub fn register_delegate_early() {
add_log("🔧 Registering macOS delegate early...".to_string());
unsafe {
let ns_app = NSApplication::sharedApplication(nil);
// Check if there's already a delegate
let existing_delegate: id = msg_send![ns_app, delegate];
if existing_delegate != nil {
add_log("⚠️ Tauri already has an NSApplication delegate, trying to extend it...".to_string());
// Try to add our method to the existing delegate's class
let delegate_class: id = msg_send![existing_delegate, class];
let class_name: *const std::os::raw::c_char = msg_send![delegate_class, name];
let class_name_str = std::ffi::CStr::from_ptr(class_name).to_string_lossy();
add_log(format!("🔍 Existing delegate class: {}", class_name_str));
// This approach won't work with existing classes, so let's try a different method
// We'll use method swizzling or create a new delegate that forwards to the old one
add_log("🔄 Will try alternative approach...".to_string());
}
let delegate_class = Class::get("StirlingAppDelegate").unwrap_or_else(|| {
let superclass = class!(NSObject);
let mut decl = objc::declare::ClassDecl::new("StirlingAppDelegate", superclass).unwrap();
// Add file opening delegate method (modern plural version)
decl.add_method(
sel!(application:openFiles:),
open_files as extern "C" fn(&Object, Sel, id, id)
);
decl.register()
});
let delegate: id = msg_send![delegate_class, new];
let _: () = msg_send![ns_app, setDelegate:delegate];
}
add_log("✅ macOS delegate registered early".to_string());
}
pub fn register_open_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Connecting app handle to file handler...".to_string());
// Store the app handle
*APP_HANDLE.lock().unwrap() = Some(app.clone());
// Process any files that were opened during launch
let launch_files = {
let mut files = LAUNCH_FILES.lock().unwrap();
let result = files.clone();
files.clear();
result
};
for file_path in launch_files {
add_log(format!("📂 Processing stored launch file: {}", file_path));
set_opened_file(file_path.clone());
let _ = app.emit("macos://open-file", file_path);
}
add_log("✅ macOS file handler connected successfully".to_string());
}
}

View File

@ -1,26 +1,22 @@
use tauri::{RunEvent, WindowEvent, Emitter};
use tauri::{RunEvent, WindowEvent};
#[cfg(target_os = "macos")]
use tauri::Emitter;
mod utils;
mod commands;
mod file_handler;
use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend, set_opened_file};
use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend};
#[cfg(target_os = "macos")]
use commands::set_opened_file;
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize file handler early for macOS
file_handler::early_init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
.setup(|_app| {
add_log("🚀 Tauri app setup started".to_string());
// Initialize platform-specific file handler
file_handler::initialize_file_handler(&app.handle());
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})

View File

@ -8,7 +8,7 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { AppConfigProvider, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
@ -31,22 +31,29 @@ function AppInitializer() {
return null;
}
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
export interface AppProvidersProps {
children: ReactNode;
appConfigRetryOptions?: AppConfigRetryOptions;
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
}
/**
* Core application providers
* Contains all providers needed for the core
*/
export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) {
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<OnboardingProvider>
<AppConfigProvider retryOptions={appConfigRetryOptions}>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />

View File

@ -1,5 +1,7 @@
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
export interface OperationButtonProps {
onClick?: () => void;
@ -31,8 +33,14 @@ const OperationButton = ({
'data-tour': dataTour
}: OperationButtonProps) => {
const { t } = useTranslation();
const { isHealthy, message: backendMessage } = useBackendHealth();
const blockedByBackend = !isHealthy;
const combinedDisabled = disabled || blockedByBackend;
const tooltipLabel = blockedByBackend
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))
: null;
return (
const button = (
<Button
type={type}
onClick={onClick}
@ -41,7 +49,7 @@ const OperationButton = ({
ml='md'
mt={mt}
loading={isLoading}
disabled={disabled}
disabled={combinedDisabled}
variant={variant}
color={color}
data-testid={dataTestId}
@ -54,6 +62,16 @@ const OperationButton = ({
}
</Button>
);
if (tooltipLabel) {
return (
<Tooltip content={tooltipLabel} position="top" arrow>
{button}
</Tooltip>
);
}
return button;
};
export default OperationButton;

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
/**
@ -41,6 +41,8 @@ export interface AppConfig {
error?: string;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
interface AppConfigContextValue {
config: AppConfig | null;
loading: boolean;
@ -59,26 +61,42 @@ const AppConfigContext = createContext<AppConfigContextValue | undefined>({
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export const AppConfigProvider: React.FC<{
export interface AppConfigProviderProps {
children: ReactNode;
retryOptions?: AppConfigRetryOptions;
}> = ({ children, retryOptions }) => {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
initialConfig?: AppConfig | null;
bootstrapMode?: AppConfigBootstrapMode;
autoFetch?: boolean;
}
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
children,
retryOptions,
initialConfig = null,
bootstrapMode = 'blocking',
autoFetch = true,
}) => {
const isBlockingMode = bootstrapMode === 'blocking';
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
const [error, setError] = useState<string | null>(null);
const [fetchCount, setFetchCount] = useState(0);
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
const [loading, setLoading] = useState(!hasResolvedConfig);
const maxRetries = retryOptions?.maxRetries ?? 0;
const initialDelay = retryOptions?.initialDelay ?? 1000;
const fetchConfig = async (force = false) => {
const fetchConfig = useCallback(async (force = false) => {
// Prevent duplicate fetches unless forced
if (!force && fetchCount > 0) {
console.debug('[AppConfig] Already fetched, skipping');
return;
}
setLoading(true);
const shouldBlockUI = !hasResolvedConfig || isBlockingMode;
if (shouldBlockUI) {
setLoading(true);
}
setError(null);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
@ -92,12 +110,13 @@ export const AppConfigProvider: React.FC<{
}
// apiClient automatically adds JWT header if available via interceptors
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
const data = response.data;
console.debug('[AppConfig] Config fetched successfully:', data);
setConfig(data);
setFetchCount(prev => prev + 1);
setHasResolvedConfig(true);
setLoading(false);
return; // Success - exit function
} catch (err: any) {
@ -108,6 +127,7 @@ export const AppConfigProvider: React.FC<{
if (status === 401) {
console.debug('[AppConfig] 401 error - using default config (login enabled)');
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
@ -124,20 +144,23 @@ export const AppConfigProvider: React.FC<{
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(errorMessage);
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
// On error, assume login is enabled (safe default)
setConfig({ enableLogin: true });
// Preserve existing config (initial default or previous fetch). If nothing is set, assume login enabled.
setConfig((current) => current ?? { enableLogin: true });
setHasResolvedConfig(true);
break;
}
}
setLoading(false);
};
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
useEffect(() => {
// Always try to fetch config to check if login is disabled
// The endpoint should be public and return proper JSON
fetchConfig();
}, []);
if (autoFetch) {
fetchConfig();
}
}, [autoFetch, fetchConfig]);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
@ -149,7 +172,7 @@ export const AppConfigProvider: React.FC<{
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, []);
}, [fetchConfig]);
const value: AppConfigContextValue = {
config,

View File

@ -12,6 +12,7 @@ import { ResponseHandler } from '@app/utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@ -187,6 +188,12 @@ export const useToolOperation = <TParams>(
return;
}
const backendReady = await ensureBackendReady();
if (!backendReady) {
actions.setError(t('backendHealth.offline', 'Embedded backend is offline. Please try again shortly.'));
return;
}
// Reset state
actions.setLoading(true);
actions.setError(null);

View File

@ -0,0 +1,12 @@
import type { BackendHealthState } from '@app/types/backendHealth';
export function useBackendHealth(): BackendHealthState {
return {
status: 'healthy',
message: null,
isChecking: false,
lastChecked: Date.now(),
error: null,
isHealthy: true,
};
}

View File

@ -0,0 +1,7 @@
/**
* Default backend readiness guard (web builds do not need to wait for
* anything outside the browser, so we always report ready).
*/
export async function ensureBackendReady(): Promise<boolean> {
return true;
}

View File

@ -0,0 +1,10 @@
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
export interface BackendHealthState {
status: BackendStatus;
message?: string | null;
lastChecked?: number;
isChecking: boolean;
error: string | null;
isHealthy: boolean;
}

View File

@ -1,5 +1,7 @@
import { ReactNode } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
/**
* Desktop application providers
@ -13,7 +15,13 @@ export function AppProviders({ children }: { children: ReactNode }) {
maxRetries: 5,
initialDelay: 1000, // 1 second, with exponential backoff
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<DesktopConfigSync />
{children}
</ProprietaryAppProviders>
);

View File

@ -0,0 +1,23 @@
import { useEffect, useRef } from 'react';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { useAppConfig } from '@app/contexts/AppConfigContext';
/**
* Desktop-only bridge that refetches the app config once the bundled backend
* becomes healthy (and whenever it restarts). Keeps the UI responsive by using
* default config until the real config is available.
*/
export function DesktopConfigSync() {
const { status } = useBackendHealth();
const { refetch } = useAppConfig();
const previousStatus = useRef(status);
useEffect(() => {
if (status === 'healthy' && previousStatus.current !== 'healthy') {
refetch();
}
previousStatus.current = status;
}, [status, refetch]);
return null;
}

View File

@ -0,0 +1,10 @@
import { AppConfig } from '@app/contexts/AppConfigContext';
/**
* Default configuration used while the bundled backend starts up.
*/
export const DESKTOP_DEFAULT_APP_CONFIG: AppConfig = {
enableLogin: false,
premiumEnabled: false,
runningProOrHigher: false,
};

View File

@ -0,0 +1,20 @@
import i18n from '@app/i18n';
export const BACKEND_NOT_READY_CODE = 'BACKEND_NOT_READY' as const;
export interface BackendNotReadyError extends Error {
code: typeof BACKEND_NOT_READY_CODE;
}
export function createBackendNotReadyError(): BackendNotReadyError {
return Object.assign(new Error(i18n.t('backendHealth.starting', 'Backend starting up...')), {
code: BACKEND_NOT_READY_CODE,
});
}
export function isBackendNotReadyError(error: unknown): error is BackendNotReadyError {
return typeof error === 'object'
&& error !== null
&& 'code' in error
&& (error as { code?: unknown }).code === BACKEND_NOT_READY_CODE;
}

View File

@ -1,91 +1,24 @@
import { useState, useEffect, useCallback } from 'react';
import { tauriBackendService } from '@app/services/tauriBackendService';
export type BackendStatus = 'starting' | 'healthy' | 'unhealthy' | 'stopped';
interface BackendHealthState {
status: BackendStatus;
message?: string;
lastChecked?: number;
isChecking: boolean;
error: string | null;
}
import { useEffect, useState, useCallback } from 'react';
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
import type { BackendHealthState } from '@app/types/backendHealth';
/**
* Hook to monitor backend health status with retries
* Hook to read the shared backend health monitor state.
* All consumers subscribe to a single poller managed by backendHealthMonitor.
*/
export function useBackendHealth(pollingInterval = 5000) {
const [health, setHealth] = useState<BackendHealthState>({
status: tauriBackendService.isBackendRunning() ? 'healthy' : 'stopped',
isChecking: false,
error: null,
});
const checkHealth = useCallback(async () => {
setHealth((current) => ({
...current,
status: current.status === 'healthy' ? 'healthy' : 'starting',
isChecking: true,
error: 'Backend starting up...',
lastChecked: Date.now(),
}));
try {
const isHealthy = await tauriBackendService.checkBackendHealth();
setHealth({
status: isHealthy ? 'healthy' : 'unhealthy',
lastChecked: Date.now(),
message: isHealthy ? 'Backend is healthy' : 'Backend is unavailable',
isChecking: false,
error: isHealthy ? null : 'Backend offline',
});
return isHealthy;
} catch (error) {
console.error('[BackendHealth] Health check failed:', error);
setHealth({
status: 'unhealthy',
lastChecked: Date.now(),
message: 'Backend is unavailable',
isChecking: false,
error: 'Backend offline',
});
return false;
}
}, []);
export function useBackendHealth() {
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
useEffect(() => {
let isMounted = true;
return backendHealthMonitor.subscribe(setHealth);
}, []);
const initialize = async () => {
setHealth((current) => ({
...current,
status: tauriBackendService.isBackendRunning() ? 'starting' : 'stopped',
isChecking: true,
error: 'Backend starting up...',
}));
await checkHealth();
if (!isMounted) return;
};
initialize();
const interval = setInterval(() => {
if (!isMounted) return;
void checkHealth();
}, pollingInterval);
return () => {
isMounted = false;
clearInterval(interval);
};
}, [checkHealth, pollingInterval]);
const checkHealth = useCallback(async () => {
return backendHealthMonitor.checkNow();
}, []);
return {
...health,
isHealthy: health.status === 'healthy',
checkHealth,
};
}

View File

@ -1,10 +1,30 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
interface EndpointConfig {
backendUrl: string;
}
const RETRY_DELAY_MS = 2500;
function getErrorMessage(err: unknown): string {
if (isAxiosError(err)) {
const data = err.response?.data as { message?: string } | undefined;
if (typeof data?.message === 'string') {
return data.message;
}
return err.message || 'Unknown error occurred';
}
if (err instanceof Error) {
return err.message;
}
return 'Unknown error occurred';
}
/**
* Desktop-specific endpoint checker that hits the backend directly via axios.
*/
@ -14,38 +34,88 @@ export function useEndpointEnabled(endpoint: string): {
error: string | null;
refetch: () => Promise<void>;
} {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const { t } = useTranslation();
const [enabled, setEnabled] = useState<boolean | null>(() => (endpoint ? true : null));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
useEffect(() => {
return () => {
isMountedRef.current = false;
clearRetryTimeout();
};
}, [clearRetryTimeout]);
const fetchEndpointStatus = useCallback(async () => {
clearRetryTimeout();
const fetchEndpointStatus = async () => {
if (!endpoint) {
if (!isMountedRef.current) return;
setEnabled(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await apiClient.get<boolean>('/api/v1/config/endpoint-enabled', {
params: { endpoint },
suppressErrorToast: true,
});
if (!isMountedRef.current) return;
setEnabled(response.data);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(message);
setEnabled(null);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (!isMountedRef.current) return;
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
setEnabled(true);
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
fetchEndpointStatus();
}, RETRY_DELAY_MS);
}
} finally {
setLoading(false);
if (isMountedRef.current) {
setLoading(false);
}
}
};
}, [endpoint, clearRetryTimeout]);
useEffect(() => {
fetchEndpointStatus();
}, [endpoint]);
if (!endpoint) {
setEnabled(null);
setLoading(false);
return;
}
if (tauriBackendService.isBackendHealthy()) {
fetchEndpointStatus();
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
fetchEndpointStatus();
}
});
return () => {
unsubscribe();
};
}, [endpoint, fetchEndpointStatus]);
return {
enabled,
@ -61,45 +131,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
error: string | null;
refetch: () => Promise<void>;
} {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const { t } = useTranslation();
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(() => {
if (!endpoints || endpoints.length === 0) return {};
return endpoints.reduce((acc, endpointName) => {
acc[endpointName] = true;
return acc;
}, {} as Record<string, boolean>);
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
useEffect(() => {
return () => {
isMountedRef.current = false;
clearRetryTimeout();
};
}, [clearRetryTimeout]);
const fetchAllEndpointStatuses = useCallback(async () => {
clearRetryTimeout();
const fetchAllEndpointStatuses = async () => {
if (!endpoints || endpoints.length === 0) {
if (!isMountedRef.current) return;
setEndpointStatus({});
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const endpointsParam = endpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
params: { endpoints: endpointsParam },
suppressErrorToast: true,
});
if (!isMountedRef.current) return;
setEndpointStatus(response.data);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(message);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (!isMountedRef.current) return;
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
acc[endpointName] = false;
acc[endpointName] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(fallbackStatus);
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
fetchAllEndpointStatuses();
}, RETRY_DELAY_MS);
}
} finally {
setLoading(false);
if (isMountedRef.current) {
setLoading(false);
}
}
};
}, [endpoints, clearRetryTimeout]);
useEffect(() => {
fetchAllEndpointStatuses();
}, [endpoints.join(',')]);
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
}
if (tauriBackendService.isBackendHealthy()) {
fetchAllEndpointStatuses();
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
fetchAllEndpointStatuses();
}
});
return () => {
unsubscribe();
};
}, [endpoints, fetchAllEndpointStatuses]);
return {
endpointStatus,

View File

@ -0,0 +1,44 @@
import { AxiosInstance } 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 i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
/**
* 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)
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
client.interceptors.request.use(
(config) => {
const skipCheck = config?.skipBackendReadyCheck === true;
if (skipCheck || tauriBackendService.isBackendHealthy()) {
return config;
}
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,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
);
}

View File

@ -0,0 +1,125 @@
import i18n from '@app/i18n';
import { tauriBackendService } from '@app/services/tauriBackendService';
import type { BackendHealthState } from '@app/types/backendHealth';
type Listener = (state: BackendHealthState) => void;
class BackendHealthMonitor {
private listeners = new Set<Listener>();
private intervalId: ReturnType<typeof setInterval> | null = null;
private readonly intervalMs: number;
private state: BackendHealthState = {
status: tauriBackendService.getBackendStatus(),
isChecking: false,
error: null,
isHealthy: tauriBackendService.getBackendStatus() === 'healthy',
};
constructor(pollingInterval = 5000) {
this.intervalMs = pollingInterval;
// Reflect status updates from the backend service immediately
tauriBackendService.subscribeToStatus((status) => {
this.updateState({
status,
error: status === 'healthy' ? null : this.state.error,
message: status === 'healthy'
? i18n.t('backendHealth.online', 'Backend Online')
: this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'),
isChecking: status === 'healthy' ? false : this.state.isChecking,
});
});
}
private updateState(partial: Partial<BackendHealthState>) {
const nextStatus = partial.status ?? this.state.status;
this.state = {
...this.state,
...partial,
status: nextStatus,
isHealthy: nextStatus === 'healthy',
};
this.listeners.forEach((listener) => listener(this.state));
}
private ensurePolling() {
if (this.intervalId !== null) {
return;
}
void this.pollOnce();
this.intervalId = setInterval(() => {
void this.pollOnce();
}, this.intervalMs);
}
private stopPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async pollOnce(): Promise<boolean> {
this.updateState({
isChecking: true,
lastChecked: Date.now(),
error: this.state.error ?? 'Backend offline',
});
try {
const healthy = await tauriBackendService.checkBackendHealth();
if (healthy) {
this.updateState({
status: 'healthy',
isChecking: false,
message: i18n.t('backendHealth.online', 'Backend Online'),
error: null,
lastChecked: Date.now(),
});
} else {
this.updateState({
status: 'unhealthy',
isChecking: false,
message: i18n.t('backendHealth.offline', 'Backend Offline'),
error: i18n.t('backendHealth.offline', 'Backend Offline'),
lastChecked: Date.now(),
});
}
return healthy;
} catch (error) {
console.error('[BackendHealthMonitor] Health check failed:', error);
this.updateState({
status: 'unhealthy',
isChecking: false,
message: 'Backend is unavailable',
error: 'Backend offline',
lastChecked: Date.now(),
});
return false;
}
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this.state);
if (this.listeners.size === 1) {
this.ensurePolling();
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.stopPolling();
}
};
}
getSnapshot(): BackendHealthState {
return this.state;
}
async checkNow(): Promise<boolean> {
return this.pollOnce();
}
}
export const backendHealthMonitor = new BackendHealthMonitor();

View File

@ -0,0 +1,35 @@
import i18n from '@app/i18n';
import { alert } from '@app/components/toast';
import { tauriBackendService } from '@app/services/tauriBackendService';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
/**
* Desktop-specific guard that ensures the embedded backend is healthy
* before tools attempt to call any API endpoints.
*/
export async function ensureBackendReady(): Promise<boolean> {
if (tauriBackendService.isBackendHealthy()) {
return true;
}
// Trigger a health check so we get the freshest status
await tauriBackendService.checkBackendHealth();
if (tauriBackendService.isBackendHealthy()) {
return true;
}
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.checking', 'Checking backend status...'),
isPersistentPopup: false,
});
}
return false;
}

View File

@ -1,8 +1,14 @@
import { invoke } from '@tauri-apps/api/core';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
export class TauriBackendService {
private static instance: TauriBackendService;
private backendStarted = false;
private backendStatus: BackendStatus = 'stopped';
private healthMonitor: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private statusListeners = new Set<(status: BackendStatus) => void>();
static getInstance(): TauriBackendService {
if (!TauriBackendService.instance) {
@ -15,32 +21,84 @@ export class TauriBackendService {
return this.backendStarted;
}
getBackendStatus(): BackendStatus {
return this.backendStatus;
}
isBackendHealthy(): boolean {
return this.backendStatus === 'healthy';
}
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
this.statusListeners.add(listener);
return () => {
this.statusListeners.delete(listener);
};
}
private setStatus(status: BackendStatus) {
if (this.backendStatus === status) {
return;
}
this.backendStatus = status;
this.statusListeners.forEach(listener => listener(status));
}
async startBackend(backendUrl?: string): Promise<void> {
if (this.backendStarted) {
return;
}
try {
const result = await invoke('start_backend', { backendUrl });
console.log('Backend started:', result);
this.backendStarted = true;
// Wait for backend to be healthy
await this.waitForHealthy();
} catch (error) {
console.error('Failed to start backend:', error);
throw error;
if (this.startPromise) {
return this.startPromise;
}
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then((result) => {
console.log('Backend started:', result);
this.backendStarted = true;
this.setStatus('starting');
this.beginHealthMonitoring();
})
.catch((error) => {
this.setStatus('unhealthy');
console.error('Failed to start backend:', error);
throw error;
})
.finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
private beginHealthMonitoring() {
if (this.healthMonitor) {
return;
}
this.healthMonitor = this.waitForHealthy()
.catch((error) => {
console.error('Backend failed to become healthy:', error);
})
.finally(() => {
this.healthMonitor = null;
});
}
async checkBackendHealth(): Promise<boolean> {
if (!this.backendStarted) {
this.setStatus('stopped');
return false;
}
try {
return await invoke('check_backend_health');
const isHealthy = await invoke<boolean>('check_backend_health');
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
console.error('Health check failed:', error);
this.setStatus('unhealthy');
return false;
}
}
@ -54,6 +112,7 @@ export class TauriBackendService {
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
this.setStatus('unhealthy');
throw new Error('Backend failed to become healthy after 60 seconds');
}
}

View File

@ -12,4 +12,16 @@ declare module 'assets/material-symbols-icons.json' {
export default value;
}
declare module 'axios' {
export interface AxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean;
skipBackendReadyCheck?: boolean;
}
export interface InternalAxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean;
skipBackendReadyCheck?: boolean;
}
}
export {};

View File

@ -1,9 +1,12 @@
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
import { AuthProvider } from "@app/auth/UseSession";
export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) {
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
<CoreAppProviders appConfigRetryOptions={appConfigRetryOptions}>
<CoreAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
{children}
</AuthProvider>