Ctrl K
ComunidadGuías…

windows/setup.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.

"""
Windows build and packaging

Examples of invocation (from parent directory):

```
python3.exe -m windows.setup
python3.exe -m windows.setup max-effort
python3.exe -m windows.setup upx
```

Maximum-effort is disabled by default to save time on routine CI builds.

UPX is disabled by default because it increases build time and sometimes
triggers antivirus false positives.
"""

# standard library
import fnmatch
import glob
import importlib.util
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time

# third party
import certifi

# local import
import bleachbit
from setup import supported_languages
from bleachbit.CleanerML import CleanerML
from bleachbit.FileUtilities import children_in_directory
from bleachbit.SystemInformation import get_version
from windows.NsisUtilities import write_nsis_expressions_to_files

SetupEncoding = sys.stdout.encoding
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
formatter = logging.Formatter(
    "%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S")
ch.setFormatter(formatter)
logger.addHandler(ch)

# Prevent propagation to root logger to avoid duplicate messages
logger.propagate = False

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
logger.info('ROOT_DIR %s', ROOT_DIR)
sys.path.append(ROOT_DIR)

GTK_DIR = sys.exec_prefix + '\\Lib\\site-packages\\gnome\\'
if os.path.exists(GTK_DIR):
    GTK_LIBDIR = GTK_DIR
else:
    GTK_LIBDIR = sys.exec_prefix
    GTK_DIR = os.path.join(GTK_LIBDIR, '..', '..')
NSIS_EXE = 'C:\\Program Files (x86)\\NSIS\\makensis.exe'
NSIS_ALT_EXE = 'C:\\Program Files\\NSIS\\makensis.exe'
SHRED_REGEX_KEY = 'AllFilesystemObjects\\shell\\shred.bleachbit'
if not os.path.exists(NSIS_EXE) and os.path.exists(NSIS_ALT_EXE):
    logger.info('NSIS found in alternate location: %s', NSIS_ALT_EXE)
    NSIS_EXE = NSIS_ALT_EXE
SZ_EXE = 'C:\\Program Files\\7-Zip\\7z.exe'
UPX_EXE = shutil.which('upx') or (ROOT_DIR + '\\upx\\upx.exe')
UPX_OPTS = '--best --nrv2e'
ADVZIP_EXE = shutil.which('advzip') or (ROOT_DIR + '\\advancecomp\\advzip.exe')


def get_build_settings():
    """Determine build preset and optimization flags.

    Presets:
      fast       - enable quick mode for PR builds
                   * skip UPX and the library.zip repack
                   * set faster compression for .zip and NSIS
      regular    - default build
                   * enables the stored library.zip repack
                   * set maximum compression for .zip and NSIS
      max-effort - enable Deadpool mode for release builds
                   * build English-only installer
                   * recompress .zip with advzip
    """
    arg = 'regular'
    for a in sys.argv[1:]:
        if a.lower() not in ('py2exe', 'upx'):
            arg = a.lower()
            break

    is_fast = arg == 'fast'
    is_max_effort = arg == 'max-effort'

    upx_requested = 'upx' in (a.lower() for a in sys.argv[1:])
    if upx_requested and not os.path.exists(UPX_EXE):
        logger.error('UPX was requested (with "upx") '
                     'but not found at %s', UPX_EXE)
        sys.exit(1)
    upx_enabled = upx_requested

    return {
        'preset': 'fast' if is_fast else ('max-effort' if is_max_effort else 'regular'),
        'fast': is_fast,  # controls .zip and NSIS compression levels
        'build_english': is_max_effort,  # build English-only installer
        'upx': upx_enabled,  # compress executables
        'upx_tag': '-upx' if upx_enabled else '',  # filename tag for UPX builds
        # recompress zips with advzip
        'advzip': is_max_effort and bool(os.path.exists(ADVZIP_EXE)),
        'recompress_lib': not is_fast,  # prune and repack library.zip
    }


def recompress_with_advzip(zip_path):
    """Recompress a .zip archive with advzip"""
    assert_exist(zip_path)
    logger.info('Recompressing %s with advzip -4 (zopfli)', zip_path)
    file_size_old = os.path.getsize(zip_path)
    t0 = time.time()
    cmd = [ADVZIP_EXE, '--recompress', '--shrink-insane', zip_path]
    run_cmd(cmd)
    t1 = time.time()
    file_size_new = os.path.getsize(zip_path)
    file_size_diff = file_size_old - file_size_new
    logger.info('advzip recompression of %s reduced size by %s from %s to %s in %.1f s',
                zip_path, f'{file_size_diff:,}', f'{file_size_old:,}', f'{file_size_new:,}', t1 - t0)


def archive(infile, outfile, settings, use_advzip=False, store=False):
    """Create an archive from a file

    This uses 7-Zip to create a .zip archive with the request
    compression stings.

    If use_advzip is enabled, then advzip recompresses the .zip
    file, which is not needed for the zipped installer.

    If store is enabled, the files are not compressed.
    """
    assert_exist(infile)
    delete_file(outfile, warn_if_exists=True)
    # maximum compression with maximum compatibility
    # mm=deflate method because deflate64 not widely supported
    # mpass=passes for deflate encoder
    # mfb=number of fast bytes
    # bso0 bsp0 quiet output
    # 7-Zip Command Line Reverence Wizard: https://axelstudios.github.io/7z/#!/
    sz_opts = ['-tzip', '-mm=Deflate', '-mfb=258',
               '-mpass=7', '-bso0', '-bsp0']  # best compression
    if store:
        sz_opts = ['-tzip', '-mm=Copy', '-bso0', '-bsp0']
    elif settings['fast']:
        # fast compression
        sz_opts = ['-tzip', '-mx=1', '-bso0', '-bsp0']
    cmd = [SZ_EXE, 'a'] + sz_opts + [outfile, infile]
    run_7z(cmd)
    assert_exist(outfile)
    if use_advzip and settings['advzip']:
        recompress_with_advzip(outfile)


