From 1bae2e03a6e13ee685f6683cb7ca25a9bb85017f Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sat, 17 Oct 2026 19:06:13 +0200 Subject: [PATCH 1/3] feat(update): installation de la nouvelle AppImage depuis l'application (utilisateur ou tous via pkexec), relance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh embarqué dans l'AppImage ; option --sha256 (revérifiée en root sur une copie) ; en root, aucune icône ni fichier utilisateur n'est lu ou exécuté - update.py : portée d'installation (user/system/none), commande (pkexec, script passé en texte), relance sans les variables de l'AppImage en cours - fenêtre À propos : installer maintenant, choix pour moi / pour tous, sortie de l'installation, relancer - traductions anglaises des nouveaux textes ; compilation du .qm dans le workflow AppImage - CI : le script embarqué est exécuté (installation utilisateur, somme refusée si fausse, désinstallation) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/linux-appimage.yml | 32 +++++ docs/5.BUILD.md | 5 +- docs/en/5.BUILD.md | 5 +- packaging/appimage/README.md | 6 +- packaging/appimage/build_appimage.sh | 5 + packaging/appimage/install.sh | 33 ++++- src/pdf_equilibrist/ui/update_dialog.py | 124 ++++++++++++++++-- src/pdf_equilibrist/update.py | 97 ++++++++++++++ tests/test_update_appimage.py | 88 +++++++++++++ translations/pdf_equilibrist_en.ts | 161 ++++++++++++++++++++++++ 10 files changed, 541 insertions(+), 15 deletions(-) diff --git a/.github/workflows/linux-appimage.yml b/.github/workflows/linux-appimage.yml index 3d91cee..15e60f0 100644 --- a/.github/workflows/linux-appimage.yml +++ b/.github/workflows/linux-appimage.yml @@ -89,6 +89,16 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pyinstaller + # Le .qm versionné peut être en retard sur le .ts : on le recompile ici, sinon l'AppImage afficherait en + # français les textes ajoutés depuis. Pas de repli silencieux : sans lrelease, le build échoue. + - name: Compile translations (.ts to .qm) + run: | + pip install pyside6-essentials --quiet + LRELEASE="$(find "$(python -c 'import PySide6, os; print(os.path.dirname(PySide6.__file__))')" -name lrelease -type f | head -n1)" + test -n "$LRELEASE" || { echo "lrelease introuvable dans pyside6-essentials" >&2; exit 1; } + for ts in translations/*.ts; do "$LRELEASE" "$ts" -qm "${ts%.ts}.qm"; done + ls -la translations/*.qm + - name: Download appimagetool (pinned and verified) run: | curl -fsSL -o appimagetool \ @@ -126,6 +136,28 @@ jobs: # 124 = toujours en cours d'exécution au bout de 15 s = démarrage réussi test "$rc" = 124 + # Le script embarqué, tel que la mise à jour intégrée le lance : installation utilisateur dans un HOME vide, + # avec la somme imposée ; une mauvaise somme doit être refusée. + - name: Bundled install script (user install, checksum enforced) + run: | + H="$(mktemp -d)" + IMG="$PWD/build/appimage/PDF-Equilibrist-x86_64.AppImage" + ( cd "$H" && APPIMAGE_EXTRACT_AND_RUN=1 "$IMG" --appimage-extract >/dev/null ) + SCRIPT="$H/squashfs-root/usr/share/pdf-equilibrist/install.sh" + test -x "$SCRIPT" + SUM="$(sha256sum "$IMG" | cut -d' ' -f1)" + if env -i HOME="$H" PATH=/usr/bin:/bin bash "$SCRIPT" --file "$IMG" --sha256 "$(printf '%064d' 0)"; then + echo "une mauvaise somme aurait dû être refusée" >&2; exit 1 + fi + env -i HOME="$H" PATH=/usr/bin:/bin APPIMAGE_EXTRACT_AND_RUN=1 bash "$SCRIPT" --file "$IMG" --sha256 "$SUM" + test -x "$H/.local/share/pdf-equilibrist/PDF-Equilibrist.AppImage" + test -L "$H/.local/bin/pdf-equilibrist" + test -f "$H/.local/share/applications/pdf-equilibrist.desktop" + test -f "$H/.local/share/icons/hicolor/256x256/apps/pdf-equilibrist.png" + desktop-file-validate "$H/.local/share/applications/pdf-equilibrist.desktop" + env -i HOME="$H" PATH=/usr/bin:/bin bash "$SCRIPT" --uninstall + test ! -e "$H/.local/share/pdf-equilibrist" + - name: Summary run: | { diff --git a/docs/5.BUILD.md b/docs/5.BUILD.md index ce2c335..58c3162 100644 --- a/docs/5.BUILD.md +++ b/docs/5.BUILD.md @@ -232,7 +232,10 @@ cinq distributions, puis la joint à la release GitHub avec son `.sha256` et `in (`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`. +télécharge la nouvelle version (SHA-256 vérifié) puis propose de l'installer avec le `install.sh` embarqué dans +l'AppImage : pour l'utilisateur seul (sans mot de passe) ou pour tous les utilisateurs (autorisation demandée par +le système via `pkexec` ; l'application ne voit jamais le mot de passe, et la somme SHA-256 est revérifiée en root +sur une copie avant l'installation). Elle propose ensuite de relancer la nouvelle version. > **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 diff --git a/docs/en/5.BUILD.md b/docs/en/5.BUILD.md index 6744921..9eb3454 100644 --- a/docs/en/5.BUILD.md +++ b/docs/en/5.BUILD.md @@ -232,7 +232,10 @@ five distributions, then attaches it to the GitHub release together with its `.s (`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`. +downloads the new version (SHA-256 checked) and offers to install it with the `install.sh` bundled in the +AppImage: for the current user only (no password) or for all users (authorisation requested by the system through +`pkexec`; the application never sees the password, and the SHA-256 is checked again as root on a copy before +installing). It then offers to restart the new version. > **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 diff --git a/packaging/appimage/README.md b/packaging/appimage/README.md index 7855f8d..8e0464d 100644 --- a/packaging/appimage/README.md +++ b/packaging/appimage/README.md @@ -39,8 +39,10 @@ Avec `APPIMAGETOOL=/chemin/appimagetool`, le script produit aussi - **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. + dernière release et la télécharge (SHA-256 vérifié si GitHub le fournit), puis propose de l'installer avec le + `install.sh` embarqué (`usr/share/pdf-equilibrist/install.sh`) : pour l'utilisateur seul, ou pour tous via + `pkexec` (script passé en texte, somme revérifiée en root sur une copie, icône existante conservée), puis de + relancer. 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 » › diff --git a/packaging/appimage/build_appimage.sh b/packaging/appimage/build_appimage.sh index e1b4732..53f7abc 100755 --- a/packaging/appimage/build_appimage.sh +++ b/packaging/appimage/build_appimage.sh @@ -37,6 +37,11 @@ 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" +# Script d'installation embarqué : la mise à jour intégrée le lance depuis l'AppImage en cours d'exécution +# (rien à télécharger ; l'icône est prise dans usr/share/icons, donc sans exécuter de fichier téléchargé). +mkdir -p "$APPDIR/usr/share/pdf-equilibrist" +install -m755 "$HERE/install.sh" "$APPDIR/usr/share/pdf-equilibrist/install.sh" + # AppStream metainfo (AppImageHub, logiciels de gestion d'applications) : version et date injectées APP_VERSION="$("$PYTHON" -c 'import re,sys; print(re.search(r"__version__ = \"(.*?)\"", open(sys.argv[1], encoding="utf-8").read()).group(1))' "$ROOT/src/pdf_equilibrist/__init__.py")" mkdir -p "$APPDIR/usr/share/metainfo" diff --git a/packaging/appimage/install.sh b/packaging/appimage/install.sh index 6e1eaa8..1da4bf9 100755 --- a/packaging/appimage/install.sh +++ b/packaging/appimage/install.sh @@ -6,6 +6,7 @@ # 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 +# install.sh --sha256 HEX avec --file : refuse l'installation si la somme SHA-256 ne correspond pas # # 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. @@ -13,7 +14,7 @@ set -euo pipefail REPO="Bit-Scripts/PDF-Equilibrist" ASSET="PDF-Equilibrist-x86_64.AppImage" -MODE=user ; ACTION=install ; FILE="" +MODE=user ; ACTION=install ; FILE="" ; SHA256="" while [ $# -gt 0 ]; do case "$1" in @@ -21,6 +22,7 @@ while [ $# -gt 0 ]; do --user) MODE=user ;; --uninstall) ACTION=uninstall ;; --file) FILE="${2:?--file demande un chemin}" ; shift ;; + --sha256) SHA256="${2:?--sha256 demande une somme}" ; shift ;; -h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' ; exit 0 ;; *) echo "Option inconnue : $1 (voir --help)" >&2 ; exit 2 ;; esac @@ -83,13 +85,36 @@ if [ -z "$FILE" ]; then fi [ -f "$FILE" ] || { echo "Fichier introuvable : $FILE" >&2 ; exit 1; } +# Somme imposée (mise à jour intégrée) : en root, on copie d'abord le fichier dans un dossier réservé à root +# puis on vérifie la COPIE, pour qu'un fichier remplacé entre-temps par un autre processus ne soit jamais installé. +if [ -n "$SHA256" ]; then + if [ "$(id -u)" -eq 0 ]; then + cp -L -- "$FILE" "$WORK/checked.AppImage" + FILE="$WORK/checked.AppImage" + fi + actual="$(sha256sum -- "$FILE" | cut -d' ' -f1)" + [ "$actual" = "$SHA256" ] || { echo "Somme SHA-256 incorrecte : installation refusée." >&2 ; exit 1; } + echo "SHA-256 vérifiée." +fi + 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)." +# Icône. Quand le script est embarqué dans l'AppImage, l'icône est à côté de lui : rien n'est exécuté. +# Sinon elle est extraite de l'AppImage (--appimage-extract, sans FUSE) — mais jamais en root : +# on n'exécute pas avec des droits élevés un fichier qui vient d'être téléchargé. +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUNDLED_ICON="$SELF/../icons/hicolor/256x256/apps/pdf-equilibrist.png" +if [ -f "$BUNDLED_ICON" ] && [ "$(id -u)" -ne 0 ]; then + install -m644 "$BUNDLED_ICON" "$ICON" +elif [ "$(id -u)" -eq 0 ]; then + # root : on ne lit ni n'exécute rien qui vienne d'un utilisateur ; une icône déjà installée est conservée + [ -f "$ICON" ] || echo "Icône non installée (installation système : icône par défaut)." +else + ( cd "$WORK" && "$TARGET" --appimage-extract pdf-equilibrist.png >/dev/null 2>&1 \ + && install -m644 squashfs-root/pdf-equilibrist.png "$ICON" ) || echo "Icône non installée (icône par défaut)." +fi cat > "$DESKTOP" <)." - ).format(target)) + self._new_appimage = target + if updater.bundled_install_script() is None: + # AppImage sans script embarqué (ancienne version) : repli manuel + 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)) + return + try: + self._new_sha = updater.expected_sha256(self._pending_asset, target) + except OSError as e: + self._lbl.setText(self.tr("Impossible de lire le fichier téléchargé : {0}").format(e)) + return + self._lbl.setText(self.tr("Nouvelle version téléchargée et vérifiée :\n{0}").format(target)) + answer = QMessageBox.question( + self, self.tr("Installer la nouvelle version"), + self.tr("La nouvelle version est téléchargée. L'installer maintenant ?"), + ) + if answer == QMessageBox.StandardButton.Yes: + self._choose_scope_and_install() + + def _choose_scope_and_install(self): + scope = updater.install_scope() + if scope == "user": + self._run_install("user") + return + box = QMessageBox(self) + box.setWindowTitle(self.tr("Installer pour qui ?")) + if scope == "system": + box.setText(self.tr( + "Cette version est installée pour tous les utilisateurs de l'ordinateur. Mettre à jour :\n\n" + "• pour vous seulement : une copie dans votre dossier personnel, sans mot de passe ;\n" + "• pour tous : le système vous demandera l'autorisation d'un administrateur.")) + else: + box.setText(self.tr( + "Cette copie n'est pas installée. L'installer :\n\n" + "• pour vous seulement : dans votre dossier personnel, sans mot de passe ;\n" + "• pour tous les utilisateurs : le système vous demandera l'autorisation d'un administrateur.")) + btn_me = box.addButton(self.tr("Pour moi uniquement"), QMessageBox.ButtonRole.AcceptRole) + btn_all = box.addButton(self.tr("Pour tous les utilisateurs"), QMessageBox.ButtonRole.ActionRole) + box.addButton(self.tr("Annuler"), QMessageBox.ButtonRole.RejectRole) + box.exec() + if box.clickedButton() is btn_me: + self._run_install("user") + elif box.clickedButton() is btn_all: + self._run_install("system") + + def _run_install(self, scope: str): + command = updater.build_install_command(self._new_appimage, scope, self._new_sha) + if command is None: + hint = ("sudo bash install.sh --system --file " if scope == "system" else "bash install.sh --file ") + self._lbl.setText(self.tr( + "Installation automatique indisponible sur ce système (pkexec ou le script est introuvable).\n" + "Dans un terminal : {0}{1}" + ).format(hint, self._new_appimage)) + return + self._install_scope = scope + self._install_output = "" + self._btn_download.setEnabled(False) + self._progress.setRange(0, 0) + self._lbl.setText(self.tr("Installation en cours…")) + env = QProcessEnvironment() + for key, value in external_env().items(): + env.insert(key, value) + self._proc = QProcess(self) + self._proc.setProcessEnvironment(env) + self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) + self._proc.readyReadStandardOutput.connect(self._on_install_output) + self._proc.finished.connect(self._on_install_finished) + self._proc.errorOccurred.connect(self._on_install_error) + self._proc.start(command[0], command[1:]) + + def _on_install_output(self): + self._install_output += bytes(self._proc.readAllStandardOutput()).decode("utf-8", "replace") + + def _on_install_error(self, error): + if error == QProcess.ProcessError.FailedToStart: + self._progress.setRange(0, 1) + self._btn_download.setEnabled(True) + self._lbl.setText(self.tr("Impossible de lancer l'installation.")) + + def _on_install_finished(self, exit_code: int, _status): + self._progress.setRange(0, 1) + self._progress.setValue(1) + self._btn_download.setEnabled(True) + if exit_code == 0: + installed = updater.installed_appimage_path(self._install_scope) + self._lbl.setText(self.tr("Installée : {0}").format(installed)) + answer = QMessageBox.question( + self, self.tr("Installation terminée"), + self.tr("La nouvelle version est installée. Relancer l'application maintenant ?"), + ) + if answer == QMessageBox.StandardButton.Yes: + self._relaunch(installed) + return + if self._install_scope == "system" and exit_code in (126, 127): + self._lbl.setText(self.tr("Autorisation refusée ou annulée : rien n'a été installé.")) + return + lines = [line for line in self._install_output.splitlines() if line.strip()] + detail = "\n".join(lines[-3:]) + self._lbl.setText(self.tr("L'installation a échoué (code {0}).\n{1}").format(exit_code, detail)) + + def _relaunch(self, path): + try: + subprocess.Popen( # nosec B603 + [str(path)], env=updater.relaunch_env(), start_new_session=True, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except OSError as e: + self._lbl.setText(self.tr("Impossible de relancer l'application : {0}").format(e)) + return + self._quit_for_installer() def _download_and_run(self): if not self._release: diff --git a/src/pdf_equilibrist/update.py b/src/pdf_equilibrist/update.py index 1ea1867..1214565 100644 --- a/src/pdf_equilibrist/update.py +++ b/src/pdf_equilibrist/update.py @@ -4,6 +4,7 @@ import json import os import re +import shutil import sys import tempfile import urllib.error @@ -51,6 +52,102 @@ def is_appimage() -> bool: return appimage_path() is not None +INSTALL_SUBDIR = Path("share") / "pdf-equilibrist" +INSTALLED_NAME = "PDF-Equilibrist.AppImage" +SYSTEM_PREFIX = Path("/usr/local") + + +def install_prefix(scope: str) -> Path: + """Préfixe d'installation du script ``install.sh`` : ``~/.local`` (user) ou ``/usr/local`` (system).""" + return SYSTEM_PREFIX if scope == "system" else Path.home() / ".local" + + +def installed_appimage_path(scope: str) -> Path: + """Où ``install.sh`` pose l'AppImage pour ce périmètre.""" + return install_prefix(scope) / INSTALL_SUBDIR / INSTALLED_NAME + + +def install_scope(current: Path | None = None) -> str: + """ + Comment l'AppImage en cours d'exécution est installée : ``"user"`` (sous ``~/.local``), + ``"system"`` (sous ``/usr/local``) ou ``"none"`` (lancée depuis n'importe quel dossier). + """ + current = current or appimage_path() + if current is None: + return "none" + try: + resolved = current.resolve() + except OSError: + return "none" + for scope in ("user", "system"): + if install_prefix(scope) / INSTALL_SUBDIR in resolved.parents: + return scope + return "none" + + +def bundled_install_script() -> Path | None: + """``install.sh`` embarqué dans l'AppImage en cours d'exécution (AppRun exporte ``APPDIR``).""" + appdir = os.environ.get("APPDIR") + if not appdir: + return None + script = Path(appdir) / "usr" / "share" / "pdf-equilibrist" / "install.sh" + return script if script.is_file() else None + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def expected_sha256(asset: dict, downloaded: Path) -> str: + """Somme à imposer à l'installation : celle de GitHub (vérifiée au téléchargement), sinon celle du fichier.""" + digest = str(asset.get("digest") or "") + if digest.startswith("sha256:") and re.fullmatch(r"[0-9a-fA-F]{64}", digest[7:]): + return digest[7:].lower() + return file_sha256(downloaded) + + +def build_install_command(new_file: Path, scope: str, sha256: str) -> list[str] | None: + """ + Commande qui installe ``new_file`` avec le ``install.sh`` embarqué. + + ``scope="user"`` : ``bash install.sh --file …`` (aucun droit particulier). + ``scope="system"`` : même chose en root via ``pkexec`` — la fenêtre d'authentification est celle du système, + l'application ne voit jamais le mot de passe. Le script est passé *en texte* (``bash -c``) : le fichier est + dans un montage FUSE que root ne peut pas lire, et on évite qu'un autre processus le remplace pendant que + l'utilisateur s'authentifie. Retourne ``None`` si le script ou ``pkexec`` manque. + """ + script = bundled_install_script() + bash = shutil.which("bash") + if script is None or bash is None: + return None + args = ["--file", str(new_file), "--sha256", sha256] + if scope != "system": + return [bash, str(script), *args] + pkexec = shutil.which("pkexec") + if pkexec is None: + return None + try: + text = script.read_text(encoding="utf-8") + except OSError: + return None + return [pkexec, bash, "-c", text, "install.sh", "--system", *args] + + +def relaunch_env() -> dict[str, str]: + """Environnement pour relancer l'application : celui du système, sans les variables de l'AppImage en cours.""" + from pdf_equilibrist.utils import external_env + + env = external_env() + for name in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): + env.pop(name, None) + env["PYINSTALLER_RESET_ENVIRONMENT"] = "1" + return env + + _LINUX_UPDATE_COMMANDS = { "arch": "yay -Syu", "ubuntu": "sudo apt update && sudo apt install --only-upgrade pdf-equilibrist", diff --git a/tests/test_update_appimage.py b/tests/test_update_appimage.py index 64f8e24..9dfde8f 100644 --- a/tests/test_update_appimage.py +++ b/tests/test_update_appimage.py @@ -90,3 +90,91 @@ def test_install_script_asset_and_target(tmp_path, monkeypatch): f.write_bytes(b"x") monkeypatch.setenv("APPIMAGE", str(f)) assert update.install_script_target(rel) == tmp_path / "install.sh" + + +# ── Installation depuis l'application (AppImage) ───────────────────────────── + +def _fake_appimage(tmp_path, monkeypatch, subdir): + folder = tmp_path / subdir + folder.mkdir(parents=True) + appimage = folder / update.INSTALLED_NAME + appimage.write_bytes(b"x") + monkeypatch.setenv("APPIMAGE", str(appimage)) + return appimage + + +def test_install_scope_user_system_none(tmp_path, monkeypatch): + monkeypatch.setattr(update.Path, "home", classmethod(lambda cls: tmp_path / "home")) + monkeypatch.setattr(update, "SYSTEM_PREFIX", tmp_path / "usr_local") + _fake_appimage(tmp_path, monkeypatch, "home/.local/share/pdf-equilibrist") + assert update.install_scope() == "user" + _fake_appimage(tmp_path, monkeypatch, "usr_local/share/pdf-equilibrist") + assert update.install_scope() == "system" + _fake_appimage(tmp_path, monkeypatch, "Downloads") + assert update.install_scope() == "none" + monkeypatch.delenv("APPIMAGE") + assert update.install_scope() == "none" + + +def test_installed_appimage_path(tmp_path, monkeypatch): + monkeypatch.setattr(update.Path, "home", classmethod(lambda cls: tmp_path / "home")) + monkeypatch.setattr(update, "SYSTEM_PREFIX", tmp_path / "usr_local") + assert update.installed_appimage_path("user") == ( + tmp_path / "home" / ".local" / "share" / "pdf-equilibrist" / "PDF-Equilibrist.AppImage") + assert update.installed_appimage_path("system") == ( + tmp_path / "usr_local" / "share" / "pdf-equilibrist" / "PDF-Equilibrist.AppImage") + + +def _bundle_script(tmp_path, monkeypatch): + script = tmp_path / "appdir" / "usr" / "share" / "pdf-equilibrist" / "install.sh" + script.parent.mkdir(parents=True) + script.write_text("#!/usr/bin/env bash\necho hi\n", encoding="utf-8") + monkeypatch.setenv("APPDIR", str(tmp_path / "appdir")) + return script + + +def test_build_install_command_user(tmp_path, monkeypatch): + script = _bundle_script(tmp_path, monkeypatch) + monkeypatch.setattr(update.shutil, "which", lambda name: "/usr/bin/" + name) + new = tmp_path / "new.AppImage" + cmd = update.build_install_command(new, "user", "ab" * 32) + assert cmd == ["/usr/bin/bash", str(script), "--file", str(new), "--sha256", "ab" * 32] + + +def test_build_install_command_system_uses_pkexec_and_inline_script(tmp_path, monkeypatch): + script = _bundle_script(tmp_path, monkeypatch) + monkeypatch.setattr(update.shutil, "which", lambda name: "/usr/bin/" + name) + new = tmp_path / "new.AppImage" + cmd = update.build_install_command(new, "system", "cd" * 32) + assert cmd[:3] == ["/usr/bin/pkexec", "/usr/bin/bash", "-c"] + assert cmd[3] == script.read_text(encoding="utf-8") # le script voyage en texte, pas en chemin + assert cmd[4:] == ["install.sh", "--system", "--file", str(new), "--sha256", "cd" * 32] + + +def test_build_install_command_unavailable(tmp_path, monkeypatch): + new = tmp_path / "new.AppImage" + monkeypatch.delenv("APPDIR", raising=False) + assert update.build_install_command(new, "user", "00") is None # pas de script embarqué + _bundle_script(tmp_path, monkeypatch) + monkeypatch.setattr(update.shutil, "which", lambda name: None if name == "pkexec" else "/usr/bin/" + name) + assert update.build_install_command(new, "system", "00") is None # pas de pkexec + assert update.build_install_command(new, "user", "00") is not None + + +def test_expected_sha256_prefers_github_digest(tmp_path): + f = tmp_path / "f" + f.write_bytes(b"payload") + assert update.expected_sha256({"digest": "sha256:" + "AB" * 32}, f) == "ab" * 32 + assert update.expected_sha256({}, f) == update.file_sha256(f) + assert update.expected_sha256({"digest": "sha256:zz"}, f) == update.file_sha256(f) + + +def test_relaunch_env_drops_appimage_variables(monkeypatch): + for name in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): + monkeypatch.setenv(name, "x") + monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEI1") + monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/lib/x") + env = update.relaunch_env() + assert not {"APPIMAGE", "APPDIR", "ARGV0", "OWD"} & set(env) + assert env["LD_LIBRARY_PATH"] == "/usr/lib/x" + assert env["PYINSTALLER_RESET_ENVIRONMENT"] == "1" diff --git a/translations/pdf_equilibrist_en.ts b/translations/pdf_equilibrist_en.ts index e094b00..c4510a0 100644 --- a/translations/pdf_equilibrist_en.ts +++ b/translations/pdf_equilibrist_en.ts @@ -1856,6 +1856,167 @@ rypt Échec du téléchargement : {0} Download failed: {0} + + + Aucun navigateur détecté : adresse copiée dans le presse-papiers. + No browser detected: the address was copied to the clipboard. + + + + Installer la nouvelle version + Install the new version + + + + La nouvelle version est téléchargée. L'installer maintenant ? + The new version is downloaded. Install it now? + + + + Installer pour qui ? + Install for whom? + + + + Pour moi uniquement + Just for me + + + + Pour tous les utilisateurs + For all users + + + + Annuler + Cancel + + + + Installation en cours… + Installing… + + + + Adresse : + Address: + + + + Cette version est installée pour tous les utilisateurs de l'ordinateur. Mettre à jour : + +• pour vous seulement : une copie dans votre dossier personnel, sans mot de passe ; +• pour tous : le système vous demandera l'autorisation d'un administrateur. + This version is installed for all users of the computer. Update it: + +• for you only: a copy in your home folder, no password needed; +• for everyone: the system will ask for an administrator's authorisation. + + + + Cette copie n'est pas installée. L'installer : + +• pour vous seulement : dans votre dossier personnel, sans mot de passe ; +• pour tous les utilisateurs : le système vous demandera l'autorisation d'un administrateur. + This copy is not installed. Install it: + +• for you only: in your home folder, no password needed; +• for all users: the system will ask for an administrator's authorisation. + + + + Impossible de lancer l'installation. + Could not start the installation. + + + + Installation terminée + Installation finished + + + + La nouvelle version est installée. Relancer l'application maintenant ? + The new version is installed. Restart the application now? + + + + Autorisation refusée ou annulée : rien n'a été installé. + Authorisation refused or cancelled: nothing was installed. + + + + Télécharger le script d'installation (install.sh) + Download the install script (install.sh) + + + + Télécharger la nouvelle AppImage + Download the new AppImage + + + + Script d'installation téléchargé : +{0} +Lancez : bash install.sh (ou sudo bash install.sh --system) + Install script downloaded: +{0} +Run: bash install.sh (or sudo bash install.sh --system) + + + + Nouvelle version téléchargée et vérifiée : +{0} + New version downloaded and verified: +{0} + + + + L'installation a échoué (code {0}). +{1} + The installation failed (code {0}). +{1} + + + + Téléchargement : {0:.0f} / {1:.0f} Mo + Downloading: {0:.0f} / {1:.0f} MB + + + + Téléchargement : {0:.0f} Mo + Downloading: {0:.0f} MB + + + + Nouvelle AppImage téléchargée : +{0} +Fermez l'application puis lancez ce fichier (ou : bash install.sh --file <fichier>). + New AppImage downloaded: +{0} +Close the application, then run this file (or: bash install.sh --file <file>). + + + + Installation automatique indisponible sur ce système (pkexec ou le script est introuvable). +Dans un terminal : {0}{1} + Automatic installation is not available on this system (pkexec or the script is missing). +In a terminal: {0}{1} + + + + Installée : {0} + Installed: {0} + + + + Impossible de lire le fichier téléchargé : {0} + Could not read the downloaded file: {0} + + + + Impossible de relancer l'application : {0} + Could not restart the application: {0} + _PageLabel From 2cdd104842bf0bc1be52f312e7e71739f4bdcfd8 Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sat, 17 Oct 2026 19:25:10 +0200 Subject: [PATCH 2/3] feat(update): repli sudo avec demande de mot de passe quand pkexec est absent (WSL, bureau minimal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkexec absent ou sans agent d'authentification : l'application demande le mot de passe dans sa fenêtre et l'envoie à sudo -S par l'entrée standard (jamais en argument), avec le même script en texte et la même somme. Traductions et tests ajoutés. Co-Authored-By: Claude Sonnet 5.5 --- docs/5.BUILD.md | 4 +- docs/en/5.BUILD.md | 3 +- packaging/appimage/README.md | 3 +- src/pdf_equilibrist/ui/update_dialog.py | 63 +++++++++++++++++++++---- src/pdf_equilibrist/update.py | 19 ++++++++ tests/test_update_appimage.py | 16 +++++++ translations/pdf_equilibrist_en.ts | 34 ++++++++++--- 7 files changed, 124 insertions(+), 18 deletions(-) diff --git a/docs/5.BUILD.md b/docs/5.BUILD.md index 58c3162..147c15b 100644 --- a/docs/5.BUILD.md +++ b/docs/5.BUILD.md @@ -235,7 +235,9 @@ main avec le champ `tag`. Dans l'application, *Aide › À propos* détecte l'Ap télécharge la nouvelle version (SHA-256 vérifié) puis propose de l'installer avec le `install.sh` embarqué dans l'AppImage : pour l'utilisateur seul (sans mot de passe) ou pour tous les utilisateurs (autorisation demandée par le système via `pkexec` ; l'application ne voit jamais le mot de passe, et la somme SHA-256 est revérifiée en root -sur une copie avant l'installation). Elle propose ensuite de relancer la nouvelle version. +sur une copie avant l'installation). Sans `pkexec` (WSL, bureau minimal, serveur), l'application demande le mot de passe dans sa propre fenêtre et le +transmet uniquement à `sudo -S` (jamais en argument, jamais enregistré). Elle propose ensuite de relancer la +nouvelle version. > **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 diff --git a/docs/en/5.BUILD.md b/docs/en/5.BUILD.md index 9eb3454..e0b8ef8 100644 --- a/docs/en/5.BUILD.md +++ b/docs/en/5.BUILD.md @@ -235,7 +235,8 @@ with the `tag` field. In the application, *Help › About* detects the AppImage downloads the new version (SHA-256 checked) and offers to install it with the `install.sh` bundled in the AppImage: for the current user only (no password) or for all users (authorisation requested by the system through `pkexec`; the application never sees the password, and the SHA-256 is checked again as root on a copy before -installing). It then offers to restart the new version. +installing). Without `pkexec` (WSL, minimal desktop, server), the application asks for the password in its own window and +passes it only to `sudo -S` (never as an argument, never stored). It then offers to restart the new version. > **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 diff --git a/packaging/appimage/README.md b/packaging/appimage/README.md index 8e0464d..b051051 100644 --- a/packaging/appimage/README.md +++ b/packaging/appimage/README.md @@ -42,7 +42,8 @@ Avec `APPIMAGETOOL=/chemin/appimagetool`, le script produit aussi dernière release et la télécharge (SHA-256 vérifié si GitHub le fournit), puis propose de l'installer avec le `install.sh` embarqué (`usr/share/pdf-equilibrist/install.sh`) : pour l'utilisateur seul, ou pour tous via `pkexec` (script passé en texte, somme revérifiée en root sur une copie, icône existante conservée), puis de - relancer. Pas de zsync (delta) pour l'instant. + relancer. Sans `pkexec`, repli sur `sudo -S` avec une demande de mot de passe dans l'application (mot de passe + sur l'entrée de sudo, jamais en argument). 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 » › diff --git a/src/pdf_equilibrist/ui/update_dialog.py b/src/pdf_equilibrist/ui/update_dialog.py index 6b56943..5521b39 100644 --- a/src/pdf_equilibrist/ui/update_dialog.py +++ b/src/pdf_equilibrist/ui/update_dialog.py @@ -4,7 +4,7 @@ import subprocess # nosec B404 from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, - QProgressBar, QFrame, QLineEdit, QApplication, QMessageBox, + QProgressBar, QFrame, QLineEdit, QApplication, QMessageBox, QInputDialog, ) from PyQt6.QtCore import QProcess, QProcessEnvironment, QThread, QTimer, pyqtSignal, Qt from PyQt6.QtGui import QPixmap @@ -428,14 +428,43 @@ def _choose_scope_and_install(self): def _run_install(self, scope: str): command = updater.build_install_command(self._new_appimage, scope, self._new_sha) + method = "user" if scope != "system" else "pkexec" + if command is None and scope == "system": + # Pas de pkexec (WSL, bureau minimal, serveur…) : repli sur sudo avec notre propre demande de mot de passe + self._run_install_sudo() + return if command is None: - hint = ("sudo bash install.sh --system --file " if scope == "system" else "bash install.sh --file ") - self._lbl.setText(self.tr( - "Installation automatique indisponible sur ce système (pkexec ou le script est introuvable).\n" - "Dans un terminal : {0}{1}" - ).format(hint, self._new_appimage)) + self._show_manual_install_hint(scope) + return + self._start_install(command, scope, method) + + def _run_install_sudo(self): + command = updater.build_sudo_install_command(self._new_appimage, self._new_sha) + if command is None: + self._show_manual_install_hint("system") + return + password, accepted = QInputDialog.getText( + self, self.tr("Mot de passe administrateur"), + self.tr("Aucune fenêtre d'autorisation du système n'est disponible. Pour installer pour tous les " + "utilisateurs, saisissez votre mot de passe : il est transmis uniquement à sudo, jamais " + "enregistré ni affiché."), + QLineEdit.EchoMode.Password, + ) + if not accepted or not password: + self._lbl.setText(self.tr("Installation annulée : rien n'a été installé.")) return + self._start_install(command, "system", "sudo", password) + + def _show_manual_install_hint(self, scope: str): + hint = "sudo bash install.sh --system --file " if scope == "system" else "bash install.sh --file " + self._lbl.setText(self.tr( + "Installation automatique indisponible sur ce système (pkexec, sudo ou le script est introuvable).\n" + "Dans un terminal : {0}{1}" + ).format(hint, self._new_appimage)) + + def _start_install(self, command: list[str], scope: str, method: str, password: str = ""): self._install_scope = scope + self._install_method = method self._install_output = "" self._btn_download.setEnabled(False) self._progress.setRange(0, 0) @@ -443,14 +472,23 @@ def _run_install(self, scope: str): env = QProcessEnvironment() for key, value in external_env().items(): env.insert(key, value) + if method == "sudo": + env.insert("LC_ALL", "C") # messages de sudo en anglais, pour reconnaître « mot de passe incorrect » self._proc = QProcess(self) self._proc.setProcessEnvironment(env) self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) self._proc.readyReadStandardOutput.connect(self._on_install_output) self._proc.finished.connect(self._on_install_finished) self._proc.errorOccurred.connect(self._on_install_error) + if method == "sudo": + # le mot de passe part sur l'entrée de sudo (-S) dès le démarrage, puis l'entrée est fermée + self._proc.started.connect(lambda: self._send_password(password)) self._proc.start(command[0], command[1:]) + def _send_password(self, password: str): + self._proc.write((password + "\n").encode("utf-8")) + self._proc.closeWriteChannel() + def _on_install_output(self): self._install_output += bytes(self._proc.readAllStandardOutput()).decode("utf-8", "replace") @@ -474,8 +512,17 @@ def _on_install_finished(self, exit_code: int, _status): if answer == QMessageBox.StandardButton.Yes: self._relaunch(installed) return - if self._install_scope == "system" and exit_code in (126, 127): - self._lbl.setText(self.tr("Autorisation refusée ou annulée : rien n'a été installé.")) + output = self._install_output.lower() + if self._install_method == "pkexec" and exit_code in (126, 127): + if "no authentication agent" in output: + # pkexec existe mais aucun agent graphique n'est lancé : on demande le mot de passe nous-mêmes + self._run_install_sudo() + else: + self._lbl.setText(self.tr("Autorisation refusée ou annulée : rien n'a été installé.")) + return + if self._install_method == "sudo" and any( + text in output for text in ("incorrect password", "no password was provided", "try again")): + self._lbl.setText(self.tr("Mot de passe incorrect : rien n'a été installé.")) return lines = [line for line in self._install_output.splitlines() if line.strip()] detail = "\n".join(lines[-3:]) diff --git a/src/pdf_equilibrist/update.py b/src/pdf_equilibrist/update.py index 1214565..c278333 100644 --- a/src/pdf_equilibrist/update.py +++ b/src/pdf_equilibrist/update.py @@ -137,6 +137,25 @@ def build_install_command(new_file: Path, scope: str, sha256: str) -> list[str] return [pkexec, bash, "-c", text, "install.sh", "--system", *args] +def build_sudo_install_command(new_file: Path, sha256: str) -> list[str] | None: + """ + Repli quand ``pkexec`` est absent (WSL, bureau minimal, serveur…) : ``sudo -S`` lit le mot de passe sur son entrée, + que l'application lui envoie après l'avoir demandé dans sa propre fenêtre. ``-k`` ignore tout mot de passe en + cache, ``-p ''`` supprime l'invite. Même script en texte et même somme imposée que pour ``pkexec``. + """ + script = bundled_install_script() + bash = shutil.which("bash") + sudo = shutil.which("sudo") + if script is None or bash is None or sudo is None: + return None + try: + text = script.read_text(encoding="utf-8") + except OSError: + return None + return [sudo, "-S", "-k", "-p", "", "--", bash, "-c", text, "install.sh", "--system", + "--file", str(new_file), "--sha256", sha256] + + def relaunch_env() -> dict[str, str]: """Environnement pour relancer l'application : celui du système, sans les variables de l'AppImage en cours.""" from pdf_equilibrist.utils import external_env diff --git a/tests/test_update_appimage.py b/tests/test_update_appimage.py index 9dfde8f..d042735 100644 --- a/tests/test_update_appimage.py +++ b/tests/test_update_appimage.py @@ -178,3 +178,19 @@ def test_relaunch_env_drops_appimage_variables(monkeypatch): assert not {"APPIMAGE", "APPDIR", "ARGV0", "OWD"} & set(env) assert env["LD_LIBRARY_PATH"] == "/usr/lib/x" assert env["PYINSTALLER_RESET_ENVIRONMENT"] == "1" + + +def test_build_sudo_install_command_reads_password_on_stdin(tmp_path, monkeypatch): + script = _bundle_script(tmp_path, monkeypatch) + monkeypatch.setattr(update.shutil, "which", lambda name: "/usr/bin/" + name) + new = tmp_path / "new.AppImage" + cmd = update.build_sudo_install_command(new, "ef" * 32) + assert cmd[:6] == ["/usr/bin/sudo", "-S", "-k", "-p", "", "--"] # mot de passe sur l'entrée, jamais en argument + assert cmd[6:9] == ["/usr/bin/bash", "-c", script.read_text(encoding="utf-8")] + assert cmd[9:] == ["install.sh", "--system", "--file", str(new), "--sha256", "ef" * 32] + + +def test_build_sudo_install_command_unavailable(tmp_path, monkeypatch): + _bundle_script(tmp_path, monkeypatch) + monkeypatch.setattr(update.shutil, "which", lambda name: None if name == "sudo" else "/usr/bin/" + name) + assert update.build_sudo_install_command(tmp_path / "new.AppImage", "00") is None diff --git a/translations/pdf_equilibrist_en.ts b/translations/pdf_equilibrist_en.ts index c4510a0..a80c11a 100644 --- a/translations/pdf_equilibrist_en.ts +++ b/translations/pdf_equilibrist_en.ts @@ -1994,13 +1994,6 @@ Fermez l'application puis lancez ce fichier (ou : bash install.sh --file <fic New AppImage downloaded: {0} Close the application, then run this file (or: bash install.sh --file <file>). - - - - Installation automatique indisponible sur ce système (pkexec ou le script est introuvable). -Dans un terminal : {0}{1} - Automatic installation is not available on this system (pkexec or the script is missing). -In a terminal: {0}{1} @@ -2017,6 +2010,33 @@ In a terminal: {0}{1} Impossible de relancer l'application : {0} Could not restart the application: {0} + + + Mot de passe administrateur + Administrator password + + + + Aucune fenêtre d'autorisation du système n'est disponible. Pour installer pour tous les utilisateurs, saisissez votre mot de passe : il est transmis uniquement à sudo, jamais enregistré ni affiché. + No system authorisation window is available. To install for all users, enter your password: it is sent only to sudo, never stored or displayed. + + + + Installation annulée : rien n'a été installé. + Installation cancelled: nothing was installed. + + + + Mot de passe incorrect : rien n'a été installé. + Wrong password: nothing was installed. + + + + Installation automatique indisponible sur ce système (pkexec, sudo ou le script est introuvable). +Dans un terminal : {0}{1} + Automatic installation is not available on this system (pkexec, sudo or the script is missing). +In a terminal: {0}{1} + _PageLabel From ec6e74569d550f43af2a50b2bb9b143406324908 Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sat, 17 Oct 2026 19:35:06 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(ci):=20ligne=20trop=20longue=20(flake8)?= =?UTF-8?q?=20et=20argument=20=C2=AB=20password=20=C2=BB=20signal=C3=A9=20?= =?UTF-8?q?par=20Bandit=20(B107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/pdf_equilibrist/ui/update_dialog.py | 8 ++++---- tests/test_update_appimage.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pdf_equilibrist/ui/update_dialog.py b/src/pdf_equilibrist/ui/update_dialog.py index 5521b39..f25311e 100644 --- a/src/pdf_equilibrist/ui/update_dialog.py +++ b/src/pdf_equilibrist/ui/update_dialog.py @@ -462,7 +462,7 @@ def _show_manual_install_hint(self, scope: str): "Dans un terminal : {0}{1}" ).format(hint, self._new_appimage)) - def _start_install(self, command: list[str], scope: str, method: str, password: str = ""): + def _start_install(self, command: list[str], scope: str, method: str, stdin_text: str | None = None): self._install_scope = scope self._install_method = method self._install_output = "" @@ -482,11 +482,11 @@ def _start_install(self, command: list[str], scope: str, method: str, password: self._proc.errorOccurred.connect(self._on_install_error) if method == "sudo": # le mot de passe part sur l'entrée de sudo (-S) dès le démarrage, puis l'entrée est fermée - self._proc.started.connect(lambda: self._send_password(password)) + self._proc.started.connect(lambda: self._send_to_stdin(stdin_text or "")) self._proc.start(command[0], command[1:]) - def _send_password(self, password: str): - self._proc.write((password + "\n").encode("utf-8")) + def _send_to_stdin(self, text: str): + self._proc.write((text + "\n").encode("utf-8")) self._proc.closeWriteChannel() def _on_install_output(self): diff --git a/tests/test_update_appimage.py b/tests/test_update_appimage.py index d042735..177c3b3 100644 --- a/tests/test_update_appimage.py +++ b/tests/test_update_appimage.py @@ -185,7 +185,8 @@ def test_build_sudo_install_command_reads_password_on_stdin(tmp_path, monkeypatc monkeypatch.setattr(update.shutil, "which", lambda name: "/usr/bin/" + name) new = tmp_path / "new.AppImage" cmd = update.build_sudo_install_command(new, "ef" * 32) - assert cmd[:6] == ["/usr/bin/sudo", "-S", "-k", "-p", "", "--"] # mot de passe sur l'entrée, jamais en argument + # le mot de passe arrive sur l'entrée de sudo, jamais en argument + assert cmd[:6] == ["/usr/bin/sudo", "-S", "-k", "-p", "", "--"] assert cmd[6:9] == ["/usr/bin/bash", "-c", script.read_text(encoding="utf-8")] assert cmd[9:] == ["install.sh", "--system", "--file", str(new), "--sha256", "ef" * 32]