#!/bin/bash
# script destiné à être lancé par l'utilisateur se4install afin d générer l'image wine-$1.img, et également si besoin tous les éléments nécessaires pour
# faire un paquet sambaedu-$1

# au logon, les prefix sont montés sur /var/tmp/$USER/wine/wine-appli/prefix.
# toute modification apportée au prefix est enregistrée en overlay.
# le script peut refaire une image à partir du montage en cours modifié, par exemple en cas de mise à jour.

# les lanceurs sont à modifier manuellement :
# wine va en général mettre un lanceur .desktop dans ~/.local/share/applications/, avec une icone en référence.
# afin de permettre une personnalisation maximale, il est conseillé de faire fichier /tmp/appli/appli.desktop qui lance un script wrapper /usr/bin/appli qui accepte éventuelleemnt des arguments
# ce script doit être nommé /tmp/appli/appli

# généralement wine va ajouter les associations de fichiers définies par l'installeur dans .config/mimeapps.list. Il faut extraire celles que l'on souhaite conserver et les
# mettre dans un fichier mime.list

# en résumé :

# /tmp/appli/wine-appli.img -> image du prefix -> /var/sambaedu/unattended/install/wine/wine-appli.img + sha256sum  + lien dans /usr/share/sambaedu/wine/
# /tmp/appli/appli.desktop -> entrée de menu -> .local/share/applications/
# /tmp/appli/appli.png -> icone -> .local/share/icons/XxX/
# /tmp/appli/appli -> wrapper (executable) -> /usr/bin/appli
# /tmp/appli/mime.list -> associations par défaut -> fusionner avec .config/mimeapps.list

# test local

# /usr/share/sambaedu/sbin/make_prefix_image.sh appli local
#

# envoi sur le dépôt (il faut avoir la clé pour ssh root@deb.sambaedu.org)

git="$HOME/git/sambaedu-wpkg-xml/sources"

# dépôt des recettes wpkg (stable/testing/XP/manuel), et checkout local de se4
# (pour wpkg-se4.js). Utilisés par install_wpkg et test_wine_apps ci-dessous.
# $HOME et non /home/$USER : ne pas supposer que le home d'un utilisateur suit
# ce schéma (faux par exemple pour ci.runner, dont le home est local, hors
# NFS/Kerberos - voir le commentaire sur /etc/passwd sur le runner CI).
wpkg_xml_repo="$HOME/git/sambaedu-wpkg-xml"
git_se4="$HOME/git/se4"

# cherche la recette wpkg d'une application dans le dépôt sambaedu-wpkg-xml.
# affiche le chemin trouvé sur stdout, échoue si aucune recette n'existe.
function find_package_xml() {
    local appli="$1" repo
    for repo in stable testing XP manuel; do
        if [ -f "$wpkg_xml_repo/$repo/$appli.xml" ]; then
            echo "$wpkg_xml_repo/$repo/$appli.xml"
            return 0
        fi
    done
    # repli : le nom de fichier ne correspond pas toujours à linux/@package
    # (ex: NotepadPlusPlus.xml déclare <linux type="wine" package="notepadplusplus">
    # - seule la casse diffère ici, mais IndexEducPronote25.xml/pronote montre que
    # ça peut aussi être un nom totalement différent). La variable APPLI du
    # pipeline (.gitlab-ci.yml de sambaedu-wpkg-xml) est renseignée avec la
    # valeur linux/@package (celle utilisée par
    # create_debian_source/xml_wine_categories), pas le nom de fichier : sans
    # ce repli, install_wpkg échoue "recette introuvable" dès que les deux
    # diffèrent - constaté en réel avec notepadplusplus.
    python3 -c '
import sys, os, glob
import xml.etree.ElementTree as ET
appli, repo = sys.argv[1], sys.argv[2]
for sub in ("stable", "testing", "XP", "manuel"):
    for path in sorted(glob.glob(os.path.join(repo, sub, "*.xml"))):
        try:
            root = ET.parse(path).getroot()
        except ET.ParseError:
            continue
        linux = root.find(".//linux")
        if linux is not None and linux.get("type") == "wine" and linux.get("package") == appli:
            print(path)
            sys.exit(0)
sys.exit(1)
' "$appli" "$wpkg_xml_repo"
}

# résout un id de paquet (<package id="...">, PAS un nom de fichier) vers
# le chemin de sa recette xml, en respectant la même priorité que
# find_package_xml (stable > testing > XP > manuel). Contrairement à
# find_package_xml, l'id ne correspond pas toujours au nom de fichier
# (ex : "dotnet3.5" n'est défini que dans dotnet35.xml, "python-idle" dans
# python.xml) : on tente d'abord le nom de fichier par optimisme, puis on
# scanne tout le dépôt si besoin. Utilisée pour résoudre les <depends>.
function find_package_by_id() {
    local pkgid="$1" fast
    if fast="$(find_package_xml "$pkgid")"; then
        if python3 -c '
import sys, xml.etree.ElementTree as ET
try:
    pkg = ET.parse(sys.argv[1]).getroot().find("package")
except ET.ParseError:
    pkg = None
sys.exit(0 if pkg is not None and pkg.get("id") == sys.argv[2] else 1)
' "$fast" "$pkgid"; then
            echo "$fast"
            return 0
        fi
    fi
    python3 -c '
import sys, os, glob
import xml.etree.ElementTree as ET
pkgid, repo = sys.argv[1], sys.argv[2]
for sub in ("stable", "testing", "XP", "manuel"):
    for path in sorted(glob.glob(os.path.join(repo, sub, "*.xml"))):
        try:
            root = ET.parse(path).getroot()
        except ET.ParseError:
            continue
        pkg = root.find("package")
        if pkg is not None and pkg.get("id") == pkgid:
            print(path)
            sys.exit(0)
sys.exit(1)
' "$pkgid" "$wpkg_xml_repo"
}

# calcule la fermeture transitive des dépendances (<depends package-id="X"/>)
# d'une recette wpkg, et produit sur stdout le packages.xml fusionné
# correspondant : wpkg-se4.js résout les dépendances uniquement à
# l'intérieur du packages.xml déjà chargé, jamais à la demande (confirmé en
# lisant getPackageDependencies()/installPackageReferences() dans
# wpkg-se4.js), donc packages.xml doit contenir toute la fermeture, pas
# seulement le paquet ciblé. Le parsing passe exclusivement par
# xml.etree.ElementTree (jamais grep/sed) car des <depends> commentés
# existent réellement dans le dépôt (ex: kompozer.xml) et ne doivent pas
# être suivis. Émet sur stderr un "SRCFILE:<chemin>" par fichier xml source
# contribuant à la fermeture (nécessaire ensuite pour télécharger aussi les
# installeurs des dépendances), et un "MISSING-DEP:<id>" par dépendance
# non commentée mais introuvable (échec, exit 1, aucun xml produit).
function compute_packages_closure() {
    python3 - "$1" "$wpkg_xml_repo" <<'PYEOF'
import sys, os, glob
import xml.etree.ElementTree as ET

xmlsrc, repo = sys.argv[1], sys.argv[2]
subdirs = ("stable", "testing", "XP", "manuel")


def resolve(pkgid):
    for sub in subdirs:
        fast = os.path.join(repo, sub, pkgid + ".xml")
        if os.path.isfile(fast):
            try:
                root = ET.parse(fast).getroot()
            except ET.ParseError:
                root = None
            if root is not None:
                pkg = root.find("package")
                if pkg is not None and pkg.get("id") == pkgid:
                    return fast
    for sub in subdirs:
        for path in sorted(glob.glob(os.path.join(repo, sub, "*.xml"))):
            try:
                root = ET.parse(path).getroot()
            except ET.ParseError:
                continue
            pkg = root.find("package")
            if pkg is not None and pkg.get("id") == pkgid:
                return path
    return None


visited = {}
order = []
srcfiles = []
missing = []
queue = [xmlsrc]
queued_ids = set()

while queue:
    path = queue.pop(0)
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError as e:
        print("MISSING-DEP:" + path + " (xml invalide : " + str(e) + ")", file=sys.stderr)
        continue
    pkg = root.find("package")
    if pkg is None:
        print("MISSING-DEP:" + path + " (pas de <package>)", file=sys.stderr)
        continue
    pkgid = pkg.get("id")
    if pkgid in visited:
        continue
    visited[pkgid] = pkg
    order.append(pkgid)
    srcfiles.append(path)
    for dep in pkg.findall("depends"):
        depid = dep.get("package-id")
        if not depid or depid in visited or depid in queued_ids:
            continue
        target = resolve(depid)
        if target is None:
            missing.append(depid)
            continue
        queued_ids.add(depid)
        queue.append(target)

if missing:
    for m in missing:
        print("MISSING-DEP:" + m, file=sys.stderr)
    sys.exit(1)

print('<?xml version="1.0" encoding="UTF-8"?>')
print("<packages>")
for pkgid in order:
    print(ET.tostring(visited[pkgid], encoding="unicode"))
print("</packages>")

for s in srcfiles:
    print("SRCFILE:" + s, file=sys.stderr)
PYEOF
}

