This commit is contained in:
ConnorYoh 2025-07-22 18:34:20 +01:00 committed by GitHub
commit b99218bd4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 8618 additions and 568 deletions

View File

@ -0,0 +1 @@
allow-ghsas: GHSA-wrw7-89jp-8q8g

182
.github/workflows/README-tauri.md vendored Normal file
View File

@ -0,0 +1,182 @@
# Tauri Build Workflows
This directory contains GitHub Actions workflows for building Tauri desktop applications for Stirling-PDF.
## Workflow
### `tauri-build.yml` - Unified Build Workflow
**Purpose**: Build Tauri applications for all platforms (Windows, macOS, Linux) with comprehensive testing and validation.
**Triggers**:
- Manual dispatch with platform selection (windows, macos, linux, or all)
- Pull requests affecting Tauri-related files
- Pushes to main branch affecting Tauri-related files
**Platforms**:
- **Windows**: x86_64 (exe and msi)
- **macOS**: Apple Silicon (aarch64) and Intel (x86_64) (dmg)
- **Linux**: x86_64 (deb and AppImage)
**Features**:
- **Dynamic Platform Selection**: Choose specific platforms or build all
- **Smart JRE Bundling**: Uses JLink to create optimized custom JRE
- **Apple Code Signing**: Full macOS notarization and signing support
- **Comprehensive Validation**: Artifact verification and size checks
- **Self-Contained**: No Java installation required for end users
- **Cross-Platform**: Builds on actual target platforms for compatibility
- **Detailed Logging**: Complete build process visibility
## Usage
### Manual Testing
1. **Test All Platforms**:
```bash
# Go to Actions tab in GitHub
# Run "Build Tauri Applications" workflow
# Select "all" for platform
```
2. **Test Specific Platform**:
```bash
# Go to Actions tab in GitHub
# Run "Build Tauri Applications" workflow
# Select specific platform (windows/macos/linux)
```
3. **Automatic Testing**:
- Builds are automatically triggered on PRs and pushes
- All platforms are tested by default
- Artifacts are uploaded for download and testing
## Configuration
### Required Secrets
#### For macOS Code Signing (Required for distribution)
Configure these secrets in your repository for macOS app signing:
- `APPLE_CERTIFICATE`: Base64-encoded .p12 certificate file
- `APPLE_CERTIFICATE_PASSWORD`: Password for the .p12 certificate
- `APPLE_SIGNING_IDENTITY`: Certificate name (e.g., "Developer ID Application: Your Name")
- `APPLE_ID`: Your Apple ID email
- `APPLE_PASSWORD`: App-specific password for your Apple ID
- `APPLE_TEAM_ID`: Your Apple Developer Team ID
#### Setting Up Apple Code Signing
1. **Get a Developer ID Certificate**:
- Join the Apple Developer Program ($99/year)
- Create a "Developer ID Application" certificate in Apple Developer portal
- Download the certificate as a .p12 file
2. **Convert Certificate to Base64**:
```bash
base64 -i certificate.p12 | pbcopy
```
3. **Create App-Specific Password**:
- Go to appleid.apple.com → Sign-In and Security → App-Specific Passwords
- Generate a new password for "Tauri CI"
4. **Find Your Team ID**:
- Apple Developer portal → Membership → Team ID
5. **Add to GitHub Secrets**:
- Repository → Settings → Secrets and variables → Actions
- Add each secret with the exact names listed above
#### For General Tauri Signing (Optional)
- `TAURI_SIGNING_PRIVATE_KEY`: Private key for signing Tauri applications
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: Password for the signing private key
### File Structure
The workflows expect this structure:
```
├── frontend/
│ ├── src-tauri/
│ │ ├── Cargo.toml
│ │ ├── tauri.conf.json
│ │ └── src/
│ ├── package.json
│ └── src/
├── gradlew
└── stirling-pdf/
└── build/libs/
```
## Validation
Both workflows include comprehensive validation:
1. **Build Validation**: Ensures all expected artifacts are created
2. **Size Validation**: Checks artifacts aren't suspiciously small
3. **Platform Validation**: Verifies platform-specific requirements
4. **Integration Testing**: Tests that Java backend builds correctly
## Troubleshooting
### Common Issues
1. **Missing Dependencies**:
- Ubuntu: Ensure system dependencies are installed
- macOS: Check Rust toolchain targets
- Windows: Verify MSVC tools are available
2. **Java Backend Build Fails**:
- Check Gradle permissions (`chmod +x ./gradlew`)
- Verify JDK 21 is properly configured
3. **Artifact Size Issues**:
- Small artifacts usually indicate build failures
- Check that backend JAR is properly copied to Tauri resources
4. **Signing Issues**:
- Ensure signing secrets are configured if needed
- Check that signing keys are valid
### Debugging
1. **Check Logs**: Each step provides detailed logging
2. **Artifact Inspection**: Download artifacts to verify contents
3. **Local Testing**: Test builds locally before running workflows
## JLink Integration Benefits
The workflows now use JLink to create custom Java runtimes:
### **Self-Contained Applications**
- **No Java Required**: Users don't need Java installed
- **Consistent Runtime**: Same Java version across all deployments
- **Smaller Size**: Only includes needed Java modules (~30-50MB vs full JRE)
### **Security & Performance**
- **Minimal Attack Surface**: Only required modules included
- **Faster Startup**: Optimized runtime with stripped debug info
- **Better Compression**: JLink level 2 compression reduces size
### **Module Analysis**
- **Automatic Detection**: Uses `jdeps` to analyze JAR dependencies
- **Fallback Safety**: Predefined module list if analysis fails
- **Platform Optimized**: Different modules per platform if needed
## Integration with Existing Workflows
These workflows are designed to complement the existing build system:
- Uses same JDK and Gradle setup as `build.yml`
- Follows same security practices as `multiOSReleases.yml`
- Compatible with existing release processes
- Integrates JLink logic from `build-tauri-jlink.sh/bat` scripts
## Next Steps
1. Test the workflows on your branch
2. Verify all platforms build successfully
3. Check artifact quality and sizes
4. Configure signing if needed
5. Merge when all tests pass

View File

@ -25,3 +25,5 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: "Dependency Review"
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with:
config-file: './.github/config/dependency-review-config.yml'

329
.github/workflows/tauri-build.yml vendored Normal file
View File

@ -0,0 +1,329 @@
name: Build Tauri Applications
on:
workflow_dispatch:
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
pull_request:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions:
contents: read
jobs:
determine-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Determine build matrix
id: set-matrix
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For PR/push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build:
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- name: Build Java backend with JLink
working-directory: ./
shell: bash
run: |
chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..."
# STIRLING_PDF_DESKTOP_UI=false ./gradlew clean bootJar --no-daemon
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
if [ -n "$DETECTED_MODULES" ]; then
echo "📋 jdeps detected modules: $DETECTED_MODULES"
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
else
echo "⚠️ jdeps analysis failed, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
else
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink (always rebuild)
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
else
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: Verify Certificate
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
run: |
echo "Verifying Apple Developer Certificate..."
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
echo "Available disk space: $(df -h /tmp | tail -1)"
echo "macOS version: $(sw_vers -productVersion)"
echo "Available tools:"
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
SIGN: 1
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 7
- name: Verify build artifacts
shell: bash
run: |
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
echo "Checking for Windows artifacts..."
find . -name "*.exe" -o -name "*.msi" | head -5
if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
echo "❌ No Windows executable found"
exit 1
fi
elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found"
exit 1
fi
else
echo "Checking for Linux artifacts..."
find . -name "*.deb" -o -name "*.AppImage" | head -5
if [ $(find . -name "*.deb" -o -name "*.AppImage" | wc -l) -eq 0 ]; then
echo "❌ No Linux artifacts found"
exit 1
fi
fi
echo "✅ Build artifacts found for ${{ matrix.name }}"
- name: Test artifact sizes
shell: bash
run: |
cd ./frontend/src-tauri/target
echo "Artifact sizes for ${{ matrix.name }}:"
find . -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.msi" | while read file; do
if [ -f "$file" ]; then
size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
echo "$file: $size bytes"
# Check if file is suspiciously small (less than 1MB)
if [ "$size" != "unknown" ] && [ "$size" -lt 1048576 ]; then
echo "⚠️ Warning: $file is smaller than 1MB"
fi
fi
done
report:
needs: build
runs-on: ubuntu-latest
if: always()
steps:
- name: Report build results
run: |
if [ "${{ needs.build.result }}" = "success" ]; then
echo "✅ All Tauri builds completed successfully!"
echo "Artifacts are ready for distribution."
else
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi

