# -*- coding: utf-8 -*- """WAPT: keep Windows 11 at or below feature update 25H2. Uses the Microsoft target feature update policy. No Windows Update service, quality update settings, deferrals, or WSUS settings are modified. """ from setuphelpers import * import json import os import re import tempfile import winreg uninstallkey = [] PACKAGE_NAME = 'comi-win11-max-25h2' POLICY_PATH = r'SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' CURRENT_VERSION_PATH = r'SOFTWARE\Microsoft\Windows NT\CurrentVersion' TARGET_VERSION = '25H2' EXPECTED = { 'ProductVersion': ('Windows 11', winreg.REG_SZ), 'TargetReleaseVersion': (1, winreg.REG_DWORD), 'TargetReleaseVersionInfo': (TARGET_VERSION, winreg.REG_SZ), } STATE_DIR = os.path.join( os.environ.get('ProgramData', r'C:\ProgramData'), 'Comitari', 'WAPT', PACKAGE_NAME, ) BACKUP_FILE = os.path.join(STATE_DIR, 'previous_policy.json') REGISTRY_VIEW = winreg.KEY_WOW64_64KEY def _read_value(path, name): """Return (value, reg_type), or None when path/value is absent.""" try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_QUERY_VALUE | REGISTRY_VIEW) as key: return winreg.QueryValueEx(key, name) except FileNotFoundError: return None def _read_os(): build_info = _read_value(CURRENT_VERSION_PATH, 'CurrentBuildNumber') install_info = _read_value(CURRENT_VERSION_PATH, 'InstallationType') edition_info = _read_value(CURRENT_VERSION_PATH, 'EditionID') display_info = _read_value(CURRENT_VERSION_PATH, 'DisplayVersion') if not build_info: raise RuntimeError('Impossible de lire la build de Windows') try: build = int(build_info[0]) except (ValueError, TypeError): raise RuntimeError('Build Windows illisible : %r' % (build_info[0],)) installation = str(install_info[0]) if install_info else '' edition = str(edition_info[0]) if edition_info else '' display = str(display_info[0]) if display_info else '' return build, installation, edition, display def _assert_supported_os(): build, installation, edition, display = _read_os() # Windows Server 2025 has build 26100 too: build alone is insufficient. if build < 22000 or installation.lower() != 'client': raise RuntimeError( 'Ce paquet est reserve aux postes Windows 11 (build >= 22000); ' 'build=%s, installation=%s' % (build, installation) ) # LTSC editions have a different servicing model and do not target 25H2. if edition.lower().startswith(('enterprises', 'iotenterprises')): raise RuntimeError('Edition LTSC non prise en charge : %s' % edition) if edition.lower().startswith('core'): raise RuntimeError('Edition Home non prise en charge officiellement : %s' % edition) return build, display def _version_tuple(value): match = re.fullmatch(r'(\d{2})H([12])', value.strip(), re.IGNORECASE) if not match: return None return int(match.group(1)), int(match.group(2)) def _snapshot_policy(): """Save the pre-existing values once, also across WAPT reinstalls.""" if os.path.isfile(BACKUP_FILE): # Fail closed if a backup exists but is unreadable. with open(BACKUP_FILE, 'r', encoding='utf-8') as stream: saved = json.load(stream) if saved.get('schema_version') != 1 or saved.get('package') != PACKAGE_NAME: raise RuntimeError('Sauvegarde de strategie incompatible : ' + BACKUP_FILE) if set(saved.get('values', {})) != set(EXPECTED): raise RuntimeError('Sauvegarde de strategie incomplete : ' + BACKUP_FILE) return state = {'schema_version': 1, 'package': PACKAGE_NAME, 'values': {}} for name in EXPECTED: previous = _read_value(POLICY_PATH, name) state['values'][name] = ( {'present': False} if previous is None else {'present': True, 'value': previous[0], 'type': previous[1]} ) os.makedirs(STATE_DIR, exist_ok=True) descriptor, tmp_path = tempfile.mkstemp(prefix='.previous-policy-', suffix='.tmp', dir=STATE_DIR) try: with os.fdopen(descriptor, 'w', encoding='utf-8') as stream: json.dump(state, stream, ensure_ascii=False, indent=2) stream.flush() os.fsync(stream.fileno()) os.replace(tmp_path, BACKUP_FILE) finally: if os.path.exists(tmp_path): os.unlink(tmp_path) def _write_policy(): with winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, POLICY_PATH, 0, winreg.KEY_SET_VALUE | REGISTRY_VIEW) as key: for name, (value, kind) in EXPECTED.items(): current = _read_value(POLICY_PATH, name) if current != (value, kind): winreg.SetValueEx(key, name, 0, kind, value) print('Policy %s = %r' % (name, value)) else: print('Policy %s deja conforme' % name) def install(): build, display = _assert_supported_os() current_version = _version_tuple(display) # 25H2 uses build family 26200. Newer release families cannot downgrade. if build >= 26300: raise RuntimeError( 'Ce poste utilise deja une build posterieure a 25H2 ' '(build %s, DisplayVersion=%s); downgrade impossible.' % (build, display) ) if current_version is not None and current_version > _version_tuple(TARGET_VERSION): raise RuntimeError( 'Ce poste est deja en Windows 11 %s (build %s). ' 'Un verrouillage 25H2 ne peut pas effectuer de downgrade.' % (display, build) ) if current_version is None: print('WARNING: DisplayVersion non reconnu (%r), verifier la version OS.' % display) _snapshot_policy() _write_policy() print('Windows Update cible Windows 11 %s; mises a jour qualite inchangees.' % TARGET_VERSION) def audit(): try: build, display = _assert_supported_os() except Exception as exc: print('ERROR: OS incompatible : %s' % exc) return 'ERROR' failures = [] for name, expected in EXPECTED.items(): actual = _read_value(POLICY_PATH, name) if actual != expected: failures.append('%s attendu=%r obtenu=%r' % (name, expected, actual)) if failures: print('ERROR: strategie absente, alteree ou surchargee : %s' % '; '.join(failures)) return 'ERROR' current_version = _version_tuple(display) if current_version is None: print('WARNING: policy conforme, mais version Windows inconnue : ' 'DisplayVersion=%r, build=%s' % (display, build)) return 'WARNING' if current_version > _version_tuple(TARGET_VERSION): print('ERROR: Windows 11 %s deja superieur a %s : downgrade impossible' % (display, TARGET_VERSION)) return 'ERROR' print('OK: Windows 11 %s (build %s), policy cible %s' % (display, build, TARGET_VERSION)) return 'OK' def uninstall(): """Restore initial settings without clobbering externally changed values. On some WAPT versions this must be triggered explicitly with `wapt-get uninstall ` before `wapt-get remove `. """ if not os.path.isfile(BACKUP_FILE): print('WARNING: aucune sauvegarde; pas de suppression aveugle des policies') return with open(BACKUP_FILE, 'r', encoding='utf-8') as stream: state = json.load(stream) if state.get('schema_version') != 1 or state.get('package') != PACKAGE_NAME: raise RuntimeError('Sauvegarde de strategie incompatible : ' + BACKUP_FILE) if set(state.get('values', {})) != set(EXPECTED): raise RuntimeError('Sauvegarde de strategie incomplete : ' + BACKUP_FILE) with winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, POLICY_PATH, 0, winreg.KEY_SET_VALUE | REGISTRY_VIEW) as key: for name, (ours, our_kind) in EXPECTED.items(): current = _read_value(POLICY_PATH, name) if current != (ours, our_kind): print('WARNING: %s modifie par un autre mecanisme; conserve : %r' % (name, current)) continue before = state['values'][name] if before['present']: winreg.SetValueEx(key, name, 0, before['type'], before['value']) print('Restauration : %s' % name) else: winreg.DeleteValue(key, name) print('Suppression : %s' % name) # Keep the WindowsUpdate parent key: it may contain other managed values. os.unlink(BACKUP_FILE) try: os.rmdir(STATE_DIR) except OSError: pass print('Restauration terminee (les parametres externes sont preserves).')