# vrai (exit 0) si l'application n'a pas besoin d'être testée sous wine
# car elle dispose d'un équivalent linux :
#
# 1. balise <linux .../> explicite (type ou method="apt" : les deux
#    s'utilisent dans le dépôt, les 63 occurrences actuelles sont toutes des
#    déclarations apt) : on fait confiance à cette déclaration du xml sans
#    condition, même si apt-cache ne trouve pas (encore) le paquet sur cette
#    machine - ex: bambustudio.xml déclare <linux type="apt"
#    package="bambustudio"/> alors que le paquet n'est pas encore synchronisé
#    dans le cache apt local : ce n'est pas une raison de tester l'appli
#    sous wine, la déclaration du xml fait foi.
# 2. sinon (pas de balise <linux>), convention implicite documentée dans le
#    README de sambaedu-wpkg-xml : l'id du <package> en minuscules est
#    utilisé comme nom de paquet Debian. Ici, contrairement au cas explicite,
#    on vérifie via apt-cache que ce paquet existe réellement : c'est une
#    simple supposition (l'id peut très bien ne correspondre à aucun paquet).
function has_native_linux() {
    local pkgname
    pkgname="$(python3 -c '
import sys, xml.etree.ElementTree as ET
root = ET.parse(sys.argv[1]).getroot()
linux = root.find(".//linux")
if linux is not None:
    print("EXPLICIT")
else:
    print(root.find("package").get("id").lower())
' "$1")"
    [ "$pkgname" = "EXPLICIT" ] && return 0
    [ -n "$pkgname" ] && apt-cache show "$pkgname" >/dev/null 2>&1
}

# vrai (exit 0) si au moins une dépendance transitive (<depends
# package-id="...">) de l'appli a elle-même un équivalent linux natif
# (has_native_linux). Tester l'appli sous wine impliquerait alors
# d'installer sous wine cette dépendance alors qu'elle est déjà couverte
# nativement - un exercice inutile, et souvent voué à l'échec pour une
# raison sans rapport avec la recette elle-même (ex: freemind dépend de
# jre, qui a un paquet debian natif mais dont l'installeur .exe échoue
# sous wine avec une erreur MSI 1603).
# Réutilise compute_packages_closure (définie plus bas) pour la résolution
# de dépendances : une seule logique de résolution dans tout le script.
function has_native_dependency() {
    local xmlfile="$1" stderr_out line dep
    stderr_out="$(compute_packages_closure "$xmlfile" 2>&1 >/dev/null)"
    while IFS= read -r line; do
        case "$line" in
            SRCFILE:*)
                dep="${line#SRCFILE:}"
                [ "$dep" = "$xmlfile" ] && continue
                has_native_linux "$dep" && return 0
                ;;
        esac
    done <<<"$stderr_out"
    return 1
}

