From 6fab84561f45ecd8c240e94e010481ebc0da12aa Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:27:23 +0200
Subject: [PATCH 01/32] Add native Windows and Linux desktop builds
---
.github/workflows/desktop-native-builds.yml | 119 ++++++++++
.gitignore | 1 +
desktop/README.md | 28 ++-
desktop/package.json | 4 +-
desktop/runtime/chrome.js | 114 ++++++++-
desktop/runtime/native-tool-linux-wrapper.sh | 7 +
desktop/runtime/pdf2htmlEX-linux-wrapper.sh | 19 ++
desktop/runtime/supervise.ps1 | 26 +++
desktop/scripts/build.mjs | 17 ++
desktop/scripts/prepare-linux-runtimes.sh | 80 +++++++
desktop/scripts/prepare-windows-runtimes.ps1 | 80 +++++++
desktop/scripts/prepare.mjs | 234 +++++++++++--------
desktop/scripts/smoke-runtimes.mjs | 95 ++++++++
desktop/src-tauri/Cargo.lock | 126 +++++++++-
desktop/src-tauri/Cargo.toml | 3 +-
desktop/src-tauri/src/lib.rs | 218 ++++++++++++-----
desktop/src-tauri/tauri.conf.json | 12 +-
17 files changed, 1011 insertions(+), 172 deletions(-)
create mode 100644 .github/workflows/desktop-native-builds.yml
create mode 100644 desktop/runtime/native-tool-linux-wrapper.sh
create mode 100644 desktop/runtime/pdf2htmlEX-linux-wrapper.sh
create mode 100644 desktop/runtime/supervise.ps1
create mode 100644 desktop/scripts/build.mjs
create mode 100644 desktop/scripts/prepare-linux-runtimes.sh
create mode 100644 desktop/scripts/prepare-windows-runtimes.ps1
create mode 100644 desktop/scripts/smoke-runtimes.mjs
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
new file mode 100644
index 0000000..280493e
--- /dev/null
+++ b/.github/workflows/desktop-native-builds.yml
@@ -0,0 +1,119 @@
+name: Desktop native builds
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - '.github/workflows/desktop-native-builds.yml'
+ - 'backend/**'
+ - 'desktop/**'
+ - 'package-lock.json'
+ - 'package.json'
+ - 'public/**'
+ - 'scripts/**'
+ - 'src/**'
+
+permissions:
+ contents: read
+
+jobs:
+ windows-x64:
+ name: Windows x64 offline installer
+ runs-on: windows-2025
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24.18.0
+ cache: npm
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: desktop/src-tauri -> target
+ - name: Install JavaScript dependencies
+ shell: pwsh
+ run: |
+ npm ci
+ npm ci --prefix desktop
+ - name: Install bundled Python conversion packages
+ shell: pwsh
+ run: python -m pip install --disable-pip-version-check pillow python-docx lxml openpyxl python-pptx xlsxwriter
+ - name: Prepare native Windows runtimes
+ shell: pwsh
+ run: ./desktop/scripts/prepare-windows-runtimes.ps1 -RuntimeRoot "$pwd/desktop/.native-runtime/windows"
+ - name: Build and smoke-test Windows package
+ shell: pwsh
+ working-directory: desktop
+ env:
+ DOCUFLEX_PDF2HTMLEX_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/windows/pdf2htmlEX
+ DOCUFLEX_OCR_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/windows/ocr
+ DOCUFLEX_OFFICE_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/windows/office
+ run: |
+ $env:DOCUFLEX_PYTHON_RUNTIME = $env:pythonLocation
+ npm run build
+ - name: Upload Windows installer
+ uses: actions/upload-artifact@v4
+ with:
+ name: Docuflex-Windows-x64
+ if-no-files-found: error
+ path: desktop/src-tauri/target/release/bundle/nsis/*.exe
+
+ linux-x64:
+ name: Linux x64 AppImage and Debian package
+ runs-on: ubuntu-22.04
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Tauri Linux build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24.18.0
+ cache: npm
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: desktop/src-tauri -> target
+ - name: Install JavaScript dependencies
+ run: |
+ npm ci
+ npm ci --prefix desktop
+ - name: Install bundled Python conversion packages
+ run: python -m pip install --disable-pip-version-check pillow python-docx lxml openpyxl python-pptx xlsxwriter
+ - name: Prepare native Linux runtimes
+ run: |
+ chmod +x desktop/scripts/prepare-linux-runtimes.sh desktop/runtime/*.sh
+ desktop/scripts/prepare-linux-runtimes.sh "$GITHUB_WORKSPACE/desktop/.native-runtime/linux"
+ - name: Build and smoke-test Linux packages
+ working-directory: desktop
+ env:
+ DOCUFLEX_PDF2HTMLEX_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/linux/pdf2htmlEX
+ DOCUFLEX_OCR_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/linux/ocr
+ DOCUFLEX_OFFICE_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/linux/office
+ DOCUFLEX_PYTHON_RUNTIME: ${{ env.pythonLocation }}
+ run: npm run build
+ - name: Upload Linux packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: Docuflex-Linux-x64
+ if-no-files-found: error
+ path: |
+ desktop/src-tauri/target/release/bundle/appimage/*.AppImage
+ desktop/src-tauri/target/release/bundle/deb/*.deb
diff --git a/.gitignore b/.gitignore
index e559c55..8998dc3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ tmp/
.pdf2htmlex/
.ocr/
.document-python/
+desktop/.native-runtime/
# Environment files
.env
diff --git a/desktop/README.md b/desktop/README.md
index 3252e44..3ce1c40 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -1,12 +1,18 @@
-# Docuflex for macOS
+# Docuflex desktop builds
-This directory packages the existing Docuflex `/editor` route as a native Tauri app. The web application source remains unchanged.
+The desktop package runs the existing `/editor` application and its Java backend entirely on loopback. Each native package contains Node.js, a reduced Java 17 runtime, Python conversion libraries, OCR tools and language data, `pdf2htmlEX`, and an office converter. Runtime staging fails instead of silently using tools installed on the end user's computer.
-The packaged app applies its macOS-only titlebar and sidebar arrangement from `runtime/chrome.js`; those overrides are injected by Tauri and are not included in the website.
+## Native packages
-## Build
+- macOS Apple Silicon: `npm run build` in `desktop/` creates `Docuflex.app`.
+- Windows x64: the `Desktop native builds` workflow creates a current-user NSIS installer and embeds the offline WebView2 installer.
+- Linux x64: the same workflow creates an AppImage and a Debian package on Ubuntu 22.04.
-The current native bundle targets Apple Silicon macOS 26 or newer.
+Windows and Linux are deliberately compiled on native GitHub runners. Tauri recommends native CI for installers, and this also ensures that every bundled helper executable matches the target operating system.
+
+Before packaging, `npm run smoke` verifies the bundled Node and Java runtimes, Python imports, all three OCR language files, real OCR output, a real PDF-to-HTML conversion that preserves text, and LibreOffice availability. A failed native dependency prevents the installer artifact from being uploaded.
+
+For a local Apple Silicon build:
```sh
cd desktop
@@ -14,12 +20,12 @@ npm install
npm run build
```
-The staging step builds the existing SvelteKit application and PDFBox server, downloads and verifies the pinned Node.js LTS runtime, creates a trimmed Java 17 runtime with `jlink`, bundles the native Apple Silicon `pdf2htmlEX` and OCR runtimes, and generates the macOS icon from `public/macos-icon-iOS-Default-1024x1024@1x.png`.
-
-The resulting app is written below `desktop/src-tauri/target/release/bundle/macos/Docuflex.app`.
+The app is written to `desktop/src-tauri/target/release/bundle/macos/Docuflex.app`. It targets macOS 26 or newer and uses the icon exported at `public/macos-icon-iOS-Default-1024x1024@1x.png`.
-The app runs its frontend and PDFBox services only on `127.0.0.1`. No hosted Docuflex backend is used. Edit Text uses the bundled native `pdf2htmlEX`, Poppler, FontForge, data files, and relocated dylibraries, so conversion works offline without Homebrew, MacPorts, Docker, or a Linux compatibility layer.
+The relocatable macOS converter and OCR inputs remain under `vendor/pdf2htmlEX-macos-arm64` and `vendor/ocr-macos-arm64`. Their pinned source versions and checksums are recorded in `runtime/pdf2htmlEX-SOURCE.txt` and the vendor `SOURCE.txt` files; the existing bundling scripts can recreate them from native builds.
-The converter bundle is under `vendor/pdf2htmlEX-macos-arm64`. Its pinned source versions and checksums are recorded in `runtime/pdf2htmlEX-SOURCE.txt`; `scripts/bundle-pdf2htmlex.mjs` recreates the relocatable runtime after the native sources have been built.
+## Window chrome
-Offline OCR uses bundled Apple Silicon builds of Tesseract, `pdftoppm`, and `pdfunite`, plus pinned English, German, and orientation language data. `scripts/bundle-ocr.mjs` recreates that relocatable runtime under `vendor/ocr-macos-arm64`.
+- macOS retains the integrated traffic lights and desktop-specific sidebar/header alignment.
+- Windows uses the web header with a draggable native window frame and Windows controls on the right; the utility area reserves the corresponding space.
+- Linux uses the normal desktop window decoration and the web header unchanged.
diff --git a/desktop/package.json b/desktop/package.json
index 3e5d5af..27a9623 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"stage": "node scripts/prepare.mjs",
- "build": "npm run stage && tauri build --bundles app",
+ "smoke": "node scripts/smoke-runtimes.mjs",
+ "bundle": "node scripts/build.mjs",
+ "build": "npm run stage && npm run smoke && npm run bundle",
"dev": "npm run stage && tauri dev"
},
"devDependencies": {
diff --git a/desktop/runtime/chrome.js b/desktop/runtime/chrome.js
index 890f4ec..b029ae0 100644
--- a/desktop/runtime/chrome.js
+++ b/desktop/runtime/chrome.js
@@ -1,4 +1,9 @@
(() => {
+ const desktopPlatform = /Windows/i.test(navigator.userAgent)
+ ? 'windows'
+ : /Linux/i.test(navigator.userAgent)
+ ? 'linux'
+ : 'macos';
const desktopBlobUrls = new Map();
const originalCreateObjectUrl = URL.createObjectURL.bind(URL);
const originalRevokeObjectUrl = URL.revokeObjectURL.bind(URL);
@@ -249,6 +254,91 @@
position: absolute;
}
+ html[data-docuflex-desktop="windows"] .topbar,
+ html[data-docuflex-desktop="windows"] .brand-area,
+ html[data-docuflex-desktop="windows"] .tab-strip,
+ html[data-docuflex-desktop="windows"] .utilities {
+ -webkit-app-region: drag;
+ }
+
+ html[data-docuflex-desktop="windows"] .utilities {
+ padding-right: 138px !important;
+ }
+
+ html[data-docuflex-desktop="windows"] .topbar button,
+ html[data-docuflex-desktop="windows"] .topbar a,
+ html[data-docuflex-desktop="windows"] .topbar input,
+ html[data-docuflex-desktop="windows"] .docuflex-windows-controls {
+ -webkit-app-region: no-drag;
+ }
+
+ .docuflex-windows-controls {
+ display: none;
+ }
+
+ html[data-docuflex-desktop="windows"] .docuflex-windows-controls {
+ display: flex;
+ height: 56px;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 10002;
+ }
+
+ .docuflex-windows-control {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #616161;
+ display: flex;
+ height: 56px;
+ justify-content: center;
+ padding: 0;
+ position: relative;
+ width: 46px;
+ }
+
+ .docuflex-windows-control:hover {
+ background: rgba(0, 0, 0, 0.07);
+ color: #171717;
+ }
+
+ .docuflex-windows-control.close:hover {
+ background: #c42b1c;
+ color: #fff;
+ }
+
+ .docuflex-windows-control::before,
+ .docuflex-windows-control::after {
+ box-sizing: border-box;
+ content: "";
+ position: absolute;
+ }
+
+ .docuflex-windows-control.minimize::before {
+ border-top: 1px solid currentColor;
+ height: 1px;
+ width: 10px;
+ }
+
+ .docuflex-windows-control.maximize::before {
+ border: 1px solid currentColor;
+ height: 10px;
+ width: 10px;
+ }
+
+ .docuflex-windows-control.close::before,
+ .docuflex-windows-control.close::after {
+ background: currentColor;
+ height: 1px;
+ transform: rotate(45deg);
+ width: 12px;
+ }
+
+ .docuflex-windows-control.close::after {
+ transform: rotate(-45deg);
+ }
+
.docuflex-desktop-export-menu {
-webkit-backdrop-filter: blur(18px);
animation: docuflex-desktop-export-menu-in 125ms cubic-bezier(0.215, 0.61, 0.355, 1);
@@ -294,7 +384,7 @@
`;
const installDesktopChrome = () => {
- document.documentElement.dataset.docuflexDesktop = 'macos';
+ document.documentElement.dataset.docuflexDesktop = desktopPlatform;
if (!document.getElementById('docuflex-desktop-chrome')) {
const style = document.createElement('style');
@@ -304,6 +394,7 @@
}
const markDragRegion = () => {
+ if (desktopPlatform === 'linux') return;
document
.querySelectorAll('.topbar, .brand-area, .tab-strip, .utilities')
.forEach((element) => element.setAttribute('data-tauri-drag-region', 'deep'));
@@ -314,6 +405,27 @@
childList: true,
subtree: true,
});
+
+ if (desktopPlatform === 'windows' && !document.querySelector('.docuflex-windows-controls')) {
+ const controls = document.createElement('div');
+ controls.className = 'docuflex-windows-controls';
+ controls.setAttribute('aria-label', 'Window controls');
+ for (const [action, label] of [
+ ['minimize', 'Minimize'],
+ ['maximize', 'Maximize or restore'],
+ ['close', 'Close'],
+ ]) {
+ const button = document.createElement('button');
+ button.className = `docuflex-windows-control ${action}`;
+ button.type = 'button';
+ button.setAttribute('aria-label', label);
+ button.addEventListener('click', () => {
+ window.location.assign(`/__docuflex/window/${action}`);
+ });
+ controls.append(button);
+ }
+ document.body.append(controls);
+ }
};
if (document.readyState === 'loading') {
diff --git a/desktop/runtime/native-tool-linux-wrapper.sh b/desktop/runtime/native-tool-linux-wrapper.sh
new file mode 100644
index 0000000..0dbbd88
--- /dev/null
+++ b/desktop/runtime/native-tool-linux-wrapper.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+TOOL_NAME=${0##*/}
+export LD_LIBRARY_PATH="$SCRIPT_DIR/../lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+exec "$SCRIPT_DIR/$TOOL_NAME-native" "$@"
diff --git a/desktop/runtime/pdf2htmlEX-linux-wrapper.sh b/desktop/runtime/pdf2htmlEX-linux-wrapper.sh
new file mode 100644
index 0000000..2d117b8
--- /dev/null
+++ b/desktop/runtime/pdf2htmlEX-linux-wrapper.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+APP_ROOT="$SCRIPT_DIR/../app"
+
+normalized_args=()
+while [ "$#" -gt 0 ]; do
+ if [ "$1" = "--embed" ] && [ "$#" -ge 2 ] && [ "$2" = "1" ]; then
+ normalized_args+=(--embed-css 1 --embed-font 1 --embed-image 1 --embed-javascript 1 --embed-outline 1)
+ shift 2
+ continue
+ fi
+ normalized_args+=("$1")
+ shift
+done
+
+export APPDIR="$APP_ROOT"
+exec "$APP_ROOT/AppRun" "${normalized_args[@]}"
diff --git a/desktop/runtime/supervise.ps1 b/desktop/runtime/supervise.ps1
new file mode 100644
index 0000000..0e591fb
--- /dev/null
+++ b/desktop/runtime/supervise.ps1
@@ -0,0 +1,26 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [int]$DocuflexParentPid,
+ [Parameter(Mandatory = $true)]
+ [string]$FilePath,
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$ProgramArguments
+)
+
+$ErrorActionPreference = 'Stop'
+if ($ProgramArguments.Count -gt 0 -and $ProgramArguments[0] -eq '--') {
+ $ProgramArguments = $ProgramArguments[1..($ProgramArguments.Count - 1)]
+}
+
+$service = Start-Process -FilePath $FilePath -ArgumentList $ProgramArguments -NoNewWindow -PassThru
+try {
+ while (-not $service.HasExited -and (Get-Process -Id $DocuflexParentPid -ErrorAction SilentlyContinue)) {
+ Start-Sleep -Milliseconds 500
+ }
+}
+finally {
+ if (-not $service.HasExited) {
+ & taskkill.exe /PID $service.Id /T /F 2>$null | Out-Null
+ }
+ $service.WaitForExit()
+}
diff --git a/desktop/scripts/build.mjs b/desktop/scripts/build.mjs
new file mode 100644
index 0000000..363cede
--- /dev/null
+++ b/desktop/scripts/build.mjs
@@ -0,0 +1,17 @@
+import { execFileSync } from 'node:child_process';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const tauriCli = join(desktopRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
+const bundles = process.platform === 'darwin'
+ ? ['app']
+ : process.platform === 'win32'
+ ? ['nsis']
+ : ['appimage', 'deb'];
+
+execFileSync(process.execPath, [tauriCli, 'build', '--bundles', bundles.join(',')], {
+ cwd: desktopRoot,
+ env: process.env,
+ stdio: 'inherit'
+});
diff --git a/desktop/scripts/prepare-linux-runtimes.sh b/desktop/scripts/prepare-linux-runtimes.sh
new file mode 100644
index 0000000..8e0d4ae
--- /dev/null
+++ b/desktop/scripts/prepare-linux-runtimes.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+set -euo pipefail
+
+RUNTIME_ROOT=${1:?Pass the Linux runtime output directory.}
+case "$RUNTIME_ROOT" in
+ */desktop/.native-runtime/linux) ;;
+ *) echo "Refusing unexpected runtime output path: $RUNTIME_ROOT" >&2; exit 2 ;;
+esac
+
+sudo apt-get update
+sudo apt-get install -y --no-install-recommends \
+ curl libarchive-tools libreoffice-writer pax-utils poppler-utils \
+ tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng tesseract-ocr-osd
+
+rm -rf -- "$RUNTIME_ROOT"
+mkdir -p "$RUNTIME_ROOT/pdf2htmlEX/bin" "$RUNTIME_ROOT/pdf2htmlEX/share" \
+ "$RUNTIME_ROOT/ocr/bin" "$RUNTIME_ROOT/ocr/lib" "$RUNTIME_ROOT/ocr/share/tessdata" \
+ "$RUNTIME_ROOT/office"
+
+PDF2HTMLEX_URL=https://github.com/pdf2htmlEX/pdf2htmlEX/releases/download/v0.18.8.rc1/pdf2htmlEX-0.18.8.rc1-master-20200630-Ubuntu-focal-x86_64.AppImage
+PDF2HTMLEX_SHA256=11de2583a3abce5f141fd7fafb1fea2c67b15886e546d6b7675c600012e6ab8c
+PDF2HTMLEX_IMAGE="$RUNTIME_ROOT/pdf2htmlEX.AppImage"
+curl -L --fail --retry 3 "$PDF2HTMLEX_URL" -o "$PDF2HTMLEX_IMAGE"
+echo "$PDF2HTMLEX_SHA256 $PDF2HTMLEX_IMAGE" | sha256sum --check --strict
+chmod +x "$PDF2HTMLEX_IMAGE"
+EXTRACT_ROOT="$RUNTIME_ROOT/pdf2htmlEX-extract"
+mkdir -p "$EXTRACT_ROOT"
+(cd "$EXTRACT_ROOT" && "$PDF2HTMLEX_IMAGE" --appimage-extract >/dev/null)
+mv "$EXTRACT_ROOT/squashfs-root" "$RUNTIME_ROOT/pdf2htmlEX/app"
+cp -a "$RUNTIME_ROOT/pdf2htmlEX/app/usr/local/share/pdf2htmlEX" "$RUNTIME_ROOT/pdf2htmlEX/share/pdf2htmlEX"
+cp desktop/runtime/pdf2htmlEX-linux-wrapper.sh "$RUNTIME_ROOT/pdf2htmlEX/bin/pdf2htmlEX"
+chmod +x "$RUNTIME_ROOT/pdf2htmlEX/bin/pdf2htmlEX"
+cat > "$RUNTIME_ROOT/pdf2htmlEX/SOURCE.txt" <<'EOF'
+Linux x86_64 pdf2htmlEX 0.18.8.rc1 official Ubuntu focal AppImage.
+Source: https://github.com/pdf2htmlEX/pdf2htmlEX/tree/v0.18.8.rc1
+Archive SHA-256: 11de2583a3abce5f141fd7fafb1fea2c67b15886e546d6b7675c600012e6ab8c
+EOF
+rm -rf -- "$EXTRACT_ROOT" "$PDF2HTMLEX_IMAGE"
+
+copy_elf_dependencies() {
+ local executable=$1
+ while IFS= read -r library; do
+ case "$library" in
+ /lib/*|/lib64/*|/usr/lib/*)
+ cp -L -n "$library" "$RUNTIME_ROOT/ocr/lib/$(basename "$library")" || true
+ ;;
+ esac
+ done < <(lddtree -l "$executable")
+}
+
+for tool in pdftoppm pdfunite tesseract; do
+ source_path=$(command -v "$tool")
+ cp -L "$source_path" "$RUNTIME_ROOT/ocr/bin/$tool-native"
+ cp desktop/runtime/native-tool-linux-wrapper.sh "$RUNTIME_ROOT/ocr/bin/$tool"
+ chmod +x "$RUNTIME_ROOT/ocr/bin/$tool" "$RUNTIME_ROOT/ocr/bin/$tool-native"
+ copy_elf_dependencies "$source_path"
+done
+
+for data in eng deu osd; do
+ source_data=$(find /usr/share -path "*/tessdata/$data.traineddata" -print -quit)
+ test -n "$source_data"
+ cp "$source_data" "$RUNTIME_ROOT/ocr/share/tessdata/$data.traineddata"
+done
+mkdir -p "$RUNTIME_ROOT/ocr/licenses"
+for package in poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-osd; do
+ if [ -f "/usr/share/doc/$package/copyright" ]; then
+ cp "/usr/share/doc/$package/copyright" "$RUNTIME_ROOT/ocr/licenses/$package-copyright.txt"
+ fi
+done
+
+cp -a /usr/lib/libreoffice/. "$RUNTIME_ROOT/office/"
+chmod +x "$RUNTIME_ROOT/office/program/soffice" "$RUNTIME_ROOT/office/program/soffice.bin"
+
+cat > "$RUNTIME_ROOT/ocr/SOURCE.txt" <<'EOF'
+Linux x86_64 native runtime prepared on Ubuntu 22.04.
+OCR: Ubuntu native Tesseract and Poppler packages with English, German, and OSD data.
+EOF
+cat > "$RUNTIME_ROOT/office/SOURCE.txt" <<'EOF'
+Linux x86_64 native LibreOffice Writer runtime prepared from the Ubuntu 22.04 package.
+EOF
diff --git a/desktop/scripts/prepare-windows-runtimes.ps1 b/desktop/scripts/prepare-windows-runtimes.ps1
new file mode 100644
index 0000000..2d50f05
--- /dev/null
+++ b/desktop/scripts/prepare-windows-runtimes.ps1
@@ -0,0 +1,80 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$RuntimeRoot
+)
+
+$ErrorActionPreference = 'Stop'
+$RuntimeRoot = [IO.Path]::GetFullPath($RuntimeRoot)
+$expectedSuffix = [IO.Path]::Combine('desktop', '.native-runtime', 'windows')
+if (-not $RuntimeRoot.EndsWith($expectedSuffix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing unexpected runtime output path: $RuntimeRoot"
+}
+
+function Get-VerifiedArchive {
+ param([string]$Url, [string]$Path, [string]$Sha256)
+ Invoke-WebRequest -Uri $Url -OutFile $Path -UseBasicParsing
+ $actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actual -ne $Sha256) { throw "Checksum failed for $Url" }
+}
+
+if (Test-Path $RuntimeRoot) { Remove-Item -LiteralPath $RuntimeRoot -Recurse -Force }
+New-Item -ItemType Directory -Force -Path $RuntimeRoot | Out-Null
+
+$pdfArchive = Join-Path $RuntimeRoot 'pdf2htmlEX.zip'
+Get-VerifiedArchive -Url 'https://soft.rubypdf.com/download/pdf2htmlex/pdf2htmlEX-win32-0.14.6-with-poppler-data.zip' -Path $pdfArchive -Sha256 'e92aa55699c3e9d9b4b4954bea157e59b5c3363cbe9a7713495c553544026354'
+$pdfExtract = Join-Path $RuntimeRoot 'pdf2htmlEX-extract'
+Expand-Archive -LiteralPath $pdfArchive -DestinationPath $pdfExtract
+$pdfRuntime = Join-Path $RuntimeRoot 'pdf2htmlEX'
+New-Item -ItemType Directory -Force -Path (Join-Path $pdfRuntime 'bin'), (Join-Path $pdfRuntime 'share') | Out-Null
+Copy-Item (Join-Path $pdfExtract 'pdf2htmlEX.exe') (Join-Path $pdfRuntime 'bin/pdf2htmlEX.exe')
+Copy-Item (Join-Path $pdfExtract 'data') (Join-Path $pdfRuntime 'share/pdf2htmlEX') -Recurse
+Copy-Item (Join-Path $pdfExtract 'LICENSE*') $pdfRuntime
+@'
+Windows native static pdf2htmlEX 0.14.6 with poppler-data.
+Source and binary distribution: https://soft.rubypdf.com/software/pdf2htmlex-windows-version
+Archive SHA-256: e92aa55699c3e9d9b4b4954bea157e59b5c3363cbe9a7713495c553544026354
+'@ | Set-Content -Path (Join-Path $pdfRuntime 'SOURCE.txt') -Encoding UTF8
+Remove-Item -LiteralPath $pdfArchive, $pdfExtract -Recurse -Force
+
+choco install tesseract --yes --no-progress
+$tesseractRoot = Join-Path $env:ProgramFiles 'Tesseract-OCR'
+if (-not (Test-Path (Join-Path $tesseractRoot 'tesseract.exe'))) { throw 'Tesseract installation was not found.' }
+
+$popplerArchive = Join-Path $RuntimeRoot 'poppler.zip'
+Get-VerifiedArchive -Url 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.02.0-0/Release-26.02.0-0.zip' -Path $popplerArchive -Sha256 '993e4a94376ed712fafc7058d724ea0b943d118bbd2305cd9ed55174eb85cda5'
+$popplerExtract = Join-Path $RuntimeRoot 'poppler-extract'
+Expand-Archive -LiteralPath $popplerArchive -DestinationPath $popplerExtract
+$popplerBin = Join-Path $popplerExtract 'poppler-26.02.0/Library/bin'
+$ocrRuntime = Join-Path $RuntimeRoot 'ocr'
+New-Item -ItemType Directory -Force -Path (Join-Path $ocrRuntime 'bin'), (Join-Path $ocrRuntime 'poppler/bin'), (Join-Path $ocrRuntime 'share/tessdata') | Out-Null
+Copy-Item (Join-Path $tesseractRoot '*') (Join-Path $ocrRuntime 'bin') -Recurse -Force
+Copy-Item (Join-Path $popplerBin '*') (Join-Path $ocrRuntime 'poppler/bin') -Recurse -Force
+Copy-Item (Join-Path $popplerExtract 'poppler-26.02.0/Library/share') (Join-Path $ocrRuntime 'share/poppler') -Recurse -Force
+
+$trainedData = @{
+ eng = '7d4322bd2a7749724879683fc3912cb542f19906c83bcc1a52132556427170b2'
+ deu = '19d219bbb6672c869d20a9636c6816a81eb9a71796cb93ebe0cb1530e2cdb22d'
+ osd = '9cf5d576fcc47564f11265841e5ca839001e7e6f38ff7f7aacf46d15a96b00ff'
+}
+foreach ($language in $trainedData.Keys) {
+ $destination = Join-Path $ocrRuntime "share/tessdata/$language.traineddata"
+ Invoke-WebRequest -Uri "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/4.1.0/$language.traineddata" -OutFile $destination -UseBasicParsing
+ $actual = (Get-FileHash -Path $destination -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actual -ne $trainedData[$language]) { throw "Checksum failed for $language.traineddata" }
+}
+Remove-Item -LiteralPath $popplerArchive, $popplerExtract -Recurse -Force
+@'
+Windows native OCR runtime.
+Tesseract 5 package: https://community.chocolatey.org/packages/tesseract
+Poppler 26.02.0: https://github.com/oschwartz10612/poppler-windows/releases/tag/v26.02.0-0
+Tesseract fast language data 4.1.0: https://github.com/tesseract-ocr/tessdata_fast/tree/4.1.0
+'@ | Set-Content -Path (Join-Path $ocrRuntime 'SOURCE.txt') -Encoding UTF8
+
+choco install libreoffice-fresh --yes --no-progress
+$officeSource = Join-Path $env:ProgramFiles 'LibreOffice'
+if (-not (Test-Path (Join-Path $officeSource 'program/soffice.exe'))) { throw 'LibreOffice installation was not found.' }
+Copy-Item $officeSource (Join-Path $RuntimeRoot 'office') -Recurse
+@'
+Windows x64 native LibreOffice runtime from the libreoffice-fresh Chocolatey package.
+https://community.chocolatey.org/packages/libreoffice-fresh
+'@ | Set-Content -Path (Join-Path $RuntimeRoot 'office/SOURCE.txt') -Encoding UTF8
diff --git a/desktop/scripts/prepare.mjs b/desktop/scripts/prepare.mjs
index 91cfc1e..6b4e99c 100644
--- a/desktop/scripts/prepare.mjs
+++ b/desktop/scripts/prepare.mjs
@@ -2,9 +2,8 @@ import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { access, cp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
-import { dirname, join, resolve } from 'node:path';
+import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { PNG } from 'pngjs';
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = resolve(desktopRoot, '..');
@@ -12,17 +11,27 @@ const tauriRoot = join(desktopRoot, 'src-tauri');
const resourcesRoot = join(tauriRoot, 'resources');
const cacheRoot = join(desktopRoot, '.cache');
const nodeVersion = 'v24.18.0';
-const nodeArchiveName = `node-${nodeVersion}-darwin-arm64.tar.gz`;
+const supportedTargets = new Set(['darwin-arm64', 'win32-x64', 'linux-x64']);
+const target = `${process.platform}-${process.arch}`;
+const nodePlatform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win' : 'linux';
+const nodeExtension = process.platform === 'win32' ? 'zip' : 'tar.xz';
+const nodeArchiveName = `node-${nodeVersion}-${nodePlatform}-${process.arch}.${nodeExtension}`;
const nodeDownloadBase = `https://nodejs.org/dist/${nodeVersion}`;
function run(binary, args, options = {}) {
execFileSync(binary, args, {
cwd: options.cwd ?? repositoryRoot,
env: { ...process.env, ...options.env },
- stdio: 'inherit'
+ stdio: options.stdio ?? 'inherit'
});
}
+function runNpm(args, options = {}) {
+ const npmCli = process.env.npm_execpath;
+ if (!npmCli) throw new Error('Run desktop staging through npm so npm_execpath is available.');
+ run(process.execPath, [npmCli, ...args], options);
+}
+
async function exists(path) {
try {
await access(path);
@@ -34,17 +43,13 @@ async function exists(path) {
async function download(url, destination) {
const response = await fetch(url);
- if (!response.ok || !response.body) {
- throw new Error(`Could not download ${url}: HTTP ${response.status}`);
- }
+ if (!response.ok || !response.body) throw new Error(`Could not download ${url}: HTTP ${response.status}`);
await writeFile(destination, new Uint8Array(await response.arrayBuffer()));
}
async function verifyNodeArchive(archivePath) {
const checksumPath = join(cacheRoot, `SHASUMS256-${nodeVersion}.txt`);
- if (!(await exists(checksumPath))) {
- await download(`${nodeDownloadBase}/SHASUMS256.txt`, checksumPath);
- }
+ if (!(await exists(checksumPath))) await download(`${nodeDownloadBase}/SHASUMS256.txt`, checksumPath);
const checksums = await readFile(checksumPath, 'utf8');
const checksumLine = checksums.split('\n').find((line) => line.endsWith(` ${nodeArchiveName}`));
if (!checksumLine) throw new Error(`Node checksum is missing for ${nodeArchiveName}.`);
@@ -55,20 +60,18 @@ async function verifyNodeArchive(archivePath) {
async function prepareNodeRuntime() {
const archivePath = join(cacheRoot, nodeArchiveName);
- if (!(await exists(archivePath))) {
- await download(`${nodeDownloadBase}/${nodeArchiveName}`, archivePath);
- }
+ if (!(await exists(archivePath))) await download(`${nodeDownloadBase}/${nodeArchiveName}`, archivePath);
await verifyNodeArchive(archivePath);
-
- const extractionRoot = await mkdir(join(tmpdir(), `docuflex-node-${process.pid}`), { recursive: true }).then(() =>
- join(tmpdir(), `docuflex-node-${process.pid}`)
- );
+ const extractionRoot = join(tmpdir(), `docuflex-node-${process.pid}`);
+ await rm(extractionRoot, { recursive: true, force: true });
+ await mkdir(extractionRoot, { recursive: true });
try {
- run('tar', ['-xzf', archivePath, '-C', extractionRoot]);
- const extractedNode = join(extractionRoot, `node-${nodeVersion}-darwin-arm64`);
+ run('tar', ['-xf', archivePath, '-C', extractionRoot]);
+ const extractedNode = join(extractionRoot, `node-${nodeVersion}-${nodePlatform}-${process.arch}`);
const bundledNode = join(resourcesRoot, 'runtime', 'node');
await mkdir(join(bundledNode, 'bin'), { recursive: true });
- await cp(join(extractedNode, 'bin', 'node'), join(bundledNode, 'bin', 'node'));
+ const nodeName = process.platform === 'win32' ? 'node.exe' : 'node';
+ await cp(join(extractedNode, nodeName === 'node.exe' ? nodeName : 'bin/node'), join(bundledNode, 'bin', nodeName));
await cp(join(extractedNode, 'LICENSE'), join(bundledNode, 'LICENSE'));
} finally {
await rm(extractionRoot, { recursive: true, force: true });
@@ -76,13 +79,18 @@ async function prepareNodeRuntime() {
}
function javaHome() {
- return execFileSync('/usr/libexec/java_home', ['-v', '17'], { encoding: 'utf8' }).trim();
+ if (process.env.JAVA_HOME?.trim()) return process.env.JAVA_HOME.trim();
+ if (process.platform === 'darwin') {
+ return execFileSync('/usr/libexec/java_home', ['-v', '17'], { encoding: 'utf8' }).trim();
+ }
+ throw new Error('JAVA_HOME must point to a JDK 17 runtime on Windows and Linux.');
}
async function prepareJavaRuntime() {
const jdk = javaHome();
const javaRuntime = join(resourcesRoot, 'runtime', 'java');
- run(join(jdk, 'bin', 'jlink'), [
+ const jlink = join(jdk, 'bin', process.platform === 'win32' ? 'jlink.exe' : 'jlink');
+ run(jlink, [
'--add-modules',
'java.base,java.desktop,java.naming,java.prefs,java.sql,jdk.httpserver',
'--strip-debug',
@@ -92,42 +100,62 @@ async function prepareJavaRuntime() {
'--output',
javaRuntime
]);
- const legalDirectory = join(javaRuntime, 'legal');
- const expandedLegalDirectory = join(javaRuntime, 'legal-expanded');
- run('cp', ['-RL', legalDirectory, expandedLegalDirectory]);
- await rm(legalDirectory, { recursive: true, force: true });
- await rename(expandedLegalDirectory, legalDirectory);
- run('chmod', ['-R', 'u+rw', legalDirectory]);
+ if (process.platform === 'darwin') {
+ const legalDirectory = join(javaRuntime, 'legal');
+ const expandedLegalDirectory = join(javaRuntime, 'legal-expanded');
+ run('cp', ['-RL', legalDirectory, expandedLegalDirectory]);
+ await rm(legalDirectory, { recursive: true, force: true });
+ await rename(expandedLegalDirectory, legalDirectory);
+ run('chmod', ['-R', 'u+rw', legalDirectory]);
+ }
}
async function preparePythonRuntime() {
- const source = process.env.DOCUFLEX_PYTHON_RUNTIME?.trim()
- || join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/python');
- const sourceLibrary = join(source, 'lib', 'python3.12');
- if (!(await exists(join(source, 'bin', 'python3.12'))) || !(await exists(sourceLibrary))) {
- throw new Error('Set DOCUFLEX_PYTHON_RUNTIME to the arm64 Python 3.12 runtime used for desktop conversion.');
+ const configured = process.env.DOCUFLEX_PYTHON_RUNTIME?.trim();
+ const source = configured || (process.platform === 'darwin'
+ ? join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/python')
+ : '');
+ if (!source || !(await exists(source))) {
+ throw new Error('DOCUFLEX_PYTHON_RUNTIME must point to a self-contained native Python 3.12 runtime.');
}
-
const destination = join(resourcesRoot, 'runtime', 'python');
- await mkdir(join(destination, 'bin'), { recursive: true });
- await mkdir(join(destination, 'lib'), { recursive: true });
- await cp(join(source, 'bin', 'python3.12'), join(destination, 'bin', 'python3.12'));
- await cp(join(source, 'lib', 'libpython3.12.dylib'), join(destination, 'lib', 'libpython3.12.dylib'));
- await cp(sourceLibrary, join(destination, 'lib', 'python3.12'), {
- recursive: true,
- filter: (path) => !path.startsWith(join(sourceLibrary, 'site-packages'))
- });
+ if (process.platform === 'darwin' && !configured) {
+ const sourceLibrary = join(source, 'lib', 'python3.12');
+ await mkdir(join(destination, 'bin'), { recursive: true });
+ await mkdir(join(destination, 'lib'), { recursive: true });
+ await cp(join(source, 'bin', 'python3.12'), join(destination, 'bin', 'python3.12'));
+ await cp(join(source, 'lib', 'libpython3.12.dylib'), join(destination, 'lib', 'libpython3.12.dylib'));
+ await cp(sourceLibrary, join(destination, 'lib', 'python3.12'), {
+ recursive: true,
+ filter: (path) => !path.startsWith(join(sourceLibrary, 'site-packages'))
+ });
+ const sitePackages = join(destination, 'lib', 'python3.12', 'site-packages');
+ const sourcePackages = join(sourceLibrary, 'site-packages');
+ await mkdir(sitePackages, { recursive: true });
+ for (const packageName of ['PIL', 'docx', 'et_xmlfile', 'lxml', 'openpyxl', 'pptx', 'typing_extensions.py', 'xlsxwriter']) {
+ const packageSource = join(sourcePackages, packageName);
+ if (!(await exists(packageSource))) throw new Error(`Desktop conversion dependency is missing: ${packageName}`);
+ await cp(packageSource, join(sitePackages, packageName), { recursive: true });
+ }
+ await writeFile(join(destination, 'bin', 'python3'), '#!/bin/sh\nexec "$(dirname "$0")/python3.12" "$@"\n');
+ run('chmod', ['+x', join(destination, 'bin', 'python3'), join(destination, 'bin', 'python3.12')]);
+ } else {
+ await cp(source, destination, { recursive: true, dereference: process.platform === 'win32' });
+ }
+ const executable = process.platform === 'win32'
+ ? join(destination, 'python.exe')
+ : join(destination, 'bin', 'python3');
+ if (!(await exists(executable))) throw new Error(`Bundled Python executable is missing: ${executable}`);
+ run(executable, ['-c', 'import PIL, docx, lxml, openpyxl, pptx, xlsxwriter'], { cwd: destination });
+}
- const sitePackages = join(destination, 'lib', 'python3.12', 'site-packages');
- const sourcePackages = join(sourceLibrary, 'site-packages');
- await mkdir(sitePackages, { recursive: true });
- for (const packageName of ['PIL', 'docx', 'et_xmlfile', 'lxml', 'openpyxl', 'pptx', 'typing_extensions.py', 'xlsxwriter']) {
- const packageSource = join(sourcePackages, packageName);
- if (!(await exists(packageSource))) throw new Error(`Desktop conversion dependency is missing: ${packageName}`);
- await cp(packageSource, join(sitePackages, packageName), { recursive: true });
+async function copyRuntime(name, environmentName, fallback) {
+ const configured = process.env[environmentName]?.trim();
+ const source = configured || fallback;
+ if (!source || !(await exists(source))) {
+ throw new Error(`${environmentName} must point to the prepared native ${name} runtime for ${target}.`);
}
- await writeFile(join(destination, 'bin', 'python3'), '#!/bin/sh\nexec "$(dirname "$0")/python3.12" "$@"\n');
- run('chmod', ['+x', join(destination, 'bin', 'python3'), join(destination, 'bin', 'python3.12')]);
+ await cp(source, join(resourcesRoot, 'runtime', name), { recursive: true, dereference: true });
}
async function copyApplicationResources() {
@@ -137,71 +165,81 @@ async function copyApplicationResources() {
await cp(join(repositoryRoot, 'backend', 'fonts'), join(resourcesRoot, 'backend', 'fonts'), { recursive: true });
await mkdir(join(resourcesRoot, 'scripts'), { recursive: true });
for (const file of await readdir(join(repositoryRoot, 'scripts'))) {
- if (!file.endsWith('.py')) continue;
- await cp(join(repositoryRoot, 'scripts', file), join(resourcesRoot, 'scripts', file));
+ if (file.endsWith('.py')) await cp(join(repositoryRoot, 'scripts', file), join(resourcesRoot, 'scripts', file));
}
await mkdir(join(resourcesRoot, 'runtime'), { recursive: true });
- await cp(join(desktopRoot, 'runtime', 'supervise.sh'), join(resourcesRoot, 'runtime', 'supervise.sh'));
- await cp(join(desktopRoot, 'runtime', 'soffice-shim.sh'), join(resourcesRoot, 'runtime', 'soffice-shim.sh'));
- run('chmod', ['+x', join(resourcesRoot, 'runtime', 'soffice-shim.sh')]);
- await cp(
- join(desktopRoot, 'vendor', 'pdf2htmlEX-macos-arm64'),
- join(resourcesRoot, 'runtime', 'pdf2htmlEX'),
- { recursive: true }
- );
- run('chmod', ['-R', 'u+w', join(resourcesRoot, 'runtime', 'pdf2htmlEX')]);
- await cp(
- join(desktopRoot, 'vendor', 'ocr-macos-arm64'),
- join(resourcesRoot, 'runtime', 'ocr'),
- { recursive: true }
- );
- run('chmod', ['-R', 'u+w', join(resourcesRoot, 'runtime', 'ocr')]);
+ if (process.platform === 'win32') {
+ await cp(join(desktopRoot, 'runtime', 'supervise.ps1'), join(resourcesRoot, 'runtime', 'supervise.ps1'));
+ } else {
+ await cp(join(desktopRoot, 'runtime', 'supervise.sh'), join(resourcesRoot, 'runtime', 'supervise.sh'));
+ }
+
+ const macPdfRuntime = process.platform === 'darwin' ? join(desktopRoot, 'vendor', 'pdf2htmlEX-macos-arm64') : '';
+ const macOcrRuntime = process.platform === 'darwin' ? join(desktopRoot, 'vendor', 'ocr-macos-arm64') : '';
+ await copyRuntime('pdf2htmlEX', 'DOCUFLEX_PDF2HTMLEX_RUNTIME', macPdfRuntime);
+ await copyRuntime('ocr', 'DOCUFLEX_OCR_RUNTIME', macOcrRuntime);
+
+ if (process.platform === 'darwin' && !process.env.DOCUFLEX_OFFICE_RUNTIME?.trim()) {
+ const officeBin = join(resourcesRoot, 'runtime', 'office', 'bin');
+ await mkdir(officeBin, { recursive: true });
+ await cp(join(desktopRoot, 'runtime', 'soffice-shim.sh'), join(officeBin, 'soffice'));
+ } else {
+ await copyRuntime('office', 'DOCUFLEX_OFFICE_RUNTIME', '');
+ }
+
+ if (process.platform !== 'win32') {
+ run('chmod', ['-R', 'u+rwX', join(resourcesRoot, 'runtime')]);
+ for (const executable of [
+ join(resourcesRoot, 'runtime', 'supervise.sh'),
+ join(resourcesRoot, 'runtime', 'office', 'bin', 'soffice'),
+ join(resourcesRoot, 'runtime', 'office', 'program', 'soffice'),
+ join(resourcesRoot, 'runtime', 'pdf2htmlEX', 'bin', 'pdf2htmlEX'),
+ join(resourcesRoot, 'runtime', 'ocr', 'bin', 'pdftoppm'),
+ join(resourcesRoot, 'runtime', 'ocr', 'bin', 'pdfunite'),
+ join(resourcesRoot, 'runtime', 'ocr', 'bin', 'tesseract')
+ ]) {
+ if (await exists(executable)) run('chmod', ['+x', executable]);
+ }
+ }
}
-async function generateMacIcon() {
+async function generateIcons() {
const source = join(repositoryRoot, 'public', 'macos-icon-iOS-Default-1024x1024@1x.png');
- const iconset = join(cacheRoot, 'Docuflex.iconset');
const iconRoot = join(tauriRoot, 'icons');
- await rm(iconset, { recursive: true, force: true });
- await mkdir(iconset, { recursive: true });
- await mkdir(iconRoot, { recursive: true });
- const parsedIcon = PNG.sync.read(await readFile(source));
- await writeFile(join(iconRoot, 'icon.png'), PNG.sync.write(parsedIcon, { colorType: 6, bitDepth: 8 }));
-
- const variants = [
- [16, 'icon_16x16.png'],
- [32, 'icon_16x16@2x.png'],
- [32, 'icon_32x32.png'],
- [64, 'icon_32x32@2x.png'],
- [128, 'icon_128x128.png'],
- [256, 'icon_128x128@2x.png'],
- [256, 'icon_256x256.png'],
- [512, 'icon_256x256@2x.png'],
- [512, 'icon_512x512.png'],
- [1024, 'icon_512x512@2x.png']
- ];
- for (const [size, name] of variants) {
- run('sips', ['-z', String(size), String(size), source, '--out', join(iconset, name)]);
- }
- run('iconutil', ['-c', 'icns', iconset, '-o', join(iconRoot, 'icon.icns')]);
+ await rm(iconRoot, { recursive: true, force: true });
+ const tauriCli = join(desktopRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
+ run(process.execPath, [tauriCli, 'icon', source, '--output', iconRoot], { cwd: desktopRoot });
+}
+
+async function writeRuntimeManifest() {
+ const manifest = {
+ target,
+ generatedAt: new Date().toISOString(),
+ node: nodeVersion,
+ java: '17 (jlink)',
+ python: '3.12',
+ pdf2htmlEX: process.platform === 'win32' ? '0.14.6 native static' : '0.18.8.rc1',
+ ocr: 'Tesseract with eng, deu, and osd data',
+ offline: true
+ };
+ await writeFile(join(resourcesRoot, 'RUNTIME.json'), `${JSON.stringify(manifest, null, 2)}\n`);
}
async function main() {
- if (process.platform !== 'darwin' || process.arch !== 'arm64') {
- throw new Error('This desktop package currently targets Apple Silicon macOS.');
- }
+ if (!supportedTargets.has(target)) throw new Error(`Unsupported desktop build target: ${target}`);
await mkdir(cacheRoot, { recursive: true });
await rm(resourcesRoot, { recursive: true, force: true });
await rm(join(tauriRoot, 'target', 'release', 'resources'), { recursive: true, force: true });
await mkdir(resourcesRoot, { recursive: true });
- run('npm', ['run', 'build']);
- run('npm', ['run', 'backend:compile']);
+ runNpm(['run', 'build']);
+ runNpm(['run', 'backend:compile']);
await copyApplicationResources();
await prepareNodeRuntime();
await prepareJavaRuntime();
await preparePythonRuntime();
- await generateMacIcon();
+ await generateIcons();
+ await writeRuntimeManifest();
}
await main();
diff --git a/desktop/scripts/smoke-runtimes.mjs b/desktop/scripts/smoke-runtimes.mjs
new file mode 100644
index 0000000..390e347
--- /dev/null
+++ b/desktop/scripts/smoke-runtimes.mjs
@@ -0,0 +1,95 @@
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+
+const desktopRoot = resolve(import.meta.dirname, '..');
+const resources = join(desktopRoot, 'src-tauri', 'resources');
+const windows = process.platform === 'win32';
+const executable = (runtime, name) => join(resources, 'runtime', runtime, 'bin', `${name}${windows ? '.exe' : ''}`);
+const ocrExecutable = (name) => windows && name !== 'tesseract'
+ ? join(resources, 'runtime', 'ocr', 'poppler', 'bin', `${name}.exe`)
+ : executable('ocr', name);
+const python = windows ? join(resources, 'runtime', 'python', 'python.exe') : executable('python', 'python3');
+const office = process.platform === 'win32'
+ ? join(resources, 'runtime', 'office', 'program', 'soffice.exe')
+ : process.platform === 'linux'
+ ? join(resources, 'runtime', 'office', 'program', 'soffice')
+ : join(resources, 'runtime', 'office', 'bin', 'soffice');
+
+function run(binary, args, options = {}) {
+ return execFileSync(binary, args, {
+ cwd: options.cwd ?? resources,
+ encoding: options.encoding ?? 'utf8',
+ env: { ...process.env, ...options.env },
+ stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
+ timeout: options.timeout ?? 30_000
+ });
+}
+
+function minimalPdf(text) {
+ const objects = [
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
+ `<< /Length ${text.length + 31} >>\nstream\nBT /F1 24 Tf 72 720 Td (${text}) Tj ET\nendstream`,
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
+ ];
+ let pdf = '%PDF-1.4\n';
+ const offsets = [0];
+ objects.forEach((object, index) => {
+ offsets.push(Buffer.byteLength(pdf));
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
+ });
+ const xref = Buffer.byteLength(pdf);
+ pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+ pdf += offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('');
+ pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+ return pdf;
+}
+
+const temporary = await mkdtemp(join(tmpdir(), 'docuflex-runtime-smoke-'));
+try {
+ run(executable('node', 'node'), ['--version']);
+ run(executable('java', 'java'), ['--version']);
+ run(python, ['-c', 'import PIL, docx, lxml, openpyxl, pptx, xlsxwriter; print("python-ok")']);
+ run(ocrExecutable('pdftoppm'), ['-v']);
+ run(ocrExecutable('pdfunite'), ['-v']);
+ const languages = run(ocrExecutable('tesseract'), ['--list-langs'], {
+ env: { TESSDATA_PREFIX: join(resources, 'runtime', 'ocr', 'share', 'tessdata') }
+ });
+ for (const language of ['eng', 'deu', 'osd']) {
+ if (!languages.split(/\s+/).includes(language)) throw new Error(`OCR language is missing: ${language}`);
+ }
+
+ const image = join(temporary, 'ocr.png');
+ const font = join(resources, 'backend', 'fonts', 'inter-variable-normal.ttf');
+ run(python, ['-c', [
+ 'from PIL import Image, ImageDraw, ImageFont',
+ `image=Image.new("RGB",(1200,260),"white")`,
+ `font=ImageFont.truetype(${JSON.stringify(font)},96)`,
+ 'ImageDraw.Draw(image).text((35,55),"DOCUFLEX OFFLINE",font=font,fill="black")',
+ `image.save(${JSON.stringify(image)})`
+ ].join(';')]);
+ const ocrText = run(ocrExecutable('tesseract'), [image, 'stdout', '-l', 'eng', '--psm', '7'], {
+ env: { TESSDATA_PREFIX: join(resources, 'runtime', 'ocr', 'share', 'tessdata') },
+ timeout: 60_000
+ });
+ if (!/docuflex/i.test(ocrText)) throw new Error(`Bundled OCR smoke test failed: ${ocrText.trim()}`);
+
+ const pdf = join(temporary, 'document.pdf');
+ const html = join(temporary, 'document.html');
+ await writeFile(pdf, minimalPdf('Docuflex Offline'));
+ run(executable('pdf2htmlEX', 'pdf2htmlEX'), [
+ '--data-dir', join(resources, 'runtime', 'pdf2htmlEX', 'share', 'pdf2htmlEX'),
+ '--quiet', '1', '--embed', '1', '--correct-text-visibility', '0',
+ '--dest-dir', temporary, pdf, 'document.html'
+ ], { timeout: 120_000 });
+ const converted = await readFile(html, 'utf8');
+ if (!/Docuflex/.test(converted)) throw new Error('Bundled pdf2htmlEX smoke test did not preserve text.');
+
+ if (process.platform !== 'darwin') run(office, ['--headless', '--version'], { timeout: 60_000 });
+ process.stdout.write(`Docuflex ${process.platform}-${process.arch} offline runtime smoke test passed.\n`);
+} finally {
+ await rm(temporary, { recursive: true, force: true });
+}
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 7ce04d5..4580b69 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -745,6 +745,15 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -773,6 +782,7 @@ name = "docuflex-desktop"
version = "0.0.1"
dependencies = [
"libc",
+ "rfd",
"tauri",
"tauri-build",
"tauri-plugin-single-instance",
@@ -794,6 +804,12 @@ dependencies = [
"tendril",
]
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
[[package]]
name = "dpi"
version = "0.1.2"
@@ -2417,7 +2433,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
- "quick-xml",
+ "quick-xml 0.41.0",
"serde",
"time",
]
@@ -2462,6 +2478,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2545,6 +2567,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -2684,6 +2715,33 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfd"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "js-sys",
+ "libc",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "percent-encoding",
+ "pollster",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -2778,6 +2836,12 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4088,6 +4152,66 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.13.1",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml 0.39.4",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.103"
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index f0837bd..71da4ab 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "docuflex-desktop"
version = "0.0.1"
-description = "Docuflex PDF editor for macOS"
+description = "Docuflex offline PDF editor for macOS, Windows, and Linux"
authors = ["Maximilian Bayer"]
edition = "2021"
@@ -14,6 +14,7 @@ tauri-build = { version = "2.5.6", features = [] }
[dependencies]
libc = "0.2.186"
+rfd = "0.17.2"
tauri = { version = "2.11.5", features = [] }
tauri-plugin-single-instance = "2.4.0"
url = "2.5.7"
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 0a1a4b7..59656d9 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -1,4 +1,5 @@
use std::{
+ ffi::OsString,
fs::{self, File},
io::{Read, Write},
net::{SocketAddr, TcpStream},
@@ -8,10 +9,9 @@ use std::{
thread,
time::{Duration, Instant},
};
-use tauri::{
- webview::DownloadEvent, LogicalPosition, Manager, TitleBarStyle, WebviewUrl,
- WebviewWindowBuilder,
-};
+use tauri::{webview::DownloadEvent, Manager, WebviewUrl, WebviewWindowBuilder};
+#[cfg(target_os = "macos")]
+use tauri::{LogicalPosition, TitleBarStyle};
use url::Url;
const FRONTEND_PORT: u16 = 43_127;
@@ -29,6 +29,14 @@ impl Services {
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
+ #[cfg(target_os = "windows")]
+ {
+ let _ = Command::new("taskkill.exe")
+ .args(["/PID", &child.id().to_string(), "/T", "/F"])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status();
+ }
}
for child in children.iter_mut() {
let _ = child.wait();
@@ -38,6 +46,83 @@ impl Services {
}
}
+fn runtime_executable(resource_root: &Path, runtime: &str, name: &str) -> PathBuf {
+ #[cfg(target_os = "windows")]
+ let executable = format!("{name}.exe");
+ #[cfg(not(target_os = "windows"))]
+ let executable = name.to_string();
+ resource_root
+ .join("runtime")
+ .join(runtime)
+ .join("bin")
+ .join(executable)
+}
+
+fn python_executable(resource_root: &Path) -> PathBuf {
+ #[cfg(target_os = "windows")]
+ return resource_root.join("runtime/python/python.exe");
+ #[cfg(not(target_os = "windows"))]
+ return resource_root.join("runtime/python/bin/python3");
+}
+
+fn office_executable(resource_root: &Path) -> PathBuf {
+ #[cfg(target_os = "windows")]
+ return resource_root.join("runtime/office/program/soffice.exe");
+ #[cfg(target_os = "linux")]
+ return resource_root.join("runtime/office/program/soffice");
+ #[cfg(target_os = "macos")]
+ return resource_root.join("runtime/office/bin/soffice");
+}
+
+fn ocr_executable(resource_root: &Path, name: &str) -> PathBuf {
+ #[cfg(target_os = "windows")]
+ {
+ if name != "tesseract" {
+ return resource_root
+ .join("runtime/ocr/poppler/bin")
+ .join(format!("{name}.exe"));
+ }
+ }
+ runtime_executable(resource_root, "ocr", name)
+}
+
+fn supervised_command(
+ resource_root: &Path,
+ parent_pid: &str,
+ executable: &Path,
+ arguments: &[OsString],
+) -> Command {
+ #[cfg(target_os = "windows")]
+ {
+ let mut command = Command::new("powershell.exe");
+ command
+ .arg("-NoLogo")
+ .arg("-NoProfile")
+ .arg("-NonInteractive")
+ .arg("-ExecutionPolicy")
+ .arg("Bypass")
+ .arg("-File")
+ .arg(resource_root.join("runtime/supervise.ps1"))
+ .arg("-DocuflexParentPid")
+ .arg(parent_pid)
+ .arg("-FilePath")
+ .arg(executable)
+ .arg("--")
+ .args(arguments);
+ command
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ let mut command = Command::new("/bin/sh");
+ command
+ .arg(resource_root.join("runtime/supervise.sh"))
+ .arg(parent_pid)
+ .arg(executable)
+ .args(arguments);
+ command
+ }
+}
+
impl Drop for Services {
fn drop(&mut self) {
self.stop();
@@ -58,22 +143,27 @@ fn spawn_services(
resource_root: &Path,
log_directory: &Path,
) -> Result, Box> {
- let java = resource_root.join("runtime/java/bin/java");
- let node = resource_root.join("runtime/node/bin/node");
+ let java = runtime_executable(resource_root, "java", "java");
+ let node = runtime_executable(resource_root, "node", "node");
let pdf2html = resource_root.join("runtime/pdf2htmlEX");
let ocr = resource_root.join("runtime/ocr");
- let python = resource_root.join("runtime/python/bin/python3");
- let document_converter = resource_root.join("runtime/soffice-shim.sh");
- let supervisor = resource_root.join("runtime/supervise.sh");
+ let python = python_executable(resource_root);
+ let document_converter = office_executable(resource_root);
let parent_pid = std::process::id().to_string();
+ #[cfg(target_os = "windows")]
+ let classpath = "backend/out;backend/lib/pdfbox-app-3.0.8.jar";
+ #[cfg(not(target_os = "windows"))]
let classpath = "backend/out:backend/lib/pdfbox-app-3.0.8.jar";
+ let backend_arguments = [
+ OsString::from("-cp"),
+ OsString::from(classpath),
+ OsString::from("DocuflexPdfServer"),
+ ];
let (backend_stdout, backend_stderr) = log_file(log_directory, "pdf-backend")?;
- let backend = Command::new("/bin/sh")
+ let mut backend_command =
+ supervised_command(resource_root, &parent_pid, &java, &backend_arguments);
+ let backend = backend_command
.current_dir(resource_root)
- .arg(&supervisor)
- .arg(&parent_pid)
- .arg(java)
- .args(["-cp", classpath, "DocuflexPdfServer"])
.env("PDF_BACKEND_HOST", "127.0.0.1")
.env("PDF_BACKEND_PORT", BACKEND_PORT.to_string())
.env(
@@ -85,13 +175,12 @@ fn spawn_services(
.stderr(backend_stderr)
.spawn()?;
+ let frontend_arguments = [OsString::from("frontend/index.js")];
let (frontend_stdout, frontend_stderr) = log_file(log_directory, "frontend")?;
- let frontend = Command::new("/bin/sh")
+ let mut frontend_command =
+ supervised_command(resource_root, &parent_pid, &node, &frontend_arguments);
+ frontend_command
.current_dir(resource_root)
- .arg(&supervisor)
- .arg(&parent_pid)
- .arg(node)
- .arg("frontend/index.js")
.env("HOST", "127.0.0.1")
.env("PORT", FRONTEND_PORT.to_string())
.env("ORIGIN", format!("http://127.0.0.1:{FRONTEND_PORT}"))
@@ -103,18 +192,23 @@ fn spawn_services(
.env("ADDRESS_HEADER", "")
.env("PROTOCOL_HEADER", "")
.env("HOST_HEADER", "")
- .env("PDF2HTMLEX_BIN", pdf2html.join("bin/pdf2htmlEX"))
+ .env(
+ "PDF2HTMLEX_BIN",
+ runtime_executable(resource_root, "pdf2htmlEX", "pdf2htmlEX"),
+ )
.env("PDF2HTMLEX_DATA_DIR", pdf2html.join("share/pdf2htmlEX"))
- .env("FONTCONFIG_PATH", pdf2html.join("etc/fonts"))
- .env("FONTCONFIG_FILE", "fonts.conf")
- .env("PDFTOPPM_BIN", ocr.join("bin/pdftoppm"))
- .env("PDFUNITE_BIN", ocr.join("bin/pdfunite"))
- .env("TESSERACT_BIN", ocr.join("bin/tesseract"))
+ .env("PDFTOPPM_BIN", ocr_executable(resource_root, "pdftoppm"))
+ .env("PDFUNITE_BIN", ocr_executable(resource_root, "pdfunite"))
+ .env("TESSERACT_BIN", ocr_executable(resource_root, "tesseract"))
.env("TESSDATA_PREFIX", ocr.join("share/tessdata"))
- .env("PDF_RENDER_BIN", ocr.join("bin/pdftoppm"))
+ .env("PDF_RENDER_BIN", ocr_executable(resource_root, "pdftoppm"))
.env("DOCUMENT_CONVERTER_PYTHON", python)
- .env("DOCUMENT_CONVERTER_BIN", document_converter)
- .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
+ .env("DOCUMENT_CONVERTER_BIN", document_converter);
+ #[cfg(target_os = "macos")]
+ frontend_command
+ .env("FONTCONFIG_PATH", pdf2html.join("etc/fonts"))
+ .env("FONTCONFIG_FILE", "fonts.conf");
+ let frontend = frontend_command
.stdin(Stdio::null())
.stdout(frontend_stdout)
.stderr(frontend_stderr)
@@ -158,29 +252,9 @@ fn choose_download_destination(suggested: &Path) -> Option {
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("document.pdf");
- let script = r#"
-on run argv
- set suggestedName to item 1 of argv
- set chosenFile to choose file name with prompt "Save exported document" default name suggestedName
- return POSIX path of chosenFile
-end run
-"#;
- let output = Command::new("/usr/bin/osascript")
- .arg("-e")
- .arg(script)
- .arg(suggested_name)
- .output()
- .ok()?;
- if !output.status.success() {
- return None;
- }
- let selected = String::from_utf8(output.stdout).ok()?;
- let selected = selected.trim();
- if selected.is_empty() {
- None
- } else {
- Some(PathBuf::from(selected))
- }
+ rfd::FileDialog::new()
+ .set_file_name(suggested_name)
+ .save_file()
}
fn editor_initialization_script() -> &'static str {
@@ -221,19 +295,49 @@ pub fn run() {
let editor_url = Url::parse(&format!("http://127.0.0.1:{FRONTEND_PORT}/editor"))?;
let allowed_origin = editor_url.origin().ascii_serialization();
- WebviewWindowBuilder::new(app, "main", WebviewUrl::External(editor_url))
- .title("Docuflex")
+ let window_actions = app.handle().clone();
+ let window_builder =
+ WebviewWindowBuilder::new(app, "main", WebviewUrl::External(editor_url))
+ .title("Docuflex")
+ .inner_size(1440.0, 920.0)
+ .min_inner_size(960.0, 640.0)
+ .center();
+ #[cfg(target_os = "macos")]
+ let window_builder = window_builder
.title_bar_style(TitleBarStyle::Overlay)
.hidden_title(true)
- .traffic_light_position(LogicalPosition::new(24.0, 24.0))
- .inner_size(1440.0, 920.0)
- .min_inner_size(960.0, 640.0)
- .center()
+ .traffic_light_position(LogicalPosition::new(24.0, 24.0));
+ #[cfg(target_os = "windows")]
+ let window_builder = window_builder.decorations(false).shadow(true);
+ window_builder
.initialization_script(editor_initialization_script())
.on_navigation(move |url| {
if url.scheme() == "about" {
return true;
}
+ if url.origin().ascii_serialization() == allowed_origin {
+ if let Some(action) = url.path().strip_prefix("/__docuflex/window/") {
+ if let Some(window) = window_actions.get_webview_window("main") {
+ match action {
+ "minimize" => {
+ let _ = window.minimize();
+ }
+ "maximize" => {
+ if window.is_maximized().unwrap_or(false) {
+ let _ = window.unmaximize();
+ } else {
+ let _ = window.maximize();
+ }
+ }
+ "close" => {
+ let _ = window.close();
+ }
+ _ => {}
+ }
+ }
+ return false;
+ }
+ }
url.origin().ascii_serialization() == allowed_origin && url.path() == "/editor"
})
.on_download(|_webview, event| match event {
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index efcfcb5..481fe6b 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -14,13 +14,21 @@
},
"bundle": {
"active": true,
- "targets": ["app"],
- "icon": ["icons/icon.png", "icons/icon.icns"],
+ "targets": "all",
+ "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"resources": ["resources/**/*"],
"macOS": {
"minimumSystemVersion": "26.0",
"signingIdentity": "-",
"entitlements": "Entitlements.plist"
+ },
+ "windows": {
+ "webviewInstallMode": {
+ "type": "offlineInstaller"
+ },
+ "nsis": {
+ "installMode": "currentUser"
+ }
}
}
}
From 8ef8e2e362b544eeb5799c90792839da8b1162f0 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:31:01 +0200
Subject: [PATCH 02/32] Bundle Linux LibreOffice symlink targets
---
desktop/scripts/prepare-linux-runtimes.sh | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/desktop/scripts/prepare-linux-runtimes.sh b/desktop/scripts/prepare-linux-runtimes.sh
index 8e0d4ae..efe9097 100644
--- a/desktop/scripts/prepare-linux-runtimes.sh
+++ b/desktop/scripts/prepare-linux-runtimes.sh
@@ -68,7 +68,9 @@ for package in poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu t
fi
done
-cp -a /usr/lib/libreoffice/. "$RUNTIME_ROOT/office/"
+# Dereference Ubuntu's links into /usr/share/libreoffice so the packaged
+# runtime remains self-contained after it leaves the build machine.
+cp -aL /usr/lib/libreoffice/. "$RUNTIME_ROOT/office/"
chmod +x "$RUNTIME_ROOT/office/program/soffice" "$RUNTIME_ROOT/office/program/soffice.bin"
cat > "$RUNTIME_ROOT/ocr/SOURCE.txt" <<'EOF'
From 5c35136b84f7cea8f5912fbf743ee7eb17956972 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:35:27 +0200
Subject: [PATCH 03/32] Generate the Tauri desktop shell during staging
---
desktop/scripts/prepare.mjs | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/desktop/scripts/prepare.mjs b/desktop/scripts/prepare.mjs
index 6b4e99c..111732e 100644
--- a/desktop/scripts/prepare.mjs
+++ b/desktop/scripts/prepare.mjs
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = resolve(desktopRoot, '..');
const tauriRoot = join(desktopRoot, 'src-tauri');
+const distRoot = join(desktopRoot, 'dist');
const resourcesRoot = join(tauriRoot, 'resources');
const cacheRoot = join(desktopRoot, '.cache');
const nodeVersion = 'v24.18.0';
@@ -225,6 +226,21 @@ async function writeRuntimeManifest() {
await writeFile(join(resourcesRoot, 'RUNTIME.json'), `${JSON.stringify(manifest, null, 2)}\n`);
}
+async function writeDesktopShell() {
+ await rm(distRoot, { recursive: true, force: true });
+ await mkdir(distRoot, { recursive: true });
+ await writeFile(join(distRoot, 'index.html'), `
+
+
+
+
+ Docuflex
+
+
+
+`);
+}
+
async function main() {
if (!supportedTargets.has(target)) throw new Error(`Unsupported desktop build target: ${target}`);
await mkdir(cacheRoot, { recursive: true });
@@ -240,6 +256,7 @@ async function main() {
await preparePythonRuntime();
await generateIcons();
await writeRuntimeManifest();
+ await writeDesktopShell();
}
await main();
From df2e639000aa206eef2c099901a32cd0dd133ef3 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:37:43 +0200
Subject: [PATCH 04/32] Normalize legacy Windows pdf2htmlEX arguments
---
desktop/runtime/pdf2htmlEX-windows-wrapper.rs | 30 +++++++++++++++++++
desktop/scripts/prepare-windows-runtimes.ps1 | 8 +++--
2 files changed, 36 insertions(+), 2 deletions(-)
create mode 100644 desktop/runtime/pdf2htmlEX-windows-wrapper.rs
diff --git a/desktop/runtime/pdf2htmlEX-windows-wrapper.rs b/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
new file mode 100644
index 0000000..7e7e1c0
--- /dev/null
+++ b/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
@@ -0,0 +1,30 @@
+use std::env;
+use std::path::PathBuf;
+use std::process::{exit, Command};
+
+fn main() {
+ let executable = env::current_exe().expect("Could not locate the pdf2htmlEX launcher");
+ let native = executable
+ .parent()
+ .map(|directory| directory.join("pdf2htmlEX-native.exe"))
+ .unwrap_or_else(|| PathBuf::from("pdf2htmlEX-native.exe"));
+
+ let mut normalized = Vec::new();
+ let mut arguments = env::args_os().skip(1);
+ while let Some(argument) = arguments.next() {
+ let option = argument.to_string_lossy();
+ if option == "--quiet" || option == "--embed" {
+ let _ = arguments.next();
+ continue;
+ }
+ normalized.push(argument);
+ }
+
+ match Command::new(native).args(normalized).status() {
+ Ok(status) => exit(status.code().unwrap_or(1)),
+ Err(error) => {
+ eprintln!("Could not start bundled pdf2htmlEX: {error}");
+ exit(1);
+ }
+ }
+}
diff --git a/desktop/scripts/prepare-windows-runtimes.ps1 b/desktop/scripts/prepare-windows-runtimes.ps1
index 2d50f05..ec97b51 100644
--- a/desktop/scripts/prepare-windows-runtimes.ps1
+++ b/desktop/scripts/prepare-windows-runtimes.ps1
@@ -26,11 +26,15 @@ $pdfExtract = Join-Path $RuntimeRoot 'pdf2htmlEX-extract'
Expand-Archive -LiteralPath $pdfArchive -DestinationPath $pdfExtract
$pdfRuntime = Join-Path $RuntimeRoot 'pdf2htmlEX'
New-Item -ItemType Directory -Force -Path (Join-Path $pdfRuntime 'bin'), (Join-Path $pdfRuntime 'share') | Out-Null
-Copy-Item (Join-Path $pdfExtract 'pdf2htmlEX.exe') (Join-Path $pdfRuntime 'bin/pdf2htmlEX.exe')
+Copy-Item (Join-Path $pdfExtract 'pdf2htmlEX.exe') (Join-Path $pdfRuntime 'bin/pdf2htmlEX-native.exe')
+$pdfWrapperSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../runtime/pdf2htmlEX-windows-wrapper.rs'))
+& rustc $pdfWrapperSource -C opt-level=z -C strip=symbols -o (Join-Path $pdfRuntime 'bin/pdf2htmlEX.exe')
+if ($LASTEXITCODE -ne 0) { throw 'Could not compile the native Windows pdf2htmlEX launcher.' }
Copy-Item (Join-Path $pdfExtract 'data') (Join-Path $pdfRuntime 'share/pdf2htmlEX') -Recurse
Copy-Item (Join-Path $pdfExtract 'LICENSE*') $pdfRuntime
@'
-Windows native static pdf2htmlEX 0.14.6 with poppler-data.
+Windows native static pdf2htmlEX 0.14.6 with poppler-data and a native launcher
+that normalizes the newer endpoint CLI shorthands for this legacy build.
Source and binary distribution: https://soft.rubypdf.com/software/pdf2htmlex-windows-version
Archive SHA-256: e92aa55699c3e9d9b4b4954bea157e59b5c3363cbe9a7713495c553544026354
'@ | Set-Content -Path (Join-Path $pdfRuntime 'SOURCE.txt') -Encoding UTF8
From 16441ac2588a605f54df6223e4addb31e3a87582 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:46:36 +0200
Subject: [PATCH 05/32] Preserve bundled Linux runtime binaries
---
desktop/scripts/build.mjs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/desktop/scripts/build.mjs b/desktop/scripts/build.mjs
index 363cede..86448cc 100644
--- a/desktop/scripts/build.mjs
+++ b/desktop/scripts/build.mjs
@@ -10,8 +10,8 @@ const bundles = process.platform === 'darwin'
? ['nsis']
: ['appimage', 'deb'];
-execFileSync(process.execPath, [tauriCli, 'build', '--bundles', bundles.join(',')], {
+execFileSync(process.execPath, [tauriCli, 'build', '--verbose', '--bundles', bundles.join(',')], {
cwd: desktopRoot,
- env: process.env,
+ env: process.platform === 'linux' ? { ...process.env, NO_STRIP: '1' } : process.env,
stdio: 'inherit'
});
From 52e469d345a7f6c4bc70d8b601b0d38b9fa6f0ce Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:47:24 +0200
Subject: [PATCH 06/32] Use the Windows LibreOffice console launcher
---
desktop/scripts/prepare-windows-runtimes.ps1 | 2 +-
desktop/scripts/smoke-runtimes.mjs | 2 +-
desktop/src-tauri/src/lib.rs | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/desktop/scripts/prepare-windows-runtimes.ps1 b/desktop/scripts/prepare-windows-runtimes.ps1
index ec97b51..4c7f53a 100644
--- a/desktop/scripts/prepare-windows-runtimes.ps1
+++ b/desktop/scripts/prepare-windows-runtimes.ps1
@@ -76,7 +76,7 @@ Tesseract fast language data 4.1.0: https://github.com/tesseract-ocr/tessdata_fa
choco install libreoffice-fresh --yes --no-progress
$officeSource = Join-Path $env:ProgramFiles 'LibreOffice'
-if (-not (Test-Path (Join-Path $officeSource 'program/soffice.exe'))) { throw 'LibreOffice installation was not found.' }
+if (-not (Test-Path (Join-Path $officeSource 'program/soffice.com'))) { throw 'LibreOffice console launcher was not found.' }
Copy-Item $officeSource (Join-Path $RuntimeRoot 'office') -Recurse
@'
Windows x64 native LibreOffice runtime from the libreoffice-fresh Chocolatey package.
diff --git a/desktop/scripts/smoke-runtimes.mjs b/desktop/scripts/smoke-runtimes.mjs
index 390e347..1a9db92 100644
--- a/desktop/scripts/smoke-runtimes.mjs
+++ b/desktop/scripts/smoke-runtimes.mjs
@@ -12,7 +12,7 @@ const ocrExecutable = (name) => windows && name !== 'tesseract'
: executable('ocr', name);
const python = windows ? join(resources, 'runtime', 'python', 'python.exe') : executable('python', 'python3');
const office = process.platform === 'win32'
- ? join(resources, 'runtime', 'office', 'program', 'soffice.exe')
+ ? join(resources, 'runtime', 'office', 'program', 'soffice.com')
: process.platform === 'linux'
? join(resources, 'runtime', 'office', 'program', 'soffice')
: join(resources, 'runtime', 'office', 'bin', 'soffice');
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 59656d9..0de567b 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -67,7 +67,7 @@ fn python_executable(resource_root: &Path) -> PathBuf {
fn office_executable(resource_root: &Path) -> PathBuf {
#[cfg(target_os = "windows")]
- return resource_root.join("runtime/office/program/soffice.exe");
+ return resource_root.join("runtime/office/program/soffice.com");
#[cfg(target_os = "linux")]
return resource_root.join("runtime/office/program/soffice");
#[cfg(target_os = "macos")]
From 60127e687392e27260d928db4c9db7432dee0cd5 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 14:58:43 +0200
Subject: [PATCH 07/32] Expose the bundled JVM to Linux packaging
---
desktop/scripts/build.mjs | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/desktop/scripts/build.mjs b/desktop/scripts/build.mjs
index 86448cc..869d452 100644
--- a/desktop/scripts/build.mjs
+++ b/desktop/scripts/build.mjs
@@ -9,9 +9,19 @@ const bundles = process.platform === 'darwin'
: process.platform === 'win32'
? ['nsis']
: ['appimage', 'deb'];
+const buildEnvironment = process.platform === 'linux'
+ ? {
+ ...process.env,
+ NO_STRIP: '1',
+ LD_LIBRARY_PATH: [
+ join(desktopRoot, 'src-tauri', 'resources', 'runtime', 'java', 'lib', 'server'),
+ process.env.LD_LIBRARY_PATH
+ ].filter(Boolean).join(':')
+ }
+ : process.env;
execFileSync(process.execPath, [tauriCli, 'build', '--verbose', '--bundles', bundles.join(',')], {
cwd: desktopRoot,
- env: process.platform === 'linux' ? { ...process.env, NO_STRIP: '1' } : process.env,
+ env: buildEnvironment,
stdio: 'inherit'
});
From 64c16e4a708b537e23ccfff96a347ed01163aa35 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 16:14:28 +0200
Subject: [PATCH 08/32] Fix AppImage service startup on rolling Linux
---
.github/workflows/desktop-native-builds.yml | 31 ++++++++++++-
desktop/src-tauri/src/lib.rs | 48 +++++++++++++++++++--
2 files changed, 74 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index 280493e..66b8936 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -75,7 +75,7 @@ jobs:
- name: Install Tauri Linux build dependencies
run: |
sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2 xvfb
- uses: actions/setup-node@v4
with:
node-version: 24.18.0
@@ -109,6 +109,35 @@ jobs:
DOCUFLEX_OFFICE_RUNTIME: ${{ github.workspace }}/desktop/.native-runtime/linux/office
DOCUFLEX_PYTHON_RUNTIME: ${{ env.pythonLocation }}
run: npm run build
+ - name: Launch-test packaged AppImage
+ working-directory: desktop
+ run: |
+ appimage=$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit)
+ test -n "$appimage"
+ chmod +x "$appimage"
+ timeout 75s xvfb-run -a "$appimage" > /tmp/docuflex-appimage.log 2>&1 &
+ launcher_pid=$!
+ cleanup() {
+ kill "$launcher_pid" 2>/dev/null || true
+ wait "$launcher_pid" 2>/dev/null || true
+ }
+ trap cleanup EXIT
+ for attempt in $(seq 1 60); do
+ if ! kill -0 "$launcher_pid" 2>/dev/null; then
+ cat /tmp/docuflex-appimage.log
+ echo "Packaged AppImage exited before its local services became ready." >&2
+ exit 1
+ fi
+ if curl --fail --silent http://127.0.0.1:43128/health >/dev/null \
+ && curl --fail --silent http://127.0.0.1:43127/editor >/dev/null; then
+ echo "Packaged AppImage local-service launch test passed."
+ exit 0
+ fi
+ sleep 1
+ done
+ cat /tmp/docuflex-appimage.log
+ echo "Packaged AppImage local services were not ready within 60 seconds." >&2
+ exit 1
- name: Upload Linux packages
uses: actions/upload-artifact@v4
with:
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 0de567b..e6c9d59 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -123,6 +123,17 @@ fn supervised_command(
}
}
+fn isolate_service_environment(_command: &mut Command) {
+ #[cfg(target_os = "linux")]
+ {
+ // AppImage's AppRun prepends its Ubuntu libraries so the GUI can load.
+ // Those paths must not leak into our self-contained Java and Node
+ // runtimes on rolling distributions such as Arch/CachyOS.
+ _command.env_remove("LD_LIBRARY_PATH");
+ _command.env_remove("LD_PRELOAD");
+ }
+}
+
impl Drop for Services {
fn drop(&mut self) {
self.stop();
@@ -162,6 +173,7 @@ fn spawn_services(
let (backend_stdout, backend_stderr) = log_file(log_directory, "pdf-backend")?;
let mut backend_command =
supervised_command(resource_root, &parent_pid, &java, &backend_arguments);
+ isolate_service_environment(&mut backend_command);
let backend = backend_command
.current_dir(resource_root)
.env("PDF_BACKEND_HOST", "127.0.0.1")
@@ -179,6 +191,7 @@ fn spawn_services(
let (frontend_stdout, frontend_stderr) = log_file(log_directory, "frontend")?;
let mut frontend_command =
supervised_command(resource_root, &parent_pid, &node, &frontend_arguments);
+ isolate_service_environment(&mut frontend_command);
frontend_command
.current_dir(resource_root)
.env("HOST", "127.0.0.1")
@@ -235,15 +248,41 @@ fn service_ready(port: u16, path: &str) -> bool {
response[..size].starts_with(b"HTTP/1.1 200")
}
-fn wait_for_services() -> Result<(), Box> {
- let deadline = Instant::now() + Duration::from_secs(20);
+fn log_tail(path: &Path) -> String {
+ let Ok(contents) = fs::read_to_string(path) else {
+ return "log unavailable".to_string();
+ };
+ let mut tail = contents.chars().rev().take(4_000).collect::>();
+ tail.reverse();
+ let tail = tail.into_iter().collect::();
+ if tail.trim().is_empty() {
+ "log is empty".to_string()
+ } else {
+ tail
+ }
+}
+
+fn service_startup_error(log_directory: &Path) -> String {
+ let backend = log_tail(&log_directory.join("pdf-backend.log"));
+ let frontend = log_tail(&log_directory.join("frontend.log"));
+ format!(
+ "Docuflex local services did not start within 60 seconds.\n\
+ Logs: {}\n\n[pdf-backend]\n{}\n\n[frontend]\n{}",
+ log_directory.display(),
+ backend,
+ frontend
+ )
+}
+
+fn wait_for_services(log_directory: &Path) -> Result<(), Box> {
+ let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
if service_ready(BACKEND_PORT, "/health") && service_ready(FRONTEND_PORT, "/editor") {
return Ok(());
}
thread::sleep(Duration::from_millis(120));
}
- Err("Docuflex local services did not start within 20 seconds.".into())
+ Err(service_startup_error(log_directory).into())
}
fn choose_download_destination(suggested: &Path) -> Option {
@@ -288,7 +327,8 @@ pub fn run() {
.lock()
.map_err(|_| "Could not track local services.")? = children;
- if let Err(error) = wait_for_services() {
+ if let Err(error) = wait_for_services(&log_directory) {
+ eprintln!("{error}");
services_for_setup.stop();
return Err(error);
}
From 34e4b1befca468d796a623e98babbbb0951773ca Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 16:57:36 +0200
Subject: [PATCH 09/32] Use compatible WebKit rendering on Linux
---
.github/workflows/desktop-native-builds.yml | 9 ++++---
desktop/src-tauri/src/lib.rs | 29 +++++++++++++++++++++
2 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index 66b8936..149ca03 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -115,7 +115,9 @@ jobs:
appimage=$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit)
test -n "$appimage"
chmod +x "$appimage"
- timeout 75s xvfb-run -a "$appimage" > /tmp/docuflex-appimage.log 2>&1 &
+ rm -f /tmp/docuflex-page-loaded
+ DOCUFLEX_PAGE_LOAD_MARKER=/tmp/docuflex-page-loaded \
+ timeout 75s xvfb-run -a "$appimage" > /tmp/docuflex-appimage.log 2>&1 &
launcher_pid=$!
cleanup() {
kill "$launcher_pid" 2>/dev/null || true
@@ -129,8 +131,9 @@ jobs:
exit 1
fi
if curl --fail --silent http://127.0.0.1:43128/health >/dev/null \
- && curl --fail --silent http://127.0.0.1:43127/editor >/dev/null; then
- echo "Packaged AppImage local-service launch test passed."
+ && curl --fail --silent http://127.0.0.1:43127/editor >/dev/null \
+ && grep --quiet '^editor-loaded$' /tmp/docuflex-page-loaded 2>/dev/null; then
+ echo "Packaged AppImage service and WebKit page-load test passed."
exit 0
fi
sleep 1
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index e6c9d59..8348255 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -9,6 +9,8 @@ use std::{
thread,
time::{Duration, Instant},
};
+#[cfg(target_os = "linux")]
+use tauri::webview::PageLoadEvent;
use tauri::{webview::DownloadEvent, Manager, WebviewUrl, WebviewWindowBuilder};
#[cfg(target_os = "macos")]
use tauri::{LogicalPosition, TitleBarStyle};
@@ -17,6 +19,22 @@ use url::Url;
const FRONTEND_PORT: u16 = 43_127;
const BACKEND_PORT: u16 = 43_128;
+fn configure_platform_webview() {
+ #[cfg(target_os = "linux")]
+ {
+ // WebKitGTK accelerated compositing can abort its web process while
+ // creating an EGL display on Arch/CachyOS, especially under Wayland
+ // and NVIDIA. Preserve explicit user overrides, otherwise use the
+ // broadly compatible software-rendering path for this editor shell.
+ if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
+ std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
+ }
+ if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() {
+ std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
+ }
+ }
+}
+
struct Services {
children: Mutex>,
}
@@ -302,6 +320,7 @@ fn editor_initialization_script() -> &'static str {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
+ configure_platform_webview();
let services = Arc::new(Services {
children: Mutex::new(Vec::new()),
});
@@ -336,6 +355,8 @@ pub fn run() {
let editor_url = Url::parse(&format!("http://127.0.0.1:{FRONTEND_PORT}/editor"))?;
let allowed_origin = editor_url.origin().ascii_serialization();
let window_actions = app.handle().clone();
+ #[cfg(target_os = "linux")]
+ let page_load_marker = std::env::var_os("DOCUFLEX_PAGE_LOAD_MARKER").map(PathBuf::from);
let window_builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(editor_url))
.title("Docuflex")
@@ -349,6 +370,14 @@ pub fn run() {
.traffic_light_position(LogicalPosition::new(24.0, 24.0));
#[cfg(target_os = "windows")]
let window_builder = window_builder.decorations(false).shadow(true);
+ #[cfg(target_os = "linux")]
+ let window_builder = window_builder.on_page_load(move |_window, payload| {
+ if payload.event() == PageLoadEvent::Finished && payload.url().path() == "/editor" {
+ if let Some(marker) = &page_load_marker {
+ let _ = fs::write(marker, b"editor-loaded\n");
+ }
+ }
+ });
window_builder
.initialization_script(editor_initialization_script())
.on_navigation(move |url| {
From 09e77949e40f4c78aaf4b9190f5030ff3621c1b8 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 17:33:38 +0200
Subject: [PATCH 10/32] Set WebKit compatibility flags in Linux launcher
---
.github/workflows/desktop-native-builds.yml | 5 ++++-
desktop/runtime/docuflex.desktop.hbs | 8 ++++++++
desktop/runtime/linux-app-launcher.sh | 15 +++++++++++++++
desktop/src-tauri/tauri.linux.conf.json | 17 +++++++++++++++++
4 files changed, 44 insertions(+), 1 deletion(-)
create mode 100644 desktop/runtime/docuflex.desktop.hbs
create mode 100755 desktop/runtime/linux-app-launcher.sh
create mode 100644 desktop/src-tauri/tauri.linux.conf.json
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index 149ca03..d768122 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -115,7 +115,8 @@ jobs:
appimage=$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit)
test -n "$appimage"
chmod +x "$appimage"
- rm -f /tmp/docuflex-page-loaded
+ rm -f /tmp/docuflex-launcher-env /tmp/docuflex-page-loaded
+ DOCUFLEX_LAUNCHER_MARKER=/tmp/docuflex-launcher-env \
DOCUFLEX_PAGE_LOAD_MARKER=/tmp/docuflex-page-loaded \
timeout 75s xvfb-run -a "$appimage" > /tmp/docuflex-appimage.log 2>&1 &
launcher_pid=$!
@@ -132,6 +133,8 @@ jobs:
fi
if curl --fail --silent http://127.0.0.1:43128/health >/dev/null \
&& curl --fail --silent http://127.0.0.1:43127/editor >/dev/null \
+ && grep --quiet '^dmabuf=1$' /tmp/docuflex-launcher-env 2>/dev/null \
+ && grep --quiet '^compositing=1$' /tmp/docuflex-launcher-env 2>/dev/null \
&& grep --quiet '^editor-loaded$' /tmp/docuflex-page-loaded 2>/dev/null; then
echo "Packaged AppImage service and WebKit page-load test passed."
exit 0
diff --git a/desktop/runtime/docuflex.desktop.hbs b/desktop/runtime/docuflex.desktop.hbs
new file mode 100644
index 0000000..8a514e7
--- /dev/null
+++ b/desktop/runtime/docuflex.desktop.hbs
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Categories={{categories}}
+{{#if comment}}Comment={{comment}}{{/if}}
+Exec=docuflex-launcher %U
+Icon={{icon}}
+Name={{name}}
+Terminal=false
+Type=Application
diff --git a/desktop/runtime/linux-app-launcher.sh b/desktop/runtime/linux-app-launcher.sh
new file mode 100755
index 0000000..802dba2
--- /dev/null
+++ b/desktop/runtime/linux-app-launcher.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+# WebKitGTK reads these during shared-library initialization, before the
+# Tauri/Rust entry point can run. Keep them in this outer launcher.
+: "${WEBKIT_DISABLE_DMABUF_RENDERER:=1}"
+: "${WEBKIT_DISABLE_COMPOSITING_MODE:=1}"
+export WEBKIT_DISABLE_DMABUF_RENDERER WEBKIT_DISABLE_COMPOSITING_MODE
+
+if [ -n "${DOCUFLEX_LAUNCHER_MARKER:-}" ]; then
+ printf 'dmabuf=%s\ncompositing=%s\n' \
+ "$WEBKIT_DISABLE_DMABUF_RENDERER" \
+ "$WEBKIT_DISABLE_COMPOSITING_MODE" > "$DOCUFLEX_LAUNCHER_MARKER"
+fi
+
+exec docuflex-desktop "$@"
diff --git a/desktop/src-tauri/tauri.linux.conf.json b/desktop/src-tauri/tauri.linux.conf.json
new file mode 100644
index 0000000..0d5aaf8
--- /dev/null
+++ b/desktop/src-tauri/tauri.linux.conf.json
@@ -0,0 +1,17 @@
+{
+ "bundle": {
+ "linux": {
+ "appimage": {
+ "files": {
+ "/usr/bin/docuflex-launcher": "../runtime/linux-app-launcher.sh"
+ }
+ },
+ "deb": {
+ "desktopTemplate": "../runtime/docuflex.desktop.hbs",
+ "files": {
+ "/usr/bin/docuflex-launcher": "../runtime/linux-app-launcher.sh"
+ }
+ }
+ }
+ }
+}
From 370ca9f61c8b7459c35b554a0c0500ca23c79d3e Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 18:00:29 +0200
Subject: [PATCH 11/32] Replace Linux shell launcher with native executable
---
desktop/runtime/linux-app-launcher.c | 57 +++++++++++++++++++++++
desktop/runtime/linux-app-launcher.sh | 15 ------
desktop/scripts/prepare-linux-runtimes.sh | 5 ++
desktop/src-tauri/tauri.linux.conf.json | 4 +-
4 files changed, 64 insertions(+), 17 deletions(-)
create mode 100644 desktop/runtime/linux-app-launcher.c
delete mode 100755 desktop/runtime/linux-app-launcher.sh
diff --git a/desktop/runtime/linux-app-launcher.c b/desktop/runtime/linux-app-launcher.c
new file mode 100644
index 0000000..0293a4b
--- /dev/null
+++ b/desktop/runtime/linux-app-launcher.c
@@ -0,0 +1,57 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include
+#include
+#include
+#include
+#include
+
+static int set_default_environment(const char *name, const char *value) {
+ if (getenv(name) != NULL) {
+ return 0;
+ }
+ return setenv(name, value, 1);
+}
+
+static void write_test_marker(void) {
+ const char *marker = getenv("DOCUFLEX_LAUNCHER_MARKER");
+ if (marker == NULL || marker[0] == '\0') {
+ return;
+ }
+
+ FILE *output = fopen(marker, "w");
+ if (output == NULL) {
+ return;
+ }
+ fprintf(output, "dmabuf=%s\ncompositing=%s\n",
+ getenv("WEBKIT_DISABLE_DMABUF_RENDERER"),
+ getenv("WEBKIT_DISABLE_COMPOSITING_MODE"));
+ fclose(output);
+}
+
+int main(int argc, char **argv) {
+ if (set_default_environment("WEBKIT_DISABLE_DMABUF_RENDERER", "1") != 0 ||
+ set_default_environment("WEBKIT_DISABLE_COMPOSITING_MODE", "1") != 0) {
+ fprintf(stderr, "Docuflex launcher could not configure WebKit: %s\n",
+ strerror(errno));
+ return 126;
+ }
+
+ write_test_marker();
+
+ char **child_arguments = calloc((size_t)argc + 1, sizeof(char *));
+ if (child_arguments == NULL) {
+ fprintf(stderr, "Docuflex launcher could not allocate arguments.\n");
+ return 126;
+ }
+ child_arguments[0] = (char *)"docuflex-desktop";
+ for (int index = 1; index < argc; index++) {
+ child_arguments[index] = argv[index];
+ }
+
+ execvp(child_arguments[0], child_arguments);
+ fprintf(stderr, "Docuflex launcher could not start the application: %s\n",
+ strerror(errno));
+ free(child_arguments);
+ return 127;
+}
diff --git a/desktop/runtime/linux-app-launcher.sh b/desktop/runtime/linux-app-launcher.sh
deleted file mode 100755
index 802dba2..0000000
--- a/desktop/runtime/linux-app-launcher.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/sh
-
-# WebKitGTK reads these during shared-library initialization, before the
-# Tauri/Rust entry point can run. Keep them in this outer launcher.
-: "${WEBKIT_DISABLE_DMABUF_RENDERER:=1}"
-: "${WEBKIT_DISABLE_COMPOSITING_MODE:=1}"
-export WEBKIT_DISABLE_DMABUF_RENDERER WEBKIT_DISABLE_COMPOSITING_MODE
-
-if [ -n "${DOCUFLEX_LAUNCHER_MARKER:-}" ]; then
- printf 'dmabuf=%s\ncompositing=%s\n' \
- "$WEBKIT_DISABLE_DMABUF_RENDERER" \
- "$WEBKIT_DISABLE_COMPOSITING_MODE" > "$DOCUFLEX_LAUNCHER_MARKER"
-fi
-
-exec docuflex-desktop "$@"
diff --git a/desktop/scripts/prepare-linux-runtimes.sh b/desktop/scripts/prepare-linux-runtimes.sh
index efe9097..9cde536 100644
--- a/desktop/scripts/prepare-linux-runtimes.sh
+++ b/desktop/scripts/prepare-linux-runtimes.sh
@@ -17,6 +17,11 @@ mkdir -p "$RUNTIME_ROOT/pdf2htmlEX/bin" "$RUNTIME_ROOT/pdf2htmlEX/share" \
"$RUNTIME_ROOT/ocr/bin" "$RUNTIME_ROOT/ocr/lib" "$RUNTIME_ROOT/ocr/share/tessdata" \
"$RUNTIME_ROOT/office"
+cc -O2 -Wall -Wextra -Werror \
+ desktop/runtime/linux-app-launcher.c \
+ -o "$RUNTIME_ROOT/docuflex-launcher"
+chmod +x "$RUNTIME_ROOT/docuflex-launcher"
+
PDF2HTMLEX_URL=https://github.com/pdf2htmlEX/pdf2htmlEX/releases/download/v0.18.8.rc1/pdf2htmlEX-0.18.8.rc1-master-20200630-Ubuntu-focal-x86_64.AppImage
PDF2HTMLEX_SHA256=11de2583a3abce5f141fd7fafb1fea2c67b15886e546d6b7675c600012e6ab8c
PDF2HTMLEX_IMAGE="$RUNTIME_ROOT/pdf2htmlEX.AppImage"
diff --git a/desktop/src-tauri/tauri.linux.conf.json b/desktop/src-tauri/tauri.linux.conf.json
index 0d5aaf8..7a44155 100644
--- a/desktop/src-tauri/tauri.linux.conf.json
+++ b/desktop/src-tauri/tauri.linux.conf.json
@@ -3,13 +3,13 @@
"linux": {
"appimage": {
"files": {
- "/usr/bin/docuflex-launcher": "../runtime/linux-app-launcher.sh"
+ "/usr/bin/docuflex-launcher": "../.native-runtime/linux/docuflex-launcher"
}
},
"deb": {
"desktopTemplate": "../runtime/docuflex.desktop.hbs",
"files": {
- "/usr/bin/docuflex-launcher": "../runtime/linux-app-launcher.sh"
+ "/usr/bin/docuflex-launcher": "../.native-runtime/linux/docuflex-launcher"
}
}
}
From 062883761f4e32800bbfe7867033968c4ca9d499 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 18:41:24 +0200
Subject: [PATCH 12/32] Build native Arch and CachyOS package
---
.github/workflows/desktop-native-builds.yml | 74 +++++++++++++++++++++
desktop/scripts/package-arch-linux.sh | 70 +++++++++++++++++++
desktop/src-tauri/src/lib.rs | 18 ++---
3 files changed, 153 insertions(+), 9 deletions(-)
create mode 100644 desktop/scripts/package-arch-linux.sh
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index d768122..5f8d5e2 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -152,3 +152,77 @@ jobs:
path: |
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
desktop/src-tauri/target/release/bundle/deb/*.deb
+
+ arch-linux-x64:
+ name: Arch and CachyOS native x64 package
+ needs: linux-x64
+ runs-on: ubuntu-22.04
+ container: archlinux:latest
+ timeout-minutes: 90
+ steps:
+ - name: Install current Arch build and runtime dependencies
+ run: |
+ pacman -Syu --noconfirm
+ pacman -S --noconfirm --needed \
+ base-devel binutils curl dbus git gtk3 libayatana-appindicator \
+ librsvg nodejs npm rust webkit2gtk-4.1 xorg-server-xvfb
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: Docuflex-Linux-x64
+ path: desktop/.linux-bundle
+ - name: Extract offline application resources
+ run: |
+ deb=$(find desktop/.linux-bundle -name '*.deb' -print -quit)
+ test -n "$deb"
+ mkdir -p desktop/.deb-extracted desktop/.deb-parts
+ (cd desktop/.deb-parts && ar x "$GITHUB_WORKSPACE/$deb")
+ data_archive=$(find desktop/.deb-parts -name 'data.tar.*' -print -quit)
+ test -n "$data_archive"
+ bsdtar -xf "$data_archive" -C desktop/.deb-extracted
+ - name: Build current-Arch native package
+ run: |
+ chmod +x desktop/scripts/package-arch-linux.sh
+ desktop/scripts/package-arch-linux.sh \
+ "$GITHUB_WORKSPACE/desktop/.deb-extracted" \
+ "$GITHUB_WORKSPACE/desktop/.arch-package"
+ - name: Install and launch-test native package
+ run: |
+ package=$(find desktop/.arch-package -name '*.pkg.tar.zst' -print -quit)
+ test -n "$package"
+ pacman -U --noconfirm "$package"
+ rm -f /tmp/docuflex-page-loaded
+ DOCUFLEX_PAGE_LOAD_MARKER=/tmp/docuflex-page-loaded \
+ timeout 75s xvfb-run -a dbus-run-session -- docuflex-desktop \
+ > /tmp/docuflex-arch.log 2>&1 &
+ app_pid=$!
+ cleanup() {
+ kill "$app_pid" 2>/dev/null || true
+ wait "$app_pid" 2>/dev/null || true
+ }
+ trap cleanup EXIT
+ for attempt in $(seq 1 60); do
+ if ! kill -0 "$app_pid" 2>/dev/null; then
+ cat /tmp/docuflex-arch.log
+ echo 'Native Arch package exited before startup completed.' >&2
+ exit 1
+ fi
+ if curl --fail --silent http://127.0.0.1:43128/health >/dev/null \
+ && curl --fail --silent http://127.0.0.1:43127/editor >/dev/null \
+ && grep --quiet '^editor-loaded$' /tmp/docuflex-page-loaded 2>/dev/null; then
+ echo 'Native Arch services and WebKit page-load test passed.'
+ exit 0
+ fi
+ sleep 1
+ done
+ cat /tmp/docuflex-arch.log
+ echo 'Native Arch package did not become ready within 60 seconds.' >&2
+ exit 1
+ - name: Upload Arch and CachyOS package
+ uses: actions/upload-artifact@v4
+ with:
+ name: Docuflex-Arch-CachyOS-x64
+ if-no-files-found: error
+ path: |
+ desktop/.arch-package/*.pkg.tar.zst
+ desktop/.arch-package/docuflex-arch-ldd.txt
diff --git a/desktop/scripts/package-arch-linux.sh b/desktop/scripts/package-arch-linux.sh
new file mode 100644
index 0000000..0413eb1
--- /dev/null
+++ b/desktop/scripts/package-arch-linux.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+DEB_ROOT=${1:?Pass the extracted Debian package root.}
+OUTPUT_DIR=${2:?Pass the package output directory.}
+REPOSITORY_ROOT=$(cd "$(dirname "$0")/../.." && pwd)
+DESKTOP_ROOT="$REPOSITORY_ROOT/desktop"
+TAURI_ROOT="$DESKTOP_ROOT/src-tauri"
+PACKAGE_ROOT=$(mktemp -d)
+trap 'rm -rf -- "$PACKAGE_ROOT"' EXIT
+
+RESOURCE_SOURCE=$(find "$DEB_ROOT/usr/lib" -mindepth 1 -maxdepth 1 -type d -print -quit)
+test -n "$RESOURCE_SOURCE"
+RESOURCE_NAME=$(basename "$RESOURCE_SOURCE")
+
+rm -rf -- "$TAURI_ROOT/resources" "$DESKTOP_ROOT/dist"
+mkdir -p "$TAURI_ROOT/resources" "$DESKTOP_ROOT/dist"
+cp -a "$RESOURCE_SOURCE/." "$TAURI_ROOT/resources/"
+printf '%s\n' '' > "$DESKTOP_ROOT/dist/index.html"
+
+npm ci --prefix "$DESKTOP_ROOT"
+node "$DESKTOP_ROOT/node_modules/@tauri-apps/cli/tauri.js" icon \
+ "$REPOSITORY_ROOT/public/macos-icon-iOS-Default-1024x1024@1x.png" \
+ --output "$TAURI_ROOT/icons"
+cargo build --release --manifest-path "$TAURI_ROOT/Cargo.toml"
+
+install -Dm755 "$TAURI_ROOT/target/release/docuflex-desktop" \
+ "$PACKAGE_ROOT/usr/bin/docuflex-desktop"
+mkdir -p "$PACKAGE_ROOT/usr/lib/$RESOURCE_NAME"
+cp -a "$RESOURCE_SOURCE/." "$PACKAGE_ROOT/usr/lib/$RESOURCE_NAME/"
+install -Dm644 "$TAURI_ROOT/icons/128x128.png" \
+ "$PACKAGE_ROOT/usr/share/icons/hicolor/128x128/apps/docuflex.png"
+install -Dm644 /dev/stdin "$PACKAGE_ROOT/usr/share/applications/docuflex.desktop" <<'EOF'
+[Desktop Entry]
+Categories=Office;
+Comment=Offline PDF editor
+Exec=docuflex-desktop %U
+Icon=docuflex
+Name=Docuflex
+Terminal=false
+Type=Application
+EOF
+
+PACKAGE_SIZE=$(du -sk "$PACKAGE_ROOT/usr" | awk '{print $1 * 1024}')
+cat > "$PACKAGE_ROOT/.PKGINFO" <&2
+ exit 1
+fi
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 8348255..161f93f 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -22,15 +22,15 @@ const BACKEND_PORT: u16 = 43_128;
fn configure_platform_webview() {
#[cfg(target_os = "linux")]
{
- // WebKitGTK accelerated compositing can abort its web process while
- // creating an EGL display on Arch/CachyOS, especially under Wayland
- // and NVIDIA. Preserve explicit user overrides, otherwise use the
- // broadly compatible software-rendering path for this editor shell.
- if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
- std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
- }
- if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() {
- std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
+ // The AppImage launcher must set these before WebKitGTK is loaded.
+ // Native distro packages use the host WebKit/Mesa stack unmodified.
+ if std::env::var_os("APPIMAGE").is_some() {
+ if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
+ std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
+ }
+ if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() {
+ std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
+ }
}
}
}
From 55d1cebb6f59a7adbbddaa7780616c50e2d414ad Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 19:05:52 +0200
Subject: [PATCH 13/32] Upload native Arch package artifact
---
.github/workflows/desktop-native-builds.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index 5f8d5e2..a1780bb 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -185,10 +185,10 @@ jobs:
chmod +x desktop/scripts/package-arch-linux.sh
desktop/scripts/package-arch-linux.sh \
"$GITHUB_WORKSPACE/desktop/.deb-extracted" \
- "$GITHUB_WORKSPACE/desktop/.arch-package"
+ "$GITHUB_WORKSPACE/desktop/arch-package"
- name: Install and launch-test native package
run: |
- package=$(find desktop/.arch-package -name '*.pkg.tar.zst' -print -quit)
+ package=$(find desktop/arch-package -name '*.pkg.tar.zst' -print -quit)
test -n "$package"
pacman -U --noconfirm "$package"
rm -f /tmp/docuflex-page-loaded
@@ -224,5 +224,5 @@ jobs:
name: Docuflex-Arch-CachyOS-x64
if-no-files-found: error
path: |
- desktop/.arch-package/*.pkg.tar.zst
- desktop/.arch-package/docuflex-arch-ldd.txt
+ desktop/arch-package/*.pkg.tar.zst
+ desktop/arch-package/docuflex-arch-ldd.txt
From 565ee401a0a74a32fa3a502d17777ec1a0052330 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 20:43:16 +0200
Subject: [PATCH 14/32] Fix Linux editor viewport and Windows startup
---
.github/workflows/desktop-native-builds.yml | 46 ++++++++++++++++++
desktop/runtime/chrome.js | 8 ++++
desktop/src-tauri/src/lib.rs | 52 +++++++++++++--------
desktop/src-tauri/src/main.rs | 2 +
4 files changed, 88 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index a1780bb..869117b 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -59,6 +59,52 @@ jobs:
run: |
$env:DOCUFLEX_PYTHON_RUNTIME = $env:pythonLocation
npm run build
+ - name: Launch-test packaged Windows runtime
+ shell: pwsh
+ working-directory: desktop
+ run: |
+ $runtimeRoot = Resolve-Path 'src-tauri/resources'
+ $releaseRoot = Resolve-Path 'src-tauri/target/release'
+ $releaseResources = Join-Path $releaseRoot 'resources'
+ if (Test-Path $releaseResources) {
+ Remove-Item -LiteralPath $releaseResources -Recurse -Force
+ }
+ Copy-Item -LiteralPath $runtimeRoot -Destination $releaseResources -Recurse
+ $marker = Join-Path $env:RUNNER_TEMP 'docuflex-windows-page-loaded'
+ Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue
+ $env:DOCUFLEX_PAGE_LOAD_MARKER = $marker
+ $process = Start-Process -FilePath (Join-Path $releaseRoot 'docuflex-desktop.exe') -PassThru
+ try {
+ foreach ($attempt in 1..60) {
+ $process.Refresh()
+ if ($process.HasExited) {
+ throw "Packaged Windows app exited with code $($process.ExitCode) before startup completed."
+ }
+ $backendReady = $false
+ $frontendReady = $false
+ try {
+ $backendReady = (Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:43128/health' -TimeoutSec 2).StatusCode -eq 200
+ $frontendReady = (Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:43127/editor' -TimeoutSec 2).StatusCode -eq 200
+ } catch {}
+ if ($backendReady -and $frontendReady -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq 'editor-loaded')) {
+ Write-Host 'Packaged Windows services and WebView page-load test passed.'
+ exit 0
+ }
+ Start-Sleep -Seconds 1
+ }
+ throw 'Packaged Windows app did not become ready within 60 seconds.'
+ } finally {
+ if (-not $process.HasExited) {
+ & taskkill.exe /PID $process.Id /T /F | Out-Null
+ }
+ $logRoot = Join-Path $env:LOCALAPPDATA 'com.docuflex.editor/logs'
+ if (Test-Path $logRoot) {
+ Get-ChildItem $logRoot -Filter '*.log' | ForEach-Object {
+ Write-Host "[$($_.Name)]"
+ Get-Content $_.FullName
+ }
+ }
+ }
- name: Upload Windows installer
uses: actions/upload-artifact@v4
with:
diff --git a/desktop/runtime/chrome.js b/desktop/runtime/chrome.js
index b029ae0..ca46814 100644
--- a/desktop/runtime/chrome.js
+++ b/desktop/runtime/chrome.js
@@ -254,6 +254,14 @@
position: absolute;
}
+ html[data-docuflex-desktop="linux"] .editor-shell {
+ height: 117.6470588dvh !important;
+ transform: scale(0.85);
+ transform-origin: top left;
+ width: 117.6470588vw !important;
+ zoom: 1 !important;
+ }
+
html[data-docuflex-desktop="windows"] .topbar,
html[data-docuflex-desktop="windows"] .brand-area,
html[data-docuflex-desktop="windows"] .tab-strip,
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 161f93f..a606881 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -9,7 +9,7 @@ use std::{
thread,
time::{Duration, Instant},
};
-#[cfg(target_os = "linux")]
+#[cfg(any(target_os = "linux", target_os = "windows"))]
use tauri::webview::PageLoadEvent;
use tauri::{webview::DownloadEvent, Manager, WebviewUrl, WebviewWindowBuilder};
#[cfg(target_os = "macos")]
@@ -112,21 +112,12 @@ fn supervised_command(
) -> Command {
#[cfg(target_os = "windows")]
{
- let mut command = Command::new("powershell.exe");
- command
- .arg("-NoLogo")
- .arg("-NoProfile")
- .arg("-NonInteractive")
- .arg("-ExecutionPolicy")
- .arg("Bypass")
- .arg("-File")
- .arg(resource_root.join("runtime/supervise.ps1"))
- .arg("-DocuflexParentPid")
- .arg(parent_pid)
- .arg("-FilePath")
- .arg(executable)
- .arg("--")
- .args(arguments);
+ use std::os::windows::process::CommandExt;
+
+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+ let _ = (resource_root, parent_pid);
+ let mut command = Command::new(executable);
+ command.args(arguments).creation_flags(CREATE_NO_WINDOW);
command
}
#[cfg(not(target_os = "windows"))]
@@ -303,6 +294,21 @@ fn wait_for_services(log_directory: &Path) -> Result<(), Box Option {
let suggested_name = suggested
.file_name()
@@ -340,14 +346,20 @@ pub fn run() {
.setup(move |app| {
let resource_root = app.path().resource_dir()?.join("resources");
let log_directory = app.path().app_log_dir()?;
- let children = spawn_services(&resource_root, &log_directory)?;
+ let children = match spawn_services(&resource_root, &log_directory) {
+ Ok(children) => children,
+ Err(error) => {
+ report_startup_error(&log_directory, error.as_ref());
+ return Err(error);
+ }
+ };
*services_for_setup
.children
.lock()
.map_err(|_| "Could not track local services.")? = children;
if let Err(error) = wait_for_services(&log_directory) {
- eprintln!("{error}");
+ report_startup_error(&log_directory, error.as_ref());
services_for_setup.stop();
return Err(error);
}
@@ -355,7 +367,7 @@ pub fn run() {
let editor_url = Url::parse(&format!("http://127.0.0.1:{FRONTEND_PORT}/editor"))?;
let allowed_origin = editor_url.origin().ascii_serialization();
let window_actions = app.handle().clone();
- #[cfg(target_os = "linux")]
+ #[cfg(any(target_os = "linux", target_os = "windows"))]
let page_load_marker = std::env::var_os("DOCUFLEX_PAGE_LOAD_MARKER").map(PathBuf::from);
let window_builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(editor_url))
@@ -370,7 +382,7 @@ pub fn run() {
.traffic_light_position(LogicalPosition::new(24.0, 24.0));
#[cfg(target_os = "windows")]
let window_builder = window_builder.decorations(false).shadow(true);
- #[cfg(target_os = "linux")]
+ #[cfg(any(target_os = "linux", target_os = "windows"))]
let window_builder = window_builder.on_page_load(move |_window, payload| {
if payload.event() == PageLoadEvent::Finished && payload.url().path() == "/editor" {
if let Some(marker) = &page_load_marker {
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
index 286c9d7..ad5178e 100644
--- a/desktop/src-tauri/src/main.rs
+++ b/desktop/src-tauri/src/main.rs
@@ -1,3 +1,5 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
fn main() {
docuflex_desktop_lib::run();
}
From e45dff99d17c3d8a84660054bc8c9572e0d42f99 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 20:46:06 +0200
Subject: [PATCH 15/32] Retry verified Windows runtime downloads
---
desktop/scripts/prepare-windows-runtimes.ps1 | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/desktop/scripts/prepare-windows-runtimes.ps1 b/desktop/scripts/prepare-windows-runtimes.ps1
index 4c7f53a..a1a9b24 100644
--- a/desktop/scripts/prepare-windows-runtimes.ps1
+++ b/desktop/scripts/prepare-windows-runtimes.ps1
@@ -12,7 +12,8 @@ if (-not $RuntimeRoot.EndsWith($expectedSuffix, [StringComparison]::OrdinalIgnor
function Get-VerifiedArchive {
param([string]$Url, [string]$Path, [string]$Sha256)
- Invoke-WebRequest -Uri $Url -OutFile $Path -UseBasicParsing
+ & curl.exe --location --fail --retry 5 --retry-all-errors --connect-timeout 30 --output $Path $Url
+ if ($LASTEXITCODE -ne 0) { throw "Download failed after retries: $Url" }
$actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $Sha256) { throw "Checksum failed for $Url" }
}
@@ -62,9 +63,7 @@ $trainedData = @{
}
foreach ($language in $trainedData.Keys) {
$destination = Join-Path $ocrRuntime "share/tessdata/$language.traineddata"
- Invoke-WebRequest -Uri "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/4.1.0/$language.traineddata" -OutFile $destination -UseBasicParsing
- $actual = (Get-FileHash -Path $destination -Algorithm SHA256).Hash.ToLowerInvariant()
- if ($actual -ne $trainedData[$language]) { throw "Checksum failed for $language.traineddata" }
+ Get-VerifiedArchive -Url "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/4.1.0/$language.traineddata" -Path $destination -Sha256 $trainedData[$language]
}
Remove-Item -LiteralPath $popplerArchive, $popplerExtract -Recurse -Force
@'
From da2a44a895e5b1169b885a632eb5d1d23e52261b Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 21:50:34 +0200
Subject: [PATCH 16/32] Test packaged Windows document tools
---
.github/workflows/desktop-native-builds.yml | 30 ++++++++---
desktop/runtime/chrome.js | 6 +--
desktop/scripts/smoke-live-document-tools.mjs | 51 +++++++++++++++++++
3 files changed, 77 insertions(+), 10 deletions(-)
create mode 100644 desktop/scripts/smoke-live-document-tools.mjs
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index 869117b..6d524fb 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -63,17 +63,31 @@ jobs:
shell: pwsh
working-directory: desktop
run: |
- $runtimeRoot = Resolve-Path 'src-tauri/resources'
- $releaseRoot = Resolve-Path 'src-tauri/target/release'
- $releaseResources = Join-Path $releaseRoot 'resources'
- if (Test-Path $releaseResources) {
- Remove-Item -LiteralPath $releaseResources -Recurse -Force
+ $installer = Resolve-Path 'src-tauri/target/release/bundle/nsis/*.exe'
+ $install = Start-Process -FilePath $installer -ArgumentList '/S' -PassThru -Wait
+ if ($install.ExitCode -ne 0) {
+ throw "NSIS installation failed with exit code $($install.ExitCode)."
+ }
+ $entry = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' |
+ Where-Object { $_.DisplayName -eq 'Docuflex' } |
+ Select-Object -First 1
+ $candidates = @()
+ if ($entry.DisplayIcon) {
+ $candidates += ($entry.DisplayIcon -replace ',\d+$', '').Trim('"')
+ }
+ if ($entry.InstallLocation -and (Test-Path $entry.InstallLocation)) {
+ $candidates += Get-ChildItem $entry.InstallLocation -Filter '*.exe' -File |
+ Where-Object { $_.Name -notmatch 'uninstall' } |
+ Select-Object -ExpandProperty FullName
+ }
+ $appPath = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $appPath) {
+ throw 'Could not locate the Docuflex executable after NSIS installation.'
}
- Copy-Item -LiteralPath $runtimeRoot -Destination $releaseResources -Recurse
$marker = Join-Path $env:RUNNER_TEMP 'docuflex-windows-page-loaded'
Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue
$env:DOCUFLEX_PAGE_LOAD_MARKER = $marker
- $process = Start-Process -FilePath (Join-Path $releaseRoot 'docuflex-desktop.exe') -PassThru
+ $process = Start-Process -FilePath $appPath -PassThru
try {
foreach ($attempt in 1..60) {
$process.Refresh()
@@ -88,6 +102,8 @@ jobs:
} catch {}
if ($backendReady -and $frontendReady -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq 'editor-loaded')) {
Write-Host 'Packaged Windows services and WebView page-load test passed.'
+ node scripts/smoke-live-document-tools.mjs
+ if ($LASTEXITCODE -ne 0) { throw 'Packaged document-tool API tests failed.' }
exit 0
}
Start-Sleep -Seconds 1
diff --git a/desktop/runtime/chrome.js b/desktop/runtime/chrome.js
index ca46814..3ed112f 100644
--- a/desktop/runtime/chrome.js
+++ b/desktop/runtime/chrome.js
@@ -270,7 +270,7 @@
}
html[data-docuflex-desktop="windows"] .utilities {
- padding-right: 138px !important;
+ padding-right: 168px !important;
}
html[data-docuflex-desktop="windows"] .topbar button,
@@ -286,7 +286,7 @@
html[data-docuflex-desktop="windows"] .docuflex-windows-controls {
display: flex;
- height: 56px;
+ height: 32px;
position: fixed;
right: 0;
top: 0;
@@ -299,7 +299,7 @@
border: 0;
color: #616161;
display: flex;
- height: 56px;
+ height: 32px;
justify-content: center;
padding: 0;
position: relative;
diff --git a/desktop/scripts/smoke-live-document-tools.mjs b/desktop/scripts/smoke-live-document-tools.mjs
new file mode 100644
index 0000000..37487a5
--- /dev/null
+++ b/desktop/scripts/smoke-live-document-tools.mjs
@@ -0,0 +1,51 @@
+const frontend = process.env.DOCUFLEX_FRONTEND_URL || 'http://127.0.0.1:43127';
+
+function minimalPdf(text) {
+ const objects = [
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
+ `<< /Length ${text.length + 31} >>\nstream\nBT /F1 24 Tf 72 720 Td (${text}) Tj ET\nendstream`,
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
+ ];
+ let pdf = '%PDF-1.4\n';
+ const offsets = [0];
+ objects.forEach((object, index) => {
+ offsets.push(Buffer.byteLength(pdf));
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
+ });
+ const xref = Buffer.byteLength(pdf);
+ pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+ pdf += offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('');
+ pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+ return Buffer.from(pdf);
+}
+
+async function expectSuccessfulResponse(label, response) {
+ if (response.ok) return response;
+ const detail = await response.text();
+ throw new Error(`${label} failed with HTTP ${response.status}: ${detail}`);
+}
+
+const pdf = minimalPdf('Docuflex Live Tools');
+const conversion = await expectSuccessfulResponse('Edit Text conversion', await fetch(`${frontend}/api/pdf/convert`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pdfBase64: pdf.toString('base64') })
+}));
+const conversionResult = await conversion.json();
+if (!conversionResult.htmlBase64 || !Buffer.from(conversionResult.htmlBase64, 'base64').includes('Docuflex')) {
+ throw new Error('Edit Text conversion did not return the expected document text.');
+}
+
+const ocr = await expectSuccessfulResponse('OCR', await fetch(`${frontend}/api/pdf/ocr`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/pdf', 'x-ocr-languages': 'eng' },
+ body: pdf
+}));
+const ocrBytes = Buffer.from(await ocr.arrayBuffer());
+if (!ocrBytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
+ throw new Error('OCR did not return a PDF document.');
+}
+
+process.stdout.write('Packaged Edit Text and OCR API tests passed.\n');
From 3831b7f944b060675039deb5856dec0ecab54a0a Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 22:22:08 +0200
Subject: [PATCH 17/32] Bundle Windows document tool data
---
desktop/runtime/pdf2htmlEX-windows-wrapper.rs | 16 ++++++-
desktop/scripts/prepare-windows-runtimes.ps1 | 1 +
desktop/scripts/smoke-live-document-tools.mjs | 43 ++++++++++++-------
desktop/scripts/smoke-runtimes.mjs | 7 +++
4 files changed, 50 insertions(+), 17 deletions(-)
diff --git a/desktop/runtime/pdf2htmlEX-windows-wrapper.rs b/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
index 7e7e1c0..6c2632f 100644
--- a/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
+++ b/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
@@ -10,6 +10,7 @@ fn main() {
.unwrap_or_else(|| PathBuf::from("pdf2htmlEX-native.exe"));
let mut normalized = Vec::new();
+ let mut data_directory = None;
let mut arguments = env::args_os().skip(1);
while let Some(argument) = arguments.next() {
let option = argument.to_string_lossy();
@@ -17,10 +18,23 @@ fn main() {
let _ = arguments.next();
continue;
}
+ if option == "--data-dir" {
+ normalized.push(argument);
+ if let Some(value) = arguments.next() {
+ data_directory = Some(PathBuf::from(&value));
+ normalized.push(value);
+ }
+ continue;
+ }
normalized.push(argument);
}
- match Command::new(native).args(normalized).status() {
+ let mut command = Command::new(native);
+ command.args(normalized);
+ if let Some(directory) = data_directory {
+ command.current_dir(directory);
+ }
+ match command.status() {
Ok(status) => exit(status.code().unwrap_or(1)),
Err(error) => {
eprintln!("Could not start bundled pdf2htmlEX: {error}");
diff --git a/desktop/scripts/prepare-windows-runtimes.ps1 b/desktop/scripts/prepare-windows-runtimes.ps1
index a1a9b24..9727ba6 100644
--- a/desktop/scripts/prepare-windows-runtimes.ps1
+++ b/desktop/scripts/prepare-windows-runtimes.ps1
@@ -53,6 +53,7 @@ $popplerBin = Join-Path $popplerExtract 'poppler-26.02.0/Library/bin'
$ocrRuntime = Join-Path $RuntimeRoot 'ocr'
New-Item -ItemType Directory -Force -Path (Join-Path $ocrRuntime 'bin'), (Join-Path $ocrRuntime 'poppler/bin'), (Join-Path $ocrRuntime 'share/tessdata') | Out-Null
Copy-Item (Join-Path $tesseractRoot '*') (Join-Path $ocrRuntime 'bin') -Recurse -Force
+Copy-Item (Join-Path $tesseractRoot 'tessdata/*') (Join-Path $ocrRuntime 'share/tessdata') -Recurse -Force
Copy-Item (Join-Path $popplerBin '*') (Join-Path $ocrRuntime 'poppler/bin') -Recurse -Force
Copy-Item (Join-Path $popplerExtract 'poppler-26.02.0/Library/share') (Join-Path $ocrRuntime 'share/poppler') -Recurse -Force
diff --git a/desktop/scripts/smoke-live-document-tools.mjs b/desktop/scripts/smoke-live-document-tools.mjs
index 37487a5..2df49b9 100644
--- a/desktop/scripts/smoke-live-document-tools.mjs
+++ b/desktop/scripts/smoke-live-document-tools.mjs
@@ -28,24 +28,35 @@ async function expectSuccessfulResponse(label, response) {
}
const pdf = minimalPdf('Docuflex Live Tools');
-const conversion = await expectSuccessfulResponse('Edit Text conversion', await fetch(`${frontend}/api/pdf/convert`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pdfBase64: pdf.toString('base64') })
-}));
-const conversionResult = await conversion.json();
-if (!conversionResult.htmlBase64 || !Buffer.from(conversionResult.htmlBase64, 'base64').includes('Docuflex')) {
- throw new Error('Edit Text conversion did not return the expected document text.');
+const failures = [];
+try {
+ const conversion = await expectSuccessfulResponse('Edit Text conversion', await fetch(`${frontend}/api/pdf/convert`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pdfBase64: pdf.toString('base64') })
+ }));
+ const conversionResult = await conversion.json();
+ if (!conversionResult.htmlBase64 || !Buffer.from(conversionResult.htmlBase64, 'base64').includes('Docuflex')) {
+ throw new Error('Edit Text conversion did not return the expected document text.');
+ }
+} catch (error) {
+ failures.push(error);
}
-const ocr = await expectSuccessfulResponse('OCR', await fetch(`${frontend}/api/pdf/ocr`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/pdf', 'x-ocr-languages': 'eng' },
- body: pdf
-}));
-const ocrBytes = Buffer.from(await ocr.arrayBuffer());
-if (!ocrBytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
- throw new Error('OCR did not return a PDF document.');
+try {
+ const ocr = await expectSuccessfulResponse('OCR', await fetch(`${frontend}/api/pdf/ocr`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/pdf', 'x-ocr-languages': 'eng' },
+ body: pdf
+ }));
+ const ocrBytes = Buffer.from(await ocr.arrayBuffer());
+ if (!ocrBytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
+ throw new Error('OCR did not return a PDF document.');
+ }
+} catch (error) {
+ failures.push(error);
}
+if (failures.length) throw new AggregateError(failures, 'Packaged document tools failed.');
+
process.stdout.write('Packaged Edit Text and OCR API tests passed.\n');
diff --git a/desktop/scripts/smoke-runtimes.mjs b/desktop/scripts/smoke-runtimes.mjs
index 1a9db92..cf8e7d7 100644
--- a/desktop/scripts/smoke-runtimes.mjs
+++ b/desktop/scripts/smoke-runtimes.mjs
@@ -76,6 +76,13 @@ try {
timeout: 60_000
});
if (!/docuflex/i.test(ocrText)) throw new Error(`Bundled OCR smoke test failed: ${ocrText.trim()}`);
+ const ocrPdfBase = join(temporary, 'ocr-searchable');
+ run(ocrExecutable('tesseract'), [image, ocrPdfBase, '--dpi', '300', '-l', 'eng', 'pdf'], {
+ env: { TESSDATA_PREFIX: join(resources, 'runtime', 'ocr', 'share', 'tessdata') },
+ timeout: 60_000
+ });
+ const ocrPdf = await readFile(`${ocrPdfBase}.pdf`);
+ if (!ocrPdf.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw new Error('Bundled OCR PDF smoke test failed.');
const pdf = join(temporary, 'document.pdf');
const html = join(temporary, 'document.html');
From 28187b6de4698b3cb1dddbff2ff7a10d2661e057 Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sat, 18 Jul 2026 22:48:26 +0200
Subject: [PATCH 18/32] Normalize packaged native tool paths
---
desktop/scripts/prepare-linux-runtimes.sh | 9 +++++++++
desktop/src-tauri/src/lib.rs | 22 +++++++++++++++++++++-
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/desktop/scripts/prepare-linux-runtimes.sh b/desktop/scripts/prepare-linux-runtimes.sh
index 9cde536..0e5ff9b 100644
--- a/desktop/scripts/prepare-linux-runtimes.sh
+++ b/desktop/scripts/prepare-linux-runtimes.sh
@@ -66,6 +66,15 @@ for data in eng deu osd; do
test -n "$source_data"
cp "$source_data" "$RUNTIME_ROOT/ocr/share/tessdata/$data.traineddata"
done
+
+# Tesseract's searchable-PDF renderer loads these relative to TESSDATA_PREFIX.
+# The traineddata files alone are enough for plain text OCR, but not PDF output.
+for relative_data in configs/pdf pdf.ttf; do
+ source_data=$(find /usr/share -path "*/tessdata/$relative_data" -print -quit)
+ test -n "$source_data"
+ mkdir -p "$RUNTIME_ROOT/ocr/share/tessdata/$(dirname "$relative_data")"
+ cp "$source_data" "$RUNTIME_ROOT/ocr/share/tessdata/$relative_data"
+done
mkdir -p "$RUNTIME_ROOT/ocr/licenses"
for package in poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-osd; do
if [ -f "/usr/share/doc/$package/copyright" ]; then
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index a606881..70c3688 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -19,6 +19,26 @@ use url::Url;
const FRONTEND_PORT: u16 = 43_127;
const BACKEND_PORT: u16 = 43_128;
+#[cfg(target_os = "windows")]
+fn native_tool_path(path: PathBuf) -> PathBuf {
+ // Tauri may return verbatim (`\\?\`) paths for installed resources. Older
+ // native tools such as pdf2htmlEX 0.14 and Tesseract do not understand that
+ // prefix even though Node and Rust do.
+ let value = path.to_string_lossy();
+ if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
+ return PathBuf::from(format!(r"\\{rest}"));
+ }
+ if let Some(rest) = value.strip_prefix(r"\\?\") {
+ return PathBuf::from(rest);
+ }
+ path
+}
+
+#[cfg(not(target_os = "windows"))]
+fn native_tool_path(path: PathBuf) -> PathBuf {
+ path
+}
+
fn configure_platform_webview() {
#[cfg(target_os = "linux")]
{
@@ -344,7 +364,7 @@ pub fn run() {
},
))
.setup(move |app| {
- let resource_root = app.path().resource_dir()?.join("resources");
+ let resource_root = native_tool_path(app.path().resource_dir()?.join("resources"));
let log_directory = app.path().app_log_dir()?;
let children = match spawn_services(&resource_root, &log_directory) {
Ok(children) => children,
From 910120508dbe6aea62c0a4f905d33106d9c52e0a Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Sun, 19 Jul 2026 12:35:30 +0200
Subject: [PATCH 19/32] Polish native PDF desktop integration
---
.github/workflows/desktop-native-builds.yml | 6 ++
desktop/runtime/chrome.js | 100 +++++++++++++++----
desktop/scripts/package-arch-linux.sh | 4 +-
desktop/src-tauri/Cargo.lock | 1 +
desktop/src-tauri/Cargo.toml | 1 +
desktop/src-tauri/src/lib.rs | 105 +++++++++++++++++++-
desktop/src-tauri/tauri.conf.json | 10 ++
7 files changed, 204 insertions(+), 23 deletions(-)
diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
index 6d524fb..bb05239 100644
--- a/.github/workflows/desktop-native-builds.yml
+++ b/.github/workflows/desktop-native-builds.yml
@@ -199,6 +199,7 @@ jobs:
&& grep --quiet '^compositing=1$' /tmp/docuflex-launcher-env 2>/dev/null \
&& grep --quiet '^editor-loaded$' /tmp/docuflex-page-loaded 2>/dev/null; then
echo "Packaged AppImage service and WebKit page-load test passed."
+ node scripts/smoke-live-document-tools.mjs
exit 0
fi
sleep 1
@@ -273,6 +274,11 @@ jobs:
&& curl --fail --silent http://127.0.0.1:43127/editor >/dev/null \
&& grep --quiet '^editor-loaded$' /tmp/docuflex-page-loaded 2>/dev/null; then
echo 'Native Arch services and WebKit page-load test passed.'
+ if ! node desktop/scripts/smoke-live-document-tools.mjs; then
+ find "$HOME/.local/share/com.docuflex.editor/logs" -type f -name '*.log' \
+ -exec sh -c 'echo "[$1]"; cat "$1"' _ {} \; 2>/dev/null || true
+ exit 1
+ fi
exit 0
fi
sleep 1
diff --git a/desktop/runtime/chrome.js b/desktop/runtime/chrome.js
index 3ed112f..d39fcce 100644
--- a/desktop/runtime/chrome.js
+++ b/desktop/runtime/chrome.js
@@ -265,26 +265,36 @@
html[data-docuflex-desktop="windows"] .topbar,
html[data-docuflex-desktop="windows"] .brand-area,
html[data-docuflex-desktop="windows"] .tab-strip,
- html[data-docuflex-desktop="windows"] .utilities {
+ html[data-docuflex-desktop="windows"] .utilities,
+ html[data-docuflex-desktop="linux"] .topbar,
+ html[data-docuflex-desktop="linux"] .brand-area,
+ html[data-docuflex-desktop="linux"] .tab-strip,
+ html[data-docuflex-desktop="linux"] .utilities {
-webkit-app-region: drag;
}
- html[data-docuflex-desktop="windows"] .utilities {
+ html[data-docuflex-desktop="windows"] .utilities,
+ html[data-docuflex-desktop="linux"] .utilities {
padding-right: 168px !important;
}
html[data-docuflex-desktop="windows"] .topbar button,
html[data-docuflex-desktop="windows"] .topbar a,
html[data-docuflex-desktop="windows"] .topbar input,
- html[data-docuflex-desktop="windows"] .docuflex-windows-controls {
+ html[data-docuflex-desktop="linux"] .topbar button,
+ html[data-docuflex-desktop="linux"] .topbar a,
+ html[data-docuflex-desktop="linux"] .topbar input,
+ html[data-docuflex-desktop="windows"] .docuflex-window-controls,
+ html[data-docuflex-desktop="linux"] .docuflex-window-controls {
-webkit-app-region: no-drag;
}
- .docuflex-windows-controls {
+ .docuflex-window-controls {
display: none;
}
- html[data-docuflex-desktop="windows"] .docuflex-windows-controls {
+ html[data-docuflex-desktop="windows"] .docuflex-window-controls,
+ html[data-docuflex-desktop="linux"] .docuflex-window-controls {
display: flex;
height: 32px;
position: fixed;
@@ -293,7 +303,7 @@
z-index: 10002;
}
- .docuflex-windows-control {
+ .docuflex-window-control {
align-items: center;
background: transparent;
border: 0;
@@ -306,44 +316,44 @@
width: 46px;
}
- .docuflex-windows-control:hover {
+ .docuflex-window-control:hover {
background: rgba(0, 0, 0, 0.07);
color: #171717;
}
- .docuflex-windows-control.close:hover {
+ .docuflex-window-control.close:hover {
background: #c42b1c;
color: #fff;
}
- .docuflex-windows-control::before,
- .docuflex-windows-control::after {
+ .docuflex-window-control::before,
+ .docuflex-window-control::after {
box-sizing: border-box;
content: "";
position: absolute;
}
- .docuflex-windows-control.minimize::before {
+ .docuflex-window-control.minimize::before {
border-top: 1px solid currentColor;
height: 1px;
width: 10px;
}
- .docuflex-windows-control.maximize::before {
+ .docuflex-window-control.maximize::before {
border: 1px solid currentColor;
height: 10px;
width: 10px;
}
- .docuflex-windows-control.close::before,
- .docuflex-windows-control.close::after {
+ .docuflex-window-control.close::before,
+ .docuflex-window-control.close::after {
background: currentColor;
height: 1px;
transform: rotate(45deg);
width: 12px;
}
- .docuflex-windows-control.close::after {
+ .docuflex-window-control.close::after {
transform: rotate(-45deg);
}
@@ -402,7 +412,6 @@
}
const markDragRegion = () => {
- if (desktopPlatform === 'linux') return;
document
.querySelectorAll('.topbar, .brand-area, .tab-strip, .utilities')
.forEach((element) => element.setAttribute('data-tauri-drag-region', 'deep'));
@@ -414,9 +423,9 @@
subtree: true,
});
- if (desktopPlatform === 'windows' && !document.querySelector('.docuflex-windows-controls')) {
+ if (desktopPlatform !== 'macos' && !document.querySelector('.docuflex-window-controls')) {
const controls = document.createElement('div');
- controls.className = 'docuflex-windows-controls';
+ controls.className = 'docuflex-window-controls';
controls.setAttribute('aria-label', 'Window controls');
for (const [action, label] of [
['minimize', 'Minimize'],
@@ -424,7 +433,7 @@
['close', 'Close'],
]) {
const button = document.createElement('button');
- button.className = `docuflex-windows-control ${action}`;
+ button.className = `docuflex-window-control ${action}`;
button.type = 'button';
button.setAttribute('aria-label', label);
button.addEventListener('click', () => {
@@ -442,6 +451,59 @@
installDesktopChrome();
}
+ if (desktopPlatform !== 'macos') {
+ const forwardedZoomEvents = new WeakSet();
+ window.addEventListener('wheel', (event) => {
+ if (forwardedZoomEvents.has(event) || (!event.ctrlKey && !event.metaKey)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const slowed = new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ ctrlKey: event.ctrlKey,
+ metaKey: event.metaKey,
+ deltaMode: event.deltaMode,
+ deltaX: event.deltaX * 0.32,
+ deltaY: event.deltaY * 0.32,
+ deltaZ: event.deltaZ * 0.32,
+ });
+ forwardedZoomEvents.add(slowed);
+ event.target?.dispatchEvent(slowed);
+ }, { capture: true, passive: false });
+ }
+
+ const openPendingNativePdf = async () => {
+ const invoke = globalThis.__TAURI__?.core?.invoke;
+ if (typeof invoke !== 'function') return;
+ const pending = await invoke('take_pending_pdf').catch((error) => {
+ console.error('Could not read the PDF opened by the operating system:', error);
+ return null;
+ });
+ if (!pending?.base64 || !pending?.name) return;
+ const binary = atob(pending.base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ const input = document.querySelector('input.file-input[type="file"]');
+ if (!(input instanceof HTMLInputElement)) {
+ console.error('Could not find the desktop PDF input.');
+ return;
+ }
+ const transfer = new DataTransfer();
+ transfer.items.add(new File([bytes], pending.name, { type: 'application/pdf', lastModified: Date.now() }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ };
+
+ window.addEventListener('docuflex-native-open-pdf', () => void openPendingNativePdf());
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => void openPendingNativePdf(), { once: true });
+ } else {
+ void openPendingNativePdf();
+ }
+
const exportButtonSelector = '.utilities .utility-button[aria-label="Download"], .utilities .utility-button[aria-label="Exporting PDF"]';
const exportFormats = [
['pdf', 'PDF'],
diff --git a/desktop/scripts/package-arch-linux.sh b/desktop/scripts/package-arch-linux.sh
index 0413eb1..268668c 100644
--- a/desktop/scripts/package-arch-linux.sh
+++ b/desktop/scripts/package-arch-linux.sh
@@ -34,9 +34,11 @@ install -Dm644 /dev/stdin "$PACKAGE_ROOT/usr/share/applications/docuflex.desktop
[Desktop Entry]
Categories=Office;
Comment=Offline PDF editor
-Exec=docuflex-desktop %U
+Exec=docuflex-desktop %F
Icon=docuflex
+MimeType=application/pdf;
Name=Docuflex
+StartupWMClass=docuflex-desktop
Terminal=false
Type=Application
EOF
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 4580b69..1cbab0f 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -781,6 +781,7 @@ dependencies = [
name = "docuflex-desktop"
version = "0.0.1"
dependencies = [
+ "base64 0.22.1",
"libc",
"rfd",
"tauri",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 71da4ab..6d671d3 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -13,6 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
+base64 = "0.22.1"
libc = "0.2.186"
rfd = "0.17.2"
tauri = { version = "2.11.5", features = [] }
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 70c3688..68abe2e 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -1,4 +1,6 @@
+use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use std::{
+ collections::HashMap,
ffi::OsString,
fs::{self, File},
io::{Read, Write},
@@ -18,6 +20,75 @@ use url::Url;
const FRONTEND_PORT: u16 = 43_127;
const BACKEND_PORT: u16 = 43_128;
+const MAX_OPEN_PDF_BYTES: u64 = 230 * 1024 * 1024;
+
+type PendingPdf = Arc>>;
+
+fn pdf_path_from_arguments(arguments: I, cwd: &Path) -> Option
+where
+ I: IntoIterator- ,
+ S: AsRef,
+{
+ arguments.into_iter().skip(1).find_map(|argument| {
+ let value = argument.as_ref();
+ let candidate = if let Ok(url) = Url::parse(value) {
+ if url.scheme() != "file" {
+ return None;
+ }
+ url.to_file_path().ok()?
+ } else {
+ let path = PathBuf::from(value);
+ if path.is_absolute() {
+ path
+ } else {
+ cwd.join(path)
+ }
+ };
+ let is_pdf = candidate
+ .extension()
+ .and_then(|extension| extension.to_str())
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"));
+ (is_pdf && candidate.is_file()).then_some(candidate)
+ })
+}
+
+fn notify_pending_pdf(app: &tauri::AppHandle) {
+ if let Some(window) = app.get_webview_window("main") {
+ let _ = window.eval("window.dispatchEvent(new Event('docuflex-native-open-pdf'))");
+ }
+}
+
+#[tauri::command]
+fn take_pending_pdf(
+ pending: tauri::State<'_, PendingPdf>,
+) -> Result
@@ -426,8 +426,7 @@
width: 192px;
min-height: 38px;
align-items: center;
- justify-content: flex-start;
- gap: 11px;
+ justify-content: center;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 12px;
@@ -448,14 +447,6 @@
color: #111;
}
- .apple-mark {
- width: 19px;
- height: 19px;
- align-self: center;
- fill: currentColor;
- transform: translateY(-1px);
- }
-
.watch-link {
position: relative;
font-size: 16px;
From 48431000748d8d3218520bf2c20d1b132e4964ba Mon Sep 17 00:00:00 2001
From: max06bayer <101212752+max06bayer@users.noreply.github.com>
Date: Mon, 20 Jul 2026 23:30:31 +0200
Subject: [PATCH 32/32] landing page update
---
src/lib/server/request-security.js | 3 +-
src/routes/+page.svelte | 369 ++++++++++++-----------------
2 files changed, 159 insertions(+), 213 deletions(-)
diff --git a/src/lib/server/request-security.js b/src/lib/server/request-security.js
index 7991531..6c3284b 100644
--- a/src/lib/server/request-security.js
+++ b/src/lib/server/request-security.js
@@ -91,7 +91,8 @@ export function applySecurityHeaders(response, url) {
'Content-Security-Policy',
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; "
+ "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; "
- + "img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' blob:"
+ + "img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; "
+ + "frame-src 'self' blob: https://www.youtube.com"
);
if (url.protocol === 'https:' || env.NODE_ENV === 'production') {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 4233cbd..62e72d0 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -119,38 +119,14 @@
-
-
-
- D
- Docuflex Feature showcase
-
-
-
-
- Watch later
-
-
-
- Share
-
-
-
-
-
-
-
-
-
- Watch on
-
- YouTube
-
+
+
@@ -423,7 +399,7 @@
.download-button {
display: inline-flex;
- width: 192px;
+ width: 120px;
min-height: 38px;
align-items: center;
justify-content: center;
@@ -604,7 +580,7 @@
}
}
- .video-placeholder {
+ .video-embed {
position: relative;
width: min(1190px, calc(100% - 80px));
aspect-ratio: 16 / 9;
@@ -616,111 +592,11 @@
color: #f1f1f1;
}
- .video-topbar {
- position: absolute;
- top: 0;
- right: 0;
- left: 0;
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- padding: 14px 16px;
- font-size: 16px;
- }
-
- .video-channel {
- display: flex;
- align-items: center;
- gap: 11px;
- }
-
- .video-avatar {
- display: grid;
- width: 38px;
- height: 38px;
- place-items: center;
- border-radius: 50%;
- background: #ececec;
- color: #0878f9;
- font-size: 18px;
- font-weight: 650;
- }
-
- .video-utilities {
- display: flex;
- gap: 24px;
- }
-
- .video-utility {
- display: grid;
- justify-items: center;
- gap: 3px;
- font-size: 12px;
- }
-
- .video-utility svg {
- width: 24px;
- height: 24px;
- fill: none;
- stroke: currentColor;
- stroke-width: 1.8;
- stroke-linecap: round;
- stroke-linejoin: round;
- }
-
- .video-utility .share-icon {
- fill: currentColor;
- stroke: none;
- }
-
- .video-play {
- position: absolute;
- top: 50%;
- left: 50%;
- display: grid;
- width: 88px;
- height: 62px;
- place-items: center;
- border-radius: 15px;
- background: #ff0033;
- transform: translate(-50%, -50%);
- }
-
- .video-play svg {
- width: 38px;
- height: 30px;
- fill: #fff;
- }
-
- .youtube-label {
- position: absolute;
- bottom: 0;
- left: 0;
- display: flex;
- height: 48px;
- align-items: center;
- gap: 6px;
- padding: 0 14px;
- background: #181818;
- color: #f4f4f4;
- font-size: 13px;
- }
-
- .youtube-label .youtube-logo {
- width: 24px;
- height: 18px;
- }
-
- .youtube-logo rect {
- fill: #ff0033;
- }
-
- .youtube-logo path {
- fill: #fff;
- }
-
- .youtube-label strong {
- font-weight: 650;
+ .video-embed iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
}
.site-footer {
@@ -899,51 +775,53 @@
@media (max-width: 760px) {
.hero {
- min-height: 980px;
+ min-height: 780px;
}
.site-header {
- height: 60px;
- padding-inline: 20px;
+ height: calc(74px + env(safe-area-inset-top, 0px));
+ align-items: flex-end;
+ padding: env(safe-area-inset-top, 0px) 22px 16px;
+ border-bottom-color: rgba(255, 255, 255, 0.09);
}
.brand {
- width: 148px;
- }
-
- .nav-link {
- display: none;
- }
-
- nav {
- gap: 0;
+ width: 176px;
}
+ .site-header nav,
.web-editor-button {
- height: 43px;
- font-size: 16px;
+ display: none;
}
.hero-background {
- inset-block-start: 60px;
+ inset-block-start: calc(74px + env(safe-area-inset-top, 0px));
+ background-image: linear-gradient(to bottom, transparent 70%, rgba(0, 0, 0, 0.72) 94%, #000 100%),
+ url('/landing-page/background.jpg');
background-position: center top;
- background-size: 100% auto;
+ background-size: auto 690px;
}
.hero-content {
- width: calc(100% - 40px);
- padding-top: 112px;
+ width: min(100% - 44px, 520px);
+ padding-top: 70px;
+ text-align: left;
}
h1 {
- font-size: clamp(43px, 13vw, 64px);
- line-height: 1.02;
+ max-width: 440px;
+ margin: 0;
+ font-size: clamp(40px, 11.5vw, 52px);
+ letter-spacing: -0.052em;
+ line-height: 1.04;
}
.hero-content p {
- margin-top: 30px;
- font-size: 17px;
- line-height: 1.55;
+ max-width: 430px;
+ margin: 24px 0 0;
+ color: #969696;
+ font-size: 16px;
+ line-height: 1.58;
}
.desktop-break {
@@ -951,108 +829,175 @@
}
.hero-actions {
- flex-wrap: wrap;
- margin-top: 34px;
+ justify-content: flex-start;
+ gap: 26px;
+ margin-top: 30px;
}
- .editor-showcase {
- top: 540px;
- width: 1020px;
- transform: translateX(-50%) rotate(-1deg);
+ .download-button {
+ width: 126px;
+ min-height: 42px;
}
-
- .next-section {
- margin-top: -330px;
- padding-block: 96px 120px;
+ .watch-link {
+ color: #b2b2b2;
}
- .feature-set img,
- .feature-marquee-empty .feature-set img {
- width: 268px;
- height: 57px;
+ .business-button {
+ display: none;
}
- .feature-title-row {
- min-height: 88px;
- margin-block: 18px;
+ .editor-showcase {
+ top: 505px;
+ left: -16%;
+ width: 185%;
+ transform: rotate(-1deg);
+ filter: drop-shadow(0 24px 36px rgba(0, 0, 0, 0.38));
}
- .feature-title-row h2 {
- width: calc(100% - 40px);
- font-size: clamp(35px, 10vw, 54px);
+ .next-section {
+ min-height: 0;
+ margin-top: 0;
+ padding-block: 58px 62px;
}
- .video-placeholder {
- width: calc(100% - 32px);
- margin-top: 70px;
+ .feature-set img,
+ .feature-marquee-empty .feature-set img {
+ width: 210px;
+ height: 45px;
}
- .video-topbar {
- padding: 10px;
- font-size: 12px;
+ .feature-set,
+ .feature-marquee-empty .feature-set {
+ gap: 12px;
+ padding-right: 12px;
}
- .video-avatar {
- width: 30px;
- height: 30px;
- font-size: 14px;
+ .feature-title-row {
+ min-height: 78px;
+ margin-block: 12px;
}
- .video-utilities {
- display: none;
+ .feature-title-row h2 {
+ width: calc(100% - 48px);
+ font-size: clamp(34px, 9.5vw, 44px);
+ line-height: 1.04;
}
- .video-play {
- width: 66px;
- height: 46px;
+ .video-embed {
+ width: calc(100% - 24px);
+ margin-top: 52px;
+ border-color: #292929;
border-radius: 12px;
+ box-shadow: 0 20px 48px rgba(0, 0, 0, 0.36);
}
- .youtube-label {
- height: 38px;
- font-size: 11px;
+ .site-footer {
+ border-top: 0;
+ background: linear-gradient(180deg, #111 0%, #0b0b0b 100%);
}
.footer-main {
grid-template-columns: 1fr 1fr;
- width: calc(100% - 40px);
+ width: 100%;
min-height: 0;
- gap: 54px 32px;
- padding-block: 68px;
+ gap: 30px 28px;
+ padding: 40px 22px 30px;
}
.footer-brand {
grid-column: 1 / -1;
- width: 176px;
+ width: 182px;
+ margin: 0;
}
.footer-column {
- gap: 20px;
- font-size: 16px;
+ gap: 12px;
+ font-size: 13px;
}
.footer-column h3 {
- font-size: 16px;
+ margin-bottom: 4px;
+ font-size: 14px;
+ }
+
+ .footer-column:last-child {
+ display: none;
}
.footer-bottom {
min-height: 0;
- align-items: flex-start;
flex-direction: column;
- gap: 34px;
- padding: 34px 20px 44px;
+ gap: 18px;
+ padding: 10px 22px 28px;
+ border-top: 0;
+ background: transparent;
+ }
+
+ .footer-socials {
+ order: -1;
+ gap: 18px;
+ padding: 0;
+ }
+
+ .footer-socials a {
+ width: 24px;
+ height: 24px;
+ }
+
+ .footer-socials svg {
+ width: 22px;
+ height: 22px;
+ }
+
+ .footer-meta {
+ width: 100%;
+ gap: 16px;
}
.footer-legal-links {
+ display: flex;
flex-wrap: wrap;
- gap: 18px 28px;
+ justify-content: flex-start;
+ gap: 10px 20px;
}
.footer-legal-links button,
.footer-legal-links a,
.footer-meta p {
- font-size: 16px;
+ font-size: 13px;
+ }
+
+ .footer-meta p {
+ color: #666;
+ font-size: 12px;
+ }
+ }
+
+ @media (max-width: 390px) {
+ .hero {
+ min-height: 750px;
+ }
+
+ .hero-content {
+ width: calc(100% - 36px);
+ padding-top: 58px;
+ }
+
+ h1 {
+ font-size: 39px;
+ }
+
+ .hero-content p {
+ font-size: 15px;
+ }
+
+ .editor-showcase {
+ top: 495px;
+ }
+
+ .footer-main {
+ gap: 34px 18px;
}
}