Ctrl K
ComunidadGuías…

tests/nsis_automation.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.

r"""Deterministic pywinauto driver for BleachBit NSIS setup/uninstall wizards.

Requires Windows, pywinauto, and Pillow:
    pip install pywinauto pillow

Run as a normal user; the script re-launches itself elevated and logs to
%TEMP%\bleachbit_nsis_test. Needs an interactive desktop session.
The installer language is forced to English for text-based page detection.

WARNING: destructive. Every mode first removes any existing BleachBit
install (per-machine and per-user), including its registry keys and
shortcuts, and the run ends by removing it again (use --keep to skip the
final cleanup). Run it in a VM or on a disposable machine.

Not covered: scenarios that need an unelevated standard user (per-user
upgrade without elevation, per-machine to per-user migration) because
the script always runs elevated, and uninstalling while the app is
running because it leaves pending delete-at-reboot entries that would
remove a later install.
Runs stop if an operation queues a rename or deletion in an install directory.
Existing unrelated pending operations, including NSIS temp cleanup, are ignored.

Usage:
    python nsis_automation.py basic|per_user|uninstall_keep_files|reinstall --new C:\path\new-setup.exe
    python nsis_automation.py upgrade|upgrade_running|upgrade_components|silent_upgrade|all --old C:\path\old-setup.exe --new C:\path\new-setup.exe
    Several modes may be listed. Options: --keep, --alert once|multiple

basic:    install --new (per-machine), plant the font cache that 6.0.5
          left in the install dir and an unrelated file next to it,
          then uninstall; verify registry, files, shortcuts, and that
          only the unrelated file is left.
per_user: the same as basic, per-user and without the unrelated file;
          verify HKCU registry, HKCU shred verb, %LOCALAPPDATA%\BleachBit,
          user shortcuts, and that the install dir is gone.
upgrade:  install --old, upgrade to --new; verify version bump and keys.
upgrade_running:
          install --old, upgrade to --new while the app runs: canceling
          the locked-file prompt keeps the old install; closing the app
          and retrying lets the upgrade through.
upgrade_components:
          install --old without Desktop shortcut, translations, or shred
          verb; upgrade to --new and verify component selection is kept.
uninstall_keep_files:
          install --new, add a custom file in the install dir, uninstall;
          verify no reboot prompt and the custom file is kept.
silent_upgrade:
          install --old, then run --new /S /allusers: aborts while the
          app runs and upgrades silently once it is closed.
reinstall: install --new, then reinstall --new over itself.
all:      run every mode in sequence.
"""

import argparse
import ctypes
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import winsound
import winreg
from contextlib import contextmanager
from ctypes import wintypes
from pathlib import Path

from PIL import ImageGrab
from pywinauto import Desktop, findwindows

RESULTS_DIR = Path(tempfile.gettempdir()) / "bleachbit_nsis_test"
TITLE_RE = re.compile(r"(?i)bleachbit")
UNPACK_RE = re.compile(
    r"unpacking data:|verifying installer:|please wait while setup is loading",
    re.I)
DEFAULT_INSTALL_DIR = Path(r"C:\Program Files (x86)\BleachBit")
PER_USER_INSTALL_DIR = (
    Path(os.environ.get("LOCALAPPDATA", r"C:\Users\Default\AppData\Local"))
    / "BleachBit"
)
UNINST_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\BleachBit"
SHRED_KEY = r"Software\Classes\AllFilesystemObjects\shell\shred.bleachbit"
APP_EXES = ("bleachbit.exe", "bleachbit_console.exe")

# ---------------------------------------------------------------- elevation


class SHELLEXECUTEINFOW(ctypes.Structure):
    """Input for shell32.ShellExecuteExW."""

    _fields_ = [
        ("cbSize", wintypes.DWORD), ("fMask", wintypes.ULONG),
        ("hwnd", wintypes.HWND), ("lpVerb", wintypes.LPCWSTR),
        ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
        ("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int),
        ("hInstApp", wintypes.HINSTANCE), ("lpIDList", ctypes.c_void_p),
        ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
        ("dwHotKey", wintypes.DWORD), ("hIcon", wintypes.HANDLE),
        ("hProcess", wintypes.HANDLE),
    ]


def is_admin():
    """Return True when running with admin rights."""
    return bool(ctypes.windll.shell32.IsUserAnAdmin())


def relaunch_elevated(argv):
    """Re-run this script elevated, wait for it, return its exit code."""
    shell32 = ctypes.WinDLL("shell32", use_last_error=True)
    kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
    params = subprocess.list2cmdline(
        [os.path.abspath(__file__), *argv, "--elevated"])
    if _alert_mode != "off":
        alert_popup("About to request elevation.\n"
                    "When the UAC dialog appears, accept it.")
    sei = SHELLEXECUTEINFOW()
    sei.cbSize = ctypes.sizeof(sei)
    sei.fMask = 0x40  # SEE_MASK_NOCLOSEPROCESS
    sei.lpVerb = "runas"
    sei.lpFile = sys.executable
    sei.lpParameters = params
    sei.lpDirectory = os.getcwd()
    sei.nShow = 1  # SW_SHOWNORMAL
    if not shell32.ShellExecuteExW(ctypes.byref(sei)):
        err = ctypes.get_last_error()
        if err == 1223:  # ERROR_CANCELLED
            print("elevation declined (UAC canceled); nothing ran")
            return 2
        raise ctypes.WinError(err)
    kernel32.WaitForSingleObject(sei.hProcess, 0xFFFFFFFF)
    code = wintypes.DWORD()
    kernel32.GetExitCodeProcess(sei.hProcess, ctypes.byref(code))
    kernel32.CloseHandle(sei.hProcess)
    return code.value

# ------------------------------------------------------------------ logging


_log_file = None

# --------------------------------------------------------------- user alert

ALERT_BEEPS = 5            # beeps per consent prompt in 'multiple' mode
ALERT_BEEP_INTERVAL = 5.0  # seconds between repeated beeps
_alert_mode = "off"


def alert_popup(text, timeout_ms=3000):
    """Auto-dismissing popup; falls back to a normal MessageBox."""
    try:
        ctypes.windll.user32.MessageBoxTimeoutW(
            0, text, "bleachbit_nsis_test", 0x40, 0, timeout_ms)
    except AttributeError:
        ctypes.windll.user32.MessageBoxW(0, text, "bleachbit_nsis_test", 0x40)
    except Exception:
        # the alert is best-effort, never fail the run over it
        pass


def beep():
    """Play a short tone; ignore audio errors."""
    try:
        winsound.Beep(880, 300)
    except Exception:
        # no sound device
        pass


def consent_exe_running():
    """Return True while a UAC consent.exe process is running."""
    out = subprocess.run(
        ["tasklist", "/fi", "IMAGENAME eq consent.exe", "/fo", "csv", "/nh"],
        capture_output=True, check=False, text=True).stdout
    return "consent.exe" in out.lower()


class ConsentWatcher(threading.Thread):
    """Beeps whenever a UAC consent.exe process appears."""

    def __init__(self, mode):
        super().__init__(daemon=True)
        self.mode = mode  # 'once' or 'multiple'

    def run(self):
        """Beep each time a UAC consent prompt appears."""
        seen = False
        while True:
            running = consent_exe_running()
            if running and not seen:
                seen = True
                if self.mode == "once":
                    beep()
                else:
                    for _ in range(ALERT_BEEPS):
                        beep()
                        time.sleep(ALERT_BEEP_INTERVAL)
                        if not consent_exe_running():
                            break
            elif not running:
                seen = False
            time.sleep(1.0)


def log(msg):
    """Print a timestamped line and append it to the log file."""
    line = f"[{time.strftime('%H:%M:%S')}] {msg}"
    print(line, flush=True)
    if _log_file:
        _log_file.write(line + "\n")
        _log_file.flush()

# ------------------------------------------------------------------- driver


def proc_image_path(pid):
    """Full image path of a process, or '' if not queryable."""
    kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
    h = kernel32.OpenProcess(0x1000, False, pid)  # QUERY_LIMITED_INFORMATION
    if not h:
        return ""
    try:
        buf = ctypes.create_unicode_buffer(1024)
        size = wintypes.DWORD(len(buf))
        if kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):
            return buf.value
    finally:
        kernel32.CloseHandle(h)
    return ""