View File

@ -0,0 +1,228 @@
# JLink Runtime Bundling for Stirling-PDF
This guide explains how to use JLink to bundle a custom Java runtime with your Tauri application, eliminating the need for users to have Java installed.
## Overview
Instead of requiring users to install a JRE separately, JLink creates a minimal, custom Java runtime that includes only the modules your application needs. This approach:
- **Eliminates JRE dependency**: Users don't need Java installed
- **Reduces size**: Only includes necessary Java modules
- **Improves security**: Minimal attack surface with fewer modules
- **Ensures consistency**: Same Java version across all deployments
## Prerequisites
- **JDK 17 or higher** (not just JRE - you need `jlink` command)
- **Node.js and npm** for the frontend
- **Rust and Tauri CLI** for building the desktop app
## Quick Start
### 1. Build with JLink
Run the appropriate build script for your platform:
**Linux/macOS:**
```bash
./scripts/build-tauri-jlink.sh
```
**Windows:**
```cmd
scripts\build-tauri-jlink.bat
```
### 2. Build Tauri Application
```bash
cd frontend
npm run tauri-build
```
The resulting application will include the bundled JRE and won't require Java to be installed on the target system.
## What the Build Script Does
1. **Builds the Stirling-PDF JAR** using Gradle
2. **Analyzes dependencies** using `jdeps` to determine required Java modules
3. **Creates custom JRE** using `jlink` with only necessary modules
4. **Copies files** to the correct Tauri directories:
- JAR file → `frontend/src-tauri/libs/`
- Custom JRE → `frontend/src-tauri/runtime/jre/`
5. **Creates test launchers** for standalone testing
## Directory Structure
After running the build script:
```
frontend/src-tauri/
├── libs/
│ └── Stirling-PDF-X.X.X.jar
├── runtime/
│ ├── jre/ # Custom JLink runtime
│ │ ├── bin/
│ │ │ ├── java(.exe)
│ │ │ └── ...
│ │ ├── lib/
│ │ └── ...
│ ├── launch-stirling.sh # Test launcher (Linux/macOS)
│ └── launch-stirling.bat # Test launcher (Windows)
└── tauri.conf.json # Already configured to bundle runtime
```
## Testing the Bundled Runtime
Before building the full Tauri app, you can test the bundled runtime:
**Linux/macOS:**
```bash
./frontend/src-tauri/runtime/launch-stirling.sh
```
**Windows:**
```cmd
frontend\src-tauri\runtime\launch-stirling.bat
```
This will start Stirling-PDF using the bundled JRE, accessible at http://localhost:8080
## Configuration Details
### Tauri Configuration (`tauri.conf.json`)
The bundle resources are configured to include both the JAR and runtime:
```json
{
"bundle": {
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
]
}
}
```
### Gradle Configuration (`build.gradle`)
JLink options are configured in the jpackage section:
```gradle
jLinkOptions = [
"--strip-debug",
"--compress=2",
"--no-header-files",
"--no-man-pages"
]
addModules = [
"java.base",
"java.desktop",
"java.logging",
"java.management",
// ... other required modules
]
```
### Rust Code (`lib.rs`)
The application automatically detects and uses the bundled JRE instead of system Java.
## Modules Included
The custom runtime includes these Java modules:
- `java.base` - Core Java functionality
- `java.desktop` - AWT/Swing (for UI components)
- `java.instrument` - Java instrumentation (required by Jetty)
- `java.logging` - Logging framework
- `java.management` - JMX and monitoring
- `java.naming` - JNDI services
- `java.net.http` - HTTP client
- `java.security.jgss` - Security services
- `java.sql` - Database connectivity
- `java.xml` - XML processing
- `java.xml.crypto` - XML security
- `jdk.crypto.ec` - Elliptic curve cryptography
- `jdk.crypto.cryptoki` - PKCS#11 support
- `jdk.unsupported` - Internal APIs (used by some libraries)
## Troubleshooting
### JLink Not Found
```
❌ jlink is not available
```
**Solution**: Install a full JDK (not just JRE). JLink is included with JDK 9+.
### Module Not Found During Runtime
If the application fails with module-related errors, you may need to add additional modules to the `addModules` list in `build.gradle`.
### Large Runtime Size
The bundled runtime should be 50-80MB. If it's much larger:
- Ensure `--strip-debug` and `--compress=2` options are used
- Review the module list - remove unnecessary modules
- Consider using `--no-header-files` and `--no-man-pages`
## Benefits Over Traditional JAR Approach
| Aspect | Traditional JAR | JLink Bundle |
|--------|----------------|--------------|
| User Setup | Requires JRE installation | No Java installation needed |
| Distribution Size | Smaller JAR, but requires ~200MB JRE | Larger bundle (~80MB), but self-contained |
| Java Version | Depends on user's installed version | Consistent, controlled version |
| Security Updates | User manages JRE updates | Developer controls runtime version |
| Startup Time | May be faster (shared JRE) | Slightly slower (isolated runtime) |
## Advanced Usage
### Custom Module Analysis
To analyze your specific JAR's module requirements:
```bash
jdeps --print-module-deps --ignore-missing-deps build/libs/Stirling-PDF-*.jar
```
### Manual JLink Command
If you want to create the runtime manually:
```bash
jlink \
--add-modules java.base,java.desktop,java.logging,java.management,java.naming,java.net.http,java.security.jgss,java.sql,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output frontend/src-tauri/runtime/jre
```
## Migration Guide
### From JAR-based Tauri App
1. Update your Tauri configuration to include the runtime resources
2. Update your Rust code to use the bundled JRE path
3. Run the JLink build script
4. Test the bundled runtime
5. Build and distribute the new self-contained app
### Deployment
The final Tauri application will be completely self-contained. Users can:
- Install the app normally (no Java installation required)
- Run the app immediately after installation
- Not worry about Java version compatibility issues
## Support
If you encounter issues with the JLink bundling:
1. Ensure you have a JDK (not JRE) installed
2. Check that the Java version is 17 or higher
3. Verify that the build script completed successfully
4. Test the bundled runtime using the provided launcher scripts
5. Check the Tauri build logs for any missing resources

View File