def recursive_glob(rootdir, patterns):
    """Recursively search for files matching the given patterns"""
    return [os.path.join(looproot, filename)
            for looproot, _, filenames in os.walk(rootdir)
            for filename in filenames
            if any(fnmatch.fnmatch(filename, pattern) for pattern in patterns)]


def assert_exist(path, msg=None):
    """Check if a path exists

    If not, log an error and exit."""
    if not os.path.exists(path):
        logger.error('%s not found', path)
        if msg:
            logger.error(msg)
        sys.exit(1)


def check_exist(path, msg=None):
    """Check if a path exists

    If not, log a warning and sleep for 5 seconds, except on CI."""
    if not os.path.exists(path):
        logger.warning('%s not found', path)
        if msg:
            logger.warning(msg)
        if not os.environ.get('GITHUB_ACTIONS'):
            time.sleep(5)


def assert_module(module):
    """Check if a module is available"""
    try:
        spec = importlib.util.find_spec(module)
    except ImportError:
        spec = None
    if spec is None:
        logger.error('Failed to import %s', module)
        logger.error('Process aborted because of error!')
        sys.exit(1)


def assert_execute(args, expected_output, timeout=120):
    """Run a command and check it returns the expected output.

    A timeout guards against hangs (e.g., GTK main loop not quitting).
    On timeout, the child is killed and any captured output is logged
    to aid troubleshooting.
    """
    try:
        actual_output = subprocess.check_output(
            args, stderr=subprocess.STDOUT, timeout=timeout).decode(SetupEncoding)
    except subprocess.CalledProcessError as e:
        output = (e.output or b'').decode(SetupEncoding, errors='replace')
        logger.error('Command %s failed with exit code %s. Output:\n%s',
                     args, e.returncode, output)
        raise
    except subprocess.TimeoutExpired as e:
        partial = (e.output or b'').decode(SetupEncoding, errors='replace')
        logger.error('Command %s timed out after %ss. Partial output:\n%s',
                     args, timeout, partial)
        raise RuntimeError(f'Timeout running {args} after {timeout}s') from e
    logger.info(actual_output.rstrip())
    if -1 == actual_output.find(expected_output):
        raise RuntimeError(
            f'When running command {args} expected output {expected_output} but got {actual_output}')


def assert_execute_console():
    """Check the application starts"""
    logger.info('Checking bleachbit_console.exe starts')
    assert_execute([r'dist\bleachbit_console.exe', '--gui', '--exit', '--no-uac'],
                   'Success')


def assert_execute_comms_check():
    """Check the network stack (requests, TLS, CA bundle)"""
    logger.info('Check the frozen network stack can talk')
    assert_execute([r'dist\bleachbit_console.exe', '--comms-check', '--no-uac'],
                   'FIVE BY FIVE')


def run_cmd(cmd, check=True, log_cmd=True, env=None):
    """Run a command and log the output

    Return the exit code. If check is true, a non-zero exit code aborts.
    When log_cmd is false, the command line itself is not logged (useful
    for tight loops that log their own summary).
    """
    if log_cmd:
        if isinstance(cmd, list):
            logger.info(subprocess.list2cmdline(cmd))
        else:
            logger.info(cmd)
    with subprocess.Popen(cmd, stdin=subprocess.PIPE,
                          stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                          env=env) as p:
        stdout, stderr = p.communicate()
        if stdout:
            logger.info(stdout.decode(SetupEncoding))
        if stderr:
            logger.error(stderr.decode(SetupEncoding))
    if p.returncode and check:
        logger.error('Command exited with code %d', p.returncode)
        sys.exit(1)
    return p.returncode


def run_7z(cmd):
    """Run 7-Zip

    Exit code 1 is a warning, such as a locked file. Higher is fatal.
    """
    returncode = run_cmd(cmd, check=False)
    if returncode == 1:
        logger.warning('7-Zip exited with a warning')
    elif returncode:
        logger.error('7-Zip exited with code %d', returncode)
        sys.exit(1)


def sign_files(filenames):
    """Add a digital signature

    Passing multiple filenames in one function call can be faster than
    two calls.
    """
    if os.path.exists('CodeSign.bat'):
        logger.info('Signing code: %s', ' '.join(filenames))
        # A failed signature must not ship unnoticed
        run_cmd(['CodeSign.bat', *filenames])
    else:
        logger.warning('CodeSign.bat not available for %s',
                       ' '.join(filenames))


def get_dir_size(start_path='.'):
    """Get the size of a directory

    FileUtilities.getsizedir() is not used because it prepends the
    extended-length prefix, which Windows rejects on the relative paths
    used here.
    """
    return sum(os.path.getsize(fn) for fn in children_in_directory(start_path))