CLICK_PRIORITY = [
    "i &agree", "&next", "&install", "&uninstall", "&retry",
    "ok", "&yes", "&finish", "&close",
]
SAFE_ON_REBOOT_DEFER = ["&no", "no", "&cancel", "cancel"]
SAFE_ON_REBOOT_COMMIT = ["&close", "&finish"]
REBOOT_LATER_RE = re.compile(r"later|manually|do not|don't|skip", re.I)


class StuckError(RuntimeError):
    """The driver could not make progress or found no safe action."""


class PendingFileOperationError(StuckError):
    """An install directory has pending operations that make testing unsafe."""


def _enabled(b):
    """Return b.is_enabled(), or False when not queryable."""
    try:
        return b.is_enabled()
    except Exception:
        return False


def _button_by_id(buttons, *control_ids):
    """Find an enabled push button without depending on its translated label."""
    for control_id in control_ids:
        for b in buttons:
            if b.control_id() == control_id and _enabled(b):
                return b, (b.window_text() or "").strip().lower()
    return None, None


def _is_window(hwnd):
    """Return True if hwnd is an existing, visible Win32 window."""
    user32 = ctypes.windll.user32
    return bool(user32.IsWindow(hwnd) and user32.IsWindowVisible(hwnd))


def all_dialogs():
    """Return all visible top-level #32770 dialog wrappers."""
    wins = []
    for hwnd in findwindows.find_windows(class_name="#32770"):
        if not _is_window(hwnd):
            continue
        try:
            # Resolve wrapper_object() immediately: a lazy WindowSpecification
            # blocks for 5s per attribute access once its hwnd closes.
            w = Desktop(backend="win32").window(handle=hwnd).wrapper_object()
            if w.is_visible():
                wins.append(w)
        except Exception:
            continue
    return wins


def _nsis_owned_path(path):
    """True if an image path looks like our installer/uninstaller copy.

    NSIS re-runs uninstallers from %TEMP% (~nsu*.tmp\\Un.exe) and the
    upgrade path executes a copy in $PLUGINSDIR (ns*.tmp); their dialog
    titles (language picker, uninstall wizard) need not contain
    'BleachBit'. Excludes the installed application executables.
    """
    if not path:
        return False
    path = os.path.normcase(path)
    base = os.path.basename(path)
    if base in APP_EXES:
        return False
    for d in (DEFAULT_INSTALL_DIR, PER_USER_INSTALL_DIR,
              install_dir("all_users"), install_dir("current_user")):
        if path.startswith(os.path.normcase(str(d)) + os.sep):
            return True
    temp = os.path.normcase(tempfile.gettempdir()) + os.sep
    if not path.startswith(temp):
        return False
    parent = os.path.basename(os.path.dirname(path)).lower()
    return ((parent.endswith(".tmp")
             and parent.startswith(("ns", "~nsu")))
            or bool(GENERIC_NAME_RE.search(base)))


def _is_unpack_banner(win):
    """Return True if win is the transient NSIS unpacking splash dialog."""
    try:
        texts = [
            (c.window_text() or "").strip()
            for c in win.descendants()
        ]
    except Exception:
        return True
    joined = " ".join(t for t in texts if t)
    return not joined or bool(UNPACK_RE.search(joined))


def find_setup_window():
    """Return the BleachBit wizard dialog, or None."""
    for w in all_dialogs():
        try:
            pid = w.process_id()
            path = proc_image_path(pid)
            if os.path.basename(path).lower() in APP_EXES:
                continue
            matched = (
                bool(TITLE_RE.search(w.window_text() or ""))
                or bool(TITLE_RE.search(os.path.basename(path)))
                or _nsis_owned_path(path)
            )
            if matched and not _is_unpack_banner(w):
                return w
        except Exception:
            continue
    return None


def _any_nsis_proc_running():
    """Return True if any NSIS installer or uninstaller stub is running."""
    return any(_nsis_owned_path(proc_image_path(pid))
               for pid in tasklist_names())


# Name match safe to kill anywhere: only our own processes match.
KILL_NAME_RE = re.compile(r"bleachbit", re.I)
# Generic names shared with other installers: kill only when the image
# lives in the install dir or %TEMP% (NSIS temp uninstaller stubs).
GENERIC_NAME_RE = re.compile(
    r"uninst|un_a|^un\.exe$|^[ab]u_.*\.exe$", re.I)


def tasklist_names():
    """Return {pid: image name} for all processes."""
    out = subprocess.run(["tasklist", "/fo", "csv", "/nh"],
                         capture_output=True, check=False, text=True).stdout
    names = {}
    for line in out.splitlines():
        m = re.match(r'"([^"]+)","(\d+)"', line)
        if m:
            names[int(m.group(2))] = m.group(1)
    return names


