mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
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.
This commit is contained in:
@@ -441,6 +441,13 @@ currentVersion = "Current Version"
|
||||
latestVersion = "Latest Version"
|
||||
checkForUpdates = "Check for Updates"
|
||||
viewDetails = "View Details"
|
||||
serverNeedsUpdate = "Server needs to be updated by administrator"
|
||||
|
||||
[settings.general.versionInfo]
|
||||
title = "Version Information"
|
||||
description = "Desktop and server version details"
|
||||
desktop = "Desktop Version"
|
||||
server = "Server Version"
|
||||
|
||||
[settings.security]
|
||||
title = "Security"
|
||||
@@ -4277,6 +4284,8 @@ fetchError = "Failed to load settings"
|
||||
saveError = "Failed to save settings"
|
||||
saved = "Settings saved successfully"
|
||||
saveSuccess = "Settings saved successfully"
|
||||
success = "Settings saved successfully"
|
||||
error = "Failed to save settings"
|
||||
save = "Save Changes"
|
||||
discard = "Discard"
|
||||
restartRequired = "Restart Required"
|
||||
@@ -4543,6 +4552,19 @@ connect = "Connect"
|
||||
disconnect = "Disconnect"
|
||||
disconnected = "Provider disconnected successfully"
|
||||
disconnectError = "Failed to disconnect provider"
|
||||
mobileScannerConvertToPdf = "Convert Images to PDF"
|
||||
mobileScannerConvertToPdfDesc = "Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is."
|
||||
mobileScannerImageResolution = "Image Resolution"
|
||||
mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" scales images to max 1200px to reduce file size."
|
||||
imageResolutionFull = "Full (Original Size)"
|
||||
imageResolutionReduced = "Reduced (Max 1200px)"
|
||||
mobileScannerPageFormat = "Page Format"
|
||||
mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions."
|
||||
pageFormatKeep = "Keep (Original Dimensions)"
|
||||
pageFormatA4 = "A4 (210×297mm)"
|
||||
pageFormatLetter = "Letter (8.5×11in)"
|
||||
mobileScannerStretchToFit = "Stretch to Fit"
|
||||
mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio."
|
||||
|
||||
[admin.settings.connections.ssoAutoLogin]
|
||||
label = "SSO Auto Login"
|
||||
@@ -4617,6 +4639,19 @@ enable = "Enable QR Code Upload"
|
||||
description = "Allow users to upload files from mobile devices by scanning a QR code"
|
||||
note = "Note: Requires Frontend URL to be configured. "
|
||||
link = "Configure in System Settings"
|
||||
mobileScannerConvertToPdf = "Convert Images to PDF"
|
||||
mobileScannerConvertToPdfDesc = "Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is."
|
||||
mobileScannerImageResolution = "Image Resolution"
|
||||
mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" scales images to max 1200px to reduce file size."
|
||||
imageResolutionFull = "Full (Original Size)"
|
||||
imageResolutionReduced = "Reduced (Max 1200px)"
|
||||
mobileScannerPageFormat = "Page Format"
|
||||
mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions."
|
||||
pageFormatKeep = "Keep (Original Dimensions)"
|
||||
pageFormatA4 = "A4 (210×297mm)"
|
||||
pageFormatLetter = "Letter (8.5×11in)"
|
||||
mobileScannerStretchToFit = "Stretch to Fit"
|
||||
mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio."
|
||||
|
||||
[admin.settings.database]
|
||||
title = "Database"
|
||||
@@ -4990,6 +5025,7 @@ noRecentFiles = "No recent files found"
|
||||
googleDriveNotAvailable = "Google Drive integration not available"
|
||||
mobileUpload = "Mobile Upload"
|
||||
mobileShort = "Mobile"
|
||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||
downloadSelected = "Download Selected"
|
||||
saveSelected = "Save Selected"
|
||||
openFiles = "Open Files"
|
||||
@@ -6464,7 +6500,8 @@ failed = "An error occurred while adding text to the PDF."
|
||||
|
||||
[mobileUpload]
|
||||
title = "Upload from Mobile"
|
||||
description = "Scan this QR code with your mobile device to upload photos directly to this page."
|
||||
description = "Scan to upload photos. Images auto-convert to PDF."
|
||||
descriptionNoConvert = "Scan to upload photos from your mobile device."
|
||||
error = "Connection Error"
|
||||
pollingError = "Error checking for files"
|
||||
sessionId = "Session ID"
|
||||
@@ -6473,7 +6510,8 @@ expiryWarning = "Session Expiring Soon"
|
||||
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
|
||||
filesReceived = "{{count}} file(s) received"
|
||||
connected = "Mobile device connected"
|
||||
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
|
||||
instructions = "Scan with your phone camera. Images convert to PDF automatically."
|
||||
instructionsNoConvert = "Scan with your phone camera to upload files."
|
||||
|
||||
[mobileScanner]
|
||||
title = "Mobile Scanner"
|
||||
|
||||
@@ -28,7 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.1.0"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-http = "2.4.4"
|
||||
tauri-plugin-http = { version = "2.4.4", features = ["dangerous-settings"] }
|
||||
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||
tauri-plugin-store = "2.1.0"
|
||||
tauri-plugin-opener = "2.0.0"
|
||||
|
||||
@@ -13,9 +13,29 @@
|
||||
"allow": [
|
||||
{ "url": "http://*" },
|
||||
{ "url": "http://*:*" },
|
||||
{ "url": "https://*" }
|
||||
{ "url": "https://*" },
|
||||
{ "url": "https://*:*" },
|
||||
{ "url": "http://192.168.*.*" },
|
||||
{ "url": "http://192.168.*.*:*" },
|
||||
{ "url": "https://192.168.*.*" },
|
||||
{ "url": "https://192.168.*.*:*" },
|
||||
{ "url": "http://10.*.*.*" },
|
||||
{ "url": "http://10.*.*.*:*" },
|
||||
{ "url": "https://10.*.*.*" },
|
||||
{ "url": "https://10.*.*.*:*" },
|
||||
{ "url": "http://172.16.*.*" },
|
||||
{ "url": "http://172.16.*.*:*" },
|
||||
{ "url": "https://172.16.*.*" },
|
||||
{ "url": "https://172.16.*.*:*" },
|
||||
{ "url": "http://localhost:*" },
|
||||
{ "url": "https://localhost:*" },
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "https://127.0.0.1:*" }
|
||||
]
|
||||
},
|
||||
"http:allow-fetch-cancel",
|
||||
"http:allow-fetch-read-body",
|
||||
"http:allow-fetch-send",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
@@ -24,6 +26,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
const icons = useFileActionIcons();
|
||||
const UploadIcon = icons.upload;
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUploadEnabled = config?.enableMobileScanner && !isMobile;
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
@@ -127,15 +132,17 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
onClick={handleMobileUploadClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled={!isMobileUploadEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||
backgroundColor: isMobileUploadEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isMobileUploadEnabled ? t('fileManager.mobileUploadNotAvailable', 'Mobile upload not available') : undefined}
|
||||
>
|
||||
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -132,10 +133,25 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const blob = await downloadResponse.blob();
|
||||
const file = new File([blob], fileMetadata.filename, {
|
||||
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]);
|
||||
@@ -256,10 +272,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'mobileUpload.description',
|
||||
'Scan this QR code with your mobile device to upload photos directly to this page.'
|
||||
)}
|
||||
{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>
|
||||
|
||||
@@ -308,10 +329,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
|
||||
{t(
|
||||
'mobileUpload.instructions',
|
||||
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
|
||||
)}
|
||||
{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
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface AppConfig {
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
enableMobileScanner?: boolean;
|
||||
mobileScannerConvertToPdf?: boolean;
|
||||
mobileScannerImageResolution?: string;
|
||||
mobileScannerPageFormat?: string;
|
||||
mobileScannerStretchToFit?: boolean;
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
|
||||
297
frontend/src/core/utils/imageToPdfUtils.ts
Normal file
297
frontend/src/core/utils/imageToPdfUtils.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { PDFDocument, PageSizes } from 'pdf-lib';
|
||||
|
||||
export interface ImageToPdfOptions {
|
||||
imageResolution?: 'full' | 'reduced';
|
||||
pageFormat?: 'keep' | 'A4' | 'letter';
|
||||
stretchToFit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file to a PDF file
|
||||
* @param imageFile - The image file to convert (JPEG, PNG, etc.)
|
||||
* @param options - Conversion options
|
||||
* @returns A Promise that resolves to a PDF File object
|
||||
*/
|
||||
export async function convertImageToPdf(
|
||||
imageFile: File,
|
||||
options: ImageToPdfOptions = {}
|
||||
): Promise<File> {
|
||||
const {
|
||||
imageResolution = 'full',
|
||||
pageFormat = 'A4',
|
||||
stretchToFit = false,
|
||||
} = options;
|
||||
try {
|
||||
// Create a new PDF document
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
// Read the image file as an array buffer
|
||||
let imageBytes = await imageFile.arrayBuffer();
|
||||
|
||||
// Apply image resolution reduction if requested
|
||||
if (imageResolution === 'reduced') {
|
||||
const reducedImage = await reduceImageResolution(imageFile, 1200); // Max 1200px on longest side
|
||||
imageBytes = await reducedImage.arrayBuffer();
|
||||
}
|
||||
|
||||
// Embed the image based on its type
|
||||
let image;
|
||||
const imageType = imageFile.type.toLowerCase();
|
||||
|
||||
if (imageType === 'image/jpeg' || imageType === 'image/jpg') {
|
||||
image = await pdfDoc.embedJpg(imageBytes);
|
||||
} else if (imageType === 'image/png') {
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
} else {
|
||||
// For other image types, try to convert to PNG first using canvas
|
||||
const convertedImage = await convertImageToPng(imageFile);
|
||||
const convertedBytes = await convertedImage.arrayBuffer();
|
||||
image = await pdfDoc.embedPng(convertedBytes);
|
||||
}
|
||||
|
||||
// Get image dimensions
|
||||
const { width: imageWidth, height: imageHeight } = image;
|
||||
|
||||
// Determine page dimensions based on pageFormat option
|
||||
let pageWidth: number;
|
||||
let pageHeight: number;
|
||||
|
||||
if (pageFormat === 'keep') {
|
||||
// Use original image dimensions
|
||||
pageWidth = imageWidth;
|
||||
pageHeight = imageHeight;
|
||||
} else if (pageFormat === 'letter') {
|
||||
// US Letter: 8.5" x 11" = 612 x 792 points
|
||||
pageWidth = PageSizes.Letter[0];
|
||||
pageHeight = PageSizes.Letter[1];
|
||||
} else {
|
||||
// A4: 210mm x 297mm = 595 x 842 points (default)
|
||||
pageWidth = PageSizes.A4[0];
|
||||
pageHeight = PageSizes.A4[1];
|
||||
}
|
||||
|
||||
// Adjust page orientation based on image orientation if using standard page size
|
||||
if (pageFormat !== 'keep') {
|
||||
const imageIsLandscape = imageWidth > imageHeight;
|
||||
const pageIsLandscape = pageWidth > pageHeight;
|
||||
|
||||
// Rotate page to match image orientation
|
||||
if (imageIsLandscape !== pageIsLandscape) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a page
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
|
||||
// Calculate image placement based on stretchToFit option
|
||||
let drawX: number;
|
||||
let drawY: number;
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
|
||||
if (stretchToFit || pageFormat === 'keep') {
|
||||
// Stretch/fill to page
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageHeight;
|
||||
} else {
|
||||
// Fit within page bounds while preserving aspect ratio
|
||||
const imageAspectRatio = imageWidth / imageHeight;
|
||||
const pageAspectRatio = pageWidth / pageHeight;
|
||||
|
||||
if (imageAspectRatio > pageAspectRatio) {
|
||||
// Image is wider than page - fit to width
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageWidth / imageAspectRatio;
|
||||
drawX = 0;
|
||||
drawY = (pageHeight - drawHeight) / 2; // Center vertically
|
||||
} else {
|
||||
// Image is taller than page - fit to height
|
||||
drawHeight = pageHeight;
|
||||
drawWidth = pageHeight * imageAspectRatio;
|
||||
drawY = 0;
|
||||
drawX = (pageWidth - drawWidth) / 2; // Center horizontally
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the image on the page
|
||||
page.drawImage(image, {
|
||||
x: drawX,
|
||||
y: drawY,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
});
|
||||
|
||||
// Save the PDF to bytes
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
|
||||
// Create a filename by replacing the image extension with .pdf
|
||||
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
|
||||
|
||||
// Create a File object from the PDF bytes
|
||||
const pdfFile = new File([new Uint8Array(pdfBytes)], pdfFilename, {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
return pdfFile;
|
||||
} catch (error) {
|
||||
console.error('Error converting image to PDF:', error);
|
||||
throw new Error(
|
||||
`Failed to convert image to PDF: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file to PNG using canvas
|
||||
* This is used for image types that pdf-lib doesn't directly support
|
||||
*/
|
||||
async function convertImageToPng(imageFile: File): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// Create a canvas with the image dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// Draw the image on the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Convert canvas to blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a File object from the blob
|
||||
const pngFilename = imageFile.name.replace(/\.[^.]+$/, '.png');
|
||||
const pngFile = new File([blob], pngFilename, {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(pngFile);
|
||||
},
|
||||
'image/png',
|
||||
1.0
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce image resolution to a maximum dimension
|
||||
* @param imageFile - The image file to reduce
|
||||
* @param maxDimension - Maximum width or height in pixels
|
||||
* @returns A Promise that resolves to a reduced resolution image file
|
||||
*/
|
||||
async function reduceImageResolution(
|
||||
imageFile: File,
|
||||
maxDimension: number
|
||||
): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const { width, height } = img;
|
||||
|
||||
// Check if reduction is needed
|
||||
if (width <= maxDimension && height <= maxDimension) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(imageFile); // No reduction needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new dimensions while preserving aspect ratio
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
if (width > height) {
|
||||
newWidth = maxDimension;
|
||||
newHeight = (height / width) * maxDimension;
|
||||
} else {
|
||||
newHeight = maxDimension;
|
||||
newWidth = (width / height) * maxDimension;
|
||||
}
|
||||
|
||||
// Create a canvas with the new dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
// Draw the resized image on the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
// Convert canvas to blob (preserve original format if possible)
|
||||
const outputType = imageFile.type.startsWith('image/')
|
||||
? imageFile.type
|
||||
: 'image/jpeg';
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a File object from the blob
|
||||
const reducedFile = new File([blob], imageFile.name, {
|
||||
type: outputType,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(reducedFile);
|
||||
},
|
||||
outputType,
|
||||
0.9 // Quality (only applies to JPEG)
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image
|
||||
*/
|
||||
export function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
@@ -75,12 +75,12 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
console.log('[ServerSelection] Fetching login configuration...');
|
||||
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
|
||||
|
||||
// Check if security is disabled (status 403 or error response)
|
||||
// Check if security is disabled (status 403, 401, or 404 - endpoint doesn't exist)
|
||||
if (!response.ok) {
|
||||
console.warn(`[ServerSelection] Login config request failed with status ${response.status}`);
|
||||
|
||||
if (response.status === 403 || response.status === 401) {
|
||||
console.log('[ServerSelection] Security is disabled on this server');
|
||||
if (response.status === 403 || response.status === 401 || response.status === 404) {
|
||||
console.log('[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)');
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,20 @@ export interface ConnectionConfig {
|
||||
server_config: ServerConfig | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticResult {
|
||||
stage: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
diagnostics?: DiagnosticResult[];
|
||||
}
|
||||
|
||||
export class ConnectionModeService {
|
||||
private static instance: ConnectionModeService;
|
||||
private currentConfig: ConnectionConfig | null = null;
|
||||
@@ -106,97 +120,766 @@ export class ConnectionModeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to a server URL and return detailed error information
|
||||
* @returns Object with success status and optional error message
|
||||
* Test connection to a server URL with comprehensive multi-stage diagnostics
|
||||
* @returns Detailed test results with diagnostics and recommendations
|
||||
*/
|
||||
async testConnection(url: string): Promise<{ success: boolean; error?: string; errorCode?: string }> {
|
||||
console.log(`[ConnectionModeService] Testing connection to: ${url}`);
|
||||
async testConnection(url: string): Promise<ConnectionTestResult> {
|
||||
console.log(`[ConnectionModeService] 🔍 Starting comprehensive connection diagnostics for: ${url}`);
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION START ====================`);
|
||||
console.log(`[ConnectionModeService] System Information:`);
|
||||
console.log(`[ConnectionModeService] - User Agent: ${navigator.userAgent}`);
|
||||
console.log(`[ConnectionModeService] - Platform: ${navigator.platform}`);
|
||||
console.log(`[ConnectionModeService] - Online: ${navigator.onLine}`);
|
||||
console.log(`[ConnectionModeService] - Connection Type: ${(navigator as any).connection?.effectiveType || 'unknown'}`);
|
||||
console.log(`[ConnectionModeService] - Language: ${navigator.language}`);
|
||||
console.log(`[ConnectionModeService] - Cookies Enabled: ${navigator.cookieEnabled}`);
|
||||
console.log(`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || 'unknown'} cores`);
|
||||
console.log(`[ConnectionModeService] - Max Touch Points: ${navigator.maxTouchPoints}`);
|
||||
|
||||
try {
|
||||
// Test connection by hitting the health/status endpoint
|
||||
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
||||
console.log(`[ConnectionModeService] Health check URL: ${healthUrl}`);
|
||||
// Check for proxy environment variables
|
||||
console.log(`[ConnectionModeService] Environment Check:`);
|
||||
const envProxy = (window as any).process?.env?.HTTP_PROXY || (window as any).process?.env?.HTTPS_PROXY;
|
||||
if (envProxy) {
|
||||
console.log(`[ConnectionModeService] - Proxy detected: ${envProxy}`);
|
||||
} else {
|
||||
console.log(`[ConnectionModeService] - No proxy environment variables detected`);
|
||||
}
|
||||
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
// Check if running in Tauri (v2 uses different detection)
|
||||
console.log(`[ConnectionModeService] - Checking Tauri context...`);
|
||||
console.log(`[ConnectionModeService] - window.__TAURI__ type: ${typeof (window as any).__TAURI__}`);
|
||||
console.log(`[ConnectionModeService] - window.__TAURI_INTERNALS__ type: ${typeof (window as any).__TAURI_INTERNALS__}`);
|
||||
console.log(`[ConnectionModeService] - window.location.href:`, window.location.href);
|
||||
console.log(`[ConnectionModeService] - window.location.protocol:`, window.location.protocol);
|
||||
|
||||
// Tauri v2 detection: check for __TAURI_INTERNALS__ or tauri:// protocol
|
||||
const isTauriV2 = typeof (window as any).__TAURI_INTERNALS__ !== 'undefined' ||
|
||||
window.location.protocol === 'tauri:' ||
|
||||
window.location.hostname === 'tauri.localhost';
|
||||
const isTauriV1 = typeof (window as any).__TAURI__ !== 'undefined';
|
||||
const isTauri = isTauriV1 || isTauriV2;
|
||||
|
||||
console.log(`[ConnectionModeService] - Running in Tauri v1: ${isTauriV1}`);
|
||||
console.log(`[ConnectionModeService] - Running in Tauri v2: ${isTauriV2}`);
|
||||
console.log(`[ConnectionModeService] - Running in Tauri: ${isTauri}`);
|
||||
|
||||
if (isTauri) {
|
||||
if (isTauriV1) {
|
||||
const tauriApi = (window as any).__TAURI__;
|
||||
console.log(`[ConnectionModeService] - Tauri v1 API:`, tauriApi);
|
||||
}
|
||||
if (isTauriV2) {
|
||||
console.log(`[ConnectionModeService] - Tauri v2 detected via internals/protocol`);
|
||||
const internals = (window as any).__TAURI_INTERNALS__;
|
||||
console.log(`[ConnectionModeService] - Tauri internals:`, internals);
|
||||
}
|
||||
}
|
||||
|
||||
const diagnostics: DiagnosticResult[] = [];
|
||||
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
||||
const isLocal = this.isLocalAddress(url);
|
||||
const isHttpUrl = url.startsWith('http://');
|
||||
const isHttpsUrl = url.startsWith('https://');
|
||||
|
||||
console.log(`[ConnectionModeService] Connection Parameters:`);
|
||||
console.log(`[ConnectionModeService] - Target URL: ${url}`);
|
||||
console.log(`[ConnectionModeService] - Health endpoint: ${healthUrl}`);
|
||||
console.log(`[ConnectionModeService] - Is local address: ${isLocal}`);
|
||||
console.log(`[ConnectionModeService] - Protocol: ${isHttpUrl ? 'HTTP' : isHttpsUrl ? 'HTTPS' : 'Unknown'}`);
|
||||
console.log(`[ConnectionModeService] ================================================================`);
|
||||
|
||||
// STAGE 1: Test the protocol they specified
|
||||
if (isHttpUrl) {
|
||||
console.log(`[ConnectionModeService] Stage 1: Testing HTTP (as specified in URL)`);
|
||||
const stage1Result = await this.testHTTP(healthUrl, 'Stage 1: HTTP (as specified)');
|
||||
diagnostics.push(stage1Result);
|
||||
|
||||
if (stage1Result.success) {
|
||||
console.log(`[ConnectionModeService] ✅ Connection successful with HTTP`);
|
||||
|
||||
// Log success summary
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
|
||||
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`);
|
||||
console.log(`[ConnectionModeService] Protocol: HTTP (as requested by user)`);
|
||||
console.log(`[ConnectionModeService] Duration: ${stage1Result.duration}ms`);
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`[ConnectionModeService] ✅ Server connection test successful`);
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorMsg = `Server returned status ${response.status}`;
|
||||
console.error(`[ConnectionModeService] ❌ ${errorMsg}`);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
errorCode: `HTTP_${response.status}`,
|
||||
success: true,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConnectionModeService] ❌ Server connection test failed:', error);
|
||||
|
||||
// Extract detailed error information
|
||||
if (error instanceof Error) {
|
||||
const errMsg = error.message.toLowerCase();
|
||||
// HTTP failed, try HTTPS as fallback
|
||||
console.log(`[ConnectionModeService] Stage 2: HTTP failed, trying HTTPS`);
|
||||
const httpsUrl = healthUrl.replace('http://', 'https://');
|
||||
const stage2Result = await this.testHTTPS(httpsUrl, 'Stage 2: Trying HTTPS', false);
|
||||
diagnostics.push(stage2Result);
|
||||
|
||||
// Connection refused
|
||||
if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection refused. Server may not be running or the port is incorrect.',
|
||||
errorCode: 'CONNECTION_REFUSED',
|
||||
};
|
||||
}
|
||||
// Timeout
|
||||
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection timed out. Server is not responding within 10 seconds.',
|
||||
errorCode: 'TIMEOUT',
|
||||
};
|
||||
}
|
||||
// DNS failure
|
||||
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot resolve server address. Please check the URL is correct.',
|
||||
errorCode: 'DNS_FAILURE',
|
||||
};
|
||||
}
|
||||
// SSL/TLS errors
|
||||
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SSL/TLS certificate error. Server may have an invalid or self-signed certificate.',
|
||||
errorCode: 'SSL_ERROR',
|
||||
};
|
||||
}
|
||||
// Protocol errors
|
||||
else if (errMsg.includes('protocol')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Protocol error. Try using https:// instead of http:// or vice versa.',
|
||||
errorCode: 'PROTOCOL_ERROR',
|
||||
};
|
||||
}
|
||||
// Generic error
|
||||
if (stage2Result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
errorCode: 'NETWORK_ERROR',
|
||||
error: 'Server is only accessible via HTTPS, not HTTP.',
|
||||
errorCode: 'HTTP_NOT_AVAILABLE',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// Both failed, continue with more diagnostics below
|
||||
} else {
|
||||
// HTTPS URL or no protocol - test HTTPS
|
||||
console.log(`[ConnectionModeService] Stage 1: Testing HTTPS with full certificate validation`);
|
||||
const stage1Result = await this.testHTTPS(healthUrl, 'Stage 1: Standard HTTPS', false);
|
||||
diagnostics.push(stage1Result);
|
||||
|
||||
if (stage1Result.success) {
|
||||
console.log(`[ConnectionModeService] ✅ Connection successful with standard HTTPS`);
|
||||
|
||||
// Log success summary
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
|
||||
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`);
|
||||
console.log(`[ConnectionModeService] Protocol: HTTPS with valid certificate`);
|
||||
console.log(`[ConnectionModeService] Duration: ${stage1Result.duration}ms`);
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
|
||||
|
||||
return { success: true, diagnostics };
|
||||
}
|
||||
|
||||
// STAGE 2: Test with certificate validation disabled (diagnose cert issues)
|
||||
console.log(`[ConnectionModeService] Stage 2: Testing HTTPS with certificate validation disabled`);
|
||||
const stage2Result = await this.testHTTPS(healthUrl, 'Stage 2: HTTPS (no cert validation)', true);
|
||||
diagnostics.push(stage2Result);
|
||||
|
||||
if (stage2Result.success) {
|
||||
console.log(`[ConnectionModeService] ⚠️ Certificate issue detected - works without validation`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'SSL certificate validation failed. The server has an invalid, self-signed, or untrusted certificate.',
|
||||
errorCode: 'SSL_CERTIFICATE_INVALID',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// STAGE 3: Try HTTP instead (for local/internal servers)
|
||||
console.log(`[ConnectionModeService] Stage 3: Testing HTTP instead of HTTPS`);
|
||||
const httpUrl = healthUrl.replace('https://', 'http://');
|
||||
const stage3Result = await this.testHTTP(httpUrl, 'Stage 3: HTTP (unencrypted)');
|
||||
diagnostics.push(stage3Result);
|
||||
|
||||
if (stage3Result.success) {
|
||||
console.log(`[ConnectionModeService] ⚠️ HTTP works but HTTPS doesn't`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server is only accessible via HTTP (not HTTPS).',
|
||||
errorCode: 'HTTPS_NOT_AVAILABLE',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// STAGE 4: Test with longer timeout (diagnose slow connections)
|
||||
console.log(`[ConnectionModeService] Stage 4: Testing with extended timeout (30s)`);
|
||||
const stage4Result = await this.testWithLongTimeout(healthUrl);
|
||||
diagnostics.push(stage4Result);
|
||||
|
||||
if (stage4Result.success) {
|
||||
console.log(`[ConnectionModeService] ⚠️ Connection slow but eventually successful`);
|
||||
return {
|
||||
success: true,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// STAGE 5A: Test external connectivity with standard endpoint
|
||||
console.log(`[ConnectionModeService] Stage 5A: Testing external connectivity (google.com)`);
|
||||
const stage5aResult = await this.testStage5_ExternalConnectivity();
|
||||
diagnostics.push(stage5aResult);
|
||||
|
||||
// STAGE 5B: Test with alternative endpoint (in case google is blocked)
|
||||
console.log(`[ConnectionModeService] Stage 5B: Testing alternative endpoint (cloudflare.com)`);
|
||||
const stage5bResult = await this.testAlternativeEndpoint();
|
||||
diagnostics.push(stage5bResult);
|
||||
|
||||
// STAGE 5C: Test with HTTP vs HTTPS for external endpoint
|
||||
console.log(`[ConnectionModeService] Stage 5C: Testing HTTP external endpoint`);
|
||||
const stage5cResult = await this.testHTTPExternal();
|
||||
diagnostics.push(stage5cResult);
|
||||
|
||||
if (!stage5aResult.success && !stage5bResult.success && !stage5cResult.success) {
|
||||
console.log(`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'No internet connectivity detected. All network requests are failing.',
|
||||
errorCode: 'NETWORK_BLOCKED',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// If some external endpoints work but not the target, it's more specific
|
||||
if (stage5aResult.success || stage5bResult.success || stage5cResult.success) {
|
||||
console.log(`[ConnectionModeService] ✅ External connectivity confirmed - issue is specific to target server`);
|
||||
}
|
||||
|
||||
// STAGE 6: Test DNS resolution for the target server
|
||||
console.log(`[ConnectionModeService] Stage 6: Testing DNS resolution for target server`);
|
||||
const urlObj = new URL(url);
|
||||
const stage6Result = await this.testStage6_DNSResolution(urlObj.hostname);
|
||||
diagnostics.push(stage6Result);
|
||||
|
||||
if (!stage6Result.success && stage6Result.message.includes('DNS lookup failed')) {
|
||||
console.log(`[ConnectionModeService] ❌ DNS resolution failed for target server`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot resolve hostname: ${urlObj.hostname}`,
|
||||
errorCode: 'DNS_RESOLUTION_FAILED',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// STAGE 7: Try different HTTP method (HEAD instead of GET)
|
||||
console.log(`[ConnectionModeService] Stage 7: Testing with HEAD method`);
|
||||
const stage7Result = await this.testWithHEADMethod(healthUrl);
|
||||
diagnostics.push(stage7Result);
|
||||
|
||||
if (stage7Result.success) {
|
||||
console.log(`[ConnectionModeService] ⚠️ HEAD method works but GET doesn't - unusual server behavior`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server responds to HEAD requests but not GET requests.',
|
||||
errorCode: 'METHOD_MISMATCH',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// STAGE 8: Try with modified User-Agent
|
||||
console.log(`[ConnectionModeService] Stage 8: Testing with browser User-Agent`);
|
||||
const stage8Result = await this.testWithBrowserUserAgent(healthUrl);
|
||||
diagnostics.push(stage8Result);
|
||||
|
||||
if (stage8Result.success) {
|
||||
console.log(`[ConnectionModeService] ⚠️ Works with browser User-Agent - server may be blocking desktop apps`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.',
|
||||
errorCode: 'USER_AGENT_BLOCKED',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// STAGE 9: Final analysis - server-specific issue
|
||||
console.log(`[ConnectionModeService] ❌ Server unreachable - all diagnostic tests failed`);
|
||||
|
||||
// Analyze timing patterns
|
||||
const avgDuration = diagnostics
|
||||
.filter(d => !d.success && d.duration)
|
||||
.reduce((sum, d) => sum + (d.duration || 0), 0) /
|
||||
diagnostics.filter(d => !d.success && d.duration).length;
|
||||
|
||||
// Log comprehensive diagnostic summary
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`);
|
||||
console.log(`[ConnectionModeService] Total tests run: ${diagnostics.length}`);
|
||||
console.log(`[ConnectionModeService] Passed: ${diagnostics.filter(d => d.success).length}`);
|
||||
console.log(`[ConnectionModeService] Failed: ${diagnostics.filter(d => !d.success).length}`);
|
||||
console.log(`[ConnectionModeService] Average failure time: ${avgDuration.toFixed(0)}ms`);
|
||||
console.log(`[ConnectionModeService] ---------------------------------------------------------------`);
|
||||
diagnostics.forEach((diag) => {
|
||||
const icon = diag.success ? '✅' : '❌';
|
||||
console.log(`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`);
|
||||
});
|
||||
console.log(`[ConnectionModeService] ================================================================`);
|
||||
console.log(`[ConnectionModeService] Error Code: SERVER_UNREACHABLE`);
|
||||
|
||||
// Log timing-based analysis
|
||||
if (avgDuration < 100) {
|
||||
console.log(`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`);
|
||||
} else if (avgDuration > 5000) {
|
||||
console.log(`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration/1000).toFixed(1)}s) suggest server not responding or network route blocked`);
|
||||
} else {
|
||||
console.log(`[ConnectionModeService] Analysis: Server may be down, blocking connections, or behind a firewall`);
|
||||
}
|
||||
|
||||
console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot connect to server. Internet works but this specific server is unreachable.',
|
||||
errorCode: 'SERVER_UNREACHABLE',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
private isLocalAddress(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname.toLowerCase();
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.16.') ||
|
||||
hostname.endsWith('.local')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async testHTTPS(url: string, stageName: string, disableCertValidation: boolean): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
|
||||
console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? 'DISABLED' : 'ENABLED'}`);
|
||||
|
||||
const fetchOptions: any = {
|
||||
method: 'GET',
|
||||
connectTimeout: 10000,
|
||||
};
|
||||
|
||||
if (disableCertValidation) {
|
||||
fetchOptions.danger = {
|
||||
acceptInvalidCerts: true,
|
||||
acceptInvalidHostnames: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[ConnectionModeService] - Fetch options:`, JSON.stringify(fetchOptions));
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: stageName,
|
||||
success: true,
|
||||
message: disableCertValidation
|
||||
? 'Connected successfully when certificate validation disabled'
|
||||
: 'Successfully connected with full certificate validation',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: stageName,
|
||||
success: false,
|
||||
error: 'Unknown error occurred while testing connection',
|
||||
errorCode: 'UNKNOWN',
|
||||
message: `Server returned HTTP ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Enhanced error logging
|
||||
console.error(`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`);
|
||||
console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`);
|
||||
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
// Log full error object structure for debugging
|
||||
if (error && typeof error === 'object') {
|
||||
console.error(`[ConnectionModeService] - Error keys:`, Object.keys(error));
|
||||
console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||
}
|
||||
|
||||
// Categorize error type
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorLower = errorMsg.toLowerCase();
|
||||
|
||||
let detailedMessage = `Failed: ${errorMsg}`;
|
||||
|
||||
if (errorLower.includes('timeout') || errorLower.includes('timed out')) {
|
||||
detailedMessage = `Timeout after ${duration}ms - server not responding`;
|
||||
} else if (errorLower.includes('certificate') || errorLower.includes('cert') || errorLower.includes('ssl') || errorLower.includes('tls')) {
|
||||
detailedMessage = `SSL/TLS error - ${errorMsg}`;
|
||||
} else if (errorLower.includes('connection refused') || errorLower.includes('econnrefused')) {
|
||||
detailedMessage = `Connection refused - server may not be running`;
|
||||
} else if (errorLower.includes('network') || errorLower.includes('dns') || errorLower.includes('enotfound')) {
|
||||
detailedMessage = `Network error - ${errorMsg}`;
|
||||
} else if (errorLower.includes('blocked') || errorLower.includes('filtered')) {
|
||||
detailedMessage = `Request blocked - possible firewall/antivirus`;
|
||||
} else if (duration < 100) {
|
||||
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
|
||||
}
|
||||
|
||||
console.error(`[ConnectionModeService] - Categorized as: ${detailedMessage}`);
|
||||
|
||||
return {
|
||||
stage: stageName,
|
||||
success: false,
|
||||
message: detailedMessage,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testHTTP(url: string, stageName: string): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: stageName,
|
||||
success: true,
|
||||
message: 'Successfully connected using HTTP',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: stageName,
|
||||
success: false,
|
||||
message: `Server returned HTTP ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Enhanced error logging
|
||||
console.error(`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`);
|
||||
console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`);
|
||||
console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||
}
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorLower = errorMsg.toLowerCase();
|
||||
|
||||
let detailedMessage = `Failed: ${errorMsg}`;
|
||||
|
||||
if (errorLower.includes('timeout') || errorLower.includes('timed out')) {
|
||||
detailedMessage = `Timeout after ${duration}ms - server not responding`;
|
||||
} else if (duration < 100) {
|
||||
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
|
||||
}
|
||||
|
||||
console.error(`[ConnectionModeService] - Categorized as: ${detailedMessage}`);
|
||||
|
||||
return {
|
||||
stage: stageName,
|
||||
success: false,
|
||||
message: detailedMessage,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testWithLongTimeout(url: string): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
connectTimeout: 30000, // 30 seconds
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 4: Extended timeout (30s)',
|
||||
success: true,
|
||||
message: `Connected after ${duration}ms (slow connection)`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 4: Extended timeout (30s)',
|
||||
success: false,
|
||||
message: `Server returned HTTP ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
stage: 'Stage 4: Extended timeout (30s)',
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testStage5_ExternalConnectivity(): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🌐 Stage 5A: Testing external connectivity (google.com)`);
|
||||
|
||||
// Test connectivity to a reliable external service
|
||||
const response = await fetch('https://www.google.com', {
|
||||
method: 'HEAD',
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok || response.status === 301 || response.status === 302) {
|
||||
return {
|
||||
stage: 'Stage 5A: External (google.com)',
|
||||
success: true,
|
||||
message: 'Internet connectivity confirmed via google.com',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 5A: External (google.com)',
|
||||
success: false,
|
||||
message: `Unexpected response from google.com: ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`[ConnectionModeService] ❌ Stage 5A: External connectivity test failed (${duration}ms)`);
|
||||
console.error(`[ConnectionModeService] - Error:`, error);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 5A: External (google.com)',
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testAlternativeEndpoint(): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`);
|
||||
|
||||
const response = await fetch('https://1.1.1.1', {
|
||||
method: 'HEAD',
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok || response.status === 301 || response.status === 302 || response.status === 403) {
|
||||
return {
|
||||
stage: 'Stage 5B: External (cloudflare)',
|
||||
success: true,
|
||||
message: 'Alternative endpoint (1.1.1.1) reachable',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 5B: External (cloudflare)',
|
||||
success: false,
|
||||
message: `Unexpected response: ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`[ConnectionModeService] ❌ Stage 5B: Alternative endpoint failed (${duration}ms)`);
|
||||
console.error(`[ConnectionModeService] - Error:`, error);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 5B: External (cloudflare)',
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testHTTPExternal(): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🌐 Stage 5C: Testing HTTP external endpoint (httpbin.org)`);
|
||||
|
||||
// Try HTTP (not HTTPS) to see if TLS/SSL is the issue
|
||||
const response = await fetch('http://httpbin.org/status/200', {
|
||||
method: 'GET',
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 5C: HTTP endpoint success - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 5C: External HTTP (no TLS)',
|
||||
success: true,
|
||||
message: 'HTTP (unencrypted) connectivity works',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 5C: External HTTP (no TLS)',
|
||||
success: false,
|
||||
message: `Unexpected response: ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`[ConnectionModeService] ❌ Stage 5C: HTTP external failed (${duration}ms)`);
|
||||
console.error(`[ConnectionModeService] - Error:`, error);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 5C: External HTTP (no TLS)',
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testStage6_DNSResolution(hostname: string): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🔍 Stage 6: Testing DNS resolution for ${hostname}`);
|
||||
|
||||
// Try to resolve DNS by making a HEAD request to the base domain
|
||||
// If DNS fails, we'll get an immediate error
|
||||
const testUrl = `https://${hostname}`;
|
||||
await fetch(testUrl, {
|
||||
method: 'HEAD',
|
||||
connectTimeout: 3000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`);
|
||||
|
||||
return {
|
||||
stage: 'Stage 6: DNS resolution',
|
||||
success: true,
|
||||
message: `DNS resolution successful for ${hostname}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorLower = errorMsg.toLowerCase();
|
||||
|
||||
console.error(`[ConnectionModeService] ❌ Stage 6: DNS test failed (${duration}ms)`);
|
||||
console.error(`[ConnectionModeService] - Error:`, errorMsg);
|
||||
|
||||
// Check if it's a DNS-specific error
|
||||
if (errorLower.includes('dns') || errorLower.includes('enotfound') || errorLower.includes('getaddrinfo')) {
|
||||
return {
|
||||
stage: 'Stage 6: DNS resolution',
|
||||
success: false,
|
||||
message: `DNS lookup failed - cannot resolve ${hostname}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
// If we got here, DNS might be working but connection failed for other reasons
|
||||
return {
|
||||
stage: 'Stage 6: DNS resolution',
|
||||
success: false,
|
||||
message: `DNS test inconclusive: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testWithHEADMethod(url: string): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 7: HEAD method success - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 7: HEAD method',
|
||||
success: true,
|
||||
message: 'HEAD method works (GET does not)',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 7: HEAD method',
|
||||
success: false,
|
||||
message: `HEAD method returned ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`[ConnectionModeService] ❌ Stage 7: HEAD method failed (${duration}ms)`);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 7: HEAD method',
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testWithBrowserUserAgent(url: string): Promise<DiagnosticResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log(`[ConnectionModeService] 🔗 Stage 8: Testing with browser User-Agent`);
|
||||
|
||||
// Try with a standard browser User-Agent instead of Tauri's default
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
connectTimeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`[ConnectionModeService] ✅ Stage 8: Browser User-Agent success - HTTP ${response.status} (${duration}ms)`);
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
stage: 'Stage 8: Browser User-Agent',
|
||||
success: true,
|
||||
message: 'Works with browser User-Agent (blocked with desktop UA)',
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'Stage 8: Browser User-Agent',
|
||||
success: false,
|
||||
message: `Browser UA returned ${response.status}`,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`[ConnectionModeService] ❌ Stage 8: Browser User-Agent failed (${duration}ms)`);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
stage: 'Stage 8: Browser User-Agent',
|
||||
success: false,
|
||||
message: `Failed: ${errorMsg}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async isFirstLaunch(): Promise<boolean> {
|
||||
try {
|
||||
const result = await invoke<boolean>('is_first_launch');
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor } from '@mantine/core';
|
||||
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
||||
import {
|
||||
ALL_PROVIDERS,
|
||||
@@ -46,6 +47,10 @@ interface ConnectionsSettingsData {
|
||||
};
|
||||
ssoAutoLogin?: boolean;
|
||||
enableMobileScanner?: boolean;
|
||||
mobileScannerConvertToPdf?: boolean;
|
||||
mobileScannerImageResolution?: string;
|
||||
mobileScannerPageFormat?: string;
|
||||
mobileScannerStretchToFit?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminConnectionsSection() {
|
||||
@@ -78,7 +83,11 @@ export default function AdminConnectionsSection() {
|
||||
saml2: securityData.saml2 || {},
|
||||
mail: mailData || {},
|
||||
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
|
||||
enableMobileScanner: systemData.enableMobileScanner || false
|
||||
enableMobileScanner: systemData.enableMobileScanner || false,
|
||||
mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false,
|
||||
mobileScannerImageResolution: systemData.mobileScannerSettings?.imageResolution || 'full',
|
||||
mobileScannerPageFormat: systemData.mobileScannerSettings?.pageFormat || 'A4',
|
||||
mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false
|
||||
};
|
||||
|
||||
// Merge pending blocks from all four endpoints
|
||||
@@ -98,6 +107,18 @@ export default function AdminConnectionsSection() {
|
||||
if (systemData._pending?.enableMobileScanner !== undefined) {
|
||||
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined) {
|
||||
pendingBlock.mobileScannerConvertToPdf = systemData._pending.mobileScannerSettings.convertToPdf;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.imageResolution !== undefined) {
|
||||
pendingBlock.mobileScannerImageResolution = systemData._pending.mobileScannerSettings.imageResolution;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.pageFormat !== undefined) {
|
||||
pendingBlock.mobileScannerPageFormat = systemData._pending.mobileScannerSettings.pageFormat;
|
||||
}
|
||||
if (systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined) {
|
||||
pendingBlock.mobileScannerStretchToFit = systemData._pending.mobileScannerSettings.stretchToFit;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
@@ -356,6 +377,35 @@ export default function AdminConnectionsSection() {
|
||||
|
||||
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
||||
|
||||
if (response.status === 200) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.settings.success', 'Settings saved successfully')
|
||||
});
|
||||
fetchSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save mobile scanner setting:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.settings.error', 'Failed to save settings')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileScannerSettingsSave = async (settingKey: string, newValue: string | boolean) => {
|
||||
// Block save if login is disabled or mobile scanner is not enabled
|
||||
if (!validateLoginEnabled() || !settings?.enableMobileScanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deltaSettings = {
|
||||
[`system.mobileScannerSettings.${settingKey}`]: newValue
|
||||
};
|
||||
|
||||
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
||||
|
||||
if (response.status === 200) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
@@ -471,6 +521,119 @@ export default function AdminConnectionsSection() {
|
||||
<PendingBadge show={isFieldPending('enableMobileScanner')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Mobile Scanner Settings - Only show when enabled */}
|
||||
<Collapse in={settings?.enableMobileScanner || false}>
|
||||
<Stack gap="md" mt="md" ml="lg" style={{ borderLeft: '2px solid var(--mantine-color-gray-3)', paddingLeft: '1rem' }}>
|
||||
{/* Convert to PDF */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t('admin.settings.connections.mobileScannerConvertToPdf', 'Convert Images to PDF')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t('admin.settings.connections.mobileScannerConvertToPdfDesc', 'Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.mobileScannerConvertToPdf !== false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
const newValue = e.target.checked;
|
||||
setSettings({ ...settings, mobileScannerConvertToPdf: newValue });
|
||||
handleMobileScannerSettingsSave('convertToPdf', newValue);
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('mobileScannerConvertToPdf')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* PDF Conversion Settings - Only show when convertToPdf is enabled */}
|
||||
{settings?.mobileScannerConvertToPdf !== false && (
|
||||
<>
|
||||
{/* Image Resolution */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t('admin.settings.connections.mobileScannerImageResolution', 'Image Resolution')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t('admin.settings.connections.mobileScannerImageResolutionDesc', 'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
value={settings?.mobileScannerImageResolution || 'full'}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, mobileScannerImageResolution: value || 'full' });
|
||||
handleMobileScannerSettingsSave('imageResolution', value || 'full');
|
||||
}}
|
||||
data={[
|
||||
{ value: 'full', label: t('admin.settings.connections.imageResolutionFull', 'Full (Original Size)') },
|
||||
{ value: 'reduced', label: t('admin.settings.connections.imageResolutionReduced', 'Reduced (Max 1200px)') }
|
||||
]}
|
||||
disabled={!loginEnabled}
|
||||
style={{ width: '250px' }}
|
||||
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('mobileScannerImageResolution')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Page Format */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t('admin.settings.connections.mobileScannerPageFormat', 'Page Format')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t('admin.settings.connections.mobileScannerPageFormatDesc', 'PDF page size for converted images. "Keep" uses original image dimensions.')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
value={settings?.mobileScannerPageFormat || 'A4'}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' });
|
||||
handleMobileScannerSettingsSave('pageFormat', value || 'A4');
|
||||
}}
|
||||
data={[
|
||||
{ value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') },
|
||||
{ value: 'A4', label: t('admin.settings.connections.pageFormatA4', 'A4 (210×297mm)') },
|
||||
{ value: 'letter', label: t('admin.settings.connections.pageFormatLetter', 'Letter (8.5×11in)') }
|
||||
]}
|
||||
disabled={!loginEnabled}
|
||||
style={{ width: '250px' }}
|
||||
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('mobileScannerPageFormat')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Stretch to Fit */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t('admin.settings.connections.mobileScannerStretchToFit', 'Stretch to Fit')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t('admin.settings.connections.mobileScannerStretchToFitDesc', 'Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.mobileScannerStretchToFit || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
const newValue = e.target.checked;
|
||||
setSettings({ ...settings, mobileScannerStretchToFit: newValue });
|
||||
handleMobileScannerSettingsSave('stretchToFit', newValue);
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('mobileScannerStretchToFit')} />
|
||||
</Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -213,6 +213,23 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Fix Mantine Button internal spans to not crop content */
|
||||
.oauth-button-vertical .mantine-Button-inner {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.oauth-button-vertical .mantine-Button-label {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.oauth-icon-small {
|
||||
width: 1.75rem; /* 28px */
|
||||
height: 1.75rem; /* 28px */
|
||||
|
||||
Reference in New Issue
Block a user