diff --git a/.github/workflows/desktop-native-builds.yml b/.github/workflows/desktop-native-builds.yml
new file mode 100644
index 0000000..5140143
--- /dev/null
+++ b/.github/workflows/desktop-native-builds.yml
@@ -0,0 +1,300 @@
+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: Launch-test packaged Windows runtime
+ shell: pwsh
+ working-directory: desktop
+ run: |
+ $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.'
+ }
+ $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 $appPath -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.'
+ node scripts/smoke-live-document-tools.mjs
+ if ($LASTEXITCODE -ne 0) { throw 'Packaged document-tool API tests failed.' }
+ 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:
+ 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 xvfb
+ - 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: 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"
+ 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=$!
+ 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 \
+ && 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."
+ if ! node 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
+ 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:
+ 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
+
+ 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 poppler rust tesseract 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.'
+ 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
+ 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/.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..1e33625 100644
--- a/desktop/runtime/chrome.js
+++ b/desktop/runtime/chrome.js
@@ -1,5 +1,32 @@
(() => {
+ const desktopPlatform = /Windows/i.test(navigator.userAgent)
+ ? 'windows'
+ : /Linux/i.test(navigator.userAgent)
+ ? 'linux'
+ : 'macos';
const desktopBlobUrls = new Map();
+ const NativeDesktopFile = globalThis.File;
+ const desktopConstructedFileBytes = new WeakMap();
+ if (typeof NativeDesktopFile === 'function') {
+ const DocuflexDesktopFile = function (parts, name, options) {
+ const file = new NativeDesktopFile(parts, name, options);
+ if (parts?.length === 1) {
+ const part = parts[0];
+ if (part instanceof ArrayBuffer) {
+ desktopConstructedFileBytes.set(file, part.slice(0));
+ } else if (ArrayBuffer.isView(part)) {
+ desktopConstructedFileBytes.set(
+ file,
+ part.buffer.slice(part.byteOffset, part.byteOffset + part.byteLength),
+ );
+ }
+ }
+ return file;
+ };
+ Object.setPrototypeOf(DocuflexDesktopFile, NativeDesktopFile);
+ DocuflexDesktopFile.prototype = NativeDesktopFile.prototype;
+ globalThis.File = DocuflexDesktopFile;
+ }
const originalCreateObjectUrl = URL.createObjectURL.bind(URL);
const originalRevokeObjectUrl = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = (object) => {
@@ -69,7 +96,9 @@
configurable: true,
enumerable: false,
value() {
- if (!(this instanceof File)) return originalArrayBuffer.call(this);
+ if (!(this instanceof NativeDesktopFile)) return originalArrayBuffer.call(this);
+ const constructedBytes = desktopConstructedFileBytes.get(this);
+ if (constructedBytes) return Promise.resolve(constructedBytes.slice(0));
let active = activeReads.get(this);
if (!active) {
const key = fileKey(this);
@@ -249,6 +278,115 @@
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="linux"] .text-highlight-layer .text-highlight,
+ html[data-docuflex-desktop="linux"] [data-docuflex-annotation-type="highlight"] rect {
+ mix-blend-mode: multiply !important;
+ opacity: 0.42 !important;
+ }
+
+ 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="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="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="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-window-controls {
+ display: none;
+ }
+
+ html[data-docuflex-desktop="windows"] .docuflex-window-controls,
+ html[data-docuflex-desktop="linux"] .docuflex-window-controls {
+ display: flex;
+ height: 32px;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 10002;
+ }
+
+ .docuflex-window-control {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #616161;
+ display: flex;
+ height: 32px;
+ justify-content: center;
+ padding: 0;
+ position: relative;
+ width: 46px;
+ }
+
+ .docuflex-window-control:hover {
+ background: rgba(0, 0, 0, 0.07);
+ color: #171717;
+ }
+
+ .docuflex-window-control.close:hover {
+ background: #c42b1c;
+ color: #fff;
+ }
+
+ .docuflex-window-control::before,
+ .docuflex-window-control::after {
+ box-sizing: border-box;
+ content: "";
+ position: absolute;
+ }
+
+ .docuflex-window-control.minimize::before {
+ border-top: 1px solid currentColor;
+ height: 1px;
+ width: 10px;
+ }
+
+ .docuflex-window-control.maximize::before {
+ border: 1px solid currentColor;
+ height: 10px;
+ width: 10px;
+ }
+
+ .docuflex-window-control.close::before,
+ .docuflex-window-control.close::after {
+ background: currentColor;
+ height: 1px;
+ transform: rotate(45deg);
+ width: 12px;
+ }
+
+ .docuflex-window-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 +432,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,9 +442,14 @@
}
const markDragRegion = () => {
- document
- .querySelectorAll('.topbar, .brand-area, .tab-strip, .utilities')
- .forEach((element) => element.setAttribute('data-tauri-drag-region', 'deep'));
+ document.querySelectorAll('.topbar, .brand-area, .tab-strip, .utilities').forEach((element) => {
+ element.setAttribute('data-tauri-drag-region', '');
+ element.querySelectorAll('*').forEach((child) => {
+ if (!child.closest('button, a, input, select, textarea, [role="button"]')) {
+ child.setAttribute('data-tauri-drag-region', '');
+ }
+ });
+ });
};
markDragRegion();
@@ -314,6 +457,27 @@
childList: true,
subtree: true,
});
+
+ if (desktopPlatform !== 'macos' && !document.querySelector('.docuflex-window-controls')) {
+ const controls = document.createElement('div');
+ controls.className = 'docuflex-window-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-window-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') {
@@ -322,6 +486,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/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.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/native-tool-linux-wrapper.sh b/desktop/runtime/native-tool-linux-wrapper.sh
new file mode 100644
index 0000000..2ca7c75
--- /dev/null
+++ b/desktop/runtime/native-tool-linux-wrapper.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+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}"
+if [ "$TOOL_NAME" = "tesseract" ]; then
+ arguments=()
+ for argument in "$@"; do
+ if [ "$argument" = "pdf" ]; then
+ arguments+=(-c textonly_pdf=0)
+ fi
+ arguments+=("$argument")
+ done
+ exec "$SCRIPT_DIR/$TOOL_NAME-native" "${arguments[@]}"
+fi
+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/pdf2htmlEX-windows-wrapper.rs b/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
new file mode 100644
index 0000000..6c2632f
--- /dev/null
+++ b/desktop/runtime/pdf2htmlEX-windows-wrapper.rs
@@ -0,0 +1,44 @@
+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 data_directory = None;
+ 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;
+ }
+ 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);
+ }
+
+ 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}");
+ exit(1);
+ }
+ }
+}
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..869d452
--- /dev/null
+++ b/desktop/scripts/build.mjs
@@ -0,0 +1,27 @@
+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'];
+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: buildEnvironment,
+ stdio: 'inherit'
+});
diff --git a/desktop/scripts/package-arch-linux.sh b/desktop/scripts/package-arch-linux.sh
new file mode 100644
index 0000000..f226a81
--- /dev/null
+++ b/desktop/scripts/package-arch-linux.sh
@@ -0,0 +1,154 @@
+#!/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/"
+INSTALLED_RESOURCE_ROOT="$PACKAGE_ROOT/usr/lib/$RESOURCE_NAME"
+if [ -d "$INSTALLED_RESOURCE_ROOT/resources/runtime" ]; then
+ RUNTIME_ROOT="$INSTALLED_RESOURCE_ROOT/resources/runtime"
+else
+ RUNTIME_ROOT="$INSTALLED_RESOURCE_ROOT/runtime"
+fi
+test -d "$RUNTIME_ROOT"
+
+# The Debian payload carries Ubuntu-compatible OCR binaries for AppImage and
+# Debian users. A native Arch package must use current Arch Poppler/Tesseract
+# binaries instead of mixing Ubuntu ELF dependencies with rolling libraries.
+for tool in pdftoppm pdfunite; do
+ install -Dm755 /dev/stdin \
+ "$RUNTIME_ROOT/ocr/bin/$tool" < not found/ { print $1 }')
+ [ -n "$missing_libraries" ] || break
+ while IFS= read -r library; do
+ [ -n "$library" ] || continue
+ library_source=$(find "$PDF2HTML_RUNTIME/app" -name "$library" -print -quit)
+ if [ -z "$library_source" ]; then
+ echo "Bundled pdf2htmlEX payload does not contain $library." >&2
+ exit 1
+ fi
+ cp -L "$library_source" "$PDF2HTML_RUNTIME/compat/$library"
+ done < not found'; then
+ echo 'Bundled pdf2htmlEX still has unresolved Arch compatibility libraries.' >&2
+ exit 1
+fi
+install -Dm755 /dev/stdin \
+ "$PDF2HTML_RUNTIME/bin/pdf2htmlEX" <<'EOF'
+#!/bin/bash
+set -eu
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+APP_ROOT="$SCRIPT_DIR/../app"
+export APPDIR="$APP_ROOT"
+export LD_LIBRARY_PATH="$SCRIPT_DIR/../compat${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+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
+exec "$APP_ROOT/AppRun" "${normalized_args[@]}"
+EOF
+"$PDF2HTML_RUNTIME/bin/pdf2htmlEX" --version >/dev/null
+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 %F
+Icon=docuflex
+MimeType=application/pdf;
+Name=Docuflex
+StartupWMClass=docuflex-desktop
+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/scripts/prepare-linux-runtimes.sh b/desktop/scripts/prepare-linux-runtimes.sh
new file mode 100644
index 0000000..0e5ff9b
--- /dev/null
+++ b/desktop/scripts/prepare-linux-runtimes.sh
@@ -0,0 +1,96 @@
+#!/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"
+
+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"
+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
+
+# 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
+ cp "/usr/share/doc/$package/copyright" "$RUNTIME_ROOT/ocr/licenses/$package-copyright.txt"
+ fi
+done
+
+# 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'
+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..9727ba6
--- /dev/null
+++ b/desktop/scripts/prepare-windows-runtimes.ps1
@@ -0,0 +1,84 @@
+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)
+ & 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" }
+}
+
+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-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 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
+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 $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
+
+$trainedData = @{
+ eng = '7d4322bd2a7749724879683fc3912cb542f19906c83bcc1a52132556427170b2'
+ deu = '19d219bbb6672c869d20a9636c6816a81eb9a71796cb93ebe0cb1530e2cdb22d'
+ osd = '9cf5d576fcc47564f11265841e5ca839001e7e6f38ff7f7aacf46d15a96b00ff'
+}
+foreach ($language in $trainedData.Keys) {
+ $destination = Join-Path $ocrRuntime "share/tessdata/$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
+@'
+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.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.
+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..111732e 100644
--- a/desktop/scripts/prepare.mjs
+++ b/desktop/scripts/prepare.mjs
@@ -2,27 +2,37 @@ 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, '..');
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';
-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 +44,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 +61,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 +80,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 +101,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 +166,97 @@ 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 writeDesktopShell() {
+ await rm(distRoot, { recursive: true, force: true });
+ await mkdir(distRoot, { recursive: true });
+ await writeFile(join(distRoot, 'index.html'), `
+
+
+
+
+ Docuflex
+
+
+
+`);
}
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 writeDesktopShell();
}
await main();
diff --git a/desktop/scripts/smoke-live-document-tools.mjs b/desktop/scripts/smoke-live-document-tools.mjs
new file mode 100644
index 0000000..1ef6684
--- /dev/null
+++ b/desktop/scripts/smoke-live-document-tools.mjs
@@ -0,0 +1,95 @@
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const frontend = process.env.DOCUFLEX_FRONTEND_URL || 'http://127.0.0.1:43127';
+
+function assertPdfHasVisiblePixels(pdfBytes) {
+ const directory = mkdtempSync(join(tmpdir(), 'docuflex-ocr-visual-'));
+ try {
+ const pdfPath = join(directory, 'ocr.pdf');
+ const imagePrefix = join(directory, 'page');
+ writeFileSync(pdfPath, pdfBytes);
+ execFileSync('pdftoppm', ['-f', '1', '-singlefile', '-r', '96', '-gray', pdfPath, imagePrefix], {
+ stdio: 'pipe'
+ });
+ const pgm = readFileSync(`${imagePrefix}.pgm`);
+ const header = pgm.toString('ascii', 0, Math.min(pgm.length, 256));
+ const match = header.match(/^P5\s+(?:#.*\s+)*(\d+)\s+(\d+)\s+(\d+)\s/);
+ if (!match) throw new Error('OCR visual check could not parse the rendered page.');
+ const headerLength = match[0].length;
+ const pixels = pgm.subarray(headerLength);
+ const visiblePixels = pixels.reduce((count, value) => count + (value < 245 ? 1 : 0), 0);
+ if (visiblePixels < 100) {
+ throw new Error(`OCR rendered a blank page (${visiblePixels} non-white pixels).`);
+ }
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+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 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);
+}
+
+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.');
+ }
+ if (ocrBytes.length < 5_000 || ocrBytes.length < pdf.length * 3) {
+ throw new Error(`OCR returned a suspiciously small, potentially blank PDF (${ocrBytes.length} bytes).`);
+ }
+ assertPdfHasVisiblePixels(ocrBytes);
+} 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
new file mode 100644
index 0000000..cf8e7d7
--- /dev/null
+++ b/desktop/scripts/smoke-runtimes.mjs
@@ -0,0 +1,102 @@
+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.com')
+ : 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 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');
+ 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..1cbab0f 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"
@@ -772,7 +781,9 @@ dependencies = [
name = "docuflex-desktop"
version = "0.0.1"
dependencies = [
+ "base64 0.22.1",
"libc",
+ "rfd",
"tauri",
"tauri-build",
"tauri-plugin-single-instance",
@@ -794,6 +805,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 +2434,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
- "quick-xml",
+ "quick-xml 0.41.0",
"serde",
"time",
]
@@ -2462,6 +2479,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 +2568,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 +2716,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 +2837,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 +4153,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..6d671d3 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"
@@ -13,7 +13,9 @@ 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 = [] }
tauri-plugin-single-instance = "2.4.0"
url = "2.5.7"
diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json
index f960699..44f00de 100644
--- a/desktop/src-tauri/capabilities/main.json
+++ b/desktop/src-tauri/capabilities/main.json
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window",
- "description": "Allows the packaged macOS editor window to use its custom native drag regions.",
+ "description": "Allows the packaged desktop editor window to use its custom native drag regions.",
"remote": {
"urls": ["http://127.0.0.1:43127/*"]
},
@@ -9,6 +9,5 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging"
- ],
- "platforms": ["macOS"]
+ ]
}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 0a1a4b7..6d46fc3 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -1,4 +1,7 @@
+use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use std::{
+ collections::HashMap,
+ ffi::OsString,
fs::{self, File},
io::{Read, Write},
net::{SocketAddr, TcpStream},
@@ -8,14 +11,130 @@ use std::{
thread,
time::{Duration, Instant},
};
-use tauri::{
- webview::DownloadEvent, LogicalPosition, Manager, TitleBarStyle, WebviewUrl,
- WebviewWindowBuilder,
-};
+#[cfg(any(target_os = "linux", target_os = "windows"))]
+use tauri::webview::PageLoadEvent;
+use tauri::{webview::DownloadEvent, Manager, WebviewUrl, WebviewWindowBuilder};
+#[cfg(target_os = "macos")]
+use tauri::{LogicalPosition, TitleBarStyle};
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
@@ -119,38 +119,14 @@
-
-
-
- D
- Docuflex Feature showcase
-
-
-
-
- Watch later
-
-
-
- Share
-
-
-
-
-
-
-
-
-
- Watch on
-
- YouTube
-
+
+
@@ -423,11 +399,10 @@
.download-button {
display: inline-flex;
- width: 192px;
+ width: 120px;
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 +423,6 @@
color: #111;
}
- .apple-mark {
- width: 19px;
- height: 19px;
- align-self: center;
- fill: currentColor;
- transform: translateY(-1px);
- }
-
.watch-link {
position: relative;
font-size: 16px;
@@ -613,7 +580,7 @@
}
}
- .video-placeholder {
+ .video-embed {
position: relative;
width: min(1190px, calc(100% - 80px));
aspect-ratio: 16 / 9;
@@ -625,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 {
@@ -908,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 {
@@ -960,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;
}
}