diff --git a/bin/cachyos-updater b/bin/cachyos-updater new file mode 100755 index 0000000..9ba1e5e --- /dev/null +++ b/bin/cachyos-updater @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Point d'entrée du daemon CachyOS Updater (doit tourner en root).""" + +import asyncio +import logging +import os +import sys + +if os.geteuid() != 0: + print("Erreur : ce daemon doit être lancé en tant que root.", file=sys.stderr) + sys.exit(1) + +sys.path.insert(0, "/usr/lib/cachyos-updater") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + stream=sys.stdout, +) + +from daemon import Daemon + +try: + asyncio.run(Daemon().run()) +except KeyboardInterrupt: + pass diff --git a/bin/cachyos-updater-shutdown b/bin/cachyos-updater-shutdown new file mode 100755 index 0000000..9c15eb6 --- /dev/null +++ b/bin/cachyos-updater-shutdown @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Installateur de mises à jour au moment de l'extinction (doit tourner en root).""" + +import os +import sys + +if os.geteuid() != 0: + print("Erreur : doit être lancé en tant que root.", file=sys.stderr) + sys.exit(1) + +sys.path.insert(0, "/usr/lib/cachyos-updater") + +import shutdown_installer + +shutdown_installer.run() diff --git a/bin/cachyos-updater-ui b/bin/cachyos-updater-ui new file mode 100755 index 0000000..c222dc0 --- /dev/null +++ b/bin/cachyos-updater-ui @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Interface graphique CachyOS Updater.""" + +import sys + +sys.path.insert(0, "/usr/lib/cachyos-updater") + +from ui import UpdaterApp + +app = UpdaterApp() +sys.exit(app.run(sys.argv)) diff --git a/data/cachyos-updater.desktop b/data/cachyos-updater.desktop new file mode 100644 index 0000000..e5a5ecd --- /dev/null +++ b/data/cachyos-updater.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Mises à jour +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 +Terminal=false +Type=Application +Categories=System;PackageManager; +Keywords=mise à jour;update;pacman;système; +StartupNotify=true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..fe97f89 --- /dev/null +++ b/install.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Script d'installation de CachyOS Updater +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[✓]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +error() { echo -e "${RED}[✗]${NC} $*" >&2; } + +if [[ $EUID -ne 0 ]]; then + error "Ce script doit être lancé en tant que root (sudo ./install.sh)" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Vérification des dépendances ─────────────────────────────────────────── +info "Vérification des dépendances…" + +deps=(python python-gobject gtk4 libadwaita pacman-contrib) +missing=() +for dep in "${deps[@]}"; do + if ! pacman -Qi "$dep" &>/dev/null; then + missing+=("$dep") + fi +done + +if [[ ${#missing[@]} -gt 0 ]]; then + warn "Paquets manquants : ${missing[*]}" + info "Installation des dépendances…" + pacman -S --noconfirm --needed "${missing[@]}" +fi + +# ── Installation des fichiers ────────────────────────────────────────────── +info "Installation des fichiers…" + +# Bibliothèques Python +install -d /usr/lib/cachyos-updater +install -m 644 "$SCRIPT_DIR"/lib/*.py /usr/lib/cachyos-updater/ + +# Exécutables +install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater /usr/bin/cachyos-updater +install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater-ui /usr/bin/cachyos-updater-ui +install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater-shutdown /usr/bin/cachyos-updater-shutdown + +# Unités systemd +install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater.service \ + /usr/lib/systemd/system/cachyos-updater.service +install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater.timer \ + /usr/lib/systemd/system/cachyos-updater.timer +install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater-shutdown.service \ + /usr/lib/systemd/system/cachyos-updater-shutdown.service + +# Fichier .desktop +install -m 644 "$SCRIPT_DIR"/data/cachyos-updater.desktop \ + /usr/share/applications/cachyos-updater.desktop + +# Répertoire de données +install -d -m 755 /var/lib/cachyos-updater + +# ── Activation des services ──────────────────────────────────────────────── +info "Configuration des services systemd…" + +systemctl daemon-reload + +# Activer le timer (démarrage automatique au boot) +systemctl enable --now cachyos-updater.timer +info "Timer activé : vérification au démarrage puis toutes les heures" + +# Activer le service de shutdown (installation avant extinction) +systemctl enable cachyos-updater-shutdown.service +info "Service shutdown activé : installation des mises à jour reportées à l'extinction" + +# ── Résumé ───────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}Installation terminée !${NC}" +echo "" +echo " • Les mises à jour sont vérifiées automatiquement toutes les heures" +echo " • Les paquets sûrs sont installés en arrière-plan sans interruption" +echo " • Les mises à jour nécessitant un redémarrage (kernel, systemd…)" +echo " seront installées automatiquement à la prochaine extinction du PC" +echo "" +echo " Interface graphique : cachyos-updater-ui" +echo " Journaux daemon : journalctl -u cachyos-updater -f" +echo " Journaux shutdown : journalctl -u cachyos-updater-shutdown" +echo "" diff --git a/lib/classifier.py b/lib/classifier.py new file mode 100644 index 0000000..b7186cd --- /dev/null +++ b/lib/classifier.py @@ -0,0 +1,22 @@ +import re +from config import PACKAGES_RESTART_REQUIRED, PACKAGES_SERVICE_RESTART, PACKAGES_RESTART_PATTERNS + + +def needs_restart(name: str) -> bool: + if name in PACKAGES_RESTART_REQUIRED: + return True + if name in PACKAGES_SERVICE_RESTART: + return True + return any(re.match(p, name) for p in PACKAGES_RESTART_PATTERNS) + + +def classify(packages) -> tuple[list, list]: + """Retourne (sûrs, reportés). + + sûrs : installables immédiatement, sans aucun redémarrage + reportés: nécessitent un redémarrage service ou système → installation à l'extinction + """ + safe, deferred = [], [] + for pkg in packages: + (deferred if needs_restart(pkg.name) else safe).append(pkg) + return safe, deferred diff --git a/lib/config.py b/lib/config.py new file mode 100644 index 0000000..3baf972 --- /dev/null +++ b/lib/config.py @@ -0,0 +1,53 @@ +from pathlib import Path + +STATE_FILE = Path("/var/lib/cachyos-updater/state.json") +LOG_FILE = Path("/var/log/cachyos-updater.log") +SOCKET_PATH = Path("/run/cachyos-updater.sock") + +# Paquets nécessitant un redémarrage complet du système +PACKAGES_RESTART_REQUIRED = { + "linux", "linux-lts", "linux-zen", "linux-hardened", + "linux-cachyos", "linux-cachyos-lts", "linux-cachyos-rc", + "linux-cachyos-bore", "linux-cachyos-bore-lts", + "systemd", "systemd-libs", "systemd-sysvcompat", "systemd-resolvconf", + "glibc", "lib32-glibc", + "linux-firmware", "linux-firmware-whence", + "mkinitcpio", "mkinitcpio-busybox", + "grub", "efibootmgr", "refind", + "dracut", +} + +# Paquets nécessitant le redémarrage de services (aussi reportés à l'extinction) +PACKAGES_SERVICE_RESTART = { + "pipewire", "pipewire-audio", "pipewire-alsa", "pipewire-jack", "pipewire-pulse", + "wireplumber", "pipewire-wireplumber", + "networkmanager", "network-manager-applet", + "bluez", "bluez-utils", "bluez-libs", + "cups", "cups-filters", + "avahi", + "dbus", "dbus-broker", "dbus-broker-units", + "polkit", + "udisks2", + "upower", + "mesa", "lib32-mesa", + "vulkan-radeon", "vulkan-intel", "vulkan-nouveau", + "lib32-vulkan-radeon", "lib32-vulkan-intel", + "wayland", + "xorg-server", "xorg-server-common", + "gdm", "sddm", "lightdm", + "plymouth", +} + +# Patterns de noms de paquets nécessitant un redémarrage +PACKAGES_RESTART_PATTERNS = [ + r"^linux-[0-9]", + r"^linux-cachyos", + r"^linux-headers", + r"^nvidia", + r"^cuda", + r"^amdgpu-pro", + r"^rocm-", +] + +UPDATE_INTERVAL_SECONDS = 3600 # Vérification toutes les heures +FIRST_RUN_DELAY_SECONDS = 0 # Pas de délai : systemd garantit le réseau via network-online.target diff --git a/lib/daemon.py b/lib/daemon.py new file mode 100644 index 0000000..d0d6b35 --- /dev/null +++ b/lib/daemon.py @@ -0,0 +1,192 @@ +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() + + # Pré-télécharger tous les paquets reportés pendant que le réseau est disponible + 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") + + 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)}") + + 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") + + 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 == "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() diff --git a/lib/package_manager.py b/lib/package_manager.py new file mode 100644 index 0000000..cdbd4d3 --- /dev/null +++ b/lib/package_manager.py @@ -0,0 +1,90 @@ +import subprocess +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +PACMAN_LOCK = Path("/var/lib/pacman/db.lck") + + +@dataclass +class Package: + name: str + old_version: str + new_version: str + + +@dataclass +class InstallResult: + success: bool + installed: list = field(default_factory=list) + failed: list = field(default_factory=list) + error: Optional[str] = None + + +def is_pacman_locked() -> bool: + return PACMAN_LOCK.exists() + + +def check_updates() -> list[Package]: + """Vérifie les mises à jour disponibles via checkupdates (sans droits root).""" + result = subprocess.run( + ["checkupdates"], + capture_output=True, text=True, timeout=120 + ) + packages = [] + for line in result.stdout.strip().splitlines(): + parts = line.split() + # Format attendu : "nom ancienne_version -> nouvelle_version" + if len(parts) >= 4 and parts[2] == "->": + packages.append(Package( + name=parts[0], + old_version=parts[1], + new_version=parts[3], + )) + return packages + + +def download_packages(names: list[str]) -> bool: + """Télécharge les paquets dans le cache sans les installer.""" + if not names: + return True + result = subprocess.run( + ["pacman", "-Syw", "--noconfirm", "--noprogressbar"] + names, + capture_output=True, text=True, timeout=600 + ) + if result.returncode != 0: + logger.error(f"Téléchargement échoué : {result.stderr[-500:]}") + return False + return True + + +def sync_db() -> bool: + """Synchronise la base de données des paquets.""" + result = subprocess.run( + ["pacman", "-Sy", "--noconfirm"], + capture_output=True, text=True, timeout=120 + ) + return result.returncode == 0 + + +def install_packages(names: list[str]) -> InstallResult: + """Installe des paquets spécifiques depuis le cache.""" + if not names: + return InstallResult(success=True) + + result = subprocess.run( + ["pacman", "-S", "--noconfirm", "--needed", "--noprogressbar"] + names, + capture_output=True, text=True, timeout=600 + ) + + if result.returncode == 0: + return InstallResult(success=True, installed=names) + else: + return InstallResult( + success=False, + failed=names, + error=result.stderr[-800:], + ) diff --git a/lib/shutdown_installer.py b/lib/shutdown_installer.py new file mode 100644 index 0000000..beb6fb7 --- /dev/null +++ b/lib/shutdown_installer.py @@ -0,0 +1,237 @@ +"""Installateur exécuté juste avant l'extinction du PC. + +Installe les paquets qui nécessitaient un redémarrage (kernel, systemd, etc.) +puis laisse systemd terminer la séquence d'arrêt normalement. +""" + +import logging +import os +import subprocess +import sys +import threading +import time +from datetime import datetime + +import state as state_mod +from package_manager import install_packages, is_pacman_locked + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [SHUTDOWN] %(message)s", + stream=sys.stdout, +) +logger = logging.getLogger(__name__) + +# ── Écran de progression ──────────────────────────────────────────────────── + +CYAN = "\033[1;36m" +GREEN = "\033[1;32m" +RED = "\033[1;31m" +YELLOW = "\033[1;33m" +RESET = "\033[0m" +BOLD = "\033[1m" +CLEAR = "\033[2J\033[H" # Efface l'écran, curseur en haut à gauche +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" + +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +class ProgressDisplay: + """Affiche un écran de progression pendant l'installation des mises à jour. + + Utilise Plymouth s'il est actif (CachyOS avec splash activé), + sinon prend le contrôle du TTY1 directement. + """ + + def __init__(self, packages: list[dict]): + self._packages = packages + self._active = False + self._status = "Installation en cours…" + self._spin_thread: threading.Thread | None = None + self._tty_fd: int | None = None + self._use_plymouth = self._probe_plymouth() + + def _probe_plymouth(self) -> bool: + try: + r = subprocess.run( + ["plymouth", "--ping"], + capture_output=True, timeout=2 + ) + return r.returncode == 0 + except Exception: + return False + + # ── Interface publique ────────────────────────────────────────────────── + + def start(self): + self._active = True + if self._use_plymouth: + self._plymouth_show() + else: + self._tty_start() + + def update(self, message: str): + self._status = message + if self._use_plymouth: + self._plym_msg(f"⟳ {message}") + + def finish(self, success: bool): + self._active = False + if success: + final = f"✓ Mises à jour installées avec succès" + color = GREEN + else: + final = f"✗ Échec de l'installation — voir les journaux" + color = RED + + if self._use_plymouth: + self._plym_msg(final) + time.sleep(1.5) + else: + if self._spin_thread: + self._spin_thread.join(timeout=1) + self._tty_finish(final, color) + time.sleep(2) + self._tty_close() + + # ── Plymouth ──────────────────────────────────────────────────────────── + + def _plymouth_show(self): + names = ", ".join(p["name"] for p in self._packages[:5]) + extra = f" (+{len(self._packages) - 5})" if len(self._packages) > 5 else "" + self._plym_msg(f"⟳ Installation des mises à jour… {names}{extra}") + + def _plym_msg(self, text: str): + try: + subprocess.run( + ["plymouth", "message", f"--text={text}"], + capture_output=True, timeout=5 + ) + except Exception: + pass + + # ── TTY direct ───────────────────────────────────────────────────────── + + def _tty_start(self): + try: + subprocess.run(["chvt", "1"], capture_output=True, timeout=2) + except Exception: + pass + + for path in ("/dev/tty1", "/dev/console"): + try: + self._tty_fd = os.open(path, os.O_WRONLY | os.O_NOCTTY) + break + except Exception: + continue + + self._tty_write(self._build_screen()) + self._spin_thread = threading.Thread(target=self._spin_loop, daemon=True) + self._spin_thread.start() + + def _build_screen(self) -> str: + n = len(self._packages) + pkg_lines = "" + for pkg in self._packages[:10]: + name = pkg["name"] + old_v = pkg.get("old_version", "") + new_v = pkg.get("new_version", "") + pkg_lines += f" {CYAN}•{RESET} {BOLD}{name:<30}{RESET} {old_v} → {new_v}\n" + if n > 10: + pkg_lines += f" {CYAN}•{RESET} … et {n - 10} autre(s) paquet(s)\n" + + return ( + f"{CLEAR}{HIDE_CURSOR}" + f"\n\n\n" + f" {CYAN}{'─' * 60}{RESET}\n" + f"\n" + f" {BOLD}CachyOS Updater{RESET}\n" + f"\n" + f" Installation de {BOLD}{n} mise{'s' if n > 1 else ''} à jour{RESET} " + f"avant extinction…\n" + f"\n" + f"{pkg_lines}" + f"\n" + f" {YELLOW}Ne pas éteindre ou débrancher votre PC.{RESET}\n" + f"\n" + f" {CYAN}{'─' * 60}{RESET}\n" + f"\n" + f" " # Point de départ du spinner (sera écrasé) + ) + + def _spin_loop(self): + i = 0 + while self._active: + c = SPINNER_FRAMES[i % len(SPINNER_FRAMES)] + self._tty_write(f"\r {CYAN}{c}{RESET} {self._status}\033[K") + i += 1 + time.sleep(0.1) + + def _tty_finish(self, message: str, color: str): + self._tty_write(f"\r {color}{message}{RESET}\033[K\n") + + def _tty_close(self): + if self._tty_fd is not None: + self._tty_write(SHOW_CURSOR) + os.close(self._tty_fd) + self._tty_fd = None + + def _tty_write(self, text: str): + if self._tty_fd is not None: + try: + os.write(self._tty_fd, text.encode("utf-8")) + except Exception: + pass + + +# ── Point d'entrée ────────────────────────────────────────────────────────── + +def run(): + state = state_mod.load() + pending = state.get("pending_restart", []) + + if not pending: + logger.info("Aucune mise à jour en attente — extinction normale") + sys.exit(0) + + names = [p["name"] for p in pending] + logger.info( + f"Installation de {len(names)} paquet(s) avant extinction : {', '.join(names)}" + ) + + if is_pacman_locked(): + logger.error("pacman est verrouillé, impossible d'installer les mises à jour") + sys.exit(1) + + display = ProgressDisplay(pending) + display.start() + + try: + display.update(f"Installation de {len(names)} paquet(s)…") + result = install_packages(names) + + except Exception as e: + logger.exception(f"Erreur inattendue : {e}") + display.finish(success=False) + sys.exit(1) + + if result.success: + logger.info("Mises à jour installées avec succès avant extinction") + now = datetime.now().isoformat() + for pkg in pending: + state["installed_history"].append({**pkg, "installed_at": now}) + state["installed_history"] = state["installed_history"][-200:] + state["pending_restart"] = [] + installed_set = set(names) + state["available_updates"] = [ + u for u in state.get("available_updates", []) + if u["name"] not in installed_set + ] + state_mod.save(state) + display.finish(success=True) + sys.exit(0) + else: + logger.error(f"Échec de l'installation : {result.error}") + display.finish(success=False) + sys.exit(1) diff --git a/lib/state.py b/lib/state.py new file mode 100644 index 0000000..cfd636e --- /dev/null +++ b/lib/state.py @@ -0,0 +1,43 @@ +import json +import logging +import os +from pathlib import Path + +from config import STATE_FILE + +logger = logging.getLogger(__name__) + +_DEFAULTS = { + "status": "idle", + "last_check": None, + "available_updates": [], + "pending_restart": [], + "installed_history": [], + "errors": [], +} + + +def load() -> dict: + if not STATE_FILE.exists(): + return dict(_DEFAULTS) + try: + with open(STATE_FILE) as f: + data = json.load(f) + for k, v in _DEFAULTS.items(): + data.setdefault(k, v) + return data + except Exception as e: + logger.error(f"Impossible de charger l'état : {e}") + return dict(_DEFAULTS) + + +def save(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + try: + tmp = STATE_FILE.with_suffix(".tmp") + with open(tmp, "w") as f: + json.dump(state, f, indent=2, default=str) + tmp.replace(STATE_FILE) + os.chmod(STATE_FILE, 0o644) # Lisible par tous pour l'interface utilisateur + except Exception as e: + logger.error(f"Impossible de sauvegarder l'état : {e}") diff --git a/lib/ui.py b/lib/ui.py new file mode 100644 index 0000000..3c1ab96 --- /dev/null +++ b/lib/ui.py @@ -0,0 +1,344 @@ +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gio, GLib, Gtk + +import json +import socket as sock_module +import threading +from datetime import datetime +from pathlib import Path + +from config import SOCKET_PATH, STATE_FILE + + +class UpdaterWindow(Adw.ApplicationWindow): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_title("Mises à jour") + self.set_default_size(500, -1) + self.set_resizable(True) + + self._pending_rows: list[Adw.ActionRow] = [] + self._recent_rows: list[Adw.ActionRow] = [] + + self._build_ui() + self._setup_file_monitor() + self._load_state() + + # ─── Construction de l'interface ──────────────────────────────────────── + + def _build_ui(self): + self.toast_overlay = Adw.ToastOverlay() + + toolbar_view = Adw.ToolbarView() + + header = Adw.HeaderBar() + self.refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic") + self.refresh_btn.set_tooltip_text("Vérifier maintenant") + self.refresh_btn.connect("clicked", self._on_refresh_clicked) + header.pack_end(self.refresh_btn) + toolbar_view.add_top_bar(header) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_propagate_natural_height(True) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + main_box.set_margin_top(20) + main_box.set_margin_bottom(28) + main_box.set_margin_start(20) + main_box.set_margin_end(20) + + # Carte de statut principal + self._build_status_card(main_box) + + # Groupe "En attente du redémarrage" + self.pending_group = Adw.PreferencesGroup() + self.pending_group.set_title("En attente du redémarrage") + self.pending_group.set_description( + "Ces paquets seront installés automatiquement à la prochaine extinction du PC" + ) + main_box.append(self.pending_group) + + # Groupe "Récemment installées" + self.recent_group = Adw.PreferencesGroup() + self.recent_group.set_title("Récemment installées") + main_box.append(self.recent_group) + + scroll.set_child(main_box) + toolbar_view.set_content(scroll) + self.toast_overlay.set_child(toolbar_view) + self.set_content(self.toast_overlay) + + def _build_status_card(self, parent: Gtk.Box): + card = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + card.add_css_class("card") + + # Côté gauche : icône ou spinner + left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + left.set_valign(Gtk.Align.CENTER) + left.set_margin_start(20) + left.set_margin_end(16) + left.set_margin_top(20) + left.set_margin_bottom(20) + + self.status_icon = Gtk.Image() + self.status_icon.set_pixel_size(40) + self.status_icon.set_valign(Gtk.Align.CENTER) + + self.status_spinner = Gtk.Spinner() + self.status_spinner.set_size_request(40, 40) + self.status_spinner.set_valign(Gtk.Align.CENTER) + self.status_spinner.set_visible(False) + + left.append(self.status_icon) + left.append(self.status_spinner) + + # Côté droit : texte + text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + text_box.set_valign(Gtk.Align.CENTER) + text_box.set_hexpand(True) + text_box.set_margin_end(20) + text_box.set_margin_top(18) + text_box.set_margin_bottom(18) + + self.status_title_lbl = Gtk.Label() + self.status_title_lbl.set_halign(Gtk.Align.START) + self.status_title_lbl.add_css_class("title-3") + self.status_title_lbl.set_wrap(True) + + self.status_sub_lbl = Gtk.Label() + self.status_sub_lbl.set_halign(Gtk.Align.START) + self.status_sub_lbl.add_css_class("caption") + self.status_sub_lbl.add_css_class("dim-label") + self.status_sub_lbl.set_wrap(True) + + text_box.append(self.status_title_lbl) + text_box.append(self.status_sub_lbl) + + card.append(left) + card.append(text_box) + parent.append(card) + + # ─── Surveillance du fichier d'état ──────────────────────────────────── + + def _setup_file_monitor(self): + gfile = Gio.File.new_for_path(str(STATE_FILE)) + try: + self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None) + self._monitor.connect("changed", self._on_file_changed) + except Exception: + GLib.timeout_add_seconds(5, self._poll_state) + + def _on_file_changed(self, _monitor, _file, _other, event): + if event in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CREATED): + GLib.timeout_add(300, self._load_state) + + def _poll_state(self) -> bool: + self._load_state() + return True + + def _load_state(self) -> bool: + try: + with open(STATE_FILE) as f: + state = json.load(f) + self._update_ui(state) + except (FileNotFoundError, json.JSONDecodeError): + self._show_service_unavailable() + return False # Ne pas répéter si appelé via timeout_add + + # ─── Mise à jour de l'interface ──────────────────────────────────────── + + def _show_service_unavailable(self): + self._set_icon("network-offline-symbolic") + self.status_title_lbl.set_text("Service non disponible") + self.status_sub_lbl.set_text( + "Démarrez le service : systemctl start cachyos-updater" + ) + self.pending_group.set_visible(False) + self.recent_group.set_visible(False) + self.refresh_btn.set_sensitive(False) + + def _set_icon(self, icon_name: str, color_class: str = ""): + self.status_spinner.stop() + self.status_spinner.set_visible(False) + self.status_icon.set_visible(True) + self.status_icon.set_from_icon_name(icon_name) + for cls in ("success", "warning", "error", "accent", "dim-label"): + self.status_icon.remove_css_class(cls) + if color_class: + self.status_icon.add_css_class(color_class) + + def _set_spinner(self): + self.status_icon.set_visible(False) + self.status_spinner.set_visible(True) + self.status_spinner.start() + + def _update_ui(self, state: dict): + status = state.get("status", "idle") + pending = state.get("pending_restart", []) + history = state.get("installed_history", []) + last_check = state.get("last_check") + errors = state.get("errors", []) + + # ── Carte de statut ────────────────────────────────────────────── + if status == "checking": + self._set_spinner() + self.status_title_lbl.set_text("Vérification des mises à jour…") + self.status_sub_lbl.set_text("") + self.refresh_btn.set_sensitive(False) + + elif status == "downloading": + self._set_spinner() + self.status_title_lbl.set_text("Téléchargement des mises à jour…") + self.status_sub_lbl.set_text("Installation dans quelques instants") + self.refresh_btn.set_sensitive(False) + + elif status == "installing": + self._set_spinner() + self.status_title_lbl.set_text("Installation en cours…") + self.status_sub_lbl.set_text("Ne pas éteindre le PC") + self.refresh_btn.set_sensitive(False) + + elif status == "error": + self._set_icon("dialog-error-symbolic", "error") + self.status_title_lbl.set_text("Erreur lors de la mise à jour") + self.status_sub_lbl.set_text( + errors[-1]["message"] if errors else "Erreur inconnue" + ) + self.refresh_btn.set_sensitive(True) + + else: # idle + self.refresh_btn.set_sensitive(True) + if pending: + n = len(pending) + self._set_icon("software-update-available-symbolic", "warning") + self.status_title_lbl.set_text( + f"{n} mise{'s' if n > 1 else ''} à jour " + f"en attente de redémarrage" + ) + self.status_sub_lbl.set_text( + "Seront installées automatiquement à la prochaine extinction" + ) + else: + self._set_icon("emblem-ok-symbolic", "success") + self.status_title_lbl.set_text("Système à jour") + sub = "" + if last_check: + try: + dt = datetime.fromisoformat(last_check) + sub = f"Dernière vérification : {self._relative_time(dt)}" + except Exception: + pass + self.status_sub_lbl.set_text(sub) + + # ── Liste des paquets en attente de redémarrage ────────────────── + for row in self._pending_rows: + self.pending_group.remove(row) + self._pending_rows.clear() + + for pkg in pending: + row = Adw.ActionRow() + row.set_title(GLib.markup_escape_text(pkg.get("name", ""))) + row.set_subtitle( + f"{pkg.get('old_version', '')} → {pkg.get('new_version', '')}" + ) + icon = Gtk.Image.new_from_icon_name("system-restart-symbolic") + icon.add_css_class("warning") + row.add_prefix(icon) + self.pending_group.add(row) + self._pending_rows.append(row) + + self.pending_group.set_visible(bool(pending)) + + # ── Historique des installations récentes ──────────────────────── + for row in self._recent_rows: + self.recent_group.remove(row) + self._recent_rows.clear() + + recent = list(reversed(history[-30:])) + for pkg in recent: + row = Adw.ActionRow() + row.set_title(GLib.markup_escape_text(pkg.get("name", ""))) + subtitle = ( + f"{pkg.get('old_version', '')} → {pkg.get('new_version', '')}" + ) + installed_at = pkg.get("installed_at", "") + if installed_at: + try: + dt = datetime.fromisoformat(installed_at) + subtitle += f" • {self._relative_time(dt)}" + except Exception: + pass + row.set_subtitle(subtitle) + icon = Gtk.Image.new_from_icon_name("emblem-ok-symbolic") + icon.add_css_class("success") + row.add_prefix(icon) + self.recent_group.add(row) + self._recent_rows.append(row) + + self.recent_group.set_visible(bool(recent)) + + # ─── Utilitaires ─────────────────────────────────────────────────────── + + def _relative_time(self, dt: datetime) -> str: + now = datetime.now() + secs = (now - dt).total_seconds() + if secs < 60: + return "à l'instant" + if secs < 3600: + m = int(secs // 60) + return f"il y a {m} min" + if secs < 86400: + h = int(secs // 3600) + return f"il y a {h}h" + days = int(secs // 86400) + if days == 1: + return f"hier à {dt.strftime('%H:%M')}" + if days < 7: + return f"il y a {days} jours" + return dt.strftime("%d/%m/%Y") + + # ─── Actions utilisateur ─────────────────────────────────────────────── + + def _on_refresh_clicked(self, _btn): + self.refresh_btn.set_sensitive(False) + threading.Thread(target=self._send_check_now, daemon=True).start() + + def _send_check_now(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": "check_now"}).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.refresh_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.refresh_btn.set_sensitive, True) + + def _show_toast(self, message: str) -> bool: + toast = Adw.Toast.new(message) + toast.set_timeout(4) + self.toast_overlay.add_toast(toast) + return False + + +class UpdaterApp(Adw.Application): + def __init__(self): + super().__init__(application_id="org.cachyos.Updater") + self.connect("activate", self._on_activate) + + def _on_activate(self, app): + win = UpdaterWindow(application=app) + win.present() diff --git a/systemd/cachyos-updater-shutdown.service b/systemd/cachyos-updater-shutdown.service new file mode 100644 index 0000000..1c7d7d8 --- /dev/null +++ b/systemd/cachyos-updater-shutdown.service @@ -0,0 +1,19 @@ +[Unit] +Description=CachyOS Updater - Installation des mises à jour reportées avant extinction +DefaultDependencies=no +Before=poweroff.target halt.target reboot.target shutdown.target +# Après Plymouth pour pouvoir lui envoyer des messages +After=local-fs.target plymouth-start.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/cachyos-updater-shutdown +# Jusqu'à 10 minutes pour installer les mises à jour (kernel, systemd...) +TimeoutStartSec=600 +RemainAfterExit=no +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cachyos-updater-shutdown + +[Install] +WantedBy=poweroff.target halt.target reboot.target diff --git a/systemd/cachyos-updater.service b/systemd/cachyos-updater.service new file mode 100644 index 0000000..205ae22 --- /dev/null +++ b/systemd/cachyos-updater.service @@ -0,0 +1,20 @@ +[Unit] +Description=CachyOS Updater - Daemon de mises à jour automatiques +Documentation=https://github.com/cachyos/cachyos-updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/cachyos-updater +Restart=on-failure +RestartSec=30 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cachyos-updater + +# Sécurité : restreindre les accès inutiles +ProtectHome=read-only +ProtectHostname=yes +PrivateTmp=yes +NoNewPrivileges=no diff --git a/systemd/cachyos-updater.timer b/systemd/cachyos-updater.timer new file mode 100644 index 0000000..b810711 --- /dev/null +++ b/systemd/cachyos-updater.timer @@ -0,0 +1,16 @@ +[Unit] +Description=CachyOS Updater - Vérification périodique des mises à jour +Requires=cachyos-updater.service +After=network-online.target +Wants=network-online.target + +[Timer] +# Lancer dès que le réseau est disponible au démarrage +OnBootSec=30s +# Puis toutes les heures après la dernière exécution +OnUnitInactiveSec=1h +Unit=cachyos-updater.service +Persistent=true + +[Install] +WantedBy=timers.target