feat: add feature to update cachyOS in the background

This commit is contained in:
2026-05-05 08:30:33 +02:00
parent 4a0ae14f8b
commit 3ea6ce58b3
15 changed files with 1190 additions and 0 deletions

26
bin/cachyos-updater Executable file
View File

@ -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

15
bin/cachyos-updater-shutdown Executable file
View File

@ -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()

11
bin/cachyos-updater-ui Executable file
View File

@ -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))

View File

@ -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

90
install.sh Executable file
View File

@ -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 ""

22
lib/classifier.py Normal file
View File

@ -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

53
lib/config.py Normal file
View File

@ -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

192
lib/daemon.py Normal file
View File

@ -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()

90
lib/package_manager.py Normal file
View File

@ -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:],
)

237
lib/shutdown_installer.py Normal file
View File

@ -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)

43
lib/state.py Normal file
View File

@ -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}")

344
lib/ui.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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