Files
Stirling-PDF/frontend/src/core/contexts/AppConfigContext.tsx
Anthony Stirling d2677e64dd OCR fix and Mobile QR changes (#5433)
# Description of Changes
## OCR / Tesseract path handling

Makes tessDataPath resolution deterministic with priority: config >
TESSDATA_PREFIX env > default.
Updates language discovery to use runtimePathConfig.getTessDataPath()
instead of raw config value.
Ensure default OCR dir is debian based not alpine

## Mobile scanner: feature gating + new conversion settings
Adds system.mobileScannerSettings (convert-to-PDF + resolution + page
format + stretch) exposed via backend config and configurable in the
proprietary admin UI.
Enforces enableMobileScanner on the MobileScannerController endpoints
(403 when disabled).
Frontend mobile upload flow can now optionally convert received images
to PDF (pdf-lib + canvas).

## Desktop/Tauri connectivity work
Expands tauri-plugin-http permissions and enables dangerous-settings.
Adds a very comprehensive multi-stage server connection diagnostic
routine (with lots of logging).


<img width="688" height="475" alt="image"
src="https://github.com/user-attachments/assets/6f9c1aec-58c7-449b-96b0-52f25430d741"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-12 11:18:37 +00:00

258 lines
8.2 KiB
TypeScript

import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
/**
* Sleep utility for delays
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export interface AppConfigRetryOptions {
maxRetries?: number;
initialDelay?: number;
}
export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
frontendUrl?: string;
appNameNavbar?: string;
languages?: string[];
defaultLocale?: string;
logoStyle?: 'modern' | 'classic';
enableLogin?: boolean;
showSettingsWhenNoLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
privacyPolicy?: string;
cookiePolicy?: string;
impressum?: string;
accessibilityStatement?: string;
runningProOrHigher?: boolean;
runningEE?: boolean;
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
enableMobileScanner?: boolean;
mobileScannerConvertToPdf?: boolean;
mobileScannerImageResolution?: string;
mobileScannerPageFormat?: string;
mobileScannerStretchToFit?: boolean;
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
dependenciesReady?: boolean;
error?: string;
isNewServer?: boolean;
isNewUser?: boolean;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
interface AppConfigContextValue {
config: AppConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
const AppConfigContext = createContext<AppConfigContextValue | undefined>({
config: null,
loading: true,
error: null,
refetch: async () => {},
});
/**
* 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 interface AppConfigProviderProps {
children: ReactNode;
retryOptions?: AppConfigRetryOptions;
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);
// Track how many times we've attempted to fetch. useRef avoids re-renders that can trigger loops.
const fetchCountRef = React.useRef(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 = useCallback(async (force = false) => {
// Prevent duplicate fetches unless forced
if (!force && fetchCountRef.current > 0) {
console.debug('[AppConfig] Already fetched, skipping');
return;
}
// Mark that we've attempted a fetch to prevent repeated auto-fetch loops
fetchCountRef.current += 1;
const shouldBlockUI = !hasResolvedConfig || isBlockingMode;
if (shouldBlockUI) {
setLoading(true);
}
setError(null);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const testConfig = getSimulatedAppConfig();
if (testConfig) {
setConfig(testConfig);
setHasResolvedConfig(true);
setLoading(false);
return;
}
if (attempt > 0) {
const delay = initialDelay * Math.pow(2, attempt - 1);
console.log(`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`);
await sleep(delay);
} else {
console.log('[AppConfig] Fetching app config...');
}
// apiClient automatically adds JWT header if available via interceptors
// Always suppress error toast - we handle 401 errors locally
const response = await apiClient.get<AppConfig>(
'/api/v1/config/app-config',
{
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
);
const data = response.data;
console.debug('[AppConfig] Config fetched successfully:', data);
setConfig(data);
setHasResolvedConfig(true);
setLoading(false);
return; // Success - exit function
} catch (err: any) {
const status = err?.response?.status;
// On 401 (not authenticated), use default config with login enabled
// This allows the app to work even without authentication
if (status === 401) {
console.debug('[AppConfig] 401 error - using default config (login enabled)');
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
// Check if we should retry (network errors or 5xx errors)
const shouldRetry = (!status || status >= 500) && attempt < maxRetries;
if (shouldRetry) {
console.warn(`[AppConfig] Attempt ${attempt + 1} failed (status ${status || 'network error'}):`, err.message, '- will retry...');
continue;
}
// Final attempt failed or non-retryable error (4xx)
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);
// 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);
}, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
useEffect(() => {
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
// Config will be fetched after successful authentication via jwt-available event
const currentPath = window.location.pathname;
const isAuthPage = currentPath.includes('/login') ||
currentPath.includes('/signup') ||
currentPath.includes('/auth/callback') ||
currentPath.includes('/invite/');
// On auth pages, always skip the config fetch
// The config will be fetched after authentication via jwt-available event
if (isAuthPage) {
console.debug('[AppConfig] On auth page - using default config, skipping fetch');
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
// On non-auth pages, fetch config (will validate JWT if present)
if (autoFetch) {
fetchConfig();
}
}, [autoFetch, fetchConfig]);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
// Force refetch with JWT
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [fetchConfig]);
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
const value: AppConfigContextValue = {
config,
loading,
error,
refetch,
};
return (
<AppConfigContext.Provider value={value}>
{children}
</AppConfigContext.Provider>
);
};
/**
* Hook to access application configuration
* Must be used within AppConfigProvider
*/
export function useAppConfig(): AppConfigContextValue {
const context = useContext(AppConfigContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within AppConfigProvider');
}
return context;
}