Après avoir désinstallé Control Expert pour la 20ᵉ fois
C’est pourquoi je vous propose aujourd’hui un programme de qualité que Schneider n’est visiblement pas capable de fournir : un désinstallateur propre pour Control Expert, avec des étapes claires et automatisées
En espérant que le code ne soit pas trop long pour ici (python):
Code : Tout sélectionner
# main.py
# Assistant nettoyage Control Expert (désinstall -> dossiers -> registre) - règle fuzzy >= 95%
# UI: tkinter (natif) | Matching: rapidfuzz | Registre: winreg
#
# Build EXE (PowerShell / CMD):
# pip install rapidfuzz pyinstaller
# pyinstaller --onefile --noconsole --uac-admin main.py
#
# Notes:
# - Ce script ne désinstalle pas automatiquement : il détecte (fuzzy >= 95%) ce qui reste.
# - Suppression dossiers + clés registre via boutons.
# - Nécessite admin pour HKLM/HKCR et certains dossiers.
import os
import re
import sys
import time
import ctypes
import shutil
import subprocess
import unicodedata
import traceback
import tkinter as tk
from tkinter import ttk, messagebox
import winreg
from rapidfuzz import fuzz
# =========================
# CONFIG (TA LISTE EXACTE)
# =========================
EXPECTED_SOFTWARE = [
# Produit principal
"Control Expert",
# Composants
"EcoStruxure Control Expert PsX DTM Library",
"Schneider Electric Mx80 HART Gateway DTM Library",
"Schneider Electric BME NOR DTM Library",
"Schneider Electric PME DTM Library",
"CXM DTM",
"FDTENV64",
"Schneider Electric Software Update",
"Schneider electric License Manager",
"Schneider Electric Floating Licence Manager",
]
FOLDERS_TO_DELETE = [
# Common Files — Schneider Electric Shared
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\SRCSDK",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\RAT",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\SSTA",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\Tlb",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\Common",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\CommonControlExpert",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\ControlExpert",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\Security",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\CetPrivateInterface",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\Generic Modbus TCP DTM",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\LOKI",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\PMEGenericDTM",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\PSxERIODTM",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\PSxM580_DTM",
r"C:\Program Files (x86)\Common Files\Schneider Electric Shared\Unity Pro Generic EthernetIP DTM",
# Program Files (x86) — Control Expert versions
r"C:\Program Files (x86)\Schneider Electric\Control Expert 16.1",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 16.0",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 15.3",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 15.2",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 15.1",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 15.0",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 14.1",
r"C:\Program Files (x86)\Schneider Electric\Control Expert 14.0",
# ProgramData — Control Expert + composants
r"C:\ProgramData\Schneider Electric\Control Expert 16.1",
r"C:\ProgramData\Schneider Electric\Control Expert 16.0",
r"C:\ProgramData\Schneider Electric\Control Expert 15.3",
r"C:\ProgramData\Schneider Electric\Control Expert 15.2",
r"C:\ProgramData\Schneider Electric\Control Expert 15.1",
r"C:\ProgramData\Schneider Electric\Control Expert 15.0",
r"C:\ProgramData\Schneider Electric\Control Expert 14.1",
r"C:\ProgramData\Schneider Electric\Control Expert 14.0",
r"C:\ProgramData\Schneider Electric\CommonControlExpert",
r"C:\ProgramData\Schneider Electric\Topology Manager",
r"C:\ProgramData\Schneider Electric\M580 Master DTM",
r"C:\ProgramData\Schneider Electric\NORDTM",
r"C:\ProgramData\Schneider Electric\PSx Ethernet RIO DTM",
r"C:\ProgramData\Schneider Electric\PSx LOKI DTM",
r"C:\ProgramData\Schneider Electric\PTK Generic DTM",
r"C:\ProgramData\Schneider Electric\SECXMDTM",
r"C:\ProgramData\Schneider Electric\Unity Pro Generic EthernetIP DTM",
]
REGISTRY_KEYS_TO_DELETE = [
# HKLM Wow6432Node
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Control Expert"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Control ExpertConfCatalog"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Control ExpertTypesLibrary"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Control ExpertTMotion"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\securityeditor"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Common"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Generic Modbus TCP DTM"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\M580 Master DTM"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\PSx Ethernet RIO DTM"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\PSx LOKI DTM"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Unity Pro Generic EthernetIP DTM"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Schneider Electric\Unity FDTDTMCommon"),
# HKCU
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\Control Expert"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\Ecostruxure Control Expert PSx DTM Library"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\PME Generic DTM"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\RATsvr"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\Schneider Electric BME NOR DTM Library"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\Schneider Electric Placeholder DTM library"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Schneider Electric\Schneider Electric PME DTM Library"),
# HKCR
(winreg.HKEY_CLASSES_ROOT, r"Mx80MasterDTM.DTMCore"),
(winreg.HKEY_CLASSES_ROOT, r"Mx80MasterDTM.DTMCore.1"),
(winreg.HKEY_CLASSES_ROOT, r"PSxEIPMasterDTM.DTMCore"),
(winreg.HKEY_CLASSES_ROOT, r"PSxEIPMasterDTM.DTMCore.1"),
(winreg.HKEY_CLASSES_ROOT, r"PTK_GenericDTM.DTMCore"),
(winreg.HKEY_CLASSES_ROOT, r"PTK_GenericDTM.DTMCore.1"),
]
FUZZY_THRESHOLD = 95
AUTO_REFRESH_MS = 2000
LOG_FILE = "cleanup_log.txt"
# =========================
# UTILS
# =========================
def is_admin() -> bool:
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def log(msg: str) -> None:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}\n"
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
def normalize_text(s: str) -> str:
if not s:
return ""
s = s.lower()
s = unicodedata.normalize("NFKD", s)
s = "".join(ch for ch in s if not unicodedata.combining(ch))
s = re.sub(r"[^\w\s]", " ", s, flags=re.UNICODE)
s = re.sub(r"\s+", " ", s).strip()
return s
def safe_exists_dir(path: str) -> bool:
try:
return os.path.isdir(path)
except Exception:
return False
def delete_dir(path: str) -> tuple[bool, str]:
try:
if not os.path.exists(path):
return True, "Déjà absent"
shutil.rmtree(path, ignore_errors=False)
return True, "Supprimé"
except Exception as e:
return False, str(e)
def _rmtree_force_onerror(func, path, _exc_info):
"""
Retire l'attribut lecture seule puis retente l'opération qui a échoué.
Utile après reprise des droits sur certains fichiers/dossiers protégés.
"""
try:
os.chmod(path, 0o700)
func(path)
except Exception:
pass
def delete_dir_admin(path: str) -> tuple[bool, str]:
"""
Suppression renforcée :
- nécessite une instance élevée ;
- attribue le propriétaire au groupe Administrateurs ;
- accorde le contrôle total au groupe Administrateurs ;
- retire les attributs lecture seule / système / caché ;
- supprime ensuite récursivement le dossier.
Les SID sont utilisés afin d'éviter les problèmes de nom localisé
du groupe Administrateurs sur Windows.
"""
try:
if not os.path.exists(path):
return True, "Déjà absent"
if not is_admin():
return False, "Droits administrateur requis"
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
commands = [
[
"icacls",
path,
"/setowner",
"*S-1-5-32-544",
"/T",
"/C",
"/Q",
],
[
"icacls",
path,
"/grant:r",
"*S-1-5-32-544:(OI)(CI)F",
"/T",
"/C",
"/Q",
],
[
"attrib",
"-R",
"-S",
"-H",
os.path.join(path, "*"),
"/S",
"/D",
],
]
for cmd in commands:
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
shell=False,
creationflags=creationflags,
timeout=120,
)
log(
"FOLDER ADMIN CMD: "
+ subprocess.list2cmdline(cmd)
+ f" => rc={completed.returncode}"
)
if completed.stdout.strip():
log("FOLDER ADMIN STDOUT: " + completed.stdout.strip())
if completed.stderr.strip():
log("FOLDER ADMIN STDERR: " + completed.stderr.strip())
shutil.rmtree(path, ignore_errors=False, onerror=_rmtree_force_onerror)
if os.path.exists(path):
return False, "Le dossier existe encore (fichier probablement verrouillé)"
return True, "Supprimé avec droits admin"
except subprocess.TimeoutExpired:
return False, "Timeout pendant la reprise des droits"
except Exception as e:
return False, str(e)
def reg_key_exists(root, subkey: str) -> bool:
try:
with winreg.OpenKey(root, subkey, 0, winreg.KEY_READ):
return True
except FileNotFoundError:
return False
except Exception:
# accès refusé = existe très probablement, on considère "existe"
return True
def reg_delete_tree(root, subkey: str) -> tuple[bool, str]:
"""
Suppression récursive d'une clé registre.
"""
try:
# Ouvrir la clé à supprimer
with winreg.OpenKey(root, subkey, 0, winreg.KEY_READ | winreg.KEY_WRITE) as hkey:
# Supprimer sous-clés d'abord
while True:
try:
child = winreg.EnumKey(hkey, 0)
except OSError:
break
ok, err = reg_delete_tree(root, subkey + "\\" + child)
if not ok:
return False, err
# Puis supprimer la clé elle-même
winreg.DeleteKey(root, subkey)
return True, "Supprimée"
except FileNotFoundError:
return True, "Déjà absente"
except PermissionError as e:
return False, f"Accès refusé: {e}"
except OSError as e:
# parfois: clé non vide / verrouillée
return False, f"Erreur: {e}"
except Exception as e:
return False, f"Erreur: {e}"
def _reg_root_name(root) -> str:
if root == winreg.HKEY_LOCAL_MACHINE:
return "HKLM"
if root == winreg.HKEY_CURRENT_USER:
return "HKCU"
if root == winreg.HKEY_CLASSES_ROOT:
return "HKCR"
return str(root)
def _read_reg_value(handle, name: str) -> str:
try:
val, _ = winreg.QueryValueEx(handle, name)
return str(val).strip() if val is not None else ""
except OSError:
return ""
def read_installed_entries() -> list[dict]:
r"""
Lit les applis installées avec DisplayName + UninstallString dans:
- HKLM\...\Uninstall
- HKLM\WOW6432Node\...\Uninstall
- HKCU\...\Uninstall
"""
entries: list[dict] = []
targets = [
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
]
for root, path in targets:
try:
with winreg.OpenKey(root, path, 0, winreg.KEY_READ) as k:
i = 0
while True:
try:
sub = winreg.EnumKey(k, i)
except OSError:
break
i += 1
full_key = path + "\\" + sub
try:
with winreg.OpenKey(root, full_key, 0, winreg.KEY_READ) as sk:
dn = _read_reg_value(sk, "DisplayName")
if not dn:
continue
entries.append({
"display_name": dn,
"root": root,
"root_name": _reg_root_name(root),
"parent_key": path,
"subkey": sub,
"full_key": full_key,
"uninstall_string": _read_reg_value(sk, "UninstallString"),
"quiet_uninstall_string": _read_reg_value(sk, "QuietUninstallString"),
"modify_path": _read_reg_value(sk, "ModifyPath"),
})
except OSError:
pass
except OSError:
pass
# dédoublonnage stable par ruche + clé registre
seen = set()
out = []
for e in entries:
key = (e.get("root_name", ""), e.get("full_key", ""))
if key not in seen:
out.append(e)
seen.add(key)
return out
def read_installed_displaynames() -> list[str]:
return [e["display_name"] for e in read_installed_entries()]
def best_fuzzy_entry(expected: str, installed: list[dict]) -> tuple[dict | None, int]:
"""
Retourne (best_entry, best_score) selon fuzz.ratio sur DisplayName normalisé.
"""
exp_n = normalize_text(expected)
best_entry = None
best_score = 0
for entry in installed:
name = entry.get("display_name", "")
score = int(round(fuzz.ratio(exp_n, normalize_text(name))))
if score > best_score:
best_score = score
best_entry = entry
if best_score == 100:
break
return best_entry, best_score
def best_fuzzy_match(expected: str, installed) -> tuple[str, int]:
"""
Compatibilité avec l'ancien code: accepte list[str] ou list[dict].
"""
if installed and isinstance(installed[0], dict):
entry, score = best_fuzzy_entry(expected, installed)
return (entry.get("display_name", "") if entry else "", score)
exp_n = normalize_text(expected)
best_name = ""
best_score = 0
for name in installed:
score = int(round(fuzz.ratio(exp_n, normalize_text(name))))
if score > best_score:
best_score = score
best_name = name
if best_score == 100:
break
return best_name, best_score
def build_uninstall_command(entry: dict) -> str:
"""
Construit une commande de désinstallation sûre.
Priorité:
- MSI GUID détecté dans la clé ou dans l'UninstallString => msiexec /x {GUID}
- sinon UninstallString classique.
"""
if not entry:
return ""
raw = entry.get("uninstall_string", "") or ""
subkey = entry.get("subkey", "") or ""
guid_re = r"\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}"
if re.fullmatch(guid_re, subkey):
return f"msiexec.exe /x {subkey}"
m = re.search(guid_re, raw)
if m and re.search(r"msiexec", raw, flags=re.IGNORECASE):
return f"msiexec.exe /x {m.group(0)}"
return raw.strip()
def relaunch_as_admin() -> bool:
"""
Relance le script / exe avec élévation UAC.
Retourne True si la demande d'élévation a été envoyée.
"""
try:
if getattr(sys, "frozen", False):
exe = sys.executable
params = " ".join(f'"{arg}"' for arg in sys.argv[1:])
else:
exe = sys.executable
script = os.path.abspath(sys.argv[0])
params = " ".join([f'"{script}"'] + [f'"{arg}"' for arg in sys.argv[1:]])
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params, None, 1)
return int(rc) > 32
except Exception as e:
log(f"ADMIN RELAUNCH ERROR: {e}")
return False
# =========================
# UI
# =========================
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Assistant nettoyage Control Expert - présents uniquement")
self.geometry("1100x650")
self.minsize(1000, 600)
self.installed_cache: list[dict] = []
self._build_header()
self._build_tabs()
self._build_footer()
self.refresh_all()
self.after(AUTO_REFRESH_MS, self._auto_refresh)
def _build_header(self):
frm = ttk.Frame(self, padding=10)
frm.pack(fill="x")
self.admin_var = tk.StringVar()
self.admin_var.set("Admin: " + ("OUI" if is_admin() else "NON"))
lbl_admin = ttk.Label(frm, textvariable=self.admin_var)
lbl_admin.pack(side="left")
self.status_var = tk.StringVar(value="Prêt")
lbl_status = ttk.Label(frm, textvariable=self.status_var)
lbl_status.pack(side="right")
def _build_tabs(self):
self.nb = ttk.Notebook(self)
self.nb.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.tab_soft = ttk.Frame(self.nb, padding=10)
self.tab_folders = ttk.Frame(self.nb, padding=10)
self.tab_reg = ttk.Frame(self.nb, padding=10)
self.nb.add(self.tab_soft, text="1) Désinstallation")
self.nb.add(self.tab_folders, text="2) Dossiers")
self.nb.add(self.tab_reg, text="3) Registre")
self._build_tab_soft()
self._build_tab_folders()
self._build_tab_reg()
def _build_footer(self):
frm = ttk.Frame(self, padding=10)
frm.pack(fill="x")
btn_refresh = ttk.Button(frm, text="Actualiser", command=self.refresh_all)
btn_refresh.pack(side="left")
self.btn_continue = ttk.Button(frm, text="Continuer (étape suivante)", command=self._continue_next)
self.btn_continue.pack(side="left", padx=10)
self.btn_finish = ttk.Button(frm, text="Terminer + redémarrer", command=self._finish)
self.btn_finish.pack(side="right")
self._update_continue_state()
def _build_tab_soft(self):
top = ttk.Frame(self.tab_soft)
top.pack(fill="x")
ttk.Label(
top,
text=f"Règle: seuls les logiciels encore présents sont affichés (similarité ≥ {FUZZY_THRESHOLD}%).",
).pack(anchor="w")
box = ttk.Frame(self.tab_soft)
box.pack(fill="both", expand=True, pady=10)
self.soft_canvas = tk.Canvas(box, highlightthickness=0)
self.soft_scroll = ttk.Scrollbar(box, orient="vertical", command=self.soft_canvas.yview)
self.soft_canvas.configure(yscrollcommand=self.soft_scroll.set)
self.soft_scroll.pack(side="right", fill="y")
self.soft_canvas.pack(side="left", fill="both", expand=True)
self.soft_rows = ttk.Frame(self.soft_canvas)
self.soft_window = self.soft_canvas.create_window((0, 0), window=self.soft_rows, anchor="nw")
self.soft_rows.bind(
"<Configure>",
lambda _e: self.soft_canvas.configure(scrollregion=self.soft_canvas.bbox("all")),
)
self.soft_canvas.bind(
"<Configure>",
lambda e: self.soft_canvas.itemconfigure(self.soft_window, width=e.width),
)
def _build_tab_folders(self):
btns = ttk.Frame(self.tab_folders)
btns.pack(fill="x")
ttk.Button(btns, text="Supprimer tout", command=self.delete_all_folders).pack(side="left")
cols = ("path", "exists", "result")
self.tree_folders = ttk.Treeview(self.tab_folders, columns=cols, show="headings", height=20)
self.tree_folders.heading("path", text="Chemin")
self.tree_folders.heading("exists", text="Existe")
self.tree_folders.heading("result", text="Résultat / Erreur")
self.tree_folders.column("path", width=820)
self.tree_folders.column("exists", width=80, anchor="center")
self.tree_folders.column("result", width=170)
self.tree_folders.pack(fill="both", expand=True, pady=10)
hint = ttk.Label(
self.tab_folders,
text="Double-clic = supprimer. Clic droit = Supprimer / Supprimer avec droits admin.",
)
hint.pack(anchor="w")
self.folder_menu = tk.Menu(self, tearoff=0)
self.folder_menu.add_command(
label="Supprimer",
command=self._delete_selected_folder,
)
self.folder_menu.add_command(
label="Supprimer avec droits admin",
command=self._delete_selected_folder_admin,
)
self.tree_folders.bind("<Double-1>", self._on_folder_double_click)
self.tree_folders.bind("<Button-3>", self._on_folder_right_click)
self.tree_folders.bind("<Delete>", self._on_folder_delete_key)
def _build_tab_reg(self):
btns = ttk.Frame(self.tab_reg)
btns.pack(fill="x")
ttk.Button(btns, text="Supprimer toutes les clés", command=self.delete_all_reg_keys).pack(side="left")
cols = ("root", "subkey", "exists", "result")
self.tree_reg = ttk.Treeview(self.tab_reg, columns=cols, show="headings", height=20)
self.tree_reg.heading("root", text="Ruche")
self.tree_reg.heading("subkey", text="Clé")
self.tree_reg.heading("exists", text="Existe")
self.tree_reg.heading("result", text="Résultat / Erreur")
self.tree_reg.column("root", width=140)
self.tree_reg.column("subkey", width=690)
self.tree_reg.column("exists", width=80, anchor="center")
self.tree_reg.column("result", width=170)
self.tree_reg.pack(fill="both", expand=True, pady=10)
hint = ttk.Label(self.tab_reg, text="Double-clic sur une ligne pour supprimer cette clé (récursif).")
hint.pack(anchor="w")
self.tree_reg.bind("<Double-1>", self._on_reg_double_click)
def _auto_refresh(self):
self.refresh_all()
self.after(AUTO_REFRESH_MS, self._auto_refresh)
def refresh_all(self):
try:
self.status_var.set("Actualisation…")
self.admin_var.set("Admin: " + ("OUI" if is_admin() else "NON"))
self.installed_cache = read_installed_entries()
self._refresh_softwares()
self._refresh_folders()
self._refresh_registry()
self._update_continue_state()
self.status_var.set("OK")
except Exception as e:
self.status_var.set(f"Erreur: {e}")
log("REFRESH ERROR: " + repr(e))
log(traceback.format_exc())
def _refresh_softwares(self):
for child in self.soft_rows.winfo_children():
child.destroy()
headers = ["Nom attendu", "Nom détecté", "Score", "Statut", "Action"]
widths = [34, 48, 8, 12, 16]
for col, (title, width) in enumerate(zip(headers, widths)):
lbl = ttk.Label(self.soft_rows, text=title, width=width, font=("Segoe UI", 9, "bold"))
lbl.grid(row=0, column=col, sticky="ew", padx=4, pady=(0, 6))
visible_row = 1
any_present = False
for exp in EXPECTED_SOFTWARE:
entry, score = best_fuzzy_entry(exp, self.installed_cache)
if score < FUZZY_THRESHOLD:
continue
any_present = True
matched = entry.get("display_name", "") if entry else ""
status = "PRÉSENT"
ttk.Label(self.soft_rows, text=exp, width=34).grid(row=visible_row, column=0, sticky="w", padx=4, pady=3)
ttk.Label(self.soft_rows, text=matched, width=48).grid(row=visible_row, column=1, sticky="w", padx=4, pady=3)
ttk.Label(self.soft_rows, text=str(score), width=8, anchor="center").grid(row=visible_row, column=2, padx=4, pady=3)
ttk.Label(self.soft_rows, text=status, width=12, anchor="center").grid(row=visible_row, column=3, padx=4, pady=3)
btn = ttk.Button(
self.soft_rows,
text="Désinstaller",
command=lambda e=entry, x=exp: self.uninstall_software(x, e),
)
btn.grid(row=visible_row, column=4, sticky="ew", padx=4, pady=2)
visible_row += 1
if not any_present:
ttk.Label(
self.soft_rows,
text="Aucun logiciel Schneider ciblé détecté.",
font=("Segoe UI", 9, "italic"),
).grid(row=1, column=0, columnspan=5, sticky="w", padx=4, pady=8)
for col in range(5):
self.soft_rows.columnconfigure(col, weight=1 if col in (0, 1) else 0)
if any_present:
log("SOFTWARE: éléments encore présents détectés.")
else:
log("SOFTWARE: rien détecté (selon règle 95%).")
def _refresh_folders(self):
for item in self.tree_folders.get_children():
self.tree_folders.delete(item)
any_existing = False
for path in FOLDERS_TO_DELETE:
if not safe_exists_dir(path):
continue
any_existing = True
self.tree_folders.insert("", "end", values=(path, "Oui", ""))
if not any_existing:
self.tree_folders.insert("", "end", values=("Aucun dossier restant", "Non", "OK"))
def _refresh_registry(self):
for item in self.tree_reg.get_children():
self.tree_reg.delete(item)
any_existing = False
for root, subkey in REGISTRY_KEYS_TO_DELETE:
if not reg_key_exists(root, subkey):
continue
any_existing = True
self.tree_reg.insert("", "end", values=(self._root_name(root), subkey, "Oui", ""))
if not any_existing:
self.tree_reg.insert("", "end", values=("", "Aucune clé registre restante", "Non", "OK"))
def _root_name(self, root) -> str:
if root == winreg.HKEY_LOCAL_MACHINE:
return "HKLM"
if root == winreg.HKEY_CURRENT_USER:
return "HKCU"
if root == winreg.HKEY_CLASSES_ROOT:
return "HKCR"
return str(root)
def _continue_next(self):
# Bascule vers l’onglet suivant
i = self.nb.index(self.nb.select())
if i < self.nb.index("end") - 1:
self.nb.select(i + 1)
def _finish(self):
ok_soft = self._softwares_all_absent()
ok_folders = self._folders_all_deleted()
ok_reg = self._registry_all_deleted()
summary = [
f"Logiciels: {'OK' if ok_soft else 'RESTE'}",
f"Dossiers: {'OK' if ok_folders else 'RESTE'}",
f"Registre: {'OK' if ok_reg else 'RESTE'}",
"",
"Redémarrage requis: OUI",
]
msg = "\n".join(summary)
log("FINISH SUMMARY:\n" + msg)
if not (ok_soft and ok_folders and ok_reg):
messagebox.showwarning(
"Nettoyage incomplet",
msg + "\n\nLe redémarrage n'est pas lancé car il reste des éléments à nettoyer.",
)
return
if not messagebox.askyesno(
"Redémarrage",
msg + "\n\nRedémarrer Windows maintenant ?",
):
log("REBOOT CANCELLED BY USER")
return
try:
log("REBOOT START: shutdown /r /t 5")
self.status_var.set("Redémarrage Windows dans 5 secondes…")
subprocess.Popen([
"shutdown",
"/r",
"/t",
"5",
"/c",
"Nettoyage Control Expert terminé - redémarrage demandé par l'assistant.",
], shell=False)
self.after(500, self.destroy)
except Exception as e:
log(f"REBOOT ERROR: {e}")
messagebox.showerror(
"Erreur redémarrage",
f"Impossible de lancer le redémarrage automatique.\n\n{e}\n\nCommande manuelle:\nshutdown /r /t 5",
)
def _softwares_all_absent(self) -> bool:
for exp in EXPECTED_SOFTWARE:
_, score = best_fuzzy_entry(exp, self.installed_cache)
if score >= FUZZY_THRESHOLD:
return False
return True
def _folders_all_deleted(self) -> bool:
return all(not safe_exists_dir(p) for p in FOLDERS_TO_DELETE)
def _registry_all_deleted(self) -> bool:
return all(not reg_key_exists(r, k) for r, k in REGISTRY_KEYS_TO_DELETE)
def _update_continue_state(self):
# Continue = passe à l’étape suivante; Terminer possible mais on garde Continue dépendant de l’étape
# Ici: on active Continue si l’étape courante est OK.
current = self.nb.index(self.nb.select())
if current == 0: # Désinstallation
enable = self._softwares_all_absent()
elif current == 1: # Dossiers
enable = self._folders_all_deleted()
else: # Registre
enable = self._registry_all_deleted()
self.btn_continue.configure(state=("normal" if enable else "disabled"))
# Terminer activé seulement si tout est OK
all_ok = self._softwares_all_absent() and self._folders_all_deleted() and self._registry_all_deleted()
self.btn_finish.configure(state=("normal" if all_ok else "disabled"))
# ===== Software actions =====
def uninstall_software(self, expected: str, entry: dict | None):
if not entry:
messagebox.showerror("Désinstallation", "Aucune entrée de désinstallation trouvée.")
return
if not is_admin():
messagebox.showerror(
"Admin requis",
"Le programme n'est pas lancé en administrateur. Relance-le en admin.",
)
return
display_name = entry.get("display_name", "")
command = build_uninstall_command(entry)
if not command:
messagebox.showerror(
"Désinstallation impossible",
f"Aucune UninstallString trouvée pour:\n{display_name}\n\nClé:\n"
f"{entry.get('root_name')}\\{entry.get('full_key')}",
)
return
msg = (
f"Désinstaller ce composant ?\n\n"
f"Attendu: {expected}\n"
f"Détecté: {display_name}\n\n"
f"Commande:\n{command}"
)
if not messagebox.askyesno("Confirmer la désinstallation", msg):
return
try:
log(f"UNINSTALL START: {display_name} => {command}")
subprocess.Popen(command, shell=True)
self.status_var.set(f"Désinstallation lancée: {display_name}")
self.after(3000, self.refresh_all)
except Exception as e:
log(f"UNINSTALL ERROR: {display_name} => {e}")
messagebox.showerror("Erreur désinstallation", str(e))
# ===== Folder actions =====
def _selected_folder_path(self) -> str | None:
selection = self.tree_folders.selection()
if not selection:
return None
values = self.tree_folders.item(selection[0], "values")
if len(values) < 2:
return None
path, exists, *_ = values
if exists != "Oui":
return None
return str(path)
def _on_folder_right_click(self, event):
item = self.tree_folders.identify_row(event.y)
if not item:
return
self.tree_folders.selection_set(item)
self.tree_folders.focus(item)
values = self.tree_folders.item(item, "values")
if len(values) < 2 or values[1] != "Oui":
return
try:
self.folder_menu.tk_popup(event.x_root, event.y_root)
finally:
self.folder_menu.grab_release()
def _on_folder_delete_key(self, _event=None):
self._delete_selected_folder()
def _delete_selected_folder(self):
path = self._selected_folder_path()
if path:
self.delete_one_folder(path)
def _delete_selected_folder_admin(self):
path = self._selected_folder_path()
if path:
self.delete_one_folder_admin(path)
def _on_folder_double_click(self, event):
item = self.tree_folders.identify_row(event.y)
if not item:
return
self.tree_folders.selection_set(item)
path, exists, *_ = self.tree_folders.item(item, "values")
if exists != "Oui":
return
self.delete_one_folder(path)
def delete_one_folder(self, path: str):
if not messagebox.askyesno(
"Confirmer la suppression",
f"Supprimer ce dossier ?\n\n{path}",
):
return
ok, res = delete_dir(path)
log(f"FOLDER DELETE: {path} => {res}")
self.refresh_all()
if not ok:
messagebox.showerror(
"Erreur suppression dossier",
f"{path}\n\n{res}\n\n"
"Tu peux réessayer avec clic droit > Supprimer avec droits admin.",
)
def delete_one_folder_admin(self, path: str):
if not messagebox.askyesno(
"Confirmer la suppression admin",
"Forcer la suppression de ce dossier avec reprise des droits ?\n\n"
f"{path}",
):
return
self.status_var.set("Suppression avec droits admin…")
self.update_idletasks()
ok, res = delete_dir_admin(path)
log(f"FOLDER DELETE ADMIN: {path} => {res}")
self.refresh_all()
if not ok:
messagebox.showerror(
"Erreur suppression dossier (admin)",
f"{path}\n\n{res}\n\n"
"S'il reste présent, un fichier ou un service est probablement verrouillé.",
)
def delete_all_folders(self):
if not messagebox.askyesno("Confirmer", "Supprimer tous les dossiers listés ?"):
return
for path in FOLDERS_TO_DELETE:
ok, res = delete_dir(path)
log(f"FOLDER DELETE ALL: {path} => {res}")
self.refresh_all()
# ===== Registry actions =====
def _on_reg_double_click(self, event):
item = self.tree_reg.identify_row(event.y)
if not item:
return
root_name, subkey, exists, *_ = self.tree_reg.item(item, "values")
if exists != "Oui":
return
root = self._name_to_root(root_name)
self.delete_one_reg_key(root, subkey)
def _name_to_root(self, name: str):
name = name.upper()
if name == "HKLM":
return winreg.HKEY_LOCAL_MACHINE
if name == "HKCU":
return winreg.HKEY_CURRENT_USER
if name == "HKCR":
return winreg.HKEY_CLASSES_ROOT
return winreg.HKEY_LOCAL_MACHINE
def delete_one_reg_key(self, root, subkey: str):
ok, res = reg_delete_tree(root, subkey)
log(f"REG DELETE: {self._root_name(root)}\\{subkey} => {res}")
self.refresh_all()
if not ok:
messagebox.showerror("Erreur suppression registre", f"{self._root_name(root)}\\{subkey}\n\n{res}")
def delete_all_reg_keys(self):
if not messagebox.askyesno("Confirmer", "Supprimer toutes les clés registre listées (récursif) ?"):
return
for root, subkey in REGISTRY_KEYS_TO_DELETE:
ok, res = reg_delete_tree(root, subkey)
log(f"REG DELETE ALL: {self._root_name(root)}\\{subkey} => {res}")
self.refresh_all()
def main():
log("=== START ===")
if not is_admin():
log("INFO: relance UAC admin demandée.")
if relaunch_as_admin():
log("INFO: instance non élevée fermée après demande UAC.")
return
ctypes.windll.user32.MessageBoxW(
None,
"Impossible de relancer en administrateur.\nRelance le programme avec clic droit > Exécuter en tant qu'administrateur.",
"Admin requis",
0x10,
)
return
app = App()
app.mainloop()
log("=== END ===")
if __name__ == "__main__":
main()


