#!/usr/bin/env bash
# =============================================================================
# affutos — capture du profil, liste de paquets et clé USB d'AffûtOS
#
#   affutos capture                 archive des DONNÉES du profil + mise à jour
#                                   de paquets.txt
#   affutos envoi [archive]         téléverse la dernière capture sous son nom
#                                   daté, EN DELTA contre la précédente (rsync
#                                   --fuzzy + zstd --rsyncable), reprise après
#                                   coupure ; destination lue dans
#                                   ~/.config/affutos/envoi.dest
#   affutos diff-paquets            met à jour paquets.txt, liste de référence
#                                   CUMULATIVE d'AffûtOS (jamais raccourcie sans
#                                   confirmation explicite)
#   affutos usb /dev/sdX [--dev]    clé USB netboot minimale : le système est
#                                   téléchargé en RAM depuis le serveur au boot.
#
# L'installation et la restauration d'une capture ne sont pas ici : Calamares
# les joue (drop-ins de /etc/affutos/postinstall.d/, page « Restauration »,
# job affutos_profil).
# Ce script est embarqué dans l'ISO publique : aucun hôte, port ni chemin privé
# du serveur ici, ils sont lus dans ~/.config/affutos/.
# =============================================================================
# Pas de « set -e » : chaque étape teste elle-même son code de retour.
set -u
# Compte visé : celui qui a lancé sudo, sinon le compte courant. Jamais root :
# chaque sous-commande demande sudo elle-même au besoin.
U="${SUDO_USER:-$(id -un)}"
[ "$U" != root ] || { echo "ERREUR : lancer « affutos » sous son compte (il demande sudo au besoin)" >&2; exit 1; }
warn() { echo "AVERTISSEMENT : $*" >&2; }
# Ajoute un chemin à la capture sous un AUTRE nom dans l'archive : règle
# --transform de tar, ajoutée à TRANSFO (locales H, CHEMINS, TRANSFO de
# cmd_capture). « \(/\?\) » : la règle vaut aussi pour la cible d'un lien
# symbolique absolu, qui suit ainsi le fichier qu'il désigne.
ranger() {   # $1 = chemin capturé (absolu), $2 = son nom sous le home dans l'archive
    local de a
    de=$(printf '%s' "${1#/}" | sed 's/[][\\.*^$,]/\\&/g')
    a=$(printf '%s' "${H#/}/$2" | sed 's/[\\&,]/\\&/g')
    CHEMINS+=("$1")
    TRANSFO+=("--transform=s,^\\(/\\?\\)$de\\(/\\|\$\\),\\1$a\\2,")
}
# Firefox : seule la connexion au compte part, Firefox Sync rend le reste.
# Pour chaque profil connecté, on écrit dans $1 (arborescence home/<compte>/…,
# prête pour tar) :
#   - signedInUser.json : identité, jeton de session, appareil ;
#   - logins.json réduit à l'entrée « chrome://FirefoxAccounts » (clés de Sync
#     chiffrées) : la première synchronisation rend les mots de passe ;
#   - key4.db, qui déchiffre cette entrée ;
#   - de prefs.js, la seule services.sync.username, qui ALLUME Sync ; pas les
#     dates de synchronisation, qui feraient croire le Firefox restauré à jour ;
#   - profiles.ini et installs.ini réduits aux profils gardés.
# Un profil sans compte ne part pas : il est nommé, pour que la perte se voie.
# On lit la racine que Firefox utilise (~/.mozilla/firefox, sinon le dossier
# XDG ~/.config/mozilla/firefox) et on écrit TOUJOURS dans le dossier XDG, où
# le skel d'AffûtOS amorce le profil : deux racines côte à côte feraient
# choisir la mauvaise à Firefox et perdre des profils à profile-sync-daemon.
firefox_compte() {   # $1 = dossier de préparation
    python3 - "/home/$U" "$1" <<'PY'
import configparser, json, os, re, shutil, sys
home, etape = sys.argv[1], sys.argv[2]
os.umask(0o077)   # jeton de session et clés : dossiers en 700, fichiers en 600

def ini(chemin):
    c = configparser.RawConfigParser()
    c.optionxform = str
    c.read(chemin)
    return c

def ecrire(chemin, contenu):
    with open(os.open(chemin, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as f:
        f.write(contenu)

def lire_json(chemin):
    try:
        with open(chemin) as f:
            return json.load(f)
    except (OSError, ValueError):
        return None

racine = ".config/mozilla/firefox"
# La première racine qui existe, et elle seule : celle que Firefox utilise.
for src in [d for d in (os.path.join(home, ".mozilla/firefox"), os.path.join(home, racine))
            if os.path.isdir(d)][:1]:
    profils = ini(os.path.join(src, "profiles.ini"))
    gardes = {}
    for s in profils.sections():
        if not s.startswith("Profile"):
            continue
        chemin = profils.get(s, "Path", fallback="")
        nom = profils.get(s, "Name", fallback=chemin)
        de = chemin if profils.get(s, "IsRelative", fallback="1") != "1" else os.path.join(src, chemin)
        # Déconnecté, Firefox ne supprime pas signedInUser.json : il y écrit
        # « null ». Seul un compte réel compte.
        compte = lire_json(os.path.join(de, "signedInUser.json"))
        logins = lire_json(os.path.join(de, "logins.json"))
        if not (isinstance(compte, dict) and compte.get("accountData")):
            print("   ⚠ firefox : profil « %s » connecté à aucun compte — NON sauvegardé" % nom)
            continue
        if not (isinstance(logins, dict) and os.path.isfile(os.path.join(de, "key4.db"))):
            print("   ⚠ firefox : profil « %s » sans logins.json ou key4.db — NON sauvegardé" % nom)
            continue
        if not chemin or os.path.isabs(chemin) or chemin in gardes:
            print("   ⚠ firefox : profil « %s » hors de %s — NON sauvegardé" % (nom, src))
            continue
        vers = os.path.join(etape, home.lstrip("/"), racine, chemin)
        os.makedirs(vers)
        ecrire(os.path.join(vers, "signedInUser.json"), json.dumps(compte))
        fxa = [x for x in logins.get("logins", []) if x.get("hostname") == "chrome://FirefoxAccounts"]
        if not fxa:
            print("   ⚠ firefox : profil « %s » sans clé Sync dans logins.json, il faudra se reconnecter" % nom)
        ecrire(os.path.join(vers, "logins.json"), json.dumps({
            "nextId": max(x["id"] for x in fxa) + 1 if fxa else 1, "logins": fxa,
            "potentiallyVulnerablePasswords": [], "dismissedBreachAlertsByLoginGUID": {},
            "version": logins.get("version", 3)}))
        shutil.copyfile(os.path.join(de, "key4.db"), os.path.join(vers, "key4.db"))
        try:
            with open(os.path.join(de, "prefs.js"), encoding="utf-8") as f:
                sync = [l for l in f if re.match(r'user_pref\("services\.sync\.username",', l)]
        except OSError:
            sync = []
        if sync:
            ecrire(os.path.join(vers, "prefs.js"), sync[0])
        gardes[chemin] = nom
        print("firefox : connexion au compte du profil « %s » (%s)%s"
              % (nom, src, "" if sync else " — Sync n'y était pas activée"))
    if not gardes:
        continue
    dst = os.path.join(etape, home.lstrip("/"), racine)
    # Les deux fichiers d'amorçage, réduits aux profils gardés. Une section
    # d'installation (profiles.ini [InstallXXX], installs.ini [XXX]) ne reste
    # que si son profil par défaut est gardé.
    sortie = configparser.RawConfigParser()
    sortie.optionxform = str
    sortie["General"] = {"StartWithLastProfile": "1", "Version": "2"}
    for i, (chemin, nom) in enumerate(gardes.items()):
        sortie["Profile%d" % i] = {"Name": nom, "IsRelative": "1", "Path": chemin}
    installs = ini(os.path.join(src, "installs.ini"))
    reduit = configparser.RawConfigParser()
    reduit.optionxform = str
    for c, prefixe in ((profils, "Install"), (installs, "")):
        for s in c.sections():
            if s.startswith(prefixe) and c.get(s, "Default", fallback="") in gardes:
                (sortie if c is profils else reduit)[s] = dict(c[s])
    # Un profil gardé est TOUJOURS le profil par défaut. Si celui de chaque
    # installation n'était pas connecté (donc pas gardé), ses sections sont
    # reprises en désignant le premier profil gardé : sans défaut, Firefox
    # créerait un profil neuf en pleine session — que profile-sync-daemon
    # déplacerait ensuite sous un Firefox en marche (revue du 26/09/2026).
    premier = next(iter(gardes))
    sortie["Profile0"]["Default"] = "1"
    if not reduit.sections():
        for c, prefixe in ((profils, "Install"), (installs, "")):
            for s in c.sections():
                if s.startswith(prefixe):
                    (sortie if c is profils else reduit)[s] = dict(c[s], Default=premier)
    for nom, contenu in (("profiles.ini", sortie), ("installs.ini", reduit)):
        with open(os.path.join(dst, nom), "w") as f:
            contenu.write(f, space_around_delimiters=False)
PY
}

cmd_capture() {
    local DEST="/home/$U/Documents/saves/userprofile"
    mkdir -p "$DEST" || { echo "ERREUR : $DEST non créable" >&2; return 1; }
    # Horodatage à la seconde et refus d'écraser : deux captures rapprochées ne
    # peuvent pas viser la même archive.
    local ARCHIVE="$DEST/save_${HOSTNAME}_${U}_$(date +%Y%m%d_%H%M%S).tar.zst"
    [ -e "$ARCHIVE" ] && { echo "ERREUR : $ARCHIVE existe déjà — capture annulée, rien n'a été touché." >&2; return 1; }
    # DONNÉES seulement, pas de configuration : les réglages reviennent par
    # l'installateur et les paquets ; les sessions et jetons (discord, ZapZap,
    # spicetify, obs-studio) ne passent pas d'une machine à l'autre.
    # Les dossiers XDG sont rangés sous leur CLÉ, .affutos-xdg/<CLÉ>, pas sous
    # leur nom : le job affutos_profil les verse dans le dossier de même clé de
    # la machine installée, dont le bureau est le home. Un dossier XDG égal au
    # home ne part pas, sauf le bureau (voir DESKTOP).
    # Documents part entrée par entrée, sans program (ROM et émulateurs, rendus
    # par les paquets), ni Claude (vit sur le serveur), ni les FIXES, qui
    # repartent sous leur propre chemin. Pas de « tar --exclude » pour program :
    # une exclusion vaut aussi pour un chemin passé en argument, elle écarterait
    # les sauvegardes Cemu reprises plus bas.
    local H="/home/$U" CHEMINS=() TRANSFO=() p rc cle dossier vus=" " bureau_home=
    local FIXES=(Documents/homebrew Documents/script Documents/saves)
    local EXCLUS=("${FIXES[@]}" Documents/program Documents/Claude) x
    command -v xdg-user-dir >/dev/null \
        || { echo "ERREUR : xdg-user-dir introuvable (paquet xdg-user-dirs) — dossiers du profil inconnus, rien n'est archivé." >&2; return 1; }
    shopt -s nullglob dotglob
    for cle in DESKTOP DOCUMENTS DOWNLOAD MUSIC PICTURES VIDEOS TEMPLATES PUBLICSHARE PROJECTS; do
        dossier=$(HOME="$H" XDG_CONFIG_HOME="$H/.config" xdg-user-dir "$cle")
        dossier=${dossier%/}
        case "$dossier" in
            "$H") [ "$cle" = DESKTOP ] || continue; bureau_home=1 ;;
            "$H"/*) [ -d "$dossier" ] || continue ;;
            *) continue ;;   # hors du home : pas un dossier du profil
        esac
        case "$vus" in *" $dossier "*) continue ;; esac
        vus+="$dossier "
        case "$cle" in
        DESKTOP)
            # Entrée par entrée : la restauration verse le bureau dans le home.
            # Un lien vers un dossier y remplacerait le vrai dossier du même
            # nom, un fichier illisible ferait échouer tar et perdre la
            # capture : écartés et nommés. Bureau = home : seuls ses fichiers
            # partent ; ses dossiers sont les autres dossiers du profil, ses
            # entrées cachées la configuration.
            while IFS= read -r -d '' p; do
                if [ -L "$p" ] && [ -d "$p" ]; then
                    echo "   ⚠ bureau : « ${p##*/} », lien vers un dossier — NON sauvegardé"
                elif [ -n "$bureau_home" ] && { [ -d "$p" ] || [[ "${p##*/}" == .* ]]; }; then
                    continue
                elif [ -L "$p" ] || [ -r "$p" ]; then
                    ranger "$p" ".affutos-xdg/DESKTOP/${p##*/}"
                else
                    echo "   ⚠ bureau : « ${p##*/} » illisible pour $U — NON sauvegardé"
                fi
            done < <(find "$dossier" -mindepth 1 -maxdepth 1 -print0) ;;
        DOCUMENTS)
            for p in "$dossier"/*; do
                for x in "${EXCLUS[@]}"; do [ "${p#"$H"/}" = "$x" ] && continue 2; done
                ranger "$p" ".affutos-xdg/DOCUMENTS/${p##*/}"
            done ;;
        *)  ranger "$dossier" ".affutos-xdg/$cle" ;;
        esac
    done
    # Bureau = home : ses dossiers qui ne sont pas des dossiers XDG ne partent
    # pas ; ils sont nommés, comme les dépôts git écartés.
    if [ -n "$bureau_home" ]; then
        for p in "$H"/[!.]*/; do
            p=${p%/}
            case "$vus" in *" $p "*) continue ;; esac
            [ -L "$p" ] || echo "   ⚠ dossier « ${p##*/} » à la racine du home (sur le bureau) — NON sauvegardé"
        done
    fi
    shopt -u nullglob dotglob
    # Chemins FIXES et sauvegardes de jeux, s'ils existent (un chemin absent
    # ferait échouer tar). De program, d'eden et de PCSX2 ne partent que les
    # sauvegardes et le BIOS PS2, que rien ne refabrique : le reste revient par
    # les paquets ou se régénère.
    for p in "${FIXES[@]}" Documents/program/games/nintendo_wii_u/mlc01/usr/save \
             .android .local/share/eden/nand/user/save .local/share/BeamNG \
             .config/PCSX2/memcards .config/PCSX2/bios; do
        [ -e "$H/$p" ] && CHEMINS+=("$H/$p")
    done
    # Steam : le dossier du profil (userdata/<compte>/config) et RIEN dedans.
    # Il suffit à switch-eden pour poser raccourcis et jaquettes des jeux Switch
    # dès la première session, avant que Steam n'ait créé le profil. Ni jetons
    # de session (révoqués par Valve s'ils changent de machine), ni
    # shortcuts.vdf, ni grid/ : un raccourci restauré se mêle à ceux de
    # switch-eden et masque ses homonymes, et grid/ ne garde que les jaquettes
    # de raccourcis disparus (celles des jeux Switch viennent de leurs paquets).
    # Le profil 0 est un pseudo-compte.
    local STEAM=()
    while IFS= read -r p; do [ -n "$p" ] && STEAM+=("$p"); done < <(
        find "/home/$U/.local/share/Steam/userdata/" -mindepth 2 -maxdepth 2 \
             -type d -name config ! -path "*/0/*" 2>/dev/null)
    [ "${#CHEMINS[@]}" -gt 0 ] \
        || { echo "ERREUR : aucun chemin du profil n'existe — rien à sauvegarder." >&2; return 1; }
    # Liste affichée AVANT l'archive : seule occasion de voir qu'un dossier
    # attendu manque.
    echo "${#CHEMINS[@]} chemin(s) sauvegardé(s) :"; printf '   %s\n' "${CHEMINS[@]}"
    [ "${#STEAM[@]}" -gt 0 ] && printf '   %s (dossier seul)\n' "${STEAM[@]}"
    # Et ce qui n'y part PAS : tar (--exclude-tag-all) écarte en silence tout
    # dossier qui contient un « .git », dossier ou fichier (worktree,
    # sous-module), travail non commité et fichiers ignorés compris : un dépôt
    # se reclone. Un dépôt imbriqué est écarté avec son parent et n'est pas
    # listé : avec le « / » final, le tri C range chaque dépôt juste avant son
    # contenu.
    local depots=() d marque
    while IFS= read -r d; do
        [ "${#depots[@]}" -gt 0 ] && [[ "$d" == "${depots[-1]}"* ]] && continue
        depots+=("$d")
    done < <(find "${CHEMINS[@]}" -name .git -prune -printf '%h/\n' 2>/dev/null | LC_ALL=C sort -u)
    if [ "${#depots[@]}" -gt 0 ]; then
        echo "${#depots[@]} dépôt(s) git ÉCARTÉ(S), fichiers ignorés compris (un dépôt se reclone) :"
        for d in "${depots[@]}"; do
            marque=""
            [ -n "$(git -C "$d" status --porcelain 2>/dev/null | head -n 1)" ] && marque+="  ⚠ modifications non commitées"
            [ -n "$(git -C "$d" log --branches --not --remotes --oneline 2>/dev/null | head -n 1)" ] && marque+="  ⚠ commits non poussés"
            printf '   %s%s\n' "${d%/}" "$marque"
        done
    fi
    # paquets.txt AVANT le tar : Documents/script part dans l'archive quand ce
    # n'est pas un dépôt git (copie posée par l'installateur, qui garde celle de
    # l'archive en paquets.txt.archive). Un échec n'empêche pas la sauvegarde,
    # qui est le but ; il est seulement signalé.
    cmd_diffpaquets || warn "paquets.txt n'a PAS été mis à jour — l'archive va contenir la liste précédente"
    # L'archive ne prend son nom qu'une fois complète : « envoi » et
    # l'installateur ne retiennent que les save_*.tar.zst, jamais une capture
    # coupée (Ctrl-C).
    local PARTIEL="$ARCHIVE.partiel"
    # Decky : Documents/homebrew part sans services/ (l'ancien loader ; il vient
    # désormais du paquet decky-loader), logs/ ni plugins/ : les plugins ne
    # voyagent que par leur nom (affutos-plugins.txt), réinstallés à jour depuis
    # le store par 20-decky. settings/,
    # data/ et themes/ partent : le store ne les rend pas.
    # Decky range ses fichiers en root : un seul lisible par root seul ferait
    # échouer tar et perdre la capture. sudo n'est demandé que dans ce cas.
    local SUDO=() DECKY="$H/Documents/homebrew"
    if [ -d "$DECKY/plugins" ]; then
        python3 - "$DECKY/plugins" > "$DECKY/affutos-plugins.txt" <<'PY' \
            || echo "⚠️  liste des plugins Decky non établie : ils ne seront pas réinstallés" >&2