@ -32,6 +32,12 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Docker for containerization
- Gradle for build management
**Desktop Application (Tauri):**
- Tauri for cross-platform desktop app packaging
- Rust backend for system integration
- PDF file association support
- Self-contained JRE bundling with JLink
**Legacy (reference only during development):**
- Thymeleaf templates (being completely replaced in 2.0)
@ -44,6 +50,8 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Java JDK 17 or later (JDK 21 recommended)
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
- Rust and Cargo (required for Tauri desktop app development)
- Tauri CLI (install with `cargo install tauri-cli`)
### Setup Steps
@ -95,6 +103,14 @@ Stirling 2.0 uses client-side file storage:
### Legacy Code Reference
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
### Tauri Desktop App Development
Stirling-PDF can be packaged as a cross-platform desktop application using Tauri with PDF file association support and bundled JRE:
**Quick Start:**
1. **Development/Testing**: `npm run tauri-dev "path/to/test.pdf"`
2. **Building**: See [DesktopApplicationDevelopmentGuide.md](DesktopApplicationDevelopmentGuide.md) for complete build instructions
3. **Features**: File associations, self-contained JRE, cross-platform support
## 5. Project Structure
```bash
@ -109,6 +125,12 @@ Stirling-PDF/
│ │ ├── services/ # API and utility services
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── src-tauri/ # Tauri desktop app configuration
│ │ ├── src/ # Rust backend code
│ │ ├── libs/ # JAR files (generated by build scripts)
│ │ ├── runtime/ # Bundled JRE (generated by build scripts)
│ │ ├── Cargo.toml # Rust dependencies
│ │ └── tauri.conf.json # Tauri configuration
│ ├── public/
│ │ └── locales/ # Internationalization files (JSON)
│ ├── package.json # Frontend dependencies

View File

@ -29,7 +29,6 @@ dependencies {
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
&& project.getProperty('STIRLING_PDF_DESKTOP_UI') != 'false')) {
implementation 'me.friwi:jcefmaven:132.3.1'
implementation 'org.openjfx:javafx-controls:21'
implementation 'org.openjfx:javafx-swing:21'
}

View File

@ -151,6 +151,13 @@ public class SPDFApplication {
serverPortStatic = serverPort;
String url = baseUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
String parentPid = System.getenv("TAURI_PARENT_PID");
log.info(
"Running in Tauri mode. Parent process PID: {}",
parentPid != null ? parentPid : "not set");
}
if (webBrowser != null
&& Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
webBrowser.initWebUI(url);

View File

@ -1,497 +0,0 @@
package stirling.software.SPDF.UI.impl;
import java.awt.AWTException;
import java.awt.BorderLayout;
import java.awt.Frame;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.event.WindowEvent;
import java.awt.event.WindowStateListener;
import java.io.File;
import java.io.InputStream;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import org.cef.CefApp;
import org.cef.CefClient;
import org.cef.CefSettings;
import org.cef.browser.CefBrowser;
import org.cef.callback.CefBeforeDownloadCallback;
import org.cef.callback.CefDownloadItem;
import org.cef.callback.CefDownloadItemCallback;
import org.cef.handler.CefDownloadHandlerAdapter;
import org.cef.handler.CefLoadHandlerAdapter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import me.friwi.jcefmaven.CefAppBuilder;
import me.friwi.jcefmaven.EnumProgress;
import me.friwi.jcefmaven.MavenCefAppHandlerAdapter;
import me.friwi.jcefmaven.impl.progress.ConsoleProgressHandler;
import stirling.software.SPDF.UI.WebBrowser;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.util.UIScaling;
@Component
@Slf4j
@ConditionalOnProperty(
name = "STIRLING_PDF_DESKTOP_UI",
havingValue = "true",
matchIfMissing = false)
public class DesktopBrowser implements WebBrowser {
private static CefApp cefApp;
private static CefClient client;
private static CefBrowser browser;
private static JFrame frame;
private static LoadingWindow loadingWindow;
private static volatile boolean browserInitialized = false;
private static TrayIcon trayIcon;
private static SystemTray systemTray;
public DesktopBrowser() {
SwingUtilities.invokeLater(
() -> {
loadingWindow = new LoadingWindow(null, "Initializing...");
loadingWindow.setVisible(true);
});
}
public void initWebUI(String url) {
CompletableFuture.runAsync(
() -> {
try {
CefAppBuilder builder = new CefAppBuilder();
configureCefSettings(builder);
builder.setProgressHandler(createProgressHandler());
builder.setInstallDir(
new File(InstallationPathConfig.getClientWebUIPath()));
// Build and initialize CEF
cefApp = builder.build();
client = cefApp.createClient();
// Set up download handler
setupDownloadHandler();
// Create browser and frame on EDT
SwingUtilities.invokeAndWait(
() -> {
browser = client.createBrowser(url, false, false);
setupMainFrame();
setupLoadHandler();
// Force initialize UI after 7 seconds if not already done
Timer timeoutTimer =
new Timer(
2500,
e -> {
log.warn(
"Loading timeout reached. Forcing"
+ " UI transition.");
if (!browserInitialized) {
// Force UI initialization
forceInitializeUI();
}
});
timeoutTimer.setRepeats(false);
timeoutTimer.start();
});
} catch (Exception e) {
log.error("Error initializing JCEF browser: ", e);
cleanup();
}
});
}
private void configureCefSettings(CefAppBuilder builder) {
CefSettings settings = builder.getCefSettings();
String basePath = InstallationPathConfig.getClientWebUIPath();
log.info("basePath " + basePath);
settings.cache_path = new File(basePath + "cache").getAbsolutePath();
settings.root_cache_path = new File(basePath + "root_cache").getAbsolutePath();
// settings.browser_subprocess_path = new File(basePath +
// "subprocess").getAbsolutePath();
// settings.resources_dir_path = new File(basePath + "resources").getAbsolutePath();
// settings.locales_dir_path = new File(basePath + "locales").getAbsolutePath();
settings.log_file = new File(basePath, "debug.log").getAbsolutePath();
settings.persist_session_cookies = true;
settings.windowless_rendering_enabled = false;
settings.log_severity = CefSettings.LogSeverity.LOGSEVERITY_INFO;
builder.setAppHandler(
new MavenCefAppHandlerAdapter() {
@Override
public void stateHasChanged(org.cef.CefApp.CefAppState state) {
log.info("CEF state changed: " + state);
if (state == CefApp.CefAppState.TERMINATED) {
System.exit(0);
}
}
});
}
private void setupDownloadHandler() {
client.addDownloadHandler(
new CefDownloadHandlerAdapter() {
@Override
public boolean onBeforeDownload(
CefBrowser browser,
CefDownloadItem downloadItem,
String suggestedName,
CefBeforeDownloadCallback callback) {
callback.Continue("", true);
return true;
}
@Override
public void onDownloadUpdated(
CefBrowser browser,
CefDownloadItem downloadItem,
CefDownloadItemCallback callback) {
if (downloadItem.isComplete()) {
log.info("Download completed: " + downloadItem.getFullPath());
} else if (downloadItem.isCanceled()) {
log.info("Download canceled: " + downloadItem.getFullPath());
}
}
});
}
private ConsoleProgressHandler createProgressHandler() {
return new ConsoleProgressHandler() {
@Override
public void handleProgress(EnumProgress state, float percent) {
Objects.requireNonNull(state, "state cannot be null");
SwingUtilities.invokeLater(
() -> {
if (loadingWindow != null) {
switch (state) {
case LOCATING:
loadingWindow.setStatus("Locating Files...");
loadingWindow.setProgress(0);
break;
case DOWNLOADING:
if (percent >= 0) {
loadingWindow.setStatus(
String.format(
"Downloading additional files: %.0f%%",
percent));
loadingWindow.setProgress((int) percent);
}
break;
case EXTRACTING:
loadingWindow.setStatus("Extracting files...");
loadingWindow.setProgress(60);
break;
case INITIALIZING:
loadingWindow.setStatus("Initializing UI...");
loadingWindow.setProgress(80);
break;
case INITIALIZED:
loadingWindow.setStatus("Finalising startup...");
loadingWindow.setProgress(90);
break;
}
}
});
}
};
}
private void setupMainFrame() {
frame = new JFrame("Stirling-PDF");
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
frame.setUndecorated(true);
frame.setOpacity(0.0f);
JPanel contentPane = new JPanel(new BorderLayout());
contentPane.setDoubleBuffered(true);
contentPane.add(browser.getUIComponent(), BorderLayout.CENTER);
frame.setContentPane(contentPane);
frame.addWindowListener(
new java.awt.event.WindowAdapter() {
@Override
public void windowClosing(java.awt.event.WindowEvent windowEvent) {
cleanup();
System.exit(0);
}
});
frame.setSize(UIScaling.scaleWidth(1280), UIScaling.scaleHeight(800));
frame.setLocationRelativeTo(null);
loadIcon();
}
private void setupLoadHandler() {
final long initStartTime = System.currentTimeMillis();
log.info("Setting up load handler at: {}", initStartTime);
client.addLoadHandler(
new CefLoadHandlerAdapter() {
@Override
public void onLoadingStateChange(
CefBrowser browser,
boolean isLoading,
boolean canGoBack,
boolean canGoForward) {
log.debug(
"Loading state change - isLoading: {}, canGoBack: {}, canGoForward:"
+ " {}, browserInitialized: {}, Time elapsed: {}ms",
isLoading,
canGoBack,
canGoForward,
browserInitialized,
System.currentTimeMillis() - initStartTime);
if (!isLoading && !browserInitialized) {
log.info(
"Browser finished loading, preparing to initialize UI"
+ " components");
browserInitialized = true;
SwingUtilities.invokeLater(
() -> {
try {
if (loadingWindow != null) {
log.info("Starting UI initialization sequence");
// Close loading window first
loadingWindow.setVisible(false);
loadingWindow.dispose();
loadingWindow = null;
log.info("Loading window disposed");
// Then setup the main frame
frame.setVisible(false);
frame.dispose();
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.pack();
frame.setSize(
UIScaling.scaleWidth(1280),
UIScaling.scaleHeight(800));
frame.setLocationRelativeTo(null);
log.debug("Frame reconfigured");
// Show the main frame
frame.setVisible(true);
frame.requestFocus();
frame.toFront();
log.info("Main frame displayed and focused");
// Focus the browser component
Timer focusTimer =
new Timer(
100,
e -> {
try {
browser.getUIComponent()
.requestFocus();
log.info(
"Browser component"
+ " focused");
} catch (Exception ex) {
log.error(
"Error focusing"
+ " browser",
ex);
}
});
focusTimer.setRepeats(false);
focusTimer.start();
}
} catch (Exception e) {
log.error("Error during UI initialization", e);
// Attempt cleanup on error
if (loadingWindow != null) {
loadingWindow.dispose();
loadingWindow = null;
}
if (frame != null) {
frame.setVisible(true);
frame.requestFocus();
}
}
});
}
}
});
}
private void setupTrayIcon(Image icon) {
if (!SystemTray.isSupported()) {
log.warn("System tray is not supported");
return;
}
try {
systemTray = SystemTray.getSystemTray();
// Create popup menu
PopupMenu popup = new PopupMenu();
// Create menu items
MenuItem showItem = new MenuItem("Show");
showItem.addActionListener(
e -> {
frame.setVisible(true);
frame.setState(Frame.NORMAL);
});
MenuItem exitItem = new MenuItem("Exit");
exitItem.addActionListener(
e -> {
cleanup();
System.exit(0);
});
// Add menu items to popup menu
popup.add(showItem);
popup.addSeparator();
popup.add(exitItem);
// Create tray icon
trayIcon = new TrayIcon(icon, "Stirling-PDF", popup);
trayIcon.setImageAutoSize(true);
// Add double-click behavior
trayIcon.addActionListener(
e -> {
frame.setVisible(true);
frame.setState(Frame.NORMAL);
});
// Add tray icon to system tray
systemTray.add(trayIcon);
// Modify frame behavior to minimize to tray
frame.addWindowStateListener(
new WindowStateListener() {
public void windowStateChanged(WindowEvent e) {
if (e.getNewState() == Frame.ICONIFIED) {
frame.setVisible(false);
}
}
});
} catch (AWTException e) {
log.error("Error setting up system tray icon", e);
}
}
private void loadIcon() {
try {
Image icon = null;
String[] iconPaths = {"/static/favicon.ico"};
for (String path : iconPaths) {
if (icon != null) break;
try {
try (InputStream is = getClass().getResourceAsStream(path)) {
if (is != null) {
icon = ImageIO.read(is);
break;
}
}
} catch (Exception e) {
log.debug("Could not load icon from " + path, e);
}
}
if (icon != null) {
frame.setIconImage(icon);
setupTrayIcon(icon);
} else {
log.warn("Could not load icon from any source");
}
} catch (Exception e) {
log.error("Error loading icon", e);
}
}
@PreDestroy
public void cleanup() {
if (browser != null) browser.close(true);
if (client != null) client.dispose();
if (cefApp != null) cefApp.dispose();
if (loadingWindow != null) loadingWindow.dispose();
}
public static void forceInitializeUI() {
try {
if (loadingWindow != null) {
log.info("Forcing start of UI initialization sequence");
// Close loading window first
loadingWindow.setVisible(false);
loadingWindow.dispose();
loadingWindow = null;
log.info("Loading window disposed");
// Then setup the main frame
frame.setVisible(false);
frame.dispose();
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.pack();
frame.setSize(UIScaling.scaleWidth(1280), UIScaling.scaleHeight(800));
frame.setLocationRelativeTo(null);
log.debug("Frame reconfigured");
// Show the main frame
frame.setVisible(true);
frame.requestFocus();
frame.toFront();
log.info("Main frame displayed and focused");
// Focus the browser component if available
if (browser != null) {
Timer focusTimer =
new Timer(
100,
e -> {
try {
browser.getUIComponent().requestFocus();
log.info("Browser component focused");
} catch (Exception ex) {
log.error(
"Error focusing browser during force ui"
+ " initialization.",
ex);
}
});
focusTimer.setRepeats(false);
focusTimer.start();
}
}
} catch (Exception e) {
log.error("Error during Forced UI initialization.", e);
// Attempt cleanup on error
if (loadingWindow != null) {
loadingWindow.dispose();
loadingWindow = null;
}
if (frame != null) {
frame.setVisible(true);
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.requestFocus();
}
}
}
}

View File

@ -0,0 +1,157 @@
package stirling.software.SPDF.config;
import java.lang.management.ManagementFactory;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
/**
* Monitor for Tauri parent process to detect orphaned Java backend processes. When running in Tauri
* mode, this component periodically checks if the parent Tauri process is still alive. If the
* parent process terminates unexpectedly, this will trigger a graceful shutdown of the Java backend
* to prevent orphaned processes.
*/
@Component
@ConditionalOnProperty(name = "STIRLING_PDF_TAURI_MODE", havingValue = "true")
public class TauriProcessMonitor {
private static final Logger logger = LoggerFactory.getLogger(TauriProcessMonitor.class);
private final ApplicationContext applicationContext;
private String parentProcessId;
private ScheduledExecutorService scheduler;
private volatile boolean monitoring = false;
public TauriProcessMonitor(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@PostConstruct
public void init() {
parentProcessId = System.getenv("TAURI_PARENT_PID");
if (parentProcessId != null && !parentProcessId.trim().isEmpty()) {
logger.info("Tauri mode detected. Parent process ID: {}", parentProcessId);
startMonitoring();
} else {
logger.warn(
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring disabled.");
}
}
private void startMonitoring() {
scheduler =
Executors.newSingleThreadScheduledExecutor(
r -> {
Thread t = new Thread(r, "tauri-process-monitor");
t.setDaemon(true);
return t;
});
monitoring = true;
// Check every 5 seconds
scheduler.scheduleAtFixedRate(this::checkParentProcess, 5, 5, TimeUnit.SECONDS);
logger.info("Started monitoring parent Tauri process (PID: {})", parentProcessId);
}
private void checkParentProcess() {
if (!monitoring) {
return;
}
try {
if (!isProcessAlive(parentProcessId)) {
logger.warn(
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful shutdown...",
parentProcessId);
initiateGracefulShutdown();
}
} catch (Exception e) {
logger.error("Error checking parent process status", e);
}
}
private boolean isProcessAlive(String pid) {
try {
long processId = Long.parseLong(pid);
// Check if process exists using ProcessHandle (Java 9+)
return ProcessHandle.of(processId).isPresent();
} catch (NumberFormatException e) {
logger.error("Invalid parent process ID format: {}", pid);
return false;
} catch (Exception e) {
logger.error("Error checking if process {} is alive", pid, e);
return false;
}
}
private void initiateGracefulShutdown() {
monitoring = false;
logger.info("Orphaned Java backend detected. Shutting down gracefully...");
// Shutdown asynchronously to avoid blocking the monitor thread
CompletableFuture.runAsync(
() -> {
try {
// Give a small delay to ensure logging completes
Thread.sleep(1000);
if (applicationContext instanceof ConfigurableApplicationContext) {
((ConfigurableApplicationContext) applicationContext).close();
} else {
// Fallback to system exit
logger.warn(
"Unable to shutdown Spring context gracefully, using System.exit");
System.exit(0);
}
} catch (Exception e) {
logger.error("Error during graceful shutdown", e);
System.exit(1);
}
});
}
@PreDestroy
public void cleanup() {
monitoring = false;
if (scheduler != null && !scheduler.isShutdown()) {
logger.info("Shutting down Tauri process monitor");
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
/** Get the current Java process ID for logging/debugging purposes */
public static String getCurrentProcessId() {
try {
return ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
} catch (Exception e) {
return "unknown";
}
}
}

View File

@ -1,6 +1,7 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -28,4 +29,17 @@ public class WebMvcConfig implements WebMvcConfigurer {
"file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/");
// .setCachePeriod(0); // Optional: disable caching
}
@Override
public void addCorsMappings(CorsRegistry registry) {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
// Tauri mode CORS configuration
registry.addMapping("/**")
.allowedOrigins(
"http://localhost:5173", "http://tauri.localhost", "tauri://localhost")
.allowedMethods("*")
.allowedHeaders("*");
return;
}
}
}

View File

@ -6,5 +6,13 @@ VITE_API_BASE_URL=${VITE_API_BASE_URL:-"http://backend:8080"}
# Replace the placeholder in nginx.conf with the actual backend URL
sed -i "s|\${VITE_API_BASE_URL}|${VITE_API_BASE_URL}|g" /etc/nginx/nginx.conf
# Inject runtime configuration into config.js
cat > /usr/share/nginx/html/config.js << EOF
// Runtime configuration - injected at container startup
window.runtimeConfig = {
apiBaseUrl: '${VITE_API_BASE_URL}'
};
EOF
# Start nginx
exec nginx -g "daemon off;"

View File

@ -72,3 +72,15 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/d
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
## Tauri
### Dev
To run Tauri in development. Use the command:
````npm run tauri-dev```
This will run the gradle runboot command and the tauri dev command concurrently, starting the app once both are stable.
### Build
To build a deployment of the Tauri app. Use the command:
```npm run tauri-build```
This will bundle the backend and frontend into one executable for each target. Targets can be set within the `tauri.conf.json` file.

