Files
Stirling-PDF/frontend/src/core/components/shared/MobileUploadModal.tsx
T
Anthony StirlingandGitHub 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

359 lines
12 KiB
TypeScript

import { useEffect, useCallback, useState, useRef } from 'react';
import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { withBasePath } from '@app/constants/app';
import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils';
interface MobileUploadModalProps {
opened: boolean;
onClose: () => void;
onFilesReceived: (files: File[]) => void;
}
// Generate a cryptographically secure UUID v4-like session ID
function generateSessionId(): string {
// Use Web Crypto API for cryptographically secure random values
const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
// Set version (4) and variant bits per RFC 4122
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
// Convert bytes to hex string in UUID format
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join(''),
].join('-');
}
// If Web Crypto is not available, fail fast rather than using insecure randomness
console.error('Web Crypto API not available. Cannot generate secure session ID.');
throw new Error('Web Crypto API not available. Cannot generate secure session ID.');
}
interface SessionInfo {
sessionId: string;
createdAt: number;
expiresAt: number;
timeoutMs: number;
}
/**
* MobileUploadModal
*
* Displays a QR code that mobile devices can scan to upload files via backend server.
* Files are temporarily stored on server and retrieved by desktop.
*/
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [sessionId, setSessionId] = useState(() => generateSessionId());
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [filesReceived, setFilesReceived] = useState(0);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const [showExpiryWarning, setShowExpiryWarning] = useState(false);
const pollIntervalRef = useRef<number | null>(null);
const timerIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set());
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const frontendUrl = config?.frontendUrl || window.location.origin;
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
// Create session on backend
const createSession = useCallback(async (newSessionId: string) => {
try {
const response = await fetch(`/api/v1/mobile-scanner/create-session/${newSessionId}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to create session');
}
const data = await response.json();
setSessionInfo(data);
setError(null);
console.log('Session created:', data);
} catch (err) {
console.error('Failed to create session:', err);
setError(t('mobileUpload.sessionCreateError', 'Failed to create session'));
}
}, [t]);
// Regenerate session (when expired or warned)
const regenerateSession = useCallback(() => {
const newSessionId = generateSessionId();
setSessionId(newSessionId);
setShowExpiryWarning(false);
setFilesReceived(0);
processedFiles.current.clear();
createSession(newSessionId);
}, [createSession]);
const pollForFiles = useCallback(async () => {
if (!opened) return;
try {
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to check for files');
}
const data = await response.json();
const files = data.files || [];
// Download only files we haven't processed yet
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
if (newFiles.length > 0) {
for (const fileMetadata of newFiles) {
try {
const downloadResponse = await fetch(
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
);
if (downloadResponse.ok) {
const blob = await downloadResponse.blob();
let file = new File([blob], fileMetadata.filename, {
type: fileMetadata.contentType || 'image/jpeg'
});
// Convert images to PDF if enabled
if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) {
try {
file = await convertImageToPdf(file, {
imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined,
pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined,
stretchToFit: config?.mobileScannerStretchToFit,
});
console.log('Converted image to PDF:', file.name);
} catch (convertError) {
console.warn('Failed to convert image to PDF, using original file:', convertError);
// Continue with original image file if conversion fails
}
}
processedFiles.current.add(fileMetadata.filename);
setFilesReceived((prev) => prev + 1);
onFilesReceived([file]);
}
} catch (err) {
console.error('Failed to download file:', fileMetadata.filename, err);
}
}
// Delete the entire session immediately after downloading all files
// This ensures files are only on server for ~1 second
try {
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
console.log('Session cleaned up after file download');
} catch (cleanupErr) {
console.warn('Failed to cleanup session after download:', cleanupErr);
}
}
} catch (err) {
console.error('Error polling for files:', err);
setError(t('mobileUpload.pollingError', 'Error checking for files'));
}
}, [opened, sessionId, onFilesReceived, t]);
// Create session when modal opens
useEffect(() => {
if (opened) {
createSession(sessionId);
setFilesReceived(0);
setError(null);
setShowExpiryWarning(false);
processedFiles.current.clear();
} else {
// Clean up session when modal closes
if (sessionId) {
fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' })
.catch(err => console.warn('Failed to cleanup session on close:', err));
}
}
}, [opened]); // Only run when opened changes
// Start polling for files when modal opens
useEffect(() => {
if (opened && sessionInfo) {
// Poll every 2 seconds
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
// Initial poll
pollForFiles();
} else {
// Stop polling when modal closes
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [opened, sessionInfo, pollForFiles]);
// Session timeout timer
useEffect(() => {
if (!opened || !sessionInfo) return;
const updateTimer = () => {
const now = Date.now();
const remaining = sessionInfo.expiresAt - now;
if (remaining <= 0) {
// Session expired - regenerate
setShowExpiryWarning(false);
regenerateSession();
} else if (remaining <= 60000 && !showExpiryWarning) {
// Less than 1 minute remaining - show warning
setShowExpiryWarning(true);
}
setTimeRemaining(Math.max(0, remaining));
};
// Update immediately
updateTimer();
// Update every second
timerIntervalRef.current = window.setInterval(updateTimer, 1000);
return () => {
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
}
};
}, [opened, sessionInfo, showExpiryWarning, regenerateSession]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t('mobileUpload.title', 'Upload from Mobile')}
centered
size="md"
radius="lg"
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
overlayProps={{ opacity: 0.35, blur: 2 }}
styles={{
body: {
paddingTop: '1.5rem',
},
}}
>
<Stack gap="md">
<Alert
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
color="blue"
variant="light"
>
<Text size="sm">
{config?.mobileScannerConvertToPdf !== false
? t(
'mobileUpload.description',
'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.'
)
: t(
'mobileUpload.descriptionNoConvert',
'Scan this QR code with your mobile device to upload photos.'
)}
</Text>
</Alert>
{showExpiryWarning && timeRemaining !== null && (
<Alert
icon={<WarningRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')}
color="orange"
>
<Text size="sm">
{t(
'mobileUpload.expiryWarningMessage',
'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.',
{ seconds: Math.ceil(timeRemaining / 1000) }
)}
</Text>
</Alert>
)}
{error && (
<Alert
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.error', 'Connection Error')}
color="red"
>
<Text size="sm">{error}</Text>
</Alert>
)}
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
</Box>
{filesReceived > 0 && (
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
</Badge>
)}
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
{config?.mobileScannerConvertToPdf !== false
? t(
'mobileUpload.instructions',
'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.'
)
: t(
'mobileUpload.instructionsNoConvert',
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
)}
</Text>
<Text
size="xs"
c="dimmed"
style={{
wordBreak: 'break-all',
textAlign: 'center',
fontFamily: 'monospace',
}}
>
{mobileUrl}
</Text>
</Box>
</Stack>
</Modal>
);
}