Compare commits

..
Author SHA1 Message Date
Anthony Stirling 7ab37b4405 update 2025-11-18 20:58:26 +00:00
37 changed files with 434 additions and 3147 deletions
+2 -304
View File
@@ -14,7 +14,7 @@ on:
- macos
- linux
pull_request:
branches: [main, V2, V2-tauri-windows]
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
@@ -61,9 +61,6 @@ 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
@@ -177,84 +174,6 @@ 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:
@@ -310,174 +229,13 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
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' }}
SIGN: 1
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: |
@@ -511,66 +269,6 @@ 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:
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) |
| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) |
| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) |
| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) |
| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) |
| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) |
| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) |
| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) |
| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) |
| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) |
| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) |
| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) |
| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) |
| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) |
| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) |
| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) |
| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) |
| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) |
| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) |
| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) |
| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) |
| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) |
| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) |
| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) |
| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) |
| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) |
| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) |
| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) |
| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) |
| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) |
| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) |
| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) |
| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) |
| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) |
| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) |
| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) |
| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) |
| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) |
| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) |
| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) |
| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) |
| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) |
| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) |
| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) |
| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) |
| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) |
| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) |
| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) |
| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) |
| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) |
| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) |
| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) |
| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) |
| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) |
| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) |
| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) |
| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) |
| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) |
| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) |
| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) |
| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) |
| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
-258
View File
@@ -1,258 +0,0 @@
# 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)
@@ -1,49 +0,0 @@
package stirling.software.SPDF.config;
import java.io.IOException;
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 org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
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,24 +46,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.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")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -89,8 +73,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
"X-XSRF-TOKEN")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
@@ -115,8 +98,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
"X-XSRF-TOKEN")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
@@ -23,7 +23,6 @@ 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;
@@ -35,7 +34,6 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -354,35 +352,6 @@ 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();
@@ -1,100 +0,0 @@
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);
}
}
}
+6 -63
View File
@@ -2134,54 +2134,13 @@
"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",
"fontLabel": "Font",
"fontSizeLabel": "Font size",
"fontSizePlaceholder": "Type or select font size (8-200)",
"colorLabel": "Text colour"
"placeholder": "Enter your full name"
},
"clear": "Clear",
"add": "Add",
"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"
}
},
"saved": "Saved Signatures",
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
@@ -2200,18 +2159,12 @@
"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",
"saved": "Saved"
"text": "Text"
},
"image": {
"label": "Upload signature image",
@@ -2222,17 +2175,11 @@
"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.",
"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."
"text": "After entering your name above, click anywhere on the PDF to place your signature."
},
"mode": {
"move": "Move Signature",
"place": "Place Signature",
"pause": "Pause placement",
"resume": "Resume placement"
"place": "Place Signature"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
@@ -4831,9 +4778,6 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"colorPicker": {
"title": "Choose colour"
},
"common": {
"previous": "Previous",
"next": "Next",
@@ -4849,8 +4793,7 @@
"used": "used",
"available": "available",
"cancel": "Cancel",
"preview": "Preview",
"done": "Done"
"preview": "Preview"
},
"config": {
"overview": {
-5
View File
@@ -51,11 +51,6 @@
"desktopTemplate": "stirling-pdf.desktop"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { 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;
@@ -33,34 +32,10 @@ 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);
@@ -79,8 +54,6 @@ 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}
@@ -113,4 +86,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
/>
</Stack>
);
};
};
@@ -1,6 +1,5 @@
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;
@@ -15,16 +14,13 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
onClose,
selectedColor,
onColorChange,
title
title = "Choose Color"
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
return (
<Modal
opened={isOpen}
onClose={onClose}
title={resolvedTitle}
title={title}
size="sm"
centered
>
@@ -40,7 +36,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
/>
<Group justify="flex-end">
<Button onClick={onClose}>
{t('common.done', 'Done')}
Done
</Button>
</Group>
</Stack>
@@ -68,4 +64,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
onClick={onClick}
/>
);
};
};
@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
@@ -21,7 +20,6 @@ interface DrawingCanvasProps {
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
initialSignatureData?: string;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
@@ -36,14 +34,12 @@ 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 [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
@@ -59,18 +55,6 @@ 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;
}
}
};
@@ -120,35 +104,36 @@ 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);
renderPreview(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;
if (onDrawingComplete) {
onDrawingComplete();
@@ -172,7 +157,6 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
}
setSavedSignatureData(null); // Clear saved signature
onSignatureDataChange(null);
};
@@ -189,73 +173,67 @@ 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>
<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}
/>
<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">
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
Click to open drawing canvas
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Stack gap="md">
<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>
<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>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
@@ -264,12 +242,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
placeholder="Size"
size="compact-sm"
style={{ width: '80px' }}
style={{ width: '60px' }}
/>
</Stack>
</Group>
</div>
</div>
<PrivateContent>
<canvas
@@ -284,8 +262,8 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '50rem',
height: '25rem',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
@@ -293,10 +271,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
{t('sign.canvas.clear', 'Clear canvas')}
Clear Canvas
</Button>
<Button onClick={closeModal}>
{t('common.done', 'Done')}
Done
</Button>
</div>
</Stack>
@@ -1,7 +1,6 @@
import React from 'react';
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
import { Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
interface DrawingControlsProps {
onUndo?: () => void;
@@ -9,11 +8,8 @@ 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> = ({
@@ -22,48 +18,30 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
onPlaceSignature,
hasSignatureData = false,
disabled = false,
canUndo = true,
canRedo = true,
showPlaceButton = true,
placeButtonText = "Update and Place",
additionalControls,
placeButtonText = "Update and Place"
}) => {
const { t } = useTranslation();
const undoDisabled = disabled || !canUndo;
const redoDisabled = disabled || !canRedo;
return (
<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}
<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>
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
@@ -72,11 +50,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
ml="auto"
flex={1}
>
{placeButtonText}
</Button>
)}
</Group>
);
};
};
@@ -34,18 +34,12 @@ 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' },
@@ -56,15 +50,10 @@ 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)}
@@ -74,7 +63,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label={t('sign.text.fontLabel', 'Font')}
label="Font"
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
@@ -99,8 +88,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
label="Font Size"
placeholder="Type or select font size (8-200)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@@ -146,29 +135,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label={t('sign.text.colorLabel', 'Text colour')}
value={colorInput}
placeholder="#000000"
label="Text Color"
value={textColor}
readOnly
disabled={disabled}
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%' }}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{
width: 24,
height: 24,
@@ -195,4 +169,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
)}
</Stack>
);
};
};
@@ -1,292 +0,0 @@
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
@@ -14,7 +14,6 @@ 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 {
@@ -35,7 +34,6 @@ 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();
@@ -55,7 +53,7 @@ const EmbedPdfViewerContent = ({
}, [rotationState.rotation]);
// Get signature context
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
const { signatureApiRef, historyApiRef } = useSignature();
// Get current file from FileContext
const { selectors, state } = useFileState();
@@ -73,9 +71,6 @@ 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);
@@ -252,17 +247,15 @@ const EmbedPdfViewerContent = ({
) : (
<>
{/* EmbedPDF Viewer */}
<Box
ref={pdfContainerRef}
style={{
position: 'relative',
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
transition: 'margin-right 0.3s ease'
}}>
<Box 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}
@@ -275,11 +268,6 @@ const EmbedPdfViewerContent = ({
// Future: Handle signature completion
}}
/>
<SignaturePlacementOverlay
containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
</Box>
</>
)}
@@ -1,16 +1,14 @@
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '@app/contexts/SignatureContext';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { 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(() => {
@@ -20,58 +18,17 @@ 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 === PdfAnnotationSubtype.STAMP && annotation.id && annotation.imageSrc) {
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
const storedImageData = getImageData(annotation.id);
if (!storedImageData) {
if (!storedImageData || storedImageData !== annotation.imageSrc) {
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 === PdfAnnotationSubtype.STAMP && annotation.id) {
if (annotation && annotation.type === 13 && annotation.id) {
getImageData(annotation.id);
// Delay the check to allow the annotation to be fully created
@@ -104,12 +61,12 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
// Small delay to ensure deletion completes
setTimeout(() => {
annotationApi.createAnnotation(event.pageIndex, restoredData);
}, ANNOTATION_RECREATION_DELAY_MS);
}, 50);
} catch (error) {
console.error('HistoryAPI: Failed to restore annotation:', error);
}
}
}, ANNOTATION_VERIFICATION_DELAY_MS);
}, 100);
}
}
};
@@ -145,21 +102,6 @@ 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,32 +43,23 @@ export function PdfViewerToolbar({
// Register for immediate scroll updates and sync with actual scroll state
useEffect(() => {
const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => {
registerImmediateScrollUpdate((currentPage, _totalPages) => {
setPageInput(currentPage);
});
setPageInput(scrollState.currentPage);
return () => {
unregister?.();
};
}, [registerImmediateScrollUpdate, scrollState.currentPage]);
}, [registerImmediateScrollUpdate]);
// Register for immediate zoom updates and sync with actual zoom state
useEffect(() => {
const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent);
registerImmediateZoomUpdate(setDisplayZoomPercent);
setDisplayZoomPercent(zoomState.zoomPercent || 140);
return () => {
unregister?.();
};
}, [registerImmediateZoomUpdate, zoomState.zoomPercent]);
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
useEffect(() => {
const unregister = registerImmediateSpreadUpdate((_mode, isDual) => {
registerImmediateSpreadUpdate((_mode, isDual) => {
setIsDualPageActive(isDual);
});
setIsDualPageActive(spreadState.isDualPage);
return () => {
unregister?.();
};
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
const handleZoomOut = () => {
@@ -1,211 +1,12 @@
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react';
import { useImperativeHandle, forwardRef, useEffect } 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, 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]);
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
// Enable keyboard deletion of selected annotations
@@ -307,9 +108,58 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
activateSignaturePlacementMode: () => {
if (!annotationApi || !signatureConfig) return;
configureStampDefaults().catch((error) => {
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) {
console.error('Error activating signature tool:', error);
});
}
},
updateDrawSettings: (color: string, size: number) => {
@@ -346,7 +196,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 === PdfAnnotationSubtype.STAMP && annotation.imageSrc) {
if (annotation && annotation.type === 13 && annotation.imageSrc) {
// Store image data before deletion
storeImageData(annotationId, annotation.imageSrc);
}
@@ -380,61 +230,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return [];
}
},
}), [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]);
}), [annotationApi, signatureConfig]);
return null; // This is a bridge component with no UI
@@ -1,171 +0,0 @@
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,5 +21,4 @@ export interface HistoryAPI {
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
}
-5
View File
@@ -3,11 +3,6 @@
// 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}`;
@@ -1,15 +0,0 @@
// 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;
@@ -10,8 +10,6 @@ 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
@@ -28,7 +26,6 @@ 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
@@ -45,7 +42,6 @@ const initialState: SignatureState = {
signatureConfig: null,
isPlacementMode: false,
signaturesApplied: true, // Start as true (no signatures placed yet)
placementPreviewSize: null,
};
// Provider component
@@ -135,27 +131,6 @@ 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 = {
@@ -174,7 +149,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
storeImageData,
getImageData,
setSignaturesApplied,
setPlacementPreviewSize,
};
return (
+6 -15
View File
@@ -39,23 +39,14 @@ import {
import { SpreadMode } from '@embedpdf/plugin-spread/react';
function useImmediateNotifier<Args extends unknown[]>() {
const callbacksRef = useRef(new Set<(...args: Args) => void>());
const callbackRef = useRef<((...args: Args) => void) | null>(null);
const register = useCallback((callback: (...args: Args) => void) => {
callbacksRef.current.add(callback);
return () => {
callbacksRef.current.delete(callback);
};
callbackRef.current = callback;
}, []);
const trigger = useCallback((...args: Args) => {
callbacksRef.current.forEach(cb => {
try {
cb(...args);
} catch (error) {
console.error('Immediate callback error:', error);
}
});
callbackRef.current?.(...args);
}, []);
return { register, trigger };
@@ -100,9 +91,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;
@@ -1,212 +0,0 @@
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 || 'canvas');
formData.append('signatureType', params.signatureType || 'draw');
// 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' | 'canvas';
signatureType: 'image' | 'text' | 'draw' | '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,
});
};
};
@@ -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;
}
+2 -11
View File
@@ -1,14 +1,5 @@
import { AxiosInstance } from 'axios';
import { getBrowserId } from '@app/utils/browserIdentifier';
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)
);
export function setupApiInterceptors(_client: AxiosInstance): void {
// Core version: no interceptors to add
}
+2 -28
View File
@@ -115,32 +115,6 @@ 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');
@@ -148,7 +122,7 @@ const Sign = (props: BaseToolProps) => {
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
const getSteps = () => {
const steps = [];
@@ -205,4 +179,4 @@ Sign.getDefaultParameters = () => ({
signerName: '',
});
export default Sign as ToolComponent;
export default Sign as ToolComponent;
@@ -1,46 +0,0 @@
/**
* 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,5 +1,4 @@
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';
@@ -229,7 +228,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
size: 12,
color: rgb(0, 0, 0)
});
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
} else if (annotation.type === 14 || annotation.type === 15) {
// Handle ink annotations (drawn signatures)
page.drawRectangle({
x: pdfX,
@@ -1,84 +0,0 @@
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,
};
};
+8
View File
@@ -50,6 +50,7 @@ ignore = [
[ca_CA]
ignore = [
'adminUserSettings.admin',
'lang.amh',
'lang.ceb',
'lang.chr',
@@ -407,6 +408,8 @@ ignore = [
'endpointStatistics.top20',
'home.pipeline.title',
'language.direction',
'pipeline.title',
'pipelineOptions.pipelineHeader',
'pro',
'showJS.tags',
]
@@ -609,6 +612,7 @@ ignore = [
'lang.urd',
'lang.yor',
'language.direction',
'oops',
'sponsor',
]
@@ -784,6 +788,7 @@ ignore = [
'lang.urd',
'lang.uzb',
'language.direction',
'navbar.sections.security',
'text',
'watermark.type.1',
]
@@ -833,6 +838,7 @@ ignore = [
'endpointStatistics.top10',
'endpointStatistics.top20',
'font',
'info',
'lang.div',
'lang.epo',
'lang.hin',
@@ -890,6 +896,7 @@ ignore = [
'lang.tir',
'lang.uzb_cyrl',
'language.direction',
'pipelineOptions.pipelineHeader',
'showJS.tags',
]
@@ -980,5 +987,6 @@ ignore = [
[zh_TW]
ignore = [
'language.direction',
'poweredBy',
'showJS.tags',
]