def copy_file(src, dst):
    """Copy a file

    The dst must be a full path.
    """
    assert_exist(src)
    dst_dirname = os.path.dirname(dst)
    # If the destination directory is current directory, do not create it.
    if dst_dirname and not os.path.exists(dst_dirname):
        os.makedirs(dst_dirname)
    # shutil.copy() and .copyfile() do not preserve file date.
    # Check if target file exists and compare content
    if os.path.exists(dst):
        if os.path.getsize(src) == os.path.getsize(dst):
            with open(src, 'rb') as f1, open(dst, 'rb') as f2:
                if f1.read() == f2.read():
                    logger.debug(
                        'files identical, skipping copy: %s to %s', src, dst)
                    return
        logger.warning('target file exists with different content: %s', dst)
        delete_file(dst)

    shutil.copy2(src, dst)


def copy_tree(src, dst):
    """Copy a directory tree"""
    src = os.path.abspath(src)
    assert_exist(src)
    logger.info('copying %s to %s', src, dst)
    # copytree() preserves file date
    shutil.copytree(src, dst, dirs_exist_ok=True)


def count_size_improvement(func):
    """Decorator to count the size improvement of a function"""
    def wrapper(*args, **kwargs):
        t0 = time.time()
        size0 = get_dir_size('dist')
        func(*args, **kwargs)
        size1 = get_dir_size('dist')
        t1 = time.time()
        logger.info('Reduced size of the dist directory by %s B from %s B to %s B in %.1f s',
                    f'{size0 - size1:,}', f'{size0:,}', f'{size1:,}', t1 - t0)
    return wrapper


def delete_file(path, warn_if_exists=False):
    """Delete a file.

    If the file does not exist, silently skip.
    If it exists and ``warn_if_exists`` is True, log a warning before
    deleting.
    """
    if not os.path.exists(path):
        return
    if warn_if_exists:
        logger.warning('Deleting file that already exists: %s', path)
    os.remove(path)


def _delete_paths(paths):
    """Delete a list of paths under dist/, logging size saved per entry.

    Each entry in *paths* is a path relative to dist/. Directories are
    removed with shutil.rmtree(ignore_errors=True); files are removed
    with os.remove, with a warning logged on failure rather than
    aborting the prune pass.
    """
    for rel in paths:
        path = os.path.join('dist', rel)
        if not os.path.exists(path):
            logger.warning('Path does not exist: %s', path)
            continue
        if os.path.isdir(path):
            size = get_dir_size(path)
            shutil.rmtree(path, ignore_errors=True)
            logger.info('Deleting directory %s saved %s B', path, f'{size:,}')
        else:
            size = os.path.getsize(path)
            try:
                os.remove(path)
            except OSError as e:
                logger.warning('Failed to remove %s: %s', path, e)
                continue
            logger.info('Deleting file %s saved %s B', path, f'{size:,}')


def _prune_assets(root, exts, keep_list, label='asset'):
    """Remove assets under *root* matching *exts*, keeping *keep_list*.

    Glob *root* recursively for files matching *exts* (e.g. ['*.png',
    '*.svg']); remove any whose basename is not in *keep_list*, logging
    each kept file as 'keeping <label>: <path>'. Removal failures are
    logged as warnings rather than raised.
    """
    for f in recursive_glob(root, exts):
        if os.path.basename(f) not in keep_list:
            try:
                os.remove(f)
            except OSError as e:
                logger.warning('Failed to remove %s: %s', f, e)
        else:
            logger.info('keeping %s: %s', label, f)


def environment_check():
    """Check the build environment"""
    logger.info('Checking for 32-bit Python')
    if bleachbit.ARCH_BITS != 32:
        logger.error('Expected 32-bit Python but found %d-bit',
                     bleachbit.ARCH_BITS)
        sys.exit(1)

    logger.info('Checking for translations')
    assert_exist('locale', 'run "make -C po local" to build translations')

    logger.info('Checking PyGI library')
    assert_module('gi')

    logger.info('Checking Python win32 library')
    assert_module('win32file')

    logger.info('Checking Python py2exe library')
    assert_module('py2exe')

    logger.info('Checking for CodeSign.bat')
    check_exist('CodeSign.bat', 'Code signing is not available')

    logger.info('Checking for NSIS')
    check_exist(
        NSIS_EXE, 'NSIS executable not found: will try to build portable BleachBit')

    logger.info('Checking for 7-Zip')
    check_exist(SZ_EXE, '7-Zip executable not found')


def build_py2exe():
    """Build executables using py2exe's freeze API"""
    from py2exe import freeze
    # See multiple issues about overly description such as:
    # https://github.com/bleachbit/bleachbit/issues/1000
    app_description = 'Delete unwanted data'

    # Common version info for both GUI and console executables
    formatted_version = get_version(four_parts=True)
    version_info = {
        'version': formatted_version,
        'product_version': formatted_version,
        'product_name': bleachbit.APP_NAME,
        'description': app_description,
        'company_name': bleachbit.APP_NAME,
        'internal_name': f'{bleachbit.APP_NAME} GUI',
        'copyright': bleachbit.APP_COPYRIGHT,
    }

    # GUI bleachbit.exe
    gui_target = {
        'script': 'bleachbit.py',
        'icon_resources': [(1, 'windows/bleachbit.ico')],
        'version_info': version_info
    }

    # Console bleachbit_console.exe
    console_target = gui_target.copy()
    console_target['script'] = 'bleachbit_console.py'
    console_target['internal_name'] = f'{bleachbit.APP_NAME} Console'

    options = {
        'bundle_files': 3,  # All files copied to dist directory
        'compressed': 0,     # Stored, so imports skip inflate
        'optimize': 2,       # Extra optimization (like python -OO)
        'includes': ['gi'],
        'packages': ['charset_normalizer', 'encodings', 'gi', 'gi.overrides', 'plyer.platforms.win.notification'],
        'excludes': ['pyreadline', 'difflib', 'doctest',
                     'pickle', 'ftplib', 'bleachbit.Unix',
                     'setuptools', 'tomli', 'wheel', 'backports',
                     'importlib_metadata', 'zipp', 'packaging', 'distutils',
                     'unittest', 'test',
                     'multiprocessing',
                     'concurrent.futures.process',
                     'chardet',  # not present now, but defensive
                     'xmlrpc',  # only consumer was multiprocessing.connection
                     '_pydecimal',  # dead fallback
                     # non-Windows platform modules
                     'psutil._psaix', 'psutil._psbsd', 'psutil._pslinux',
                     'psutil._psosx', 'psutil._pssunos'],
    }

    freeze(
        windows=[gui_target],
        console=[console_target],
        zipfile='library.zip',
        options=options,
        version_info=version_info
    )


