mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02: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:
+11
-8
@@ -94,16 +94,19 @@ public class RuntimePathConfig {
|
|||||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||||
|
|
||||||
// Initialize Tesseract data path
|
// Initialize Tesseract data path
|
||||||
String defaultTessDataPath =
|
// Priority: config setting > TESSDATA_PREFIX env var > default path
|
||||||
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
|
|
||||||
|
|
||||||
String tessPath = system.getTessdataDir();
|
String tessPath = system.getTessdataDir();
|
||||||
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
|
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||||
|
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
|
||||||
|
|
||||||
|
if (tessPath != null && !tessPath.isEmpty()) {
|
||||||
|
this.tessDataPath = tessPath;
|
||||||
|
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
|
||||||
|
this.tessDataPath = tessdataPrefix;
|
||||||
|
} else {
|
||||||
|
this.tessDataPath = defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
this.tessDataPath =
|
|
||||||
resolvePath(
|
|
||||||
defaultTessDataPath,
|
|
||||||
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
|
|
||||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -418,6 +418,15 @@ public class ApplicationProperties {
|
|||||||
|
|
||||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||||
|
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class MobileScannerSettings {
|
||||||
|
private boolean convertToPdf = true; // Whether to automatically convert images to PDF
|
||||||
|
private String imageResolution = "full"; // Options: "full", "reduced"
|
||||||
|
private String pageFormat = "A4"; // Options: "keep", "A4", "letter"
|
||||||
|
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAnalyticsEnabled() {
|
public boolean isAnalyticsEnabled() {
|
||||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ public class UIDataController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> getAvailableTesseractLanguages() {
|
private List<String> getAvailableTesseractLanguages() {
|
||||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
String tessdataDir = runtimePathConfig.getTessDataPath();
|
||||||
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
|
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
|
||||||
if (files == null) {
|
if (files == null) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
|
|||||||
+16
-1
@@ -75,10 +75,25 @@ public class ConfigController {
|
|||||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
||||||
|
|
||||||
// Add mobile scanner setting
|
// Add mobile scanner settings
|
||||||
configData.put(
|
configData.put(
|
||||||
"enableMobileScanner",
|
"enableMobileScanner",
|
||||||
applicationProperties.getSystem().isEnableMobileScanner());
|
applicationProperties.getSystem().isEnableMobileScanner());
|
||||||
|
configData.put(
|
||||||
|
"mobileScannerConvertToPdf",
|
||||||
|
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
|
||||||
|
configData.put(
|
||||||
|
"mobileScannerImageResolution",
|
||||||
|
applicationProperties
|
||||||
|
.getSystem()
|
||||||
|
.getMobileScannerSettings()
|
||||||
|
.getImageResolution());
|
||||||
|
configData.put(
|
||||||
|
"mobileScannerPageFormat",
|
||||||
|
applicationProperties.getSystem().getMobileScannerSettings().getPageFormat());
|
||||||
|
configData.put(
|
||||||
|
"mobileScannerStretchToFit",
|
||||||
|
applicationProperties.getSystem().getMobileScannerSettings().isStretchToFit());
|
||||||
|
|
||||||
// Extract values from ApplicationProperties
|
// Extract values from ApplicationProperties
|
||||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||||
|
|||||||
+59
-1
@@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.service.MobileScannerService;
|
import stirling.software.common.service.MobileScannerService;
|
||||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||||
|
|
||||||
@@ -51,9 +52,31 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
|
|||||||
public class MobileScannerController {
|
public class MobileScannerController {
|
||||||
|
|
||||||
private final MobileScannerService mobileScannerService;
|
private final MobileScannerService mobileScannerService;
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
public MobileScannerController(MobileScannerService mobileScannerService) {
|
public MobileScannerController(
|
||||||
|
MobileScannerService mobileScannerService,
|
||||||
|
ApplicationProperties applicationProperties) {
|
||||||
this.mobileScannerService = mobileScannerService;
|
this.mobileScannerService = mobileScannerService;
|
||||||
|
this.applicationProperties = applicationProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if mobile scanner feature is enabled
|
||||||
|
*
|
||||||
|
* @return Error response if disabled, null if enabled
|
||||||
|
*/
|
||||||
|
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
|
||||||
|
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(
|
||||||
|
Map.of(
|
||||||
|
"error",
|
||||||
|
"Mobile scanner feature is not enabled",
|
||||||
|
"enabled",
|
||||||
|
false));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,10 +94,16 @@ public class MobileScannerController {
|
|||||||
description = "Session created successfully",
|
description = "Session created successfully",
|
||||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid session ID")
|
@ApiResponse(responseCode = "400", description = "Invalid session ID")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||||
public ResponseEntity<Map<String, Object>> createSession(
|
public ResponseEntity<Map<String, Object>> createSession(
|
||||||
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
|
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
|
||||||
String sessionId) {
|
String sessionId) {
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||||
|
if (featureCheck != null) {
|
||||||
|
return featureCheck;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MobileScannerService.SessionInfo sessionInfo =
|
MobileScannerService.SessionInfo sessionInfo =
|
||||||
mobileScannerService.createSession(sessionId);
|
mobileScannerService.createSession(sessionId);
|
||||||
@@ -109,10 +138,16 @@ public class MobileScannerController {
|
|||||||
description = "Session is valid",
|
description = "Session is valid",
|
||||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||||
@ApiResponse(responseCode = "404", description = "Session not found or expired")
|
@ApiResponse(responseCode = "404", description = "Session not found or expired")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||||
public ResponseEntity<Map<String, Object>> validateSession(
|
public ResponseEntity<Map<String, Object>> validateSession(
|
||||||
@Parameter(description = "Session ID to validate", required = true) @PathVariable
|
@Parameter(description = "Session ID to validate", required = true) @PathVariable
|
||||||
String sessionId) {
|
String sessionId) {
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||||
|
if (featureCheck != null) {
|
||||||
|
return featureCheck;
|
||||||
|
}
|
||||||
|
|
||||||
MobileScannerService.SessionInfo sessionInfo =
|
MobileScannerService.SessionInfo sessionInfo =
|
||||||
mobileScannerService.validateSession(sessionId);
|
mobileScannerService.validateSession(sessionId);
|
||||||
|
|
||||||
@@ -147,6 +182,7 @@ public class MobileScannerController {
|
|||||||
description = "Files uploaded successfully",
|
description = "Files uploaded successfully",
|
||||||
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
|
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
|
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||||
@ApiResponse(responseCode = "500", description = "Upload failed")
|
@ApiResponse(responseCode = "500", description = "Upload failed")
|
||||||
public ResponseEntity<Map<String, Object>> uploadFiles(
|
public ResponseEntity<Map<String, Object>> uploadFiles(
|
||||||
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
|
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
|
||||||
@@ -154,6 +190,11 @@ public class MobileScannerController {
|
|||||||
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
|
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
|
||||||
List<MultipartFile> files) {
|
List<MultipartFile> files) {
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||||
|
if (featureCheck != null) {
|
||||||
|
return featureCheck;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (files == null || files.isEmpty()) {
|
if (files == null || files.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
|
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
|
||||||
@@ -194,10 +235,16 @@ public class MobileScannerController {
|
|||||||
responseCode = "200",
|
responseCode = "200",
|
||||||
description = "File list retrieved",
|
description = "File list retrieved",
|
||||||
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
|
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
|
||||||
|
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||||
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
||||||
@Parameter(description = "Session ID", required = true) @PathVariable
|
@Parameter(description = "Session ID", required = true) @PathVariable
|
||||||
String sessionId) {
|
String sessionId) {
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||||
|
if (featureCheck != null) {
|
||||||
|
return featureCheck;
|
||||||
|
}
|
||||||
|
|
||||||
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
|
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
|
||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
@@ -221,12 +268,17 @@ public class MobileScannerController {
|
|||||||
description =
|
description =
|
||||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||||
public ResponseEntity<Resource> downloadFile(
|
public ResponseEntity<Resource> downloadFile(
|
||||||
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
|
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
|
||||||
@Parameter(description = "Filename to download", required = true) @PathVariable
|
@Parameter(description = "Filename to download", required = true) @PathVariable
|
||||||
String filename) {
|
String filename) {
|
||||||
|
|
||||||
|
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Path filePath = mobileScannerService.getFile(sessionId, filename);
|
Path filePath = mobileScannerService.getFile(sessionId, filename);
|
||||||
|
|
||||||
@@ -268,10 +320,16 @@ public class MobileScannerController {
|
|||||||
summary = "Delete a session",
|
summary = "Delete a session",
|
||||||
description = "Manually delete a session and all its uploaded files")
|
description = "Manually delete a session and all its uploaded files")
|
||||||
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
|
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||||
public ResponseEntity<Map<String, Object>> deleteSession(
|
public ResponseEntity<Map<String, Object>> deleteSession(
|
||||||
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
||||||
String sessionId) {
|
String sessionId) {
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||||
|
if (featureCheck != null) {
|
||||||
|
return featureCheck;
|
||||||
|
}
|
||||||
|
|
||||||
mobileScannerService.deleteSession(sessionId);
|
mobileScannerService.deleteSession(sessionId);
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ system:
|
|||||||
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||||
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||||
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||||
|
mobileScannerSettings:
|
||||||
|
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
|
||||||
|
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
|
||||||
|
pageFormat: A4 # Page format for converted PDFs: 'keep' (original image dimensions), 'A4' (A4 page size), or 'letter' (US Letter page size). Only applies when convertToPdf is true.
|
||||||
|
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
|
||||||
serverCertificate:
|
serverCertificate:
|
||||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ repositories {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = 'stirling.software'
|
group = 'stirling.software'
|
||||||
version = '2.2.0'
|
version = '2.2.1'
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
exclude group: 'commons-logging', module: 'commons-logging'
|
exclude group: 'commons-logging', module: 'commons-logging'
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ echo "==================================="
|
|||||||
setup_ocr() {
|
setup_ocr() {
|
||||||
echo "Setting up OCR languages..."
|
echo "Setting up OCR languages..."
|
||||||
|
|
||||||
# Copy tessdata
|
# In Alpine, tesseract uses /usr/share/tessdata
|
||||||
mkdir -p /usr/share/tessdata
|
TESSDATA_DIR="/usr/share/tessdata"
|
||||||
cp -rn /usr/share/tessdata-original/* /usr/share/tessdata 2>/dev/null || true
|
|
||||||
|
|
||||||
if [ -d /usr/share/tesseract-ocr/4.00/tessdata ]; then
|
# Create tessdata directory
|
||||||
cp -r /usr/share/tesseract-ocr/4.00/tessdata/* /usr/share/tessdata 2>/dev/null || true
|
mkdir -p "$TESSDATA_DIR"
|
||||||
|
|
||||||
|
# Restore system languages from backup (Dockerfile moved them to tessdata-original)
|
||||||
|
if [ -d /usr/share/tessdata-original ]; then
|
||||||
|
echo "Restoring system tessdata from backup..."
|
||||||
|
cp -rn /usr/share/tessdata-original/* "$TESSDATA_DIR"/ 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then
|
# Note: If user mounted custom languages to /usr/share/tessdata, they'll be overlaid here.
|
||||||
cp -r /usr/share/tesseract-ocr/5/tessdata/* /usr/share/tessdata 2>/dev/null || true
|
# The cp -rn above won't overwrite user files, just adds missing system files.
|
||||||
fi
|
|
||||||
|
|
||||||
# Install additional languages if specified
|
# Install additional languages if specified
|
||||||
if [[ -n "$TESSERACT_LANGS" ]]; then
|
if [[ -n "$TESSERACT_LANGS" ]]; then
|
||||||
@@ -32,10 +35,15 @@ setup_ocr() {
|
|||||||
pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$'
|
pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$'
|
||||||
for LANG in $SPACE_SEPARATED_LANGS; do
|
for LANG in $SPACE_SEPARATED_LANGS; do
|
||||||
if [[ $LANG =~ $pattern ]]; then
|
if [[ $LANG =~ $pattern ]]; then
|
||||||
|
echo "Installing tesseract language: $LANG"
|
||||||
apk add --no-cache "tesseract-ocr-data-$LANG" 2>/dev/null || true
|
apk add --no-cache "tesseract-ocr-data-$LANG" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Point to the consolidated location
|
||||||
|
export TESSDATA_PREFIX="$TESSDATA_DIR"
|
||||||
|
echo "Using TESSDATA_PREFIX=$TESSDATA_PREFIX"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to setup user permissions (from init-without-ocr.sh)
|
# Function to setup user permissions (from init-without-ocr.sh)
|
||||||
|
|||||||
@@ -441,6 +441,13 @@ currentVersion = "Current Version"
|
|||||||
latestVersion = "Latest Version"
|
latestVersion = "Latest Version"
|
||||||
checkForUpdates = "Check for Updates"
|
checkForUpdates = "Check for Updates"
|
||||||
viewDetails = "View Details"
|
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]
|
[settings.security]
|
||||||
title = "Security"
|
title = "Security"
|
||||||
@@ -4277,6 +4284,8 @@ fetchError = "Failed to load settings"
|
|||||||
saveError = "Failed to save settings"
|
saveError = "Failed to save settings"
|
||||||
saved = "Settings saved successfully"
|
saved = "Settings saved successfully"
|
||||||
saveSuccess = "Settings saved successfully"
|
saveSuccess = "Settings saved successfully"
|
||||||
|
success = "Settings saved successfully"
|
||||||
|
error = "Failed to save settings"
|
||||||
save = "Save Changes"
|
save = "Save Changes"
|
||||||
discard = "Discard"
|
discard = "Discard"
|
||||||
restartRequired = "Restart Required"
|
restartRequired = "Restart Required"
|
||||||
@@ -4543,6 +4552,19 @@ connect = "Connect"
|
|||||||
disconnect = "Disconnect"
|
disconnect = "Disconnect"
|
||||||
disconnected = "Provider disconnected successfully"
|
disconnected = "Provider disconnected successfully"
|
||||||
disconnectError = "Failed to disconnect provider"
|
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]
|
[admin.settings.connections.ssoAutoLogin]
|
||||||
label = "SSO Auto Login"
|
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"
|
description = "Allow users to upload files from mobile devices by scanning a QR code"
|
||||||
note = "Note: Requires Frontend URL to be configured. "
|
note = "Note: Requires Frontend URL to be configured. "
|
||||||
link = "Configure in System Settings"
|
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]
|
[admin.settings.database]
|
||||||
title = "Database"
|
title = "Database"
|
||||||
@@ -4990,6 +5025,7 @@ noRecentFiles = "No recent files found"
|
|||||||
googleDriveNotAvailable = "Google Drive integration not available"
|
googleDriveNotAvailable = "Google Drive integration not available"
|
||||||
mobileUpload = "Mobile Upload"
|
mobileUpload = "Mobile Upload"
|
||||||
mobileShort = "Mobile"
|
mobileShort = "Mobile"
|
||||||
|
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||||
downloadSelected = "Download Selected"
|
downloadSelected = "Download Selected"
|
||||||
saveSelected = "Save Selected"
|
saveSelected = "Save Selected"
|
||||||
openFiles = "Open Files"
|
openFiles = "Open Files"
|
||||||
@@ -6464,7 +6500,8 @@ failed = "An error occurred while adding text to the PDF."
|
|||||||
|
|
||||||
[mobileUpload]
|
[mobileUpload]
|
||||||
title = "Upload from Mobile"
|
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"
|
error = "Connection Error"
|
||||||
pollingError = "Error checking for files"
|
pollingError = "Error checking for files"
|
||||||
sessionId = "Session ID"
|
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."
|
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
|
||||||
filesReceived = "{{count}} file(s) received"
|
filesReceived = "{{count}} file(s) received"
|
||||||
connected = "Mobile device connected"
|
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]
|
[mobileScanner]
|
||||||
title = "Mobile Scanner"
|
title = "Mobile Scanner"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
|||||||
tauri-plugin-log = "2.0.0-rc"
|
tauri-plugin-log = "2.0.0-rc"
|
||||||
tauri-plugin-shell = "2.1.0"
|
tauri-plugin-shell = "2.1.0"
|
||||||
tauri-plugin-fs = "2.4.4"
|
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-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||||
tauri-plugin-store = "2.1.0"
|
tauri-plugin-store = "2.1.0"
|
||||||
tauri-plugin-opener = "2.0.0"
|
tauri-plugin-opener = "2.0.0"
|
||||||
|
|||||||
@@ -13,9 +13,29 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
{ "url": "http://*" },
|
{ "url": "http://*" },
|
||||||
{ "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",
|
"identifier": "fs:allow-read-file",
|
||||||
"allow": [{ "path": "**" }]
|
"allow": [{ "path": "**" }]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
|||||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
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';
|
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||||
|
|
||||||
interface FileSourceButtonsProps {
|
interface FileSourceButtonsProps {
|
||||||
@@ -24,6 +26,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const UploadIcon = icons.upload;
|
const UploadIcon = icons.upload;
|
||||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const isMobileUploadEnabled = config?.enableMobileScanner && !isMobile;
|
||||||
|
|
||||||
const handleGoogleDriveClick = async () => {
|
const handleGoogleDriveClick = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -127,15 +132,17 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
onClick={handleMobileUploadClick}
|
onClick={handleMobileUploadClick}
|
||||||
fullWidth={!horizontal}
|
fullWidth={!horizontal}
|
||||||
size={horizontal ? "xs" : "sm"}
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
disabled={!isMobileUploadEnabled}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
'&:hover': {
|
'&: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')}
|
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
|||||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||||
import { withBasePath } from '@app/constants/app';
|
import { withBasePath } from '@app/constants/app';
|
||||||
|
import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils';
|
||||||
|
|
||||||
interface MobileUploadModalProps {
|
interface MobileUploadModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -132,10 +133,25 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
|||||||
|
|
||||||
if (downloadResponse.ok) {
|
if (downloadResponse.ok) {
|
||||||
const blob = await downloadResponse.blob();
|
const blob = await downloadResponse.blob();
|
||||||
const file = new File([blob], fileMetadata.filename, {
|
let file = new File([blob], fileMetadata.filename, {
|
||||||
type: fileMetadata.contentType || 'image/jpeg'
|
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);
|
processedFiles.current.add(fileMetadata.filename);
|
||||||
setFilesReceived((prev) => prev + 1);
|
setFilesReceived((prev) => prev + 1);
|
||||||
onFilesReceived([file]);
|
onFilesReceived([file]);
|
||||||
@@ -256,10 +272,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
|||||||
variant="light"
|
variant="light"
|
||||||
>
|
>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t(
|
{config?.mobileScannerConvertToPdf !== false
|
||||||
'mobileUpload.description',
|
? t(
|
||||||
'Scan this QR code with your mobile device to upload photos directly to this page.'
|
'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>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@@ -308,10 +329,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
|
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
|
||||||
{t(
|
{config?.mobileScannerConvertToPdf !== false
|
||||||
'mobileUpload.instructions',
|
? t(
|
||||||
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
|
'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>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export interface AppConfig {
|
|||||||
SSOAutoLogin?: boolean;
|
SSOAutoLogin?: boolean;
|
||||||
serverCertificateEnabled?: boolean;
|
serverCertificateEnabled?: boolean;
|
||||||
enableMobileScanner?: boolean;
|
enableMobileScanner?: boolean;
|
||||||
|
mobileScannerConvertToPdf?: boolean;
|
||||||
|
mobileScannerImageResolution?: string;
|
||||||
|
mobileScannerPageFormat?: string;
|
||||||
|
mobileScannerStretchToFit?: boolean;
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
machineType?: string;
|
machineType?: string;
|
||||||
activeSecurity?: boolean;
|
activeSecurity?: boolean;
|
||||||
|
|||||||
@@ -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...');
|
console.log('[ServerSelection] Fetching login configuration...');
|
||||||
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
|
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) {
|
if (!response.ok) {
|
||||||
console.warn(`[ServerSelection] Login config request failed with status ${response.status}`);
|
console.warn(`[ServerSelection] Login config request failed with status ${response.status}`);
|
||||||
|
|
||||||
if (response.status === 403 || response.status === 401) {
|
if (response.status === 403 || response.status === 401 || response.status === 404) {
|
||||||
console.log('[ServerSelection] Security is disabled on this server');
|
console.log('[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)');
|
||||||
setSecurityDisabled(true);
|
setSecurityDisabled(true);
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export interface ConnectionConfig {
|
|||||||
server_config: ServerConfig | null;
|
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 {
|
export class ConnectionModeService {
|
||||||
private static instance: ConnectionModeService;
|
private static instance: ConnectionModeService;
|
||||||
private currentConfig: ConnectionConfig | null = null;
|
private currentConfig: ConnectionConfig | null = null;
|
||||||
@@ -106,97 +120,766 @@ export class ConnectionModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test connection to a server URL and return detailed error information
|
* Test connection to a server URL with comprehensive multi-stage diagnostics
|
||||||
* @returns Object with success status and optional error message
|
* @returns Detailed test results with diagnostics and recommendations
|
||||||
*/
|
*/
|
||||||
async testConnection(url: string): Promise<{ success: boolean; error?: string; errorCode?: string }> {
|
async testConnection(url: string): Promise<ConnectionTestResult> {
|
||||||
console.log(`[ConnectionModeService] Testing connection to: ${url}`);
|
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 {
|
// Check for proxy environment variables
|
||||||
// Test connection by hitting the health/status endpoint
|
console.log(`[ConnectionModeService] Environment Check:`);
|
||||||
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
const envProxy = (window as any).process?.env?.HTTP_PROXY || (window as any).process?.env?.HTTPS_PROXY;
|
||||||
console.log(`[ConnectionModeService] Health check URL: ${healthUrl}`);
|
if (envProxy) {
|
||||||
|
console.log(`[ConnectionModeService] - Proxy detected: ${envProxy}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[ConnectionModeService] - No proxy environment variables detected`);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(healthUrl, {
|
// Check if running in Tauri (v2 uses different detection)
|
||||||
method: 'GET',
|
console.log(`[ConnectionModeService] - Checking Tauri context...`);
|
||||||
connectTimeout: 10000,
|
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 {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
error: errorMsg,
|
diagnostics,
|
||||||
errorCode: `HTTP_${response.status}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('[ConnectionModeService] ❌ Server connection test failed:', error);
|
|
||||||
|
|
||||||
// Extract detailed error information
|
// HTTP failed, try HTTPS as fallback
|
||||||
if (error instanceof Error) {
|
console.log(`[ConnectionModeService] Stage 2: HTTP failed, trying HTTPS`);
|
||||||
const errMsg = error.message.toLowerCase();
|
const httpsUrl = healthUrl.replace('http://', 'https://');
|
||||||
|
const stage2Result = await this.testHTTPS(httpsUrl, 'Stage 2: Trying HTTPS', false);
|
||||||
|
diagnostics.push(stage2Result);
|
||||||
|
|
||||||
// Connection refused
|
if (stage2Result.success) {
|
||||||
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
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: 'Server is only accessible via HTTPS, not HTTP.',
|
||||||
errorCode: 'NETWORK_ERROR',
|
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 {
|
return {
|
||||||
|
stage: stageName,
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unknown error occurred while testing connection',
|
message: `Server returned HTTP ${response.status}`,
|
||||||
errorCode: 'UNKNOWN',
|
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> {
|
async isFirstLaunch(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<boolean>('is_first_launch');
|
const result = await invoke<boolean>('is_first_launch');
|
||||||
|
|||||||
+165
-2
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { alert } from '@app/components/toast';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
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 ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
||||||
import {
|
import {
|
||||||
ALL_PROVIDERS,
|
ALL_PROVIDERS,
|
||||||
@@ -46,6 +47,10 @@ interface ConnectionsSettingsData {
|
|||||||
};
|
};
|
||||||
ssoAutoLogin?: boolean;
|
ssoAutoLogin?: boolean;
|
||||||
enableMobileScanner?: boolean;
|
enableMobileScanner?: boolean;
|
||||||
|
mobileScannerConvertToPdf?: boolean;
|
||||||
|
mobileScannerImageResolution?: string;
|
||||||
|
mobileScannerPageFormat?: string;
|
||||||
|
mobileScannerStretchToFit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminConnectionsSection() {
|
export default function AdminConnectionsSection() {
|
||||||
@@ -78,7 +83,11 @@ export default function AdminConnectionsSection() {
|
|||||||
saml2: securityData.saml2 || {},
|
saml2: securityData.saml2 || {},
|
||||||
mail: mailData || {},
|
mail: mailData || {},
|
||||||
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
|
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
|
// Merge pending blocks from all four endpoints
|
||||||
@@ -98,6 +107,18 @@ export default function AdminConnectionsSection() {
|
|||||||
if (systemData._pending?.enableMobileScanner !== undefined) {
|
if (systemData._pending?.enableMobileScanner !== undefined) {
|
||||||
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
|
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) {
|
if (Object.keys(pendingBlock).length > 0) {
|
||||||
result._pending = pendingBlock;
|
result._pending = pendingBlock;
|
||||||
@@ -356,6 +377,35 @@ export default function AdminConnectionsSection() {
|
|||||||
|
|
||||||
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
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) {
|
if (response.status === 200) {
|
||||||
alert({
|
alert({
|
||||||
alertType: 'success',
|
alertType: 'success',
|
||||||
@@ -471,6 +521,119 @@ export default function AdminConnectionsSection() {
|
|||||||
<PendingBadge show={isFieldPending('enableMobileScanner')} />
|
<PendingBadge show={isFieldPending('enableMobileScanner')} />
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</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>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,23 @@
|
|||||||
outline-offset: 2px;
|
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 {
|
.oauth-icon-small {
|
||||||
width: 1.75rem; /* 28px */
|
width: 1.75rem; /* 28px */
|
||||||
height: 1.75rem; /* 28px */
|
height: 1.75rem; /* 28px */
|
||||||
|
|||||||
+15
-23
@@ -68,36 +68,28 @@ fi
|
|||||||
|
|
||||||
# # === tessdata ===
|
# # === tessdata ===
|
||||||
# # Prepare Tesseract OCR data directory.
|
# # Prepare Tesseract OCR data directory.
|
||||||
REAL_TESSDATA="/usr/share/tesseract-ocr/5/tessdata"
|
# In Debian, tesseract looks in /usr/share/tesseract-ocr/5/tessdata
|
||||||
SEC_TESSDATA="/usr/share/tessdata"
|
# For backwards compatibility, copy any user-mounted files from /usr/share/tessdata
|
||||||
|
TESSDATA_SYSTEM="/usr/share/tesseract-ocr/5/tessdata"
|
||||||
|
TESSDATA_MOUNT="/usr/share/tessdata"
|
||||||
|
|
||||||
log_warn() {
|
log_warn() {
|
||||||
echo "[init][warn] $*" >&2
|
echo "[init][warn] $*" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ -d "$REAL_TESSDATA" ] && [ -w "$REAL_TESSDATA" ]; then
|
# Ensure system tessdata directory exists
|
||||||
log_warn "Skipping tessdata adjustments; directory writable: $REAL_TESSDATA"
|
mkdir -p "$TESSDATA_SYSTEM" 2>/dev/null || true
|
||||||
else
|
|
||||||
log_warn "Skipping tessdata adjustments; directory missing or not writable: $REAL_TESSDATA"
|
# For backwards compatibility: if user mounted custom languages to /usr/share/tessdata,
|
||||||
|
# copy them to the system location where Tesseract actually looks
|
||||||
|
if [ -d "$TESSDATA_MOUNT" ] && [ "$(ls -A "$TESSDATA_MOUNT" 2>/dev/null)" ]; then
|
||||||
|
log_warn "Found user-mounted tessdata in $TESSDATA_MOUNT, copying to system location $TESSDATA_SYSTEM"
|
||||||
|
cp -rn "$TESSDATA_MOUNT"/* "$TESSDATA_SYSTEM"/ 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then
|
# Set TESSDATA_PREFIX to system location
|
||||||
REAL_TESSDATA="/usr/share/tesseract-ocr/5/tessdata"
|
export TESSDATA_PREFIX="$TESSDATA_SYSTEM"
|
||||||
log_warn "Using /usr/share/tesseract-ocr/5/tessdata as TESSDATA_PREFIX"
|
log_warn "Using TESSDATA_PREFIX=$TESSDATA_PREFIX"
|
||||||
elif [ -d /usr/share/tessdata ]; then
|
|
||||||
REAL_TESSDATA="/usr/share/tessdata"
|
|
||||||
log_warn "Using /usr/share/tessdata as TESSDATA_PREFIX"
|
|
||||||
elif [ -d /tessdata ]; then
|
|
||||||
REAL_TESSDATA="/tessdata"
|
|
||||||
log_warn "Using /tessdata as TESSDATA_PREFIX"
|
|
||||||
else
|
|
||||||
REAL_TESSDATA=""
|
|
||||||
log_warn "No tessdata directory found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$REAL_TESSDATA" ]; then
|
|
||||||
export TESSDATA_PREFIX="$REAL_TESSDATA"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# === Temp dir ===
|
# === Temp dir ===
|
||||||
# Ensure the temporary directory exists and has proper permissions.
|
# Ensure the temporary directory exists and has proper permissions.
|
||||||
|
|||||||
Reference in New Issue
Block a user