diff --git a/.gitattributes b/.gitattributes index e002770..4fb1f3d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,5 @@ packaging/ppa/debian/copyright text eol=lf packaging/ppa/debian/source/format text eol=lf packaging/copr/pdf-equilibrist.spec text eol=lf packaging/copr/pdf-equilibrist.sh text eol=lf +*.sh text eol=lf +packaging/appimage/AppRun text eol=lf diff --git a/.github/workflows/linux-appimage.yml b/.github/workflows/linux-appimage.yml new file mode 100644 index 0000000..4a650fc --- /dev/null +++ b/.github/workflows/linux-appimage.yml @@ -0,0 +1,207 @@ +name: Linux AppImage + +# Canal « autres distributions » : un seul fichier .AppImage, joint à la release GitHub. +# - tag v* : construit, teste, puis JOINT l'AppImage à la release du tag (celle que crée +# release.yml ; ce workflow attend qu'elle existe). +# - workflow_dispatch avec `tag` (ex. v0.1.25) : même chose pour une release existante (rattrapage) ; +# le code construit est celui de la branche choisie dans « Run workflow ». +# - workflow_dispatch sans `tag`, ou PR touchant packaging/appimage/ : construit et teste seulement, +# artefact de run de 14 jours, rien n'est publié. +# +# Construit sur Ubuntu 22.04 (glibc 2.35) : un binaire construit sur un système ancien tourne sur +# les récents, pas l'inverse. L'AppImage est ensuite lancée dans plusieurs distributions vierges. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag de la release à laquelle joindre l''AppImage (ex. v0.1.25) ; vide = essai sans publication' + required: false + default: '' + pull_request: + branches: [main] + paths: + - 'packaging/appimage/**' + - '.github/workflows/linux-appimage.yml' + - 'src/pdf_equilibrist/update.py' + +permissions: + contents: read + +env: + APPIMAGETOOL_VERSION: '1.9.1' + # SHA-256 du binaire de la release officielle AppImage/appimagetool 1.9.1 (épinglé : on n'exécute + # jamais un binaire téléchargé sans l'avoir vérifié) + APPIMAGETOOL_SHA256: 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0' + +jobs: + # Comme tous les workflows de publication : rien n'est construit ni publié si les contrôles échouent. + checks: + if: github.event_name == 'push' + name: Contrôles (lint, Bandit, tests) + uses: ./.github/workflows/checks.yml + + build: + name: Build AppImage (Ubuntu 22.04) + needs: checks + # `checks` est ignoré hors tag (PR, dispatch) : on construit alors quand même + if: ${{ !cancelled() && (needs.checks.result == 'success' || needs.checks.result == 'skipped') }} + runs-on: ubuntu-22.04 + outputs: + tag: ${{ steps.version.outputs.TAG }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version + id: version + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + tag="" + if [ "$REF_TYPE" = "tag" ]; then tag="$REF_NAME"; elif [ -n "$INPUT_TAG" ]; then tag="$INPUT_TAG"; fi + if [ -n "$tag" ]; then + case "$tag" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "Tag inattendu : $tag" >&2; exit 1;; esac + echo "TAG=$tag" >> "$GITHUB_OUTPUT" + # Même injection que release.yml : l'application affiche (et compare) la version du tag + sed -i "s/^__version__ = \".*\"/__version__ = \"${tag#v}\"/" src/pdf_equilibrist/__init__.py + grep '^__version__' src/pdf_equilibrist/__init__.py + fi + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install system libraries (Qt6/xcb, offscreen) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxcb-cursor0 libegl1 libgl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libglib2.0-0 binutils + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt pyinstaller + + - name: Download appimagetool (pinned and verified) + run: | + curl -fsSL -o appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage" + echo "${APPIMAGETOOL_SHA256} appimagetool" | sha256sum -c - + chmod +x appimagetool + + - name: Build the AppDir and the AppImage + env: + PYTHON: python + run: | + APPIMAGETOOL="$PWD/appimagetool" bash packaging/appimage/build_appimage.sh + ls -la build/appimage/*.AppImage + + # Environnement minimal et HOME vide : ce que verrait un utilisateur sur une machine vierge. + - name: OCR selftest on the AppImage (offline, empty home) + run: | + H="$(mktemp -d)" + env -i HOME="$H" PATH=/usr/bin:/bin APPIMAGE_EXTRACT_AND_RUN=1 \ + build/appimage/PDF-Equilibrist-x86_64.AppImage --ocr-selftest "$H/report.txt" || { cat "$H/report.txt"; exit 1; } + cat "$H/report.txt" + # Informatif : en local (Ubuntu 24.04) un dossier ~/.cache apparaît pendant le selftest ; on veut + # savoir quel composant l'écrit (aucun fichier utilisateur ne doit contenir de document). + echo "Fichiers créés dans HOME hors rapport :" + find "$H" -mindepth 1 ! -name report.txt | head -20 || true + + - name: GUI starts offscreen and stays up + run: | + H="$(mktemp -d)" + set +e + env -i HOME="$H" PATH=/usr/bin:/bin APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ + timeout 15 build/appimage/PDF-Equilibrist-x86_64.AppImage > "$H/run.log" 2>&1 + rc=$? + cat "$H/run.log" | tail -20 + # 124 = toujours en cours d'exécution au bout de 15 s = démarrage réussi + test "$rc" = 124 + + - name: Summary + run: | + { + echo "### AppImage (prototype)" + echo "- Taille : $(du -h build/appimage/PDF-Equilibrist-x86_64.AppImage | cut -f1)" + echo "- sha256 : \`$(sha256sum build/appimage/PDF-Equilibrist-x86_64.AppImage | cut -d' ' -f1)\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the AppImage + uses: actions/upload-artifact@v4 + with: + name: PDF-Equilibrist-x86_64.AppImage + path: build/appimage/PDF-Equilibrist-x86_64.AppImage + retention-days: 14 + + # La même AppImage, lancée dans des distributions vierges (sans Python ni aucune dépendance de l'appli) + distros: + name: Run on ${{ matrix.image }} + needs: build + # `checks` (ancêtre) est ignoré hors tag : sans cette condition, GitHub ignorerait aussi ce job + if: ${{ !cancelled() && needs.build.result == 'success' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: ['debian:12', 'ubuntu:24.04', 'fedora:44', 'archlinux:latest', 'opensuse/tumbleweed'] + container: + image: ${{ matrix.image }} + steps: + - name: Download the AppImage + uses: actions/download-artifact@v4 + with: + name: PDF-Equilibrist-x86_64.AppImage + + - name: OCR selftest (no Python, no network used, fresh distribution) + run: | + chmod +x PDF-Equilibrist-x86_64.AppImage + . /etc/os-release; echo "== $PRETTY_NAME"; command -v python3 || echo "(pas de python3 : normal)" + export HOME="$(mktemp -d)" APPIMAGE_EXTRACT_AND_RUN=1 + ./PDF-Equilibrist-x86_64.AppImage --ocr-selftest "$HOME/report.txt" || { cat "$HOME/report.txt" 2>/dev/null; exit 1; } + cat "$HOME/report.txt" + + # Joint l'AppImage à la release GitHub (créée par release.yml sur le même tag). + publish: + name: Attach to the GitHub release + needs: [build, distros] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.distros.result == 'success' && needs.build.outputs.tag != '' }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.build.outputs.tag }} + steps: + - name: Checkout (install script) + uses: actions/checkout@v4 + with: + sparse-checkout: packaging/appimage/install.sh + sparse-checkout-cone-mode: false + + - name: Download the AppImage + uses: actions/download-artifact@v4 + with: + name: PDF-Equilibrist-x86_64.AppImage + + # release.yml (Windows) crée la release bien après la fin de ce workflow : on attend (max 60 min) + - name: Wait for the release + run: | + for i in $(seq 1 60); do + if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG trouvée"; exit 0; fi + echo "Release $TAG pas encore créée (essai $i/60)…"; sleep 60 + done + echo "Release $TAG introuvable : le workflow Release a-t-il échoué ?" >&2; exit 1 + + - name: Upload (replaces any previous AppImage) + run: | + sha256sum PDF-Equilibrist-x86_64.AppImage | tee PDF-Equilibrist-x86_64.AppImage.sha256 + gh release upload "$TAG" PDF-Equilibrist-x86_64.AppImage PDF-Equilibrist-x86_64.AppImage.sha256 packaging/appimage/install.sh --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 382e0c5..e9f56d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,6 +152,9 @@ jobs: - **Arch Linux et dérivées** : `yay -S pdf-equilibrist` ([AUR](https://aur.archlinux.org/packages/pdf-equilibrist)) - **Ubuntu et dérivées** : `sudo add-apt-repository ppa:paulwoisard/pdf-equilibrist && sudo apt update && sudo apt install pdf-equilibrist` ([PPA](https://launchpad.net/~paulwoisard/+archive/ubuntu/pdf-equilibrist)) - **Fedora et dérivées** : `sudo dnf copr enable paullux/PDF-Equilibrist && sudo dnf install pdf-equilibrist` ([COPR](https://copr.fedorainfracloud.org/coprs/paullux/PDF-Equilibrist/)) + - **Autres distributions Linux** (Debian, openSUSE, Mint…) : AppImage, un seul fichier, ajoutée quelques minutes après l'installeur. + Installation guidée (menu, association aux PDF, sans root) : `curl -fsSLO https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest/download/install.sh && bash install.sh` + ou téléchargez `PDF-Equilibrist-x86_64.AppImage` ci-dessous, `chmod +x` puis lancez-la (`.sha256` fourni). append_body: true generate_release_notes: true draft: false diff --git a/.gitignore b/.gitignore index 4aea276..34d3b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ PDF Equilibrist.zip # PyInstaller *.spec !packaging/copr/pdf-equilibrist.spec +!packaging/appimage/PDF-Equilibrist-linux.spec # Logs and temp files *.log @@ -51,5 +52,8 @@ packaging/copr/*.src.rpm *.pyo *.pyd +# Dossier de preuves forensiques (antériorité) — jamais poussé +forensic/ + # Réglages locaux de Claude Code (chemins, permissions) — ne pas versionner .claude/settings.local.json diff --git a/PRIVACY.md b/PRIVACY.md index b215547..51e2018 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -13,7 +13,7 @@ the content of a document, and both can be blocked (see below). | Service | Endpoint | When | What is sent | |---|---|---|---| -| GitHub | `api.github.com` | At startup, if "Check for updates at startup" is enabled (default on Windows only), and when you open *Help › Check for Updates* or *Help › About PDF-Equilibrist* | Nothing: the version comparison happens on your computer. Clicking *Download and install* (Windows) downloads the installer from GitHub. | +| GitHub | `api.github.com` | At startup, if "Check for updates at startup" is enabled (default on Windows only), and when you open *Help › Check for Updates* or *Help › About PDF-Equilibrist* | Nothing: the version comparison happens on your computer. Clicking *Download and install* (Windows) downloads the installer from GitHub; on an AppImage, *Download the new AppImage* and the install-script link download the file from the GitHub release. | | OSV.dev | `api.osv.dev` | Only when you run *Help › Check CVE Vulnerabilities* | The names and versions of the libraries used by the application | Like any Internet request, these connections expose your IP address to the @@ -54,7 +54,7 @@ jamais le contenu d'un document, et les deux peuvent être bloqués (voir plus b | Service | Point d'accès | Quand | Ce qui est envoyé | |---|---|---|---| -| GitHub | `api.github.com` | Au démarrage si « Vérifier les mises à jour au démarrage » est coché (par défaut sous Windows uniquement), et à l'ouverture de *Aide › Vérifier les mises à jour* ou *Aide › À propos de PDF-Equilibrist* | Rien : la comparaison de version se fait sur votre ordinateur. Le bouton *Télécharger et installer* (Windows) télécharge l'installeur depuis GitHub. | +| GitHub | `api.github.com` | Au démarrage si « Vérifier les mises à jour au démarrage » est coché (par défaut sous Windows uniquement), et à l'ouverture de *Aide › Vérifier les mises à jour* ou *Aide › À propos de PDF-Equilibrist* | Rien : la comparaison de version se fait sur votre ordinateur. Le bouton *Télécharger et installer* (Windows) télécharge l'installeur depuis GitHub ; sous AppImage, *Télécharger la nouvelle AppImage* et le lien du script d'installation téléchargent le fichier depuis la release GitHub. | | OSV.dev | `api.osv.dev` | Uniquement quand vous lancez *Aide › Vérifier les vulnérabilités CVE* | Les noms et versions des bibliothèques utilisées par l'application | Comme toute requête Internet, ces connexions exposent votre adresse IP au service diff --git a/README.fr.md b/README.fr.md index c6ba372..783f8bf 100644 --- a/README.fr.md +++ b/README.fr.md @@ -7,9 +7,10 @@ [![AUR](https://img.shields.io/aur/version/pdf-equilibrist)](https://aur.archlinux.org/packages/pdf-equilibrist) [![Téléchargements (total)](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.total&label=t%C3%A9l%C3%A9chargements%20%28total%29&color=6BBF4E&cacheSeconds=3600&v=2)](https://pdf-equilibrist.org/#diffusion) -[![Téléchargements Windows](https://img.shields.io/github/downloads/Bit-Scripts/PDF-Equilibrist/total?label=t%C3%A9l%C3%A9chargements%20Windows&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMGg3LjV2Ny41SDB6TTguNSAwSDE2djcuNUg4LjV6TTAgOC41aDcuNVYxNkgwek04LjUgOC41SDE2VjE2SDguNXoiLz48L3N2Zz4=&cacheSeconds=3600)](https://github.com/Bit-Scripts/PDF-Equilibrist/releases) +[![Téléchargements Windows](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.github.total&label=t%C3%A9l%C3%A9chargements%20Windows&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMGg3LjV2Ny41SDB6TTguNSAwSDE2djcuNUg4LjV6TTAgOC41aDcuNVYxNkgwek04LjUgOC41SDE2VjE2SDguNXoiLz48L3N2Zz4=&cacheSeconds=3600&v=2)](https://github.com/Bit-Scripts/PDF-Equilibrist/releases) [![Téléchargements Ubuntu](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.launchpad.total&label=t%C3%A9l%C3%A9chargements%20Ubuntu&logo=ubuntu&logoColor=white&color=E95420&cacheSeconds=3600&v=2)](https://launchpad.net/~paulwoisard/+archive/ubuntu/pdf-equilibrist) [![Téléchargements Fedora](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.copr.total&label=t%C3%A9l%C3%A9chargements%20Fedora&logo=fedora&logoColor=white&color=51A2DA&cacheSeconds=3600&v=2)](https://copr.fedorainfracloud.org/coprs/paullux/PDF-Equilibrist/) +[![Téléchargements AppImage](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.appimage.total&label=t%C3%A9l%C3%A9chargements%20AppImage&logo=linux&logoColor=white&color=8E6BBF&cacheSeconds=3600&v=2)](https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest) [![Téléchargements Arch : non mesuré](https://img.shields.io/badge/t%C3%A9l%C3%A9chargements%20Arch-non%20mesur%C3%A9-lightgrey?logo=archlinux&logoColor=white "L'AUR ne publie aucun compteur de téléchargements")](https://pdf-equilibrist.org/#diffusion-arch) [![CI](https://github.com/Bit-Scripts/PDF-Equilibrist/actions/workflows/ci.yml/badge.svg)](https://github.com/Bit-Scripts/PDF-Equilibrist/actions/workflows/ci.yml) @@ -118,6 +119,17 @@ sudo dnf install pdf-equilibrist → [Page COPR](https://copr.fedorainfracloud.org/coprs/paullux/PDF-Equilibrist/) +🐧 **Toute autre distribution Linux** (AppImage — Debian, openSUSE, Mint…) : un seul fichier, sans dépôt. + +```bash +curl -fsSLO https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest/download/install.sh +bash install.sh # sans root : installe dans ~/.local (menu, association aux PDF) +# sudo bash install.sh --system # tous les utilisateurs : /usr/local +# bash install.sh --uninstall # désinstaller +``` + +Le script télécharge la dernière release et vérifie son SHA-256. Vous pouvez aussi télécharger [`PDF-Equilibrist-x86_64.AppImage`](https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest/download/PDF-Equilibrist-x86_64.AppImage) vous-même, `chmod +x` puis le lancer. Sans FUSE (`libfuse2`), lancez-le avec `--appimage-extract-and-run`. *Aide › À propos* propose de télécharger les nouvelles versions. + ### Installation depuis les sources (développeurs) 1. Créez un environnement Python compatible (`>=3.12`). @@ -159,7 +171,7 @@ Pour de nombreux usages courants — édition, annotation, conversion, protectio **Quels systèmes d'exploitation sont supportés ?** -Windows (installeur .exe), Arch Linux (AUR), Ubuntu (PPA) et Fedora (COPR). +Windows (installeur .exe), Arch Linux (AUR), Ubuntu (PPA), Fedora (COPR) et toute autre distribution Linux 64 bits (AppImage). **Faut-il des droits administrateur pour l'installer ?** diff --git a/README.md b/README.md index c01952a..fc369c1 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ [![AUR](https://img.shields.io/aur/version/pdf-equilibrist)](https://aur.archlinux.org/packages/pdf-equilibrist) [![Total downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.total&label=total%20downloads&color=6BBF4E&cacheSeconds=3600&v=2)](https://pdf-equilibrist.org/en/#diffusion) -[![Windows downloads](https://img.shields.io/github/downloads/Bit-Scripts/PDF-Equilibrist/total?label=Windows%20downloads&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMGg3LjV2Ny41SDB6TTguNSAwSDE2djcuNUg4LjV6TTAgOC41aDcuNVYxNkgwek04LjUgOC41SDE2VjE2SDguNXoiLz48L3N2Zz4=&cacheSeconds=3600)](https://github.com/Bit-Scripts/PDF-Equilibrist/releases) +[![Windows downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.github.total&label=Windows%20downloads&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMGg3LjV2Ny41SDB6TTguNSAwSDE2djcuNUg4LjV6TTAgOC41aDcuNVYxNkgwek04LjUgOC41SDE2VjE2SDguNXoiLz48L3N2Zz4=&cacheSeconds=3600&v=2)](https://github.com/Bit-Scripts/PDF-Equilibrist/releases) [![Ubuntu downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.launchpad.total&label=Ubuntu%20downloads&logo=ubuntu&logoColor=white&color=E95420&cacheSeconds=3600&v=2)](https://launchpad.net/~paulwoisard/+archive/ubuntu/pdf-equilibrist) [![Fedora downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.copr.total&label=Fedora%20downloads&logo=fedora&logoColor=white&color=51A2DA&cacheSeconds=3600&v=2)](https://copr.fedorainfracloud.org/coprs/paullux/PDF-Equilibrist/) +[![AppImage downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpdf-equilibrist.org%2Fstats.json&query=%24.channels.appimage.total&label=AppImage%20downloads&logo=linux&logoColor=white&color=8E6BBF&cacheSeconds=3600&v=2)](https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest) [![Arch downloads: not tracked](https://img.shields.io/badge/Arch%20downloads-not%20tracked-lightgrey?logo=archlinux&logoColor=white "The AUR does not publish any download counter")](https://pdf-equilibrist.org/en/#diffusion-arch) [![CI](https://github.com/Bit-Scripts/PDF-Equilibrist/actions/workflows/ci.yml/badge.svg)](https://github.com/Bit-Scripts/PDF-Equilibrist/actions/workflows/ci.yml) @@ -115,6 +116,17 @@ sudo dnf install pdf-equilibrist → [COPR page](https://copr.fedorainfracloud.org/coprs/paullux/PDF-Equilibrist/) +🐧 **Any other Linux distribution** (AppImage — Debian, openSUSE, Mint…): one file, no repository. + +```bash +curl -fsSLO https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest/download/install.sh +bash install.sh # no root: installs into ~/.local (menu entry, PDF association) +# sudo bash install.sh --system # all users: /usr/local +# bash install.sh --uninstall # remove +``` + +The script downloads the latest release and checks its SHA-256. You can also download [`PDF-Equilibrist-x86_64.AppImage`](https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest/download/PDF-Equilibrist-x86_64.AppImage) yourself, `chmod +x` it and run it. Without FUSE (`libfuse2`), start it with `--appimage-extract-and-run`. *Help › About* offers to download newer versions. + ### Installing from source (developers) 1. Create a compatible Python environment (`>=3.12`). @@ -156,7 +168,7 @@ For many common uses — editing, annotation, conversion, protection — yes. It **Which operating systems are supported?** -Windows (.exe installer), Arch Linux (AUR), Ubuntu (PPA) and Fedora (COPR). +Windows (.exe installer), Arch Linux (AUR), Ubuntu (PPA), Fedora (COPR) and any other 64-bit Linux distribution (AppImage). **Are administrator rights required to install it?** diff --git a/docs/3.CONTRIBUTING.md b/docs/3.CONTRIBUTING.md index f63cca4..07d21f2 100644 --- a/docs/3.CONTRIBUTING.md +++ b/docs/3.CONTRIBUTING.md @@ -8,7 +8,7 @@ - Python 3.12 - Conda (Miniconda ou Anaconda) -- Windows 10/11 (plateforme principale, installeur `.exe`) ou Linux (Arch/AUR, Ubuntu/PPA, Fedora/COPR) +- Windows 10/11 (plateforme principale, installeur `.exe`) ou Linux (Arch/AUR, Ubuntu/PPA, Fedora/COPR, AppImage) — l'impression utilise un backend Win32 GDI sous Windows et Qt/CUPS ailleurs ; macOS n'est pas encore distribué diff --git a/docs/5.BUILD.md b/docs/5.BUILD.md index 00a4a9e..ce2c335 100644 --- a/docs/5.BUILD.md +++ b/docs/5.BUILD.md @@ -210,7 +210,8 @@ Pour définir PDF Equilibrist comme **application par défaut** pour les PDF : ## Distribution Linux et OCR Canaux actifs : **Windows** (installeur NSIS), **Ubuntu** (PPA, noble + resolute), **Fedora** (COPR) -et **Arch Linux** (AUR). L'OCR (RapidOCR + ONNX Runtime) est présent sur les quatre : les modèles +et **Arch Linux** (AUR), plus un fichier **AppImage** pour toutes les autres distributions. L'OCR +(RapidOCR + ONNX Runtime) est présent sur les cinq : les modèles `.onnx` (~30 Mo) sont livrés *dans* la wheel `rapidocr`, jamais téléchargés. | Canal | Où vivent les dépendances OCR | Vérification avant publication | @@ -218,6 +219,7 @@ et **Arch Linux** (AUR). L'OCR (RapidOCR + ONNX Runtime) est présent sur les qu | Windows | PyInstaller embarque `rapidocr` + modèles (`collect_data_files`) | `ci.yml` / `release.yml` : `PDF-Equilibrist.exe --ocr-selftest` sur l'exe figé | | PPA | Wheelhouse vendorisé (`packaging/ppa/fetch_wheelhouse.sh`, source unique de la liste), venv privé `/usr/lib/pdf-equilibrist/venv` | `debian/rules` lance l'auto-test à la fin du build ; `linux-packages-check.yml` construit et teste noble et resolute | | COPR | Wheels `rapidocr` + `omegaconf` vendorisées ; onnxruntime, shapely, pyclipper… via les paquets Fedora | `%check` du `.spec` ; `linux-packages-check.yml` (Fedora 44) | +| AppImage | PyInstaller (`packaging/appimage/PDF-Equilibrist-linux.spec`) embarque tout, `rapidocr` et modèles compris ; construite sur Ubuntu 22.04 (glibc 2.35) | `linux-appimage.yml` : auto-test OCR réseau interdit, puis lancement sur Debian 12, Ubuntu 24.04, Fedora 44, Arch et openSUSE avant publication | | AUR | Wheel `rapidocr` en source du `PKGBUILD` (somme de contrôle) ; le reste via les paquets officiels (`python-onnxruntime`, `python-shapely`…) | `linux-aur.yml` / `linux-aur-publish.yml` : auto-test sur le paquet construit | L'auto-test (`--ocr-selftest`) génère une page « scannée », lance l'OCR avec **toute connexion réseau @@ -225,6 +227,13 @@ interdite** et vérifie que le texte français (accents compris) est reconnu. Le publication PPA et COPR dépendent de ces vérifications : aucune release Linux n'est publiée avec un OCR annoncé mais non fonctionnel. +**AppImage.** Un tag `v*` déclenche `linux-appimage.yml`, qui construit l'AppImage, la teste sur les +cinq distributions, puis la joint à la release GitHub avec son `.sha256` et `install.sh` +(`packaging/appimage/install.sh` : installation sans root dans `~/.local` ou `--system` dans `/usr/local`, +désinstallation avec `--uninstall`). Pour joindre l'AppImage à une release existante : lancer le workflow à la +main avec le champ `tag`. Dans l'application, *Aide › À propos* détecte l'AppImage (variable `APPIMAGE`), +télécharge la nouvelle version à côté de l'actuelle (SHA-256 vérifié) et propose le lien vers `install.sh`. + > **Flatpak : temporairement suspendu.** Aucun développement, packaging ni test Flatpak n'est prévu > pour l'instant, et l'OCR n'y est pas inclus. Les fichiers existants (`packaging/flatpak/`) sont > conservés pour une reprise ultérieure ; il suffira alors d'ajouter `rapidocr` et `onnxruntime` au diff --git a/docs/6.SECURITE.md b/docs/6.SECURITE.md index 14d137f..03fe69c 100644 --- a/docs/6.SECURITE.md +++ b/docs/6.SECURITE.md @@ -10,9 +10,10 @@ ## 1. Nature de l'application PDF Equilibrist est une application **desktop** d'édition de fichiers PDF, distribuée pour -**Windows** (installeur NSIS) et pour **Linux** (paquets Ubuntu/PPA, Fedora/COPR, Arch/AUR). Ce +**Windows** (installeur NSIS) et pour **Linux** (paquets Ubuntu/PPA, Fedora/COPR, Arch/AUR, ou fichier AppImage). Ce document décrit surtout le déploiement Windows ; sous Linux, l'application est un paquet système -installé par le gestionnaire de paquets, avec un environnement Python privé sous `/usr/lib*/pdf-equilibrist/`. +installé par le gestionnaire de paquets, avec un environnement Python privé sous `/usr/lib*/pdf-equilibrist/` ; +l'AppImage embarque ses propres bibliothèques, qui ne sont corrigées que par une nouvelle release. | Caractéristique | Valeur | |---|---| diff --git a/docs/en/3.CONTRIBUTING.md b/docs/en/3.CONTRIBUTING.md index 49f79dc..7d32e6f 100644 --- a/docs/en/3.CONTRIBUTING.md +++ b/docs/en/3.CONTRIBUTING.md @@ -8,7 +8,7 @@ - Python 3.12 - Conda (Miniconda or Anaconda) -- Windows 10/11 (main platform, `.exe` installer) or Linux (Arch/AUR, Ubuntu/PPA, Fedora/COPR) +- Windows 10/11 (main platform, `.exe` installer) or Linux (Arch/AUR, Ubuntu/PPA, Fedora/COPR, AppImage) — printing uses a Win32 GDI backend on Windows and Qt/CUPS elsewhere; macOS is not distributed yet --- diff --git a/docs/en/5.BUILD.md b/docs/en/5.BUILD.md index 1f37bc7..6744921 100644 --- a/docs/en/5.BUILD.md +++ b/docs/en/5.BUILD.md @@ -210,7 +210,8 @@ To set PDF Equilibrist as the **default application** for PDFs: ## Linux distribution and OCR Active channels: **Windows** (NSIS installer), **Ubuntu** (PPA, noble + resolute), **Fedora** (COPR) -and **Arch Linux** (AUR). OCR (RapidOCR + ONNX Runtime) is available on all four: the `.onnx` models +and **Arch Linux** (AUR), plus an **AppImage** file for every other distribution. OCR +(RapidOCR + ONNX Runtime) is available on all five: the `.onnx` models (~30 MB) ship *inside* the `rapidocr` wheel and are never downloaded. | Channel | Where the OCR dependencies live | Verification before publishing | @@ -218,6 +219,7 @@ and **Arch Linux** (AUR). OCR (RapidOCR + ONNX Runtime) is available on all four | Windows | PyInstaller bundles `rapidocr` + models (`collect_data_files`) | `ci.yml` / `release.yml`: `PDF-Equilibrist.exe --ocr-selftest` on the frozen exe | | PPA | Vendored wheelhouse (`packaging/ppa/fetch_wheelhouse.sh`, single source of the list), private venv `/usr/lib/pdf-equilibrist/venv` | `debian/rules` runs the selftest at the end of the build; `linux-packages-check.yml` builds and tests noble and resolute | | COPR | Vendored `rapidocr` + `omegaconf` wheels; onnxruntime, shapely, pyclipper… from Fedora packages | `%check` in the `.spec`; `linux-packages-check.yml` (Fedora 44) | +| AppImage | PyInstaller (`packaging/appimage/PDF-Equilibrist-linux.spec`) bundles everything, `rapidocr` and models included; built on Ubuntu 22.04 (glibc 2.35) | `linux-appimage.yml`: OCR selftest with the network forbidden, then launched on Debian 12, Ubuntu 24.04, Fedora 44, Arch and openSUSE before publishing | | AUR | `rapidocr` wheel as a `PKGBUILD` source (checksummed); everything else from the official packages (`python-onnxruntime`, `python-shapely`…) | `linux-aur.yml` / `linux-aur-publish.yml`: selftest on the built package | The selftest (`--ocr-selftest`) generates a "scanned" page, runs OCR with **every network connection @@ -225,6 +227,13 @@ forbidden** and checks that the French text (accents included) is recognised. Th workflows depend on these checks: no Linux release is published with an OCR that is advertised but broken. +**AppImage.** A `v*` tag triggers `linux-appimage.yml`, which builds the AppImage, tests it on the +five distributions, then attaches it to the GitHub release together with its `.sha256` and `install.sh` +(`packaging/appimage/install.sh`: rootless install into `~/.local`, or `--system` into `/usr/local`, +removal with `--uninstall`). To attach the AppImage to an existing release, run the workflow by hand +with the `tag` field. In the application, *Help › About* detects the AppImage (`APPIMAGE` variable), +downloads the new version next to the current one (SHA-256 checked) and offers the link to `install.sh`. + > **Flatpak: temporarily suspended.** No Flatpak development, packaging or testing is planned for now, > and OCR is not included there. The existing files (`packaging/flatpak/`) are kept for a later > resumption; `rapidocr` and `onnxruntime` will then need to be added to the manifest. diff --git a/docs/en/6.SECURITE.md b/docs/en/6.SECURITE.md index 3f22a16..46d6330 100644 --- a/docs/en/6.SECURITE.md +++ b/docs/en/6.SECURITE.md @@ -10,9 +10,10 @@ ## 1. Nature of the application PDF Equilibrist is a **desktop** application for editing PDF files, distributed for **Windows** -(NSIS installer) and **Linux** (Ubuntu/PPA, Fedora/COPR and Arch/AUR packages). This document mostly +(NSIS installer) and **Linux** (Ubuntu/PPA, Fedora/COPR and Arch/AUR packages, or an AppImage file). This document mostly describes the Windows deployment; on Linux the application is a system package installed by the -package manager, with a private Python environment under `/usr/lib*/pdf-equilibrist/`. +package manager, with a private Python environment under `/usr/lib*/pdf-equilibrist/`; the AppImage bundles its own +libraries, which are only fixed by a new release. | Characteristic | Value | |---|---| diff --git a/packaging/RELEASE_CHECKLIST.md b/packaging/RELEASE_CHECKLIST.md index ef7b4bb..7d9ec6e 100644 --- a/packaging/RELEASE_CHECKLIST.md +++ b/packaging/RELEASE_CHECKLIST.md @@ -61,5 +61,8 @@ git push origin v0.1.25 # ← déclenche TOUT : release publique + AUR + PP sans réseau côté builder : vérifier launchpad.net/~paulwoisard/+archive/ubuntu/pdf-equilibrist). Surveiller le quota du PPA (wheelhouse ≈ 250 Mo par version). - [ ] `Linux COPR publish` : build COPR réussi pour les chroots Fedora +- [ ] `Linux AppImage` : build + 5 distributions vertes, puis la release contient `PDF-Equilibrist-x86_64.AppImage`, + son `.sha256` et `install.sh` (le job `publish` attend la release Windows, jusqu'à 60 min). Rattrapage : + Run workflow avec `tag` = `vX.Y.Z`. - [ ] Site : `softwareVersion` mis à jour, liens de téléchargement valides - [ ] Si un canal échoue : corriger puis bump du dernier chiffre (le PPA refuse de réutiliser une version avec un contenu différent) diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun new file mode 100755 index 0000000..b12e7ba --- /dev/null +++ b/packaging/appimage/AppRun @@ -0,0 +1,7 @@ +#!/bin/sh +# Point d'entrée de l'AppImage : lance le binaire PyInstaller embarqué. +HERE="$(dirname "$(readlink -f "$0")")" +export APPDIR="${APPDIR:-$HERE}" +# Ne pas hériter des modules Qt de l'hôte : ils ne correspondent pas aux bibliothèques embarquées +unset QT_PLUGIN_PATH QT_QPA_PLATFORM_PLUGIN_PATH +exec "$HERE/usr/lib/pdf-equilibrist/PDF-Equilibrist" "$@" diff --git a/packaging/appimage/PDF-Equilibrist-linux.spec b/packaging/appimage/PDF-Equilibrist-linux.spec new file mode 100644 index 0000000..618a0cc --- /dev/null +++ b/packaging/appimage/PDF-Equilibrist-linux.spec @@ -0,0 +1,104 @@ +# -*- mode: python ; coding: utf-8 -*- +# PDF-Equilibrist — PyInstaller onedir spec pour LINUX (base de l'AppImage) +# +# Variante de ../../PDF-Equilibrist.spec (Windows) sans ce qui n'existe pas sous Linux : +# pas de Splash (dépend de Tcl/Tk), pas d'icône .ico, pas de winreg/ctypes.wintypes, pas d'UPX. +# Construire avec packaging/appimage/build_appimage.sh (lance PyInstaller puis assemble l'AppDir). +# +# → build/appimage/dist/PDF-Equilibrist/ (dossier onedir) + +import warnings +warnings.filterwarnings("ignore", category=SyntaxWarning) + +import importlib.metadata +import os + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata + +block_cipher = None + +# Racine du dépôt : les chemins relatifs d'un .spec dépendent du dossier de lancement, pas de ce fichier +ROOT = os.path.abspath(os.path.join(SPECPATH, "..", "..")) # noqa: F821 — SPECPATH fourni par PyInstaller + +# ── Données embarquées (mêmes que Windows) ──────────────────────────────────── +datas = [ + (os.path.join(ROOT, "assets"), "assets"), + (os.path.join(ROOT, "src", "pdf_equilibrist"), "pdf_equilibrist"), # lu par l'analyse SAST intégrée (cve_checker) + (os.path.join(ROOT, "translations"), "translations"), +] +datas += collect_data_files("pymupdf") +# OCR : RapidOCR + modèles ONNX livrés dans la wheel (rapidocr/models/*.onnx) — aucun téléchargement. +datas += collect_data_files("rapidocr") + +# Métadonnées (dist-info) de tous les paquets installés : le vérificateur de CVE intégré +# s'appuie sur importlib.metadata.distributions() — sans elles, le binaire figé n'en verrait presque aucun. +for _dist in importlib.metadata.distributions(): + _name = _dist.metadata.get("Name") + if not _name: + continue + try: + datas += copy_metadata(_name) + except Exception: + pass + +# ── Imports cachés ──────────────────────────────────────────────────────────── +hidden = [ + "PyQt6.QtCore", "PyQt6.QtGui", "PyQt6.QtWidgets", + "PyQt6.QtNetwork", "PyQt6.sip", "PyQt6.QtPrintSupport", + "pymupdf", "pymupdf.utils", "pymupdf.table", # `fitz` = alias runtime (voir pdf_equilibrist/__init__.py) + "zlib", "zipfile", "struct", + "pdf2docx", "pdfplumber", "pdfminer", + "openpyxl", "pptx", "docx", + "PIL", "PIL.Image", "PIL.PngImagePlugin", "PIL.JpegImagePlugin", + "cv2", "numpy", + "pyparsing", "pypdfium2", + "rapidocr", "onnxruntime", + "shapely", "pyclipper", + "omegaconf", "colorlog", +] +hidden += collect_submodules("pdf_equilibrist") +hidden += collect_submodules("rapidocr") +# bandit charge ses tests via des entry points (stevedore), invisibles à l'analyse statique +hidden += collect_submodules("bandit") +hidden += collect_submodules("stevedore") + +excludes = ["tkinter", "matplotlib", "scipy", "IPython"] + +a = Analysis( + [os.path.join(ROOT, "src", "pdf_equilibrist", "main.py")], + pathex=[os.path.join(ROOT, "src")], + binaries=[], + datas=datas, + hiddenimports=hidden, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, # onedir + name="PDF-Equilibrist", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, # utile en AppImage : les messages d'erreur restent visibles dans le terminal +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + name="PDF-Equilibrist", +) diff --git a/packaging/appimage/README.md b/packaging/appimage/README.md new file mode 100644 index 0000000..7855f8d --- /dev/null +++ b/packaging/appimage/README.md @@ -0,0 +1,63 @@ +# AppImage — PDF-Equilibrist + +Canal « autres distributions » (Debian, openSUSE, Mint…) : **un seul fichier à télécharger**, sans +dépôt à configurer. Publié sur chaque tag `v*` : l'AppImage est jointe à la release GitHub. + +## Fichiers + +| Fichier | Rôle | +|---|---| +| `PDF-Equilibrist-linux.spec` | Variante Linux du `.spec` PyInstaller Windows : onedir, sans Splash/icône/winreg/UPX | +| `build_appimage.sh` | **Source unique** (CI et essais locaux) : PyInstaller → AppDir → libxcb-cursor → AppImage | +| `AppRun`, `pdf-equilibrist.desktop` | Point d'entrée et lanceur de l'AppDir | +| `../../.github/workflows/linux-appimage.yml` | Build sur Ubuntu 22.04, auto-test OCR, démarrage offscreen, puis lancement dans Debian 12, Ubuntu 24.04, Fedora 44, Arch et openSUSE Tumbleweed | + +## Essai local (sans appimagetool) + +```bash +python3 -m venv venv && venv/bin/pip install -r requirements.txt pyinstaller +sudo apt install libxcb-cursor0 # ou l'équivalent de la distribution +PYTHON=venv/bin/python packaging/appimage/build_appimage.sh +build/appimage/AppDir/AppRun --ocr-selftest /tmp/r.txt && cat /tmp/r.txt +``` + +Avec `APPIMAGETOOL=/chemin/appimagetool`, le script produit aussi +`build/appimage/PDF-Equilibrist-x86_64.AppImage` (la CI épingle appimagetool 1.9.1 par SHA-256). + +## Mesuré (Ubuntu 24.04, WSL) + +- AppDir : environ 570 Mo décompressé (PyQt6 104, OpenCV 150, PyMuPDF 55, OCR 59) ; l'AppImage, compressée + en squashfs, sera nettement plus petite (mesure donnée par la CI). +- `AppRun --ocr-selftest` : OK, avec un environnement minimal (`env -i`) et un HOME vide. +- Démarrage de l'interface en `QT_QPA_PLATFORM=offscreen` : tient 12 s. +- Aucune bibliothèque système manquante ; les `libxcb-*` nécessaires à Qt6 (dont `libxcb-cursor`) sont embarquées. +- Un dossier `~/.cache` est créé pendant l'auto-test (origine à identifier ; la CI le liste). + +## À décider / limites connues + +- **FUSE** : sans `libfuse2`, lancer avec `--appimage-extract-and-run` ou extraire l'image. +- **glibc** : construire sur le système le plus ancien à couvrir (CI : Ubuntu 22.04 = glibc 2.35). +- **Impression (CUPS), polices d'emoji, Wayland** : dépendent du système hôte, non testés en vrai. +- **Mises à jour** : `update.py` détecte l'AppImage via `APPIMAGE`, cherche l'asset `*x86_64.AppImage` de la + dernière release et le télécharge à côté de l'actuelle sous un nom versionné (SHA-256 vérifié si GitHub le + fournit). L'utilisateur la relance lui-même. Pas de zsync (delta) pour l'instant. +- **Version** : le workflow injecte la version du tag dans `__version__`, comme `release.yml`. +- **Publication** : un tag `v*` construit l'AppImage puis la joint à la release (`gh release upload`, après avoir + attendu que `release.yml` l'ait créée). Rattrapage d'une release existante : Actions › « Linux AppImage » › + Run workflow, champ `tag` = `v0.1.25` (le code construit est celui de la branche choisie). +- **Sécurité** : les bibliothèques embarquées ne sont corrigées que par une nouvelle release. + +## Installation (script `install.sh`) + +Joint à chaque release avec l'AppImage. Il crée le lanceur de menu, l'icône, la commande +`pdf-equilibrist` et l'association aux PDF. + +```bash +bash install.sh # sans root → ~/.local +sudo bash install.sh --system # tous les utilisateurs → /usr/local +bash install.sh --uninstall # (ajouter --system si installé avec sudo) +bash install.sh --file ./PDF-Equilibrist-x86_64.AppImage # AppImage déjà téléchargée +``` + +Sans `--file`, il utilise l'AppImage posée à côté de lui, sinon télécharge la dernière release et vérifie +son SHA-256. Pour mettre à jour : le relancer. diff --git a/packaging/appimage/build_appimage.sh b/packaging/appimage/build_appimage.sh new file mode 100755 index 0000000..9f7dad8 --- /dev/null +++ b/packaging/appimage/build_appimage.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Construit l'AppDir (et, si appimagetool est disponible, l'AppImage) de PDF-Equilibrist. +# +# Source unique pour la CI (.github/workflows/linux-appimage.yml) ET les essais locaux. +# +# Usage (depuis n'importe où) : +# packaging/appimage/build_appimage.sh # PyInstaller + AppDir +# APPIMAGETOOL=/chemin/appimagetool packaging/appimage/build_appimage.sh # + .AppImage +# +# Prérequis : Python 3.12 avec `pip install -r requirements.txt pyinstaller`, libxcb-cursor0 (Qt6/xcb). +# Sortie : build/appimage/AppDir/ et build/appimage/PDF-Equilibrist-x86_64.AppImage +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +OUT="$ROOT/build/appimage" +APPDIR="$OUT/AppDir" +PYTHON="${PYTHON:-python3}" + +cd "$ROOT" +rm -rf "$OUT" +mkdir -p "$OUT" + +echo "== 1/4 PyInstaller (onedir)" +"$PYTHON" -m PyInstaller --noconfirm --clean \ + --distpath "$OUT/dist" --workpath "$OUT/work" \ + "$HERE/PDF-Equilibrist-linux.spec" + +echo "== 2/4 AppDir" +mkdir -p "$APPDIR/usr/lib" "$APPDIR/usr/share/icons/hicolor/256x256/apps" "$APPDIR/usr/share/applications" +cp -a "$OUT/dist/PDF-Equilibrist" "$APPDIR/usr/lib/pdf-equilibrist" +install -m755 "$HERE/AppRun" "$APPDIR/AppRun" +install -m644 "$HERE/pdf-equilibrist.desktop" "$APPDIR/pdf-equilibrist.desktop" +install -m644 "$HERE/pdf-equilibrist.desktop" "$APPDIR/usr/share/applications/pdf-equilibrist.desktop" +install -m644 "$ROOT/packaging/ppa/debian/pdf-equilibrist-256.png" "$APPDIR/pdf-equilibrist.png" +install -m644 "$ROOT/packaging/ppa/debian/pdf-equilibrist-256.png" \ + "$APPDIR/usr/share/icons/hicolor/256x256/apps/pdf-equilibrist.png" +ln -sf pdf-equilibrist.png "$APPDIR/.DirIcon" + +echo "== 3/4 Bibliothèques xcb pour Qt6 (libxcb-cursor n'est pas installée par défaut sur toutes les distributions)" +INTERNAL="$APPDIR/usr/lib/pdf-equilibrist/_internal" +# (pas de `exit` dans awk : avec pipefail, ldconfig recevrait SIGPIPE et set -e arrêterait le script) +cursor="$(ldconfig -p | awk '/libxcb-cursor\.so\.0 /{p=$NF} END{print p}')" +if [ -n "$cursor" ]; then + # la bibliothèque + ses dépendances libxcb-* (hors libxcb.so.1 : fournie par tout système X11) + for lib in "$cursor" $(ldd "$cursor" | awk '/libxcb-/{print $3}'); do + cp -nL "$lib" "$INTERNAL/" 2>/dev/null || true + done + echo " embarquée : $(ls "$INTERNAL" | grep -c '^libxcb-' || true) bibliothèque(s) libxcb-*" +else + echo " AVERTISSEMENT : libxcb-cursor.so.0 introuvable sur la machine de build (apt install libxcb-cursor0)" +fi + +du -sh "$APPDIR" | awk '{print " AppDir : " $1}' + +echo "== 4/4 AppImage" +if [ -n "${APPIMAGETOOL:-}" ] && [ -x "$APPIMAGETOOL" ]; then + ARCH=x86_64 "$APPIMAGETOOL" --appimage-extract-and-run "$APPDIR" "$OUT/PDF-Equilibrist-x86_64.AppImage" 2>&1 | tail -3 \ + || ARCH=x86_64 "$APPIMAGETOOL" "$APPDIR" "$OUT/PDF-Equilibrist-x86_64.AppImage" + ls -la "$OUT"/*.AppImage +else + echo " APPIMAGETOOL non défini : AppDir seulement (l'AppImage est produite par la CI)." +fi diff --git a/packaging/appimage/install.sh b/packaging/appimage/install.sh new file mode 100755 index 0000000..6e1eaa8 --- /dev/null +++ b/packaging/appimage/install.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Installe (ou désinstalle) l'AppImage de PDF-Equilibrist : lanceur dans le menu, icône, commande +# `pdf-equilibrist`, association aux fichiers PDF. +# +# install.sh utilisateur courant, sans root → ~/.local +# sudo install.sh --system pour tous les utilisateurs → /usr/local +# install.sh --uninstall retire l'installation (ajouter --system si faite avec sudo) +# install.sh --file CHEMIN utilise cette AppImage au lieu de la télécharger +# +# Sans --file : prend l'AppImage posée à côté du script, sinon télécharge la dernière release GitHub +# (api.github.com / github.com uniquement) et vérifie sa somme SHA-256 avant d'installer. +set -euo pipefail + +REPO="Bit-Scripts/PDF-Equilibrist" +ASSET="PDF-Equilibrist-x86_64.AppImage" +MODE=user ; ACTION=install ; FILE="" + +while [ $# -gt 0 ]; do + case "$1" in + --system) MODE=system ;; + --user) MODE=user ;; + --uninstall) ACTION=uninstall ;; + --file) FILE="${2:?--file demande un chemin}" ; shift ;; + -h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' ; exit 0 ;; + *) echo "Option inconnue : $1 (voir --help)" >&2 ; exit 2 ;; + esac + shift +done + +if [ "$MODE" = system ]; then + PREFIX=/usr/local + [ "$(id -u)" -eq 0 ] || { echo "--system demande les droits root : sudo $0 --system" >&2 ; exit 1; } +else + PREFIX="${HOME}/.local" + [ "$(id -u)" -ne 0 ] || { echo "Ne lancez pas le mode utilisateur en root (utilisez --system)." >&2 ; exit 1; } +fi + +APPDIR="$PREFIX/share/pdf-equilibrist" +TARGET="$APPDIR/PDF-Equilibrist.AppImage" +BIN="$PREFIX/bin/pdf-equilibrist" +DESKTOP="$PREFIX/share/applications/pdf-equilibrist.desktop" +ICON="$PREFIX/share/icons/hicolor/256x256/apps/pdf-equilibrist.png" + +refresh_caches() { + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$PREFIX/share/applications" 2>/dev/null || true + command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -q -t "$PREFIX/share/icons/hicolor" 2>/dev/null || true +} + +if [ "$ACTION" = uninstall ]; then + rm -f "$BIN" "$DESKTOP" "$ICON" + rm -rf "$APPDIR" + refresh_caches + echo "PDF-Equilibrist désinstallé de $PREFIX (vos réglages dans ~/.config ne sont pas touchés)." + exit 0 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if [ -z "$FILE" ]; then + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # AppImage posée à côté (nom de la release ou nom versionné de la mise à jour intégrée) : la plus récente + LOCAL="$(ls -t "$HERE"/PDF-Equilibrist*x86_64.AppImage 2>/dev/null | head -n1 || true)" + if [ -n "$LOCAL" ]; then + FILE="$LOCAL" + echo "AppImage trouvée à côté du script : $FILE" + else + command -v curl >/dev/null 2>&1 || { echo "curl est requis pour télécharger (ou utilisez --file)." >&2 ; exit 1; } + BASE="https://github.com/$REPO/releases/latest/download" + echo "Téléchargement de la dernière version depuis GitHub…" + curl -fL --proto '=https' --tlsv1.2 -o "$WORK/$ASSET" "$BASE/$ASSET" + curl -fsSL --proto '=https' --tlsv1.2 -o "$WORK/$ASSET.sha256" "$BASE/$ASSET.sha256" || true + if [ -s "$WORK/$ASSET.sha256" ]; then + expected="$(cut -d' ' -f1 "$WORK/$ASSET.sha256")" + actual="$(sha256sum "$WORK/$ASSET" | cut -d' ' -f1)" + [ "$expected" = "$actual" ] || { echo "Somme SHA-256 incorrecte : fichier rejeté." >&2 ; exit 1; } + echo "SHA-256 vérifiée." + else + echo "AVERTISSEMENT : pas de fichier .sha256 sur cette release, somme non vérifiée." >&2 + fi + FILE="$WORK/$ASSET" + fi +fi +[ -f "$FILE" ] || { echo "Fichier introuvable : $FILE" >&2 ; exit 1; } + +mkdir -p "$APPDIR" "$PREFIX/bin" "$(dirname "$DESKTOP")" "$(dirname "$ICON")" +install -m755 "$FILE" "$TARGET" +ln -sf "$TARGET" "$BIN" + +# Icône : extraite de l'AppImage elle-même (--appimage-extract ne demande pas FUSE) +( cd "$WORK" && "$TARGET" --appimage-extract pdf-equilibrist.png >/dev/null 2>&1 \ + && install -m644 squashfs-root/pdf-equilibrist.png "$ICON" ) || echo "Icône non extraite (le lanceur utilisera l'icône par défaut)." + +cat > "$DESKTOP" < Rien n'est soumis automatiquement : le formulaire Softpedia se remplit à la main depuis le compte de +> l'éditeur. La première soumission (Windows) n'avait **aucun fichier Linux à télécharger** ; cette fiche +> corrige cela. Soumettre **après** la publication de la release qui contient l'AppImage. + +## Champs + +| Champ | Valeur | +|---|---| +| Nom | PDF-Equilibrist | +| Catégorie | Unix/Linux › Office tools › PDF (ou Graphics › PDF tools selon le menu) | +| Licence | GPLv3 (open source, gratuit) | +| Plateforme | Linux 64 bits (x86_64) — toute distribution récente (glibc ≥ 2.35) | +| Site officiel | https://github.com/Bit-Scripts/PDF-Equilibrist | +| Téléchargement direct | `https://github.com/Bit-Scripts/PDF-Equilibrist/releases/latest/download/PDF-Equilibrist-x86_64.AppImage` | +| Somme SHA-256 | fichier `PDF-Equilibrist-x86_64.AppImage.sha256` joint à la même release | +| Taille | à relever sur la release (voir le résumé du run « Linux AppImage ») | +| Version | celle de la release (sans le « v ») | +| Prix | gratuit | + +## Courte description (≈ 250 caractères) + +Éditeur PDF de bureau libre : modifier le texte, annoter, signer, fusionner/diviser, convertir (Word, Excel, +PowerPoint, images), OCR hors ligne, chiffrement AES-256. Aucun envoi de document : tout reste sur votre disque. + +## Description longue + +PDF-Equilibrist est un éditeur PDF de bureau (PyQt6 + PyMuPDF) qui fonctionne entièrement en local. +Édition du texte bloc par bloc, ajout de texte/images/tampons/signatures, filigrane, annotations +(surligner, barrer, souligner), gestion des pages, conversions Word/Excel/PowerPoint/images, OCR embarqué +(aucun téléchargement de modèle), chiffrement AES-256 et impression soignée. Interface en français et en anglais. + +Pour Linux, un seul fichier AppImage : rendez-le exécutable (`chmod +x`) et lancez-le, sans installation ni +dépôt. L'application vous prévient des nouvelles versions et télécharge la nouvelle AppImage depuis la page +de release. Des paquets natifs existent aussi (AUR, PPA Ubuntu, COPR Fedora). + +## Captures d'écran à joindre +Reprendre celles du site (fenêtre principale, mode édition de texte, OCR) — en anglais si possible. + +## Liens de téléchargement alternatifs +- AUR : https://aur.archlinux.org/packages/pdf-equilibrist +- PPA : https://launchpad.net/~paulwoisard/+archive/ubuntu/pdf-equilibrist +- COPR : https://copr.fedorainfracloud.org/coprs/paullux/PDF-Equilibrist/ diff --git a/src/pdf_equilibrist/main.py b/src/pdf_equilibrist/main.py index 8c472d8..c9f0007 100644 --- a/src/pdf_equilibrist/main.py +++ b/src/pdf_equilibrist/main.py @@ -112,8 +112,9 @@ def main(): try: import pyi_splash pyi_splash.close() - except ModuleNotFoundError: - pass # normal en mode dev — pyi_splash n'existe que dans l'exe + except (ModuleNotFoundError, KeyError): + # absent en mode dev ; sans splash natif (AppImage) PyInstaller lève KeyError('_PYI_SPLASH_IPC') + pass splash.close() splash.deleteLater() diff --git a/src/pdf_equilibrist/ui/update_dialog.py b/src/pdf_equilibrist/ui/update_dialog.py index 592d825..ef4d82b 100644 --- a/src/pdf_equilibrist/ui/update_dialog.py +++ b/src/pdf_equilibrist/ui/update_dialog.py @@ -5,12 +5,13 @@ QDialog, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QProgressBar, QFrame, QLineEdit, QApplication, ) -from PyQt6.QtCore import QThread, pyqtSignal, Qt +from PyQt6.QtCore import QThread, QTimer, pyqtSignal, Qt from PyQt6.QtGui import QPixmap from pdf_equilibrist import __version__ from pdf_equilibrist import network from pdf_equilibrist import update as updater +from pdf_equilibrist.utils import open_external _GREEN = "#6BBF4E" _GRAY = "#888888" @@ -51,6 +52,26 @@ def run(self): self.finished_err.emit(str(e)) +class _DownloadThread(QThread): + """Télécharge hors du fil de l'interface (un installeur / une AppImage pèse plusieurs centaines de Mo).""" + progress = pyqtSignal(int, int) + finished_ok = pyqtSignal() + finished_err = pyqtSignal(str) + + def __init__(self, asset: dict, target): + super().__init__() + self.asset = asset + self.target = target + + def run(self): + try: + updater.download_release_asset( + self.asset, self.target, progress=lambda done, total: self.progress.emit(done, total)) + self.finished_ok.emit() + except Exception as e: + self.finished_err.emit(str(e)) + + def _sep(parent=None) -> QFrame: line = QFrame(parent) line.setFrameShape(QFrame.Shape.HLine) @@ -103,6 +124,7 @@ def __init__(self, parent=None, repo: str | None = None): # ── Vérification de version ─────────────────────────────────────────── self._lbl = QLabel(self.tr("Vérification des nouvelles versions…")) + self._lbl.setWordWrap(True) layout.addWidget(self._lbl) self._progress = QProgressBar() @@ -159,6 +181,21 @@ def __init__(self, parent=None, repo: str | None = None): layout.addWidget(self._linux_cmd_widget) self._btn_copy_cmd.clicked.connect(self._copy_linux_command) + # ── Adresse à copier à la main (affichée seulement si aucun navigateur ne s'ouvre) ── + self._url_edit = QLineEdit() + self._url_edit.setReadOnly(True) + self._btn_copy_url = QPushButton(self.tr("Copier")) + url_row = QHBoxLayout() + url_row.addWidget(QLabel(self.tr("Adresse :"))) + url_row.addWidget(self._url_edit) + url_row.addWidget(self._btn_copy_url) + self._url_widget = QFrame() + self._url_widget.setLayout(url_row) + self._url_widget.setVisible(False) + layout.addWidget(self._url_widget) + self._btn_copy_url.clicked.connect( + lambda: QApplication.clipboard().setText(self._url_edit.text())) + # ── Boutons ─────────────────────────────────────────────────────────── btn_row = QHBoxLayout() btn_row.addStretch() @@ -172,6 +209,16 @@ def __init__(self, parent=None, repo: str | None = None): btn_row.addWidget(self._btn_close) layout.addLayout(btn_row) + # Lien (AppImage seulement) : récupérer le script qui l'installe dans le menu et le PATH + self._script_link = QLabel( + f"" + + self.tr("Télécharger le script d'installation (install.sh)") + "") + self._script_link.setTextFormat(Qt.TextFormat.RichText) + self._script_link.setAlignment(Qt.AlignmentFlag.AlignRight) + self._script_link.setVisible(False) + self._script_link.linkActivated.connect(lambda _href: self._download_script()) + layout.addWidget(self._script_link) + self._btn_close.clicked.connect(self.accept) self._btn_open_page.clicked.connect(self._open_release_page) self._btn_download.clicked.connect(self._download_and_run) @@ -218,6 +265,11 @@ def _on_result(self, release: object | None): self._linux_cmd_widget.setVisible(True) self._btn_download.setEnabled(False) self._btn_download.setVisible(False) + elif updater.is_appimage(): + # AppImage : la release contient la nouvelle AppImage, à télécharger. + self._btn_download.setText(self.tr("Télécharger la nouvelle AppImage")) + self._btn_download.setEnabled(bool(updater.find_appimage_asset(release))) + self._script_link.setVisible(bool(updater.find_install_script_asset(release))) elif os.name != "nt": # Linux non reconnu (pas encore de PPA/COPR, ou distro inconnue) : # ni exe Windows ni commande fiable à proposer, on masque juste @@ -243,46 +295,114 @@ def _on_stats_error(self, _msg: str): self._lbl_current.setText("—") self._lbl_total.setText("—") + def _open_url(self, url: str): + """Ouvre l'URL ; sans navigateur utilisable (WSL, serveur…), l'affiche et la copie dans le presse-papiers.""" + if open_external(url): + return + QApplication.clipboard().setText(url) + self._url_edit.setText(url) + self._url_edit.setCursorPosition(0) + self._url_widget.setVisible(True) + self._lbl.setText(self.tr("Aucun navigateur détecté : adresse copiée dans le presse-papiers.")) + def _open_github(self): - import webbrowser - webbrowser.open(_GITHUB_URL) + self._open_url(_GITHUB_URL) def _open_release_page(self): if not self._release: return url = updater.get_release_page_url(self._release) if url: - import webbrowser - webbrowser.open(url) + self._open_url(url) + + def _quit_for_installer(self): + self.accept() + QApplication.closeAllWindows() + QApplication.quit() + + def _start_download(self, asset: dict, target, on_done): + """Lance le téléchargement en arrière-plan, avec barre de progression.""" + self._btn_download.setEnabled(False) + self._lbl.setText(self.tr("Téléchargement en cours…")) + self._progress.setRange(0, 0) + self._dl_thread = _DownloadThread(asset, target) + self._dl_thread.progress.connect(self._on_download_progress) + self._dl_thread.finished_ok.connect(lambda: self._on_download_done(target, on_done)) + self._dl_thread.finished_err.connect(self._on_download_error) + self._dl_thread.start() + + def _on_download_progress(self, done: int, total: int): + mb = done / 1_048_576 + if total: + self._progress.setRange(0, total) + self._progress.setValue(done) + self._lbl.setText(self.tr("Téléchargement : {0:.0f} / {1:.0f} Mo").format(mb, total / 1_048_576)) + else: + self._lbl.setText(self.tr("Téléchargement : {0:.0f} Mo").format(mb)) + + def _on_download_error(self, msg: str): + self._progress.setRange(0, 1) + self._btn_download.setEnabled(True) + self._lbl.setText(self.tr("Échec du téléchargement : {0}").format(msg)) + + def _on_download_done(self, target, on_done): + self._progress.setRange(0, 1) + self._progress.setValue(1) + on_done(target) + + def _download_appimage(self): + asset = updater.find_appimage_asset(self._release) + if not asset: + return + self._start_download(asset, updater.appimage_download_target(self._release), self._appimage_ready) + + def _download_script(self): + asset = updater.find_install_script_asset(self._release) if self._release else None + if asset: + self._start_download(asset, updater.install_script_target(self._release), self._script_ready) + + def _script_ready(self, target): + try: + target.chmod(0o755) + except OSError: + pass + self._btn_download.setEnabled(True) + self._lbl.setText(self.tr( + "Script d'installation téléchargé :\n{0}\nLancez : bash install.sh (ou sudo bash install.sh --system)" + ).format(target)) + + def _appimage_ready(self, target): + try: + target.chmod(0o755) + except OSError: + pass + self._btn_download.setEnabled(True) + self._lbl.setText(self.tr( + "Nouvelle AppImage téléchargée :\n{0}\nFermez l'application puis lancez ce fichier " + "(ou : bash install.sh --file )." + ).format(target)) def _download_and_run(self): if not self._release: return + if updater.is_appimage(): + self._download_appimage() + return asset = updater.find_installer_asset(self._release) if not asset: return - target = updater.get_download_target(asset) + self._start_download(asset, updater.get_download_target(asset), self._installer_ready) + + def _installer_ready(self, target): + self._lbl.setText(self.tr("Téléchargé : {0}").format(target)) try: - self._lbl.setText(self.tr("Téléchargement en cours…")) - self._progress.setRange(0, 0) - updater.download_release_asset(asset, target) - self._lbl.setText(self.tr("Téléchargé : {0}").format(target)) - try: - if os.name == "nt": - # Fichier qu'on vient nous-même de télécharger dans le temp - os.startfile(str(target)) # nosec B606 - else: - import shutil - import subprocess # nosec B404 - xdg_open = shutil.which("xdg-open") - if xdg_open: - subprocess.Popen([xdg_open, str(target)]) # nosec B603 - else: - raise FileNotFoundError("xdg-open introuvable") - except Exception: - import webbrowser - webbrowser.open(target.parent.as_uri()) - except Exception as e: - self._lbl.setText(self.tr("Échec du téléchargement : {0}").format(e)) - finally: - self._progress.setRange(0, 1) + if os.name == "nt": + # Fichier qu'on vient nous-même de télécharger dans le temp + os.startfile(str(target)) # nosec B606 + # L'installeur refuse de continuer tant que l'application tourne + # (fichiers verrouillés) : on se ferme pour lui laisser la place. + QTimer.singleShot(1500, self._quit_for_installer) + else: + open_external(str(target)) + except Exception: + open_external(target.parent.as_uri()) diff --git a/src/pdf_equilibrist/ui/viewer.py b/src/pdf_equilibrist/ui/viewer.py index 3e26ea5..25c316f 100644 --- a/src/pdf_equilibrist/ui/viewer.py +++ b/src/pdf_equilibrist/ui/viewer.py @@ -49,13 +49,13 @@ ce qui lui permet d'utiliser les coordonnées locales de la page. """ from __future__ import annotations -import webbrowser from pathlib import Path from PyQt6.QtWidgets import QScrollArea, QLabel, QWidget, QVBoxLayout from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen import pymupdf from pdf_equilibrist.core.document import Document +from pdf_equilibrist.utils import open_external class _PageLabel(QLabel): @@ -282,7 +282,7 @@ def _confirm_and_open_uri(self, uri: str): QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - webbrowser.open(uri) + open_external(uri) @staticmethod def _open_with_system(path: str): diff --git a/src/pdf_equilibrist/update.py b/src/pdf_equilibrist/update.py index 7d6ea0d..1ea1867 100644 --- a/src/pdf_equilibrist/update.py +++ b/src/pdf_equilibrist/update.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import re @@ -31,6 +32,25 @@ def is_flatpak() -> bool: return os.path.exists("/.flatpak-info") or "FLATPAK_ID" in os.environ +def appimage_path() -> Path | None: + """ + Chemin du fichier ``.AppImage`` en cours d'exécution, ou ``None``. + + Le lanceur AppImage exporte ``APPIMAGE`` (chemin absolu du fichier d'origine). + On exige un fichier réel : une variable héritée d'un autre processus ne doit + pas faire croire à un AppImage. + """ + value = os.environ.get("APPIMAGE") + if not value: + return None + path = Path(value) + return path if path.is_file() else None + + +def is_appimage() -> bool: + return appimage_path() is not None + + _LINUX_UPDATE_COMMANDS = { "arch": "yay -Syu", "ubuntu": "sudo apt update && sudo apt install --only-upgrade pdf-equilibrist", @@ -50,7 +70,9 @@ def linux_update_command() -> str | None: à jour se fait via le gestionnaire de paquets ; ``None`` signale à l'appelant de masquer toute action de mise à jour plutôt que d'en proposer une fausse. """ - if sys.platform == "win32" or is_flatpak(): + if sys.platform == "win32" or is_flatpak() or is_appimage(): + # AppImage : posée sur n'importe quelle distribution, le gestionnaire de + # paquets ne la met pas à jour (voir find_appimage_asset). return None try: os_release = Path("/etc/os-release").read_text(encoding="utf-8") @@ -139,7 +161,46 @@ def find_installer_asset(release: dict) -> dict | None: return None -def download_release_asset(asset: dict, target_path: Path, timeout: int = 120) -> Path: +def find_appimage_asset(release: dict) -> dict | None: + for asset in release.get("assets", []) or []: + name = asset.get("name", "").lower() + if name.endswith(".appimage") and "x86_64" in name: + return asset + return None + + +def find_install_script_asset(release: dict) -> dict | None: + for asset in release.get("assets", []) or []: + if asset.get("name") == "install.sh": + return asset + return None + + +def install_script_target(release: dict) -> Path: + """``install.sh`` est posé dans le même dossier que la nouvelle AppImage.""" + return appimage_download_target(release).parent / "install.sh" + + +def appimage_download_target(release: dict) -> Path: + """ + Où poser la nouvelle AppImage : à côté de l'actuelle (même dossier), sous un nom + versionné pour ne jamais écraser celle qui tourne ; ``~/Downloads`` si le dossier + n'est pas modifiable. + """ + tag = re.sub(r"[^0-9A-Za-z.\-]", "", release.get("tag_name") or "") or "update" + filename = f"PDF-Equilibrist-{tag}-x86_64.AppImage" + current = appimage_path() + folder = current.parent if current else Path.home() / "Downloads" + if not os.access(folder, os.W_OK): + folder = Path.home() / "Downloads" + return folder / filename + + +def download_release_asset(asset: dict, target_path: Path, timeout: int = 120, progress=None) -> Path: + """ + Télécharge l'asset vers ``target_path`` (fichier ``.part`` puis renommage : jamais de fichier + tronqué sous le nom final). ``progress(octets_lus, total_ou_0)`` est appelé à chaque bloc. + """ url = asset.get("browser_download_url") if not url: raise ValueError("L'asset ne contient pas d'URL de téléchargement valide.") @@ -156,9 +217,29 @@ def download_release_asset(asset: dict, target_path: Path, timeout: int = 120) - }, ) target_path.parent.mkdir(parents=True, exist_ok=True) - with network.urlopen(request, timeout=timeout) as response: - data = response.read() - target_path.write_bytes(data) + part = target_path.with_name(target_path.name + ".part") + sha = hashlib.sha256() + done = 0 + try: + with network.urlopen(request, timeout=timeout) as response, open(part, "wb") as out: + headers = getattr(response, "headers", None) + total = int(headers.get("Content-Length") or 0) if headers is not None else 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + out.write(chunk) + sha.update(chunk) + done += len(chunk) + if progress: + progress(done, total) + # GitHub publie l'empreinte de chaque asset (« sha256:… ») : si elle est là, on la vérifie. + digest = str(asset.get("digest") or "") + if digest.startswith("sha256:") and sha.hexdigest() != digest[7:].lower(): + raise ValueError("Empreinte SHA-256 du fichier téléchargé incorrecte.") + os.replace(part, target_path) + finally: + part.unlink(missing_ok=True) return target_path diff --git a/src/pdf_equilibrist/utils.py b/src/pdf_equilibrist/utils.py index 92b9770..eb843aa 100644 --- a/src/pdf_equilibrist/utils.py +++ b/src/pdf_equilibrist/utils.py @@ -77,3 +77,55 @@ def resource_path(relative: str) -> Path: # Mode dev : remonter src/pdf_equilibrist/utils.py → projet root return Path(__file__).resolve().parent.parent.parent / relative + + +def external_env() -> dict[str, str]: + """ + Environnement à donner aux programmes externes (xdg-open, gio…). + + Un exécutable PyInstaller (AppImage comprise) place ses propres bibliothèques en tête de + ``LD_LIBRARY_PATH`` : un programme système qui les charge (ex. ``gio`` avec la glib embarquée) + plante avec « undefined symbol ». On restitue la valeur d'origine, que le lanceur PyInstaller + garde dans ``LD_LIBRARY_PATH_ORIG``. + """ + import os + + env = dict(os.environ) + if "LD_LIBRARY_PATH_ORIG" in env: + env["LD_LIBRARY_PATH"] = env.pop("LD_LIBRARY_PATH_ORIG") + elif getattr(sys, "frozen", False): + env.pop("LD_LIBRARY_PATH", None) + return env + + +def open_external(target: str) -> bool: + """Ouvre une URL ou un fichier avec l'application par défaut du système (sans polluer son environnement).""" + import os + import shutil + import subprocess # nosec B404 + import webbrowser + + if sys.platform.startswith("linux"): + xdg_open = shutil.which("xdg-open") + if xdg_open: + try: + subprocess.Popen( # nosec B603 + [xdg_open, target], env=external_env(), start_new_session=True, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + return True + except OSError: + pass + elif sys.platform == "win32" and not target.startswith(("http://", "https://")): + os.startfile(target) # nosec B606 + return True + # Repli : webbrowser lance gio / gvfs-open / un navigateur avec NOTRE environnement. On le + # nettoie le temps de l'appel (open() rend la main tout de suite, les enfants sont déjà créés). + saved = dict(os.environ) + try: + os.environ.clear() + os.environ.update(external_env()) + return webbrowser.open(target) + finally: + os.environ.clear() + os.environ.update(saved) diff --git a/tests/test_update_appimage.py b/tests/test_update_appimage.py new file mode 100644 index 0000000..64f8e24 --- /dev/null +++ b/tests/test_update_appimage.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pdf_equilibrist import update + + +def _release(*names: str, tag: str = "v0.1.26") -> dict: + return {"tag_name": tag, "assets": [{"name": n, "browser_download_url": f"https://x/{n}"} for n in names]} + + +def test_find_appimage_asset_ignores_exe_and_checksum(): + rel = _release( + "PDF-Equilibrist-Setup.exe", + "PDF-Equilibrist-x86_64.AppImage.sha256", + "PDF-Equilibrist-x86_64.AppImage", + ) + assert update.find_appimage_asset(rel)["name"] == "PDF-Equilibrist-x86_64.AppImage" + assert update.find_installer_asset(rel)["name"] == "PDF-Equilibrist-Setup.exe" + assert update.find_appimage_asset(_release("PDF-Equilibrist-Setup.exe")) is None + + +def test_is_appimage_requires_a_real_file(tmp_path, monkeypatch): + monkeypatch.delenv("APPIMAGE", raising=False) + assert not update.is_appimage() + monkeypatch.setenv("APPIMAGE", str(tmp_path / "absent.AppImage")) + assert not update.is_appimage() + f = tmp_path / "PDF-Equilibrist-x86_64.AppImage" + f.write_bytes(b"x") + monkeypatch.setenv("APPIMAGE", str(f)) + assert update.is_appimage() + assert update.linux_update_command() is None + + +def test_appimage_target_is_versioned_next_to_current(tmp_path, monkeypatch): + f = tmp_path / "PDF-Equilibrist-x86_64.AppImage" + f.write_bytes(b"x") + monkeypatch.setenv("APPIMAGE", str(f)) + target = update.appimage_download_target(_release(tag="v0.1.26")) + assert target == tmp_path / "PDF-Equilibrist-v0.1.26-x86_64.AppImage" + assert target != f + + +def test_appimage_target_sanitizes_tag(tmp_path, monkeypatch): + f = tmp_path / "a.AppImage" + f.write_bytes(b"x") + monkeypatch.setenv("APPIMAGE", str(f)) + target = update.appimage_download_target(_release(tag="../../evil")) + assert target.parent == tmp_path + + +def test_download_checks_sha256(tmp_path, monkeypatch): + import hashlib + import io + + data = b"payload" + + class _Resp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(update.network, "urlopen", lambda *a, **k: _Resp(data)) + asset = {"browser_download_url": "https://x/f", "digest": "sha256:" + hashlib.sha256(data).hexdigest()} + assert update.download_release_asset(asset, tmp_path / "ok").read_bytes() == data + asset["digest"] = "sha256:" + "0" * 64 + with pytest.raises(ValueError): + update.download_release_asset(asset, Path(tmp_path / "bad")) + assert not (tmp_path / "bad").exists() + + +def test_external_env_restores_original_library_path(monkeypatch): + from pdf_equilibrist.utils import external_env + + monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEI123") + monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/lib/custom") + assert external_env()["LD_LIBRARY_PATH"] == "/usr/lib/custom" + assert "LD_LIBRARY_PATH_ORIG" not in external_env() + + +def test_install_script_asset_and_target(tmp_path, monkeypatch): + rel = _release("PDF-Equilibrist-x86_64.AppImage", "install.sh") + assert update.find_install_script_asset(rel)["name"] == "install.sh" + assert update.find_install_script_asset(_release("PDF-Equilibrist-x86_64.AppImage")) is None + f = tmp_path / "PDF-Equilibrist-x86_64.AppImage" + f.write_bytes(b"x") + monkeypatch.setenv("APPIMAGE", str(f)) + assert update.install_script_target(rel) == tmp_path / "install.sh"