def recompile_mo(langdir, app, langid, dst):
    """Recompile gettext .mo file to shrink file size."""
    mo_pathname = os.path.normpath(f'{langdir}/LC_MESSAGES/{app}.mo')
    if not os.path.exists(mo_pathname):
        logger.info('does not exist: %s', mo_pathname)
        return

    # decompile .mo to .po
    po = os.path.join(dst, langid + '.po')
    __args = ['msgunfmt', '-o', po,
              mo_pathname]
    ret = bleachbit.General.run_external(__args)
    if ret[0] != 0:
        raise RuntimeError(ret[2])

    # shrink .po
    po2 = os.path.join(dst, langid + '.po2')
    __args = ['msgmerge', '--no-fuzzy-matching', po,
              os.path.normpath(f'windows/{app}.pot'),
              '-o', po2]
    ret = bleachbit.General.run_external(__args)
    if ret[0] != 0:
        raise RuntimeError(ret[2])

    # compile smaller .po to smaller .mo
    __args = ['msgfmt', po2, '-o', mo_pathname]
    ret = bleachbit.General.run_external(__args)
    if ret[0] != 0:
        raise RuntimeError(ret[2])

    # clean up
    os.remove(po)
    os.remove(po2)


@count_size_improvement
def clean_dist_locale():
    """Recompile GTK translations in dist/share/locale to shrink them"""
    tmpd = tempfile.mkdtemp('gtk_locale')
    supported_langs = supported_languages()
    basedir = os.path.normpath('dist/share/locale')
    have_msgunfmt = bleachbit.FileUtilities.exe_exists('msgunfmt.exe')
    # clean_translations() already removed the unsupported languages
    recompile_langs = [langid for langid in sorted(os.listdir(basedir))
                       if langid in supported_langs]
    if recompile_langs:
        if have_msgunfmt:
            logger.info('recompiling supported GTK languages: %s',
                        recompile_langs)
            for lang_id in recompile_langs:
                langdir = os.path.join(basedir, lang_id)
                recompile_mo(langdir, 'gtk30', lang_id, tmpd)
        else:
            logger.warning('msgunfmt missing: skipping recompile')
    os.rmdir(tmpd)