def preflight(exe_path, keep=None):
    """Kill leftover installer processes and close stale wizard windows.

    A process whose image path equals keep is spared; the upgrade tests
    keep the installed app running on purpose.
    """
    wanted = os.path.normcase(str(exe_path)) if exe_path else ""
    keep = os.path.normcase(str(keep)) if keep else None
    dirs = [os.path.normcase(str(d)) + os.sep for d in (
        DEFAULT_INSTALL_DIR, PER_USER_INSTALL_DIR,
        install_dir("all_users"), install_dir("current_user"),
        tempfile.gettempdir())]
    for pid, name in tasklist_names().items():
        path = os.path.normcase(proc_image_path(pid))
        if keep and path == keep:
            continue
        kill = bool(KILL_NAME_RE.search(name))
        if not kill:
            if wanted and path == wanted:
                kill = True
            elif GENERIC_NAME_RE.search(name):
                kill = any(path.startswith(d) for d in dirs)
        if kill:
            log(f"  preflight kill: {name} pid={pid}")
            subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
                           capture_output=True, check=False)
    for _ in range(30):
        if not find_setup_window():
            return
        for w in all_dialogs():
            try:
                if TITLE_RE.search(w.window_text() or ""):
                    w.close()
            except Exception:
                # window closed under us; the loop rechecks
                pass
        time.sleep(0.5)
    raise StuckError("leftover BleachBit window would not close")


class WizardDriver:
    """Steps through any NSIS #32770 wizard whose title mentions BleachBit."""

    # The installer's fc-cache step can take ten minutes, as its text says
    def __init__(self, shots_dir, idle_timeout=660, first_timeout=120,
                 cancel_locked=False, locked_callback=None, proc=None,
                 scope="all_users"):
        self.shots_dir = shots_dir
        self.idle_timeout = idle_timeout
        self.first_timeout = first_timeout
        self.cancel_locked = cancel_locked
        self.locked_callback = locked_callback
        self._locked_cb_fired = False
        self.proc = proc
        self.scope = scope
        self.flags = set()
        self.step = 0
        self.last_sig = None
        self.same_sig_count = 0

    def find_window(self):
        """Return the BleachBit wizard dialog, or None."""
        return find_setup_window()

    def wait_window(self, timeout):
        """Poll up to timeout seconds for the wizard dialog."""
        deadline = time.time() + timeout
        while time.time() < deadline:
            w = self.find_window()
            if w:
                return w
            time.sleep(0.5)
        return None

    @staticmethod
    def page_texts(win):
        """Return non-empty text of all descendant controls."""
        if not _is_window(win.handle):
            return []
        texts = []
        try:
            for c in win.descendants():
                t = (c.window_text() or "").strip()
                if t:
                    texts.append(t)
        except Exception:
            # page changed mid-walk; return what was read
            pass
        return texts

    def snapshot(self, win):
        """Save a screenshot of the wizard window."""
        if not _is_window(win.handle):
            return
        self.shots_dir.mkdir(parents=True, exist_ok=True)
        try:
            r = win.rectangle()
            img = ImageGrab.grab(bbox=(r.left, r.top, r.right, r.bottom))
            img.save(self.shots_dir / f"step{self.step:02d}.png")
        except Exception as e:
            log(f"  screenshot failed: {e}")

    def _reboot_click(self, buttons, labels, texts):
        """Pick a safe click on a reboot prompt, or raise."""
        # Never permit a real reboot: Finish/Close execute whichever
        # radio is checked, so allow them only after a 'later' option is
        # selected. Defer/cancel buttons are always safe on their own.
        button, label = _button_by_id(labels.values(), 7, 2)  # IDNO, IDCANCEL
        if button:
            return button, label
        later_selected = False
        for b in buttons:
            t = (b.window_text() or "").lower()
            if (b.friendly_class_name() != "Button"
                    and REBOOT_LATER_RE.search(t)):
                try:
                    if not b.is_checked():
                        b.click()
                except Exception:
                    b.click()
                later_selected = True
                log(f"  reboot page: selected {b.window_text()!r}")
                time.sleep(0.5)
                break
        wants = list(SAFE_ON_REBOOT_DEFER)
        if later_selected:
            wants += SAFE_ON_REBOOT_COMMIT
        for want in wants:
            for label, b in labels.items():
                if _enabled(b) and (label.startswith(want)
                                    or REBOOT_LATER_RE.search(label)):
                    return b, label
        raise StuckError(f"reboot prompt without safe option: {texts[:8]}")

    def _select_install_mode(self, buttons):
        """Select the 'All users' or 'For me' radio button on Choose Users."""
        needle = ("for me" if self.scope == "current_user"
                  else "all users")
        for b in buttons:
            if b.friendly_class_name() == "Button":
                continue
            norm = (b.window_text() or "").replace("&", "").lower()
            if needle in norm:
                try:
                    if not b.is_checked():
                        b.click()
                        log(f"  selected {b.window_text()!r}")
                except Exception:
                    b.click()
                    log(f"  clicked {b.window_text()!r}")
                time.sleep(0.5)
                break

    def drive(self):
        """Click through the wizard until no BleachBit window remains."""
        win = self.wait_window(self.first_timeout)
        if not win:
            raise StuckError("no setup window appeared")
        gone_since = None
        while True:
            texts = self.page_texts(win)
            if not texts:
                nxt = self.wait_window(5.0)
                if nxt:
                    win = nxt
                    continue
            joined = " ".join(texts).lower()
            sig = hash(joined)
            buttons = []
            try:
                for c in win.descendants():
                    if c.friendly_class_name() in (
                            "Button", "RadioButton", "CheckBox"):
                        buttons.append(c)
            except Exception:
                # page changed mid-walk; keep what was found
                pass
            btn_joined = " ".join(
                (b.window_text() or "").lower() for b in buttons)
            # Reboot detection must look at controls, not body text: the
            # Welcome page itself mentions "reboot" in its blurb.
            reboot_page = (
                "reboot now" in joined
                or "reboot" in btn_joined or "restart" in btn_joined
                or (("reboot" in joined or "restart" in joined)
                    and len(texts) <= 4))
            if reboot_page:
                self.flags.add("saw_reboot")
            if "already installed" in joined:
                self.flags.add("saw_already_installed")
            # the installer asks to close BleachBit before any file is written
            locked_page = ("error opening file for writing" in joined
                           or "bleachbit is running" in joined)
            if locked_page:
                self.flags.add("saw_locked_file")
                if self.locked_callback and not self._locked_cb_fired:
                    self._locked_cb_fired = True
                    self.locked_callback()
            if "finish" in joined:
                self.flags.add("saw_finish")
            log(f"  page {self.step}: {texts[:6]}")
            self.snapshot(win)
            self.step += 1

            if sig == self.last_sig:
                self.same_sig_count += 1
                if self.same_sig_count >= 10:
                    raise StuckError(f"stuck on page: {texts[:8]}")
            else:
                self.same_sig_count = 0
            self.last_sig = sig

            # Select the requested install mode radio on "Choose Users".
            # Skip push buttons: clicking one may advance the page and
            # invalidate the controls list used below.
            self._select_install_mode(buttons)

            # License pages gate Next behind an "accept" radio.
            # Skip push buttons for the same reason as above.
            for b in buttons:
                t = (b.window_text() or "").lower()
                if (b.friendly_class_name() != "Button"
                        and re.search(r"accept|agree", t)
                        and not re.search(r"not|decline", t)):
                    try:
                        if not b.is_checked():
                            b.click()
                            log(f"  selected {b.window_text()!r}")
                    except Exception:
                        b.click()
                        log(f"  clicked {b.window_text()!r}")
                    time.sleep(0.5)
                    break

            # Uncheck 'run/launch app' boxes: launching bleachbit.exe at the
            # end triggers its own UAC prompt via consent.exe.
            for b in buttons:
                t = (b.window_text() or "").strip().lower()
                if (b.friendly_class_name() in ("CheckBox", "Button")
                        and re.search(r"\b(run|launch|open|view)\b", t)
                        and "shortcut" not in t and "menu" not in t):
                    try:
                        if b.is_checked():
                            b.click()
                            log(f"  unchecked {b.window_text()!r}")
                    except Exception as e:
                        log(f"  could not uncheck {t!r}: {e}")
                    break

            labels = {(b.window_text() or "").strip().lower(): b
                      for b in buttons
                      if b.friendly_class_name() == "Button"}
            clicked = None
            clicked_label = None
            if reboot_page:
                clicked, clicked_label = self._reboot_click(
                    buttons, labels, texts)
            elif locked_page:
                # Native MessageBox labels follow Windows' UI language.
                control_id = 2 if self.cancel_locked else 4  # IDCANCEL, IDRETRY
                clicked, clicked_label = _button_by_id(
                    labels.values(), control_id)
                if not clicked:
                    log(f"  locked-file page lacks button {control_id}; "
                        "waiting")
            elif buttons:
                for want in CLICK_PRIORITY:
                    for label, b in labels.items():
                        if _enabled(b) and label.startswith(want):
                            clicked, clicked_label = b, label
                            break
                    if clicked:
                        break
                if not clicked:
                    clicked, clicked_label = _button_by_id(
                        labels.values(), 1, 6)  # IDOK, IDYES
                if not clicked:
                    log("  no enabled actionable button; waiting")
            if clicked:
                log(f"  click: {clicked_label!r}")
                try:
                    clicked.click()
                except Exception:
                    clicked.click_input()
                gone_since = None

            # Wait for the page to change or the window to vanish.
            time.sleep(0.8)
            deadline = time.time() + self.idle_timeout
            advanced = False
            while time.time() < deadline and not advanced:
                nxt = self.find_window()
                if not nxt:
                    # A live installer or temp uninstaller stub with no
                    # matching window is busy (e.g. unpacking $PLUGINSDIR).
                    if ((self.proc is not None and self.proc.poll() is None)
                            or _any_nsis_proc_running()):
                        gone_since = None
                    else:
                        if gone_since is None:
                            gone_since = time.time()
                        if time.time() - gone_since > 3.0:
                            return  # process gone and quiet -> wizard done
                else:
                    gone_since = None
                    nxt_texts = self.page_texts(nxt)
                    if nxt_texts:
                        new_sig = hash(" ".join(nxt_texts).lower())
                        if nxt.handle != win.handle or new_sig != sig:
                            win = nxt
                            advanced = True
                time.sleep(0.5)
            if not advanced:
                raise StuckError(
                    f"timed out waiting for next page; texts={texts[:8]} "
                    f"labels={list(labels)}")