import json, os, sys
for dossier in sorted(os.listdir(sys.argv[1])):
    try:
        with open(os.path.join(sys.argv[1], dossier, "plugin.json"), encoding="utf-8") as f:
            print(json.load(f)["name"])
    except (OSError, ValueError, KeyError):
        print("   plugin illisible, non noté : %s" % dossier, file=sys.stderr)
PY
        [ -s "$DECKY/affutos-plugins.txt" ] \
            && echo "plugins Decky notés pour réinstallation : $(paste -sd, "$DECKY/affutos-plugins.txt" | sed 's/,/, /g')"
    fi
    if [ -d "$DECKY" ] && [ -n "$(find "$DECKY" \( -path "$DECKY/services" -o -path "$DECKY/logs" \
            -o -path "$DECKY/plugins" \) -prune -o ! -readable -print -quit 2>/dev/null)" ]; then
        echo "Documents/homebrew porte des fichiers de Decky lisibles par root seul : sudo demandé pour les capturer."
        sudo -v || { echo "ERREUR : sans root, les réglages des plugins Decky ne peuvent pas être capturés — rien n'est archivé." >&2; return 1; }
        SUDO=(sudo)
    fi
    # Créée sous le compte de l'utilisateur : tar sous sudo l'écrit sans en
    # changer le propriétaire.
    : > "$PARTIEL" || { echo "ERREUR : impossible de créer $PARTIEL" >&2; return 1; }
    # La connexion Firefox, préparée dans ETAPE, entre dans l'archive par -C.
    # ETAPE porte un jeton de session : supprimé quelle que soit l'issue.
    local ETAPE FIREFOX=()
    ETAPE=$(mktemp -d) || { echo "ERREUR : dossier temporaire non créable" >&2; rm -f "$PARTIEL"; return 1; }
    trap 'rm -f "$PARTIEL"; rm -rf "$ETAPE"; echo "capture interrompue — archive partielle supprimée" >&2; exit 130' INT TERM HUP
    if ! firefox_compte "$ETAPE"; then
        rm -rf "${ETAPE:?}/home"
        warn "connexion Firefox non capturée : il faudra se reconnecter à son compte Firefox"
    fi
    # Les dossiers cachés d'ETAPE (.config), pas son home : il n'a ni les
    # droits ni les dates du vrai.
    shopt -s nullglob
    for p in "$ETAPE/${H#/}"/.[!.]*; do FIREFOX+=("${p#"$ETAPE"/}"); done
    shopt -u nullglob
    [ "${#FIREFOX[@]}" -gt 0 ] && FIREFOX=(-C "$ETAPE" "${FIREFOX[@]}")
    # --rsyncable : la compression repart de zéro à intervalles réguliers, les
    # blocs inchangés redonnent les mêmes octets d'une capture à l'autre ; c'est
    # ce qui permet à « envoi » de ne téléverser que le delta (~1-2 % de taille
    # en plus).
    # --no-unquote : sans lui, tar « déséchappe » les noms passés en argument
    # (« facture\2025.pdf » devient introuvable).
    "${SUDO[@]}" tar -I 'zstd -19 -T32 --rsyncable' -cf "$PARTIEL" --no-unquote --exclude-tag-all=.git \
        --exclude="save_*.tar.zst*" \
        --exclude="Documents/homebrew/services" --exclude="Documents/homebrew/logs" \
        --exclude="Documents/homebrew/plugins" "${TRANSFO[@]}" "${FIREFOX[@]}" \
        --no-recursion "${STEAM[@]}" --recursion -- "${CHEMINS[@]}"
    rc=$?
    rm -rf "$ETAPE"
    trap - INT TERM HUP
    # tar : 1 = un fichier a changé pendant la lecture (banal en session,
    # archive utilisable) ; 2 et plus = erreur fatale.
    if [ "$rc" -ge 2 ]; then
        echo "ERREUR : tar a échoué (code $rc) — archive supprimée. paquets.txt n'est pas concerné : il a été traité juste avant." >&2
        rm -f "$PARTIEL"; return 1
    fi
    mv -- "$PARTIEL" "$ARCHIVE" || { echo "ERREUR : $PARTIEL non renommée" >&2; return 1; }
    empreinte "$ARCHIVE" || warn "empreinte non écrite : l'installateur restaurera sans la vérifier"
    [ "$rc" = 1 ] && warn "des fichiers ont changé pendant la lecture (archive utilisable)"
    echo "archive : $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
    return 0
}

