windows/setup.py
Rama master · réplica de lectura del repositorio original.
# 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()