def run_wizard(exe_path, shots_dir, keep=None, expected_exit=0, **driver_kwargs):
    """Launch exe_path, drive its wizard to completion, return page flags."""
    exe_path = Path(exe_path)
    if not exe_path.is_file():
        raise StuckError(f"executable not found: {exe_path}")
    with watch_pending_file_operations():
        preflight(exe_path, keep=keep)
        force_english()
        log(f"launching {exe_path.name}")
        return _drive_wizard(exe_path, shots_dir, expected_exit, driver_kwargs)


def _drive_wizard(exe_path, shots_dir, expected_exit, driver_kwargs):
    """Drive the launched process, including an explicitly expected exit code."""
    with subprocess.Popen([str(exe_path)]) as proc:
        d = WizardDriver(shots_dir, proc=proc, **driver_kwargs)
        err = None
        try:
            d.drive()
        except Exception as e:
            err = e
        try:
            proc.wait(timeout=15)
        except subprocess.TimeoutExpired:
            log("  killing lingering installer process tree")
            subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
                           capture_output=True, check=False)
            if err is None:
                err = StuckError("installer process did not exit cleanly")
        log(f"  exit code: {proc.returncode} (expected {expected_exit})")
        if err is None and proc.returncode != expected_exit:
            err = StuckError(
                f"installer exited {proc.returncode}; expected {expected_exit}")
        log(f"  wizard flags: {sorted(d.flags)}")
    if err:
        raise err
    return d.flags

# -------------------------------------------------------------- inspection


def read_reg(root, path, value_name=None):
    """Return {view: value_or_True} for each registry view where key exists."""
    out = {}
    for tag, flag in (("64", winreg.KEY_WOW64_64KEY),
                      ("32", winreg.KEY_WOW64_32KEY)):
        try:
            k = winreg.OpenKey(root, path, 0, winreg.KEY_READ | flag)
        except OSError:
            continue
        try:
            if value_name is None:
                out[tag] = True
            else:
                try:
                    out[tag] = winreg.QueryValueEx(k, value_name)[0]
                except OSError:
                    out[tag] = None
        finally:
            winreg.CloseKey(k)
    return out


def delete_reg_tree(root, subkey):
    """Recursively delete subkey in both 32-bit and 64-bit registry views."""
    for flag in (winreg.KEY_WOW64_32KEY, winreg.KEY_WOW64_64KEY):
        try:
            with winreg.OpenKey(
                    root, subkey, 0,
                    winreg.KEY_READ | winreg.KEY_WRITE | flag) as k:
                children = []
                try:
                    while True:
                        children.append(winreg.EnumKey(k, len(children)))
                except OSError:
                    pass
            for child in children:
                delete_reg_tree(root, f"{subkey}\\{child}")
            winreg.DeleteKeyEx(root, subkey, flag, 0)
        except OSError:
            continue


def safe_rmtree(path):
    """Remove an install dir, refusing anything not named BleachBit.

    The path may come from the registry, so never trust it blindly.
    """
    path = Path(path)
    if path.name.lower() != "bleachbit" or len(path.parts) < 3:
        log(f"  refusing to delete unexpected directory: {path}")
        return
    shutil.rmtree(path, ignore_errors=True)