View File

@ -12,11 +12,12 @@
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Vite App</title>
<title>Stirling-PDF</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

View File

@ -17,6 +17,8 @@
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@ -37,6 +39,7 @@
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
@ -2019,6 +2022,242 @@
"tailwindcss": "4.1.8"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.6.0.tgz",
"integrity": "sha512-hRNcdercfgpzgFrMXWwNDBN0B7vNzOzRepy6ZAmhxi5mDLVPNrTpo9MGg2tN/F7JRugj4d2aF7E1rtPXAHaetg==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.5.0.tgz",
"integrity": "sha512-rAtHqG0Gh/IWLjN2zTf3nZqYqbo81oMbqop56rGTjrlWk9pTTAjkqOjSL9XQLIMZ3RbeVjveCqqCA0s8RnLdMg==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.5.0",
"@tauri-apps/cli-darwin-x64": "2.5.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.5.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.5.0",
"@tauri-apps/cli-linux-arm64-musl": "2.5.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.5.0",
"@tauri-apps/cli-linux-x64-gnu": "2.5.0",
"@tauri-apps/cli-linux-x64-musl": "2.5.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.5.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.5.0",
"@tauri-apps/cli-win32-x64-msvc": "2.5.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz",
"integrity": "sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz",
"integrity": "sha512-hUF01sC06cZVa8+I0/VtsHOk9BbO75rd+YdtHJ48xTdcYaQ5QIwL4yZz9OR1AKBTaUYhBam8UX9Pvd5V2/4Dpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.5.0.tgz",
"integrity": "sha512-LQKqttsK252LlqYyX8R02MinUsfFcy3+NZiJwHFgi5Y3+ZUIAED9cSxJkyNtuY5KMnR4RlpgWyLv4P6akN1xhg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.5.0.tgz",
"integrity": "sha512-mTQufsPcpdHg5RW0zypazMo4L55EfeE5snTzrPqbLX4yCK2qalN7+rnP8O8GT06xhp6ElSP/Ku1M2MR297SByQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz",
"integrity": "sha512-rQO1HhRUQqyEaal5dUVOQruTRda/TD36s9kv1hTxZiFuSq3558lsTjAcUEnMAtBcBkps20sbyTJNMT0AwYIk8Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.5.0.tgz",
"integrity": "sha512-7oS18FN46yDxyw1zX/AxhLAd7T3GrLj3Ai6s8hZKd9qFVzrAn36ESL7d3G05s8wEtsJf26qjXnVF4qleS3dYsA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.5.0.tgz",
"integrity": "sha512-SG5sFNL7VMmDBdIg3nO3EzNRT306HsiEQ0N90ILe3ZABYAVoPDO/ttpCO37ApLInTzrq/DLN+gOlC/mgZvLw1w==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz",
"integrity": "sha512-QXDM8zp/6v05PNWju5ELsVwF0VH1n6b5pk2E6W/jFbbiwz80Vs1lACl9pv5kEHkrxBj+aWU/03JzGuIj2g3SkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.5.0.tgz",
"integrity": "sha512-pFSHFK6b+o9y4Un8w0gGLwVyFTZaC3P0kQ7umRt/BLDkzD5RnQ4vBM7CF8BCU5nkwmEBUCZd7Wt3TWZxe41o6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.5.0.tgz",
"integrity": "sha512-EArv1IaRlogdLAQyGlKmEqZqm5RfHCUMhJoedWu7GtdbOMUfSAz6FMX2boE1PtEmNO4An+g188flLeVErrxEKg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.5.0.tgz",
"integrity": "sha512-lj43EFYbnAta8pd9JnUq87o+xRUR0odz+4rixBtTUwUgdRdwQ2V9CzFtsMu6FQKpFQ6mujRK6P1IEwhL6ADRsQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.0.tgz",
"integrity": "sha512-Sp8AdDcbyXyk6LD6Pmdx44SH3LPeNAvxR2TFfq/8CwqzfO1yOyV+RzT8fov0NNN7d9nvW7O7MtMAptJ42YXA5g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.6.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@ -2629,6 +2868,21 @@
"node": ">=10"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -4719,21 +4973,6 @@
"postcss": "^8.0.0"
}
},
"node_modules/postcss-cli/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/postcss-cli/node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
@ -4815,35 +5054,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/postcss-cli/node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/postcss-cli/node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/postcss-js": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
@ -6341,6 +6551,35 @@
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