# Empreinte « hash  nom » à côté de l'archive, calculée sur l'entrée standard
# pour que sha256sum n'échappe pas un nom contenant « \ ». L'installateur la
# vérifie ; sur le serveur, publiée après l'archive, elle témoigne d'un envoi
# complet.
empreinte() {
    local h; h="$(sha256sum < "$1")" || return 1
    printf '%s  %s\n' "${h%% *}" "${1##*/}" > "$1.sha256"
}

cmd_envoi() {
    # Téléverse la dernière capture (ou celle donnée) sous son nom daté, que la
    # page « Restauration » de l'installateur affiche tel quel.
    local H="/home/$U" ARCHIVE="${1:-}"
    local FDEST="/home/$U/.config/affutos/envoi.dest"
    [ -s "$FDEST" ] || {
        echo "❌ $FDEST absent — y écrire deux lignes :" >&2
        echo "   ligne 1 : utilisateur@hote:/chemin/saves/   (destination rsync)" >&2
        echo "   ligne 2 : ssh -p PORT                       (commande ssh, defaut : ssh)" >&2
        exit 1
    }
    [ -z "$ARCHIVE" ] && ARCHIVE="$(ls -t "$H"/Documents/saves/userprofile/save_*.tar.zst 2>/dev/null | head -n 1 || true)"
    [ -f "${ARCHIVE:-}" ] || { echo "❌ aucune archive à envoyer (faire « affutos capture » d'abord)."; exit 1; }
    local DEST RSH NOM
    DEST=$(sed -n 1p "$FDEST"); RSH=$(sed -n 2p "$FDEST"); : "${RSH:=ssh}"
    NOM=$(basename "$ARCHIVE")
    echo "envoi : $NOM ($(du -h "$ARCHIVE" | cut -f1))"
    # L'empreinte part APRÈS l'archive : l'installateur ne propose que les
    # archives du serveur qui en ont une, donc jamais un envoi coupé. Calculée
    # ici si la capture ne l'a pas écrite.
    [ -s "$ARCHIVE.sha256" ] || empreinte "$ARCHIVE" || { echo "❌ archive illisible : $ARCHIVE"; exit 1; }
    # Pas de mise en veille pendant l'envoi (des heures, sur un portable) : un
    # verrou « block » de logind la refuse, capot compris. polkit l'accorde à une
    # session locale active, pas à ssh : on prévient et on envoie quand même.
    local VEILLE=()
    if systemd-inhibit --what=sleep --mode=block --who=affutos --why=test true >/dev/null 2>&1; then
        VEILLE=(systemd-inhibit --what=sleep --mode=block --who="affutos envoi" --why="envoi de la sauvegarde vers le serveur")
    else
        echo "⚠️  mise en veille NON bloquée (session sans droit d'inhibition) : ne pas fermer le capot pendant l'envoi"
    fi
    # Reprise automatique sur erreur de transport (rsync 10, 12, 30, 35 ; ssh
    # 255), la box renouvelant sa connexion chaque nuit : une minute d'attente,
    # vingt essais au plus. --timeout : une connexion morte échoue au lieu de
    # figer l'envoi.
    local essai=1 rc
    while :; do
        # --fuzzy : faute d'un fichier du même nom sur le serveur, la base est le
        # plus proche, la capture précédente ; avec zstd --rsyncable, seuls les
        # blocs changés voyagent. --partial-dir : une relance reprend après une
        # coupure, le partiel servant alors de base (la suite voyage en entier).
        # -t : date de la capture. -p --chmod=F644 : nginx sert l'archive sous un
        # autre compte ; sans -p, --chmod reste soumis au masque du récepteur
        # (644 & ~077 = 600, d'où un 403 au téléchargement).
        "${VEILLE[@]}" rsync -t -p --chmod=F644 --fuzzy --partial-dir=.rsync-partiel --timeout=120 --progress \
            --rsh="$RSH" -- "$ARCHIVE" "${DEST}${NOM}"
        rc=$?
        [ "$rc" -eq 0 ] && break
        case "$rc" in 10|12|30|35|255) [ "$essai" -lt 20 ] ;; *) false ;; esac || {
            echo "❌ envoi interrompu (rsync $rc) : relancer « affutos envoi », il reprendra où il s'est arrêté (l'installateur ne propose pas cette archive d'ici là)"
            exit 1
        }
        essai=$((essai + 1))
        echo "⚠️  connexion perdue (rsync $rc) — reprise dans 60 s, tentative $essai/20"
        sleep 60
    done
    rsync -p --chmod=F644 --rsh="$RSH" -- "$ARCHIVE.sha256" "${DEST}${NOM}.sha256" \
        || { echo "❌ archive envoyée, mais PAS son empreinte : l'installateur ne la proposera pas avant un nouvel « affutos envoi »"; exit 1; }
    echo "✅ capture envoyée : ${DEST}${NOM} (+ .sha256)"
}

