mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84ac407017 | ||
|
|
794d15ebaa | ||
|
|
1e11848d4f | ||
|
|
5f32502b54 | ||
|
|
4f4f3c90ba | ||
|
|
2e51a7b288 | ||
|
|
8140ff8c02 | ||
|
|
432e1047d9 | ||
|
|
89df78c347 | ||
|
|
dd6419743f | ||
|
|
fca8470637 | ||
|
|
76f2fd3b76 | ||
|
|
06af6be14b | ||
|
|
c87da6d5cc | ||
|
|
6c8d2c89fe | ||
|
|
8d9e70c796 | ||
|
|
f4725b98b0 | ||
|
|
75414b89f9 | ||
|
|
dd1c653301 | ||
|
|
0f73a1cf13 | ||
|
|
8ffe53536d | ||
|
|
6177ccd333 | ||
|
|
5f62e6f81f |
@@ -14,7 +14,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2]
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/desktop/**'
|
||||
@@ -61,6 +61,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
@@ -174,6 +177,84 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
@@ -229,13 +310,174 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
SIGN: 1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# List available certificates and check if they have certificates attached
|
||||
Write-Host "Checking for available certificates..."
|
||||
$certList = & smctl keypair ls 2>&1
|
||||
Write-Host "Keypair list output:"
|
||||
Write-Host $certList
|
||||
Write-Host ""
|
||||
|
||||
# Parse the output to check certificate status
|
||||
$lines = $certList -split "`n"
|
||||
$foundKeypair = $false
|
||||
$hasCertificate = $false
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
|
||||
$foundKeypair = $true
|
||||
Write-Host "[SUCCESS] Found keypair in list"
|
||||
|
||||
# Check if this line has certificate info (not just empty spaces after alias)
|
||||
$parts = $line -split "\s+"
|
||||
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
|
||||
$hasCertificate = $true
|
||||
Write-Host "[SUCCESS] Certificate is associated with keypair"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $foundKeypair) {
|
||||
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
|
||||
Write-Host "Available keypairs are listed above"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify:"
|
||||
Write-Host " 1. Keypair alias is correct in GitHub secret"
|
||||
Write-Host " 2. API key has access to this keypair"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $hasCertificate) {
|
||||
Write-Host "[ERROR] No certificate associated with keypair"
|
||||
Write-Host "This usually means:"
|
||||
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
|
||||
Write-Host " 2. Certificate is pending approval"
|
||||
Write-Host " 3. Certificate needs to be attached to the keypair"
|
||||
Write-Host ""
|
||||
Write-Host "Try running in DigiCert ONE portal:"
|
||||
Write-Host " smctl keypair sync"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[SUCCESS] Certificate check passed"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
Write-Host ""
|
||||
Write-Host "Possible issues:"
|
||||
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
|
||||
Write-Host " 2. Incorrect keypair alias"
|
||||
Write-Host " 3. API key lacks signing permissions"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify in DigiCert ONE portal:"
|
||||
Write-Host " - Certificate status is 'Issued' (not Pending)"
|
||||
Write-Host " - Keypair status is 'Online'"
|
||||
Write-Host " - 'Can sign' is set to 'Yes'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
@@ -269,6 +511,66 @@ jobs:
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying Windows code signatures..."
|
||||
|
||||
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
|
||||
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
|
||||
|
||||
$allSigned = $true
|
||||
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
|
||||
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
|
||||
|
||||
# Check EXE signature
|
||||
if (Test-Path $exePath) {
|
||||
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
|
||||
Write-Host "EXE Signature Status: $($exeSig.Status)"
|
||||
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
|
||||
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($exeSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] EXE is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
# Check MSI signature
|
||||
if (Test-Path $msiPath) {
|
||||
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
|
||||
Write-Host "MSI Signature Status: $($msiSig.Status)"
|
||||
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
|
||||
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($msiSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] MSI is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
|
||||
Write-Host "[ERROR] Code signing verification failed"
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "[SUCCESS] Code signature verification completed"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Windows Code Signing Setup Guide
|
||||
|
||||
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
|
||||
|
||||
## Overview
|
||||
|
||||
Windows code signing is essential for:
|
||||
- Preventing Windows SmartScreen warnings
|
||||
- Building trust with users
|
||||
- Enabling Microsoft Store distribution
|
||||
- Professional application distribution
|
||||
|
||||
## Certificate Types
|
||||
|
||||
### OV Certificate (Organization Validated)
|
||||
- More affordable option
|
||||
- Requires business verification
|
||||
- May trigger SmartScreen warnings initially until reputation builds
|
||||
- Suitable for most independent software vendors
|
||||
|
||||
### EV Certificate (Extended Validation)
|
||||
- Premium option with immediate SmartScreen reputation
|
||||
- Requires hardware security module (HSM) or cloud-based signing
|
||||
- Higher cost but provides immediate trust
|
||||
- Required since June 2023 for new certificates
|
||||
|
||||
## Obtaining a Certificate
|
||||
|
||||
### Certificate Authorities
|
||||
Popular certificate authorities for Windows code signing:
|
||||
- DigiCert
|
||||
- Sectigo (formerly Comodo)
|
||||
- GlobalSign
|
||||
- SSL.com
|
||||
|
||||
### Certificate Format
|
||||
You'll receive a certificate in one of these formats:
|
||||
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
|
||||
- `.cer` + private key (needs conversion to .pfx)
|
||||
|
||||
### Converting to PFX (if needed)
|
||||
If you have separate certificate and private key files:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
|
||||
```
|
||||
|
||||
## Setting Up GitHub Secrets
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
|
||||
|
||||
Add the following secrets:
|
||||
|
||||
#### 1. `WINDOWS_CERTIFICATE`
|
||||
- **Description**: Base64-encoded .pfx certificate file
|
||||
- **How to create**:
|
||||
|
||||
**On macOS/Linux:**
|
||||
```bash
|
||||
base64 -i certificate.pfx | pbcopy # Copies to clipboard
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the entire base64 string into the GitHub secret.
|
||||
|
||||
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
|
||||
- **Description**: Password for the .pfx certificate
|
||||
- **Value**: The password you set when creating/exporting the .pfx file
|
||||
|
||||
### Optional Secrets for Tauri Updater
|
||||
|
||||
If you're using Tauri's built-in updater feature:
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- Generated using Tauri CLI: `npm run tauri signer generate`
|
||||
- Used for update package verification
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
- Password for the Tauri signing key
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
|
||||
|
||||
The Windows signing configuration is already set up:
|
||||
|
||||
```json
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
|
||||
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
|
||||
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
|
||||
|
||||
**Alternative Timestamp Servers:**
|
||||
- DigiCert: `http://timestamp.digicert.com`
|
||||
- Sectigo: `http://timestamp.sectigo.com`
|
||||
- GlobalSign: `http://timestamp.globalsign.com`
|
||||
|
||||
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
|
||||
|
||||
The workflow includes three Windows signing steps:
|
||||
|
||||
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
|
||||
2. **Build Tauri App**: Builds and signs the application using the imported certificate
|
||||
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Local Testing (Windows Only)
|
||||
|
||||
Before pushing to GitHub, test locally:
|
||||
|
||||
```powershell
|
||||
# Set environment variables
|
||||
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
|
||||
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
|
||||
|
||||
# Build the application
|
||||
cd frontend
|
||||
npm run tauri build
|
||||
|
||||
# Verify the signature
|
||||
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
|
||||
```
|
||||
|
||||
### 2. GitHub Actions Testing
|
||||
|
||||
1. Push your changes to a branch
|
||||
2. Manually trigger the workflow:
|
||||
- Go to Actions → Build Tauri Applications
|
||||
- Click "Run workflow"
|
||||
- Select "windows" platform
|
||||
3. Check the build logs for:
|
||||
- ✅ Certificate import success
|
||||
- ✅ Build completion
|
||||
- ✅ Signature verification
|
||||
|
||||
### 3. Verifying Signed Binaries
|
||||
|
||||
After downloading the built artifacts:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Status: `Valid`
|
||||
- Signer: Your organization name
|
||||
- Timestamp: Recent date/time
|
||||
|
||||
**Windows (GUI):**
|
||||
1. Right-click the .exe or .msi file
|
||||
2. Select "Properties"
|
||||
3. Go to "Digital Signatures" tab
|
||||
4. Verify signature details
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "HashMismatch" Status
|
||||
- Certificate doesn't match the binary
|
||||
- Possible file corruption during download
|
||||
- Re-download and verify
|
||||
|
||||
### "NotSigned" Status
|
||||
- Certificate wasn't imported correctly
|
||||
- Check GitHub secrets are set correctly
|
||||
- Verify base64 encoding is complete (no truncation)
|
||||
|
||||
### "UnknownError" Status
|
||||
- Timestamp server unreachable
|
||||
- Try alternative timestamp URL in tauri.conf.json
|
||||
- Check network connectivity in GitHub Actions
|
||||
|
||||
### SmartScreen Still Shows Warnings
|
||||
- Normal for OV certificates initially
|
||||
- Reputation builds over time with user downloads
|
||||
- Consider EV certificate for immediate reputation
|
||||
|
||||
### Certificate Not Found During Build
|
||||
- Verify `WINDOWS_CERTIFICATE` secret is set
|
||||
- Check base64 encoding is correct (no extra whitespace)
|
||||
- Ensure password is correct
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit certificates to version control**
|
||||
- Keep .pfx files secure and backed up
|
||||
- Use GitHub secrets for CI/CD
|
||||
|
||||
2. **Rotate certificates before expiration**
|
||||
- Set calendar reminders
|
||||
- Update GitHub secrets with new certificate
|
||||
|
||||
3. **Use strong passwords**
|
||||
- Certificate password should be complex
|
||||
- Store securely (password manager)
|
||||
|
||||
4. **Monitor certificate usage**
|
||||
- Review GitHub Actions logs
|
||||
- Set up notifications for failed builds
|
||||
|
||||
5. **Limit access to secrets**
|
||||
- Only repository admins should access secrets
|
||||
- Audit secret access regularly
|
||||
|
||||
## Certificate Lifecycle
|
||||
|
||||
### Before Expiration
|
||||
1. Obtain new certificate from CA (typically annual renewal)
|
||||
2. Convert to .pfx format if needed
|
||||
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
|
||||
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
|
||||
5. Test build to verify new certificate works
|
||||
|
||||
### Expired Certificates
|
||||
- Signed binaries remain valid (timestamp proves signing time)
|
||||
- New builds will fail until certificate is renewed
|
||||
- Users can still install previously signed versions
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### Certificate Costs (Annual, as of 2024)
|
||||
- **OV Certificate**: $100-400/year
|
||||
- **EV Certificate**: $400-1000/year
|
||||
|
||||
### Choosing the Right Certificate
|
||||
- **Open source / early stage**: Start with OV
|
||||
- **Commercial / enterprise**: Consider EV for better trust
|
||||
- **Microsoft Store**: EV certificate required
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
|
||||
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
|
||||
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
|
||||
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues with Windows code signing:
|
||||
1. Check GitHub Actions logs for detailed error messages
|
||||
2. Verify all secrets are set correctly
|
||||
3. Test certificate locally first (Windows environment required)
|
||||
4. Open an issue in the repository with relevant logs (remove sensitive data)
|
||||
@@ -38,6 +38,7 @@ public class GeneralUtils {
|
||||
Set.of(
|
||||
"OCR images.json",
|
||||
"Prepare-pdfs-for-email.json",
|
||||
"Pre-publish-sanitization.json",
|
||||
"split-rotate-auto-rename.json");
|
||||
|
||||
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.util.Properties;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.web.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@@ -198,6 +200,14 @@ public class SPDFApplication {
|
||||
// }
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onWebServerInitialized(WebServerInitializedEvent event) {
|
||||
int actualPort = event.getWebServer().getPort();
|
||||
serverPortStatic = String.valueOf(actualPort);
|
||||
// Log the actual runtime port for Tauri to parse
|
||||
log.info("Stirling-PDF running on port: {}", actualPort);
|
||||
}
|
||||
|
||||
private static void printStartupLogs() {
|
||||
log.info("Stirling-PDF Started.");
|
||||
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
||||
|
||||
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class EndpointConfiguration {
|
||||
|
||||
public enum DisableReason {
|
||||
CONFIG,
|
||||
DEPENDENCY,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public static class EndpointAvailability {
|
||||
private final boolean enabled;
|
||||
private final DisableReason reason;
|
||||
|
||||
public EndpointAvailability(boolean enabled, DisableReason reason) {
|
||||
this.enabled = enabled;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DisableReason getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String REMOVE_BLANKS = "remove-blanks";
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
|
||||
processEnvironmentConfigs();
|
||||
}
|
||||
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
endpointStatuses.put(endpoint, true);
|
||||
log.debug("Enabled endpoint: {}", endpoint);
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
endpointDisableReasons.remove(normalized);
|
||||
log.debug("Enabled endpoint: {}", normalized);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint) {
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
|
||||
log.debug("Disabling endpoint: {}", endpoint);
|
||||
disableEndpoint(endpoint, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint, DisableReason reason) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
log.debug("Disabling endpoint: {}", normalized);
|
||||
}
|
||||
endpointStatuses.put(endpoint, false);
|
||||
endpointStatuses.put(normalized, false);
|
||||
endpointDisableReasons.put(normalized, reason);
|
||||
}
|
||||
|
||||
public boolean isEndpointEnabled(String endpoint) {
|
||||
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
public void disableGroup(String group) {
|
||||
disableGroup(group, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableGroup(String group, DisableReason reason) {
|
||||
if (disabledGroups.add(group)) {
|
||||
if (isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
|
||||
group);
|
||||
}
|
||||
}
|
||||
groupDisableReasons.put(group, reason);
|
||||
// Only cascade to endpoints for *functional* groups
|
||||
if (!isToolGroup(group)) {
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::disableEndpoint);
|
||||
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
|
||||
if (disabledGroups.remove(group)) {
|
||||
log.debug("Enabling group: {}", group);
|
||||
}
|
||||
groupDisableReasons.remove(group);
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::enableEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
public EndpointAvailability getEndpointAvailability(String endpoint) {
|
||||
boolean enabled = isEndpointEnabled(endpoint);
|
||||
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
|
||||
return new EndpointAvailability(enabled, reason);
|
||||
}
|
||||
|
||||
private DisableReason determineDisableReason(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
Set<String> endpoints = entry.getValue();
|
||||
if (!disabledGroups.contains(group) || endpoints == null) {
|
||||
continue;
|
||||
}
|
||||
if (endpoints.contains(normalized)) {
|
||||
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
return DisableReason.UNKNOWN;
|
||||
}
|
||||
|
||||
public Set<String> getDisabledGroups() {
|
||||
return new HashSet<>(disabledGroups);
|
||||
}
|
||||
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -394,6 +469,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
addEndpointToGroup("Java", "cbz-to-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
|
||||
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
|
||||
if (affectedGroups != null) {
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
|
||||
if (!pythonAvailable) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
|
||||
String.join(", ", pythonFeatures),
|
||||
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
|
||||
e.getMessage(),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
|
||||
/**
|
||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is
|
||||
* disabled (no-login mode).
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WAUTrackingFilter implements Filter {
|
||||
|
||||
private final WeeklyActiveUsersService wauService;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (request instanceof HttpServletRequest httpRequest) {
|
||||
// Extract browser ID from header
|
||||
String browserId = httpRequest.getHeader("X-Browser-Id");
|
||||
|
||||
if (browserId != null && !browserId.trim().isEmpty()) {
|
||||
// Record browser access
|
||||
wauService.recordBrowserAccess(browserId);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue the filter chain
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
@@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedOriginPatterns(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else {
|
||||
// Default to allowing all origins when nothing is configured
|
||||
logger.info(
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
|
||||
// default)
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -1,6 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
@@ -154,6 +159,25 @@ public class ConfigController {
|
||||
// EE features not available, continue without them
|
||||
}
|
||||
|
||||
// Add version and machine info for update checking
|
||||
try {
|
||||
if (applicationContext.containsBean("appVersion")) {
|
||||
configData.put(
|
||||
"appVersion", applicationContext.getBean("appVersion", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("machineType")) {
|
||||
configData.put(
|
||||
"machineType", applicationContext.getBean("machineType", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("activeSecurity")) {
|
||||
configData.put(
|
||||
"activeSecurity",
|
||||
applicationContext.getBean("activeSecurity", Boolean.class));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -181,4 +205,19 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointInspector;
|
||||
import stirling.software.SPDF.config.StartupApplicationListener;
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
import stirling.software.common.annotations.api.InfoApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@@ -34,6 +35,7 @@ public class MetricsController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final EndpointInspector endpointInspector;
|
||||
private final Optional<WeeklyActiveUsersService> wauService;
|
||||
private boolean metricsEnabled;
|
||||
|
||||
@PostConstruct
|
||||
@@ -352,6 +354,36 @@ public class MetricsController {
|
||||
return ResponseEntity.ok(formatDuration(uptime));
|
||||
}
|
||||
|
||||
@GetMapping("/wau")
|
||||
@Operation(
|
||||
summary = "Weekly Active Users statistics",
|
||||
description =
|
||||
"Returns WAU (Weekly Active Users) count and total unique browsers. "
|
||||
+ "Only available when security is disabled (no-login mode). "
|
||||
+ "Tracks unique browsers via client-generated UUID in localStorage.")
|
||||
public ResponseEntity<?> getWeeklyActiveUsers() {
|
||||
if (!metricsEnabled) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
|
||||
}
|
||||
|
||||
// Check if WAU service is available (only when security.enableLogin=false)
|
||||
if (wauService.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(
|
||||
"WAU tracking is only available when security is disabled (no-login mode)");
|
||||
}
|
||||
|
||||
WeeklyActiveUsersService service = wauService.get();
|
||||
|
||||
Map<String, Object> wauStats = new HashMap<>();
|
||||
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
|
||||
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
|
||||
wauStats.put("daysOnline", service.getDaysOnline());
|
||||
wauStats.put("trackingSince", service.getStartTime().toString());
|
||||
|
||||
return ResponseEntity.ok(wauStats);
|
||||
}
|
||||
|
||||
private String formatDuration(Duration duration) {
|
||||
long days = duration.toDays();
|
||||
long hours = duration.toHoursPart();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with
|
||||
* automatic cleanup of old entries.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class WeeklyActiveUsersService {
|
||||
|
||||
// Map of browser ID -> last seen timestamp
|
||||
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||
|
||||
// Track total unique browsers seen (overall)
|
||||
private long totalUniqueBrowsers = 0;
|
||||
|
||||
// Application start time
|
||||
private final Instant startTime = Instant.now();
|
||||
|
||||
/**
|
||||
* Records a browser access with the current timestamp
|
||||
*
|
||||
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||
*/
|
||||
public void recordBrowserAccess(String browserId) {
|
||||
if (browserId == null || browserId.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||
activeBrowsers.put(browserId, Instant.now());
|
||||
|
||||
if (isNewBrowser) {
|
||||
totalUniqueBrowsers++;
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of unique browsers seen in the last 7 days
|
||||
*
|
||||
* @return Weekly Active Users count
|
||||
*/
|
||||
public long getWeeklyActiveUsers() {
|
||||
cleanupOldEntries();
|
||||
return activeBrowsers.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of unique browsers ever seen
|
||||
*
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
return totalUniqueBrowsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of days the service has been running
|
||||
*
|
||||
* @return Days online
|
||||
*/
|
||||
public long getDaysOnline() {
|
||||
return ChronoUnit.DAYS.between(startTime, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp when tracking started
|
||||
*
|
||||
* @return Start time
|
||||
*/
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
/** Removes entries older than 7 days */
|
||||
private void cleanupOldEntries() {
|
||||
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/** Manual cleanup trigger (can be called by scheduled task if needed) */
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
int sizeAfter = activeBrowsers.size();
|
||||
|
||||
if (sizeBefore != sizeAfter) {
|
||||
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "Pre-publish-sanitization",
|
||||
"pipeline": [
|
||||
{
|
||||
"operation": "/api/v1/security/sanitize-pdf",
|
||||
"parameters": {
|
||||
"removeJavaScript": true,
|
||||
"removeEmbeddedFiles": true,
|
||||
"removeXMPMetadata": true,
|
||||
"removeMetadata": true,
|
||||
"removeLinks": true,
|
||||
"removeFonts": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/flatten",
|
||||
"parameters": {
|
||||
"flattenOnlyForms": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/general/remove-annotations",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/update-metadata",
|
||||
"parameters": {
|
||||
"deleteAll": true,
|
||||
"author": "",
|
||||
"creationDate": "",
|
||||
"creator": "",
|
||||
"keywords": "",
|
||||
"modificationDate": "",
|
||||
"producer": "",
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"trapped": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/compress-pdf",
|
||||
"parameters": {
|
||||
"optimizeLevel": 3,
|
||||
"expectedOutputSize": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"_examples": {
|
||||
"outputDir": "{outputFolder}/{folderName}",
|
||||
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
|
||||
},
|
||||
"outputDir": "{outputFolder}",
|
||||
"outputFileName": "pre_publish_{filename}.PDF"
|
||||
}
|
||||
+6
-1
@@ -113,7 +113,12 @@ public class LicenseKeyChecker {
|
||||
|
||||
public void updateLicenseKey(String newKey) throws IOException {
|
||||
applicationProperties.getPremium().setKey(newKey);
|
||||
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
|
||||
GeneralUtils.saveKeyToSettings("premium.key", newKey);
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
public void resyncLicense() {
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
|
||||
* and endpoints for managing license keys.
|
||||
*/
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
|
||||
public class AdminLicenseController {
|
||||
|
||||
@Autowired(required = false)
|
||||
private LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
@Autowired(required = false)
|
||||
private KeygenLicenseVerifier keygenLicenseVerifier;
|
||||
|
||||
@Autowired private ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
|
||||
* as metadata in Stripe checkout to link licenses to specific installations.
|
||||
*
|
||||
* @return Map containing the installation ID
|
||||
*/
|
||||
@GetMapping("/installation-id")
|
||||
@Operation(
|
||||
summary = "Get installation ID",
|
||||
description =
|
||||
"Returns the unique installation ID (MAC-based fingerprint) for this"
|
||||
+ " self-hosted instance")
|
||||
public ResponseEntity<Map<String, String>> getInstallationId() {
|
||||
try {
|
||||
String installationId = GeneralUtils.generateMachineFingerprint();
|
||||
log.info("Admin requested installation ID: {}", installationId);
|
||||
return ResponseEntity.ok(Map.of("installationId", installationId));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate installation ID", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to generate installation ID"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and activate a license key. This endpoint accepts a license key from the frontend (e.g.,
|
||||
* after Stripe checkout) and activates it on the backend.
|
||||
*
|
||||
* @param request Map containing the license key
|
||||
* @return Response with success status, license type, and whether restart is required
|
||||
*/
|
||||
@PostMapping("/license-key")
|
||||
@Operation(
|
||||
summary = "Save and activate license key",
|
||||
description =
|
||||
"Accepts a license key and activates it on the backend. Returns the activated"
|
||||
+ " license type.")
|
||||
public ResponseEntity<Map<String, Object>> saveLicenseKey(
|
||||
@RequestBody Map<String, String> request) {
|
||||
String licenseKey = request.get("licenseKey");
|
||||
|
||||
// Reject null but allow empty string to clear license
|
||||
if (licenseKey == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "License key is required"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (licenseKeyChecker == null) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "error", "License checker not available"));
|
||||
}
|
||||
// assume premium enabled when setting license key
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
|
||||
// Use existing LicenseKeyChecker to update and validate license
|
||||
// Empty string will be evaluated as NORMAL license (free tier)
|
||||
licenseKeyChecker.updateLicenseKey(licenseKey.trim());
|
||||
|
||||
// Get current license status
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
|
||||
// Auto-enable premium features if license is valid
|
||||
if (license != License.NORMAL) {
|
||||
GeneralUtils.saveKeyToSettings("premium.enabled", true);
|
||||
// Enable premium features
|
||||
|
||||
// Save maxUsers from license metadata
|
||||
Integer maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
if (maxUsers != null) {
|
||||
GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers);
|
||||
}
|
||||
} else {
|
||||
GeneralUtils.saveKeyToSettings("premium.enabled", false);
|
||||
log.info("License key is not valid for premium features: type={}", license.name());
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("enabled", applicationProperties.getPremium().isEnabled());
|
||||
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
|
||||
response.put("requiresRestart", false); // Dynamic evaluation works
|
||||
response.put("message", "License key saved and activated");
|
||||
|
||||
log.info("License key saved and activated: type={}", license.name());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save license key", e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to activate license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync the current license with Keygen. This endpoint re-validates the existing license key
|
||||
* and updates the max users setting. Used after subscription upgrades to sync the new license
|
||||
* limits.
|
||||
*
|
||||
* @return Response with updated license information
|
||||
*/
|
||||
@PostMapping("/license/resync")
|
||||
@Operation(
|
||||
summary = "Resync license with Keygen",
|
||||
description =
|
||||
"Re-validates the existing license key with Keygen and updates local settings."
|
||||
+ " Used after subscription upgrades.")
|
||||
public ResponseEntity<Map<String, Object>> resyncLicense() {
|
||||
try {
|
||||
if (licenseKeyChecker == null) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "error", "License checker not available"));
|
||||
}
|
||||
|
||||
String currentKey = applicationProperties.getPremium().getKey();
|
||||
if (currentKey == null || currentKey.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "No license key configured"));
|
||||
}
|
||||
|
||||
log.info("Resyncing license with Keygen");
|
||||
|
||||
// Re-validate license and sync settings
|
||||
licenseKeyChecker.resyncLicense();
|
||||
|
||||
// Get updated license status
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
ApplicationProperties.Premium premium = applicationProperties.getPremium();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("enabled", premium.isEnabled());
|
||||
response.put("maxUsers", premium.getMaxUsers());
|
||||
response.put("message", "License resynced successfully");
|
||||
|
||||
log.info(
|
||||
"License resynced: type={}, maxUsers={}",
|
||||
license.name(),
|
||||
premium.getMaxUsers());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to resync license", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to resync license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the current license key status, including license type, enabled status,
|
||||
* and max users.
|
||||
*
|
||||
* @return Map containing license information
|
||||
*/
|
||||
@GetMapping("/license-info")
|
||||
@Operation(
|
||||
summary = "Get license information",
|
||||
description =
|
||||
"Returns information about the current license including type, enabled status,"
|
||||
+ " and max users")
|
||||
public ResponseEntity<Map<String, Object>> getLicenseInfo() {
|
||||
try {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (licenseKeyChecker != null) {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
response.put("licenseType", license.name());
|
||||
} else {
|
||||
response.put("licenseType", License.NORMAL.name());
|
||||
}
|
||||
|
||||
ApplicationProperties.Premium premium = applicationProperties.getPremium();
|
||||
response.put("enabled", premium.isEnabled());
|
||||
response.put("maxUsers", premium.getMaxUsers());
|
||||
response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty());
|
||||
|
||||
// Include license key for upgrades (admin-only endpoint)
|
||||
if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) {
|
||||
response.put("licenseKey", premium.getKey());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get license info", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to retrieve license information"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -299,6 +299,16 @@ public class AdminSettingsController {
|
||||
+ String.join(", ", VALID_SECTION_NAMES));
|
||||
}
|
||||
|
||||
// Auto-enable premium features if license key is provided
|
||||
if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) {
|
||||
Object keyValue = sectionData.get("key");
|
||||
if (keyValue != null && !keyValue.toString().trim().isEmpty()) {
|
||||
// Automatically set enabled to true when a key is provided
|
||||
sectionData.put("enabled", true);
|
||||
log.info("Auto-enabling premium features because license key was provided");
|
||||
}
|
||||
}
|
||||
|
||||
int updatedCount = 0;
|
||||
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
|
||||
String propertyKey = entry.getKey();
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 3600000;
|
||||
private static final long EXPIRATION = 43200000;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
Generated
+160
-3
@@ -39,10 +39,14 @@
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
@@ -3120,6 +3124,138 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
|
||||
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
|
||||
"react": ">=16.8.0 <20.0.0",
|
||||
"react-dom": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
|
||||
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
|
||||
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
|
||||
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
|
||||
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
|
||||
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
|
||||
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.81.1",
|
||||
"@supabase/functions-js": "2.81.1",
|
||||
"@supabase/postgrest-js": "2.81.1",
|
||||
"@supabase/realtime-js": "2.81.1",
|
||||
"@supabase/storage-js": "2.81.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
|
||||
@@ -3887,6 +4023,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.4.tgz",
|
||||
"integrity": "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -4193,7 +4338,6 @@
|
||||
"version": "24.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -4205,6 +4349,12 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -4239,6 +4389,15 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -13952,7 +14111,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
@@ -14823,7 +14981,6 @@
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -26,19 +26,23 @@
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@mantine/core": "^8.3.1",
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
@@ -110,11 +114,11 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"comingSoon": "Coming soon:",
|
||||
"favorite": "Add to favourites",
|
||||
"favorites": "Favourites",
|
||||
"unavailable": "Disabled by server administrator:",
|
||||
"unavailableDependency": "Unavailable - required tool missing on server:",
|
||||
"heading": "All tools (fullscreen view)",
|
||||
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
|
||||
"recommended": "Recommended",
|
||||
@@ -362,7 +364,15 @@
|
||||
"defaultPdfEditorInactive": "Another application is set as default",
|
||||
"defaultPdfEditorChecking": "Checking...",
|
||||
"defaultPdfEditorSet": "Already Default",
|
||||
"setAsDefault": "Set as Default"
|
||||
"setAsDefault": "Set as Default",
|
||||
"updates": {
|
||||
"title": "Software Updates",
|
||||
"description": "Check for updates and view version information",
|
||||
"currentVersion": "Current Version",
|
||||
"latestVersion": "Latest Version",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"viewDetails": "View Details"
|
||||
}
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
@@ -383,6 +393,37 @@
|
||||
"searchPlaceholder": "Search tools..."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"modalTitle": "Update Available",
|
||||
"current": "Current Version",
|
||||
"latest": "Latest Version",
|
||||
"latestStable": "Latest Stable",
|
||||
"priorityLabel": "Priority",
|
||||
"recommendedAction": "Recommended Action",
|
||||
"breakingChangesDetected": "Breaking Changes Detected",
|
||||
"breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.",
|
||||
"migrationGuides": "Migration Guides",
|
||||
"viewGuide": "View Guide",
|
||||
"loadingDetailedInfo": "Loading detailed information...",
|
||||
"close": "Close",
|
||||
"viewAllReleases": "View All Releases",
|
||||
"downloadLatest": "Download Latest",
|
||||
"availableUpdates": "Available Updates",
|
||||
"unableToLoadDetails": "Unable to load detailed information.",
|
||||
"version": "Version",
|
||||
"urgentUpdateAvailable": "Urgent Update",
|
||||
"updateAvailable": "Update Available",
|
||||
"releaseNotes": "Release Notes",
|
||||
"priority": {
|
||||
"urgent": "Urgent",
|
||||
"normal": "Normal",
|
||||
"minor": "Minor",
|
||||
"low": "Low"
|
||||
},
|
||||
"breakingChanges": "Breaking Changes",
|
||||
"breakingChangesDefault": "This version contains breaking changes.",
|
||||
"migrationGuide": "Migration Guide"
|
||||
},
|
||||
"changeCreds": {
|
||||
"title": "Change Credentials",
|
||||
"header": "Update Your Account Details",
|
||||
@@ -879,6 +920,11 @@
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while merging the PDFs."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Merge Settings Overview"
|
||||
}
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
@@ -2095,13 +2141,54 @@
|
||||
"title": "Draw your signature",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"canvas": {
|
||||
"heading": "Draw your signature",
|
||||
"clickToOpen": "Click to open the drawing canvas",
|
||||
"modalTitle": "Draw your signature",
|
||||
"colorLabel": "Colour",
|
||||
"penSizeLabel": "Pen size",
|
||||
"penSizePlaceholder": "Size",
|
||||
"clear": "Clear canvas",
|
||||
"colorPickerTitle": "Choose stroke colour"
|
||||
},
|
||||
"text": {
|
||||
"name": "Signer Name",
|
||||
"placeholder": "Enter your full name"
|
||||
"placeholder": "Enter your full name",
|
||||
"fontLabel": "Font",
|
||||
"fontSizeLabel": "Font size",
|
||||
"fontSizePlaceholder": "Type or select font size (8-200)",
|
||||
"colorLabel": "Text colour"
|
||||
},
|
||||
"clear": "Clear",
|
||||
"add": "Add",
|
||||
"saved": "Saved Signatures",
|
||||
"saved": {
|
||||
"heading": "Saved signatures",
|
||||
"description": "Reuse saved signatures at any time.",
|
||||
"emptyTitle": "No saved signatures yet",
|
||||
"emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.",
|
||||
"type": {
|
||||
"canvas": "Drawing",
|
||||
"image": "Upload",
|
||||
"text": "Text"
|
||||
},
|
||||
"limitTitle": "Limit reached",
|
||||
"limitDescription": "Remove a saved signature before adding new ones (max {{max}}).",
|
||||
"carouselPosition": "{{current}} of {{total}}",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"delete": "Remove",
|
||||
"label": "Label",
|
||||
"defaultLabel": "Signature",
|
||||
"defaultCanvasLabel": "Drawing signature",
|
||||
"defaultImageLabel": "Uploaded signature",
|
||||
"defaultTextLabel": "Typed signature",
|
||||
"saveButton": "Save signature",
|
||||
"saveUnavailable": "Create a signature first to save it.",
|
||||
"noChanges": "Current signature is already saved.",
|
||||
"status": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
},
|
||||
"save": "Save Signature",
|
||||
"applySignatures": "Apply Signatures",
|
||||
"personalSigs": "Personal Signatures",
|
||||
@@ -2120,12 +2207,18 @@
|
||||
"steps": {
|
||||
"configure": "Configure Signature"
|
||||
},
|
||||
"step": {
|
||||
"createDesc": "Choose how you want to create the signature",
|
||||
"place": "Place & save",
|
||||
"placeDesc": "Position the signature on your PDF"
|
||||
},
|
||||
"type": {
|
||||
"title": "Signature Type",
|
||||
"draw": "Draw",
|
||||
"canvas": "Canvas",
|
||||
"image": "Image",
|
||||
"text": "Text"
|
||||
"text": "Text",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"image": {
|
||||
"label": "Upload signature image",
|
||||
@@ -2136,11 +2229,17 @@
|
||||
"title": "How to add signature",
|
||||
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
||||
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
||||
"text": "After entering your name above, click anywhere on the PDF to place your signature."
|
||||
"saved": "Select a saved signature above, then click anywhere on the PDF to place it.",
|
||||
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
|
||||
"paused": "Placement paused",
|
||||
"resumeHint": "Resume placement to click and add your signature.",
|
||||
"noSignature": "Create a signature above to enable placement tools."
|
||||
},
|
||||
"mode": {
|
||||
"move": "Move Signature",
|
||||
"place": "Place Signature"
|
||||
"place": "Place Signature",
|
||||
"pause": "Pause placement",
|
||||
"resume": "Resume placement"
|
||||
},
|
||||
"updateAndPlace": "Update and Place",
|
||||
"activate": "Activate Signature Placement",
|
||||
@@ -2280,6 +2379,14 @@
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Remove Annotations"
|
||||
},
|
||||
"description": {
|
||||
"title": "What it does"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
@@ -2323,7 +2430,7 @@
|
||||
},
|
||||
"cta": "Compare",
|
||||
"loading": "Comparing...",
|
||||
|
||||
|
||||
"summary": {
|
||||
"baseHeading": "Original document",
|
||||
"comparisonHeading": "Edited document",
|
||||
@@ -2379,7 +2486,7 @@
|
||||
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
|
||||
"cancel": "Cancel comparison"
|
||||
},
|
||||
|
||||
|
||||
"newLine": "new-line",
|
||||
"complex": {
|
||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||
@@ -2742,6 +2849,9 @@
|
||||
"header": {
|
||||
"title": "How Auto-Rename Works"
|
||||
},
|
||||
"description": {
|
||||
"title": "What it does"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Smart Renaming",
|
||||
"text": "Automatically finds the title from your PDF content and uses it as the filename.",
|
||||
@@ -2749,6 +2859,9 @@
|
||||
"bullet2": "Creates a clean, valid filename from the detected title",
|
||||
"bullet3": "Keeps the original name if no suitable title is found"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "About"
|
||||
}
|
||||
},
|
||||
"adjust-contrast": {
|
||||
@@ -4327,9 +4440,21 @@
|
||||
"title": "Premium & Enterprise",
|
||||
"description": "Configure your premium or enterprise license key.",
|
||||
"license": "License Configuration",
|
||||
"licenseKey": {
|
||||
"toggle": "Got a license key or certificate file?",
|
||||
"info": "If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features."
|
||||
},
|
||||
"key": {
|
||||
"label": "License Key",
|
||||
"description": "Enter your premium or enterprise license key"
|
||||
"description": "Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
|
||||
"success": "License Key Saved",
|
||||
"successMessage": "Your license key has been activated successfully. No restart required.",
|
||||
"overwriteWarning": {
|
||||
"title": "⚠️ Warning: Existing License Detected",
|
||||
"line1": "Overwriting your current license key cannot be undone.",
|
||||
"line2": "Your previous license will be permanently lost unless you have backed it up elsewhere.",
|
||||
"line3": "Important: Keep license keys private and secure. Never share them publicly."
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Enable Premium Features",
|
||||
@@ -4736,9 +4861,14 @@
|
||||
"secureWorkflow": "Security Workflow",
|
||||
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
|
||||
"processImages": "Process Images",
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.",
|
||||
"prePublishSanitization": "Pre-publish Sanitization",
|
||||
"prePublishSanitizationDesc": "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"title": "Choose colour"
|
||||
},
|
||||
"common": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
@@ -4754,7 +4884,12 @@
|
||||
"used": "used",
|
||||
"available": "available",
|
||||
"cancel": "Cancel",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"close": "Close",
|
||||
"done": "Done",
|
||||
"loading": "Loading...",
|
||||
"back": "Back",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"config": {
|
||||
"overview": {
|
||||
@@ -4820,6 +4955,14 @@
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Add Attachments"
|
||||
},
|
||||
"description": {
|
||||
"title": "What it does"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
},
|
||||
@@ -5172,6 +5315,14 @@
|
||||
"showComparison": "Compare All Features",
|
||||
"hideComparison": "Hide Feature Comparison",
|
||||
"featureComparison": "Feature Comparison",
|
||||
"from": "From",
|
||||
"perMonth": "/month",
|
||||
"licensedSeats": "Licensed: {{count}} seats",
|
||||
"includedInCurrent": "Included in Your Plan",
|
||||
"selectPlan": "Select Plan",
|
||||
"manageSubscription": {
|
||||
"description": "Manage your subscription, billing, and payment methods"
|
||||
},
|
||||
"activePlan": {
|
||||
"title": "Active Plan",
|
||||
"subtitle": "Your current subscription details"
|
||||
@@ -5189,13 +5340,16 @@
|
||||
"upTo": "Up to"
|
||||
},
|
||||
"period": {
|
||||
"month": "month"
|
||||
"month": "month",
|
||||
"perUserPerMonth": "/user/month"
|
||||
},
|
||||
"free": {
|
||||
"name": "Free",
|
||||
"highlight1": "Limited Tool Usage Per week",
|
||||
"highlight2": "Access to all tools",
|
||||
"highlight3": "Community support"
|
||||
"highlight3": "Community support",
|
||||
"forever": "Forever free",
|
||||
"included": "Included"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
@@ -5237,13 +5391,44 @@
|
||||
"error": "Failed to open billing portal"
|
||||
}
|
||||
},
|
||||
"upgradeBanner": {
|
||||
"title": "Upgrade to Server Plan",
|
||||
"message": "Get the most out of Stirling PDF with unlimited users and advanced features",
|
||||
"upgradeButton": "Upgrade Now",
|
||||
"dismiss": "Dismiss banner"
|
||||
},
|
||||
"payment": {
|
||||
"preparing": "Preparing your checkout...",
|
||||
"upgradeTitle": "Upgrade to {{planName}}",
|
||||
"success": "Payment Successful!",
|
||||
"successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.",
|
||||
"autoClose": "This window will close automatically...",
|
||||
"error": "Payment Error"
|
||||
"error": "Payment Error",
|
||||
"upgradeSuccess": "Payment successful! Your subscription has been upgraded. The license has been updated on your server. You will receive a confirmation email shortly.",
|
||||
"paymentSuccess": "Payment successful! Retrieving your license key...",
|
||||
"licenseActivated": "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address.",
|
||||
"licenseDelayed": "Payment successful! Your license is being generated. You will receive an email with your license key shortly. If you don't receive it within 10 minutes, please contact support.",
|
||||
"licensePollingError": "Payment successful but we couldn't retrieve your license key automatically. Please check your email or contact support with your payment confirmation.",
|
||||
"licenseRetrievalError": "Payment successful but license retrieval failed. You will receive your license key via email. Please contact support if you don't receive it within 10 minutes.",
|
||||
"syncError": "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist.",
|
||||
"licenseSaveError": "Failed to save license key. Please contact support with your license key to complete activation.",
|
||||
"paymentCanceled": "Payment was canceled. No charges were made.",
|
||||
"syncingLicense": "Syncing your upgraded license...",
|
||||
"generatingLicense": "Generating your license key...",
|
||||
"upgradeComplete": "Upgrade Complete",
|
||||
"upgradeCompleteMessage": "Your subscription has been upgraded successfully. Your existing license key has been updated.",
|
||||
"stripeNotConfigured": "Stripe Not Configured",
|
||||
"stripeNotConfiguredMessage": "Stripe payment integration is not configured. Please contact your administrator.",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"billingPeriod": "Billing Period",
|
||||
"enterpriseNote": "Seats can be adjusted in checkout (1-1000).",
|
||||
"installationId": "Installation ID",
|
||||
"licenseKey": "Your License Key",
|
||||
"licenseInstructions": "Enter this key in Settings → Admin Plan → License Key section",
|
||||
"canCloseWindow": "You can now close this window.",
|
||||
"licenseKeyProcessing": "License Key Processing",
|
||||
"licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support."
|
||||
},
|
||||
"firstLogin": {
|
||||
"title": "First Time Login",
|
||||
@@ -5402,5 +5587,164 @@
|
||||
"offline": "Backend Offline",
|
||||
"starting": "Backend starting up...",
|
||||
"wait": "Please wait for the backend to finish launching and try again."
|
||||
},
|
||||
"encryptedPdfUnlock": {
|
||||
"unlockPrompt": "Unlock PDF to continue",
|
||||
"title": "Remove password to continue",
|
||||
"description": "This PDF is password protected. Enter the password so you can continue working with it.",
|
||||
"password": {
|
||||
"label": "PDF password",
|
||||
"placeholder": "Enter the PDF password"
|
||||
},
|
||||
"skip": "Skip for now",
|
||||
"unlock": "Unlock & Continue",
|
||||
"incorrectPassword": "Incorrect password",
|
||||
"missingFile": "The selected file is no longer available.",
|
||||
"emptyResponse": "Password removal did not produce a file.",
|
||||
"required": "Enter the password to continue.",
|
||||
"successTitle": "Password removed",
|
||||
"successBodyWithName": "Password removed from {{fileName}}",
|
||||
"successBody": "Password removed successfully."
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Welcome to Stirling PDF",
|
||||
"description": "Get started by choosing how you want to use Stirling PDF",
|
||||
"step1": {
|
||||
"label": "Choose Mode",
|
||||
"description": "Offline or Server"
|
||||
},
|
||||
"step2": {
|
||||
"label": "Select Server",
|
||||
"description": "Self-hosted server"
|
||||
},
|
||||
"step3": {
|
||||
"label": "Login",
|
||||
"description": "Enter credentials"
|
||||
},
|
||||
"mode": {
|
||||
"offline": {
|
||||
"title": "Use Offline",
|
||||
"description": "Run locally without an internet connection"
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"description": "Connect to a remote Stirling PDF server"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"subtitle": "Enter your self-hosted server URL",
|
||||
"type": {
|
||||
"saas": "Stirling PDF SaaS",
|
||||
"selfhosted": "Self-hosted server"
|
||||
},
|
||||
"url": {
|
||||
"label": "Server URL",
|
||||
"description": "Enter the full URL of your self-hosted Stirling PDF server"
|
||||
},
|
||||
"error": {
|
||||
"emptyUrl": "Please enter a server URL",
|
||||
"unreachable": "Could not connect to server",
|
||||
"testFailed": "Connection test failed"
|
||||
},
|
||||
"testing": "Testing connection..."
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign In",
|
||||
"subtitle": "Enter your credentials to continue",
|
||||
"connectingTo": "Connecting to:",
|
||||
"username": {
|
||||
"label": "Username",
|
||||
"placeholder": "Enter your username"
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "Enter your password"
|
||||
},
|
||||
"error": {
|
||||
"emptyUsername": "Please enter your username",
|
||||
"emptyPassword": "Please enter your password"
|
||||
},
|
||||
"submit": "Login"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"connection": {
|
||||
"title": "Connection Mode",
|
||||
"mode": {
|
||||
"offline": "Offline",
|
||||
"server": "Server"
|
||||
},
|
||||
"server": "Server",
|
||||
"user": "Logged in as",
|
||||
"switchToServer": "Connect to Server",
|
||||
"switchToOffline": "Switch to Offline",
|
||||
"logout": "Logout",
|
||||
"selectServer": "Select Server",
|
||||
"login": "Login"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Configure general application preferences.",
|
||||
"user": "User",
|
||||
"logout": "Log out",
|
||||
"enableFeatures": {
|
||||
"dismiss": "Dismiss",
|
||||
"title": "For System Administrators",
|
||||
"intro": "Enable user authentication, team management, and workspace features for your organisation.",
|
||||
"action": "Configure",
|
||||
"and": "and",
|
||||
"benefit": "Enables user roles, team collaboration, admin controls, and enterprise features.",
|
||||
"learnMore": "Learn more in documentation"
|
||||
},
|
||||
"defaultToolPickerMode": "Default tool picker mode",
|
||||
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
|
||||
"mode": {
|
||||
"sidebar": "Sidebar",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
|
||||
"autoUnzip": "Auto-unzip API responses",
|
||||
"autoUnzipDescription": "Automatically extract files from ZIP responses",
|
||||
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
|
||||
"autoUnzipFileLimit": "Auto-unzip file limit",
|
||||
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
|
||||
"defaultPdfEditor": "Default PDF editor",
|
||||
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
|
||||
"defaultPdfEditorInactive": "Another application is set as default",
|
||||
"defaultPdfEditorChecking": "Checking...",
|
||||
"defaultPdfEditorSet": "Already Default",
|
||||
"setAsDefault": "Set as Default",
|
||||
"updates": {
|
||||
"title": "Software Updates",
|
||||
"description": "Check for updates and view version information",
|
||||
"currentVersion": "Current Version",
|
||||
"latestVersion": "Latest Version",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"viewDetails": "View Details"
|
||||
},
|
||||
"hideUnavailableTools": "Hide unavailable tools",
|
||||
"hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.",
|
||||
"hideUnavailableConversions": "Hide unavailable conversions",
|
||||
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
},
|
||||
"hotkeys": {
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
"searchPlaceholder": "Search tools...",
|
||||
"none": "Not assigned",
|
||||
"customBadge": "Custom",
|
||||
"defaultLabel": "Default: {{shortcut}}",
|
||||
"capturing": "Press keys… (Esc to cancel)",
|
||||
"change": "Change shortcut",
|
||||
"reset": "Reset",
|
||||
"shortcut": "Shortcut",
|
||||
"noShortcut": "No shortcut set"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"sessionExpired": "Session Expired",
|
||||
"pleaseLoginAgain": "Please login again.",
|
||||
"accessDenied": "Access Denied",
|
||||
"insufficientPermissions": "You do not have permission to perform this action."
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+403
-4
@@ -589,10 +589,29 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -767,6 +786,12 @@ dependencies = [
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -871,6 +896,15 @@ dependencies = [
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1365,8 +1399,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1376,9 +1412,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1548,6 +1586,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.3.1",
|
||||
"indexmap 2.12.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1677,7 +1734,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
@@ -1701,6 +1758,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.12",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
@@ -1712,6 +1770,23 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http 1.3.1",
|
||||
"hyper 1.7.0",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
@@ -1744,9 +1819,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"system-configuration 0.6.1",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2057,6 +2134,16 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2137,6 +2224,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2155,6 +2248,12 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -3092,6 +3191,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
@@ -3112,6 +3217,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.3"
|
||||
@@ -3121,6 +3236,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.1",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.41"
|
||||
@@ -3167,6 +3337,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3187,6 +3367,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3205,6 +3395,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3318,7 +3517,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
@@ -3336,7 +3535,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration",
|
||||
"system-configuration 0.5.1",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
@@ -3355,22 +3554,32 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.12",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.7.0",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3380,6 +3589,21 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.16",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3427,6 +3651,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3449,6 +3679,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
@@ -3458,6 +3702,27 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3952,6 +4217,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-services",
|
||||
"keyring",
|
||||
"log",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
@@ -3959,9 +4225,11 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -3996,6 +4264,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -4063,7 +4337,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
"system-configuration-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4076,6 +4361,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4305,6 +4600,30 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c00685aceab12643cf024f712ab0448ba8fcadf86f2391d49d2e5aa732aacc70"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http 1.3.1",
|
||||
"regex",
|
||||
"reqwest 0.12.24",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.7.1"
|
||||
@@ -4363,6 +4682,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.9.1"
|
||||
@@ -4596,9 +4931,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
@@ -4609,6 +4956,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.16"
|
||||
@@ -4898,6 +5255,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.7"
|
||||
@@ -5131,6 +5494,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.1"
|
||||
@@ -5175,6 +5548,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.0"
|
||||
@@ -5360,6 +5742,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -5962,6 +6355,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.2"
|
||||
|
||||
@@ -28,7 +28,10 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.1.0"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-http = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tauri-plugin-store = "2.1.0"
|
||||
keyring = "3.6.1"
|
||||
tokio = { version = "1.0", features = ["time"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
"allow": [
|
||||
{ "url": "http://localhost:*" },
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "https://*" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
use keyring::Entry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const STORE_FILE: &str = "connection.json";
|
||||
const USER_INFO_KEY: &str = "user_info";
|
||||
const KEYRING_SERVICE: &str = "stirling-pdf";
|
||||
const KEYRING_TOKEN_KEY: &str = "auth-token";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserInfo {
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
fn get_keyring_entry() -> Result<Entry, String> {
|
||||
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
|
||||
.map_err(|e| format!("Failed to access keyring: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
||||
log::info!("Saving auth token to keyring");
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
entry
|
||||
.set_password(&token)
|
||||
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
|
||||
|
||||
log::info!("Auth token saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
|
||||
log::debug!("Retrieving auth token from keyring");
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Clearing auth token from keyring");
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
// Delete the token - ignore error if it doesn't exist
|
||||
match entry.delete_credential() {
|
||||
Ok(_) => {
|
||||
log::info!("Auth token cleared successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
log::info!("Auth token was already cleared");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Failed to clear token: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_user_info(
|
||||
app_handle: AppHandle,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Saving user info for: {}", username);
|
||||
|
||||
let user_info = UserInfo { username, email };
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.set(
|
||||
USER_INFO_KEY,
|
||||
serde_json::to_value(&user_info)
|
||||
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
|
||||
);
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("User info saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
|
||||
log::debug!("Retrieving user info");
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let user_info: Option<UserInfo> = store
|
||||
.get(USER_INFO_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
Ok(user_info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Clearing user info");
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.delete(USER_INFO_KEY);
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("User info cleared successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Response types for Spring Boot login
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootSession {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootUser {
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootLoginResponse {
|
||||
session: SpringBootSession,
|
||||
user: SpringBootUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// Login command - makes HTTP request from Rust to bypass CORS
|
||||
/// Supports Spring Boot authentication (self-hosted)
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<LoginResponse, String> {
|
||||
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
||||
|
||||
// Build login URL
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Login URL: {}", login_url);
|
||||
|
||||
// Create HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Make login request
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
log::info!("Login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
@@ -3,10 +3,12 @@ use tauri::Manager;
|
||||
use std::sync::Mutex;
|
||||
use std::path::PathBuf;
|
||||
use crate::utils::add_log;
|
||||
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
|
||||
|
||||
// Store backend process handle globally
|
||||
// Store backend process handle and port globally
|
||||
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
|
||||
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
|
||||
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
|
||||
|
||||
// Helper function to reset starting flag
|
||||
fn reset_starting_flag() {
|
||||
@@ -14,6 +16,20 @@ fn reset_starting_flag() {
|
||||
*starting_guard = false;
|
||||
}
|
||||
|
||||
// Extract port number from "Stirling-PDF running on port: PORT" log line
|
||||
fn extract_port_from_running_log(log_line: &str) -> Option<u16> {
|
||||
// Look for pattern: "running on port: PORT"
|
||||
if let Some(start) = log_line.find("running on port: ") {
|
||||
let after_prefix = &log_line[start + 17..]; // Skip "running on port: "
|
||||
// Take digits until whitespace or end of line
|
||||
let port_str: String = after_prefix.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
return port_str.parse::<u16>().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Check if backend is already running or starting
|
||||
fn check_backend_status() -> Result<(), String> {
|
||||
// Check if backend is already running
|
||||
@@ -24,7 +40,7 @@ fn check_backend_status() -> Result<(), String> {
|
||||
return Err("Backend already running".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check and set starting flag to prevent multiple simultaneous starts
|
||||
{
|
||||
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
|
||||
@@ -34,7 +50,7 @@ fn check_backend_status() -> Result<(), String> {
|
||||
}
|
||||
*starting_guard = true;
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -46,13 +62,13 @@ fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
|
||||
} else {
|
||||
jre_dir.join("bin").join("java")
|
||||
};
|
||||
|
||||
|
||||
if !java_executable.exists() {
|
||||
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
|
||||
add_log(error_msg.clone());
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
|
||||
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
|
||||
Ok(java_executable)
|
||||
}
|
||||
@@ -77,20 +93,20 @@ fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
if jar_files.is_empty() {
|
||||
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
|
||||
add_log(error_msg.clone());
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
|
||||
// Sort by filename to get the latest version (case-insensitive)
|
||||
jar_files.sort_by(|a, b| {
|
||||
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
name_b.cmp(&name_a) // Reverse order to get latest first
|
||||
});
|
||||
|
||||
|
||||
let jar_path = jar_files[0].path();
|
||||
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
|
||||
Ok(jar_path)
|
||||
@@ -123,23 +139,23 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".config").join("Stirling-PDF")
|
||||
};
|
||||
|
||||
|
||||
// Create subdirectories for different purposes
|
||||
let config_dir = app_data_dir.join("configs");
|
||||
let log_dir = app_data_dir.join("logs");
|
||||
let work_dir = app_data_dir.join("workspace");
|
||||
|
||||
|
||||
// Create all necessary directories
|
||||
std::fs::create_dir_all(&app_data_dir).ok();
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
std::fs::create_dir_all(&work_dir).ok();
|
||||
std::fs::create_dir_all(&config_dir).ok();
|
||||
|
||||
|
||||
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
|
||||
add_log(format!("📁 Log directory: {}", log_dir.display()));
|
||||
add_log(format!("📁 Working directory: {}", work_dir.display()));
|
||||
add_log(format!("📁 Config directory: {}", config_dir.display()));
|
||||
|
||||
|
||||
// Define all Java options with Tauri-specific paths
|
||||
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
|
||||
|
||||
@@ -150,10 +166,13 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
"-DSTIRLING_PDF_TAURI_MODE=true",
|
||||
&log_path_option,
|
||||
"-Dlogging.file.name=stirling-pdf.log",
|
||||
"-Dserver.port=0", // Let OS assign an available port
|
||||
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
|
||||
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
|
||||
"-jar",
|
||||
jar_path.to_str().unwrap()
|
||||
jar_path.to_str().unwrap(),
|
||||
];
|
||||
|
||||
|
||||
// Log the equivalent command for external testing
|
||||
let java_command = format!(
|
||||
"TAURI_PARENT_PID={} \"{}\" {}",
|
||||
@@ -163,14 +182,14 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
);
|
||||
add_log(format!("🔧 Equivalent command: {}", java_command));
|
||||
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
|
||||
|
||||
|
||||
// Additional macOS-specific checks
|
||||
if cfg!(target_os = "macos") {
|
||||
// Check if java executable has execute permissions
|
||||
if let Ok(metadata) = std::fs::metadata(java_path) {
|
||||
let permissions = metadata.permissions();
|
||||
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
|
||||
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -181,7 +200,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check if we can read the JAR file
|
||||
if let Ok(metadata) = std::fs::metadata(jar_path) {
|
||||
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
|
||||
@@ -189,7 +208,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
add_log("⚠️ Cannot read JAR file metadata".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let sidecar_command = app
|
||||
.shell()
|
||||
.command(java_path.to_str().unwrap())
|
||||
@@ -199,9 +218,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
|
||||
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
|
||||
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
|
||||
|
||||
|
||||
add_log("⚙️ Starting backend with bundled JRE...".to_string());
|
||||
|
||||
|
||||
let (rx, child) = sidecar_command
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
@@ -209,18 +228,18 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
add_log(error_msg.clone());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
|
||||
// Store the process handle
|
||||
{
|
||||
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
|
||||
*process_guard = Some(child);
|
||||
}
|
||||
|
||||
|
||||
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
|
||||
|
||||
|
||||
// Start monitoring output
|
||||
monitor_backend_output(rx);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -229,7 +248,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
tokio::spawn(async move {
|
||||
let mut _startup_detected = false;
|
||||
let mut error_count = 0;
|
||||
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
|
||||
@@ -237,17 +256,22 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
// Strip exactly one trailing newline to avoid double newlines
|
||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||
add_log(format!("📤 Backend: {}", output_str));
|
||||
|
||||
// Look for startup indicators
|
||||
if output_str.contains("Started SPDFApplication") ||
|
||||
output_str.contains("Navigate to "){
|
||||
|
||||
// Look for actual runtime port from web server initialization
|
||||
// Format: "Stirling-PDF running on port: PORT"
|
||||
if output_str.contains("running on port:") {
|
||||
_startup_detected = true;
|
||||
add_log(format!("🎉 Backend startup detected: {}", output_str));
|
||||
if let Some(port) = extract_port_from_running_log(&output_str) {
|
||||
let mut port_guard = BACKEND_PORT.lock().unwrap();
|
||||
*port_guard = Some(port);
|
||||
add_log(format!("🎉 Backend started on port: {}", port));
|
||||
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
|
||||
}
|
||||
}
|
||||
|
||||
// Look for port binding
|
||||
if output_str.contains("8080") {
|
||||
add_log(format!("🔌 Port 8080 related output: {}", output_str));
|
||||
|
||||
if output_str.contains("Started SPDFApplication") {
|
||||
_startup_detected = true;
|
||||
add_log(format!("🎉 Backend startup completed: {}", output_str));
|
||||
}
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
|
||||
@@ -255,13 +279,13 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
// Strip exactly one trailing newline to avoid double newlines
|
||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||
add_log(format!("📥 Backend Error: {}", output_str));
|
||||
|
||||
|
||||
// Look for error indicators
|
||||
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
|
||||
error_count += 1;
|
||||
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
|
||||
}
|
||||
|
||||
|
||||
// Look for specific common issues
|
||||
if output_str.contains("Address already in use") {
|
||||
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
|
||||
@@ -299,7 +323,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if error_count > 0 {
|
||||
println!("⚠️ Backend process ended with {} errors detected", error_count);
|
||||
}
|
||||
@@ -308,14 +332,36 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
|
||||
// Command to start the backend with bundled JRE
|
||||
#[tauri::command]
|
||||
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
|
||||
pub async fn start_backend(
|
||||
app: tauri::AppHandle,
|
||||
connection_state: tauri::State<'_, AppConnectionState>,
|
||||
) -> Result<String, String> {
|
||||
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
|
||||
|
||||
|
||||
// Check connection mode
|
||||
let mode = {
|
||||
let state = connection_state.0.lock().map_err(|e| {
|
||||
let error_msg = format!("❌ Failed to access connection state: {}", e);
|
||||
add_log(error_msg.clone());
|
||||
error_msg
|
||||
})?;
|
||||
state.mode.clone()
|
||||
};
|
||||
|
||||
match mode {
|
||||
ConnectionMode::Offline => {
|
||||
add_log("🔌 Running in Offline mode - starting local backend".to_string());
|
||||
}
|
||||
ConnectionMode::Server => {
|
||||
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if backend is already running or starting
|
||||
if let Err(msg) = check_backend_status() {
|
||||
return Ok(msg);
|
||||
}
|
||||
|
||||
|
||||
// Use Tauri's resource API to find the bundled JRE and JAR
|
||||
let resource_dir = app.path().resource_dir().map_err(|e| {
|
||||
let error_msg = format!("❌ Failed to get resource directory: {}", e);
|
||||
@@ -323,53 +369,60 @@ pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
|
||||
reset_starting_flag();
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
|
||||
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
|
||||
|
||||
|
||||
// Find the bundled JRE
|
||||
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
|
||||
reset_starting_flag();
|
||||
e
|
||||
})?;
|
||||
|
||||
|
||||
// Find the Stirling-PDF JAR
|
||||
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
|
||||
reset_starting_flag();
|
||||
e
|
||||
})?;
|
||||
|
||||
|
||||
// Normalize the paths to remove Windows UNC prefix
|
||||
let normalized_java_path = normalize_path(&java_executable);
|
||||
let normalized_jar_path = normalize_path(&jar_path);
|
||||
|
||||
|
||||
add_log(format!("📦 Found JAR file: {:?}", jar_path));
|
||||
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
|
||||
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
|
||||
|
||||
|
||||
// Create and start the Java command
|
||||
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
|
||||
reset_starting_flag();
|
||||
e
|
||||
})?;
|
||||
|
||||
|
||||
// Wait for the backend to start
|
||||
println!("⏳ Waiting for backend startup...");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
|
||||
|
||||
|
||||
// Reset the starting flag since startup is complete
|
||||
reset_starting_flag();
|
||||
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
|
||||
|
||||
|
||||
Ok("Backend startup initiated successfully with bundled JRE".to_string())
|
||||
}
|
||||
|
||||
// Get the dynamically assigned backend port
|
||||
#[tauri::command]
|
||||
pub fn get_backend_port() -> Option<u16> {
|
||||
let port_guard = BACKEND_PORT.lock().unwrap();
|
||||
*port_guard
|
||||
}
|
||||
|
||||
// Cleanup function to stop backend on app exit
|
||||
pub fn cleanup_backend() {
|
||||
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
|
||||
if let Some(child) = process_guard.take() {
|
||||
let pid = child.pid();
|
||||
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
|
||||
|
||||
|
||||
match child.kill() {
|
||||
Ok(_) => {
|
||||
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
|
||||
@@ -380,4 +433,4 @@ pub fn cleanup_backend() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::state::connection_state::{
|
||||
AppConnectionState,
|
||||
ConnectionMode,
|
||||
ServerConfig,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const STORE_FILE: &str = "connection.json";
|
||||
const FIRST_LAUNCH_KEY: &str = "setup_completed";
|
||||
const CONNECTION_MODE_KEY: &str = "connection_mode";
|
||||
const SERVER_CONFIG_KEY: &str = "server_config";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub mode: ConnectionMode,
|
||||
pub server_config: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_connection_config(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppConnectionState>,
|
||||
) -> Result<ConnectionConfig, String> {
|
||||
// Try to load from store
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let mode = store
|
||||
.get(CONNECTION_MODE_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(ConnectionMode::Offline);
|
||||
|
||||
let server_config: Option<ServerConfig> = store
|
||||
.get(SERVER_CONFIG_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
// Update in-memory state
|
||||
if let Ok(mut conn_state) = state.0.lock() {
|
||||
conn_state.mode = mode.clone();
|
||||
conn_state.server_config = server_config.clone();
|
||||
}
|
||||
|
||||
Ok(ConnectionConfig {
|
||||
mode,
|
||||
server_config,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_connection_mode(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppConnectionState>,
|
||||
mode: ConnectionMode,
|
||||
server_config: Option<ServerConfig>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Setting connection mode: {:?}", mode);
|
||||
|
||||
// Update in-memory state
|
||||
if let Ok(mut conn_state) = state.0.lock() {
|
||||
conn_state.mode = mode.clone();
|
||||
conn_state.server_config = server_config.clone();
|
||||
}
|
||||
|
||||
// Save to store
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.set(
|
||||
CONNECTION_MODE_KEY,
|
||||
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
|
||||
);
|
||||
|
||||
if let Some(config) = &server_config {
|
||||
store.set(
|
||||
SERVER_CONFIG_KEY,
|
||||
serde_json::to_value(config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?,
|
||||
);
|
||||
} else {
|
||||
store.delete(SERVER_CONFIG_KEY);
|
||||
}
|
||||
|
||||
// Mark setup as completed
|
||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("Connection mode saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let setup_completed = store
|
||||
.get(FIRST_LAUNCH_KEY)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(!setup_completed)
|
||||
}
|
||||
@@ -1,36 +1,16 @@
|
||||
// Command to check if backend is healthy
|
||||
use reqwest;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_backend_health() -> Result<bool, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
pub async fn check_backend_health(port: u16) -> Result<bool, String> {
|
||||
let url = format!("http://localhost:{}/api/v1/info/status", port);
|
||||
|
||||
match reqwest::Client::new()
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
match client.get("http://localhost:8080/api/v1/info/status").send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
match response.text().await {
|
||||
Ok(_body) => {
|
||||
println!("✅ Backend health check successful");
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ Failed to read health response: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("⚠️ Health check failed with status: {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Only log connection errors if they're not the common "connection refused" during startup
|
||||
if !e.to_string().contains("connection refused") && !e.to_string().contains("No connection could be made") {
|
||||
println!("❌ Health check error: {}", e);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => Ok(response.status().is_success()),
|
||||
Err(_) => Ok(false), // Return false instead of error for connection failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
pub mod backend;
|
||||
pub mod health;
|
||||
pub mod files;
|
||||
pub mod connection;
|
||||
pub mod auth;
|
||||
pub mod default_app;
|
||||
pub mod health;
|
||||
|
||||
pub use backend::{start_backend, cleanup_backend};
|
||||
pub use health::check_backend_health;
|
||||
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
set_connection_mode,
|
||||
};
|
||||
pub use auth::{
|
||||
clear_auth_token,
|
||||
clear_user_info,
|
||||
get_auth_token,
|
||||
get_user_info,
|
||||
login,
|
||||
save_auth_token,
|
||||
save_user_info,
|
||||
};
|
||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||
pub use health::check_backend_health;
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
|
||||
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
|
||||
|
||||
mod utils;
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
use commands::{
|
||||
start_backend,
|
||||
check_backend_health,
|
||||
get_opened_files,
|
||||
clear_opened_files,
|
||||
cleanup_backend,
|
||||
add_opened_file,
|
||||
check_backend_health,
|
||||
cleanup_backend,
|
||||
clear_auth_token,
|
||||
clear_opened_files,
|
||||
clear_user_info,
|
||||
is_default_pdf_handler,
|
||||
get_auth_token,
|
||||
get_backend_port,
|
||||
get_connection_config,
|
||||
get_opened_files,
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
login,
|
||||
save_auth_token,
|
||||
save_user_info,
|
||||
set_connection_mode,
|
||||
set_as_default_pdf_handler,
|
||||
start_backend,
|
||||
};
|
||||
use state::connection_state::AppConnectionState;
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -20,6 +33,9 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.manage(AppConnectionState::default())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
// This callback runs when a second instance tries to start
|
||||
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
||||
@@ -60,12 +76,23 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_backend,
|
||||
check_backend_health,
|
||||
get_backend_port,
|
||||
get_opened_files,
|
||||
clear_opened_files,
|
||||
get_tauri_logs,
|
||||
get_connection_config,
|
||||
set_connection_mode,
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
is_first_launch,
|
||||
check_backend_health,
|
||||
login,
|
||||
save_auth_token,
|
||||
get_auth_token,
|
||||
clear_auth_token,
|
||||
save_user_info,
|
||||
get_user_info,
|
||||
clear_user_info,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionMode {
|
||||
Offline,
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ServerType {
|
||||
SaaS,
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub url: String,
|
||||
pub server_type: ServerType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionState {
|
||||
pub mode: ConnectionMode,
|
||||
pub server_config: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
impl Default for ConnectionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ConnectionMode::Offline,
|
||||
server_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppConnectionState(pub Mutex<ConnectionState>);
|
||||
|
||||
impl Default for AppConnectionState {
|
||||
fn default() -> Self {
|
||||
Self(Mutex::new(ConnectionState::default()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod connection_state;
|
||||
@@ -51,6 +51,11 @@
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
@@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
const { historyApiRef } = useSignature();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const historyApiInstance = historyApiRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyApiInstance) {
|
||||
setHistoryAvailability({ canUndo: false, canRedo: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAvailability = () => {
|
||||
setHistoryAvailability({
|
||||
canUndo: historyApiInstance.canUndo?.() ?? false,
|
||||
canRedo: historyApiInstance.canRedo?.() ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
|
||||
updateAvailability();
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [historyApiInstance]);
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
setSignatureData(data);
|
||||
@@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
@@ -86,4 +113,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,13 +15,16 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title = "Choose Color"
|
||||
title
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
title={resolvedTitle}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
@@ -36,7 +40,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
@@ -20,6 +21,7 @@ interface DrawingCanvasProps {
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
initialSignatureData?: string;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
@@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
initialSignatureData,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
@@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
minDistance: 5,
|
||||
velocityFilterWeight: 0.7,
|
||||
});
|
||||
|
||||
// Restore saved signature data if it exists
|
||||
if (savedSignatureData) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
img.src = savedSignatureData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,36 +120,35 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const renderPreview = (dataUrl: string) => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (canvas.width - scaledWidth) / 2;
|
||||
const y = (canvas.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
};
|
||||
img.src = dataUrl;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
const untrimmedPng = canvas.toDataURL('image/png');
|
||||
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
||||
onSignatureDataChange(trimmedPng);
|
||||
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
|
||||
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
previewCanvasRef.current.width / img.width,
|
||||
previewCanvasRef.current.height / img.height
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
|
||||
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = trimmedPng;
|
||||
renderPreview(trimmedPng);
|
||||
|
||||
if (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
@@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
setSavedSignatureData(null); // Clear saved signature
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
@@ -173,67 +189,73 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePenColor(selectedColor);
|
||||
}, [selectedColor]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePenSize(penSize);
|
||||
}, [penSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
if (!initialSignatureData) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setSavedSignatureData(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
renderPreview(initialSignatureData);
|
||||
setSavedSignatureData(initialSignatureData);
|
||||
}, [initialSignatureData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<PrivateContent>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
</PrivateContent>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Click to open drawing canvas
|
||||
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
||||
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<Popover
|
||||
opened={colorPickerOpen}
|
||||
onChange={setColorPickerOpen}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={(color) => {
|
||||
onColorSwatchClick();
|
||||
updatePenColor(color);
|
||||
}}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.colorLabel', 'Colour')}
|
||||
</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.penSizeLabel', 'Pen size')}
|
||||
</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
@@ -242,12 +264,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<PrivateContent>
|
||||
<canvas
|
||||
@@ -262,8 +284,8 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
height: '400px',
|
||||
maxWidth: '50rem',
|
||||
height: '25rem',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
@@ -271,10 +293,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
Clear Canvas
|
||||
{t('sign.canvas.clear', 'Clear canvas')}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
Done
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
@@ -8,8 +9,11 @@ interface DrawingControlsProps {
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
additionalControls?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
@@ -18,30 +22,48 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
canUndo = true,
|
||||
canRedo = true,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place"
|
||||
placeButtonText = "Update and Place",
|
||||
additionalControls,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const undoDisabled = disabled || !canUndo;
|
||||
const redoDisabled = disabled || !canRedo;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
{onUndo && (
|
||||
<Tooltip label={t('sign.undo', 'Undo')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.undo', 'Undo')}
|
||||
onClick={onUndo}
|
||||
disabled={undoDisabled}
|
||||
color={undoDisabled ? 'gray' : 'blue'}
|
||||
>
|
||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onRedo && (
|
||||
<Tooltip label={t('sign.redo', 'Redo')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.redo', 'Redo')}
|
||||
onClick={onRedo}
|
||||
disabled={redoDisabled}
|
||||
color={redoDisabled ? 'gray' : 'blue'}
|
||||
>
|
||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{additionalControls}
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
@@ -50,11 +72,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
flex={1}
|
||||
ml="auto"
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [colorInput, setColorInput] = useState(textColor);
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
// Sync color input with prop changes
|
||||
useEffect(() => {
|
||||
setColorInput(textColor);
|
||||
}, [textColor]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
@@ -50,10 +56,15 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
|
||||
// Validate hex color
|
||||
const isValidHexColor = (color: string): boolean => {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer Name')}
|
||||
label={label || t('sign.text.name', 'Signer name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
@@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label="Font"
|
||||
label={t('sign.text.fontLabel', 'Font')}
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
@@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-200)"
|
||||
label={t('sign.text.fontSizeLabel', 'Font size')}
|
||||
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
@@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label="Text Color"
|
||||
value={textColor}
|
||||
readOnly
|
||||
label={t('sign.text.colorLabel', 'Text colour')}
|
||||
value={colorInput}
|
||||
placeholder="#000000"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setColorInput(value);
|
||||
|
||||
// Update color if valid hex
|
||||
if (isValidHexColor(value)) {
|
||||
onTextColorChange(value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Revert to valid color on blur if invalid
|
||||
if (!isValidHexColor(colorInput)) {
|
||||
setColorInput(textColor);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
rightSection={
|
||||
<Box
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -169,4 +195,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
@@ -56,7 +57,14 @@ const FileEditorThumbnail = ({
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const {
|
||||
pinFile,
|
||||
unpinFile,
|
||||
isFilePinned,
|
||||
activeFiles,
|
||||
actions: fileActions,
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
@@ -77,6 +85,7 @@ const FileEditorThumbnail = ({
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
@@ -301,6 +310,21 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
|
||||
<ActionIcon
|
||||
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
>
|
||||
<LockOpenIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type KeyboardEventHandler } from 'react';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface EncryptedPdfUnlockModalProps {
|
||||
opened: boolean;
|
||||
fileName?: string;
|
||||
password: string;
|
||||
errorMessage?: string | null;
|
||||
isProcessing: boolean;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onUnlock: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const EncryptedPdfUnlockModal = ({
|
||||
opened,
|
||||
fileName,
|
||||
password,
|
||||
errorMessage,
|
||||
isProcessing,
|
||||
onPasswordChange,
|
||||
onUnlock,
|
||||
onSkip,
|
||||
}: EncryptedPdfUnlockModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) {
|
||||
onUnlock();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onSkip}
|
||||
title={t('encryptedPdfUnlock.title', 'Remove password to continue')}
|
||||
centered
|
||||
size="md"
|
||||
closeOnClickOutside={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} ta="center">{fileName}</Text>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t(
|
||||
'encryptedPdfUnlock.description',
|
||||
'This PDF is password protected. Enter the password so you can continue working with it.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t('encryptedPdfUnlock.password.label', 'PDF password')}
|
||||
placeholder={t('encryptedPdfUnlock.password.placeholder', 'Enter the PDF password')}
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isProcessing}
|
||||
autoFocus
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="red" size="sm">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
|
||||
{t('encryptedPdfUnlock.skip', 'Skip for now')}
|
||||
</Button>
|
||||
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
|
||||
{t('encryptedPdfUnlock.unlock', 'Unlock & Continue')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncryptedPdfUnlockModal;
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
interface UpdateModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
updateSummary: UpdateSummary;
|
||||
machineInfo: MachineInfo;
|
||||
}
|
||||
|
||||
const UpdateModal: React.FC<UpdateModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
currentVersion,
|
||||
updateSummary,
|
||||
machineInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setLoading(true);
|
||||
setExpandedVersions(new Set([0]));
|
||||
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
|
||||
setFullUpdateInfo(info);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [opened, currentVersion, machineInfo]);
|
||||
|
||||
const toggleVersion = (index: number) => {
|
||||
setExpandedVersions((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'urgent':
|
||||
return 'red';
|
||||
case 'normal':
|
||||
return 'blue';
|
||||
case 'minor':
|
||||
return 'cyan';
|
||||
case 'low':
|
||||
return 'gray';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string): string => {
|
||||
const key = priority?.toLowerCase();
|
||||
return t(`update.priority.${key}`, priority || 'Normal');
|
||||
};
|
||||
|
||||
const downloadUrl = updateService.getDownloadUrl(machineInfo);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t('update.modalTitle', 'Update Available')}
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
size="xl"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: '75vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Version Summary Section */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.current', 'Current Version')}
|
||||
</Text>
|
||||
<Text fw={600} size="xl">
|
||||
{currentVersion}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4} style={{ flex: 1 }} ta="center">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.priorityLabel', 'Priority')}
|
||||
</Text>
|
||||
<Badge
|
||||
color={getPriorityColor(updateSummary.max_priority)}
|
||||
size="lg"
|
||||
variant="filled"
|
||||
style={{ alignSelf: 'center' }}
|
||||
>
|
||||
{getPriorityLabel(updateSummary.max_priority)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4} style={{ flex: 1 }} ta="right">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.latest', 'Latest Version')}
|
||||
</Text>
|
||||
<Text fw={600} size="xl" c="blue">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{updateSummary.latest_stable_version && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-green-0)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-green-2)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" justify="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('update.latestStable', 'Latest Stable')}:
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="green">
|
||||
{updateSummary.latest_stable_version}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Recommended action */}
|
||||
{updateSummary.recommended_action && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-blue-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} mb={4} tt="uppercase">
|
||||
{t('update.recommendedAction', 'Recommended Action')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{updateSummary.recommended_action}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Breaking changes warning */}
|
||||
{updateSummary.any_breaking && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-orange-light)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-orange-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} mb={4} tt="uppercase">
|
||||
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'update.breakingChangesMessage',
|
||||
'Some versions contain breaking changes. Please review the migration guides below before updating.'
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Migration guides */}
|
||||
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
|
||||
{t('update.migrationGuides', 'Migration Guides')}
|
||||
</Text>
|
||||
{updateSummary.migration_guides.map((guide, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('update.version', 'Version')} {guide.version}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{guide.notes}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component="a"
|
||||
href={guide.url}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="xs"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.viewGuide', 'View Guide')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Version details */}
|
||||
<Divider />
|
||||
{loading ? (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
|
||||
{t('update.availableUpdates', 'Available Updates')}
|
||||
</Text>
|
||||
<Badge variant="light" color="gray">
|
||||
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{fullUpdateInfo.new_versions.map((version, index) => {
|
||||
const isExpanded = expandedVersions.has(index);
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="md"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onClick={() => toggleVersion(index)}
|
||||
>
|
||||
<Group gap="md" style={{ flex: 1 }}>
|
||||
<Box>
|
||||
<Text fw={600} size="sm" c="dimmed" mb={2}>
|
||||
{t('update.version', 'Version')}
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{version.version}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge color={getPriorityColor(version.priority)} size="md">
|
||||
{getPriorityLabel(version.priority)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v${version.version}`}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.releaseNotes', 'Release Notes')}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
) : (
|
||||
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<Stack gap="md" mt="md">
|
||||
<Box>
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
{version.announcement.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
|
||||
{version.announcement.message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{version.compatibility.breaking_changes && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-orange-light)',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--mantine-color-orange-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
|
||||
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
|
||||
<Text size="xs" fw={600} tt="uppercase">
|
||||
{t('update.breakingChanges', 'Breaking Changes')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" mb="xs">
|
||||
{version.compatibility.breaking_description ||
|
||||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
|
||||
</Text>
|
||||
{version.compatibility.migration_guide_url && (
|
||||
<Button
|
||||
component="a"
|
||||
href={version.compatibility.migration_guide_url}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.migrationGuide', 'Migration Guide')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('update.close', 'Close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
component="a"
|
||||
href="https://github.com/Stirling-Tools/Stirling-PDF/releases"
|
||||
target="_blank"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('update.viewAllReleases', 'View All Releases')}
|
||||
</Button>
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
color="green"
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('update.downloadLatest', 'Download Latest')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateModal;
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { updateService, UpdateSummary } from '@app/services/updateService';
|
||||
import UpdateModal from '@app/components/shared/UpdateModal';
|
||||
|
||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
|
||||
@@ -22,12 +24,50 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
// Check localStorage on mount
|
||||
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
|
||||
});
|
||||
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
|
||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
|
||||
// Sync local state with preference changes
|
||||
useEffect(() => {
|
||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||
}, [preferences.autoUnzipFileLimit]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (config?.appVersion && config?.machineType) {
|
||||
checkForUpdate();
|
||||
}
|
||||
}, [config?.appVersion, config?.machineType]);
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
if (!config?.appVersion || !config?.machineType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingUpdate(true);
|
||||
const machineInfo = {
|
||||
machineType: config.machineType,
|
||||
activeSecurity: config.activeSecurity ?? false,
|
||||
licenseType: config.license ?? 'NORMAL',
|
||||
};
|
||||
|
||||
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
|
||||
if (summary && summary.latest_version) {
|
||||
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
|
||||
if (isNewerVersion) {
|
||||
setUpdateSummary(summary);
|
||||
} else {
|
||||
// Clear any existing update summary if user is on latest version
|
||||
setUpdateSummary(null);
|
||||
}
|
||||
} else {
|
||||
// No update available (latest_version is null) - clear any existing update summary
|
||||
setUpdateSummary(null);
|
||||
}
|
||||
setCheckingUpdate(false);
|
||||
};
|
||||
|
||||
// Check if login is disabled
|
||||
const loginDisabled = !config?.enableLogin;
|
||||
|
||||
@@ -94,6 +134,93 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t('settings.general.updates.title', 'Software Updates')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.updates.description', 'Check for updates and view version information')}
|
||||
</Text>
|
||||
</div>
|
||||
{updateSummary && (
|
||||
<Badge
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
variant="filled"
|
||||
>
|
||||
{updateSummary.max_priority === 'urgent'
|
||||
? t('update.urgentUpdateAvailable', 'Urgent Update')
|
||||
: t('update.updateAvailable', 'Update Available')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.updates.currentVersion', 'Current Version')}:{' '}
|
||||
<Text component="span" fw={500}>
|
||||
{config.appVersion}
|
||||
</Text>
|
||||
</Text>
|
||||
{updateSummary && (
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t('settings.general.updates.latestVersion', 'Latest Version')}:{' '}
|
||||
<Text component="span" fw={500} c="blue">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={checkForUpdate}
|
||||
loading={checkingUpdate}
|
||||
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.checkForUpdates', 'Check for Updates')}
|
||||
</Button>
|
||||
{updateSummary && (
|
||||
<Button
|
||||
size="sm"
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
onClick={() => setUpdateModalOpened(true)}
|
||||
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{updateSummary?.any_breaking && (
|
||||
<Alert
|
||||
color="orange"
|
||||
title={t('update.breakingChangesDetected', 'Breaking Changes Detected')}
|
||||
styles={{
|
||||
title: { fontWeight: 600 }
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'update.breakingChangesMessage',
|
||||
'Some versions contain breaking changes. Please review the migration guides before updating.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -114,6 +241,34 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableTools', 'Hide unavailable tools')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableTools}
|
||||
onChange={(event) => updatePreference('hideUnavailableTools', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableConversions}
|
||||
onChange={(event) => updatePreference('hideUnavailableConversions', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
@@ -170,6 +325,21 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Modal */}
|
||||
{updateSummary && config?.appVersion && config?.machineType && (
|
||||
<UpdateModal
|
||||
opened={updateModalOpened}
|
||||
onClose={() => setUpdateModalOpened(false)}
|
||||
currentVersion={config.appVersion}
|
||||
updateSummary={updateSummary}
|
||||
machineInfo={{
|
||||
machineType: config.machineType,
|
||||
activeSecurity: config.activeSecurity ?? false,
|
||||
licenseType: config.license ?? 'NORMAL',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Allows selecting files to attach to PDFs.
|
||||
*/
|
||||
|
||||
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
|
||||
import { Stack, Text, Group, ActionIcon, ScrollArea, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -20,16 +20,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
|
||||
</Text>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
|
||||
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
|
||||
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
|
||||
@@ -47,8 +48,12 @@ const ConvertSettings = ({
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
const { state, selectors } = useFileState();
|
||||
const activeFiles = state.files.ids;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
||||
const allEndpoints = useMemo(() => {
|
||||
const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT);
|
||||
return endpoints;
|
||||
}, []);
|
||||
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
@@ -56,7 +61,8 @@ const ConvertSettings = ({
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
return endpointStatus[endpointKey] === true;
|
||||
const isAvailable = endpointStatus[endpointKey] === true;
|
||||
return isAvailable;
|
||||
};
|
||||
|
||||
// Enhanced FROM options with endpoint availability
|
||||
@@ -74,6 +80,12 @@ const ConvertSettings = ({
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out unavailable source formats if preference is enabled
|
||||
let filteredOptions = baseOptions;
|
||||
if (preferences.hideUnavailableConversions) {
|
||||
filteredOptions = baseOptions.filter(opt => opt.enabled !== false);
|
||||
}
|
||||
|
||||
// Add dynamic format option if current selection is a file-<extension> format
|
||||
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
|
||||
const extension = parameters.fromExtension.replace('file-', '');
|
||||
@@ -85,22 +97,32 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
// Add the dynamic option at the beginning
|
||||
return [dynamicOption, ...baseOptions];
|
||||
return [dynamicOption, ...filteredOptions];
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
return filteredOptions;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
return availableOptions.map(option => ({
|
||||
...option,
|
||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||
}));
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
const enhanced = availableOptions.map(option => {
|
||||
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
|
||||
return {
|
||||
...option,
|
||||
enabled
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out unavailable conversions if preference is enabled
|
||||
if (preferences.hideUnavailableConversions) {
|
||||
return enhanced.filter(opt => opt.enabled !== false);
|
||||
}
|
||||
|
||||
return enhanced;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
|
||||
const resetParametersToDefaults = () => {
|
||||
onParameterChange('imageOptions', {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface CompactToolItemProps {
|
||||
id: string;
|
||||
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
|
||||
|
||||
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, false);
|
||||
const iconClasses = 'tool-panel__fullscreen-list-icon';
|
||||
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
|
||||
</button>
|
||||
);
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? (
|
||||
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
|
||||
<span><strong>{disabledMessage}</strong> {tool.description}</span>
|
||||
)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface DetailedToolItemProps {
|
||||
id: string;
|
||||
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
|
||||
|
||||
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, true);
|
||||
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
iconNode = tool.icon;
|
||||
}
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
|
||||
{tool.description}
|
||||
{disabled ? (
|
||||
<>
|
||||
<strong>{disabledMessage} </strong>
|
||||
{tool.description}
|
||||
</>
|
||||
) : tool.description}
|
||||
</Text>
|
||||
{binding && (
|
||||
<div className="tool-panel__fullscreen-shortcut">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
|
||||
export const getItemClasses = (isDetailed: boolean): string => {
|
||||
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
|
||||
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
|
||||
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
|
||||
|
||||
export const getToolDisabledReason = (
|
||||
id: string,
|
||||
tool: ToolRegistryEntry,
|
||||
toolAvailability?: ToolAvailabilityMap
|
||||
): ToolDisabledReason => {
|
||||
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
|
||||
return 'comingSoon';
|
||||
}
|
||||
|
||||
const availabilityInfo = toolAvailability?.[id as ToolId];
|
||||
if (availabilityInfo && availabilityInfo.available === false) {
|
||||
if (availabilityInfo.reason === 'missingDependency') {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (availabilityInfo.reason === 'disabledByAdmin') {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
return 'unknownUnavailable';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDisabledLabel = (
|
||||
disabledReason: ToolDisabledReason
|
||||
): { key: string; fallback: string } => {
|
||||
if (disabledReason === 'missingDependency') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailableDependency',
|
||||
fallback: 'Unavailable - required tool missing on server:'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'disabledByAdmin' || disabledReason === 'unknownUnavailable') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailable',
|
||||
fallback: 'Disabled by server administrator:'
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.comingSoon',
|
||||
fallback: 'Coming soon:'
|
||||
};
|
||||
};
|
||||
|
||||
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
|
||||
const { hotkeys } = useHotkeys();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
|
||||
const isFav = isFavorite(id as ToolId);
|
||||
const binding = hotkeys[id as ToolId];
|
||||
const disabled = isToolDisabled(id, tool);
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const disabled = disabledReason !== null;
|
||||
|
||||
return {
|
||||
binding,
|
||||
isFav,
|
||||
toggleFavorite: () => toggleFavorite(id as ToolId),
|
||||
disabled,
|
||||
disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Alert } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Stack } from '@mantine/core';
|
||||
|
||||
const RemoveAnnotationsSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info-rounded" width="1.2rem" height="1.2rem" />}
|
||||
title={t('removeAnnotations.info.title', 'About Remove Annotations')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('removeAnnotations.info.description',
|
||||
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
{/* No settings needed for this tool - description is in tooltip */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
|
||||
interface SavedSignaturesSectionProps {
|
||||
signatures: SavedSignature[];
|
||||
disabled?: boolean;
|
||||
isAtCapacity: boolean;
|
||||
onUseSignature: (signature: SavedSignature) => void;
|
||||
onDeleteSignature: (signature: SavedSignature) => void;
|
||||
onRenameSignature: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
const typeBadgeColor: Record<SavedSignatureType, string> = {
|
||||
canvas: 'indigo',
|
||||
image: 'teal',
|
||||
text: 'grape',
|
||||
};
|
||||
|
||||
export const SavedSignaturesSection = ({
|
||||
signatures,
|
||||
disabled = false,
|
||||
isAtCapacity,
|
||||
onUseSignature,
|
||||
onDeleteSignature,
|
||||
onRenameSignature,
|
||||
}: SavedSignaturesSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const activeSignature = signatures[activeIndex];
|
||||
const appliedSignatureIdRef = useRef<string | null>(null);
|
||||
const onUseSignatureRef = useRef(onUseSignature);
|
||||
|
||||
useEffect(() => {
|
||||
onUseSignatureRef.current = onUseSignature;
|
||||
}, [onUseSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelDrafts(prev => {
|
||||
const nextDrafts: Record<string, string> = {};
|
||||
signatures.forEach(sig => {
|
||||
nextDrafts[sig.id] = prev[sig.id] ?? sig.label ?? '';
|
||||
});
|
||||
return nextDrafts;
|
||||
});
|
||||
}, [signatures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (signatures.length === 0) {
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
|
||||
}, [signatures.length]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(direction: 'prev' | 'next') => {
|
||||
setActiveIndex(prev => {
|
||||
if (direction === 'prev') {
|
||||
return Math.max(0, prev - 1);
|
||||
}
|
||||
return Math.min(signatures.length - 1, prev + 1);
|
||||
});
|
||||
},
|
||||
[signatures.length]
|
||||
);
|
||||
|
||||
const renderPreview = (signature: SavedSignature) => {
|
||||
if (signature.type === 'text') {
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
style={{
|
||||
fontFamily: signature.fontFamily,
|
||||
fontSize: `${signature.fontSize}px`,
|
||||
color: signature.textColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '120px',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '0.5rem',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="lg"
|
||||
style={{
|
||||
fontFamily: signature.fontFamily,
|
||||
color: signature.textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{signature.signerName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '0.5rem',
|
||||
height: '120px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={signature.dataUrl}
|
||||
alt={signature.label}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const emptyState = (
|
||||
<Card withBorder>
|
||||
<Stack gap="xs">
|
||||
<Text fw={500}>{t('sign.saved.emptyTitle', 'No saved signatures yet')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'sign.saved.emptyDescription',
|
||||
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
|
||||
{ max: MAX_SAVED_SIGNATURES }
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const typeLabel = (type: SavedSignatureType) => {
|
||||
switch (type) {
|
||||
case 'canvas':
|
||||
return t('sign.saved.type.canvas', 'Drawing');
|
||||
case 'image':
|
||||
return t('sign.saved.type.image', 'Upload');
|
||||
case 'text':
|
||||
return t('sign.saved.type.text', 'Text');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLabelBlur = (signature: SavedSignature) => {
|
||||
const nextValue = labelDrafts[signature.id]?.trim() ?? '';
|
||||
if (!nextValue || nextValue === signature.label) {
|
||||
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
|
||||
return;
|
||||
}
|
||||
onRenameSignature(signature.id, nextValue);
|
||||
};
|
||||
|
||||
const handleLabelChange = (event: React.ChangeEvent<HTMLInputElement>, signature: SavedSignature) => {
|
||||
const { value } = event.currentTarget;
|
||||
setLabelDrafts(prev => ({ ...prev, [signature.id]: value }));
|
||||
};
|
||||
|
||||
const handleLabelKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, signature: SavedSignature) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSignature || disabled) {
|
||||
appliedSignatureIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliedSignatureIdRef.current === activeSignature.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedSignatureIdRef.current = activeSignature.id;
|
||||
onUseSignatureRef.current(activeSignature);
|
||||
}, [activeSignature, disabled]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="md">
|
||||
{t('sign.saved.heading', 'Saved signatures')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('sign.saved.description', 'Reuse saved signatures at any time.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isAtCapacity && (
|
||||
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}>
|
||||
<Text size="sm">
|
||||
{t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||
max: MAX_SAVED_SIGNATURES,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{signatures.length === 0 ? (
|
||||
emptyState
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', {
|
||||
current: activeIndex + 1,
|
||||
total: signatures.length,
|
||||
})}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
aria-label={t('sign.saved.prev', 'Previous')}
|
||||
onClick={() => handleNavigate('prev')}
|
||||
disabled={disabled || activeIndex === 0}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
aria-label={t('sign.saved.next', 'Next')}
|
||||
onClick={() => handleNavigate('next')}
|
||||
disabled={disabled || activeIndex >= signatures.length - 1}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{activeSignature && (
|
||||
<Card withBorder padding="sm" key={activeSignature.id}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
|
||||
{typeLabel(activeSignature.type)}
|
||||
</Badge>
|
||||
<Tooltip label={t('sign.saved.delete', 'Remove')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('sign.saved.delete', 'Remove')}
|
||||
onClick={() => onDeleteSignature(activeSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{renderPreview(activeSignature)}
|
||||
|
||||
<TextInput
|
||||
label={t('sign.saved.label', 'Label')}
|
||||
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
|
||||
onChange={event => handleLabelChange(event, activeSignature)}
|
||||
onBlur={() => handleLabelBlur(activeSignature)}
|
||||
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SavedSignaturesSection;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: ToolId;
|
||||
@@ -26,12 +27,12 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
|
||||
const { t } = useTranslation();
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const isUnavailable = disabledReason !== null;
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const fav = isFavorite(id as ToolId);
|
||||
|
||||
const handleClick = (id: ToolId) => {
|
||||
@@ -48,8 +49,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
// Get navigation props for URL support (only if navigation is not disabled)
|
||||
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useAddAttachmentsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("AddAttachmentsRequest.tooltip.header.title", "About Add Attachments")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("AddAttachmentsRequest.tooltip.description.title", "What it does"),
|
||||
description: t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."),
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -9,6 +9,10 @@ export const useAutoRenameTips = (): TooltipContent => {
|
||||
title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("auto-rename.tooltip.description.title", "What it does"),
|
||||
description: t("auto-rename.description", "Automatically finds the title from your PDF content and uses it as the filename."),
|
||||
},
|
||||
{
|
||||
title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"),
|
||||
bullets: [
|
||||
|
||||
@@ -5,6 +5,9 @@ export const useMergeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('merge.tooltip.header.title', 'Merge Settings Overview')
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useRemoveAnnotationsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("removeAnnotations.tooltip.header.title", "About Remove Annotations")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("removeAnnotations.tooltip.description.title", "What it does"),
|
||||
description: t('removeAnnotations.info.description',
|
||||
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
|
||||
),
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
@@ -34,6 +35,7 @@ const EmbedPdfViewerContent = ({
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
@@ -53,7 +55,7 @@ const EmbedPdfViewerContent = ({
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@@ -71,6 +73,9 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
@@ -247,15 +252,17 @@ const EmbedPdfViewerContent = ({
|
||||
) : (
|
||||
<>
|
||||
{/* EmbedPDF Viewer */}
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<Box
|
||||
ref={pdfContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
@@ -268,6 +275,11 @@ const EmbedPdfViewerContent = ({
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
<SignaturePlacementOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { uuidV4 } from '@embedpdf/models';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
|
||||
|
||||
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
const restoringIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
@@ -18,17 +20,58 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
const annotation = event.annotation;
|
||||
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||
if (!storedImageData) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
|
||||
// Prevent infinite loops when we recreate annotations
|
||||
if (restoringIds.current.has(annotation.id)) {
|
||||
restoringIds.current.delete(annotation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
|
||||
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
|
||||
const newId = uuidV4();
|
||||
restoringIds.current.add(newId);
|
||||
storeImageData(newId, storedImageData);
|
||||
|
||||
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
|
||||
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||
|
||||
try {
|
||||
annotationApi.deleteAnnotation(pageIndex, annotation.id);
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: annotation.type,
|
||||
rect,
|
||||
author: annotation.author || 'Digital Signature',
|
||||
subject: annotation.subject || 'Digital Signature',
|
||||
pageIndex,
|
||||
id: newId,
|
||||
created: annotation.created || new Date(),
|
||||
imageSrc: storedImageData,
|
||||
contents: storedImageData,
|
||||
data: storedImageData,
|
||||
appearance: storedImageData,
|
||||
});
|
||||
}, ANNOTATION_RECREATION_DELAY_MS);
|
||||
} catch (restoreError) {
|
||||
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
|
||||
getImageData(annotation.id);
|
||||
|
||||
// Delay the check to allow the annotation to be fully created
|
||||
@@ -61,12 +104,12 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
// Small delay to ensure deletion completes
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(event.pageIndex, restoredData);
|
||||
}, 50);
|
||||
}, ANNOTATION_RECREATION_DELAY_MS);
|
||||
} catch (error) {
|
||||
console.error('HistoryAPI: Failed to restore annotation:', error);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}, ANNOTATION_VERIFICATION_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -102,6 +145,21 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
canRedo: () => {
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
|
||||
subscribe: (listener: () => void) => {
|
||||
if (!historyApi?.onHistoryChange) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const wrapped = () => listener();
|
||||
const unsubscribe = historyApi.onHistoryChange(wrapped);
|
||||
listener();
|
||||
|
||||
if (typeof unsubscribe === 'function') {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
}), [historyApi]);
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -43,23 +43,32 @@ export function PdfViewerToolbar({
|
||||
|
||||
// Register for immediate scroll updates and sync with actual scroll state
|
||||
useEffect(() => {
|
||||
registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
||||
const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
||||
setPageInput(currentPage);
|
||||
});
|
||||
setPageInput(scrollState.currentPage);
|
||||
}, [registerImmediateScrollUpdate]);
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [registerImmediateScrollUpdate, scrollState.currentPage]);
|
||||
|
||||
// Register for immediate zoom updates and sync with actual zoom state
|
||||
useEffect(() => {
|
||||
registerImmediateZoomUpdate(setDisplayZoomPercent);
|
||||
const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent);
|
||||
setDisplayZoomPercent(zoomState.zoomPercent || 140);
|
||||
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [registerImmediateZoomUpdate, zoomState.zoomPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
registerImmediateSpreadUpdate((_mode, isDual) => {
|
||||
const unregister = registerImmediateSpreadUpdate((_mode, isDual) => {
|
||||
setIsDualPageActive(isDual);
|
||||
});
|
||||
setIsDualPageActive(spreadState.isDualPage);
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
|
||||
|
||||
const handleZoomOut = () => {
|
||||
|
||||
@@ -1,12 +1,211 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
|
||||
// This prevents rendering issues and ensures signatures are always visible and usable.
|
||||
const MIN_SIGNATURE_DIMENSION = 12;
|
||||
|
||||
// Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images.
|
||||
// This provides a good balance between visual fidelity and performance/memory usage.
|
||||
const TEXT_OVERSAMPLE_FACTOR = 2;
|
||||
|
||||
type TextStampImageResult = {
|
||||
dataUrl: string;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
};
|
||||
|
||||
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
|
||||
if (!value || depth > 6) return undefined;
|
||||
|
||||
// Prevent circular references
|
||||
if (typeof value === 'object' && visited.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('data:image') ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
visited.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const result = extractDataUrl(entry, depth + 1, visited);
|
||||
if (result) return result;
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1, visited);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createTextStampImage = (
|
||||
config: SignParameters,
|
||||
displaySize?: { width: number; height: number } | null
|
||||
): TextStampImageResult | null => {
|
||||
const text = (config.signerName ?? '').trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSize = config.fontSize ?? 16;
|
||||
const fontFamily = config.fontFamily ?? 'Helvetica';
|
||||
const textColor = config.textColor ?? '#000000';
|
||||
|
||||
const paddingX = Math.max(4, Math.round(fontSize * 0.8));
|
||||
const paddingY = Math.max(4, Math.round(fontSize * 0.6));
|
||||
|
||||
const measureCanvas = document.createElement('canvas');
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
if (!measureCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measureCtx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = measureCtx.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2);
|
||||
const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2));
|
||||
|
||||
const scale =
|
||||
displaySize && naturalWidth > 0 && naturalHeight > 0
|
||||
? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight)
|
||||
: 1;
|
||||
|
||||
const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale);
|
||||
const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale);
|
||||
|
||||
const canvasWidth = Math.max(
|
||||
MIN_SIGNATURE_DIMENSION,
|
||||
Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR)
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
MIN_SIGNATURE_DIMENSION,
|
||||
Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR)
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR;
|
||||
ctx.scale(effectiveScale, effectiveScale);
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
pixelWidth: canvasWidth,
|
||||
pixelHeight: canvasHeight,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
|
||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
|
||||
const lastStampImageRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentZoom(getZoomState()?.currentZoom ?? 1);
|
||||
const unregister = registerImmediateZoomUpdate(percent => {
|
||||
setCurrentZoom(Math.max(percent / 100, 0.01));
|
||||
});
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [getZoomState, registerImmediateZoomUpdate]);
|
||||
|
||||
const cssToPdfSize = useCallback(
|
||||
(size: { width: number; height: number }) => {
|
||||
const zoom = currentZoom || 1;
|
||||
const factor = 1 / zoom;
|
||||
return {
|
||||
width: size.width * factor,
|
||||
height: size.height * factor,
|
||||
};
|
||||
},
|
||||
[currentZoom]
|
||||
);
|
||||
|
||||
const applyStampDefaults = useCallback(
|
||||
(imageSrc: string, subject: string, size?: { width: number; height: number }) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc,
|
||||
subject,
|
||||
...(size ? { imageSize: { width: size.width, height: size.height } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
[annotationApi]
|
||||
);
|
||||
|
||||
const configureStampDefaults = useCallback(async () => {
|
||||
if (!annotationApi || !signatureConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
const textStamp = createTextStampImage(signatureConfig, placementPreviewSize);
|
||||
if (textStamp) {
|
||||
const displaySize =
|
||||
placementPreviewSize ?? {
|
||||
width: textStamp.displayWidth,
|
||||
height: textStamp.displayHeight,
|
||||
};
|
||||
const pdfSize = cssToPdfSize(displaySize);
|
||||
lastStampImageRef.current = textStamp.dataUrl;
|
||||
applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signatureConfig.signatureData) {
|
||||
const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined;
|
||||
lastStampImageRef.current = signatureConfig.signatureData;
|
||||
applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preparing signature defaults:', error);
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
@@ -108,58 +307,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
activateSignaturePlacementMode: () => {
|
||||
if (!annotationApi || !signatureConfig) return;
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Skip native text tools - always use stamp for consistent sizing
|
||||
const activatedTool = null;
|
||||
|
||||
if (!activatedTool) {
|
||||
// Create text image as stamp with actual pixel size matching desired display size
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const baseFontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
const textColor = signatureConfig.textColor || '#000000';
|
||||
|
||||
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
|
||||
canvas.height = baseFontSize + 20;
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${baseFontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||
const dataURL = canvas.toDataURL();
|
||||
|
||||
// Deactivate and reactivate to force refresh
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: dataURL,
|
||||
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (signatureConfig.signatureData) {
|
||||
// Use stamp tool for image/canvas signatures
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
|
||||
if (activeTool && activeTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: signatureConfig.signatureData,
|
||||
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
configureStampDefaults().catch((error) => {
|
||||
console.error('Error activating signature tool:', error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateDrawSettings: (color: string, size: number) => {
|
||||
@@ -196,7 +346,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
|
||||
if (annotation && annotation.type === 13 && annotation.imageSrc) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.imageSrc) {
|
||||
// Store image data before deletion
|
||||
storeImageData(annotationId, annotation.imageSrc);
|
||||
}
|
||||
@@ -230,7 +380,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig]);
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotationApi?.onAnnotationEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = annotationApi.onAnnotationEvent(event => {
|
||||
if (event.type !== 'create' && event.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotation: any = event.annotation;
|
||||
const annotationId: string | undefined = annotation?.id;
|
||||
if (!annotationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directData =
|
||||
extractDataUrl(annotation.imageSrc) ||
|
||||
extractDataUrl(annotation.imageData) ||
|
||||
extractDataUrl(annotation.appearance) ||
|
||||
extractDataUrl(annotation.stampData) ||
|
||||
extractDataUrl(annotation.contents) ||
|
||||
extractDataUrl(annotation.data) ||
|
||||
extractDataUrl(annotation.customData) ||
|
||||
extractDataUrl(annotation.asset);
|
||||
|
||||
const dataToStore = directData || lastStampImageRef.current;
|
||||
if (dataToStore) {
|
||||
storeImageData(annotationId, dataToStore);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [annotationApi, storeImageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
configureStampDefaults().catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Error updating signature defaults:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import {
|
||||
MAX_PREVIEW_WIDTH_RATIO,
|
||||
MAX_PREVIEW_HEIGHT_RATIO,
|
||||
MAX_PREVIEW_WIDTH_REM,
|
||||
MAX_PREVIEW_HEIGHT_REM,
|
||||
MIN_SIGNATURE_DIMENSION_REM,
|
||||
OVERLAY_EDGE_PADDING_REM,
|
||||
} from '@app/constants/signConstants';
|
||||
|
||||
// Convert rem to pixels using browser's base font size (typically 16px)
|
||||
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
interface SignaturePlacementOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
signatureConfig: SignParameters | null;
|
||||
}
|
||||
|
||||
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
|
||||
containerRef,
|
||||
isActive,
|
||||
signatureConfig,
|
||||
}) => {
|
||||
const [preview, setPreview] = useState<SignaturePreview | null>(null);
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
|
||||
const { setPlacementPreviewSize } = useSignature();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const buildPreview = async () => {
|
||||
try {
|
||||
const value = await buildSignaturePreview(signatureConfig ?? null);
|
||||
if (!cancelled) {
|
||||
setPreview(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to build signature preview:', error);
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
buildPreview();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [signatureConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!isActive || !element) {
|
||||
setCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setCursor({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => setCursor(null);
|
||||
|
||||
element.addEventListener('mousemove', handleMove);
|
||||
element.addEventListener('mouseleave', handleLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mousemove', handleMove);
|
||||
element.removeEventListener('mouseleave', handleLeave);
|
||||
};
|
||||
}, [containerRef, isActive]);
|
||||
|
||||
const scaledSize = useMemo(() => {
|
||||
if (!preview || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
|
||||
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
maxWidth / Math.max(preview.width, 1),
|
||||
maxHeight / Math.max(preview.height, 1)
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
|
||||
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
|
||||
};
|
||||
}, [preview, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !scaledSize) {
|
||||
setPlacementPreviewSize(null);
|
||||
} else {
|
||||
setPlacementPreviewSize(scaledSize);
|
||||
}
|
||||
}, [isActive, scaledSize, setPlacementPreviewSize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPlacementPreviewSize(null);
|
||||
};
|
||||
}, [setPlacementPreviewSize]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (!preview || !scaledSize || !cursor || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const width = scaledSize.width;
|
||||
const height = scaledSize.height;
|
||||
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
|
||||
|
||||
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
|
||||
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
|
||||
|
||||
return {
|
||||
left: clampedLeft,
|
||||
top: clampedTop,
|
||||
width,
|
||||
height,
|
||||
dataUrl: preview.dataUrl,
|
||||
};
|
||||
}, [preview, scaledSize, cursor, containerRef]);
|
||||
|
||||
if (!isActive || !display || !preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
left: `${display.left}px`,
|
||||
top: `${display.top}px`,
|
||||
width: `${display.width}px`,
|
||||
height: `${display.height}px`,
|
||||
backgroundImage: `url(${display.dataUrl})`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
|
||||
borderRadius: '4px',
|
||||
transition: 'transform 70ms ease-out',
|
||||
transform: 'translateZ(0)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -214,4 +214,4 @@ export function ZoomAPIBridge() {
|
||||
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,5 @@ export interface HistoryAPI {
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// When no subpath, use empty string instead of '.' to avoid relative path issues
|
||||
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
|
||||
|
||||
// EmbedPDF needs time to remove annotations internally before a recreation runs.
|
||||
// Without the buffer we occasionally end up with duplicate annotations or stale image data.
|
||||
export const ANNOTATION_RECREATION_DELAY_MS = 50;
|
||||
export const ANNOTATION_VERIFICATION_DELAY_MS = 100;
|
||||
|
||||
/** For in-app navigations when you must touch window.location (rare). */
|
||||
export const withBasePath = (path: string): string => {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Timeout delays (ms) to allow PDF viewer to complete rendering before activating placement mode
|
||||
export const PLACEMENT_ACTIVATION_DELAY = 60; // Standard delay for signature changes
|
||||
export const FILE_SWITCH_ACTIVATION_DELAY = 80; // Slightly longer delay when switching files
|
||||
|
||||
// Signature preview sizing
|
||||
export const MAX_PREVIEW_WIDTH_RATIO = 0.35; // Max preview width as percentage of container
|
||||
export const MAX_PREVIEW_HEIGHT_RATIO = 0.35; // Max preview height as percentage of container
|
||||
export const MAX_PREVIEW_WIDTH_REM = 15; // Absolute max width in rem
|
||||
export const MAX_PREVIEW_HEIGHT_REM = 10; // Absolute max height in rem
|
||||
export const MIN_SIGNATURE_DIMENSION_REM = 0.75; // Min dimension for visibility
|
||||
export const OVERLAY_EDGE_PADDING_REM = 0.25; // Padding from container edges
|
||||
|
||||
// Text signature padding (relative to font size)
|
||||
export const HORIZONTAL_PADDING_RATIO = 0.8;
|
||||
export const VERTICAL_PADDING_RATIO = 0.6;
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { waitFor, renderHook, act } from '@testing-library/react';
|
||||
import { AppConfigProvider, useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('@app/services/apiClient');
|
||||
|
||||
describe('AppConfigContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock window.location.pathname
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AppConfigProvider>{children}</AppConfigProvider>
|
||||
);
|
||||
|
||||
it('should fetch and provide app config on non-auth pages', async () => {
|
||||
const mockConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: 'Stirling PDF',
|
||||
languages: ['en-US', 'en-GB'],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.config).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fetch on auth pages and use default config', async () => {
|
||||
// Mock being on login page
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/login' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
// Should NOT call API on auth pages
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle 401 error gracefully', async () => {
|
||||
const mockError = Object.assign(new Error('Unauthorized'), {
|
||||
response: { status: 401, data: {} },
|
||||
});
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
// 401 should be handled gracefully, error may be null or set
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const errorMessage = 'Network error occurred';
|
||||
const mockError = new Error(errorMessage);
|
||||
// Network errors don't have response property
|
||||
// Mock rejection for all retry attempts (default is 3 attempts)
|
||||
vi.mocked(apiClient.get)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
expect(result.current.error).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fetch on signup page', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/signup' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip fetch on auth callback page', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/auth/callback' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip fetch on invite accept page', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/invite/abc123' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refetch config when jwt-available event is triggered', async () => {
|
||||
const initialConfig = {
|
||||
enableLogin: true,
|
||||
appNameNavbar: 'Stirling PDF',
|
||||
};
|
||||
|
||||
const updatedConfig = {
|
||||
enableLogin: true,
|
||||
appNameNavbar: 'Stirling PDF',
|
||||
isAdmin: true,
|
||||
enableAnalytics: true,
|
||||
};
|
||||
|
||||
// First call returns initial config
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: initialConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(initialConfig);
|
||||
});
|
||||
|
||||
// Setup second call for refetch
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: updatedConfig,
|
||||
} as any);
|
||||
|
||||
// Trigger jwt-available event wrapped in act
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
// Wait a tick for event handler to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(updatedConfig);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should provide refetch function', async () => {
|
||||
const mockConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: 'Test App',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
// Call refetch wrapped in act
|
||||
await act(async () => {
|
||||
await result.current.refetch();
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not fetch twice without force flag', async () => {
|
||||
const mockConfig = {
|
||||
enableLogin: false,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
// Should only be called once (no duplicate fetches)
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle initial config prop', async () => {
|
||||
const initialConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: 'Initial App',
|
||||
};
|
||||
|
||||
const customWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AppConfigProvider initialConfig={initialConfig}>
|
||||
{children}
|
||||
</AppConfigProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), {
|
||||
wrapper: customWrapper,
|
||||
});
|
||||
|
||||
// With blocking mode (default), should still fetch even with initial config
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Should still make API call
|
||||
expect(apiClient.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use suppressErrorToast for all config requests', async () => {
|
||||
const mockConfig = { enableLogin: true };
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,9 @@ export interface AppConfig {
|
||||
license?: string;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -111,7 +114,8 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
}
|
||||
|
||||
// apiClient automatically adds JWT header if available via interceptors
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
|
||||
// Always suppress error toast - we handle 401 errors locally
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', { suppressErrorToast: true });
|
||||
const data = response.data;
|
||||
|
||||
console.debug('[AppConfig] Config fetched successfully:', data);
|
||||
@@ -156,8 +160,25 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
// Always try to fetch config to check if login is disabled
|
||||
// The endpoint should be public and return proper JSON
|
||||
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
|
||||
// Config will be fetched after successful authentication via jwt-available event
|
||||
const currentPath = window.location.pathname;
|
||||
const isAuthPage = currentPath.includes('/login') ||
|
||||
currentPath.includes('/signup') ||
|
||||
currentPath.includes('/auth/callback') ||
|
||||
currentPath.includes('/invite/');
|
||||
|
||||
// On auth pages, always skip the config fetch
|
||||
// The config will be fetched after authentication via jwt-available event
|
||||
if (isAuthPage) {
|
||||
console.debug('[AppConfig] On auth page - using default config, skipping fetch');
|
||||
setConfig({ enableLogin: true });
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// On non-auth pages, fetch config (will validate JWT if present)
|
||||
if (autoFetch) {
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
|
||||
*/
|
||||
|
||||
import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react';
|
||||
import {
|
||||
FileContextProviderProps,
|
||||
FileContextSelectors,
|
||||
@@ -22,17 +22,27 @@ import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile,
|
||||
} from '@app/types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer';
|
||||
import { createFileSelectors } from '@app/contexts/file/fileSelectors';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
||||
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
|
||||
import { useZipConfirmation } from '@app/hooks/useZipConfirmation';
|
||||
import ZipWarningModal from '@app/components/shared/ZipWarningModal';
|
||||
import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
|
||||
import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { processResponse } from '@app/utils/toolResponseProcessor';
|
||||
import { ToolOperation } from '@app/types/file';
|
||||
import { handlePasswordError } from '@app/utils/toolErrorHandler';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -63,6 +73,98 @@ function FileContextInner({
|
||||
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
|
||||
}
|
||||
const lifecycleManager = lifecycleManagerRef.current;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [encryptedQueue, setEncryptedQueue] = useState<FileId[]>([]);
|
||||
const [activeEncryptedFileId, setActiveEncryptedFileId] = useState<FileId | null>(null);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [unlockError, setUnlockError] = useState<string | null>(null);
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
const dismissedEncryptedFilesRef = useRef<Set<FileId>>(new Set());
|
||||
const observedFileIdsRef = useRef<Set<FileId>>(new Set());
|
||||
|
||||
const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => {
|
||||
if (fileIds.length === 0) return;
|
||||
setEncryptedQueue(prevQueue => {
|
||||
const existing = new Set(prevQueue);
|
||||
const next = [...prevQueue];
|
||||
for (const id of fileIds) {
|
||||
if (dismissedEncryptedFilesRef.current.has(id)) continue;
|
||||
if (id === activeEncryptedFileId) continue;
|
||||
if (existing.has(id)) continue;
|
||||
existing.add(id);
|
||||
next.push(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousIds = observedFileIdsRef.current;
|
||||
const nextIds = new Set(state.files.ids);
|
||||
const newEncryptedIds: FileId[] = [];
|
||||
|
||||
for (const id of state.files.ids) {
|
||||
if (!previousIds.has(id)) {
|
||||
const stub = state.files.byId[id];
|
||||
if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) {
|
||||
newEncryptedIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newEncryptedIds.length > 0) {
|
||||
enqueueEncryptedFiles(newEncryptedIds);
|
||||
}
|
||||
|
||||
observedFileIdsRef.current = nextIds;
|
||||
}, [state.files.ids, state.files.byId, enqueueEncryptedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEncryptedFileId && encryptedQueue.length > 0) {
|
||||
setActiveEncryptedFileId(encryptedQueue[0]);
|
||||
setEncryptedQueue(prev => prev.slice(1));
|
||||
}
|
||||
}, [activeEncryptedFileId, encryptedQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) {
|
||||
setActiveEncryptedFileId(null);
|
||||
}
|
||||
}, [activeEncryptedFileId, state.files.ids]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnlockPassword('');
|
||||
setUnlockError(null);
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
const handleUnlockSkip = useCallback(() => {
|
||||
if (activeEncryptedFileId) {
|
||||
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
|
||||
}
|
||||
setActiveEncryptedFileId(null);
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
const promptEncryptedUnlock = useCallback((fileId: FileId) => {
|
||||
const stub = stateRef.current.files.byId[fileId];
|
||||
if (!stub?.processedFile?.isEncrypted) {
|
||||
return;
|
||||
}
|
||||
|
||||
dismissedEncryptedFilesRef.current.delete(fileId);
|
||||
|
||||
setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId));
|
||||
|
||||
setActiveEncryptedFileId(currentActiveId => {
|
||||
if (currentActiveId && currentActiveId !== fileId) {
|
||||
setEncryptedQueue(prevQueue => {
|
||||
const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId);
|
||||
return [currentActiveId, ...withoutDuplicates];
|
||||
});
|
||||
}
|
||||
return fileId;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Create stable selectors (memoized once to avoid re-renders)
|
||||
const selectors = useMemo<FileContextSelectors>(() =>
|
||||
@@ -131,6 +233,80 @@ function FileContextInner({
|
||||
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
|
||||
}, []);
|
||||
|
||||
const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise<void> => {
|
||||
const file = filesRef.current.get(fileId);
|
||||
const parentStub = stateRef.current.files.byId[fileId];
|
||||
|
||||
if (!file || !parentStub) {
|
||||
throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.'));
|
||||
}
|
||||
|
||||
const params: RemovePasswordParameters = { password };
|
||||
const formData = buildRemovePasswordFormData(params, file);
|
||||
|
||||
const response = await apiClient.post('/api/v1/security/remove-password', formData, {
|
||||
responseType: 'blob',
|
||||
suppressErrorToast: true // Handle errors in modal UI instead of toast
|
||||
});
|
||||
const responseFiles = await processResponse(response.data, [file]);
|
||||
|
||||
const unlockedFile = responseFiles[0];
|
||||
if (!unlockedFile) {
|
||||
throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.'));
|
||||
}
|
||||
|
||||
const processedMetadata = await generateProcessedFileMetadata(unlockedFile);
|
||||
const thumbnail = processedMetadata?.thumbnailUrl;
|
||||
|
||||
const operation: ToolOperation = {
|
||||
toolId: 'removePassword',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata);
|
||||
const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id);
|
||||
|
||||
await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]);
|
||||
}, [consumeFilesWrapper, t]);
|
||||
|
||||
const handleUnlockSubmit = useCallback(async () => {
|
||||
if (!activeEncryptedFileId) return;
|
||||
if (!unlockPassword.trim()) {
|
||||
setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUnlocking(true);
|
||||
setUnlockError(null);
|
||||
try {
|
||||
await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim());
|
||||
const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name;
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('encryptedPdfUnlock.successTitle', 'Password removed'),
|
||||
body: fileName
|
||||
? t('encryptedPdfUnlock.successBodyWithName', {
|
||||
defaultValue: 'Removed password from {{fileName}}',
|
||||
fileName,
|
||||
})
|
||||
: t('encryptedPdfUnlock.successBody', 'Password removed successfully.'),
|
||||
expandable: false,
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId);
|
||||
setActiveEncryptedFileId(null);
|
||||
} catch (error) {
|
||||
const errorMessage = await handlePasswordError(
|
||||
error,
|
||||
t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'),
|
||||
t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.')
|
||||
);
|
||||
setUnlockError(errorMessage);
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
}, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
@@ -199,7 +375,8 @@ function FileContextInner({
|
||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||
scheduleCleanup: (fileId: FileId, delay?: number) =>
|
||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef),
|
||||
openEncryptedUnlockPrompt: promptEncryptedUnlock
|
||||
}), [
|
||||
baseActions,
|
||||
addRawFiles,
|
||||
@@ -211,7 +388,8 @@ function FileContextInner({
|
||||
pinFileWrapper,
|
||||
unpinFileWrapper,
|
||||
indexedDB,
|
||||
enablePersistence
|
||||
enablePersistence,
|
||||
promptEncryptedUnlock
|
||||
]);
|
||||
|
||||
// Split context values to minimize re-renders
|
||||
@@ -225,6 +403,9 @@ function FileContextInner({
|
||||
dispatch
|
||||
}), [actions]);
|
||||
|
||||
const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined;
|
||||
const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub);
|
||||
|
||||
// Persistence loading disabled - files only loaded on explicit user action
|
||||
// useEffect(() => {
|
||||
// if (!enablePersistence || !indexedDB) return;
|
||||
@@ -251,6 +432,16 @@ function FileContextInner({
|
||||
fileCount={confirmationState.fileCount}
|
||||
zipFileName={confirmationState.fileName}
|
||||
/>
|
||||
<EncryptedPdfUnlockModal
|
||||
opened={isUnlockModalOpen}
|
||||
fileName={activeEncryptedStub?.name}
|
||||
password={unlockPassword}
|
||||
errorMessage={unlockError}
|
||||
isProcessing={isUnlocking}
|
||||
onPasswordChange={setUnlockPassword}
|
||||
onUnlock={handleUnlockSubmit}
|
||||
onSkip={handleUnlockSkip}
|
||||
/>
|
||||
</FileActionsContext.Provider>
|
||||
</FileStateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ interface SignatureState {
|
||||
isPlacementMode: boolean;
|
||||
// Whether signatures have been applied (allows export)
|
||||
signaturesApplied: boolean;
|
||||
// Size (in screen units) we want newly placed signatures to use
|
||||
placementPreviewSize: { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
// Signature actions interface
|
||||
@@ -26,6 +28,7 @@ interface SignatureActions {
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
setSignaturesApplied: (applied: boolean) => void;
|
||||
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
|
||||
}
|
||||
|
||||
// Combined context interface
|
||||
@@ -42,6 +45,7 @@ const initialState: SignatureState = {
|
||||
signatureConfig: null,
|
||||
isPlacementMode: false,
|
||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||
placementPreviewSize: null,
|
||||
};
|
||||
|
||||
// Provider component
|
||||
@@ -131,6 +135,27 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setPlacementPreviewSize = useCallback((size: { width: number; height: number } | null) => {
|
||||
setState(prev => {
|
||||
const prevSize = prev.placementPreviewSize;
|
||||
const same =
|
||||
(prevSize === null && size === null) ||
|
||||
(prevSize !== null &&
|
||||
size !== null &&
|
||||
Math.abs(prevSize.width - size.width) < 0.5 &&
|
||||
Math.abs(prevSize.height - size.height) < 0.5);
|
||||
|
||||
if (same) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
placementPreviewSize: size,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// No auto-activation - all modes use manual buttons
|
||||
|
||||
const contextValue: SignatureContextValue = {
|
||||
@@ -149,6 +174,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
storeImageData,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
setPlacementPreviewSize,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useToolManagement } from '@app/hooks/useToolManagement';
|
||||
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '@app/types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Tool management hook
|
||||
const { toolRegistry, getSelectedTool } = useToolManagement();
|
||||
const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
|
||||
const { allTools } = useToolRegistry();
|
||||
|
||||
// Tool history hook
|
||||
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
const availabilityInfo = toolAvailability[toolId];
|
||||
const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false;
|
||||
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
|
||||
return;
|
||||
}
|
||||
// If we're currently on a custom workbench (e.g., Validate Signature report),
|
||||
// selecting any tool should take the user back to the default file manager view.
|
||||
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
|
||||
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
toolResetFunctions,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
toolAvailability,
|
||||
|
||||
// Workflow Actions
|
||||
handleToolSelect,
|
||||
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
|
||||
@@ -39,14 +39,23 @@ import {
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
|
||||
function useImmediateNotifier<Args extends unknown[]>() {
|
||||
const callbackRef = useRef<((...args: Args) => void) | null>(null);
|
||||
const callbacksRef = useRef(new Set<(...args: Args) => void>());
|
||||
|
||||
const register = useCallback((callback: (...args: Args) => void) => {
|
||||
callbackRef.current = callback;
|
||||
callbacksRef.current.add(callback);
|
||||
return () => {
|
||||
callbacksRef.current.delete(callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const trigger = useCallback((...args: Args) => {
|
||||
callbackRef.current?.(...args);
|
||||
callbacksRef.current.forEach(cb => {
|
||||
try {
|
||||
cb(...args);
|
||||
} catch (error) {
|
||||
console.error('Immediate callback error:', error);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { register, trigger };
|
||||
@@ -91,9 +100,9 @@ interface ViewerContextType {
|
||||
getExportState: () => ExportState;
|
||||
|
||||
// Immediate update callbacks
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
|
||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
|
||||
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
|
||||
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
|
||||
|
||||
// Internal - for bridges to trigger immediate updates
|
||||
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function createProcessedFile(
|
||||
thumbnail?: string,
|
||||
pageRotations?: number[],
|
||||
pageDimensions?: Array<{ width: number; height: number }>
|
||||
) {
|
||||
): ProcessedFileMetadata {
|
||||
return {
|
||||
totalPages: pageCount,
|
||||
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||
@@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
|
||||
// Use rotated thumbnail for file manager
|
||||
processedFile.thumbnailUrl = rotatedResult.thumbnail;
|
||||
|
||||
if (unrotatedResult.isEncrypted || rotatedResult.isEncrypted) {
|
||||
processedFile.isEncrypted = true;
|
||||
}
|
||||
|
||||
return processedFile;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
||||
|
||||
@@ -188,6 +188,7 @@ export function useFileContext() {
|
||||
|
||||
// Active files
|
||||
activeFiles: selectors.getFiles(),
|
||||
openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt,
|
||||
|
||||
// Direct access to actions and selectors (for advanced use cases)
|
||||
actions,
|
||||
|
||||
@@ -82,6 +82,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
|
||||
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
|
||||
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants';
|
||||
import CompressSettings from "@app/components/tools/compress/CompressSettings";
|
||||
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
|
||||
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
|
||||
@@ -300,6 +301,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
endpoints: ["get-info-on-pdf"],
|
||||
synonyms: getSynonyms(t, "getPdfInfo"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
@@ -398,6 +400,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.split.desc", "Split PDFs into multiple documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
|
||||
operationConfig: splitOperationConfig,
|
||||
automationSettings: SplitAutomationSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
@@ -465,6 +468,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["booklet-imposition"],
|
||||
},
|
||||
pdfToSinglePage: {
|
||||
|
||||
@@ -559,6 +563,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-annotations"],
|
||||
operationConfig: removeAnnotationsOperationConfig,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "removeAnnotations")
|
||||
@@ -597,7 +602,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["remove-cert-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
automationSettings: null,
|
||||
@@ -626,7 +631,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
name: t("home.autoRename.title", "Auto Rename PDF File"),
|
||||
component: AutoRename,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["auto-rename"],
|
||||
operationConfig: autoRenameOperationConfig,
|
||||
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
@@ -681,6 +686,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["overlay-pdf"],
|
||||
operationConfig: overlayPdfsOperationConfig,
|
||||
synonyms: getSynonyms(t, "overlay-pdfs"),
|
||||
automationSettings: OverlayPdfsSettings
|
||||
@@ -705,6 +711,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.addImage.desc", "Add images to PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["add-image"],
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
automationSettings: null
|
||||
},
|
||||
@@ -715,6 +722,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["scanner-effect"],
|
||||
synonyms: getSynonyms(t, "scannerEffect"),
|
||||
automationSettings: null
|
||||
},
|
||||
@@ -805,6 +813,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["compress-pdf"],
|
||||
operationConfig: compressOperationConfig,
|
||||
automationSettings: CompressSettings,
|
||||
synonyms: getSynonyms(t, "compress")
|
||||
@@ -848,6 +857,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["ocr-pdf"],
|
||||
operationConfig: ocrOperationConfig,
|
||||
automationSettings: OCRSettings,
|
||||
synonyms: getSynonyms(t, "ocr")
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SPLIT_METHODS } from '@app/constants/splitConstants';
|
||||
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
|
||||
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
|
||||
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
|
||||
const PrivacyIcon = () => React.createElement(LocalIcon, { icon: 'shield-lock', width: '1.5rem', height: '1.5rem' });
|
||||
|
||||
export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||
const { t } = useTranslation();
|
||||
@@ -67,6 +68,63 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||
updatedAt: now,
|
||||
icon: SecurityIcon,
|
||||
},
|
||||
{
|
||||
id: "pre-publish-sanitization",
|
||||
name: t("automation.suggested.prePublishSanitization", "Pre-publish Sanitization"),
|
||||
description: t("automation.suggested.prePublishSanitizationDesc", "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."),
|
||||
operations: [
|
||||
{
|
||||
operation: "sanitize",
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: true,
|
||||
removeXMPMetadata: true,
|
||||
removeMetadata: true,
|
||||
removeLinks: true,
|
||||
removeFonts: false,
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "flatten",
|
||||
parameters: {
|
||||
flattenOnlyForms: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "removeAnnotations",
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
operation: "changeMetadata",
|
||||
parameters: {
|
||||
deleteAll: true,
|
||||
author: '',
|
||||
creationDate: '',
|
||||
creator: '',
|
||||
keywords: '',
|
||||
modificationDate: '',
|
||||
producer: '',
|
||||
subject: '',
|
||||
title: '',
|
||||
trapped: '',
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "compress",
|
||||
parameters: {
|
||||
compressionLevel: 3,
|
||||
grayscale: false,
|
||||
expectedSize: '',
|
||||
compressionMethod: 'quality',
|
||||
fileSizeValue: '',
|
||||
fileSizeUnit: 'MB',
|
||||
}
|
||||
},
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
icon: PrivacyIcon,
|
||||
},
|
||||
{
|
||||
id: "email-preparation",
|
||||
name: t("automation.suggested.emailPreparation", "Email Preparation"),
|
||||
|
||||
@@ -40,13 +40,15 @@ export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters
|
||||
|
||||
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
|
||||
let keyNumber = 0;
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
|
||||
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
|
||||
}
|
||||
});
|
||||
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
|
||||
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
+1
-1
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
|
||||
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-certificate-sign',
|
||||
endpointName: 'remove-cert-sign',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
|
||||
/**
|
||||
* Builds FormData for remove password API request.
|
||||
* Separated from operation config to avoid circular dependencies with FileContext.
|
||||
*/
|
||||
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
return formData;
|
||||
};
|
||||
@@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
||||
import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
return formData;
|
||||
};
|
||||
// Re-export for backwards compatibility with any other imports
|
||||
export { buildRemovePasswordFormData };
|
||||
|
||||
// Static configuration object
|
||||
export const removePasswordOperationConfig = {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
|
||||
import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters';
|
||||
import type { ResponseType } from 'axios';
|
||||
|
||||
export interface ShowJSOperationHook extends ToolOperationHook<ShowJSParameters> {
|
||||
scriptText: string | null;
|
||||
@@ -71,8 +70,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => {
|
||||
|
||||
const response = await apiClient.post('/api/v1/misc/show-javascript', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'text' as ResponseType,
|
||||
transformResponse: [(data) => data],
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
const text: string = typeof response.data === 'string' ? response.data : '';
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||
export const MAX_SAVED_SIGNATURES = 10;
|
||||
|
||||
export type SavedSignatureType = 'canvas' | 'image' | 'text';
|
||||
|
||||
export type SavedSignaturePayload =
|
||||
| {
|
||||
type: 'canvas';
|
||||
dataUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
dataUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'text';
|
||||
signerName: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
export type SavedSignature = SavedSignaturePayload & {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type AddSignatureResult =
|
||||
| { success: true; signature: SavedSignature }
|
||||
| { success: false; reason: 'limit' | 'invalid' };
|
||||
|
||||
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
|
||||
const safeParse = (raw: string | null): SavedSignature[] => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((entry: any): entry is SavedSignature => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.type === 'text') {
|
||||
return (
|
||||
typeof entry.signerName === 'string' &&
|
||||
typeof entry.fontFamily === 'string' &&
|
||||
typeof entry.fontSize === 'number' &&
|
||||
typeof entry.textColor === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
return typeof entry.dataUrl === 'string';
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readFromStorage = (): SavedSignature[] => {
|
||||
if (!isSupportedEnvironment()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return safeParse(window.localStorage.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeToStorage = (entries: SavedSignature[]) => {
|
||||
if (!isSupportedEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// Swallow storage errors silently; we still keep state in memory.
|
||||
}
|
||||
};
|
||||
|
||||
const generateId = () => crypto.randomUUID();
|
||||
|
||||
export const useSavedSignatures = () => {
|
||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupportedEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncFromStorage = () => {
|
||||
setSavedSignatures(readFromStorage());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', syncFromStorage);
|
||||
return () => window.removeEventListener('storage', syncFromStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeToStorage(savedSignatures);
|
||||
}, [savedSignatures]);
|
||||
|
||||
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
|
||||
|
||||
const addSignature = useCallback(
|
||||
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
|
||||
if (
|
||||
(payload.type === 'text' && !payload.signerName.trim()) ||
|
||||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
|
||||
) {
|
||||
return { success: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
let createdSignature: SavedSignature | null = null;
|
||||
setSavedSignatures(prev => {
|
||||
if (prev.length >= MAX_SAVED_SIGNATURES) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const nextEntry: SavedSignature = {
|
||||
...payload,
|
||||
id: generateId(),
|
||||
label: (label || 'Signature').trim() || 'Signature',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
createdSignature = nextEntry;
|
||||
return [nextEntry, ...prev];
|
||||
});
|
||||
|
||||
return createdSignature
|
||||
? { success: true, signature: createdSignature }
|
||||
: { success: false, reason: 'limit' };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeSignature = useCallback((id: string) => {
|
||||
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
|
||||
setSavedSignatures(prev =>
|
||||
prev.map(entry =>
|
||||
entry.id === id
|
||||
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
|
||||
setSavedSignatures(prev =>
|
||||
prev.map(entry =>
|
||||
entry.id === id
|
||||
? {
|
||||
...entry,
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearSignatures = useCallback(() => {
|
||||
setSavedSignatures([]);
|
||||
}, []);
|
||||
|
||||
const byTypeCounts = useMemo(() => {
|
||||
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
|
||||
(acc, entry) => {
|
||||
acc[entry.type] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ canvas: 0, image: 0, text: 0 }
|
||||
);
|
||||
}, [savedSignatures]);
|
||||
|
||||
return {
|
||||
savedSignatures,
|
||||
isAtCapacity,
|
||||
addSignature,
|
||||
removeSignature,
|
||||
updateSignatureLabel,
|
||||
replaceSignature,
|
||||
clearSignatures,
|
||||
byTypeCounts,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseSavedSignaturesReturn = ReturnType<typeof useSavedSignatures>;
|
||||
@@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
|
||||
}
|
||||
|
||||
// Add signature type
|
||||
formData.append('signatureType', params.signatureType || 'draw');
|
||||
formData.append('signatureType', params.signatureType || 'canvas');
|
||||
|
||||
// Add other parameters
|
||||
if (params.reason) {
|
||||
@@ -56,4 +56,4 @@ export const useSignOperation = (): ToolOperationHook<SignParameters> => {
|
||||
...signOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface SignaturePosition {
|
||||
}
|
||||
|
||||
export interface SignParameters {
|
||||
signatureType: 'image' | 'text' | 'draw' | 'canvas';
|
||||
signatureType: 'image' | 'text' | 'canvas';
|
||||
signatureData?: string; // Base64 encoded image or text content
|
||||
signaturePosition?: SignaturePosition;
|
||||
reason?: string;
|
||||
@@ -60,4 +60,4 @@ export const useSignParameters = () => {
|
||||
endpointName: 'add-signature',
|
||||
validateFn: validateSignParameters,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
const globalEndpointCache: Record<string, boolean> = {};
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
*/
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
if (endpoint in globalEndpointCache) {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if JWT exists - if not, optimistically enable all endpoints
|
||||
const hasJwt = !!localStorage.getItem('stirling_jwt');
|
||||
if (!hasJwt) {
|
||||
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.assign(globalEndpointCache, statusMap);
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
const fullStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
|
||||
setEndpointStatus(fullStatus);
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
globalEndpointCache[endpoint] = true; // Cache the optimistic value
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => fetchAllEndpointStatuses(true),
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { FileId } from '@app/types/file';
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
|
||||
|
||||
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
|
||||
|
||||
export interface ToolAvailabilityInfo {
|
||||
available: boolean;
|
||||
reason?: ToolDisableCause;
|
||||
}
|
||||
|
||||
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
@@ -11,6 +22,7 @@ interface ToolManagementResult {
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const { allTools } = useToolRegistry();
|
||||
const baseRegistry = allTools;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
// Keep tools enabled during loading (optimistic UX)
|
||||
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
|
||||
const tool = baseRegistry[toolKey];
|
||||
if (!tool) {
|
||||
return 'unknown';
|
||||
}
|
||||
const endpoints = tool.endpoints || [];
|
||||
const disabledReasons: EndpointDisableReason[] = endpoints
|
||||
.filter(endpoint => endpointStatus[endpoint] === false)
|
||||
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
|
||||
|
||||
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (disabledReasons.some(reason => reason === 'CONFIG')) {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
if (disabledReasons.length > 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [baseRegistry, endpointDetails, endpointStatus]);
|
||||
|
||||
const toolAvailability = useMemo(() => {
|
||||
if (endpointsLoading) {
|
||||
return {};
|
||||
}
|
||||
const availability: ToolAvailabilityMap = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
const available = isToolAvailable(toolKey);
|
||||
availability[toolKey] = available
|
||||
? { available: true }
|
||||
: { available: false, reason: deriveToolDisableReason(toolKey) };
|
||||
});
|
||||
return availability;
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
|
||||
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (baseTool) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
}
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (!baseTool) return;
|
||||
const availabilityInfo = toolAvailability[toolKey];
|
||||
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
|
||||
|
||||
// Check if tool is "coming soon" (has no component and no link)
|
||||
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
|
||||
|
||||
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
|
||||
return;
|
||||
}
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
});
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, baseRegistry]);
|
||||
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
|
||||
|
||||
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export function useTooltipPosition({
|
||||
if (sidebarTooltip) {
|
||||
// Require sidebar refs and state for proper positioning
|
||||
if (!sidebarRefs || !sidebarState) {
|
||||
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||
setPositionReady(false);
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function useTooltipPosition({
|
||||
|
||||
// Only show tooltip if we have the tool panel active
|
||||
if (!sidebarInfo.isToolPanelActive) {
|
||||
console.log('🚫 Not showing tooltip - tool panel not active');
|
||||
console.log('Not showing tooltip - tool panel not active');
|
||||
setPositionReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||
|
||||
export function setupApiInterceptors(_client: AxiosInstance): void {
|
||||
// Core version: no interceptors to add
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Add browser ID header for WAU tracking
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const browserId = getBrowserId();
|
||||
config.headers['X-Browser-Id'] = browserId;
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface UserPreferences {
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
hideUnavailableTools: boolean;
|
||||
hideUnavailableConversions: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
hideUnavailableTools: false,
|
||||
hideUnavailableConversions: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
// Check if Supabase is configured
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
// Create client only if configured, otherwise export null
|
||||
export const supabase: SupabaseClient | null = isSupabaseConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: null;
|
||||
|
||||
// Log warning if not configured (for self-hosted installations)
|
||||
if (!isSupabaseConfigured) {
|
||||
console.warn(
|
||||
'Supabase is not configured. Checkout and billing features will be disabled. ' +
|
||||
'Static plan information will be displayed instead.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
export interface UpdateSummary {
|
||||
latest_version: string | null;
|
||||
latest_stable_version?: string | null;
|
||||
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
recommended_action?: string;
|
||||
any_breaking: boolean;
|
||||
migration_guides?: Array<{
|
||||
version: string;
|
||||
notes: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface VersionUpdate {
|
||||
version: string;
|
||||
priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
announcement: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
compatibility: {
|
||||
breaking_changes: boolean;
|
||||
breaking_description?: string;
|
||||
migration_guide_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FullUpdateInfo {
|
||||
latest_version: string;
|
||||
latest_stable_version?: string;
|
||||
new_versions: VersionUpdate[];
|
||||
}
|
||||
|
||||
export interface MachineInfo {
|
||||
machineType: string;
|
||||
activeSecurity: boolean;
|
||||
licenseType: string;
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates';
|
||||
|
||||
/**
|
||||
* Compare two version strings
|
||||
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
compareVersions(version1: string, version2: string): number {
|
||||
const v1 = version1.split('.');
|
||||
const v2 = version2.split('.');
|
||||
|
||||
for (let i = 0; i < v1.length || i < v2.length; i++) {
|
||||
const n1 = parseInt(v1[i]) || 0;
|
||||
const n2 = parseInt(v2[i]) || 0;
|
||||
|
||||
if (n1 > n2) {
|
||||
return 1;
|
||||
} else if (n1 < n2) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL based on machine type and security settings
|
||||
*/
|
||||
getDownloadUrl(machineInfo: MachineInfo): string | null {
|
||||
// Only show download for non-Docker installations
|
||||
if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = 'https://files.stirlingpdf.com/';
|
||||
|
||||
// Determine file based on machine type and security
|
||||
if (machineInfo.machineType === 'Server-jar') {
|
||||
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
|
||||
}
|
||||
|
||||
// Client installations
|
||||
if (machineInfo.machineType.startsWith('Client-')) {
|
||||
const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix
|
||||
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
|
||||
|
||||
if (os === 'unix') {
|
||||
return baseUrl + os + type + '.jar';
|
||||
} else if (os === 'win') {
|
||||
return baseUrl + os + '-installer.exe';
|
||||
} else if (os === 'mac') {
|
||||
return baseUrl + os + '-installer.dmg';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch update summary from API
|
||||
*/
|
||||
async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise<UpdateSummary | null> {
|
||||
// Map Java License enum to API types
|
||||
let type = 'normal';
|
||||
if (machineInfo.licenseType === 'PRO') {
|
||||
type = 'pro';
|
||||
} else if (machineInfo.licenseType === 'ENTERPRISE') {
|
||||
type = 'enterprise';
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`;
|
||||
console.log('Fetching update summary from:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data as UpdateSummary;
|
||||
} else {
|
||||
console.error('Failed to fetch update summary from Supabase:', response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch update summary from Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full update information with detailed version info
|
||||
*/
|
||||
async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise<FullUpdateInfo | null> {
|
||||
// Map Java License enum to API types
|
||||
let type = 'normal';
|
||||
if (machineInfo.licenseType === 'PRO') {
|
||||
type = 'pro';
|
||||
} else if (machineInfo.licenseType === 'ENTERPRISE') {
|
||||
type = 'enterprise';
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`;
|
||||
console.log('Fetching full update info from:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log('Full update response status:', response.status);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data as FullUpdateInfo;
|
||||
} else {
|
||||
console.error('Failed to fetch full update info from Supabase:', response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch full update info from Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version from GitHub build.gradle as fallback
|
||||
*/
|
||||
async getCurrentVersionFromGitHub(): Promise<string> {
|
||||
const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
|
||||
const match = versionRegex.exec(text);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
throw new Error('Version number not found');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest version from build.gradle:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateService = new UpdateService();
|
||||
@@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100;
|
||||
export const Z_INDEX_SELECTION_BOX = 1000;
|
||||
export const Z_INDEX_DROP_INDICATOR = 1001;
|
||||
export const Z_INDEX_DRAG_BADGE = 1001;
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation)
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
|
||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
||||
|
||||
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/use
|
||||
import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings";
|
||||
import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips";
|
||||
|
||||
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const addAttachmentsTips = useAddAttachmentsTips();
|
||||
|
||||
const params = useAddAttachmentsParameters();
|
||||
const operation = useAddAttachmentsOperation();
|
||||
@@ -64,6 +66,7 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
|
||||
isVisible: true,
|
||||
tooltip: addAttachmentsTips,
|
||||
content: (
|
||||
<AddAttachmentsSettings
|
||||
parameters={params.parameters}
|
||||
|
||||
@@ -9,21 +9,28 @@ import { useAutoRenameTips } from "@app/components/tooltips/useAutoRenameTips";
|
||||
|
||||
const AutoRename =(props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const autoRenameTips = useAutoRenameTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'"auto-rename-pdf-file',
|
||||
'auto-rename-pdf-file',
|
||||
useAutoRenameParameters,
|
||||
useAutoRenameOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()},
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
steps: [
|
||||
{
|
||||
title: t("auto-rename.settings.title", "About"),
|
||||
isCollapsed: false,
|
||||
tooltip: autoRenameTips,
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("auto-rename.submit", "Auto Rename"),
|
||||
isVisible: !base.hasResults,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotatio
|
||||
import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips";
|
||||
|
||||
const RemoveAnnotations = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const removeAnnotationsTips = useRemoveAnnotationsTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'removeAnnotations',
|
||||
@@ -26,6 +28,7 @@ const RemoveAnnotations = (props: BaseToolProps) => {
|
||||
title: t("removeAnnotations.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: removeAnnotationsTips,
|
||||
content: <RemoveAnnotationsSettings />,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -115,6 +115,32 @@ const Sign = (props: BaseToolProps) => {
|
||||
// Deactivate signature placement mode after everything completes
|
||||
handleDeactivateSignature();
|
||||
|
||||
const hasSignatureReady = (() => {
|
||||
const params = base.params.parameters;
|
||||
switch (params.signatureType) {
|
||||
case 'canvas':
|
||||
case 'image':
|
||||
return Boolean(params.signatureData);
|
||||
case 'text':
|
||||
return Boolean(params.signerName && params.signerName.trim() !== '');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (hasSignatureReady) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready.
|
||||
// However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload.
|
||||
// This value was empirically determined to be sufficient for most environments, but should be revisited.
|
||||
window.setTimeout(() => {
|
||||
handleActivateSignaturePlacement();
|
||||
}, 150);
|
||||
} else {
|
||||
handleActivateSignaturePlacement();
|
||||
}
|
||||
}
|
||||
|
||||
// File has been consumed - viewer should reload automatically via key prop
|
||||
} else {
|
||||
console.error('Signature flattening failed');
|
||||
@@ -122,7 +148,7 @@ const Sign = (props: BaseToolProps) => {
|
||||
} catch (error) {
|
||||
console.error('Error saving signed document:', error);
|
||||
}
|
||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
||||
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
||||
|
||||
const getSteps = () => {
|
||||
const steps = [];
|
||||
@@ -179,4 +205,4 @@ Sign.getDefaultParameters = () => ({
|
||||
signerName: '',
|
||||
});
|
||||
|
||||
export default Sign as ToolComponent;
|
||||
export default Sign as ToolComponent;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
|
||||
|
||||
export interface EndpointAvailabilityDetails {
|
||||
enabled: boolean;
|
||||
reason?: EndpointDisableReason;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface ProcessedFileMetadata {
|
||||
pages: ProcessedFilePage[];
|
||||
totalPages?: number;
|
||||
lastProcessed?: number;
|
||||
isEncrypted?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -301,6 +302,7 @@ export interface FileContextActions {
|
||||
trackBlobUrl: (url: string) => void;
|
||||
scheduleCleanup: (fileId: FileId, delay?: number) => void;
|
||||
cleanupFile: (fileId: FileId) => void;
|
||||
openEncryptedUnlockPrompt: (fileId: FileId) => void;
|
||||
}
|
||||
|
||||
// File selectors (separate from actions to avoid re-renders)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Browser identifier utility for anonymous usage tracking
|
||||
* Generates and persists a unique UUID in localStorage for WAU tracking
|
||||
*/
|
||||
|
||||
const BROWSER_ID_KEY = 'stirling_browser_id';
|
||||
|
||||
/**
|
||||
* Gets or creates a unique browser identifier
|
||||
* Used for Weekly Active Users (WAU) tracking in no-login mode
|
||||
*/
|
||||
export function getBrowserId(): string {
|
||||
try {
|
||||
// Try to get existing ID from localStorage
|
||||
let browserId = localStorage.getItem(BROWSER_ID_KEY);
|
||||
|
||||
if (!browserId) {
|
||||
// Generate new UUID v4
|
||||
browserId = generateUUID();
|
||||
localStorage.setItem(BROWSER_ID_KEY, browserId);
|
||||
}
|
||||
|
||||
return browserId;
|
||||
} catch (error) {
|
||||
// Fallback to session-based ID if localStorage is unavailable
|
||||
console.warn('localStorage unavailable, using session-based ID', error);
|
||||
return `session_${generateUUID()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// Use crypto.randomUUID if available (modern browsers)
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback to manual UUID generation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile, createChildStub } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
@@ -228,7 +229,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0)
|
||||
});
|
||||
} else if (annotation.type === 14 || annotation.type === 15) {
|
||||
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
|
||||
// Handle ink annotations (drawn signatures)
|
||||
page.drawRectangle({
|
||||
x: pdfX,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/constants/signConstants';
|
||||
|
||||
export interface SignaturePreview {
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
export const buildSignaturePreview = async (config: SignParameters | null): Promise<SignaturePreview | null> => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.signatureType === 'text') {
|
||||
const text = config.signerName?.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSize = config.fontSize ?? 16;
|
||||
const fontFamily = config.fontFamily ?? 'Helvetica';
|
||||
const textColor = config.textColor ?? '#000000';
|
||||
|
||||
const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO);
|
||||
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
|
||||
|
||||
const measureCanvas = document.createElement('canvas');
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
|
||||
if (!measureCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measureCtx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = measureCtx.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
|
||||
const width = Math.max(1, textWidth + paddingX * 2);
|
||||
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(text, paddingX, height / 2);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
return { dataUrl, width, height };
|
||||
}
|
||||
|
||||
const dataUrl = config.signatureData;
|
||||
if (!dataUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = await loadImage(dataUrl);
|
||||
return {
|
||||
dataUrl,
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata {
|
||||
pageCount: number;
|
||||
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
|
||||
pageDimensions?: Array<{ width: number; height: number }>;
|
||||
isEncrypted?: boolean;
|
||||
}
|
||||
|
||||
interface ColorScheme {
|
||||
@@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
|
||||
if (error instanceof Error && error.name === "PasswordException") {
|
||||
// Handle encrypted PDFs
|
||||
const thumbnail = generateEncryptedPDFThumbnail(file);
|
||||
return { thumbnail, pageCount: 1 };
|
||||
return { thumbnail, pageCount: 1, isEncrypted: true };
|
||||
}
|
||||
|
||||
const thumbnail = generatePlaceholderThumbnail(file);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Standardized error handling utilities for tool operations
|
||||
*/
|
||||
|
||||
import { normalizeAxiosErrorData } from '@app/services/errorUtils';
|
||||
|
||||
/**
|
||||
* Default error extractor that follows the standard pattern
|
||||
*/
|
||||
@@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => {
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles password-related errors with status code checking
|
||||
* @param error - The error object from axios
|
||||
* @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status)
|
||||
* @param fallbackMessage - Message to show for other errors
|
||||
* @returns Error message string
|
||||
*/
|
||||
export const handlePasswordError = async (
|
||||
error: any,
|
||||
incorrectPasswordMessage: string,
|
||||
fallbackMessage: string
|
||||
): Promise<string> => {
|
||||
const status = error?.response?.status;
|
||||
|
||||
// Handle specific error cases with user-friendly messages
|
||||
if (status === 500) {
|
||||
// 500 typically means incorrect password for encrypted PDFs
|
||||
return incorrectPasswordMessage;
|
||||
}
|
||||
|
||||
// For other errors, try to extract the message
|
||||
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);
|
||||
const errorWithNormalizedData = {
|
||||
...error,
|
||||
response: {
|
||||
...error?.response,
|
||||
data: normalizedData
|
||||
}
|
||||
};
|
||||
return extractErrorMessage(errorWithNormalizedData) || fallbackMessage;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user