View File

@ -13,6 +13,8 @@
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@ -36,6 +38,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build",
"generate-licenses": "node scripts/generate-licenses.js"
},
"eslintConfig": {
@ -57,6 +61,7 @@
]
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",

View File

@ -0,0 +1,4 @@
// Runtime configuration - injected at container startup
window.runtimeConfig = {
apiBaseUrl: 'http://localhost:8080'
};

5
frontend/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
/runtime/

5420
frontend/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
[package]
name = "stirling-pdf"
version = "0.1.0"
description = "Stirling-PDF Desktop Application"
authors = ["Stirling-PDF Contributors"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.5.0", features = [] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.0.0"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
# macOS-specific dependencies for native file opening
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
cocoa = "0.24"
once_cell = "1.19"

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rainbow" viewBox="0 0 16 16"><path d="M8 4.5a7 7 0 0 0-7 7 .5.5 0 0 1-1 0 8 8 0 1 1 16 0 .5.5 0 0 1-1 0 7 7 0 0 0-7-7zm0 2a5 5 0 0 0-5 5 .5.5 0 0 1-1 0 6 6 0 1 1 12 0 .5.5 0 0 1-1 0 5 5 0 0 0-5-5zm0 2a3 3 0 0 0-3 3 .5.5 0 0 1-1 0 4 4 0 1 1 8 0 .5.5 0 0 1-1 0 3 3 0 0 0-3-3zm0 2a1 1 0 0 0-1 1 .5.5 0 0 1-1 0 2 2 0 1 1 4 0 .5.5 0 0 1-1 0 1 1 0 0 0-1-1z"/></svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@ -0,0 +1,379 @@
use tauri_plugin_shell::ShellExt;
use tauri::Manager;
use std::sync::Mutex;
use std::path::PathBuf;
use crate::utils::add_log;
// Store backend process handle globally
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
// Helper function to reset starting flag
fn reset_starting_flag() {
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
*starting_guard = false;
}
// Check if backend is already running or starting
fn check_backend_status() -> Result<(), String> {
// Check if backend is already running
{
let process_guard = BACKEND_PROCESS.lock().unwrap();
if process_guard.is_some() {
add_log("⚠️ Backend process already running, skipping start".to_string());
return Err("Backend already running".to_string());
}
}
// Check and set starting flag to prevent multiple simultaneous starts
{
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
if *starting_guard {
add_log("⚠️ Backend already starting, skipping duplicate start".to_string());
return Err("Backend startup already in progress".to_string());
}
*starting_guard = true;
}
Ok(())
}
// Find the bundled JRE and return the java executable path
fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
let jre_dir = resource_dir.join("runtime").join("jre");
let java_executable = if cfg!(windows) {
jre_dir.join("bin").join("java.exe")
} else {
jre_dir.join("bin").join("java")
};
if !java_executable.exists() {
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
add_log(error_msg.clone());
return Err(error_msg);
}
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
Ok(java_executable)
}
// Find the Stirling-PDF JAR file
fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
let libs_dir = resource_dir.join("libs");
let mut jar_files: Vec<_> = std::fs::read_dir(&libs_dir)
.map_err(|e| {
let error_msg = format!("Failed to read libs directory: {}. Make sure the JAR is copied to libs/", e);
add_log(error_msg.clone());
error_msg
})?
.filter_map(|entry| entry.ok())
.filter(|entry| {
let path = entry.path();
// Match any .jar file containing "stirling-pdf" (case-insensitive)
path.extension().and_then(|s| s.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")).unwrap_or(false)
&& path.file_name()
.and_then(|f| f.to_str())
.map(|name| name.to_ascii_lowercase().contains("stirling-pdf"))
.unwrap_or(false)
})
.collect();
if jar_files.is_empty() {
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
add_log(error_msg.clone());
return Err(error_msg);
}
// Sort by filename to get the latest version (case-insensitive)
jar_files.sort_by(|a, b| {
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
name_b.cmp(&name_a) // Reverse order to get latest first
});
let jar_path = jar_files[0].path();
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
Ok(jar_path)
}
// Normalize path to remove Windows UNC prefix
fn normalize_path(path: &PathBuf) -> PathBuf {
if cfg!(windows) {
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
PathBuf::from(&path_str[4..]) // Remove \\?\ prefix
} else {
path.clone()
}
} else {
path.clone()
}
}
// Create, configure and run the Java command to run Stirling-PDF JAR
fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &PathBuf) -> Result<(), String> {
// Get platform-specific application data directory for Tauri mode
let app_data_dir = if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join("Library").join("Application Support").join("Stirling-PDF")
} else if cfg!(target_os = "windows") {
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string());
PathBuf::from(appdata).join("Stirling-PDF")
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".config").join("Stirling-PDF")
};
// Create subdirectories for different purposes
let config_dir = app_data_dir.join("configs");
let log_dir = app_data_dir.join("logs");
let work_dir = app_data_dir.join("workspace");
// Create all necessary directories
std::fs::create_dir_all(&app_data_dir).ok();
std::fs::create_dir_all(&log_dir).ok();
std::fs::create_dir_all(&work_dir).ok();
std::fs::create_dir_all(&config_dir).ok();
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
add_log(format!("📁 Log directory: {}", log_dir.display()));
add_log(format!("📁 Working directory: {}", work_dir.display()));
add_log(format!("📁 Config directory: {}", config_dir.display()));
// Define all Java options with Tauri-specific paths
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
let java_options = vec![
"-Xmx2g",
"-DBROWSER_OPEN=false",
"-DSTIRLING_PDF_DESKTOP_UI=false",
"-DSTIRLING_PDF_TAURI_MODE=true",
&log_path_option,
"-Dlogging.file.name=stirling-pdf.log",
"-jar",
jar_path.to_str().unwrap()
];
// Log the equivalent command for external testing
let java_command = format!(
"TAURI_PARENT_PID={} \"{}\" {}",
std::process::id(),
java_path.display(),
java_options.join(" ")
);
add_log(format!("🔧 Equivalent command: {}", java_command));
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
// Additional macOS-specific checks
if cfg!(target_os = "macos") {
// Check if java executable has execute permissions
if let Ok(metadata) = std::fs::metadata(java_path) {
let permissions = metadata.permissions();
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = permissions.mode();
add_log(format!("🔍 Java executable mode: 0o{:o}", mode));
if mode & 0o111 == 0 {
add_log("⚠️ Java executable may not have execute permissions".to_string());
}
}
}
// Check if we can read the JAR file
if let Ok(metadata) = std::fs::metadata(jar_path) {
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
} else {
add_log("⚠️ Cannot read JAR file metadata".to_string());
}
}
let sidecar_command = app
.shell()
.command(java_path.to_str().unwrap())
.args(java_options)
.current_dir(&work_dir) // Set working directory to writable location
.env("TAURI_PARENT_PID", std::process::id().to_string())
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
add_log("⚙️ Starting backend with bundled JRE...".to_string());
let (rx, child) = sidecar_command
.spawn()
.map_err(|e| {
let error_msg = format!("❌ Failed to spawn sidecar: {}", e);
add_log(error_msg.clone());
error_msg
})?;
// Store the process handle
{
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = Some(child);
}
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
// Start monitoring output
monitor_backend_output(rx);
Ok(())
}
// Monitor backend output in a separate task
fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_shell::process::CommandEvent>) {
tokio::spawn(async move {
let mut _startup_detected = false;
let mut error_count = 0;
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
let output_str = String::from_utf8_lossy(&output);
add_log(format!("📤 Backend: {}", output_str));
// Look for startup indicators
if output_str.contains("Started SPDFApplication") ||
output_str.contains("Navigate to "){
_startup_detected = true;
add_log(format!("🎉 Backend startup detected: {}", output_str));
}
// Look for port binding
if output_str.contains("8080") {
add_log(format!("🔌 Port 8080 related output: {}", output_str));
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
let output_str = String::from_utf8_lossy(&output);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
error_count += 1;
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
}
// Look for specific common issues
if output_str.contains("Address already in use") {
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
}
if output_str.contains("java.lang.ClassNotFoundException") {
add_log("🚨 CRITICAL: Missing Java dependencies!".to_string());
}
if output_str.contains("java.io.FileNotFoundException") {
add_log("🚨 CRITICAL: Required file not found!".to_string());
}
}
tauri_plugin_shell::process::CommandEvent::Error(error) => {
add_log(format!("❌ Backend process error: {}", error));
}
tauri_plugin_shell::process::CommandEvent::Terminated(payload) => {
add_log(format!("💀 Backend terminated with code: {:?}", payload.code));
if let Some(code) = payload.code {
match code {
0 => println!("✅ Process terminated normally"),
1 => println!("❌ Process terminated with generic error"),
2 => println!("❌ Process terminated due to misuse"),
126 => println!("❌ Command invoked cannot execute"),
127 => println!("❌ Command not found"),
128 => println!("❌ Invalid exit argument"),
130 => println!("❌ Process terminated by Ctrl+C"),
_ => println!("❌ Process terminated with code: {}", code),
}
}
// Clear the stored process handle
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = None;
}
_ => {
println!("🔍 Unknown command event: {:?}", event);
}
}
}
if error_count > 0 {
println!("⚠️ Backend process ended with {} errors detected", error_count);
}
});
}
// Command to start the backend with bundled JRE
#[tauri::command]
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
// Check if backend is already running or starting
if let Err(msg) = check_backend_status() {
return Ok(msg);
}
// Use Tauri's resource API to find the bundled JRE and JAR
let resource_dir = app.path().resource_dir().map_err(|e| {
let error_msg = format!("❌ Failed to get resource directory: {}", e);
add_log(error_msg.clone());
reset_starting_flag();
error_msg
})?;
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
// Find the bundled JRE
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Find the Stirling-PDF JAR
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Normalize the paths to remove Windows UNC prefix
let normalized_java_path = normalize_path(&java_executable);
let normalized_jar_path = normalize_path(&jar_path);
add_log(format!("📦 Found JAR file: {:?}", jar_path));
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
// Create and start the Java command
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
reset_starting_flag();
e
})?;
// Wait for the backend to start
println!("⏳ Waiting for backend startup...");
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
// Reset the starting flag since startup is complete
reset_starting_flag();
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
Ok("Backend startup initiated successfully with bundled JRE".to_string())
}
// Cleanup function to stop backend on app exit
pub fn cleanup_backend() {
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
if let Some(child) = process_guard.take() {
let pid = child.pid();
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
match child.kill() {
Ok(_) => {
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
}
Err(e) => {
add_log(format!("❌ Failed to terminate backend process during cleanup: {}", e));
println!("❌ Failed to terminate backend process during cleanup: {}", e);
}
}
}
}

