diff --git a/data/cachyos-updater.desktop b/data/cachyos-updater.desktop index e5a5ecd..03ab7ed 100644 --- a/data/cachyos-updater.desktop +++ b/data/cachyos-updater.desktop @@ -4,7 +4,7 @@ Name[en]=System Updates Comment=Gérer les mises à jour du système CachyOS Comment[en]=Manage CachyOS system updates Exec=cachyos-updater-ui -Icon=software-update-available +Icon=cachyos-updater Terminal=false Type=Application Categories=System;PackageManager; diff --git a/data/cachyos-updater.svg b/data/cachyos-updater.svg new file mode 100644 index 0000000..c225e5f --- /dev/null +++ b/data/cachyos-updater.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/install.sh b/install.sh index fe97f89..98272a2 100755 --- a/install.sh +++ b/install.sh @@ -55,6 +55,12 @@ install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater.timer \ install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater-shutdown.service \ /usr/lib/systemd/system/cachyos-updater-shutdown.service +# Icône +install -d /usr/share/icons/hicolor/scalable/apps +install -m 644 "$SCRIPT_DIR"/data/cachyos-updater.svg \ + /usr/share/icons/hicolor/scalable/apps/cachyos-updater.svg +gtk-update-icon-cache -f -t /usr/share/icons/hicolor &>/dev/null || true + # Fichier .desktop install -m 644 "$SCRIPT_DIR"/data/cachyos-updater.desktop \ /usr/share/applications/cachyos-updater.desktop diff --git a/lib/daemon.py b/lib/daemon.py index d0d6b35..434bc24 100644 --- a/lib/daemon.py +++ b/lib/daemon.py @@ -72,30 +72,26 @@ class Daemon: existing.add(pkg.name) self._save() - # Pré-télécharger tous les paquets reportés pendant que le réseau est disponible + safe_names = [p.name for p in safe] pending_names = [p["name"] for p in self._state["pending_restart"]] - if pending_names: - logger.info(f"Pré-téléchargement des paquets reportés : {', '.join(pending_names)}") - ok = await asyncio.to_thread(download_packages, pending_names) - if not ok: - logger.warning("Pré-téléchargement partiel — une nouvelle tentative aura lieu au prochain cycle") + + # Télécharger safe + reportés en une seule transaction pour que pacman + # puisse résoudre toutes les dépendances inter-paquets correctement. + all_names = list(dict.fromkeys(safe_names + pending_names)) + self._set_status("downloading") + logger.info(f"Téléchargement ({len(all_names)} paquets) : {', '.join(all_names)}") + + ok = await asyncio.to_thread(download_packages, all_names) + if not ok: + self._add_error("Échec du téléchargement des paquets") + self._set_status("error") + return if not safe: logger.info("Aucun paquet à installer immédiatement") self._set_status("idle") return - # Synchroniser la DB et télécharger - self._set_status("downloading") - safe_names = [p.name for p in safe] - logger.info(f"Téléchargement : {', '.join(safe_names)}") - - ok = await asyncio.to_thread(download_packages, safe_names) - if not ok: - self._add_error("Échec du téléchargement des paquets") - self._set_status("error") - return - # Installer les paquets sûrs self._set_status("installing") logger.info(f"Installation : {', '.join(safe_names)}") @@ -129,6 +125,51 @@ class Daemon: self._add_error(str(e)) self._set_status("error") + async def install_pending_cycle(self): + if self._state["status"] not in ("idle", "error"): + logger.info("Une opération est déjà en cours, installation ignorée") + return + + pending = self._state.get("pending_restart", []) + if not pending: + logger.info("Aucun paquet en attente à installer") + return + + if is_pacman_locked(): + logger.info("pacman est verrouillé, impossible d'installer maintenant") + self._add_error("pacman est verrouillé, réessayez dans quelques instants") + return + + names = [p["name"] for p in pending] + try: + self._set_status("installing") + logger.info(f"Installation manuelle : {', '.join(names)}") + + result = await asyncio.to_thread(install_packages, names) + + if result.success: + logger.info("Installation manuelle réussie") + now = datetime.now().isoformat() + for pkg in pending: + self._state["installed_history"].append({**pkg, "installed_at": now}) + self._state["installed_history"] = self._state["installed_history"][-200:] + self._state["pending_restart"] = [] + installed_set = set(names) + self._state["available_updates"] = [ + u for u in self._state["available_updates"] + if u["name"] not in installed_set + ] + else: + logger.error(f"Installation manuelle échouée : {result.error}") + self._add_error(f"Échec de l'installation : {result.error}") + + self._set_status("idle") + + except Exception as e: + logger.exception(f"Erreur lors de l'installation manuelle : {e}") + self._add_error(str(e)) + self._set_status("error") + def _add_error(self, message: str): self._state["errors"].append({ "time": datetime.now().isoformat(), @@ -146,6 +187,9 @@ class Daemon: if action == "check_now": asyncio.create_task(self.update_cycle()) response = {"status": "ok", "message": "Vérification lancée"} + elif action == "install_pending": + asyncio.create_task(self.install_pending_cycle()) + response = {"status": "ok", "message": "Installation lancée"} elif action == "get_state": response = {"status": "ok", "state": self._state} else: diff --git a/lib/package_manager.py b/lib/package_manager.py index cdbd4d3..82d12b6 100644 --- a/lib/package_manager.py +++ b/lib/package_manager.py @@ -88,3 +88,28 @@ def install_packages(names: list[str]) -> InstallResult: failed=names, error=result.stderr[-800:], ) + + +def upgrade_cached_packages(expected_names: list[str]) -> InstallResult: + """Installe toutes les mises à jour présentes dans le cache pacman. + + Utilisé à l'extinction pour éviter les échecs de dépendances liés aux + mises à jour partielles : pacman -Su installe l'ensemble cohérent des + paquets déjà téléchargés plutôt qu'une liste isolée. + """ + if not expected_names: + return InstallResult(success=True) + + result = subprocess.run( + ["pacman", "-Su", "--noconfirm", "--noprogressbar"], + capture_output=True, text=True, timeout=600 + ) + + if result.returncode == 0: + return InstallResult(success=True, installed=expected_names) + else: + return InstallResult( + success=False, + failed=expected_names, + error=result.stderr[-800:], + ) diff --git a/lib/shutdown_installer.py b/lib/shutdown_installer.py index beb6fb7..7202907 100644 --- a/lib/shutdown_installer.py +++ b/lib/shutdown_installer.py @@ -13,7 +13,7 @@ import time from datetime import datetime import state as state_mod -from package_manager import install_packages, is_pacman_locked +from package_manager import upgrade_cached_packages, is_pacman_locked logging.basicConfig( level=logging.INFO, @@ -209,7 +209,7 @@ def run(): try: display.update(f"Installation de {len(names)} paquet(s)…") - result = install_packages(names) + result = upgrade_cached_packages(names) except Exception as e: logger.exception(f"Erreur inattendue : {e}") diff --git a/lib/ui.py b/lib/ui.py index 3c1ab96..4716c6e 100644 --- a/lib/ui.py +++ b/lib/ui.py @@ -59,6 +59,13 @@ class UpdaterWindow(Adw.ApplicationWindow): self.pending_group.set_description( "Ces paquets seront installés automatiquement à la prochaine extinction du PC" ) + + self.install_now_btn = Gtk.Button.new_with_label("Installer maintenant") + self.install_now_btn.add_css_class("suggested-action") + self.install_now_btn.add_css_class("pill") + self.install_now_btn.connect("clicked", self._on_install_pending_clicked) + self.pending_group.set_header_suffix(self.install_now_btn) + main_box.append(self.pending_group) # Groupe "Récemment installées" @@ -233,6 +240,10 @@ class UpdaterWindow(Adw.ApplicationWindow): pass self.status_sub_lbl.set_text(sub) + # ── Bouton "Installer maintenant" ──────────────────────────────── + can_install = status in ("idle", "error") and bool(pending) + self.install_now_btn.set_sensitive(can_install) + # ── Liste des paquets en attente de redémarrage ────────────────── for row in self._pending_rows: self.pending_group.remove(row) @@ -306,6 +317,31 @@ class UpdaterWindow(Adw.ApplicationWindow): self.refresh_btn.set_sensitive(False) threading.Thread(target=self._send_check_now, daemon=True).start() + def _on_install_pending_clicked(self, _btn): + self.install_now_btn.set_sensitive(False) + threading.Thread(target=self._send_install_pending, daemon=True).start() + + def _send_install_pending(self): + try: + s = sock_module.socket(sock_module.AF_UNIX, sock_module.SOCK_STREAM) + s.settimeout(5) + s.connect(str(SOCKET_PATH)) + s.sendall(json.dumps({"action": "install_pending"}).encode()) + s.recv(1024) + s.close() + except PermissionError: + GLib.idle_add( + self._show_toast, + "Permission refusée — êtes-vous dans le groupe wheel ?", + ) + GLib.idle_add(self.install_now_btn.set_sensitive, True) + except Exception as e: + GLib.idle_add( + self._show_toast, + f"Impossible de contacter le service : {e}", + ) + GLib.idle_add(self.install_now_btn.set_sensitive, True) + def _send_check_now(self): try: s = sock_module.socket(sock_module.AF_UNIX, sock_module.SOCK_STREAM)