# applique, si la recette le demande (<package><wine winetricks="..."
# script="fichier"/></package>), une préparation du prefix avant
# l'installation Windows proprement dite : winetricks (verbes séparés par des
# espaces) et/ou un script exécuté sur l'HÔTE de packaging (pas sous wine,
# contrairement à winetricks) qui reçoit le chemin du prefix en argument - à
# lui d'invoquer wine/winetricks/wine reg import selon ses besoins, avec la
# même liberté que make-prefix lui-même. "fichier" (script=) doit avoir été
# récupéré au préalable par un <download url=... saveto="fichier"> classique
# (n'importe où dans le xml, y compris sous <linux> : le téléchargement
# existant d'install_wpkg parcourt tout l'arbre via iter("download"), aucun
# changement nécessaire là).
# Idempotent par prefix+paquet (fichier marqueur) : dans un prefix partagé
# (ex. prefix=svt-apps utilisé par plusieurs applis), ne s'applique qu'une
# fois par paquet, pas à chaque réinstallation/nouvel essai.
function apply_wine_config() {
    local xmlsrc="$1" prefixname="$2" pkgid="$3"
    local PREFIX="/var/tmp/$USER/wine/wine-$prefixname/prefix"
    local INSTALL_DIR="$PREFIX/drive_c/windows/install"
    local marker="$PREFIX/.sambaedu-wine-config-$pkgid"
    [ -f "$marker" ] && return 0

    local winetricks_verbs script_saveto
    IFS=$'\t' read -r winetricks_verbs script_saveto < <(python3 -c '
import sys, xml.etree.ElementTree as ET
root = ET.parse(sys.argv[1]).getroot()
wine = root.find("package/wine")
w = wine.get("winetricks", "") if wine is not None else ""
s = wine.get("script", "") if wine is not None else ""
print(w + "\t" + s)
' "$xmlsrc")

    if [ -n "$winetricks_verbs" ]; then
        echo "$pkgid : winetricks $winetricks_verbs sur $prefixname"
        # $winetricks_verbs volontairement non quoté : liste de verbes séparés par des espaces
        if ! WINEDLLOVERRIDES='winemenubuilder.exe=' WINEPREFIX="$PREFIX" winetricks -q $winetricks_verbs >/dev/null 2>&1; then
            echo "$pkgid : échec winetricks ($winetricks_verbs)"
            return 1
        fi
    fi

    if [ -n "$script_saveto" ]; then
        local script_path="$INSTALL_DIR/$script_saveto"
        if [ ! -f "$script_path" ]; then
            echo "$pkgid : script de configuration $script_saveto introuvable (vérifier le <download saveto=\"$script_saveto\"> de la recette)"
            return 1
        fi
        echo "$pkgid : exécution du script de configuration $script_saveto sur $prefixname"
        chmod +x "$script_path"
        if ! "$script_path" "$PREFIX"; then
            echo "$pkgid : échec du script de configuration $script_saveto"
            return 1
        fi
    fi

    touch "$marker"
}

# installe une application via le client wpkg natif (wpkg-native.py) dans un
# prefix wine dédié, à partir de la recette du dépôt local sambaedu-wpkg-xml.
# En prod le téléchargement des installeurs est fait côté serveur : on le
# reproduit ici, en réutilisant en lecture seule /opt/install/packages
# (partage réseau serveur) si présent, sinon /opt/local/packages (cache
# local persistant, alimenté ici même après chaque téléchargement - utile
# quand /opt/install n'est pas monté : poste sans serveur SambaEdu, CI...),
# et en téléchargeant sinon dans le prefix lui-même.
function install_wpkg() {
    local appli="$1" prefixname="$2"
    local PREFIX="/var/tmp/$USER/wine/wine-$prefixname/prefix"
    if [ ! -d "$PREFIX" ]; then
        echo "Le prefix $prefixname n'existe pas. Lancez d'abord : $0 new $prefixname"
        return 1
    fi
    echo "tentative d'installation via wpkg windows de l'application $appli sur le prefix wine-$prefixname"

    local xmlsrc
    if ! xmlsrc="$(find_package_xml "$appli")"; then
        echo "recette wpkg introuvable pour $appli dans $wpkg_xml_repo"
        return 1
    fi

    # l'id du paquet (attribut id="..." du <package>) ne correspond pas
    # toujours exactement (à la casse près) au nom du fichier xml : c'est lui
    # qu'il faut utiliser pour /install: et profiles.xml, pas $appli.
    local pkgid
    pkgid="$(python3 -c '
import sys, xml.etree.ElementTree as ET
print(ET.parse(sys.argv[1]).getroot().find("package").get("id"))
' "$xmlsrc")"
    if [ -z "$pkgid" ]; then
        echo "impossible de déterminer l'id du paquet dans $xmlsrc"
        return 1
    fi

    local INSTALL_DIR="$PREFIX/drive_c/windows/install"
    local WPKG_DIR="$INSTALL_DIR/wpkg"
    mkdir -p "$WPKG_DIR"

    # packages.xml doit contenir $xmlsrc ET toute sa fermeture de
    # dépendances (<depends package-id="...">) : voir compute_packages_closure.
    local closure_err="/var/tmp/$USER/wine/wine-$prefixname-closure.err"
    if ! compute_packages_closure "$xmlsrc" >"$WPKG_DIR/packages.xml" 2>"$closure_err"; then
        echo "résolution des dépendances impossible pour $appli :"
        cat "$closure_err"
        rm -f "$closure_err"
        return 1
    fi
    local srcfiles=()
    mapfile -t srcfiles < <(grep "^SRCFILE:" "$closure_err" | sed 's/^SRCFILE://')
    rm -f "$closure_err"

    # hosts.xml/profiles.xml ne sont pas régénérés : c'était un artefact du
    # protocole CLI de wpkg-se4.js (/profile:/host:), inutile pour
    # wpkg-native.py, qui prend directement packages.xml en entrée.

    # certaines recettes (ex: adnarn, curl, geogebra, vlc...) utilisent dans
    # leurs commandes <install cmd="..."> des utilitaires livrés à côté de
    # wpkg-se4.js en production (%Z%\wpkg\tools\7za.exe, nircmd.exe...) : sans
    # cette copie elles échouent avec "fichier introuvable".
    if [ -d "$git_se4/sources/var/sambaedu/unattended/install/wpkg/tools" ]; then
        rm -rf "$WPKG_DIR/tools"
        cp -r "$git_se4/sources/var/sambaedu/unattended/install/wpkg/tools" "$WPKG_DIR/tools"
    fi

    # téléchargement des fichiers d'installation référencés par la recette
    # ET par toute sa fermeture de dépendances (srcfiles, calculée plus
    # haut) : une dépendance a aussi ses propres <download> à récupérer.
    # le cache /opt/install/packages n'est réutilisé que si son checksum
    # correspond à celui attendu par la recette (sinon il peut être plus
    # ancien que ce que déclare le xml : on retélécharge dans ce cas).
    # dlerrors/INSTALL_WPKG_DL_ERROR (volontairement PAS locale : elle doit
    # rester lisible par l'appelant, ex. test_wine_apps, après le retour de
    # cette fonction) permet de distinguer explicitement dans le rapport un
    # échec de téléchargement (url injoignable, checksum différent - donc un
    # problème de recette/ressource externe) d'un échec d'installation.
    local ok=1 url saveto sha256 dest cached cached_sha256 got srcfile localcache
    local dlerrors=()
    INSTALL_WPKG_DL_ERROR=""
    for srcfile in "${srcfiles[@]}"; do
        while IFS=$'\t' read -r url saveto sha256; do
            [ -z "$url" ] && continue
            dest="$INSTALL_DIR/$saveto"
            mkdir -p "$(dirname "$dest")"
            localcache="/opt/local/packages/$saveto"

            if [ ! -f "$dest" ]; then
                cached="/opt/install/$saveto"
                cached_sha256="$(sha256sum "$cached" 2>/dev/null | cut -d' ' -f1)"
                if [ -f "$cached" ] && { [ -z "$sha256" ] || [ "${cached_sha256^^}" == "${sha256^^}" ]; }; then
                    echo "copie depuis le cache $cached"
                    cp "$cached" "$dest"
                else
                    # /opt/install n'existe (partage réseau serveur) que sur
                    # un poste rattaché à un serveur SambaEdu : sur un poste
                    # sans serveur (ou ce runner CI), /opt/local/packages sert
                    # de repli, alimenté ci-dessous après chaque téléchargement
                    # réussi, pour éviter de re-télécharger à chaque run.
                    cached_sha256="$(sha256sum "$localcache" 2>/dev/null | cut -d' ' -f1)"
                    if [ -f "$localcache" ] && { [ -z "$sha256" ] || [ "${cached_sha256^^}" == "${sha256^^}" ]; }; then
                        echo "copie depuis le cache local $localcache"
                        cp "$localcache" "$dest"
                    else
                        echo "téléchargement de $url"
                        if ! wget -q -O "$dest" "$url"; then
                            echo "échec du téléchargement de $url"
                            rm -f "$dest"
                            ok=0
                            dlerrors+=("url injoignable : $url")
                            continue
                        fi
                    fi
                fi
            fi

            if [ -n "$sha256" ]; then
                got="$(sha256sum "$dest" | cut -d' ' -f1)"
                if [ "${got^^}" != "${sha256^^}" ]; then
                    echo "sha256 invalide pour $dest (attendu $sha256, obtenu $got)"
                    ok=0
                    dlerrors+=("checksum différent : $saveto (attendu ${sha256^^}, obtenu ${got^^})")
                    continue
                fi
            fi

            # alimente /opt/local/packages (pas /opt/install, qui n'est
            # écrit que côté serveur) uniquement si le fichier n'y est pas
            # déjà et que $dest vient de passer la vérification sha256
            # ci-dessus (ou qu'aucun sha256 n'est fourni par la recette).
            if [ ! -f "$localcache" ] && [ ! -f "/opt/install/$saveto" ]; then
                mkdir -p "$(dirname "$localcache")" 2>/dev/null && cp "$dest" "$localcache" 2>/dev/null
            fi
        done < <(python3 -c '
import sys, xml.etree.ElementTree as ET
tree = ET.parse(sys.argv[1])
for d in tree.getroot().iter("download"):
    print(d.get("url","") + "\t" + d.get("saveto","") + "\t" + d.get("sha256sum",""))
' "$srcfile")
    done

    if [ "$ok" != "1" ]; then
        INSTALL_WPKG_DL_ERROR="$(IFS='; '; echo "${dlerrors[*]}")"
        echo "téléchargement des fichiers d'installation incomplet pour $appli : $INSTALL_WPKG_DL_ERROR"
        return 3
    fi

    # configuration préalable du prefix (winetricks/script), si la recette le
    # demande via <package><wine winetricks="..." script="..."/></package> -
    # doit se faire après le téléchargement (le script= éventuel est un
    # <download> comme un autre) et avant l'installation Windows proprement
    # dite ci-dessous, dont elle peut être un prérequis.
    if ! apply_wine_config "$xmlsrc" "$prefixname" "$pkgid"; then
        return 4
    fi

    # certains installeurs silencieux (dont celui de 7-Zip) rendent la main
    # avant d'avoir fini d'écrire tous les fichiers sur disque (plus visible
    # sous Wine que sous Windows) : la vérification post-installation de
    # wpkg-se4.js peut donc échouer alors que l'application est bel et bien
    # installée. wpkg est idempotent (la vérification pré-installation d'un
    # nouvel essai constate alors que le paquet est déjà installé) : on
    # retente donc quelques fois avant de déclarer un échec définitif.
    local log="/var/tmp/$USER/wine/wine-$prefixname-wpkg.log"
    local attempt
    local wpkg_status
    for attempt in 1 2 3; do
        # certains installeurs affichent leur propre boîte d'erreur, ou wine
        # affiche sa fenêtre de crash (winedbg, désactivée dans create_prefix
        # mais on garde ce filet de sécurité) : sans limite de temps, un test
        # non interactif resterait bloqué indéfiniment sur un clic humain qui
        # ne viendra jamais. timeout garantit qu'on ne reste jamais coincé.
        # WINEDLLOVERRIDES=winemenubuilder.exe= : winemenubuilder n'a pas
        # d'option pour désactiver spécifiquement l'export d'icônes vers le
        # VRAI bureau linux (confirmé en lisant son binaire) - il le fait
        # unilatéralement pour tout raccourci créé pendant l'installation, ce
        # qui pollue le bureau réel de l'utilisateur pour chaque appli
        # testée. On le désactive donc entièrement ici (les .lnk bruts
        # restent dans le prefix, utilisés directement par
        # run_prefix_app/smoke_test_app) ; create_debian_source le
        # réactive ponctuellement, pour la seule appli qu'on package
        # réellement, où avoir un menu/icônes est nécessaire. wpkg-native.py
        # transmet cette variable d'environnement aux commandes wine qu'il
        # lance pour les <install cmd="...">.
        WINEDLLOVERRIDES='winemenubuilder.exe=' \
            timeout --kill-after=10 300 \
            wpkg-native.py --packages "$WPKG_DIR/packages.xml" --prefix "$PREFIX" \
            >"$log.attempt$attempt" 2>&1
        wpkg_status=$?
        cp "$log.attempt$attempt" "$log"
        if grep -q "RESULT:$pkgid:OK" "$log"; then
            echo "$appli : installation réussie (log : $log, essai $attempt/3)"
            return 0
        fi
        if [ "$wpkg_status" -eq 124 ] || [ "$wpkg_status" -eq 137 ]; then
            echo "$appli : installation interrompue après 300s (blocage probable : boîte de dialogue, crash) (essai $attempt/3)"
        fi
        # état propre pour le prochain essai : un process tué par timeout
        # peut laisser une boîte de dialogue ou un mutex qui referait
        # bloquer l'essai suivant sinon.
        WINEPREFIX="$PREFIX" wineserver -k >/dev/null 2>&1
        sleep 3
    done

    echo "$appli : échec de l'installation (log : $log)"
    return 1
}

# lance brièvement l'application installée pour vérifier qu'elle ne plante
# pas immédiatement - un signal complémentaire, PAS une preuve de bon
# fonctionnement (une appli bloquée sur une boîte de dialogue de première
# utilisation est indiscernable ici d'une appli qui marche). N'utilise
# jamais le PID bash du lancement en arrière-plan pour détecter/tuer le
# process réel : sur un lancement via .lnk, ce PID est celui du wrapper
# "start.exe" de wine, jamais l'exécutable réel (vérifié via pstree) - on
# utilise donc "wine tasklist" (le vrai tasklist.exe de wine, scopé au
# WINEPREFIX) pour observer les process réels, via un diff avant/après
# (aucune balise StartupWMClass disponible ici : winemenubuilder, seule
# source de cette information, est désactivé pendant install_wpkg - voir
# find_prefix_shortcuts). wineserver -k pour tout arrêter proprement à la
# fin. Affiche sur stdout un statut parmi : SMOKE-OK, SMOKE-FAIL,
# SMOKE-SKIPPED-no-shortcut, SMOKE-SKIPPED-tasklist-unavailable.
# smoke_test_app appli prefix
function smoke_test_app() {
    local appli="$1" prefixname="$2"
    local PREFIX="/var/tmp/$USER/wine/wine-$prefixname/prefix"
    local grace=10

    local lnk
    lnk="$(find_prefix_shortcuts "$prefixname" | head -n1)"
    if [ -z "$lnk" ]; then
        echo "SMOKE-SKIPPED-no-shortcut"
        return 0
    fi

    # wine tasklist encode sa sortie dans la page de code OEM (pas UTF-8) :
    # grep -a force le mode texte, sinon grep peut détecter à tort un
    # "fichier binaire" sur les octets non-UTF-8 des colonnes accentuées
    # (ex: "Mémoire") et se comporter de façon peu fiable.
    local before status
    if ! before="$(WINEPREFIX="$PREFIX" wine tasklist 2>/dev/null)"; then
        echo "SMOKE-SKIPPED-tasklist-unavailable"
        return 0
    fi

    local log="/var/tmp/$USER/wine/wine-$prefixname-smoke.log"
    WINEPREFIX="$PREFIX" WINEDLLOVERRIDES='winemenubuilder.exe=' wine "$lnk" >"$log" 2>&1 &
    sleep "$grace"

    local after new_procs
    if ! after="$(WINEPREFIX="$PREFIX" wine tasklist 2>/dev/null)"; then
        status="SMOKE-SKIPPED-tasklist-unavailable"
    else
        # une ligne présente après le lancement mais pas avant = un nouveau
        # process réel (l'appli elle-même, pas le wrapper start.exe qui,
        # lui, apparaît puis disparaît généralement avant ce point).
        new_procs="$(comm -13 <(echo "$before" | sort) <(echo "$after" | sort))"
        if [ -n "$new_procs" ]; then
            status="SMOKE-OK"
        else
            status="SMOKE-FAIL"
        fi
    fi

    WINEPREFIX="$PREFIX" wineserver -k >/dev/null 2>&1
    echo "$status"
}

# crée (ou remet à niveau) un prefix wine. Non-interactif (wineboot --init),
# utilisable en CI. Ne dépend plus de wsh57/msxml3 (winetricks) : ces verbes
# n'étaient nécessaires que pour faire tourner wpkg-se4.js (JScript/MSXML)
# sous wine, remplacé par wpkg-native.py (voir install_wpkg), qui n'a besoin
# d'aucun des deux.
function create_prefix() {
    local prefixname="$1"
    local image="wine-$prefixname"
    local wp="/var/tmp/$USER/wine/$image/prefix"
    local fresh=1

    # prefix partagé déjà monté sur cette machine (mise à jour d'un prefix
    # existant) : on le réutilise tel quel, jamais de réinitialisation.
    if mountpoint -q "$wp" 2>/dev/null; then
        fresh=0
    # sinon, avant de créer un prefix tout neuf, on simule le
    # download_wine_prefix d'un postinst de paquet sambaedu-* (voir
    # wine.inc.sh) : le prefix peut déjà exister côté serveur (déjà publié
    # par un run précédent, ou par un autre poste) sans jamais avoir été
    # utilisé sur CETTE machine (CI, ou poste tout juste réinstallé) - dans
    # ce cas il faut télécharger l'image et la monter, pas repartir de zéro.
    # Échoue silencieusement si le prefix n'existe pas encore côté serveur :
    # cas normal d'un tout nouveau prefix, cf. plus bas.
    elif sudo /usr/share/sambaedu/scripts/download_wine.sh "$image" >/dev/null 2>&1 \
        && systemctl --user start "wine-prepare-prefix@${image}.service" >/dev/null 2>&1 \
        && mountpoint -q "$wp" 2>/dev/null; then
        echo "prefix partagé $image téléchargé et monté."
        fresh=0
    fi

    if [ "$fresh" = 1 ]; then
        mkdir -p "$wp"
        WINEDLLOVERRIDES='winemenubuilder.exe=' WINEPREFIX="$wp" wineboot --init >/dev/null 2>&1
    fi

    WINEPREFIX="$wp" wineserver -w
    # désactive la fenêtre de crash de wine (winedbg) : sans ça, un
    # installeur qui plante sous wine affiche une boîte de dialogue qui
    # attend un clic humain et bloque indéfiniment un test non interactif.
    WINEPREFIX="$wp" wine reg add "HKEY_CURRENT_USER\\Software\\Wine\\WineDbg" /v ShowCrashDialog /t REG_DWORD /d 0 /f >/dev/null 2>&1

    [ "$fresh" = 1 ] || return 0

    # wineboot --init pointe par défaut Desktop/Documents/Pictures/Music/
    # Videos/Downloads (drive_c/users/$USER/*) vers les VRAIS dossiers de
    # l'utilisateur (Documents et Pictures pointent même directement sur
    # $HOME en entier !). Un installeur qui écrit là (raccourci sur le
    # bureau, fichier dans "Mes documents"...) pollue donc directement les
    # vrais fichiers de l'utilisateur. On remplace tout symlink qui pointe
    # sous $HOME par un vrai dossier local, confiné au prefix jetable.
    # (pour un prefix partagé réutilisé, prepare_prefix.sh a déjà fait
    # l'équivalent - voir wine-prepare-prefix@.service)
    local f target
    for f in "$wp/drive_c/users/$USER"/*; do
        [ -L "$f" ] || continue
        target="$(readlink -f "$f")"
        case "$target" in
        "$HOME" | "$HOME"/*)
            rm -f "$f"
            mkdir -p "$f"
            ;;
        esac
    done
}

# boucle d'intégration continue : teste, chacune dans un prefix wine séparé,
# toutes les applications du dépôt sambaedu-wpkg-xml (stable/) pour lesquelles
# aucun paquet debian du même id n'existe (voir has_native_linux).
function test_wine_apps() {
    local report="/var/tmp/$USER/wine/wpkg-ci-report.txt"
    local mode="$1"

    # reprise : par défaut (pas d'argument "reset"), une appli déjà présente
    # dans le rapport (d'une exécution précédente, interrompue ou non)
    # n'est pas retestée. Une interruption (Ctrl+C, timeout bloquant, arrêt
    # manuel) ne fait donc jamais repartir de zéro.
    local -A done_apps=()
    if [ "$mode" = "reset" ]; then
        : >"$report"
    elif [ -f "$report" ]; then
        local line
        while IFS= read -r line; do
            done_apps["${line%%:*}"]=1
        done <"$report"
    else
        : >"$report"
    fi

    local total=0 xmlfile appli status smoke

    for xmlfile in "$wpkg_xml_repo/stable/"*.xml; do
        [ -f "$xmlfile" ] || continue
        appli="$(basename "$xmlfile" .xml)"

        [ -n "${done_apps[$appli]:-}" ] && continue

        if has_native_linux "$xmlfile"; then
            continue
        fi

        # inutile de tester sous wine une appli dont une dépendance a déjà
        # un équivalent natif (voir has_native_dependency) : cela reviendrait
        # à installer cette dépendance sous wine sans raison.
        if has_native_dependency "$xmlfile"; then
            continue
        fi

        total=$((total + 1))
        echo "=== Test de $appli ($total) ==="
        create_prefix "$appli"
        install_wpkg "$appli" "$appli"
        status=$?
        case $status in
            0)
                # signal complémentaire, non bloquant (cf. smoke_test_app) :
                # ne modifie jamais le statut ok/fail/untestable ci-dessus.
                smoke="$(smoke_test_app "$appli" "$appli")"
                echo "$appli: OK ($smoke)" >>"$report"
                ;;
            2)
                echo "$appli: NON TESTABLE (erreur d'accès au registre)" >>"$report"
                ;;
            3)
                # échec de téléchargement (url injoignable, checksum différent) :
                # un problème de recette/ressource externe, pas un défaut de
                # wpkg-se4.js ou de l'installeur lui-même - à distinguer
                # explicitement d'un échec d'installation (voir install_wpkg).
                echo "$appli: ECHEC (téléchargement : $INSTALL_WPKG_DL_ERROR)" >>"$report"
                ;;
            4)
                # échec de la configuration préalable du prefix (winetricks
                # ou script=, voir apply_wine_config) : distinct d'un échec
                # de l'installeur Windows lui-même.
                echo "$appli: ECHEC (configuration prefix winetricks/script)" >>"$report"
                ;;
            *)
                echo "$appli: ECHEC (installation)" >>"$report"
                ;;
        esac

        # le sweep sert à valider la compatibilité, pas à produire des
        # artefacts à publier (ça, c'est le rôle de new/install_wpkg/prepare/
        # image pour UNE appli choisie séparément) : chaque prefix (plusieurs
        # Go avec winetricks) est donc supprimé aussitôt son résultat
        # enregistré, sinon le disque se remplit en quelques dizaines
        # d'applis. Les logs (petits fichiers texte, utiles pour le
        # diagnostic) sont conservés, eux.
        WINEPREFIX="/var/tmp/$USER/wine/wine-$appli/prefix" wineserver -k >/dev/null 2>&1
        rm -rf "/var/tmp/$USER/wine/wine-$appli"
    done

    # décompte final à partir du rapport complet (cumulatif d'éventuelles
    # reprises précédentes), pas seulement des applis traitées dans cet
    # appel, pour rester cohérent après une reprise.
    local ok fail untestable skipped=0
    ok="$(grep -c ': OK' "$report")"
    untestable="$(grep -c ': NON TESTABLE' "$report")"
    fail="$(grep -c ': ECHEC' "$report")"
    for xmlfile in "$wpkg_xml_repo/stable/"*.xml; do
        [ -f "$xmlfile" ] || continue
        if has_native_linux "$xmlfile" || has_native_dependency "$xmlfile"; then
            skipped=$((skipped + 1))
        fi
    done
    echo "Résumé : $ok/$((ok + fail + untestable)) réussites, $fail échec(s), $untestable non testable(s) (registre), $skipped application(s) native(s) ou à dépendance native ignorée(s)."
    echo "Détail : $report"
}

# cherche le(s) raccourci(s) .lnk créés par l'installeur dans le prefix
# wine-$prefixname (Menu Démarrer, tous utilisateurs et utilisateur
# courant). Utilisé directement (winemenubuilder étant désactivé pendant
# install_wpkg, voir plus haut, aucun .desktop n'est généré à ce stade) par
# run_prefix_app et smoke_test_app. Un chemin unix par ligne sur stdout
# (peut être vide : certaines applis n'ont pas de raccourci, ex. outils en
# ligne de commande ou mises à jour silencieuses).
function find_prefix_shortcuts() {
    local prefixname="$1" PREFIX
    PREFIX="/var/tmp/$USER/wine/wine-$prefixname/prefix"
    find "$PREFIX/drive_c/ProgramData/Microsoft/Windows/Start Menu/Programs" \
        "$PREFIX/drive_c/users/$USER/AppData/Roaming/Microsoft/Windows/Start Menu/Programs" \
        -iname "*.lnk" 2>/dev/null
}

# lance interactivement une application déjà installée dans son prefix, pour
# validation fonctionnelle manuelle par un humain (contrairement à
# install_wpkg/test_wine, ce n'est PAS une action pensée pour le CI :
# premier plan, pas de log, pas de retry - l'utilisateur observe et
# interagit directement avec wine, Ctrl+C pour arrêter).
# run_prefix_app appli prefix [sous-chaine-desktop]
function run_prefix_app() {
    local appli="$1" prefixname="$2" filtre="$3"
    local PREFIX="/var/tmp/$USER/wine/wine-$prefixname/prefix"
    if [ ! -d "$PREFIX" ]; then
        echo "Le prefix $prefixname n'existe pas. Lancez d'abord : $0 new $prefixname puis $0 install_wpkg $appli $prefixname"
        return 1
    fi

    local shortcuts=()
    mapfile -t shortcuts < <(find_prefix_shortcuts "$prefixname")
    if [ -n "$filtre" ]; then
        local filtered=() d
        for d in "${shortcuts[@]}"; do
            [[ "$d" == *"$filtre"* ]] && filtered+=("$d")
        done
        shortcuts=("${filtered[@]}")
    fi

    if [ "${#shortcuts[@]}" -eq 0 ]; then
        echo "aucun raccourci .lnk trouvé pour le prefix wine-$prefixname (l'appli n'a peut-être pas de raccourci Menu Démarrer, ex. outil en ligne de commande)."
        return 1
    fi

    local choix
    if [ "${#shortcuts[@]}" -eq 1 ]; then
        choix="${shortcuts[0]}"
    else
        echo "plusieurs raccourcis trouvés pour wine-$prefixname :"
        local i
        for i in "${!shortcuts[@]}"; do
            echo "  $((i + 1))) $(basename "${shortcuts[$i]}" .lnk)"
        done
        local reponse
        read -r -p "lequel lancer (numéro, ou relancez avec un 3e argument pour filtrer) ? " reponse
        if ! [[ "$reponse" =~ ^[0-9]+$ ]] || [ "$reponse" -lt 1 ] || [ "$reponse" -gt "${#shortcuts[@]}" ]; then
            echo "choix invalide."
            return 1
        fi
        choix="${shortcuts[$((reponse - 1))]}"
    fi

    echo "lancement de $(basename "$choix" .lnk) (Ctrl+C pour arrêter) :"
    WINEPREFIX="$PREFIX" WINEDLLOVERRIDES='winemenubuilder.exe=' wine "$choix"
}

# cherche, dans tout le dépôt sambaedu-wpkg-xml (stable/testing/XP/manuel),
# la balise <linux type="wine" package="$1"> et affiche son attribut
# categories= (vide si non trouvée). $1 est ici la valeur de linux/@package,
# PAS le nom de fichier xml ni le <package id=> : les deux peuvent différer
# (ex : IndexEducPronote25.xml déclare <linux type="wine" package="pronote"
# ...>, et c'est "pronote" que l'opérateur passe à "prepare"), un scan
# complet du dépôt est donc nécessaire ici (pas de raccourci par nom de
# fichier possible, à la différence de find_package_by_id qui indexe sur
# l'id).
function xml_wine_categories() {
    python3 -c '
import sys, os, glob
import xml.etree.ElementTree as ET
pkgname, repo = sys.argv[1], sys.argv[2]
for sub in ("stable", "testing", "XP", "manuel"):
    for path in sorted(glob.glob(os.path.join(repo, sub, "*.xml"))):
        try:
            root = ET.parse(path).getroot()
        except ET.ParseError:
            continue
        for linux in root.findall(".//linux"):
            if linux.get("type") == "wine" and linux.get("package") == pkgname:
                print(linux.get("categories", ""))
                sys.exit(0)
print("")
' "$1" "$wpkg_xml_repo"
}

# copie un .desktop (lanceur d'application ou association mime) et ses
# icônes dans l'arborescence git du paquet, ajoute les fichiers au .install.
# Factorisé car create_debian_source traite désormais deux familles de
# .desktop (lanceurs, associations mime) avec un traitement mécanique
# identique (copie, réécriture Exec=/icônes, ajout au .install) mais un
# Exec=/Categories= différents selon le cas - $3/$4 portent cette différence,
# calculés par l'appelant.
function _install_desktop_and_icons() {
    local pkg="$1" desktop="$2" exec_line="$3" categories_line="$4"
    local dest="usr/share/applications" fichier icon icone rel idest src

    mkdir -p "$git/$dest"
    fichier="$(basename "${desktop// /_}")"
    cp "$desktop" "$git/$dest/$fichier"

    icon="$(sed -n 's/^Icon=//p' "$desktop")"
    icone="${icon// /_}"
    sed -i "s#Exec=.*#Exec=$exec_line#;/Path=/d;s/${icon}/${icone}/" "$git/$dest/$fichier"
    if [ -n "$categories_line" ]; then
        echo "Categories=$categories_line" >>"$git/$dest/$fichier"
    fi

    echo "$dest/$fichier" >>"$git/debian/sambaedu-$pkg.install"

    find "$HOME/.local/share/icons" -name "${icon}.png" | while read -r src; do
        rel="${src#"$HOME/.local/share/icons/"}"
        idest="usr/share/icons/${rel}"
        mkdir -p "$(dirname "$git/$idest")"
        cp "$src" "$git/${idest// /_}"
        # ajout icones dans le package
        echo "${idest// /_}" >>"$git/debian/sambaedu-$pkg.install"
    done
}

# copie le .desktop et et les icones associées dans l'arborescence git du package
# ajoute les fichier au sambaedu-$1.install
# ajoute la catégorie XDG (lue depuis <linux type="wine" package="$1"
# categories="..."> du dépôt sambaedu-wpkg-xml, ou $3 en override manuel)
# prépare un fichier usr/bin/$1 qui sert de lanceur pour le raccourci
# Si vous voulez pouvoir passer des arguments dans le .desktop,
# il faudra probablement éditer ce lanceur pour lancer directement l'exe et pas le .lnk qui est généralement
# utilisé dans le lanceur généré par wine

# les icones sont dans ./local/share/icons/hicolor/XxX/apps/
# on recherche leur nom à partir du raccourci créé par wine pour l'application
# on les copie toutes dans le git/sambaedu-wpkg-xml/sources/usr/share/icons
# on crée les dossiers si besoin.

# create_debian_source nom_package nom_image [categorie]

function create_debian_source() {
    # repart de zéro à chaque exécution (idempotent : "prepare" doit pouvoir
    # être rejoué pour mettre à jour un paquet existant sans accumuler de
    # doublons si le jeu de .desktop/icônes a changé entre deux versions).
    : >"$git/debian/sambaedu-$1.install"
    local stub_written=0

    # $3 reste un override manuel possible (rétrocompatibilité) ; par défaut
    # la catégorie est lue dans le xml (voir xml_wine_categories), qui
    # contient déjà le ";" final (convention XDG déjà en place), contrairement
    # à $3 qui n'en a pas.
    local categories="$3"
    if [ -z "$categories" ]; then
        categories="$(xml_wine_categories "$1")"
    elif [[ "$categories" != *\; ]]; then
        categories="$categories;"
    fi

    # install_wpkg désactive winemenubuilder pendant l'installation (pour ne
    # pas polluer le vrai bureau linux, voir install_wpkg) : aucun
    # .desktop/icône n'existe donc encore à ce stade pour cette appli. On
    # régénère ici, ponctuellement et volontairement (winemenubuilder
    # activé), les entrées de menu pour les seuls raccourcis de CE prefix -
    # nécessaire pour que les boucles ci-dessous (grep sur
    # ~/.local/share/applications/) trouvent quelque chose.
    #
    # mime_since (marqueur temporel, capturé AVANT ces appels) sert de
    # référence pour la détection des associations mime à l'étape 2 : un
    # prefix fraîchement créé possède déjà, dès install_wpkg, des .desktop
    # génériques Wine/Windows (association par défaut de .reg/.vbs/.chm/...
    # à rundll32/wscript/etc., pas à l'application installée - vérifié en
    # réel sur "coeur" : Name=rundll32/wscript/"A Wine application", rien à
    # voir avec l'appli). Comme ils portent le même WINEPREFIX dans leur
    # Exec=, un filtre par contenu seul ne peut pas les distinguer d'une
    # véritable association déclarée par l'appli - seule leur date permet de
    # les exclure : ils existent déjà avant que cette fonction ne traite les
    # raccourcis de l'appli, jamais après.
    # timeout --kill-after : winemenubuilder peut rester bloqué indéfiniment
    # sur certains .lnk (constaté en réel : un raccourci pointant vers un
    # DOSSIER plutôt qu'un exécutable, ex. "Exemples Regressi.lnk", au lieu
    # d'échouer proprement) - sans limite de temps, un seul raccourci de ce
    # genre bloque ce job pour toujours. Même principe que la boucle de
    # retry d'install_wpkg.
    local lnk mime_since
    mime_since="$(mktemp)"
    while IFS= read -r lnk; do
        WINEPREFIX="/var/tmp/$USER/wine/wine-$2/prefix" timeout --kill-after=10 60 \
            wine winemenubuilder.exe -w "$lnk" >/dev/null 2>&1
    done < <(find_prefix_shortcuts "$2")

    # raccourcis déjà revendiqués par un AUTRE paquet sambaedu-* du dépôt
    # (sources/debian/sambaedu-<autre>.install) : nécessaire dès qu'un 2e
    # paquet partage le même prefix wine ($2) - find_prefix_shortcuts (plus
    # haut) est scopé prefix, pas appli, donc winemenubuilder régénère aussi
    # les raccourcis des applis sœurs déjà packagées dans CE MÊME prefix
    # partagé, qui apparaîtraient sinon comme candidats pour CE paquet-ci
    # (cas réel visé : ajouter une 2e appli à svt-apps aux côtés de coeur).
    local -a claimed=()
    local install_file d
    for install_file in "$git"/debian/sambaedu-*.install; do
        [ -f "$install_file" ] || continue
        [ "$(basename "$install_file")" = "sambaedu-$1.install" ] && continue
        while IFS= read -r d; do
            claimed+=("$(basename "$d")")
        done < <(grep '^usr/share/applications/.*\.desktop$' "$install_file")
    done

    # étape 1 : lanceurs d'application (.desktop "normaux", ceux que
    # winemenubuilder écrit sous .../applications/wine/Programs/). Un
    # installeur peut en créer plusieurs (suite, sous-composants) : on
    # demande alors lesquels garder, avec la même UX que le sélecteur de
    # raccourcis déjà existant dans run_prefix_app (make-prefix:817-833).
    local -a candidates=()
    while IFS= read -r desktop; do
        local base is_claimed=0 c
        base="$(basename "$desktop")"
        for c in "${claimed[@]}"; do
            [ "$c" = "$base" ] && is_claimed=1 && break
        done
        [ "$is_claimed" = 1 ] && continue
        candidates+=("$desktop")
    done < <(grep -rl --include="*.desktop" "Exec=env \"WINEPREFIX=/var/tmp/$USER/wine/wine-$2/prefix\"" ~/.local/share/applications/wine/Programs/ 2>/dev/null)

    local -a selected=()
    if [ "${#candidates[@]}" -le 1 ]; then
        selected=("${candidates[@]}")
    else
        echo "plusieurs .desktop trouvés pour $1 :"
        local i
        for i in "${!candidates[@]}"; do
            echo "  $((i + 1))) $(basename "${candidates[$i]}" .desktop)"
        done
        local reponse idx
        # "read" échoue (EOF avant toute ligne complète) quand aucune entrée
        # n'est fournie du tout (ni tty, ni pipe) - à distinguer d'une ligne
        # VIDE effectivement reçue (tty : Enter direct, ou pipe : echo ''),
        # qui reste "all" comme documenté ci-dessous. Sans cette distinction,
        # un "prepare" lancé sans aucune entrée (job CI non interactif)
        # recevait une réponse vide interprétée à tort comme "all" - constaté
        # en réel sur sambaedu-regressi.install, contaminé par les
        # raccourcis Uninstall/Reset d'une appli sœur (algodoo). On échoue
        # explicitement plutôt que de deviner.
        if ! read -r -p "lesquels conserver (numéros séparés par espace/virgule, vide ou 'all' = tous) ? " reponse; then
            echo "aucune entrée fournie (EOF) - relancez interactivement, ou fournissez une entrée explicite (ex: echo '1' | make-prefix prepare $1 $2)." >&2
            return 1
        fi
        if [ -z "$reponse" ] || [ "$reponse" = "all" ]; then
            selected=("${candidates[@]}")
        else
            for idx in ${reponse//,/ }; do
                if [[ "$idx" =~ ^[0-9]+$ ]] && [ "$idx" -ge 1 ] && [ "$idx" -le "${#candidates[@]}" ]; then
                    selected+=("${candidates[$((idx - 1))]}")
                fi
            done
        fi
    fi

    local desktop run
    for desktop in "${selected[@]}"; do
        run="$(sed -n "s/$USER/\$USER/g;s/Exec=//p" "$desktop")"
        _install_desktop_and_icons "$1" "$desktop" "/usr/bin/$1" "$categories"

        # le lanceur n'est écrit qu'une fois par exécution, même si plusieurs
        # .desktop correspondent (ex: suites avec plusieurs raccourcis) :
        # sinon son contenu était dupliqué une fois par raccourci trouvé,
        # y compris au sein d'une seule exécution.
        # Utilisable tel quel (lance le .lnk d'origine, "$@" transmis au cas
        # où) : à adapter seulement si un argument doit atteindre l'exe réel
        # (ex: %f d'une association mime, voir _install_desktop_and_icons) -
        # il faut alors remplacer l'appel au .lnk ci-dessous par l'exécutable
        # réel de l'application, un .lnk ne transmettant pas fiablement les
        # arguments qu'on lui passe.
        # wine_scripts (pre.d/post.d, voir wine.inc.sh) est appelé
        # inconditionnellement : exec_script ne fait rien si aucun script
        # n'existe pour $1 (juste un test -f), donc pas besoin de le
        # commenter par défaut. Le paquet dépend déjà de
        # sambaedu-linux-stations-wine (voir plus bas), qui fournit
        # wine.inc.sh - d'où le "source" explicite ici, l'include n'étant
        # pas sourcé globalement par ailleurs.
        if [ "$stub_written" = 0 ]; then
            echo "#!/bin/bash
# lanceur pour $1
. /usr/share/sambaedu/includes/wine.inc.sh
wine_scripts pre $1 || true
$run \"\$@\"
wine_scripts post $1 || true
" >"$git/usr/bin/$1"
            chmod 755 "$git/usr/bin/$1"
            echo "usr/bin/$1" >>"$git/debian/sambaedu-$1.install"
            stub_written=1
        fi
        cp "$git/debian/sambaedu-appli.postinst.ex" "$git/debian/sambaedu-$1.postinst"
        sed -i "s/{{appli}}/$1/g" "$git/debian/sambaedu-$1.postinst"
        sed -i "s/{{prefix}}/wine-$2/g" "$git/debian/sambaedu-$1.postinst"
    done

    # étape 2 : .desktop d'association mime (enregistrement par l'installeur
    # d'un gestionnaire de type de fichier, distinct des lanceurs
    # d'application ci-dessus). winemenubuilder ne les écrit pas forcément au
    # même endroit que les lanceurs (confirmé en réel sur "coeur" : à plat
    # sous ~/.local/share/applications/, pas sous .../wine/Programs/) :
    # détection par contenu (WINEPREFIX + MimeType=) - mais restreinte aux
    # fichiers modifiés depuis mime_since (voir plus haut) : sans ce filtre
    # temporel, les .desktop génériques Wine (mêmes WINEPREFIX, déjà présents
    # AVANT cette fonction) seraient à tort traités comme des associations de
    # l'application. Dédoublonnage supplémentaire avec les lanceurs déjà
    # traités ci-dessus (un lanceur peut en théorie aussi déclarer MimeType=
    # sur lui-même).
    local -a mime_candidates=()
    local already d
    while IFS= read -r d; do
        mime_candidates+=("$d")
    done < <(find ~/.local/share/applications -name "*.desktop" -newer "$mime_since" 2>/dev/null \
        | xargs -r grep -l "WINEPREFIX=/var/tmp/$USER/wine/wine-$2/prefix" 2>/dev/null \
        | xargs -r grep -l '^MimeType=' 2>/dev/null)
    rm -f "$mime_since"

    for d in "${mime_candidates[@]}"; do
        already=0
        for desktop in "${selected[@]}"; do
            [ "$d" = "$desktop" ] && already=1 && break
        done
        [ "$already" = 1 ] && continue
        # même exclusion que pour les lanceurs (étape 1) : une association
        # mime déjà revendiquée par une appli sœur du même prefix partagé ne
        # doit pas être reprise ici non plus.
        base="$(basename "$d")"
        for c in "${claimed[@]}"; do
            [ "$c" = "$base" ] && already=1 && break
        done
        [ "$already" = 1 ] && continue
        _install_desktop_and_icons "$1" "$d" "/usr/bin/$1 %f" ""
        echo "association mime ajoutée : $(basename "$d") ($(sed -n 's/^MimeType=//p' "$d"))"
    done

    # ajout/mise à jour du bloc dans debian/control : on retire d'abord
    # l'éventuel bloc déjà présent pour ce paquet avant d'en écrire un
    # nouveau, plutôt qu'un simple grep -q de non-présence - sinon un
    # deuxième "prepare" avec un $2 (prefix) différent du premier (ex :
    # passage d'un prefix dédié à un prefix partagé, cas réel rencontré en
    # préparant "coeur") laisse une Description obsolète, la balise
    # Depends/Description n'étant alors jamais régénérée.
    awk -v pkg="Package: sambaedu-$1" '
        $0 == pkg { skip = 1; next }
        skip && $0 == "" { skip = 0; next }
        skip { next }
        { print }
    ' "$git/debian/control" >"$git/debian/control.tmp" && mv "$git/debian/control.tmp" "$git/debian/control"
    {
        echo ""
        echo "Package: sambaedu-$1"
        echo "Architecture: all"
        echo "Depends: \${misc:Depends}, sambaedu-linux-stations-wine"
        echo "Description: Package $1 pour l'image wine-$2."
        echo " Déploie sur le serveur se4fs l'image squashfs wine-$2, et ajoute les raccourcis et menus nécessaires."
    } >>"$git/debian/control"
}

# compile l'image squashfs à partir de l'image montée par l'utilisateur
# make_image prefix
function make_image() {
    PREFIX="/var/tmp/$USER/wine/wine-$1/prefix"
    mkdir -p "$PREFIX"
    # rename user and hostname in prefix
    sed -i "s/$USER/{{USER}}/g" $PREFIX/*.reg
    sed -i "s/$(hostname)/{{HOSTNAME}}/g" $PREFIX/*.reg
    mv "$PREFIX/drive_c/users/$USER" "$PREFIX/drive_c/users/{{USER}}"

    mksquashfs "$PREFIX" "/var/tmp/$USER/wine/wine-$1.img" -all-root -no-progress -no-xattrs -noappend # -quiet
    sha256sum "/var/tmp/$USER/wine/wine-$1.img" | cut -d' ' -f1 >"/var/tmp/$USER/wine/wine-$1.sha256"

    # revert user and hostname in prefix
    sed -i "s/{{USER}}/$USER/g" $PREFIX/*.reg
    sed -i "s/{{HOSTNAME}}/$(hostname)/g" $PREFIX/*.reg
    mv "$PREFIX/drive_c/users/{{USER}}" "$PREFIX/drive_c/users/$USER"
}
# construit le package sambaedu-$1 demandé
function make_package() {
    cd "$git" || return
    rm -f "*.deb"
    DEBFULLNAME="$USER" DEBEMAIL="$USER@sambaedu.org" dch -i "compilation du paquet $1"
    # "dh binary -psambaedu-$1" scopé (pas "debuild -us -uc -b", qui construit
    # TOUS les paquets binaires de ce dépôt monorepo) : évite qu'un paquet
    # tiers cassé ailleurs dans l'arbre (sambaedu-proteus/sambaedu-flowcode au
    # moment d'écrire ceci, .install référençant des fichiers absents) ne
    # fasse échouer dh_install globalement et ne produise un .deb vide pour
    # CE paquet - constaté en réel en construisant sambaedu-coeur et
    # sambaedu-notepadplusplus. DH_OPTIONS propage le scope dans les cibles
    # override_dh_install/override_dh_builddeb de debian/rules.
    fakeroot dh binary -psambaedu-$1
    find ../ -maxdepth 1 -name "sambaedu-*.deb" ! -name "sambaedu-$1*.deb" -delete
    cd
}

# installe l'image en local (il faut être root)
# compile le package et l'installe avec dpkg -i
#
function install_local() {
    dpkg -i "$git/../sambaedu-$1*.deb"
}

# pousse l'image sur le repo, pousse les fichiers dans un projet git, et lance la construction distante du paquet
# et sa publication par reprepro
# install_repo prefix nom "message de commit"
# committe les fichiers de paquet générés par create_debian_source pour $1
# (control/changelog/*.install/*.postinst/usr/bin/$1 et le contenu du
# .install), sans toucher à l'image ni au dépôt apt distant (voir
# install_repo pour la publication complète) : permet de committer tôt,
# même quand la reconstruction de l'image/paquet est différée (voir
# ci/auto-update-wine.sh de sambaedu-wpkg-xml, qui appelle cette action
# seule via `make-prefix commit`). "git commit" est un no-op silencieux
# (pas d'erreur) si create_debian_source n'a rien changé de nouveau -
# cas normal d'une mise à jour où rien n'a changé côté lanceur/icônes.
function commit_package_source() {
    cd "$git"
    # on commite uniquement les fichiers produits par create_debian_source
    # pour CE paquet (pas "commit -a") : sur un runner CI où le disque
    # persiste d'un job à l'autre, "-a" commiterait aussi toute modification
    # locale sans rapport (résidu d'un job précédent ou d'une investigation
    # manuelle sur ce runner).
    local -a to_add=(debian/control debian/changelog)
    local f
    for f in "debian/sambaedu-$1.install" "debian/sambaedu-$1.postinst" "usr/bin/$1"; do
        [ -e "$f" ] && to_add+=("$f")
    done
    if [ -f "debian/sambaedu-$1.install" ]; then
        while IFS= read -r f; do
            [ -n "$f" ] && [ -e "$f" ] && to_add+=("$f")
        done <"debian/sambaedu-$1.install"
    fi
    git add -- "${to_add[@]}"
    if git diff --cached --quiet; then
        echo "$1 : rien de nouveau à committer."
    else
        git commit -m "génération du paquet wine $1 $2" || return 1
    fi
    # échec constaté en réel (branches divergentes sans stratégie de
    # réconciliation configurée, puis échec d'authentification sur push) :
    # sans ce garde-fou explicite, le job continuait comme si de rien
    # n'était (aucun "set -e" dans ce script), laissant le dépôt distant
    # désynchronisé du .deb/image effectivement publiés sans que personne ne
    # le remarque.
    if ! git pull; then
        echo "$1 : échec de git pull (branches divergentes ? configurer pull.rebase/pull.ff sur ce clone)" >&2
        return 1
    fi
    if ! git push; then
        echo "$1 : échec de git push (credentials ? voir ~/.git-credentials sur ce runner)" >&2
        return 1
    fi
}

install_repo() {
    # on pose l'image et le checksum Azerty-12345437765
    SSH="ssh root@deb.sambaedu.org"
    SERVER="http://deb.sambaedu.org/wpkg/files/wine"

    # test si l'image existe déjà, on compare son checksum avec celui du dépôt
    if [ -f "/var/tmp/$USER/wine/wine-$2.sha256" ]; then
        if ! wget -q -O /tmp/wine-$2.sha256 $SERVER/wine-$2.sha256 || [ "$(cat /tmp/wine-$2.sha256)" != "$(cat /var/tmp/$USER/wine/wine-$2.sha256)" ]; then
            scp /var/tmp/$USER/wine/wine-$2.* root@deb.sambaedu.org:/var/www/wpkg/files/wine/
        fi
    fi
    commit_package_source "$1" "$2" || return 1
    # reprepro
    $SSH "mkdir -p /root/tmpse4"
    deb="$(ls $git/../sambaedu-$1*.deb 2>/dev/null | tail -1)"
    if [ -n "$deb" ]; then
    scp "$deb" root@deb.sambaedu.org:/root/tmpse4
    paquet=$(basename "$deb")
    $SSH "echo \"traitement de $paquet\"; reprepro -C se4XP -b /var/www/debian includedeb trixie /root/tmpse4/$paquet"
    $SSH "echo \"traitement de $paquet\"; reprepro -C stable -b /var/www/debian includedeb trixie /root/tmpse4/$paquet"
    $SSH "rm -fr /root/tmpse4/$paquet"
    fi
}

pull_repo() {
    mkdir -p $HOME/git
    cd $HOME/git
    if [ ! -d "$HOME/git/sambaedu-wpkg-xml" ]; then
        git clone https://gitlab.sambaedu.org/sambaedu/sambaedu-wpkg-xml.git
    fi
    cd $HOME/git/sambaedu-wpkg-xml
    git pull
    cd
}

action="$1"
package="$2"
if [ -z "$3" ]; then
    prefix="$package"
else
    prefix="$3"
fi
if [ -z "$action" ] || [ "$action" == "--help" ]; then
    echo "$0 new|install_wpkg|run|test_wine|prepare|image|build|install|publish <package> [prefix] [categorie]

 new : crée un nouveau prefix (non-interactif, adapté au CI) ou remet à niveau un existant.
    A faire avant d'installer l'application Windows. le prefix est dans /var/tmp/$USER/wine/wine-$prefix/prefix
    Normalement toute commande wine doit être précédée de WINEPREFIX=/var/tmp/$USER/wine/wine-$prefix/prefix.
    Installe également wsh57 et msxml3 (indispensables pour install_wpkg).
    En cas de modification d'un prefix déjà en place, cette étape n'est pas nécessaire.

 install_wpkg : installe l'application via le client wpkg windows (wpkg-se4.js), à partir de la
    recette du dépôt local sambaedu-wpkg-xml (~/git/sambaedu-wpkg-xml). Le prefix doit déjà exister
    (make-prefix new). Ne dépend pas du domaine SambaEdu ni d'un poste joint au domaine. Installe
    aussi les dépendances éventuelles (<depends package-id=...>) de la recette.

 run : lance l'application déjà installée (install_wpkg) pour une validation fonctionnelle manuelle.
    Ce n'est PAS une action pour le CI : premier plan, interactif, Ctrl+C pour arrêter.
    make-prefix run <package> <prefix> [sous-chaine-desktop pour désambiguïser]

 test_wine : lance install_wpkg (dans un prefix séparé pour chacune) pour toutes les applications de
    sambaedu-wpkg-xml/stable pour lesquelles aucun paquet debian du même id n'existe, ni aucune de leurs
    dépendances (apt-cache show / balise <linux>). Pensé pour le CI.
    Rapport dans /var/tmp/$USER/wine/wpkg-ci-report.txt : une appli déjà présente dans ce rapport n'est PAS
    retestée (reprise automatique après une interruption). Pour repartir de zéro : make-prefix test_wine reset

 prepare : fusionne les éléments du prefix en cours avec le dépôt git sambaedu-wpkg-xml/sources. Tente de récupérer les
    .desktop (lanceurs et associations mime), icones, et crée un lanceur shell utilisable tel quel (lance le
    raccourci .lnk d'origine). Si plusieurs lanceurs sont trouvés, demande lesquels garder. À adapter manuellement
    seulement pour les cas particuliers (arguments à transmettre à l'exe réel, ex: association mime).
    [categorie] est optionnel : par défaut lu depuis categories= sur <linux type="wine"> dans la recette xml.

 image : construit l'image squashfs du prefix (assez long)

 build : construit le paquet debian. Le numéro de version est incrémenté.

 publish : pousse sur le dépôt gitlab et deb le paquet (authentification sur deb.sambaedu.org et gitlab indispensables).
    seuls les paquets indiqués seront publiés. Si certains ont des dépendances il faut publier aussi les dépendances.

 install : installe localement le paquet généré (droits root indispensables)"
fi

case $action in
new)
    create_prefix "$prefix"
    export WINEPREFIX="/var/tmp/$USER/wine/wine-$prefix/prefix"
    echo "le prefix /var/tmp/$USER/wine/wine-$prefix/prefix a été créé est est actif par défaut pour wine pour cette session."
    ;;
install_wpkg)
    # garantit que ~/git/sambaedu-wpkg-xml existe et est à jour avant de
    # chercher une recette dedans (find_package_xml) : sur un runner CI où
    # le home peut être réinitialisé entre deux logons, ce clone n'est pas
    # garanti présent avant ce tout premier job qui en a besoin.
    pull_repo
    install_wpkg $package $prefix
    status=$?
    if [ "$status" = 0 ]; then
        smoke="$(smoke_test_app "$package" "$prefix")"
        echo "smoke-test : $smoke"
        # sans garde-fou humain (pipeline CI en mode mise à jour, où
        # validate_manually devient automatique), le smoke-test est le seul
        # signal disponible que l'appli démarre correctement - un SMOKE-FAIL
        # doit donc faire échouer ce job, pas seulement être loggué. Les
        # SMOKE-SKIPPED-* (pas de raccourci, tasklist indisponible) restent
        # non bloquants : ils signifient "indéterminé", pas "échec".
        [ "$smoke" = "SMOKE-FAIL" ] && status=1
    fi
    exit $status
    ;;
run)
    run_prefix_app "$package" "$prefix" "$4"
    ;;
test_wine)
    test_wine_apps "$package"
    ;;
prepare)
    pull_repo
    if ! create_debian_source $package $prefix $4; then
        exit 1
    fi
    echo "le dépôt local $package a été mis à jour. modifiez manuellement les choses nécessaires..."
    ;;
image)
    make_image $prefix
    ;;
build)
    make_package $package
    ;;
install)
    install_local $package $prefix
    ;;
commit)
    pull_repo
    commit_package_source $package $prefix
    ;;
publish)
    install_repo $package $prefix
    ;;
esac