View File

@ -0,0 +1,48 @@
use crate::utils::add_log;
use std::sync::Mutex;
// Store the opened file path globally
static OPENED_FILE: Mutex<Option<String>> = Mutex::new(None);
// Set the opened file path (called by macOS file open events)
pub fn set_opened_file(file_path: String) {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = Some(file_path.clone());
add_log(format!("📂 File opened via file open event: {}", file_path));
}
// Command to get opened file path (if app was launched with a file)
#[tauri::command]
pub async fn get_opened_file() -> Result<Option<String>, String> {
// First check if we have a file from macOS file open events
{
let opened_file = OPENED_FILE.lock().unwrap();
if let Some(ref file_path) = *opened_file {
add_log(format!("📂 Returning stored opened file: {}", file_path));
return Ok(Some(file_path.clone()));
}
}
// Fallback to command line arguments (Windows/Linux)
let args: Vec<String> = std::env::args().collect();
// Look for a PDF file argument (skip the first arg which is the executable)
for arg in args.iter().skip(1) {
if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 PDF file opened via command line: {}", arg));
return Ok(Some(arg.clone()));
}
}
Ok(None)
}
// Command to clear the opened file (after processing)
#[tauri::command]
pub async fn clear_opened_file() -> Result<(), String> {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = None;
add_log("📂 Cleared opened file".to_string());
Ok(())
}

View File

@ -0,0 +1,36 @@
// Command to check if backend is healthy
#[tauri::command]
pub async fn check_backend_health() -> Result<bool, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
match client.get("http://localhost:8080/api/v1/info/status").send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
match response.text().await {
Ok(_body) => {
println!("✅ Backend health check successful");
Ok(true)
}
Err(e) => {
println!("⚠️ Failed to read health response: {}", e);
Ok(false)
}
}
} else {
println!("⚠️ Health check failed with status: {}", status);
Ok(false)
}
}
Err(e) => {
// Only log connection errors if they're not the common "connection refused" during startup
if !e.to_string().contains("connection refused") && !e.to_string().contains("No connection could be made") {
println!("❌ Health check error: {}", e);
}
Ok(false)
}
}
}

