diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 75cecb7fb5..8303cf102a 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -12,13 +12,14 @@ on: - "true" - "false" platform: - description: "Platform to build (windows, macos, linux, or all)" + description: "Platform to build (windows, windows-arm64, macos, linux, or all)" required: true default: "all" type: choice options: - all - windows + - windows-arm64 - macos - linux sign: @@ -87,24 +88,35 @@ jobs: - name: Determine build matrix id: set-matrix run: | + # windows-arm64: NSIS only (WiX MSI has no arm64 support in Tauri) and no + # JPDFium natives yet - flip to windows-arm64 once JPDFium ships them. + WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}' + WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}' + MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}' + LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}' + ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX" + 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","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT + echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT + ;; + "windows-arm64") + echo "matrix={\"include\":[$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT ;; "macos") - echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT + echo "matrix={\"include\":[$MACOS]}" >> $GITHUB_OUTPUT ;; "linux") - echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT + echo "matrix={\"include\":[$LINUX]}" >> $GITHUB_OUTPUT ;; *) - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT + echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT ;; esac else # For push/release events, build all platforms - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT + echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT fi build-jars: @@ -236,11 +248,12 @@ jobs: if: matrix.platform == 'macos-15' run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + # Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does. - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "25" - distribution: "temurin" + distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Setup Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -271,7 +284,7 @@ jobs: # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -281,7 +294,7 @@ jobs: SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." @@ -316,7 +329,7 @@ jobs: # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - name: Import Windows Code Signing Certificate - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} @@ -384,7 +397,7 @@ jobs: # Without this, signCommand failures are opaque (Tauri captures but drops # smctl's stderr) - running these loudly surfaces auth/env/keypair issues. - name: Preflight smctl - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: pwsh env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -415,7 +428,7 @@ jobs: # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD # from env (set by prior DigiCert setup step). No --config-file needed. - name: Configure Windows code signing - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: bash env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -486,6 +499,54 @@ jobs: args: ${{ matrix.args }} updaterJsonKeepUniversal: true + # Bundled libwayland conflicts with the host's on some distros (Fedora + # Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it, + # then regenerate the updater .sig (repack invalidates the original) and + # GPG-sign again when release signing is on. + - name: Strip bundled Wayland libs from AppImage + if: matrix.platform == 'ubuntu-22.04' + continue-on-error: true + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }} + SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} + APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} + run: | + set -euo pipefail + AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1) + if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi + chmod +x "$AI" + WORK=$(mktemp -d) + (cd "$WORK" && "$AI" --appimage-extract >/dev/null) + if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then + echo "No bundled libwayland - nothing to strip" + rm -rf "$WORK" + exit 0 + fi + rm -f "$WORK/squashfs-root/usr/lib/"libwayland-* + curl -fsSL -o "$WORK/appimagetool" \ + https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + # Pinned checksum: never execute an unverified downloaded binary. On + # mismatch (upstream rebuilt continuous) the step aborts and the + # original AppImage ships unchanged - update the pin deliberately. + echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c - + chmod +x "$WORK/appimagetool" + SIGN_ARGS=() + if [ "$GPG_SIGN" = "1" ] && [ -n "${SIGN_KEY:-}" ]; then + SIGN_ARGS=(--sign --sign-key "$SIGN_KEY") + fi + "$WORK/appimagetool" --appimage-extract-and-run "${SIGN_ARGS[@]}" "$WORK/squashfs-root" "$AI.new" + # Updater payload signature must match the repacked bytes. The CLI + # reads the key/password from env - never pass secrets as argv. + if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + (cd frontend && npx tauri signer sign "$AI.new") + mv "$AI.new.sig" "$AI.sig" + fi + mv "$AI.new" "$AI" + rm -rf "$WORK" + echo "Stripped bundled libwayland from $(basename "$AI")" + - name: Clear release GPG key from runner keyring (Linux) if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') env: @@ -502,10 +563,31 @@ jobs: # artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} timeout-minutes: 15 shell: pwsh run: | + # arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the + # inner exe before packing and the setup exe after, so verifying the setup + # exe is the arm64 equivalent of the MSI + inner-exe check below. + if ("${{ matrix.platform }}" -eq "windows-11-arm") { + $setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File + if ($setupExes.Count -eq 0) { + Write-Host "[ERROR] No NSIS installer found under target/" + exit 1 + } + foreach ($exe in $setupExes) { + $sig = Get-AuthenticodeSignature -FilePath $exe.FullName + Write-Host "NSIS installer: $($exe.Name) Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + Write-Host "[ERROR] NSIS installer is not signed" + exit 1 + } + } + Write-Host "[SUCCESS] NSIS installer is properly signed" + exit 0 + } + $allSigned = $true # Check MSI installer (outer wrapper - what users download) @@ -569,7 +651,7 @@ jobs: # but drops stderr when the command exits non-zero, making failures opaque. # The real errors live in smctl's log files - surface them here for debugging. - name: Dump smctl logs on failure - if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }} shell: pwsh run: | $logDir = "$env:USERPROFILE\.signingmanager\logs" @@ -605,6 +687,11 @@ jobs: if [ "${{ matrix.platform }}" = "windows-latest" ]; then find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \; + elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then + # arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri). + # The setup exe is also its own updater payload (-> sibling .sig). + find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \; + find . -name "*-setup.exe.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe.sig" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then # DMG = manual install; .app.tar.gz (+ .sig) = updater payload. # Raw .app is intentionally not shipped (hundreds of MB of uncompressed input). @@ -719,6 +806,10 @@ jobs: 'bundles': ['Stirling-PDF-windows-x86_64.msi'], 'targets': ['windows-x86_64-msi', 'windows-x86_64'], }, + { + 'bundles': ['Stirling-PDF-windows-arm64-setup.exe'], + 'targets': ['windows-aarch64-nsis', 'windows-aarch64'], + }, { 'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'], 'targets': ['darwin-x86_64', 'darwin-aarch64'], @@ -817,6 +908,7 @@ jobs: files: | ./artifacts/**/*.jar ./artifacts/**/*.msi + ./artifacts/**/*-setup.exe ./artifacts/**/*.dmg ./artifacts/**/*.app.tar.gz ./artifacts/**/*.deb diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index e504826d35..415e8be4e5 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -12,7 +12,7 @@ on: workflow_call: inputs: platform: - description: "Platform to build (windows, macos, linux, windows-macos, or all)." + description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)." required: false type: string default: "all" @@ -29,13 +29,14 @@ on: workflow_dispatch: inputs: platform: - description: "Platform to build (windows, macos, linux, windows-macos, or all)" + description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)" required: true default: "all" type: choice options: - all - windows + - windows-arm64 - macos - linux - windows-macos @@ -73,15 +74,19 @@ jobs: PLATFORM: ${{ inputs.platform }} run: | WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}' + # ARM64: NSIS only (WiX MSI has no arm64 support in Tauri) and no JPDFium + # natives yet - flip jpdfium_platforms to windows-arm64 once JPDFium ships it. + WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}' MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}' LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}' case "$PLATFORM" in - windows) ENTRIES=("$WINDOWS") ;; + windows) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64") ;; + windows-arm64) ENTRIES=("$WINDOWS_ARM64") ;; macos) ENTRIES=("$MACOS") ;; linux) ENTRIES=("$LINUX") ;; windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;; - *) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;; + *) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64" "$MACOS" "$LINUX") ;; esac # Drop macOS entries when Apple certificate secret is unavailable @@ -167,11 +172,12 @@ jobs: if: matrix.platform == 'macos-15' run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + # Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does. - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "25" - distribution: "temurin" + distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Setup Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -203,7 +209,7 @@ jobs: # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup - if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -213,7 +219,7 @@ jobs: SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate - if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." @@ -248,7 +254,7 @@ jobs: # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - name: Import Windows Code Signing Certificate - if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }} env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} @@ -323,7 +329,7 @@ jobs: ls -la /usr/bin/hd* || echo "No hd* tools found" - name: Preflight smctl - if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: pwsh env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -336,7 +342,7 @@ jobs: if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" } - name: Configure Windows code signing - if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: bash env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -452,6 +458,37 @@ jobs: tauriScript: npx tauri args: --bundles appimage + # Bundled libwayland conflicts with the host's on some distros (Fedora + # Wayland: EGL_BAD_PARAMETER, blank window - #6878). The AppImage + # ecosystem excludelist agrees these libs must come from the system. + - name: Strip bundled Wayland libs from AppImage + if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal + continue-on-error: true + run: | + set -euo pipefail + AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1) + if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi + chmod +x "$AI" + WORK=$(mktemp -d) + (cd "$WORK" && "$AI" --appimage-extract >/dev/null) + if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then + echo "No bundled libwayland - nothing to strip" + rm -rf "$WORK" + exit 0 + fi + rm -f "$WORK/squashfs-root/usr/lib/"libwayland-* + curl -fsSL -o "$WORK/appimagetool" \ + https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + # Pinned checksum: never execute an unverified downloaded binary. On + # mismatch (upstream rebuilt continuous) the step aborts and the + # original AppImage ships unchanged - update the pin deliberately. + echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c - + chmod +x "$WORK/appimagetool" + "$WORK/appimagetool" --appimage-extract-and-run "$WORK/squashfs-root" "$AI.new" + mv "$AI.new" "$AI" + rm -rf "$WORK" + echo "Stripped bundled libwayland from $(basename "$AI")" + - name: Clear release GPG key from runner keyring (Linux) if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main' env: @@ -490,6 +527,9 @@ jobs: # Only ship the MSI installer. The loose exe and WiX toolset exes # are not the user-facing installer - the MSI contains the signed inner exe. find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; + elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then + # arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri). + find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \; else @@ -501,9 +541,28 @@ jobs: # Verify the MSI AND the inner exe extracted from it are signed. # The inner exe is what gets installed on users' machines and what AV scans. - name: Verify Windows Code Signature - if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' + if: inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' shell: pwsh run: | + # arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the + # inner exe before packing and the setup exe after, so verifying the setup + # exe is the arm64 equivalent of the MSI + inner-exe check below. + if ("${{ matrix.platform }}" -eq "windows-11-arm") { + $exePath = "./dist/Stirling-PDF-${{ matrix.name }}-setup.exe" + if (-not (Test-Path $exePath)) { + Write-Host "[ERROR] NSIS installer not found at $exePath" + exit 1 + } + $sig = Get-AuthenticodeSignature -FilePath $exePath + Write-Host "NSIS installer: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + Write-Host "[ERROR] NSIS installer is not signed" + exit 1 + } + Write-Host "[SUCCESS] NSIS installer is properly signed" + exit 0 + } + $allSigned = $true $msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi" @@ -549,7 +608,7 @@ jobs: Write-Host "[SUCCESS] MSI and inner exe are properly signed" - name: Dump smctl logs on failure - if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }} shell: pwsh run: | $logDir = "$env:USERPROFILE\.signingmanager\logs" @@ -576,7 +635,7 @@ jobs: cd ./frontend/editor/src-tauri/target # Check for expected artifacts based on platform - if [ "${{ matrix.platform }}" = "windows-latest" ]; then + if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then echo "Checking for Windows artifacts..." find . -name "*.exe" -o -name "*.msi" | head -5 if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then @@ -655,6 +714,7 @@ jobs: // Map of expected artifact names to display info const artifactMap = { 'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' }, + 'Stirling-PDF-windows-arm64': { icon: '🪟', platform: 'Windows ARM64', files: '-setup.exe (NSIS)' }, 'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' }, 'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' } }; diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index c80196305b..27f8dafde0 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -22,6 +22,7 @@ vars: linux-amd64) echo "linux-x64";; linux-arm64) echo "linux-arm64";; windows-amd64) echo "windows-x64";; + windows-arm64) echo "none";; # no JPDFium windows-arm64 natives published yet *) echo "all";; esac fi diff --git a/app/common/build.gradle b/app/common/build.gradle index 71ccbf236f..73be441940 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -36,18 +36,24 @@ dependencies { api "com.stirling:jpdfium:${jpdfiumVersion}" - // -PjpdfiumPlatforms=all| + // -PjpdfiumPlatforms=all|none| + // 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform). def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] - def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all' - ? jpdfiumAllPlatforms - : jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it } + def jpdfiumPlatforms + if (jpdfiumPlatformsProp == 'all') { + jpdfiumPlatforms = jpdfiumAllPlatforms + } else if (jpdfiumPlatformsProp == 'none') { + jpdfiumPlatforms = [] + } else { + jpdfiumPlatforms = jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it } + } def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) } if (jpdfiumInvalid) { throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " + - "Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.") + "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.") } - logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}") + logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}") jpdfiumPlatforms.each { platform -> runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}" } diff --git a/build.gradle b/build.gradle index 38fab663f6..2af5522a73 100644 --- a/build.gradle +++ b/build.gradle @@ -41,7 +41,7 @@ ext { bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" - jpdfiumVersion = "1.0.2" + jpdfiumVersion = "1.0.4" jwtVersion = "0.13.0" awsSdkVersion = "2.44.12" jschVersion = "0.2.23" diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index 8855604624..16810649fd 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -71,9 +71,43 @@ fn parse_launch_files(args: &[String]) -> Vec { .collect() } +// URLs the webview is allowed to show: the bundled app and dev server only. +// Anything else (file://, https://...) must never replace the app UI. +fn is_app_url(url: &tauri::Url) -> bool { + match url.scheme() { + "tauri" | "about" | "blob" | "data" => true, + "http" | "https" => matches!( + url.host_str(), + Some("tauri.localhost") | Some("localhost") | Some("127.0.0.1") + ), + _ => false, + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // WebKitGTK's DMA-BUF renderer crashes the web process on NVIDIA and some + // Wayland stacks (blank window, app dying on tool switch). Opt out unless overridden. + #[cfg(target_os = "linux")] + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + tauri::Builder::default() + .plugin( + // Dropping a file outside a dropzone makes WebKit navigate the webview to + // that file, killing the app UI and its close handler (window becomes + // unclosable). Block every off-app navigation at the Rust layer. + tauri::plugin::Builder::::new("navigation-guard") + .on_navigation(|_webview, url| { + let allowed = is_app_url(url); + if !allowed { + add_log(format!("🚫 Blocked webview navigation to: {}", url)); + } + allowed + }) + .build() + ) .plugin( tauri_plugin_log::Builder::new() .level(log::LevelFilter::Info) @@ -209,10 +243,20 @@ pub fn run() { // Use Tauri's built-in cleanup app_handle.cleanup_before_exit(); } - RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => { + RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, label, .. } => { add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string()); // Don't cleanup here - let JavaScript handler prevent close if needed // Backend cleanup happens in ExitRequested when window actually closes + // + // Failsafe: if the webview somehow left the app (JS close handler gone, + // window would stay open forever), destroy the window directly. + if let Some(window) = app_handle.get_webview_window(&label) { + let off_app = window.url().map(|u| !is_app_url(&u)).unwrap_or(false); + if off_app { + add_log(format!("🚨 Webview '{}' is off-app, destroying window directly", label)); + let _ = window.destroy(); + } + } } RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => { use tauri::DragDropEvent; @@ -267,3 +311,39 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::is_app_url; + + fn allows(raw: &str) -> bool { + is_app_url(&tauri::Url::parse(raw).expect("valid url")) + } + + #[test] + fn allows_bundled_app_and_dev_server() { + assert!(allows("tauri://localhost/index.html")); + assert!(allows("http://tauri.localhost/")); + assert!(allows("http://localhost:5173/")); + assert!(allows("http://127.0.0.1:8080/api")); + assert!(allows("about:blank")); + assert!(allows("blob:http://localhost:5173/abc")); + assert!(allows("data:text/html,hi")); + } + + #[test] + fn blocks_dropped_files() { + // The #6872 lockup: webview navigating to a dropped PDF. + assert!(!allows("file:///C:/Users/me/report.pdf")); + assert!(!allows("file:///home/me/report.pdf")); + } + + #[test] + fn blocks_remote_origins() { + assert!(!allows("https://example.com/")); + assert!(!allows("http://evil.test/")); + // Look-alike hosts must not slip past the allowlist. + assert!(!allows("https://localhost.evil.test/")); + assert!(!allows("https://nottauri.localhost.evil.test/")); + } +} diff --git a/frontend/editor/src/desktop/components/AppProviders.tsx b/frontend/editor/src/desktop/components/AppProviders.tsx index 69c258b4f6..5b00c1f4f5 100644 --- a/frontend/editor/src/desktop/components/AppProviders.tsx +++ b/frontend/editor/src/desktop/components/AppProviders.tsx @@ -62,6 +62,20 @@ export function AppProviders({ children }: { children: ReactNode }) { const [appKey, setAppKey] = useState(0); const hasLoadedInitialMode = useRef(false); + // Files dropped outside a dropzone must never navigate the webview to the + // file (Linux WebKit renders the PDF fullscreen and orphans the app UI). + // Dropzone-level handlers run before these window-level listeners, so + // in-app drag & drop is unaffected. + useEffect(() => { + const preventNavigation = (e: DragEvent) => e.preventDefault(); + window.addEventListener("dragover", preventNavigation); + window.addEventListener("drop", preventNavigation); + return () => { + window.removeEventListener("dragover", preventNavigation); + window.removeEventListener("drop", preventNavigation); + }; + }, []); + // Load connection mode on mount and subscribe to future changes useEffect(() => { void connectionModeService.getCurrentMode().then((mode) => { diff --git a/frontend/editor/src/desktop/hooks/useExitWarning.ts b/frontend/editor/src/desktop/hooks/useExitWarning.ts index 6f19125d17..1e6b4423fc 100644 --- a/frontend/editor/src/desktop/hooks/useExitWarning.ts +++ b/frontend/editor/src/desktop/hooks/useExitWarning.ts @@ -21,8 +21,6 @@ export function useExitWarning() { const handleCloseRequested = async (event: { preventDefault: () => void; }) => { - event.preventDefault(); - if (isClosingRef.current) { return; } @@ -30,6 +28,15 @@ export function useExitWarning() { const allStubs = selectorsRef.current.getStirlingFileStubs(); const dirtyStubs = allStubs.filter((stub) => stub.isDirty); + // Nothing unsaved: don't preventDefault, so the window closes natively + // without depending on the JS dialog/destroy round-trip below. + if (dirtyStubs.length === 0) { + isClosingRef.current = true; + return; + } + + event.preventDefault(); + if (dirtyStubs.length > 0) { const fileList = dirtyStubs.map((f) => `• ${f.name}`).join("\n"); const saveLabel = t("confirmCloseSave", "Save and close"); diff --git a/frontend/editor/src/desktop/services/tauriBackendService.ts b/frontend/editor/src/desktop/services/tauriBackendService.ts index dd1af3d96b..31ed0119c5 100644 --- a/frontend/editor/src/desktop/services/tauriBackendService.ts +++ b/frontend/editor/src/desktop/services/tauriBackendService.ts @@ -18,6 +18,11 @@ export class TauriBackendService { private isRecovering = false; private restartAttempts = 0; private static readonly MAX_RESTART_ATTEMPTS = 3; + /** Failed health checks before the first-ever success are "starting", not "unhealthy" - + * a slow first boot must not trigger the restart/notification loop. */ + private hasBeenHealthy = false; + private startupGraceUntil = 0; + private static readonly STARTUP_GRACE_MS = 120_000; static getInstance(): TauriBackendService { if (!TauriBackendService.instance) { @@ -115,9 +120,18 @@ export class TauriBackendService { // Reset started flag so startBackend() will run again this.backendStarted = false; this.startPromise = null; + // Fresh grace window: the restarted backend needs boot time before failed + // health checks may count as unhealthy again. + this.hasBeenHealthy = false; this.setStatus("starting"); try { await this.startBackend(); + // startBackend resolves once the port is known, not once Spring is up - + // only declare success after a real health check passes. + const healthy = await this.waitUntilHealthy(60_000); + if (!healthy) { + throw new Error("Backend did not become healthy after restart"); + } this.restartAttempts = 0; // Reset on successful restart this.isRecovering = false; console.log("[TauriBackendService] Backend restarted successfully."); @@ -156,6 +170,7 @@ export class TauriBackendService { } this.backendStarted = true; // Mark as active for health checks + this.startupGraceUntil = Date.now() + TauriBackendService.STARTUP_GRACE_MS; this.setStatus("starting"); this.beginHealthMonitoring(); @@ -176,6 +191,7 @@ export class TauriBackendService { return this.startPromise; } + this.startupGraceUntil = Date.now() + TauriBackendService.STARTUP_GRACE_MS; this.setStatus("starting"); this.startPromise = invoke("start_backend", { backendUrl }) @@ -260,7 +276,7 @@ export class TauriBackendService { console.warn( `[TauriBackendService] Health check failed: ${response.status}`, ); - this.setStatus("unhealthy"); + this.setStatus(this.isInStartupGrace() ? "starting" : "unhealthy"); return false; } @@ -270,15 +286,22 @@ export class TauriBackendService { `[TauriBackendService] dependenciesReady=${dependenciesReady}`, ); + if (dependenciesReady) { + this.hasBeenHealthy = true; + } this.setStatus(dependenciesReady ? "healthy" : "starting"); return dependenciesReady; } catch (error) { console.error("[TauriBackendService] Health check error:", error); - this.setStatus("unhealthy"); + this.setStatus(this.isInStartupGrace() ? "starting" : "unhealthy"); return false; } } + private isInStartupGrace(): boolean { + return !this.hasBeenHealthy && Date.now() < this.startupGraceUntil; + } + private async waitForHealthy(): Promise { while (true) { const isHealthy = await this.checkBackendHealth(); @@ -289,6 +312,17 @@ export class TauriBackendService { } } + private async waitUntilHealthy(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await this.checkBackendHealth()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return false; + } + /** * Reset backend state (used when switching from external to local backend) */ @@ -298,6 +332,8 @@ export class TauriBackendService { this.isLocalBackend = false; this.isRecovering = false; this.restartAttempts = 0; + this.hasBeenHealthy = false; + this.startupGraceUntil = 0; if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null;