import asyncio import json import logging import os from datetime import datetime import state as state_mod from classifier import classify from config import FIRST_RUN_DELAY_SECONDS, SOCKET_PATH, UPDATE_INTERVAL_SECONDS from package_manager import ( check_updates, download_packages, install_packages, is_pacman_locked, sync_db, ) logger = logging.getLogger(__name__) class Daemon: def __init__(self): self._state = state_mod.load() self._state["status"] = "idle" self._running = True def _save(self): state_mod.save(self._state) def _set_status(self, status: str): self._state["status"] = status self._save() async def update_cycle(self): if self._state["status"] not in ("idle", "error"): logger.info("Mise à jour déjà en cours, cycle ignoré") return if is_pacman_locked(): logger.info("pacman est verrouillé (autre processus actif), cycle ignoré") return try: self._set_status("checking") logger.info("Vérification des mises à jour disponibles…") packages = await asyncio.to_thread(check_updates) self._state["last_check"] = datetime.now().isoformat() if not packages: logger.info("Aucune mise à jour disponible") self._state["available_updates"] = [] self._set_status("idle") return logger.info(f"{len(packages)} mise(s) à jour trouvée(s)") self._state["available_updates"] = [ {"name": p.name, "old_version": p.old_version, "new_version": p.new_version} for p in packages ] safe, deferred = classify(packages) logger.info(f" → {len(safe)} immédiate(s), {len(deferred)} reportée(s)") # Ajouter les paquets reportés à la liste d'attente (sans doublons) existing = {p["name"] for p in self._state["pending_restart"]} for pkg in deferred: if pkg.name not in existing: self._state["pending_restart"].append({ "name": pkg.name, "old_version": pkg.old_version, "new_version": pkg.new_version, "queued_at": datetime.now().isoformat(), }) existing.add(pkg.name) self._save() safe_names = [p.name for p in safe] pending_names = [p["name"] for p in self._state["pending_restart"]] # 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 # Installer les paquets sûrs self._set_status("installing") logger.info(f"Installation : {', '.join(safe_names)}") result = await asyncio.to_thread(install_packages, safe_names) if result.success: logger.info("Installation réussie") now = datetime.now().isoformat() for pkg in safe: self._state["installed_history"].append({ "name": pkg.name, "old_version": pkg.old_version, "new_version": pkg.new_version, "installed_at": now, }) self._state["installed_history"] = self._state["installed_history"][-200:] installed_set = set(result.installed) self._state["available_updates"] = [ u for u in self._state["available_updates"] if u["name"] not in installed_set ] else: logger.error(f"Installation é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"Cycle de mise à jour interrompu : {e}") 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(), "message": message, }) self._state["errors"] = self._state["errors"][-20:] self._save() async def _handle_client(self, reader, writer): try: data = await asyncio.wait_for(reader.read(4096), timeout=5) cmd = json.loads(data.decode()) action = cmd.get("action") 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: response = {"status": "error", "message": "Commande inconnue"} writer.write(json.dumps(response, default=str).encode()) await writer.drain() except Exception as e: logger.debug(f"Erreur client : {e}") finally: writer.close() try: await writer.wait_closed() except Exception: pass async def run(self): if SOCKET_PATH.exists(): SOCKET_PATH.unlink() SOCKET_PATH.parent.mkdir(parents=True, exist_ok=True) server = await asyncio.start_unix_server( self._handle_client, path=str(SOCKET_PATH) ) # Permettre aux membres du groupe wheel de se connecter os.chmod(SOCKET_PATH, 0o660) try: import grp gid = grp.getgrnam("wheel").gr_gid os.chown(SOCKET_PATH, 0, gid) except Exception: os.chmod(SOCKET_PATH, 0o666) logger.info("Daemon CachyOS Updater démarré") self._save() async with server: await asyncio.sleep(FIRST_RUN_DELAY_SECONDS) await self.update_cycle() while self._running: await asyncio.sleep(UPDATE_INTERVAL_SECONDS) await self.update_cycle()