def sha256(path):
    """Return the SHA-256 hex digest of a file."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def _scope_root(scope):
    """Return the registry hive root for scope."""
    return (winreg.HKEY_CURRENT_USER if scope == "current_user"
            else winreg.HKEY_LOCAL_MACHINE)


def install_dir(scope="all_users"):
    """Return the install dir from the registry for scope, or the default."""
    loc = read_reg(_scope_root(scope), UNINST_KEY, "InstallLocation")
    for v in loc.values():
        if v:
            return Path(v)
    return (PER_USER_INSTALL_DIR if scope == "current_user"
            else DEFAULT_INSTALL_DIR)


def _shell_folder(csidl):
    """Return a shell folder path, honoring redirection (e.g., OneDrive)."""
    buf = ctypes.create_unicode_buffer(260)
    if ctypes.windll.shell32.SHGetFolderPathW(0, csidl, 0, 0, buf):
        raise OSError(f"SHGetFolderPathW failed for CSIDL {csidl:#x}")
    return Path(buf.value)


def shortcut_dirs(scope="all_users"):
    """Return (start_menu_programs_dir, desktop_dir) for scope."""
    # CSIDL_PROGRAMS, CSIDL_DESKTOPDIRECTORY and their COMMON_ variants
    if scope == "current_user":
        return _shell_folder(0x02), _shell_folder(0x10)
    return _shell_folder(0x17), _shell_folder(0x19)


def shortcuts_found(scope="all_users"):
    """Return BleachBit shortcuts on Start Menu and Desktop for scope."""
    found = []
    for b in shortcut_dirs(scope):
        if b.exists():
            found += [str(p) for p in b.rglob("*bleachbit*.lnk")]
    return found


def system_state(scope="all_users", idir=None):
    """Return a dict of the on-disk and registry install state for scope."""
    idir = install_dir(scope) if idir is None else Path(idir)
    root = _scope_root(scope)
    uninst = idir / "uninstall.exe"
    return {
        "scope": scope,
        "install_dir": str(idir),
        "install_dir_exists": idir.exists(),
        "exe": (idir / "bleachbit.exe").exists(),
        "uninstall_exe": uninst.exists(),
        "uninstall_sha": sha256(uninst) if uninst.exists() else None,
        "reg_uninstall_version": read_reg(root, UNINST_KEY, "DisplayVersion"),
        "reg_uninstall_present": bool(read_reg(root, UNINST_KEY)),
        "shred_hklm": read_reg(winreg.HKEY_LOCAL_MACHINE, SHRED_KEY),
        "shred_hkcu": read_reg(winreg.HKEY_CURRENT_USER, SHRED_KEY),
        "shortcuts": shortcuts_found(scope),
    }


def check(results, name, ok, detail=""):
    """Record a check result and log it as PASS or FAIL."""
    results.append((name, bool(ok), detail))
    log(f"  {'PASS' if ok else 'FAIL'}  {name}  {detail}")


def force_english():
    """Set the language consumed by NSIS' language picker and uninstaller."""
    with winreg.CreateKeyEx(
            winreg.HKEY_CURRENT_USER, r"Software\BleachBit", 0,
            winreg.KEY_SET_VALUE | winreg.KEY_WOW64_32KEY) as key:
        winreg.SetValueEx(key, "Installer Language", 0, winreg.REG_SZ, "1033")


def pending_file_operations(dirs):
    """Return pending source/destination pairs affecting the given directories."""
    try:
        with winreg.OpenKey(
                winreg.HKEY_LOCAL_MACHINE,
                r"SYSTEM\CurrentControlSet\Control\Session Manager") as key:
            paths, _ = winreg.QueryValueEx(key, "PendingFileRenameOperations")
    except FileNotFoundError:
        return set()

    def normalized(path):
        # Windows can prefix pending paths with flags, followed by an NT prefix.
        path = re.sub(r"^\*\d+", "", path).lstrip("!")
        if path.startswith(("\\??\\", "\\\\?\\")):
            path = path[4:]
        return os.path.normcase(path)

    roots = [os.path.normcase(str(d)).rstrip("\\") for d in dirs]

    def related(path):
        path = normalized(path)
        return any(path == root or path.startswith(root + "\\") for root in roots)

    return {(source, dest) for source, dest in zip(paths[::2], paths[1::2])
            if related(source) or related(dest)}


@contextmanager
def watch_pending_file_operations():
    """Reject existing or newly queued operations that could damage an install."""
    # Retain custom paths even if uninstall removes their registry entries.
    dirs = {DEFAULT_INSTALL_DIR, PER_USER_INSTALL_DIR,
            install_dir("all_users"), install_dir("current_user")}
    before = pending_file_operations(dirs)
    if before:
        raise PendingFileOperationError(
            f"existing pending install-file operations: {sorted(before)}; "
            "reboot the test machine before retrying")
    try:
        yield
    finally:
        dirs.update((install_dir("all_users"), install_dir("current_user")))
        added = pending_file_operations(dirs) - before
        if added:
            raise PendingFileOperationError(
                f"new pending install-file operations: {sorted(added)}; "
                "stopping without further cleanup; reboot before retrying")

# ----------------------------------------------------------- app & silent


def proc_running(exe_path):
    """Return True if a process with this image path is running."""
    wanted = os.path.normcase(str(exe_path))
    return any(os.path.normcase(proc_image_path(pid)) == wanted
               for pid in tasklist_names())


def launch_app(exe_path, settle=3.0):
    """Launch the installed app; return True while it stays running."""
    exe_path = Path(exe_path)
    if not exe_path.exists():
        log(f"  app not found: {exe_path}")
        return False
    log(f"launching {exe_path}")
    # pylint: disable-next=consider-using-with
    subprocess.Popen([str(exe_path)])
    time.sleep(settle)
    ok = proc_running(exe_path)
    if not ok:
        log("  app process exited or never started")
    return ok


def kill_path(exe_path):
    """Kill every process whose image path equals exe_path."""
    wanted = os.path.normcase(str(exe_path))
    for pid in tasklist_names():
        if os.path.normcase(proc_image_path(pid)) == wanted:
            log(f"  killing pid={pid} ({exe_path})")
            subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
                           capture_output=True, check=False)


