diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9389ce --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# comi-cleanmgr - sources de paquet WAPT + +Paquet de configuration de **N profils cleanmgr Windows**, destine a etre personnalise +avec **Lancer update-package** depuis la console WAPT. Pas de suppression de fichiers lors +de l'installation : seuls les parametres des profils sont deposes dans le registre. + +## Update-package : approche native WAPT + +Le code est adapte des conventions utilisees dans le paquet public officiel +[`tis-waptagent-logo-template` 1.1-15](https://wapt.tranquil.it/store/en/details/tis-waptagent-logo-template_1.1-15_windows_5.1_PROD.wapt) : + +- `waptguihelper.message_dialog(...)` : introduction et validation. +- `waptguihelper.input_dialog(...)` : nombre de profils, categories additionnelles. +- `waptguihelper.grid_dialog(..., waptguihelper.GRT_ALL, MetaData)` : tableau editable des IDs/noms et grilles de categories (booleens). +- `control.inc_build()` puis `control.save_control_to_wapt()` : incrementation native du build WAPT lors d'un changement. +- Garde pour `params['install_with_luti']` ou `params['running_as_luti']` : pas d'interface sur les builds automatiques LUTI. + +Les helpers `ask_control_package` et `ask_control_version` de l'exemple de Tranquil IT +sont **definis dans le fichier de l'exemple** ; ce ne sont pas des API standard. +Ici, comme le nom du paquet ne change pas et que seule sa configuration change, +`control.inc_build()` est suffisant. + +## Utilisation + +1. Extraire l'archive comme sources WAPT, puis ouvrir le paquet dans la **console WAPT sur Windows**. +2. Depuis le depot prive, cliquer **Lancer update-package**. +3. Saisir N (1 a 50), puis renseigner **dans une seule grille** les IDs `0..9999` + et les noms des N profils. +4. Pour chacun, cocher les categories dans la grille interactive. Les `VolumeCaches` + personnalises sont disponibles dans un champ facultatif. +5. Confirmer le recapitulatif : cela reecrit `profiles.json`, incremente le numero + de build de `WAPT/control` et enregistre le controle. +6. Construire, **signer** et publier le paquet selon le flux de travail habituel, + puis l'installer sur les postes. +7. Exemples d'execution (locale, admin ou action distante autorisee) : + + ```cmd + cleanmgr.exe /sagerun:4101 + cleanmgr.exe /sagerun:4102 + ``` + +`update_package()` s'execute **sur le poste de l'administrateur**, pas sur les endpoints. +Les postes ne voient jamais de fenetres de configuration : `setup.py` lit seulement +`profiles.json` a l'installation. + +## Fonctionnement agent + +- Configuration en `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches`. +- `StateFlagsNNNN` REG_DWORD a `2` (categorie activee) ou `0` (categorie desactivee). +- Trace des IDs et de la configuration dans `HKLM\SOFTWARE\Comitari\CleanmgrProfiles`. +- `audit()` verifie les flags : `OK`, `WARNING` si categorie selectionnee absente de ce Windows, + `ERROR` si registre non conforme. +- `uninstall()` efface les valeurs de profil gerees sans toucher aux autres IDs. +- Les profils supprimes lors d'une evolution du paquet voient leurs flags nettoyes. + +## Precautions + +- Ne **jamais** reutiliser un ID attribue a un autre administrateur : le paquet prend + le controle des `StateFlagsNNNN` de l'ID utilise. +- Certaines categories n'existent pas sous toutes les versions de Windows. +- `Previous Installations` et `Update Cleanup` peuvent limiter le retour en arriere ; + `Recycle Bin` supprime le contenu de la corbeille. +- Le comportement sous SYSTEM peut differer de celui dans une session utilisateur. +- L'action integree **Lancer CleanMgr** de WAPT Enterprise n'est pas prouvee comme + prenant en charge les profils `4101/4102` personnalises. +- Aucune tache planifiee ou nettoyage n'est lance automatiquement par ce paquet. +- L'interface graphique n'a pas ete testee sur une console WAPT Windows reelle. + La logique a ete testee via des doubles Python des helpers, de la console et du registre. + +## Contenu + +- `WAPT/control` : metadonnees WAPT (minimum WAPT 2.4). +- `update_package.py` : assistant interactif de configuration, utilisant les helpers WAPT. +- `profiles.json` : donnees des profils embarquees. +- `setup.py` : application des profils sur les machines Windows, `audit`, `uninstall`. + +Sources uniquement ; archive non signee, non compilable directement en `.wapt` +sans la console et la chaine de signature WAPT. diff --git a/WAPT/control b/WAPT/control new file mode 100644 index 0000000..4c156e3 --- /dev/null +++ b/WAPT/control @@ -0,0 +1,43 @@ +package : comi-conf-clean-manager-template +version : 0-0 +architecture : all +section : base +priority : optional +name : +categories : +maintainer : root +description : Package for comi-conf-clean-manager-template +depends : +conflicts : +maturity : +locale : +target_os : all +min_wapt_version : +sources : +installed_size : +impacted_process : +description_fr : +description_pl : +description_de : +description_es : +description_pt : +description_it : +description_nl : +description_ru : +audit_schedule : +editor : +keywords : +licence : +homepage : +package_uuid : +valid_from : +valid_until : +forced_install_on : +changelog : +min_os_version : +max_os_version : +icon_sha256sum : +signer : +signer_fingerprint: +signature_date : +signed_attributes : \ No newline at end of file diff --git a/WAPT/wapt.psproj b/WAPT/wapt.psproj new file mode 100644 index 0000000..610a1bb --- /dev/null +++ b/WAPT/wapt.psproj @@ -0,0 +1,239 @@ +[PyScripter] +Version=3.6.4.0 + +[Project] +ClassName=TProjectRootNode +StoreRelativePaths=TRUE +ShowFileExtensions=FALSE + +[Project\ChildNodes\Node0] +ClassName=TProjectFilesNode + +[Project\ChildNodes\Node0\ChildNodes\Node0] +ClassName=TProjectFolderNode +Name=wapt + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node0] +ClassName=TProjectFileNode +FileName=/opt/wapt\common.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node1] +ClassName=TProjectFileNode +FileName=/opt/wapt\setuphelpers.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node2] +ClassName=TProjectFileNode +FileName=/opt/wapt\setuphelpers_linux.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node3] +ClassName=TProjectFileNode +FileName=/opt/wapt\setuphelpers_macos.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node4] +ClassName=TProjectFileNode +FileName=/opt/wapt\setuphelpers_unix.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node5] +ClassName=TProjectFileNode +FileName=/opt/wapt\setuphelpers_windows.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node6] +ClassName=TProjectFileNode +FileName=/opt/wapt\wapt-get.ini + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node7] +ClassName=TProjectFileNode +FileName=/opt/wapt\wapt-get.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes\Node8] +ClassName=TProjectFileNode +FileName=/opt/wapt\waptpackage.py + +[Project\ChildNodes\Node0\ChildNodes\Node0\ChildNodes] +Count=9 + +[Project\ChildNodes\Node0\ChildNodes\Node1] +ClassName=TProjectFileNode +FileName=$[Project-Path]changelog.txt + +[Project\ChildNodes\Node0\ChildNodes\Node2] +ClassName=TProjectFileNode +FileName=$[Project-Path]control + +[Project\ChildNodes\Node0\ChildNodes\Node3] +ClassName=TProjectFileNode +FileName=$[Project-Path]..\setup.py + +[Project\ChildNodes\Node0\ChildNodes\Node4] +ClassName=TProjectFileNode +FileName=$[Project-Path]..\update_package.py + +[Project\ChildNodes\Node0\ChildNodes] +Count=5 + +[Project\ChildNodes\Node1] +ClassName=TProjectRunConfiguationsNode + +[Project\ChildNodes\Node1\ChildNodes\Node0] +ClassName=TProjectRunConfiguationNode +Name=WAPT: install + +[Project\ChildNodes\Node1\ChildNodes\Node0\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch package installation. +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=install "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node0\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[ActiveScript-Dir] + +[Project\ChildNodes\Node1\ChildNodes\Node1] +ClassName=TProjectRunConfiguationNode +Name=WAPT: remove + +[Project\ChildNodes\Node1\ChildNodes\Node1\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch uninstallation (the uninstallation code found in local wapt database, remember to launch after an installation). +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=remove "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node1\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[ActiveScript-Dir] + +[Project\ChildNodes\Node1\ChildNodes\Node2] +ClassName=TProjectRunConfiguationNode +Name=WAPT: uninstall + +[Project\ChildNodes\Node1\ChildNodes\Node2\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch uninstallation code (for debugging purpose, it only runs the code of uninstall function). +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=uninstall "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node2\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[Project-Path].. + +[Project\ChildNodes\Node1\ChildNodes\Node3] +ClassName=TProjectRunConfiguationNode +Name=WAPT: session-setup + +[Project\ChildNodes\Node1\ChildNodes\Node3\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch session-setup (it runs directly as current user). +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=session-setup "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node3\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[ActiveScript-Dir] + +[Project\ChildNodes\Node1\ChildNodes\Node4] +ClassName=TProjectRunConfiguationNode +Name=WAPT: audit + +[Project\ChildNodes\Node1\ChildNodes\Node4\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch package audit. +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=audit -f "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node4\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[ActiveScript-Dir] + +[Project\ChildNodes\Node1\ChildNodes\Node5] +ClassName=TProjectRunConfiguationNode +Name=WAPT: update-package + +[Project\ChildNodes\Node1\ChildNodes\Node5\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch update_package (it usually serve to update binaries of the package). +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=update-package-sources "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node5\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[ActiveScript-Dir] + +[Project\ChildNodes\Node1\ChildNodes\Node6] +ClassName=TProjectRunConfiguationNode +Name=WAPT: cleanup-session + +[Project\ChildNodes\Node1\ChildNodes\Node6\RunConfig] +ScriptName=/opt/wapt\wapt-get.py +Description=Launch cleanup_session for the current package (it must have been installed, had session-setup and then removed). +EngineType=peRemote +ReinitializeBeforeRun=TRUE +Parameters=cleanup-session "$[Project-Path].." +WorkingDir=$[Project-Path].. +WriteOutputToFile=FALSE +OutputFileName=$[ActiveScript-NoExt].log +AppendToFile=FALSE + +[Project\ChildNodes\Node1\ChildNodes\Node6\RunConfig\ExternalRun] +Caption=External Run +Description=Run script using an external Python Interpreter +ApplicationName=$[PythonExe-Short] +Parameters=$[ActiveScript-Short] +WorkingDirectory=$[ActiveScript-Dir] + +[Project\ChildNodes\Node1\ChildNodes] +Count=7 + +[Project\ChildNodes] +Count=2 + +[Project\ExtraPythonPath] +Count=0 + diff --git a/setup.py b/setup.py index 7656a91..15987cf 100644 --- a/setup.py +++ b/setup.py @@ -1,78 +1,193 @@ - # -*- coding: utf-8 -*- +"""WAPT agent-side configuration of cleanmgr.exe profiles. + +The WAPT update_package.py wizard generates profiles.json in the package sources. +Installing this package configures VolumeCaches only: it DOES NOT run cleanup. +""" + from setuphelpers import * +import json +import re import winreg uninstallkey = [] -PROFILE = 100 -REG_PATH = ( - r'SOFTWARE\Microsoft\Windows\CurrentVersion' - r'\Explorer\VolumeCaches' -) - -CATEGORIES = { - 'Temporary Files', - 'Thumbnail Cache', - 'Delivery Optimization Files', -} - -FLAG = 'StateFlags{:04d}'.format(PROFILE) +VOLUME_CACHES = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches' +TRACK_KEY = r'SOFTWARE\Comitari\CleanmgrProfiles' +TRACK_VALUE = 'ProfilesJson' +PROFILE_LIMIT = 50 -def get_categories(): - with reg_openkey_noredir( - winreg.HKEY_LOCAL_MACHINE, REG_PATH - ) as root: - categories = [] - index = 0 +def _validate_config(config): + if not isinstance(config, dict) or config.get('schema_version') != 1: + raise ValueError('Invalid profiles.json schema_version') + profiles = config.get('profiles') + if not isinstance(profiles, list) or not 1 <= len(profiles) <= PROFILE_LIMIT: + raise ValueError('Expected 1..%d profiles' % PROFILE_LIMIT) + + seen = set() + for profile in profiles: + if not isinstance(profile, dict): + raise ValueError('Invalid profile') + profile_id = profile.get('id') + if type(profile_id) is not int or not 0 <= profile_id <= 9999: + raise ValueError('Profile id must be in 0..9999') + if profile_id in seen: + raise ValueError('Duplicate profile id: %d' % profile_id) + seen.add(profile_id) + + name = profile.get('name') + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 80: + raise ValueError('Profile name missing or too long') + categories = profile.get('categories') + if not isinstance(categories, list) or not categories: + raise ValueError('Profile %d has no categories' % profile_id) + for category in categories: + if not isinstance(category, str) or not re.fullmatch( + r'[A-Za-z0-9][A-Za-z0-9 ()_.-]{0,127}', category): + raise ValueError('Unsafe or invalid VolumeCaches key: %r' % category) + if len(set(categories)) != len(categories): + raise ValueError('Duplicate categories in profile %d' % profile_id) + return config + + +def _read_deployed_config(): + try: + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, TRACK_KEY, + sam=winreg.KEY_READ) as key: + raw = winreg.QueryValueEx(key, TRACK_VALUE)[0] + except FileNotFoundError: + return None + return _validate_config(json.loads(raw)) + + +def _store_deployed_config(config): + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, TRACK_KEY, + sam=winreg.KEY_WRITE, create_if_missing=True) as key: + reg_setvalue(key, TRACK_VALUE, + json.dumps(config, ensure_ascii=False, sort_keys=True), + type=winreg.REG_SZ) + + +def _list_volume_categories(): + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, VOLUME_CACHES, + sam=winreg.KEY_READ) as root: + result = [] + position = 0 while True: try: - categories.append(winreg.EnumKey(root, index)) - index += 1 + result.append(winreg.EnumKey(root, position)) except OSError: break - return categories + position += 1 + return sorted(result) + + +def _flag(profile_id): + return 'StateFlags%04d' % profile_id + + +def _delete_profile_flags(profile_id, categories): + for category in categories: + path = VOLUME_CACHES + '\\' + category + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, path, + sam=winreg.KEY_SET_VALUE) as key: + try: + winreg.DeleteValue(key, _flag(profile_id)) + except FileNotFoundError: + pass + + +def _apply_profile(profile, available): + wanted = set(profile['categories']) + flag = _flag(profile['id']) + for category in available: + path = VOLUME_CACHES + '\\' + category + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, path, + sam=winreg.KEY_SET_VALUE) as key: + reg_setvalue(key, flag, 2 if category in wanted else 0, + type=winreg.REG_DWORD) + unavailable = wanted - set(available) + if unavailable: + print('Profile %d (%s), missing on this Windows: %s' % ( + profile['id'], profile['name'], ', '.join(sorted(unavailable)))) + print('Configured profile %d (%s): %d available selected categories' % ( + profile['id'], profile['name'], len(wanted & set(available)))) def install(): - available = get_categories() + path = makepath(basedir, 'profiles.json') + with open(path, 'r', encoding='utf-8') as stream: + config = _validate_config(json.load(stream)) - for category in available: - path = REG_PATH + '\\' + category + available = _list_volume_categories() + if not available: + raise RuntimeError('No VolumeCaches categories available on this Windows') - with reg_openkey_noredir( - winreg.HKEY_LOCAL_MACHINE, - path, - sam=winreg.KEY_SET_VALUE - ) as key: - value = 2 if category in CATEGORIES else 0 - reg_setvalue( - key, FLAG, value, type=winreg.REG_DWORD - ) + previous = _read_deployed_config() + previous_ids = {p['id'] for p in previous['profiles']} if previous else set() + new_ids = {p['id'] for p in config['profiles']} + for removed_id in sorted(previous_ids - new_ids): + _delete_profile_flags(removed_id, available) + print('Removed former profile %d' % removed_id) - if category in CATEGORIES: - print('Enabled: {}'.format(category)) + for profile in config['profiles']: + _apply_profile(profile, available) - missing = CATEGORIES - set(available) - if missing: - print('Unavailable categories: {}'.format(missing)) + _store_deployed_config(config) + print('Cleanmgr profiles configured. No cleaning was executed.') - # Execution silencieuse du profil - run('cleanmgr.exe /sagerun:{}'.format(PROFILE), - timeout=3600) + +def audit(): + try: + config = _read_deployed_config() + if not config: + print('Cleanmgr profile configuration missing') + return 'ERROR' + available = _list_volume_categories() + if not available: + return 'ERROR' + missing = False + errors = [] + for profile in config['profiles']: + wanted = set(profile['categories']) + missing_categories = wanted - set(available) + if missing_categories: + print('Profile %d unavailable categories: %s' % ( + profile['id'], ', '.join(sorted(missing_categories)))) + missing = True + for category in available: + path = VOLUME_CACHES + '\\' + category + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, path, + sam=winreg.KEY_READ) as key: + try: + current, kind = winreg.QueryValueEx(key, _flag(profile['id'])) + except FileNotFoundError: + current, kind = None, None + expected = 2 if category in wanted else 0 + if current != expected or kind != winreg.REG_DWORD: + errors.append('%d / %s: expected %d, got %r' % ( + profile['id'], category, expected, current)) + if errors: + for item in errors[:25]: + print(item) + return 'ERROR' + return 'WARNING' if missing else 'OK' + except Exception as exc: + print('Cleanmgr audit failed: %s' % exc) + return 'ERROR' def uninstall(): - for category in get_categories(): - path = REG_PATH + '\\' + category - - with reg_openkey_noredir( - winreg.HKEY_LOCAL_MACHINE, - path, - sam=winreg.KEY_SET_VALUE - ) as key: + previous = _read_deployed_config() + if previous: + available = _list_volume_categories() + for profile in previous['profiles']: + _delete_profile_flags(profile['id'], available) + with reg_openkey_noredir(winreg.HKEY_LOCAL_MACHINE, TRACK_KEY, + sam=winreg.KEY_SET_VALUE) as key: try: - winreg.DeleteValue(key, FLAG) + winreg.DeleteValue(key, TRACK_VALUE) except FileNotFoundError: pass + print('Managed Cleanmgr profile flags removed. No cleaning was executed.') diff --git a/update_package.py b/update_package.py new file mode 100644 index 0000000..faa4cb1 --- /dev/null +++ b/update_package.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +"""Interactive WAPT update_package for multiple cleanmgr profiles. + +Uses the same WAPT Console conventions as Tranquil IT's +``tis-waptagent-logo-template``: waptguihelper dialogs, control.inc_build(), +and control.save_control_to_wapt(). The names ask_control_version() and +ask_control_package() in that template are local helpers, not WAPT APIs. +""" + +from setuphelpers import * +import json +import os +import re + +MAX_PROFILES = 50 + +# Stable VolumeCaches registry subkey names, NOT translated Windows labels. +# Windows builds expose different subsets of this list. +CATALOG = [ + ('Temporary Files', 'Fichiers temporaires', 'Standard'), + ('Thumbnail Cache', 'Miniatures', 'Standard'), + ('D3D Shader Cache', 'Cache des shaders Direct3D', 'Standard'), + ('Delivery Optimization Files', 'Optimisation de la distribution', 'Standard'), + ('Downloaded Program Files', 'Programmes telecharges', 'Standard'), + ('Internet Cache Files', 'Cache Internet de Windows', 'Standard'), + ('Windows Defender', 'Fichiers non essentiels Defender', 'Maintenance'), + ('Windows Error Reporting Files', 'Rapports erreurs Windows', 'Maintenance'), + ('Setup Log Files', 'Journaux installation Windows', 'Maintenance'), + ('Windows Upgrade Log Files', 'Journaux mises a niveau Windows', 'Maintenance'), + ('Temporary Setup Files', 'Fichiers installation temporaires', 'Maintenance'), + ('Old ChkDsk Files', 'Fragments CHKDSK anciens', 'Maintenance'), + ('System error memory dump files', 'Dump memoire systeme', 'Diagnostic'), + ('System error minidump files', 'Mini dumps systeme', 'Diagnostic'), + ('Memory Dump Files', 'Autres dumps memoire', 'Diagnostic'), + ('Update Cleanup', 'Nettoyage Windows Update / rollback', 'Sensible'), + ('Previous Installations', 'Anciennes installations Windows / rollback', 'Sensible'), + ('Recycle Bin', 'Corbeille', 'Sensible'), + ('Windows ESD installation files', 'Fichiers de reinitialisation ESD', 'Sensible'), + ('Device Driver Packages', 'Anciens packages pilotes', 'Sensible'), +] + +PROFILE_META = json.dumps({'columns': [ + {'propertyname': 'id', 'datatype': 'Integer'}, + {'propertyname': 'name', 'datatype': 'String'}, +]}) +CATEGORY_META = json.dumps({'columns': [ + {'propertyname': 'key', 'datatype': 'String'}, + {'propertyname': 'description', 'datatype': 'String'}, + {'propertyname': 'risk', 'datatype': 'String'}, + {'propertyname': 'enabled', 'datatype': 'Boolean'}, +]}) + +DEFAULT_SAFE_CATEGORIES = [ + 'Temporary Files', 'Thumbnail Cache', + 'D3D Shader Cache', 'Delivery Optimization Files', +] + + +def _load_config(path): + if not os.path.isfile(path): + return {'schema_version': 1, 'profiles': []} + with open(path, 'r', encoding='utf-8') as stream: + content = json.load(stream) + if not isinstance(content, dict) or content.get('schema_version') != 1: + raise ValueError('profiles.json : schema_version invalide.') + if not isinstance(content.get('profiles'), list): + raise ValueError('profiles.json : profiles doit etre une liste.') + return content + + +def _as_rows(result, dialog_name): + if result is None: + return None + if isinstance(result, (str, bytes)): + result = json.loads(result) + if not isinstance(result, list) or any(not isinstance(row, dict) for row in result): + raise ValueError('%s : resultat de grille invalide.' % dialog_name) + return result + + +def _valid_key(key): + return isinstance(key, str) and bool(re.fullmatch( + r'[A-Za-z0-9][A-Za-z0-9 ()_.-]{0,127}', key)) + + +def _normalize_id(value): + if isinstance(value, bool): + raise ValueError('ID Cleanmgr invalide : %r' % value) + if isinstance(value, int): + profile_id = value + elif isinstance(value, str) and value.strip().isdigit(): + profile_id = int(value.strip()) + else: + raise ValueError('ID Cleanmgr invalide : %r (nombre entier attendu)' % value) + if not 0 <= profile_id <= 9999: + raise ValueError('ID Cleanmgr hors plage 0..9999 : %s' % profile_id) + return profile_id + + +def _enabled(value): + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + return isinstance(value, str) and value.strip().lower() in ( + 'true', 'yes', 'oui', '1') + + +def _next_id(used): + # Keeps IDs together without overwriting existing IDs already in use. + for candidate in list(range(4101, 10000)) + list(range(0, 4101)): + if candidate not in used: + used.add(candidate) + return candidate + raise ValueError('Plus aucun identifiant Cleanmgr disponible.') + + +def _profile_rows(old_profiles, count): + rows = [{'id': profile['id'], 'name': profile['name']} + for profile in old_profiles[:count]] + used = {row['id'] for row in rows} + while len(rows) < count: + profile_id = _next_id(used) + rows.append({'id': profile_id, 'name': 'Profil %s' % profile_id}) + return rows + + +def _validate_profile_rows(rows, count): + if len(rows) != count: + raise ValueError('La grille doit contenir exactement %d profils.' % count) + profiles = [] + used = set() + for row in rows: + profile_id = _normalize_id(row.get('id')) + if profile_id in used: + raise ValueError('Identifiant Cleanmgr duplique : %d.' % profile_id) + used.add(profile_id) + name = row.get('name') + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 80: + raise ValueError('Le profil %d doit avoir un nom (1 a 80 caracteres).' % profile_id) + profiles.append({'id': profile_id, 'name': name.strip()}) + return profiles + + +def _category_rows(selected): + keys = {key for key, _, _ in CATALOG} + rows = [ + {'key': key, 'description': label, 'risk': risk, 'enabled': key in selected} + for key, label, risk in CATALOG + ] + for key in sorted(selected - keys): + rows.append({'key': key, 'description': 'Categorie personnalisee existante', + 'risk': 'A verifier', 'enabled': True}) + return rows + + +def _parse_categories(rows, allowed_keys): + if rows is None: + return None + if len(rows) != len(allowed_keys): + raise ValueError('Le nombre de categories de la grille a ete modifie.') + result = [] + seen = set() + for row in rows: + key = row.get('key') + if key not in allowed_keys or key in seen: + raise ValueError('Cle VolumeCaches inconnue, modifiee ou dupliquee : %r' % key) + seen.add(key) + if _enabled(row.get('enabled')): + result.append(key) + return result + + +def _write_config(path, config): + temporary = path + '.tmp' + try: + with open(temporary, 'w', encoding='utf-8') as stream: + json.dump(config, stream, indent=2, ensure_ascii=False) + stream.write('\n') + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.remove(temporary) + + +def update_package(): + import waptguihelper + + # Based on official Store template: LUTI/headless builds must avoid GUI prompts. + current_params = globals().get('params', {}) or {} + if current_params.get('install_with_luti') or current_params.get('running_as_luti'): + print('Build LUTI : conservation de profiles.json sans intervention graphique.') + return 'OK' + + config_path = makepath(basedir, 'profiles.json') + existing_config = _load_config(config_path) + old_profiles = existing_config['profiles'] + + waptguihelper.message_dialog( + 'Comitari - Profils Cleanmgr', + 'Cette action permet de definir plusieurs profils de nettoyage Windows.\n' + 'Pour chaque profil, choisissez un identifiant, un nom et les categories.\n' + 'L installation configure les profils mais NE LANCE PAS le nettoyage.\n' + 'Les categories disponibles dependent de la version de Windows.', + 0 + 64 # MB_OK + MB_ICONINFORMATION (comme le template officiel) + ) + + count_str = waptguihelper.input_dialog( + 'Comitari - Profils Cleanmgr', + 'Nombre de profils a configurer (1 a %d) :' % MAX_PROFILES, + str(len(old_profiles) or 2)) + if count_str is None: + print('Configuration annulee (aucun fichier modifie).') + return False + try: + count = int(count_str.strip()) + except (TypeError, ValueError): + raise ValueError('Veuillez saisir un nombre entier de profils.') + if not 1 <= count <= MAX_PROFILES: + raise ValueError('Le nombre de profils doit etre entre 1 et %d.' % MAX_PROFILES) + + # WAPT native editable grid: one dialog for N names and IDs instead of 2*N dialogs. + rows = _as_rows(waptguihelper.grid_dialog( + 'Profils Cleanmgr : modifiez les identifiants et noms', + _profile_rows(old_profiles, count), + waptguihelper.GRT_ALL, + PROFILE_META), 'Profils Cleanmgr') + if rows is None: + return False + new_profiles = _validate_profile_rows(rows, count) + + by_id = {p['id']: p for p in old_profiles} + for index, profile in enumerate(new_profiles): + # When an ID is changed, retain selections from its previous row. + prior = by_id.get(profile['id']) + if prior is None and index < len(old_profiles): + prior = old_profiles[index] + previous = set(prior.get('categories', [])) if prior else set(DEFAULT_SAFE_CATEGORIES) + possible = _category_rows(previous) + possible_keys = {row['key'] for row in possible} + title = 'Profil %d/%d : %d - %s' % ( + index + 1, count, profile['id'], profile['name']) + + returned = _as_rows(waptguihelper.grid_dialog( + title + ' - cochez les categories', + possible, + waptguihelper.GRT_ALL, + CATEGORY_META), 'Categories Cleanmgr') + if returned is None: + return False + categories = _parse_categories(returned, possible_keys) + + # Add optional custom registry subkeys, when needed on specific Windows versions. + custom = waptguihelper.input_dialog( + title, 'Autres sous-cles VolumeCaches a ajouter (virgules, optionnel) :', '') + if custom is None: + return False + for candidate in custom.split(','): + category = candidate.strip() + if category and category not in categories: + categories.append(category) + if not categories: + raise ValueError('Le profil %d doit contenir au moins une categorie.' % profile['id']) + if len(categories) > 200 or any(not _valid_key(key) for key in categories): + raise ValueError('Categorie VolumeCaches invalide ou liste trop longue.') + profile['categories'] = categories + + overview = '\n'.join('%d - %s : %d categories' % ( + p['id'], p['name'], len(p['categories'])) for p in new_profiles) + risky = [p['name'] for p in new_profiles if + set(p['categories']) & {'Previous Installations', 'Recycle Bin', + 'Windows ESD installation files', 'Update Cleanup'}] + if risky: + overview += ('\n\nATTENTION : categories sensibles dans %s.\n' + 'Certaines suppressions empechent un retour a la version ' + 'precedente de Windows ou vident la corbeille.' % ', '.join(risky)) + overview += '\n\nEnregistrer les profils dans ce paquet ?' + confirmed = waptguihelper.message_dialog( + 'Validation des profils', overview, + 4 + 32) # MB_YESNO + MB_ICONQUESTION + if confirmed != waptguihelper.ID_YES: + print('Configuration non validee (aucun fichier modifie).') + return False + + new_config = {'schema_version': 1, 'profiles': new_profiles} + if new_config == existing_config: + waptguihelper.message_dialog( + 'Profils inchanges', 'Aucun changement detecte dans profiles.json.', 0 + 64) + return False + + # WAPT-native control API instead of manually parsing the version string. + _write_config(config_path, new_config) + control.inc_build() + control.save_control_to_wapt() + print('Profils enregistres : %s (%d profils).' % (config_path, len(new_profiles))) + print('Version du paquet WAPT : %s' % control.version) + return True