def build():
    """Build the application"""
    logger.info('Deleting directories build and dist')
    shutil.rmtree('build', ignore_errors=True)
    shutil.rmtree('dist', ignore_errors=True)
    shutil.rmtree('BleachBit-Portable', ignore_errors=True)

    logger.info('Running py2exe')
    copy_file('bleachbit.py', 'bleachbit_console.py')
    build_py2exe()
    assert_exist('dist\\bleachbit.exe')
    assert_exist('dist\\bleachbit_console.exe')
    os.remove('bleachbit_console.py')

    if not os.path.exists('dist'):
        os.makedirs('dist')

    os.makedirs(os.path.join('dist', 'share'), exist_ok=True)

    logger.info('Copying GTK helpers')
    for exe in glob.glob('gspawn-win*-helper*.exe', root_dir=GTK_LIBDIR):
        copy_file(os.path.join(GTK_LIBDIR, exe), os.path.join('dist', exe))
    for exe in ('fc-cache.exe', 'gdbus.exe'):
        copy_file(os.path.join(GTK_LIBDIR, exe), os.path.join('dist', exe))

    logger.info('Copying GTK files and icon')
    for d in ('fonts', 'gtk-3.0'):
        path = os.path.join(GTK_DIR, 'etc', d)
        copy_tree(path, os.path.join('dist', 'etc', d))
    # GTK then loads the dark theme first, the default dark_mode option
    copy_file(r'windows\settings.ini', r'dist\etc\gtk-3.0\settings.ini')
    for d in ('gdk-pixbuf-2.0', 'girepository-1.0'):
        path = os.path.join(GTK_DIR, 'lib', d)
        copy_tree(path, os.path.join('dist', 'lib', d))

    gtk_share = os.path.join(GTK_LIBDIR, 'share')
    for d in ('icons', 'themes'):
        path = os.path.join(gtk_share, d)
        copy_tree(path, os.path.join('dist', 'share', d))

    # The startup checks before shrink() rebuilds it need working paths
    fix_loaders_cache_paths(os.path.join(
        'dist', 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders.cache'))

    schemas_dir = 'share\\glib-2.0\\schemas'
    gschemas_compiled_src = os.path.join(
        GTK_DIR, schemas_dir, 'gschemas.compiled')
    gschemas_compiled_dst = os.path.join(
        'dist', schemas_dir, 'gschemas.compiled')
    copy_file(gschemas_compiled_src, gschemas_compiled_dst)
    copy_file('bleachbit.png', 'dist\\share\\bleachbit.png')
    # bleachbit.ico is used the for pop-up notification.
    copy_file('windows\\bleachbit.ico', 'dist\\share\\bleachbit.ico')
    for dll in glob.glob('*.dll', root_dir=GTK_LIBDIR):
        copy_file(os.path.join(GTK_LIBDIR, dll), 'dist\\' + dll)

    # Copy share files
    copy_file('share\\app-menu.ui', 'dist\\share\\app-menu.ui')
    copy_file('share\\protected_path.xml', 'dist\\share\\protected_path.xml')

    logger.info('Copying themes')
    copy_tree('themes', 'dist\\themes')

    logger.info('Copying CA bundle')
    copy_file(certifi.where(),
              os.path.join('dist', 'cacert.pem'))

    dist_locale_dir = r'dist\share\locale'
    shutil.rmtree(dist_locale_dir, ignore_errors=True)
    os.makedirs(dist_locale_dir)

    logger.info('Copying GTK localizations')
    locale_dir = os.path.join(GTK_DIR, 'share\\locale\\')
    for f in recursive_glob(locale_dir, ['gtk30.mo']):
        if not f.startswith(locale_dir):
            continue
        rel_f = f[len(locale_dir):]
        copy_file(f, os.path.join(dist_locale_dir, rel_f))
    assert_exist(os.path.join(dist_locale_dir, r'es\LC_MESSAGES\gtk30.mo'))

    logger.info('Copying BleachBit localizations')
    copy_tree('locale', dist_locale_dir)
    assert_exist(os.path.join(dist_locale_dir, r'es\LC_MESSAGES\bleachbit.mo'))

    logger.info('Copying BleachBit cleaners')
    if not os.path.exists('dist\\share\\cleaners'):
        os.makedirs('dist\\share\\cleaners')
    cleaners_files = recursive_glob('cleaners', ['*.xml'])
    for file in cleaners_files:
        shutil.copy(file, 'dist\\share\\cleaners')

    logger.info('Checking for CleanerML')
    assert_exist('dist\\share\\cleaners\\internet_explorer.xml')

    logger.info('Copying license')
    copy_file('COPYING', 'dist\\COPYING')

    logger.info('Copying DLL')
    python_version = sys.version_info[:2]
    if python_version == (3, 4):
        # For Python 3.4, copy msvcr100.dll
        dll_name = 'msvcr100.dll'
    elif python_version >= (3, 10):
        # For Python 3.10, copy vcruntime140.dll
        dll_name = 'vcruntime140.dll'
    else:
        logger.error('Unsupported Python version')
        sys.exit(1)
    dll_dirs = (sys.prefix, r'c:\windows\system32', r'c:\windows\SysWOW64')
    copied_dll = False
    for dll_dir in dll_dirs:
        dll_path = os.path.join(dll_dir, dll_name)
        if os.path.exists(dll_path):
            logger.info('Copying %s from %s', dll_name, dll_path)
            shutil.copy(dll_path, 'dist')
            copied_dll = True
            break
    if not copied_dll:
        logger.error('%s not found', dll_name)
        sys.exit(1)

    sign_files(('dist\\bleachbit.exe', 'dist\\bleachbit_console.exe'))

    assert_execute_console()

    # The installer runs fc-cache.exe to build the font cache
    logger.info('Checking fc-cache.exe starts')
    assert_execute([r'dist\fc-cache.exe', '--version'], 'fontconfig version')


@count_size_improvement
def delete_unnecessary():
    """Delete unnecessary files"""
    logger.info('Deleting unnecessary files')
    # Remove SVG to reduce space and avoid this error
    # Error loading theme icon 'dialog-warning' for stock: Unable to load image-loading module: C:/PythonXY/Lib/site-packages/gtk-2.0/runtime/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.dll: `C:/PythonXY/Lib/site-packages/gtk-2.0/runtime/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.dll': The specified module could not be found.
    # https://bugs.launchpad.net/bleachbit/+bug/1650907
    delete_paths = [
        r'_queue.pyd',
        r'_win32sysloader.pyd',
        r'perfmon.pyd',
        r'servicemanager.pyd',
        r'share\icons\highcontrast',
        r'win32evtlog.pyd',
    ]
    _delete_paths(delete_paths)


@count_size_improvement
def delete_icons():
    """Delete unused PNG/SVG icons to reduce size"""
    logger.info('Deleting unused PNG/SVG icons')
    # This keep list comes from analyze_process_monitor_events.py
    # (run via procmon_capture.py in bleachbit-misc repo).
    # SVGs were removed from the keep list because ProcMon tracing
    # confirmed GTK 3 did not load any .svg files from disk.
    # It does load some resources from its dll.
    # emblem-readonly is an exception: it is requested by name in
    # GuiPreferences.py and has no GTK built-in equivalent.
    icon_keep_list = [
        'edit-clear-all.png',
        'edit-delete.png',
        'edit-find.png',
        'process-stop.png',  # abort on toolbar
        'emblem-readonly.png',  # keep list page in preferences
        'emblem-readonly.svg',  # keep list page in preferences
    ]
    _prune_assets(r'dist\share\icons', ['*.png', '*.svg'],
                  icon_keep_list, label='protected icon')


@count_size_improvement
def delete_unused_themes():
    """Delete unused theme files to reduce size"""
    logger.info('Deleting unused theme files')
    # share\themes\adwaita\ ships a gtk.css that literally says
    # "this file is not used": GTK uses its internal Adwaita theme.
    _delete_paths([r'share\themes\adwaita'])

    # Prune unused assets from themes\windows10\assets\.
    # The keep list comes from ProcMon tracing (analyze_process_monitor_events.py
    # in bleachbit-misc repo).
    theme_asset_keep_list = [
        # arrows (non-active states)
        'arrow-down.svg',
        'arrow-left.svg',
        'arrow-right.svg',
        'arrow-up.svg',
        # checkboxes (checked + unchecked, with insensitive/over states)
        'checkbox-checked.png',
        'checkbox-checked@2.png',
        'checkbox-checked-insensitive.png',
        'checkbox-checked-insensitive@2.png',
        'checkbox-checked-over.png',
        'checkbox-checked-over@2.png',
        'checkbox-unchecked.png',
        'checkbox-unchecked@2.png',
        'checkbox-unchecked-insensitive.png',
        'checkbox-unchecked-insensitive@2.png',
        'checkbox-unchecked-over.png',
        'checkbox-unchecked-over@2.png',
        # window buttons
        'close-focused.png',
        'close-focused-active.png',
        'close-unfocused.png',
        'maximize-focused.png',
        'maximize-unfocused.png',
        'minimize-focused.png',
        'minimize-unfocused.png',
    ]
    assets_dir = r'dist\themes\windows10\assets'
    if os.path.exists(assets_dir):
        _prune_assets(assets_dir, ['*.png', '*.svg'],
                      theme_asset_keep_list, label='theme asset')


def remove_empty_dirs(root):
    """Remove empty directories"""
    for entry in os.scandir(root):
        if entry.is_dir():
            remove_empty_dirs(entry.path)
            if not os.listdir(entry.path):
                logger.info('Deleting empty directory: %s', entry.path)
                os.rmdir(entry.path)


@count_size_improvement
def clean_translations():
    """Clean translations (localizations)"""
    logger.info('Cleaning translations')
    pygtk_translations = os.listdir('dist/share/locale')
    supported_translations = supported_languages()
    for pt in pygtk_translations:
        if pt not in supported_translations:
            path = 'dist/share/locale/' + pt
            shutil.rmtree(path)


@count_size_improvement
def upx():
    """Compress executables with UPX to reduce size"""
    if not os.path.exists(UPX_EXE):
        logger.warning(
            'UPX not found. To compress executables, install UPX to: %s', UPX_EXE)
        return

    logger.info('Compressing executables')
    # Do not compress bleachbit.exe and bleachbit_console.exe to avoid false positives
    # with antivirus software. Not much is space with gained with these small files, anyway.
    upx_files = recursive_glob('dist', ['*.dll', '*.pyd'])
    # Skip vcruntime140.dll because CantPackException and already signed.
    upx_skip = {'vcruntime140.dll'}
    upx_files = [f for f in upx_files
                 if os.path.basename(f).lower() not in upx_skip]

    # upx is single-threaded, so split files into size-balanced batches
    # and run them as concurrent processes to use all CPU cores.
    num_batches = min(os.cpu_count() or 1, len(upx_files)) or 1
    batches = [[] for _ in range(num_batches)]
    batch_sizes = [0] * num_batches
    for f in sorted(upx_files, key=os.path.getsize, reverse=True):
        i = batch_sizes.index(min(batch_sizes))
        batches[i].append(f)
        batch_sizes[i] += os.path.getsize(f)

    procs = []
    for batch in batches:
        if not batch:
            continue
        cmd = [UPX_EXE] + UPX_OPTS.split() + batch
        logger.info(subprocess.list2cmdline(cmd))
        # The processes run in parallel and are collected below.
        # pylint: disable-next=consider-using-with
        procs.append(subprocess.Popen(cmd, stdin=subprocess.PIPE,
                                      stdout=subprocess.PIPE, stderr=subprocess.PIPE))
    for p in procs:
        stdout, stderr = p.communicate()
        logger.info(stdout.decode(SetupEncoding))
        if stderr:
            logger.error(stderr.decode(SetupEncoding))
        # Not fatal because UPX returns non-zero for any file it cannot pack
        if p.returncode:
            logger.error('UPX exited with code %d', p.returncode)

    assert_execute_console()


@count_size_improvement
def delete_linux_only():
    """Delete Linux-only cleaners to reduce size"""
    logger.info('Deleting Linux-only cleaners')
    files = recursive_glob('dist/share/cleaners/', ['*.xml'])
    for fn in files:
        cml = CleanerML(fn)
        if not cml.get_cleaner().is_usable():
            logger.info('Deleting cleaner not usable on this OS: %s', fn)
            os.remove(fn)


def repack_library(settings):
    """Prune library.zip and repack it stored, so imports skip inflate"""
    if not os.path.exists(SZ_EXE):
        logger.warning('%s does not exist', SZ_EXE)
        return

    logger.info('Repacking library.zip with 7-Zip')

    # extract library.zip
    if not os.path.exists('dist\\library'):
        os.makedirs('dist\\library')
    cmd = [SZ_EXE, 'x', 'dist\\library.zip', '-odist\\library', '-y']
    run_7z(cmd)
    file_size_old = os.path.getsize('dist\\library.zip')
    os.remove('dist\\library.zip')

    # clean unused modules from library.zip
    delete_paths = ['plyer\\platforms\\android',
                    'plyer\\platforms\\ios', 'plyer\\platforms\\linux', 'plyer\\platforms\\macosx']
    for p in delete_paths:
        path = os.path.join('dist', 'library', p)
        if os.path.exists(path):
            shutil.rmtree(path)

    # Prune unneeded codecs from the encodings package.
    encodings_keep_list = (
        '__init__', 'aliases',
        'cp*',  # every Windows ANSI codepage
        'utf_8', 'utf_8_sig',
        'utf_16', 'utf_16_le', 'utf_16_be',
        'utf_32', 'utf_32_le', 'utf_32_be',
        'latin_1', 'ascii', 'mbcs', 'charmap', 'iso8859_1',
        # Below are codecs used by standard library.
        'punycode', 'idna',
        'unicode_escape', 'raw_unicode_escape', 'unicode_internal',
        'hex_codec', 'base64_codec', 'zlib_codec', 'bz2_codec',
        'qp_codec', 'quopri_codec', 'uu_codec',
        'rot_13', 'palmos', 'oem', 'undefined',
    )
    encodings_files = glob.glob(os.path.join(
        'dist', 'library', 'encodings', '*.pyc'))
    encodings_deleted = 0
    for enc in encodings_files:
        stem = os.path.splitext(os.path.basename(enc))[0]
        if any(fnmatch.fnmatch(stem, pattern)
               for pattern in encodings_keep_list):
            continue
        encodings_deleted += 1
        try:
            os.remove(enc)
        except OSError as e:
            logger.warning('Failed to remove %s: %s', enc, e)
    logger.info('Deleted %d of %d files in dist\\library\\encodings',
                encodings_deleted, len(encodings_files))

    # remove .dist-info metadata directories
    for dist_info_dir in glob.glob(os.path.join('dist', 'library', '*.dist-info')):
        logger.info('Removing .dist-info directory: %s', dist_info_dir)
        shutil.rmtree(dist_info_dir)

    # remove empty directories
    remove_empty_dirs('dist\\library')

    # repack library.zip
    os.chdir('dist\\library')
    archive('.', '..\\library.zip', settings, store=True)
    os.chdir('..\\..')
    file_size_new = os.path.getsize('dist\\library.zip')
    logger.info('library.zip changed size by %s from %s to %s',
                f'{file_size_new - file_size_old:+,}', f'{file_size_old:,}', f'{file_size_new:,}')
    shutil.rmtree('dist\\library', ignore_errors=True)
    assert_exist('dist\\library.zip')


def shrink(settings):
    """After building, run all the applicable size optimizations"""
    delete_unnecessary()
    delete_icons()
    delete_unused_themes()
    clean_translations()
    remove_empty_dirs('dist')
    if settings['upx']:
        upx()
    clean_dist_locale()

    delete_linux_only()

    if settings['recompress_lib']:
        repack_library(settings)

    rebuild_loaders_cache()

    # so calculate the size of the folder, as it is a goal to shrink it.
    logger.info('Final size of the dist folder: %s',
                f'{get_dir_size("dist"):,}')

    # After all the shrink changes, check that the app still works.
    assert_execute_console()
    assert_execute_comms_check()


def rebuild_loaders_cache():
    """Rebuild loaders.cache from what dist ships

    Run after the pruning in shrink() so a DLL the SVG loader needs
    fails the build when it's missing or was pruned.
    """
    logger.info('Rebuilding loaders.cache file')
    # The GTK bundle's copy can predate the SVG loader and list nothing
    dist_dir = os.path.abspath('dist')
    pixbuf_dir = os.path.join(dist_dir, 'lib', 'gdk-pixbuf-2.0', '2.10.0')
    loaders_fn = os.path.join(pixbuf_dir, 'loaders.cache')
    query_loaders = os.path.join(
        GTK_DIR, 'tools', 'gdk-pixbuf', 'gdk-pixbuf-query-loaders.exe')
    assert_exist(query_loaders)
    # Windows looks next to the exe first and the bundle keeps its own
    # glib etc. there, so run a copy that can only find the DLLs in dist
    with tempfile.TemporaryDirectory() as tmp_dir:
        tmp_exe = shutil.copy(query_loaders, tmp_dir)
        env = dict(os.environ,
                   GDK_PIXBUF_MODULEDIR=os.path.join(pixbuf_dir, 'loaders'),
                   GDK_PIXBUF_MODULE_FILE=loaders_fn,
                   PATH=dist_dir)
        run_cmd([tmp_exe, '--update-cache'], env=env)
    data = fix_loaders_cache_paths(loaders_fn)
    # query-loaders skips a loader it can't load and still exits 0
    if '"pixbufloader-svg.dll"' not in data:
        logger.error('%s has no SVG loader', loaders_fn)
        sys.exit(1)


def fix_loaders_cache_paths(loaders_fn):
    """Strip the directories from the loader paths in loaders.cache

    GdkPixbuf finds a bare file name in its loaders folder, wherever dist is.
    Return the new contents.
    """
    with open(loaders_fn, 'r+', encoding=SetupEncoding) as f:
        data = f.read()
        data = re.sub(r'^".*[/\\](.*\.dll)"$',
                      r'"\1"', data, flags=re.I | re.M)
        f.seek(0)
        f.write(data)
        f.truncate()
    return data


def keep_font_cache_in_portable(portable_dir):
    """Make fontconfig store its cache in the portable folder

    fontconfig writes to the first cachedir it can create and puts the
    folder of fontconfig-1.dll in front of a path starting with /.
    %LOCALAPPDATA% stays as the fallback for a read-only folder.
    """
    fn = os.path.join(portable_dir, 'etc', 'fonts', 'fonts.conf')
    with open(fn, encoding='utf-8', newline='') as f:
        data = f.read()
    anchor = '<cachedir>LOCAL_APPDATA_FONTCONFIG_CACHE</cachedir>'
    cachedir = '<cachedir>/var/cache/fontconfig</cachedir>'
    if cachedir in data:
        # Just copied from dist, so the installer would ship it too
        logger.error('%s already contains %s', fn, cachedir)
        sys.exit(1)
    data, count = re.subn(
        rf'^([ \t]*)({re.escape(anchor)})(\r?\n)',
        rf'\1{cachedir}\3\1\2\3',
        data, count=1, flags=re.M)
    if not count:
        logger.error('%s not found in %s', anchor, fn)
        sys.exit(1)
    with open(fn, 'w', encoding='utf-8', newline='') as f:
        f.write(data)


def package_portable(settings):
    """Package the portable version"""
    logger.info('Building portable')
    copy_tree('dist', 'BleachBit-Portable')
    with open("BleachBit-Portable\\BleachBit.ini", "w", encoding=SetupEncoding) as text_file:
        text_file.write("[Portable]")
    keep_font_cache_in_portable('BleachBit-Portable')

    archive('BleachBit-Portable',
            f'BleachBit-{get_version()}-portable{settings["upx_tag"]}.zip',
            settings, use_advzip=True)


def nsis(opts, exe_name, nsi_path, settings):
    """Run NSIS with the options to build exe_name"""
    if os.path.exists(exe_name):
        logger.info('Deleting old file: %s', exe_name)
        os.remove(exe_name)
    # /WX: treat NSIS warnings as errors
    cmd = [NSIS_EXE, '/WX'] + opts.split() + [
        f'/DVERSION={get_version()}',
        f'/DSHRED_REGEX_KEY={SHRED_REGEX_KEY}',
        nsi_path]
    if settings['upx'] and os.path.exists(UPX_EXE):
        # NSIS !packhdr requires backslashes and no quotes in the define
        upx_path = UPX_EXE.replace('/', '\\')
        cmd.insert(-1, f'/DUPX_EXE={upx_path}')
    run_cmd(cmd)
    assert_exist(exe_name)


def installer_name(settings, lang='', ext='exe'):
    """Return the installer filename for the given language and extension"""
    return f'windows\\BleachBit-{get_version()}-setup{lang}{settings["upx_tag"]}.{ext}'


def package_installer(settings, nsi_path=r'windows\bleachbit.nsi'):
    """Package the installer"""

    assert isinstance(settings, dict)
    assert isinstance(nsi_path, str)

    if not os.path.exists(NSIS_EXE):
        logger.warning('NSIS not found, so not building installer')
        return

    logger.info('Building installer')

    write_nsis_expressions_to_files()

    exe_name_multilang = installer_name(settings)
    exe_name_en = installer_name(settings, '-English')
    # Was:
    # opts = '' if fast else '/X"SetCompressor /FINAL zlib"'
    # Now: Done in NSIS file!
    opts = '' if settings['fast'] else '/V3 /DCompressor'
    if settings['upx']:
        opts += f' /Dpackhdr /DUPX_TAG={settings["upx_tag"]}'
    nsis(opts, exe_name_multilang, nsi_path, settings)

    # The English-only installer is controlled by the build_english setting,
    # which the max-effort preset enables (used for tag/release builds). This
    # lets CI skip it for non-tag builds to save GHA minutes. Local
    # development builds skip it by default; pass max-effort to enable.
    build_english = settings['build_english']

    if settings['fast']:
        sign_files((exe_name_multilang,))
    elif build_english:
        # Was:
        # nsis('/DNoTranslations',
        # Now: Compression gets now done in NSIS file!
        # As of 2022-11-20, there is not a big size difference for
        # the English-only build, and Google Search flags the Python 3.10
        # version as malware.
        nsis(opts + ' /DNoTranslations', exe_name_en, nsi_path, settings)
        sign_files((exe_name_multilang, exe_name_en))
    else:
        # English-only installer skipped (e.g., non-tag CI build).
        sign_files((exe_name_multilang,))

    if os.path.exists(SZ_EXE):
        logger.info('Zipping installer')
        # The archive does not have the folder name.
        infile = f'{ROOT_DIR}\\{exe_name_multilang}'
        outfile = os.path.splitext(infile)[0] + '.zip'
        archive(infile, outfile, settings)
    else:
        logger.warning('%s does not exist', SZ_EXE)


def main():
    """Main function"""
    start_time = time.time()
    logger.info('BleachBit version %s', get_version())
    environment_check()
    settings = get_build_settings()
    logger.info('Build preset: %s (UPX: %s, AdvZip: %s)',
                settings['preset'], settings['upx'], settings['advzip'])
    build()
    shrink(settings)
    package_portable(settings)
    package_installer(settings)
    # Clearly show the sizes of the files that end users download because the
    # goal is to minimize them.
    subprocess.run(
        ['cmd', '/c', 'dir', '*.zip', r'windows\*.exe', r'windows\*.zip'],
        check=False)
    duration = time.time() - start_time
    minutes = int(duration // 60)
    seconds = int(duration % 60)
    logger.info('%s success! Duration: %d minutes, %d seconds',
                __file__, minutes, seconds)


if '__main__' == __name__:
    # Add py2exe to sys.argv if it's not already there
    # This allows running 'python3 -m windows.setup' without explicitly adding py2exe
    if len(sys.argv) == 1 or 'py2exe' not in sys.argv:
        sys.argv.append('py2exe')
    main()