def wait_for(pred, desc, timeout=60):
    """Poll pred once a second; return False when timeout expires."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        if pred():
            return True
        time.sleep(1)
    log(f"  timeout waiting for {desc}")
    return False


def run_silent(exe_path, extra_args=(), keep=None, timeout=900):
    """Run an installer with /S; return its exit code (None on timeout)."""
    exe_path = Path(exe_path)
    if not exe_path.is_file():
        raise StuckError(f"executable not found: {exe_path}")
    with watch_pending_file_operations():
        preflight(exe_path, keep=keep)
        force_english()
        cmd = [str(exe_path), "/S", *extra_args]
        log("running silently: " + " ".join(cmd))
        try:
            rc = subprocess.run(cmd, timeout=timeout, check=False).returncode
        except subprocess.TimeoutExpired:
            log(f"  silent run did not exit within {timeout}s")
            return None
        time.sleep(3)  # let registry and files settle
        log(f"  exit code: {rc}")
        return rc


def ensure_uninstalled():
    """Remove any existing per-machine or per-user BleachBit installation."""
    with watch_pending_file_operations():
        preflight(None)
        for scope, flag in (("all_users", "/allusers"),
                            ("current_user", "/currentuser")):
            idir = install_dir(scope)
            uninst = idir / "uninstall.exe"
            if uninst.exists():
                log(f"  cleanup: running existing uninstaller ({scope})")
                tmp_uninst = (Path(tempfile.gettempdir())
                              / "bb_cleanup_uninst.exe")
                try:
                    shutil.copy(uninst, tmp_uninst)
                    subprocess.run(
                        [str(tmp_uninst), flag, "/S", f"_?={idir}"],
                        timeout=120, check=False)
                except Exception as e:
                    log(f"  cleanup uninstaller error: {e}")
                finally:
                    tmp_uninst.unlink(missing_ok=True)
            if idir.exists():
                safe_rmtree(idir)
            delete_reg_tree(_scope_root(scope), UNINST_KEY)
            delete_reg_tree(_scope_root(scope), SHRED_KEY)
            start_dir, desktop_dir = shortcut_dirs(scope)
            shutil.rmtree(start_dir / "BleachBit", ignore_errors=True)
            (desktop_dir / "BleachBit.lnk").unlink(missing_ok=True)


def install_baseline(exe_path, results, label="old installed"):
    """Clean the system, silently install exe_path (/allusers), return state."""
    ensure_uninstalled()
    rc = run_silent(Path(exe_path), ["/allusers"])
    wait_for(lambda: os.path.exists(install_dir() / "bleachbit.exe"),
             "baseline bleachbit.exe", timeout=60)
    st = system_state()
    log("baseline state: " + json.dumps(st, indent=2, default=str))
    check(results, label,
          rc == 0 and st["exe"] and st["uninstall_exe"], f"rc={rc}")
    return st


def verify_old_intact(results, st_old, st, label):
    """Check that an aborted/canceled upgrade left the old install intact."""
    check(results, label,
          st["exe"] and st["reg_uninstall_present"]
          and st["uninstall_sha"] == st_old["uninstall_sha"]
          and st["reg_uninstall_version"] == st_old["reg_uninstall_version"],
          f"exe={st['exe']} reg={st['reg_uninstall_present']}")


def verify_upgraded(results, st_old, st):
    """Check standard post-upgrade invariants against st_old."""
    check(results, "install dir exists", st["install_dir_exists"])
    check(results, "bleachbit.exe present", st["exe"])
    check(results, "uninstall.exe replaced",
          st["uninstall_sha"]
          and st["uninstall_sha"] != st_old["uninstall_sha"])
    ver = [v for v in st["reg_uninstall_version"].values() if v]
    check(results, "version bumped", any(
        str(v) != str(o)
        for v in ver for o in st_old["reg_uninstall_version"].values() if o),
        f"old={st_old['reg_uninstall_version']} "
        f"new={st['reg_uninstall_version']}")
    check(results, "shred context menu intact",
          bool(st["shred_hklm"] or st["shred_hkcu"]),
          f"HKLM={st['shred_hklm']} HKCU={st['shred_hkcu']}")
    check(results, "shortcuts intact",
          bool(st["shortcuts"]), str(st["shortcuts"]))

# -------------------------------------------------------------------- modes


def _install_and_uninstall(exe_path, scope, tag, plant_unrelated=False):
    """Install exe_path in scope, then uninstall; return check results."""
    results = []
    ensure_uninstalled()
    log(f"== install ({tag}, {scope}) ==")
    flags = run_wizard(Path(exe_path), RESULTS_DIR / f"{tag}-install",
                       scope=scope)
    check(results, "wizard finished", "saw_finish" in flags)
    check(results, "no reboot prompt", "saw_reboot" not in flags)
    st = system_state(scope)
    log("state: " + json.dumps(st, indent=2, default=str))
    check(results, "install dir exists",
          st["install_dir_exists"], st["install_dir"])
    check(results, "bleachbit.exe present", st["exe"])
    check(results, "uninstall.exe present", st["uninstall_exe"])
    check(results, "Uninstall reg key", st["reg_uninstall_present"],
          str(st["reg_uninstall_version"]))
    if scope == "current_user":
        check(results, "HKLM Uninstall key absent",
              not read_reg(winreg.HKEY_LOCAL_MACHINE, UNINST_KEY))
        check(results, "shred context menu key in HKCU only",
              bool(st["shred_hkcu"]) and not st["shred_hklm"],
              f"HKLM={st['shred_hklm']} HKCU={st['shred_hkcu']}")
    else:
        check(results, "shred context menu key",
              bool(st["shred_hklm"] or st["shred_hkcu"]),
              f"HKLM={st['shred_hklm']} HKCU={st['shred_hkcu']}")
    check(results, "shortcuts present",
          bool(st["shortcuts"]), str(st["shortcuts"]))

    idir = Path(st["install_dir"])
    uninst = idir / "uninstall.exe"
    if not uninst.exists():
        check(results, "uninstaller available", False, str(uninst))
        return results
    # 6.0.5 ran fc-cache with a cachedir inside the install folder, and
    # upgrading keeps the result (#2437)
    font_cache = idir / "var" / "cache" / "fontconfig"
    font_cache.mkdir(parents=True, exist_ok=True)
    (font_cache / "CACHEDIR.TAG").write_text(
        "Signature: 8a477f597d28d172789f06886806bc55\n", encoding="utf-8")
    (font_cache / "0123456789abcdef0123456789abcdef-le32d4.cache-9"
     ).write_bytes(b"\0" * 64)
    check(results, "font cache leftover planted", font_cache.is_dir())
    unrelated = idir / "var" / "unrelated.txt"
    if plant_unrelated:
        unrelated.write_text("not ours\n", encoding="utf-8")
    log(f"== uninstall ({tag}, {scope}) ==")
    flags = run_wizard(uninst, RESULTS_DIR / f"{tag}-uninstall", scope=scope)
    check(results, "uninstall wizard finished", "saw_finish" in flags)
    check(results, "no reboot prompt on uninstall", "saw_reboot" not in flags)
    st = system_state(scope, idir=idir)
    log("state: " + json.dumps(st, indent=2, default=str))
    check(results, "font cache leftover removed",
          not (idir / "var" / "cache").exists())
    if plant_unrelated:
        left = sorted(p.relative_to(idir).as_posix() for p in idir.rglob("*"))
        check(results, "only the unrelated file left",
              left == ["var", "var/unrelated.txt"], str(left))
        safe_rmtree(idir)
    else:
        check(results, "install dir removed", not st["install_dir_exists"])
    check(results, "Uninstall reg key removed",
          not st["reg_uninstall_present"])
    check(results, "shred key removed",
          not st["shred_hklm"] and not st["shred_hkcu"])
    check(results, "shortcuts removed",
          not st["shortcuts"], str(st["shortcuts"]))
    return results


def mode_basic(a):
    """Install --new (per-machine), then uninstall; verify system state."""
    return _install_and_uninstall(a.new, "all_users", "basic",
                                  plant_unrelated=True)


def mode_per_user(a):
    """Install --new (per-user), then uninstall; verify HKCU and files."""
    return _install_and_uninstall(a.new, "current_user", "per_user")


def mode_upgrade(a):
    """Install --old via GUI, upgrade with --new, verify the upgrade state."""
    results = []
    ensure_uninstalled()
    log("== install (old) ==")
    flags_old = run_wizard(Path(a.old), RESULTS_DIR / "upgrade-old-install")
    st_old = system_state()
    log("old state: " + json.dumps(st_old, indent=2, default=str))
    check(results, "old installed",
          "saw_finish" in flags_old
          and st_old["exe"] and st_old["uninstall_exe"])

    log("== upgrade (new over old) ==")
    flags = run_wizard(Path(a.new), RESULTS_DIR / "upgrade-new-install")
    check(results, "'already installed' prompt seen",
          "saw_already_installed" in flags)
    check(results, "no reboot prompt", "saw_reboot" not in flags)
    check(results, "wizard finished", "saw_finish" in flags)
    st = system_state()
    log("new state: " + json.dumps(st, indent=2, default=str))
    verify_upgraded(results, st_old, st)
    return results


def mode_upgrade_running(a):
    """Upgrade --old to --new while the app is running.

    First run cancels the locked-file prompt and expects the old install
    to survive untouched; second run kills the app when the prompt
    appears, so the driver's retry lets the upgrade through.
    """
    results = []
    log("== install (old) ==")
    st_old = install_baseline(a.old, results)
    app_exe = Path(st_old["install_dir"]) / "bleachbit.exe"

    log("== upgrade, app running, cancel ==")
    check(results, "app running (cancel run)", launch_app(app_exe))
    flags = run_wizard(Path(a.new), RESULTS_DIR / "upgrade_running-cancel",
                       keep=app_exe, cancel_locked=True, expected_exit=2)
    check(results, "'already installed' prompt seen",
          "saw_already_installed" in flags)
    check(results, "locked-file prompt seen", "saw_locked_file" in flags)
    kill_path(app_exe)
    verify_old_intact(results, st_old, system_state(),
                      "old install intact after cancel")

    log("== upgrade, app running, retry after close ==")
    check(results, "app running (retry run)", launch_app(app_exe))
    flags = run_wizard(Path(a.new), RESULTS_DIR / "upgrade_running-retry",
                       keep=app_exe,
                       locked_callback=lambda: kill_path(app_exe))
    check(results, "locked-file prompt seen", "saw_locked_file" in flags)
    check(results, "no reboot prompt", "saw_reboot" not in flags)
    check(results, "wizard finished", "saw_finish" in flags)
    st = system_state()
    log("new state: " + json.dumps(st, indent=2, default=str))
    verify_upgraded(results, st_old, st)
    return results


def mode_upgrade_components(a):
    """Verify ReadOldComponents preserves deselected optional components.

    Installs --old, strips the Desktop shortcut, share\\locale, and the
    shred registry key (leaving Start Menu shortcuts in place), then runs
    --new via the GUI wizard and checks that only the Start Menu
    shortcuts and core files are installed.
    """
    results = []
    log("== install (old) and strip optional components ==")
    st_old = install_baseline(a.old, results)
    idir = Path(st_old["install_dir"])
    start_dir, desktop_dir = shortcut_dirs("all_users")
    desktop_lnk = desktop_dir / "BleachBit.lnk"
    locale_dir = idir / "share" / "locale"

    desktop_lnk.unlink(missing_ok=True)
    shutil.rmtree(locale_dir, ignore_errors=True)
    delete_reg_tree(winreg.HKEY_LOCAL_MACHINE, SHRED_KEY)
    delete_reg_tree(winreg.HKEY_CURRENT_USER, SHRED_KEY)
    check(results, "old optional components stripped",
          not desktop_lnk.exists() and not locale_dir.exists()
          and not read_reg(winreg.HKEY_LOCAL_MACHINE, SHRED_KEY))

    log("== upgrade (new over stripped old) ==")
    flags = run_wizard(
        Path(a.new), RESULTS_DIR / "upgrade_components-new-install")
    check(results, "'already installed' prompt seen",
          "saw_already_installed" in flags)
    check(results, "no reboot prompt", "saw_reboot" not in flags)
    check(results, "wizard finished", "saw_finish" in flags)
    st = system_state()
    log("new state: " + json.dumps(st, indent=2, default=str))
    check(results, "bleachbit.exe present", st["exe"])
    check(results, "uninstall.exe replaced",
          st["uninstall_sha"]
          and st["uninstall_sha"] != st_old["uninstall_sha"])
    check(results, "Start Menu shortcut preserved",
          (start_dir / "BleachBit" / "BleachBit.lnk").exists())
    check(results, "Desktop shortcut stayed unselected",
          not desktop_lnk.exists())
    check(results, "Translations stayed unselected",
          not locale_dir.exists())
    check(results, "Shred verb stayed unselected",
          not st["shred_hklm"] and not st["shred_hkcu"])
    return results


def mode_uninstall_keep_files(a):
    """Verify uninstall keeps user-added files without asking for a reboot."""
    results = []
    log("== install (new) ==")
    st_init = install_baseline(a.new, results, label="new installed")
    idir = Path(st_init["install_dir"])
    custom_file = idir / "custom_cleaner.xml"
    custom_file.write_text("<cleaner id='custom'/>\n", encoding="utf-8")
    check(results, "custom file created", custom_file.exists())

    log("== uninstall with custom file present ==")
    uninst = idir / "uninstall.exe"
    flags = run_wizard(uninst, RESULTS_DIR / "uninstall_keep_files")
    check(results, "uninstall wizard finished", "saw_finish" in flags)
    check(results, "no reboot prompt on uninstall", "saw_reboot" not in flags)
    st = system_state(idir=idir)
    log("post-uninstall state: " + json.dumps(st, indent=2, default=str))
    check(results, "bleachbit.exe removed", not st["exe"])
    check(results, "uninstall.exe removed", not st["uninstall_exe"])
    check(results, "custom file kept", custom_file.exists())
    check(results, "Uninstall reg key removed",
          not st["reg_uninstall_present"])
    check(results, "shred key removed",
          not st["shred_hklm"] and not st["shred_hkcu"])
    check(results, "shortcuts removed",
          not st["shortcuts"], str(st["shortcuts"]))

    safe_rmtree(idir)
    return results


def mode_silent_upgrade(a):
    """Silent /allusers upgrade of --old to --new.

    A silent upgrade must abort while the app is running (NSIS answers
    the locked-file prompt with its /SD IDCANCEL default and Abort
    exits nonzero), leaving the old install intact; rerun after the app
    closes to verify the silent upgrade itself.
    """
    results = []
    log("== install (old) ==")
    st_old = install_baseline(a.old, results)
    app_exe = Path(st_old["install_dir"]) / "bleachbit.exe"

    log("== silent upgrade while app runs ==")
    check(results, "app running", launch_app(app_exe))
    rc = run_silent(Path(a.new), ["/allusers"], keep=app_exe)
    check(results, "silent upgrade aborted while running",
          rc is not None and rc != 0, f"rc={rc}")
    verify_old_intact(results, st_old, system_state(),
                      "old install intact after abort")
    kill_path(app_exe)

    log("== silent upgrade after app closed ==")
    rc = run_silent(Path(a.new), ["/allusers"], keep=app_exe)
    check(results, "silent upgrade succeeded", rc == 0, f"rc={rc}")
    wait_for(lambda: os.path.exists(install_dir() / "bleachbit.exe"),
             "new bleachbit.exe", timeout=120)
    st = system_state()
    log("new state: " + json.dumps(st, indent=2, default=str))
    verify_upgraded(results, st_old, st)
    return results


def mode_reinstall(a):
    """Reinstall the same --new version over itself via the GUI wizard."""
    results = []
    log("== first install (new) ==")
    install_baseline(a.new, results, label="first install succeeded")

    log("== reinstall (new over new) ==")
    flags = run_wizard(Path(a.new), RESULTS_DIR / "reinstall")
    check(results, "'already installed' prompt seen",
          "saw_already_installed" in flags)
    check(results, "no reboot prompt", "saw_reboot" not in flags)
    check(results, "wizard finished", "saw_finish" in flags)
    st = system_state()
    log("reinstall state: " + json.dumps(st, indent=2, default=str))
    check(results, "install dir exists", st["install_dir_exists"])
    check(results, "bleachbit.exe present", st["exe"])
    check(results, "uninstall.exe present", st["uninstall_exe"])
    check(results, "Uninstall reg key", st["reg_uninstall_present"])
    check(results, "shred context menu intact",
          bool(st["shred_hklm"] or st["shred_hkcu"]))
    check(results, "shortcuts intact", bool(st["shortcuts"]))
    return results

# --------------------------------------------------------------------- main


MODES = {
    "basic": mode_basic,
    "per_user": mode_per_user,
    "upgrade": mode_upgrade,
    "upgrade_running": mode_upgrade_running,
    "upgrade_components": mode_upgrade_components,
    "uninstall_keep_files": mode_uninstall_keep_files,
    "silent_upgrade": mode_silent_upgrade,
    "reinstall": mode_reinstall,
}
NEEDS_OLD = {"upgrade", "upgrade_running",
             "upgrade_components", "silent_upgrade"}


def installer_path(value):
    """Validate installer inputs before any elevation or destructive cleanup."""
    path = Path(value).absolute()
    if not path.is_file():
        raise argparse.ArgumentTypeError(f"installer is not a file: {value}")
    return path


def main():
    """Run _main and log any uncaught crash to %TEMP%."""
    try:
        _main()
    except Exception:
        import traceback
        tb = traceback.format_exc()
        try:
            RESULTS_DIR.mkdir(exist_ok=True)
            (RESULTS_DIR / "crash.log").write_text(tb, encoding="utf-8")
        except Exception:
            # still printed below
            pass
        print(tb)
        sys.exit(1)


def _main():
    """Parse args, elevate if needed, run the requested mode(s), and exit."""
    p = argparse.ArgumentParser()
    p.add_argument("mode", nargs="+", choices=[*MODES, "all"])
    p.add_argument("--old", type=installer_path)
    p.add_argument("--new", type=installer_path)
    p.add_argument("--keep", action="store_true",
                   help="skip the final uninstall so state can be inspected")
    p.add_argument("--elevated", action="store_true", help=argparse.SUPPRESS)
    p.add_argument("--alert", choices=["off", "once", "multiple"],
                   default="off",
                   help="beep when a UAC consent prompt needs a human click")
    a = p.parse_args()
    modes = list(MODES) if "all" in a.mode else a.mode
    if not a.new:
        p.error("--new is required")
    need_old = [m for m in modes if m in NEEDS_OLD]
    if need_old and not a.old:
        p.error(f"{', '.join(need_old)} requires --old")

    global _alert_mode  # pylint: disable=global-statement
    _alert_mode = a.alert

    if not a.elevated:
        if is_admin():
            log("already elevated; running directly")
        else:
            log("not elevated; re-launching (expect one UAC prompt)")
            sys.exit(relaunch_elevated(sys.argv[1:]))

    if a.alert != "off":
        ConsentWatcher(a.alert).start()

    global _log_file  # pylint: disable=global-statement
    RESULTS_DIR.mkdir(exist_ok=True)
    tag = "all" if "all" in a.mode else "-".join(modes)
    log_path = RESULTS_DIR / f"{tag}-{int(time.time())}.log"
    all_results = []
    pending_reboot = False
    with open(log_path, "w", encoding="utf-8") as log_f:
        _log_file = log_f
        for m in modes:
            log(f"================ mode: {m} ================")
            try:
                res = MODES[m](a)
            except StuckError as e:
                log(f"DRIVER ERROR ({m}): {e}")
                res = [(f"{m}: driver completed", False, str(e))]
                pending_reboot = isinstance(e, PendingFileOperationError)
            except Exception:
                import traceback
                log(f"UNEXPECTED ERROR ({m}):\n" + traceback.format_exc())
                res = [(f"{m}: driver completed", False,
                        "unexpected exception")]
            all_results.extend((f"{m}: {name}", ok, detail)
                               for name, ok, detail in res)
            if pending_reboot:
                break
        if not a.keep and not pending_reboot:
            try:
                ensure_uninstalled()
            except PendingFileOperationError as e:
                check(all_results, "final cleanup: no pending install-file operations",
                      False, str(e))

        failed = [r for r in all_results if not r[1]]
        passed = len(all_results) - len(failed)
        log(f"== done: {passed}/{len(all_results)} checks passed ==")
        for name, _, detail in failed:
            log(f"  FAILED: {name}  {detail}")
        sys.exit(1 if failed else 0)


if __name__ == "__main__":
    main()