View File

@ -0,0 +1,7 @@
pub mod backend;
pub mod health;
pub mod files;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_file, clear_opened_file, set_opened_file};

View File

@ -0,0 +1,189 @@
/// Multi-platform file opening handler
///
/// This module provides unified file opening support across platforms:
/// - macOS: Uses native NSApplication delegate (proper Apple Events)
/// - Windows/Linux: Uses command line arguments (fallback approach)
/// - All platforms: Runtime event handling via Tauri events
use crate::utils::add_log;
use crate::commands::set_opened_file;
use tauri::AppHandle;
/// Initialize file handling for the current platform
pub fn initialize_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Initializing file handler...".to_string());
// Platform-specific initialization
#[cfg(target_os = "macos")]
{
add_log("🍎 Using macOS native file handler".to_string());
macos_native::register_open_file_handler(app);
}
#[cfg(not(target_os = "macos"))]
{
add_log("🖥️ Using command line argument file handler".to_string());
let _ = app; // Suppress unused variable warning
}
// Universal: Check command line arguments (works on all platforms)
check_command_line_args();
}
/// Early initialization for macOS delegate registration
pub fn early_init() {
#[cfg(target_os = "macos")]
{
add_log("🔄 Early macOS initialization...".to_string());
macos_native::register_delegate_early();
}
}
/// Check command line arguments for file paths (universal fallback)
fn check_command_line_args() {
let args: Vec<String> = std::env::args().collect();
add_log(format!("🔍 DEBUG: All command line args: {:?}", args));
// Check command line arguments for file opening
for (i, arg) in args.iter().enumerate() {
add_log(format!("🔍 DEBUG: Arg {}: {}", i, arg));
if i > 0 && arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 File argument detected: {}", arg));
set_opened_file(arg.clone());
break; // Only handle the first PDF file
}
}
}
/// Handle runtime file open events (for future single-instance support)
#[allow(dead_code)]
pub fn handle_runtime_file_open(file_path: String) {
if file_path.ends_with(".pdf") && std::path::Path::new(&file_path).exists() {
add_log(format!("📂 Runtime file open: {}", file_path));
set_opened_file(file_path);
}
}
#[cfg(target_os = "macos")]
mod macos_native {
use objc::{class, msg_send, sel, sel_impl};
use objc::runtime::{Class, Object, Sel};
use cocoa::appkit::NSApplication;
use cocoa::base::{id, nil};
use once_cell::sync::Lazy;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
use crate::utils::add_log;
use crate::commands::set_opened_file;
// Static app handle storage
static APP_HANDLE: Lazy<Mutex<Option<AppHandle<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
// Store files opened during launch
static LAUNCH_FILES: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
extern "C" fn open_files(_self: &Object, _cmd: Sel, _sender: id, filenames: id) {
unsafe {
add_log(format!("📂 macOS native openFiles event called"));
// filenames is an NSArray of NSString objects
let count: usize = msg_send![filenames, count];
add_log(format!("📂 Number of files to open: {}", count));
for i in 0..count {
let filename: id = msg_send![filenames, objectAtIndex: i];
let cstr = {
let bytes: *const std::os::raw::c_char = msg_send![filename, UTF8String];
std::ffi::CStr::from_ptr(bytes)
};
if let Ok(path) = cstr.to_str() {
add_log(format!("📂 macOS file open: {}", path));
if path.ends_with(".pdf") {
// Always set the opened file for command-line interface
set_opened_file(path.to_string());
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
// App is running, emit event immediately
add_log(format!("✅ App running, emitting file event: {}", path));
let _ = app.emit("macos://open-file", path.to_string());
} else {
// App not ready yet, store for later processing
add_log(format!("🚀 App not ready, storing file for later: {}", path));
LAUNCH_FILES.lock().unwrap().push(path.to_string());
}
}
}
}
}
}
// Register the delegate immediately when the module loads
pub fn register_delegate_early() {
add_log("🔧 Registering macOS delegate early...".to_string());
unsafe {
let ns_app = NSApplication::sharedApplication(nil);
// Check if there's already a delegate
let existing_delegate: id = msg_send![ns_app, delegate];
if existing_delegate != nil {
add_log("⚠️ Tauri already has an NSApplication delegate, trying to extend it...".to_string());
// Try to add our method to the existing delegate's class
let delegate_class: id = msg_send![existing_delegate, class];
let class_name: *const std::os::raw::c_char = msg_send![delegate_class, name];
let class_name_str = std::ffi::CStr::from_ptr(class_name).to_string_lossy();
add_log(format!("🔍 Existing delegate class: {}", class_name_str));
// This approach won't work with existing classes, so let's try a different method
// We'll use method swizzling or create a new delegate that forwards to the old one
add_log("🔄 Will try alternative approach...".to_string());
}
let delegate_class = Class::get("StirlingAppDelegate").unwrap_or_else(|| {
let superclass = class!(NSObject);
let mut decl = objc::declare::ClassDecl::new("StirlingAppDelegate", superclass).unwrap();
// Add file opening delegate method (modern plural version)
decl.add_method(
sel!(application:openFiles:),
open_files as extern "C" fn(&Object, Sel, id, id)
);
decl.register()
});
let delegate: id = msg_send![delegate_class, new];
let _: () = msg_send![ns_app, setDelegate:delegate];
}
add_log("✅ macOS delegate registered early".to_string());
}
pub fn register_open_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Connecting app handle to file handler...".to_string());
// Store the app handle
*APP_HANDLE.lock().unwrap() = Some(app.clone());
// Process any files that were opened during launch
let launch_files = {
let mut files = LAUNCH_FILES.lock().unwrap();
let result = files.clone();
files.clear();
result
};
for file_path in launch_files {
add_log(format!("📂 Processing stored launch file: {}", file_path));
set_opened_file(file_path.clone());
let _ = app.emit("macos://open-file", file_path);
}
add_log("✅ macOS file handler connected successfully".to_string());
}
}

View File

@ -0,0 +1,65 @@
use tauri::{RunEvent, WindowEvent, Emitter};
mod utils;
mod commands;
mod file_handler;
use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend, set_opened_file};
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize file handler early for macOS
file_handler::early_init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Initialize platform-specific file handler
file_handler::initialize_file_handler(&app.handle());
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
match event {
RunEvent::ExitRequested { .. } => {
add_log("🔄 App exit requested, cleaning up...".to_string());
cleanup_backend();
// Use Tauri's built-in cleanup
app_handle.cleanup_before_exit();
}
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
add_log("🔄 Window close requested, cleaning up...".to_string());
cleanup_backend();
// Allow the window to close
}
#[cfg(target_os = "macos")]
RunEvent::Opened { urls } => {
add_log(format!("📂 Tauri file opened event: {:?}", urls));
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
let file_path = url_str.strip_prefix("file://").unwrap_or(url_str);
if file_path.ends_with(".pdf") {
add_log(format!("📂 Processing opened PDF: {}", file_path));
set_opened_file(file_path.to_string());
let _ = app_handle.emit("macos://open-file", file_path.to_string());
}
}
}
}
_ => {
// Only log unhandled events in debug mode to reduce noise
// #[cfg(debug_assertions)]
// add_log(format!("🔍 DEBUG: Unhandled event: {:?}", event));
}
}
});
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

View File