cmd_diffpaquets() {   # met à jour paquets.txt, liste de référence d'AffûtOS
    # Liste CUMULATIVE : tout ce qui distingue une machine AffûtOS d'une base
    # CachyOS, quel que soit le canal qui l'a posé. Pas un simple delta depuis
    # l'installation : Calamares pose lui-même les paquets AffûtOS, ce delta
    # serait presque vide. Une capture ne peut que l'enrichir ; tout retrait
    # est montré et confirmé.
    # Chemin ABSOLU : à côté d'une copie du script (/usr/local/bin…), la liste
    # repartirait de rien, sans garde-fou. Jamais de référence reconstruite à
    # partir de rien.
    local FICH="/home/$U/Documents/script/paquets.txt" BASE="/home/$U/.paquets_freshinstall" jour
    [ -f "$FICH" ] || { echo "ERREUR : $FICH introuvable — refus d'écrire une liste de référence à partir de rien." >&2; return 1; }
    if [ ! -f "$BASE" ]; then
        jour=$(head -n 1 /var/log/pacman.log | cut -c2-11)
        echo "Pas de base enregistrée — reconstruction depuis pacman.log (installation du $jour)"
        grep "^\[$jour" /var/log/pacman.log | awk '/\[ALPM\] installed/{print $4}' \
            | LC_ALL=C sort -u > "$BASE"
    fi
    local anciens delta nouveaux retires n_av n_ap rep sur tmp _dev _eden
    anciens="$(awk '!/^#/ && NF {print $1}' "$FICH" 2>/dev/null | LC_ALL=C sort -u)"
    [ -n "$anciens" ] || { echo "ERREUR : $FICH ne contient aucun paquet — refus de repartir d'une liste nue." >&2; return 1; }
    # Hors de la référence : chaotic-keyring et chaotic-mirrorlist (AffûtOS ne
    # les installe plus), et les paquets de l'ISO dev (paquets-dev.txt, livré
    # par affutos-scripts) : présents sur un poste de développement, ils
    # partiraient dans l'ISO publique.
    _dev="$([ -f /usr/share/affutos/paquets-dev.txt ] \
        && awk '!/^#/ && NF {print $1}' /usr/share/affutos/paquets-dev.txt | paste -sd'|')"
    # Variante d'Eden (eden-steamdeck, eden-rog-ally…) : le live installe celle
    # du processeur. La référence vaut pour toute machine et alimente le
    # préchargement : la variante d'ici (propriétaire de /usr/bin/eden) n'y
    # entre pas.
    _eden="$(pacman -Qqo /usr/bin/eden 2>/dev/null | grep '^eden-')"
    delta="$(LC_ALL=C comm -13 <(LC_ALL=C sort -u "$BASE") <(pacman -Qqe | LC_ALL=C sort -u) \
             | grep -vxE "chaotic-keyring|chaotic-mirrorlist${_dev:+|$_dev}${_eden:+|$_eden}" || true)"
    # Retraits : seulement ce qui n'est plus installé DU TOUT. Pas de
    # « pacman -Qqe » ici : un paquet devenu dépendance serait retiré à tort.
    retires="$(LC_ALL=C comm -23 <(printf '%s\n' "$anciens") \
                                 <(pacman -Qq | LC_ALL=C sort -u) || true)"
    nouveaux="$(printf '%s\n%s\n' "$anciens" "$delta" | grep -v '^$' | LC_ALL=C sort -u)"
    if [ -n "$retires" ]; then
        echo; echo "⚠️ $(printf '%s\n' "$retires" | grep -c .) paquet(s) de paquets.txt ne sont plus installés :"
        printf '   %s\n' $retires
        rep=""
        [ -t 0 ] && read -rp "Les RETIRER de la liste de référence ? [o/N] : " rep
        case "${rep:-N}" in
            [oO]) nouveaux="$(LC_ALL=C comm -23 <(printf '%s\n' "$nouveaux") \
                                                <(printf '%s\n' "$retires"))" ;;
            # Défaut, seul choix hors terminal : les paquets conservés gardent
            # leur ligne actuelle, reprise plus bas.
            *) echo "   conservés dans la liste (leur ligne actuelle est reprise telle quelle)." ;;
        esac
    fi
    n_av=$(printf '%s\n' "$anciens"  | grep -c . || true)
    n_ap=$(printf '%s\n' "$nouveaux" | grep -c . || true)
    # Garde-fou : jamais de liste plus courte sans un OUI explicite, après
    # avoir montré exactement ce qui disparaît.
    if [ "$n_ap" -lt "$n_av" ]; then
        echo; echo "⚠️ paquets.txt passerait de $n_av à $n_ap paquets. Disparaîtraient :"
        LC_ALL=C comm -23 <(printf '%s\n' "$anciens") <(printf '%s\n' "$nouveaux") | sed 's/^/   - /'
        [ -t 0 ] || { echo "Entrée non interactive — paquets.txt laissé intact." >&2; return 1; }
        sur=""; read -rp "Écrire quand même cette liste raccourcie ? (taper OUI) : " sur
        [ "$sur" = OUI ] || { echo "paquets.txt laissé intact."; return 1; }
    fi
    # mktemp à côté du fichier : même système de fichiers, et paquets.txt n'est
    # jamais laissé tronqué si pacman meurt en route.
    tmp="$(mktemp "$FICH.XXXXXX")" \
        || { echo "ERREUR : fichier temporaire non créable à côté de $FICH — paquets.txt laissé intact." >&2; return 1; }
    # pacman -Qi ignore un paquet désinstallé : seuls les paquets installés sont
    # décrits, les autres reprennent telle quelle leur ligne de l'ancien
    # fichier. Une annotation écrite à la main ne survit que sur la ligne d'un
    # paquet désinstallé.
    local decrire gardes
    decrire="$(LC_ALL=C comm -12 <(printf '%s\n' "$nouveaux" | grep -v '^$') \
                                 <(pacman -Qq | LC_ALL=C sort -u) || true)"
    gardes="$(LC_ALL=C comm -23 <(printf '%s\n' "$nouveaux" | grep -v '^$') \
                                <(printf '%s\n' "$decrire" | grep -v '^$') || true)"
    # Une ligne « paquet  # description ». LC_ALL=C : champs Name et Description
    # non traduits (LANG seul serait écrasé par un LC_ALL hérité).
    printf '%s\n' "$decrire" | LC_ALL=C xargs -r pacman -Qi 2>/dev/null | awk '
        /^Name/        { sub(/^[^:]*: /, ""); n = $0 }
        /^Description/ { sub(/^[^:]*: /, ""); printf "%-32s # %s\n", n, $0 }' > "$tmp"
    if [ -n "$gardes" ]; then
        echo "   $(printf '%s\n' "$gardes" | grep -c .) ligne(s) reprise(s) telles quelles depuis $FICH :"
        printf '   %s\n' $gardes
        # Comparaison sur le premier champ, pas « grep -F » : « lld » trouverait
        # « lld-libs », « rust » toute description qui contient le mot.
        printf '%s\n' "$gardes" | awk 'NR == FNR { if (NF) g[$1] = 1; next }
                                       !/^#/ && NF && ($1 in g)' - "$FICH" >> "$tmp"
    fi
    LC_ALL=C sort -u -o "$tmp" "$tmp"
    # Une description par paquet, sinon rien n'est écrit. Compte normalisé : une
    # valeur vide ferait échouer le test « -lt » et sauter le contrôle.
    local n_desc; n_desc="$(grep -c . "$tmp" 2>/dev/null || true)"; n_desc="${n_desc:-0}"
    if [ "$n_desc" -lt "$n_ap" ]; then
        echo "ERREUR : $n_desc descriptions pour $n_ap paquets — paquets.txt laissé intact." >&2
        echo "   sans description :" >&2
        LC_ALL=C comm -23 <(printf '%s\n' "$nouveaux" | grep -v '^$') \
                          <(awk '!/^#/ && NF {print $1}' "$tmp" | LC_ALL=C sort -u) | sed 's/^/   - /' >&2
        rm -f "$tmp"; return 1
    fi
    chmod 644 "$tmp"; mv -f "$tmp" "$FICH"
    echo "→ $FICH : $n_ap paquets (référence AffûtOS), dont $(printf '%s\n' "$delta" | grep -c . || true) ajouté(s) depuis l'installation"
    # Cohérence avec le netinstall : ses groupes installent tout paquets.txt,
    # sauf ceux que pose pacstrap (basePackages) : le socle graphique et
    # affutos-booster.
    # Simple avertissement ; sans netinstall.yaml (livré par affutos-scripts),
    # contrôle sauté.
    local NI=/usr/share/affutos/netinstall.yaml ni=""
    [ -f "$NI" ] && ni="$(sed -n 's/^[[:space:]]\+- \([a-z0-9][a-z0-9._+-]*\)$/\1/p' "$NI" | LC_ALL=C sort -u)"
    if [ -n "$ni" ]; then
        # Un seul sens : un paquet de la référence qu'aucun groupe ne propose.
        # L'inverse n'est jamais vide : netinstall.yaml décrit tous les groupes
        # de la page « Paquets », cochés ou non.
        local orphelins
        orphelins="$(LC_ALL=C comm -23 <(printf '%s\n' "$nouveaux") <(printf '%s\n' "$ni") \
            | grep -vxE 'mesa|lib32-mesa|vulkan-radeon|lib32-vulkan-radeon|affutos-booster' || true)"
        [ -n "$orphelins" ] && { warn "dans paquets.txt mais AUCUN groupe ne les installe :"; printf '   %s\n' $orphelins; }
    fi
    # return 0 explicite : sinon la fonction rendrait le code du test ci-dessus,
    # 1 quand il n'y a aucun orphelin, et cmd_capture signalerait un échec.
    return 0
}