@ -0,0 +1,90 @@
use std::sync::Mutex;
use std::collections::VecDeque;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
// Store backend logs globally
static BACKEND_LOGS: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
// Get platform-specific log directory
fn get_log_directory() -> PathBuf {
if cfg!(target_os = "macos") {
// macOS: ~/Library/Logs/Stirling-PDF
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join("Library").join("Logs").join("Stirling-PDF")
} else if cfg!(target_os = "windows") {
// Windows: %APPDATA%\Stirling-PDF\logs
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string());
PathBuf::from(appdata).join("Stirling-PDF").join("logs")
} else {
// Linux: ~/.config/Stirling-PDF/logs
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".config").join("Stirling-PDF").join("logs")
}
}
// Helper function to add log entry
pub fn add_log(message: String) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let log_entry = format!("{}: {}", timestamp, message);
// Add to memory logs
{
let mut logs = BACKEND_LOGS.lock().unwrap();
logs.push_back(log_entry.clone());
// Keep only last 100 log entries
if logs.len() > 100 {
logs.pop_front();
}
}
// Write to file
write_to_log_file(&log_entry);
// Remove trailing newline if present
let clean_message = message.trim_end_matches('\n').to_string();
println!("{}", clean_message); // Also print to console
}
// Write log entry to file
fn write_to_log_file(log_entry: &str) {
let log_dir = get_log_directory();
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!("Failed to create log directory: {}", e);
return;
}
let log_file = log_dir.join("tauri-backend.log");
match OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
{
Ok(mut file) => {
if let Err(e) = writeln!(file, "{}", log_entry) {
eprintln!("Failed to write to log file: {}", e);
}
}
Err(e) => {
eprintln!("Failed to open log file {:?}: {}", log_file, e);
}
}
}
// Get current logs for debugging
pub fn get_logs() -> Vec<String> {
let logs = BACKEND_LOGS.lock().unwrap();
logs.iter().cloned().collect()
}
// Command to get logs from frontend
#[tauri::command]
pub async fn get_tauri_logs() -> Result<Vec<String>, String> {
Ok(get_logs())
}

View File

@ -0,0 +1,3 @@
pub mod logging;
pub use logging::{add_log, get_tauri_logs};

View File

@ -0,0 +1,14 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Stirling-PDF
Comment=Locally hosted web application that allows you to perform various operations on PDF files
Icon={{icon}}
Terminal=false
MimeType=application/pdf;
Categories=Office;Graphics;Utility;
Actions=open-file;
[Desktop Action open-file]
Name=Open PDF File
Exec=/usr/bin/stirling-pdf %F

View File

@ -0,0 +1,63 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.0.0",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1024,
"height": 768,
"resizable": true,
"fullscreen": false
}
]
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "dmg", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": ["pdf"],
"name": "PDF Document",
"description": "Open PDF files with Stirling-PDF",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
}
}
}

View File

@ -1,18 +1,56 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext';
import HomePage from './pages/HomePage';
import { useOpenedFile } from './hooks/useOpenedFile';
import { useBackendInitializer } from './hooks/useBackendInitializer';
import { fileOpenService } from './services/fileOpenService';
// Import global styles
import './styles/tailwind.css';
import './index.css';
import { BackendHealthIndicator } from './components/BackendHealthIndicator';
export default function App() {
// Initialize backend on app startup
useBackendInitializer();
// Handle file opened with app (Tauri mode)
const { openedFilePath, loading: openedFileLoading } = useOpenedFile();
const [openedFile, setOpenedFile] = useState<File | null>(null);
// Load opened file once when path is available
useEffect(() => {
if (openedFilePath && !openedFileLoading) {
const loadOpenedFile = async () => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(openedFilePath);
if (fileData) {
// Create a File object from the ArrayBuffer
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
setOpenedFile(file);
}
} catch (error) {
console.error('Failed to load opened file:', error);
}
};
loadOpenedFile();
}
}, [openedFilePath, openedFileLoading]);
return (
<>
<BackendHealthIndicator className="absolute top-3 left-3 z-10" />
<RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<HomePage />
<HomePage openedFile={openedFile} />
</FileContextProvider>
</RainbowThemeProvider>
</>
);
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import '../index.css';
import { useBackendHealth } from '../hooks/useBackendHealth';
interface BackendHealthIndicatorProps {
className?: string;
}
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
className = ''
}) => {
const { isHealthy, isChecking, error, checkHealth } = useBackendHealth();
let statusColor = 'bg-red-500'; // offline
if (isChecking || (!isHealthy && error === 'Backend starting up...')) {
statusColor = 'bg-yellow-500'; // starting/checking
} else if (isHealthy) {
statusColor = 'bg-green-500'; // online
}
return (
<div
className={`w-2xs h-2xs ${statusColor} rounded-full cursor-pointer ${isChecking ? 'animate-pulse' : ''} ${className}`}
onClick={checkHealth}
title={isHealthy ? 'Backend Online' : isChecking ? 'Checking...' : 'Backend Offline'}
/>
);
};

View File

@ -5,6 +5,7 @@ import { useFileContext } from '../../../contexts/FileContext';
import { FileOperation } from '../../../types/fileContext';
import { zipFileService } from '../../../services/zipFileService';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import {makeApiUrl} from '../../../utils/api';
export interface CompressParameters {
compressionLevel: number;
@ -184,7 +185,7 @@ export const useCompressOperation = (): CompressOperationHook => {
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const response = await axios.post(makeApiUrl(endpoint), formData, { responseType: "blob" });
// Determine the correct content type from the response
const contentType = response.headers['content-type'] || 'application/zip';

View File

@ -7,6 +7,7 @@ import { zipFileService } from '../../../services/zipFileService';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
import { SPLIT_MODES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
import { makeApiUrl } from '../../../utils/api';
export interface SplitOperationHook {
executeOperation: (
@ -184,7 +185,7 @@ export const useSplitOperation = (): SplitOperationHook => {
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const response = await axios.post(makeApiUrl(endpoint), formData, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { makeApiUrl } from '../utils/api';
export interface AppConfig {
baseUrl?: string;
@ -46,7 +47,7 @@ export function useAppConfig(): UseAppConfigReturn {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/config/app-config');
const response = await fetch(makeApiUrl('/api/v1/config/app-config'));
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);

View File

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback } from 'react';
import { makeApiUrl } from '../utils/api';
export interface BackendHealthState {
isHealthy: boolean;
isChecking: boolean;
lastChecked: Date | null;
error: string | null;
}
export const useBackendHealth = (checkInterval: number = 2000) => {
const [healthState, setHealthState] = useState<BackendHealthState>({
isHealthy: false,
isChecking: false,
lastChecked: null,
error: null,
});
const [startupTime] = useState<Date>(new Date());
const [attemptCount, setAttemptCount] = useState<number>(0);
const checkHealth = useCallback(async () => {
setHealthState(prev => ({ ...prev, isChecking: true, error: null }));
setAttemptCount(prev => prev + 1);
try {
// Direct HTTP call to backend health endpoint using api.ts
const healthUrl = makeApiUrl('/api/v1/info/status');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(healthUrl, {
method: 'GET',
signal: controller.signal,
headers: {
'Accept': 'application/json',
},
});
clearTimeout(timeoutId);
const isHealthy = response.ok;
setHealthState({
isHealthy,
isChecking: false,
lastChecked: new Date(),
error: null,
});
if (isHealthy) {
// Log success message if this is the first successful check after failures
if (attemptCount > 0) {
const now = new Date();
const timeSinceStartup = now.getTime() - startupTime.getTime();
console.log('✅ Backend health check successful:', {
timeSinceStartup: Math.round(timeSinceStartup / 1000) + 's',
attemptsBeforeSuccess: attemptCount,
});
}
setAttemptCount(0); // Reset attempt count on success
}
} catch (error) {
const now = new Date();
const timeSinceStartup = now.getTime() - startupTime.getTime();
const isWithinStartupPeriod = timeSinceStartup < 60000; // 60 seconds
let errorMessage: string;
if (error instanceof Error) {
if (error.name === 'AbortError') {
errorMessage = isWithinStartupPeriod ? 'Backend starting up...' : 'Health check timeout';
} else if (error.message.includes('fetch')) {
errorMessage = isWithinStartupPeriod ? 'Backend starting up...' : 'Cannot connect to backend';
} else {
errorMessage = isWithinStartupPeriod ? 'Backend starting up...' : error.message;
}
} else {
errorMessage = isWithinStartupPeriod ? 'Backend starting up...' : 'Health check failed';
}
// Only log errors to console after startup period
if (!isWithinStartupPeriod) {
console.error('Backend health check failed:', {
error: error instanceof Error ? error.message : error,
timeSinceStartup: Math.round(timeSinceStartup / 1000) + 's',
attemptCount,
});
} else {
// During startup, only log on first few attempts to reduce noise
if (attemptCount <= 3) {
console.log('Backend health check (startup period):', {
message: errorMessage,
timeSinceStartup: Math.round(timeSinceStartup / 1000) + 's',
attempt: attemptCount,
});
}
}
setHealthState({
isHealthy: false,
isChecking: false,
lastChecked: new Date(),
error: errorMessage,
});
}
}, [startupTime]);
useEffect(() => {
// Initial health check
checkHealth();
// Set up periodic health checks
const interval = setInterval(checkHealth, checkInterval);
return () => clearInterval(interval);
}, [checkHealth, checkInterval]);
return {
...healthState,
checkHealth,
};
};

Some files were not shown because too many files have changed in this diff Show More