# ---------------------------------------------------------------------- usb --
cmd_usb() {   # la clé ne porte que noyau et initramfs : retirable une fois le noyau chargé
    set -e    # la moindre étape ratée doit arrêter net, pas afficher « prête »
    local DEV="" MODEDEV=0 a
    for a in "$@"; do
        case "$a" in
            --dev) MODEDEV=1 ;;
            *) DEV="$a" ;;
        esac
    done
    local BASE=https://affutos.fadamatik.fr/netboot KVER=linux-cachyos-deckify TITRE=""
    if [ "$MODEDEV" = 1 ]; then
        local FURL="/home/$U/.config/affutos/netboot-dev.url"
        [ -s "$FURL" ] || { echo "❌ --dev : $FURL absent (URL privée de l'image dev)"; exit 1; }
        BASE="$(head -n1 "$FURL")"
        TITRE=" DEV"
        echo "ℹ️ clé DEV : image téléchargée depuis le chemin privé du serveur"
    fi
    [ -b "$DEV" ] || { echo "Usage : affutos usb /dev/sdX [--dev]"; exit 1; }
    [ "$(lsblk -dno RM "$DEV")" = 1 ] || echo "⚠️ $DEV ne semble pas amovible"
    lsblk -dno NAME,SIZE,MODEL,VENDOR "$DEV"
    local r; read -rp "EFFACER ENTIÈREMENT ce périphérique ? (taper OUI) " r
    [ "$r" = OUI ] || { echo "abandon"; exit 1; }
    # Wi-Fi enregistré, facultatif : passé au noyau par la clé, jamais dans
    # l'image publique. Sans lui, l'entrée « choisir un Wi-Fi » scanne et
    # demande le mot de passe au boot.
    local WSSID WPSK="" WOPT=""
    read -rp "SSID Wi-Fi à enregistrer sur la clé (vide = choix au boot) : " WSSID
    if [ -n "$WSSID" ]; then
        read -rsp "Mot de passe Wi-Fi : " WPSK; echo
        # wifi_ask=1 : si le réseau enregistré échoue, le hook retombe sur le
        # choix interactif.
        WOPT=" wifi_ssid=\"$WSSID\" wifi_psk=\"$WPSK\" wifi_ask=1"
    fi
    # DHCP sur le Wi-Fi seul : sinon archiso essaie l'Ethernet (mort sur le
    # 4800H) et abandonne au bout de 60 s.
    local IPOPT="ip=:::::wlan0:dhcp"
    # KDE auto-monte les partitions des clés : tout démonter avant d'effacer
    local mp
    while read -r mp; do
        [ -n "$mp" ] || continue
        sudo umount "$mp" || { echo "démontage impossible : $mp"; exit 1; }
    done < <(lsblk -lno MOUNTPOINT "$DEV")
    sudo wipefs -aq "$DEV"
    sudo parted -s "$DEV" mklabel gpt mkpart ESP fat32 1MiB 600MiB set 1 esp on
    sudo partprobe "$DEV"; sleep 2
    local P="${DEV}1"; [ -b "${DEV}p1" ] && P="${DEV}p1"
    sudo mkfs.fat -F32 -n AFFUTOSNET "$P" >/dev/null
    local M; M=$(mktemp -d)
    sudo mount "$P" "$M"
    sudo mkdir -p "$M/EFI/BOOT" "$M/loader/entries"
    sudo cp /usr/lib/systemd/boot/efi/systemd-bootx64.efi "$M/EFI/BOOT/BOOTX64.EFI"
    echo "Téléchargement du noyau/initramfs de la dernière ISO publiée…"
    sudo curl -f# -o "$M/vmlinuz-$KVER"        "$BASE/arch/boot/x86_64/vmlinuz-$KVER"
    sudo curl -f# -o "$M/initramfs-$KVER.img"  "$BASE/arch/boot/x86_64/initramfs-$KVER.img"
    local DEFAUT=affutos-wifi.conf
    [ -n "$WSSID" ] && DEFAUT=affutos-enregistre.conf
    printf 'default %s\ntimeout 5\n' "$DEFAUT" | sudo tee "$M/loader/loader.conf" >/dev/null
    local COMMUN="archisobasedir=arch archiso_http_srv=$BASE/ checksum=y cow_spacesize=10G module_blacklist=pcspkr zswap.enabled=0 nowatchdog"
    local ENTREES=()
    entree() {   # $1 = fichier, $2 = fin du titre, $3 = options réseau
        printf 'title   AffutOS%s — %s\nlinux   /vmlinuz-%s\ninitrd  /initramfs-%s.img\noptions %s %s\n' \
            "$TITRE" "$2" "$KVER" "$KVER" "$COMMUN" "$3" | sudo tee "$M/loader/entries/$1" >/dev/null
        ENTREES+=("loader/entries/$1")
    }
    entree affutos-wifi.conf "choisir un Wi-Fi au demarrage" "$IPOPT wifi_ask=1"
    [ -z "$WSSID" ] || entree affutos-enregistre.conf "Wi-Fi enregistre ($WSSID)" "$IPOPT$WOPT"
    entree affutos-eth.conf "Ethernet" "ip=dhcp"
    # Tout doit être sur la clé AVANT d'annoncer le succès.
    local f
    for f in EFI/BOOT/BOOTX64.EFI loader/loader.conf "${ENTREES[@]}" "vmlinuz-$KVER" "initramfs-$KVER.img"; do
        sudo test -s "$M/$f" || { echo "❌ fichier manquant sur la clé : $f"; sudo umount "$M"; exit 1; }
    done
    echo "--- contenu de la clé ---"; sudo ls -lh "$M" "$M/EFI/BOOT" "$M/loader/entries"
    sudo umount "$M"; rmdir "$M"; sync
    echo "✅ Clé prête et vérifiée : boot UEFI → Wi-Fi ou Ethernet → système téléchargé en RAM depuis $BASE"
    echo "   Refaire « affutos usb $DEV » après une release qui change le noyau."
}

# --------------------------------------------------------------- répartiteur --
case "${1:-}" in
    capture)    cmd_capture ;;
    envoi)      shift; cmd_envoi "$@" ;;
    diff-paquets) cmd_diffpaquets ;;
    usb)        shift; cmd_usb "$@" ;;
    # L'aide : l'en-tête, de la ligne 5 jusqu'à la première ligne « # » seule.
    *) sed -n '5,/^#$/{/^#$/q;p}' "$0"; exit 1 ;;
esac
