.
Example:
~/.config/f*
~/.config/foo
%AppData\foo
"""
var_name = var.attrib.get('name', '')
for value_element in var.findall('value'):
if not self.os_match(value_element.attrib.get('os', '')):
continue
value_str = _gettext_etree(value_element)
search_type = value_element.attrib.get('search', '')
if search_type == 'glob':
value_list = expand_glob_join(value_str, '')
elif search_type == 'winreg':
if not IS_WINDOWS:
continue
# pylint: disable-next=possibly-used-before-assignment
value_list = read_registry_key(value_element.attrib.get(
'path', ''), value_element.attrib.get('name', ''))
if value_list is None:
continue
value_list = [value_list, ]
else:
value_list = [value_str, ]
if var_name in self.vars:
# append
self.vars[var_name] = value_list + self.vars[var_name]
else:
# initialize
self.vars[var_name] = value_list
def reject_world_writable(pathname):
"""Warn and return True if the cleaner or its directory is world writable.
Callers gate this on POSIX: the mode bits are meaningless on Windows.
"""
if is_world_writable(pathname):
# TRANSLATORS: Warning printed to the log. %s expands to the
# path of the XML cleaner file that was skipped
logger.warning(_("Ignoring cleaner because it is "
"world writable: %s"), pathname)
return True
if is_world_writable(os.path.dirname(pathname)):
# TRANSLATORS: Warning printed to the log. %s expands to the
# path of the XML cleaner file whose directory is world
# writable.
logger.warning(_("Ignoring cleaner because its directory is "
"world writable: %s"), pathname)
return True
return False
def list_cleanerml_files(local_only=False, system_only=False):
"""List CleanerML files"""
cleanerdirs = ()
if not system_only:
cleanerdirs += (bleachbit.personal_cleaners_dir, )
if bleachbit.local_cleaners_dir:
# If the application is installed, locale_cleaners_dir is None.
# If portable mode, local_cleaners_dir is under the directory of
# `bleachbit.py`.
cleanerdirs += (bleachbit.local_cleaners_dir, )
if not local_only and bleachbit.system_cleaners_dir:
cleanerdirs += (bleachbit.system_cleaners_dir, )
check_world_writable = not IS_WINDOWS
for pathname in listdir(cleanerdirs):
if not pathname.lower().endswith('.xml'):
continue
if check_world_writable:
try:
os.stat(pathname)
except OSError as e:
logger.warning('Could not read cleaner metadata %s: %s',
pathname, e)
continue
if reject_world_writable(pathname):
continue
yield pathname
def is_trusted_cleaner(pathname):
"""Return True if the cleaner file sits next to the application.
The personal cleaners directory is user-writable, so a lower-integrity
process could plant a cleaner there. The system and local cleaners
directories are writable in portable mode and in the source tree, but so
is the executable beside them, so distrusting them would gain nothing.
In portable mode the personal cleaners directory is the local one.
"""
trusted_dirs = [d for d in (bleachbit.system_cleaners_dir,
bleachbit.local_cleaners_dir) if d]
pathname = os.path.normpath(os.path.abspath(pathname))
for trusted_dir in trusted_dirs:
trusted_dir = os.path.normpath(os.path.abspath(trusted_dir))
if path_startswith(pathname, trusted_dir):
return True
return False
def load_cleaners(cb_progress=lambda x: None, allow_local=True):
"""Scan for CleanerML and load them"""
cleanerml_files = list(list_cleanerml_files(system_only=not allow_local))
cleanerml_files.sort()
if not cleanerml_files:
logger.debug('No CleanerML files to load.')
return
total_files = len(cleanerml_files)
cb_progress(0.0)
files_done = 0
not_usable = []
for pathname in cleanerml_files:
try:
xmlcleaner = CleanerML(
pathname, trusted=is_trusted_cleaner(pathname))
except Exception:
# TRANSLATORS: Error message printed to the log.
# %s expands to the path of the XML cleaner file
logger.exception(_("Error reading cleaner: %s"), pathname)
files_done += 1
cb_progress(1.0 * files_done / total_files)
yield True
continue
cleaner = xmlcleaner.get_cleaner()
if cleaner.is_usable():
Cleaner.backends[cleaner.id] = cleaner
else:
if cleaner.id:
not_usable.append(cleaner.id)
else:
not_usable.append(os.path.basename(pathname))
files_done += 1
cb_progress(1.0 * files_done / total_files)
yield True
if not_usable:
logger.debug(
"%d cleaners are not usable on this OS because they have no actions: %s", len(not_usable), ', '.join(not_usable))
def pot_fragment(msgid, pathname, translators=None):
"""Create a string fragment for generating .pot files"""
msgid = msgid.replace('"', '\\"') # escape quotation mark
if translators:
translators = f"#. {translators}\n"
else:
translators = ""
pathname = pathname.replace('\\', '/')
return f'''{translators}#: {pathname}
msgid "{msgid}"
msgstr ""
'''
def create_pot():
"""Create a .pot for translation using gettext
This function is called from the Makefile.
Paths and newlines are normalized to Unix style.
"""
with open('../po/cleanerml.pot', 'w', encoding='utf-8', newline='\n') as f:
for pathname in listdir('../cleaners'):
if not pathname.lower().endswith(".xml"):
continue
strings = []
try:
CleanerML(pathname,
lambda newstr, translators=None, current_strings=strings:
current_strings.append([newstr, translators]))
except Exception:
logger.exception(_("Error reading cleaner: %s"), pathname)
continue
for (string, translators) in strings:
f.write(pot_fragment(string, pathname, translators))
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Command.py 0000775 0001750 0001750 00000030635 15265023030 014661 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Command design pattern implementation for cleaning
Standard clean up commands are Delete, Truncate and Shred. Everything
else is counted as special commands: run any external process, edit
JSON or INI file, delete registry key, edit SQLite3 database, etc.
"""
import errno
import logging
import os
import types
import warnings
from bleachbit import FileUtilities, IS_WINDOWS
from bleachbit.Constant import CLEAN_FILE_LABEL
from bleachbit.Language import get_text as _
if IS_WINDOWS:
import bleachbit.Windows
else:
# pylint: disable-next=redefined-builtin
from bleachbit.General import WindowsError
logger = logging.getLogger(__name__)
def ret_keep_list(path):
"""Return information that this file matched by keep list"""
return {
# TRANSLATORS: This is the label in the log indicating a path
# was skipped because it matches the keep list
'label': _('Skip'),
'n_deleted': 0,
'n_special': 0,
'path': path,
'size': 0}
class Delete:
"""Delete a single file or directory. Obey the user
preference regarding shredding."""
def __init__(self, path, shred=False):
"""Create a Delete instance to delete 'path'"""
self.path = path
self.shred = shred
def __str__(self):
return f'Command to {"shred" if self.shred else "delete"} {self.path}'
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield ret_keep_list(self.path)
return
try:
size = FileUtilities.getsize(self.path)
except PermissionError:
size = None
except Exception as e:
# Handle Windows-specific pywintypes.error
# pywintypes.error: (5, 'FindFirstFileW', 'Access is denied.')
# Use a truthy check rather than hasattr(): on Windows every
# OSError carries a winerror attribute, sometimes None.
if getattr(e, 'winerror', None):
size = None
else:
raise
ret = {
# TRANSLATORS: Label in the log indicating a path will be deleted
# (for previews) or was actually deleted (clean mode).
'label': _('Delete'),
'n_deleted': 1,
'n_special': 0,
'path': self.path,
'size': size}
if really_delete:
try:
deleted = FileUtilities.delete(self.path, self.shred)
# pylint: disable-next=possibly-used-before-assignment
except WindowsError as e:
# WindowsError: [Error 32] The process cannot access the file because it is being
# used by another process: 'C:\\Documents and
# Settings\\username\\Cookies\\index.dat'
if not e.winerror == 32:
raise
# pylint: disable-next=possibly-used-before-assignment
bleachbit.Windows.delete_locked_file(self.path)
if self.shred:
warnings.warn(
# TRANSLATORS: Warning message shown in the progress log.
_('At least one file was locked by another process, '
'so its contents could not be overwritten. '
'It will be marked for deletion upon system reboot.'))
# TRANSLATORS: Label in the log when the file will be deleted
# when the system reboots. 'Mark' is a verb.
ret['label'] = _('Mark for deletion')
else:
if not deleted:
ret['n_deleted'] = 0
ret['size'] = 0
yield ret
class Function:
"""Execute a simple Python function"""
def __init__(self, path, func, label, preview_func=None):
"""Initialize a Function command
Parameters:
path (str or None): Path to file or None if function doesn't operate on a file
func (function): Function to execute that takes path or returns size
label (str): Label for display in the UI
preview_func (function, optional): Function to call in preview mode
func takes the path when path is set and no arguments when it is
None. preview_func always takes no arguments. Both return an integer.
"""
self.path = path
self.func = func
self.label = label
self.preview_func = preview_func
assert isinstance(path, (str, type(None)))
if not isinstance(func, types.FunctionType):
raise TypeError(
f'Expected FunctionType for func but got {type(func)}')
assert isinstance(label, str)
if not isinstance(preview_func, (types.FunctionType, type(None))):
raise TypeError(
f'Expected FunctionType or None for preview_func but got {type(preview_func)}')
def __str__(self):
if self.path:
return f'Function: {self.label}: {self.path}'
return f'Function: {self.label}'
def execute(self, really_delete):
"""Execute the function and return results"""
# In FreeBSD, sqlite3 is a separate package
import sqlite3
if self.path is not None and FileUtilities.whitelisted(self.path):
yield ret_keep_list(self.path)
return
ret = {
'label': self.label,
'n_deleted': 0,
'n_special': 1,
'path': self.path,
'size': None}
if not really_delete and self.preview_func is not None:
# Preview mode: call preview function to get list of items that would be deleted
try:
preview_items = self.preview_func()
if isinstance(preview_items, int):
ret['size'] = preview_items
except Exception as e:
logger.warning('Preview function failed: %s', e)
ret['size'] = 0
elif really_delete:
if self.path is None:
# Function takes no path. It returns the size.
func_ret = self.func()
if isinstance(func_ret, types.GeneratorType):
# function returned generator
for func_ret in func_ret:
if func_ret is True or isinstance(func_ret, tuple):
# Return control to GTK idle loop.
# If tuple, then display progress.
yield func_ret
# either way, func_ret should be an integer
assert isinstance(func_ret, int)
ret['size'] = func_ret
else:
if os.path.isdir(self.path):
raise RuntimeError(
f'Attempting to run file function {self.func.__name__} on directory {self.path}')
# Function takes a path. We check the size.
oldsize = FileUtilities.getsize(self.path)
try:
self.func(self.path)
except sqlite3.DatabaseError as e:
# Firefox version 140 added a collation sequence that
# cannot be vacuumed.
# https://github.com/bleachbit/bleachbit/issues/1866
if 'no such collation sequence' in str(e):
logger.debug(str(e))
return
raise
try:
newsize = FileUtilities.getsize(self.path)
except OSError as e:
if e.errno == errno.ENOENT:
# file does not exist
newsize = 0
else:
raise
ret['size'] = oldsize - newsize
yield ret
class ConfigFile:
"""Base for commands that shrink a configuration file in place"""
def _clean(self):
"""Rewrite the file without the targeted content"""
raise NotImplementedError
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield ret_keep_list(self.path)
return
ret = {
'label': CLEAN_FILE_LABEL,
'n_deleted': 0,
'n_special': 1,
'path': self.path,
'size': None}
if really_delete:
oldsize = FileUtilities.getsize(self.path)
self._clean()
newsize = FileUtilities.getsize(self.path)
ret['size'] = oldsize - newsize
yield ret
class Ini(ConfigFile):
"""Remove sections or parameters from a .ini file"""
def __init__(self, path, section, parameter):
"""Create the instance"""
self.path = path
self.section = section
self.parameter = parameter
def __str__(self):
return f'Command to clean .ini path={self.path}, section={self.section}, parameter={self.parameter} '
def _clean(self):
FileUtilities.clean_ini(self.path, self.section, self.parameter)
class Json(ConfigFile):
"""Remove a key from a JSON configuration file"""
def __init__(self, path, address):
"""Create the instance"""
self.path = path
self.address = address
def __str__(self):
return f'Command to clean JSON file, path={self.path}, address={self.address} '
def _clean(self):
FileUtilities.clean_json(self.path, self.address)
class Shred(Delete):
"""Shred a single file"""
def __init__(self, path):
"""Create an instance to shred 'path'"""
Delete.__init__(self, path, shred=True)
def __str__(self):
return f'Command to shred {self.path}'
class Truncate(Delete):
"""Truncate a single file"""
def __str__(self):
return f'Command to truncate {self.path}'
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield ret_keep_list(self.path)
return
ret = {
# TRANSLATORS: The file will be truncated to 0 bytes in length
'label': _('Truncate'),
'n_deleted': 1,
'n_special': 0,
'path': self.path,
'size': FileUtilities.getsize(self.path)}
if really_delete:
FileUtilities.truncate_file(self.path)
yield ret
class Winreg:
"""Clean Windows registry"""
def __init__(self, keyname, valuename, excludekeys=None):
"""Create the Windows registry cleaner"""
self.keyname = keyname
self.valuename = valuename
self.excludekeys = excludekeys or []
def __str__(self):
return f'Command to clean registry, key={self.keyname}, value={self.valuename}'
def execute(self, really_delete):
"""Execute the Windows registry cleaner"""
if not IS_WINDOWS:
return
if self.valuename:
_str = f'{self.keyname}<{self.valuename}>'
ret = bleachbit.Windows.delete_registry_value(self.keyname,
self.valuename, really_delete)
else:
ret = bleachbit.Windows.delete_registry_key(
self.keyname, really_delete, self.excludekeys)
_str = self.keyname
if not ret:
# Nothing to delete or nothing was deleted. This return
# makes the auto-hide feature work nicely.
return
ret = {
# TRANSLATORS: A label of a command to delete a Windows registry key
'label': _('Delete registry key'),
'n_deleted': 0,
'n_special': 1,
'path': _str,
'size': 0}
yield ret
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Constant.py 0000664 0001750 0001750 00000003626 15265023030 015071 0 ustar 00z z # 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.
"""Constants"""
from bleachbit.Language import get_text as _
# This module centralizes constants.
# By reducing imports, this module avoids circular dependencies.
URL_COOKIE_MGR = "https://docs.bleachbit.org/doc/cookie-manager.html"
# TRANSLATORS: Button label on (1) the headerbar and (2) the chaff dialog.
# 'Abort' is a verb.
ABORT_BUTTON_LABEL = _('Abort')
# This string is used several times. Listing it here helps with translation.
# TRANSLATORS: This is the name of a cleaning action. 'Clean' is a verb.
# It shows in the log of actions that would be performed (preview mode) or
# in the log of actions that are performed (clean mode).
CLEAN_FILE_LABEL = _('Clean file')
# TRANSLATORS: This message is used in three places (1) label at top of the
# preferences dialog (2) confirmation dialog when enabling the cleaning option
# (3) log message when starting to clean empty space.
EMPTY_SPACE_WARNING = _("Wiping empty space removes traces of files that were "
"deleted without shredding, but it will not free additional disk space. The "
"process can take a very long time and may temporarily slow your computer. "
"It is not necessary if your drive is protected with full-disk encryption. "
"The method works best on traditional hard drives. On solid-state drives, it "
"is less reliable, and frequent use contributes to wear.")
# TRANSLATORS: Error message shown in the infobar or notice shown in a tooltip.
# Expert mode is an option in the preferences to relax guardrails.
REQUIRES_EXPERT_MODE = _("This option requires expert mode. "
"Enable it in Preferences.")
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Cookie.py 0000664 0001750 0001750 00000033005 15265023030 014503 0 ustar 00z z # 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.
"""
Cookie module for selective deletion of cookies
"""
import contextlib
import json
import logging
import os
import bleachbit
from bleachbit import FileUtilities, IS_MAC
from bleachbit.Special import sqlite_table_exists, _sqlite_uri
logger = logging.getLogger(__name__)
COOKIE_KEEP_LIST_FILENAME = "cookie_keep_list.json"
def _estimate_in_memory_size(conn, table_name, delete_query, params):
"""Return estimated database size (bytes) after deleting rows in-memory.
Args:
conn: SQLite database connection
table_name (str): Name of the table being modified
delete_query (str): SQL DELETE query to execute
params (tuple): Parameters for the DELETE query
Returns:
int or None: Estimated size in bytes after deletion, or None if estimation fails
"""
mem_conn = None
try:
# In FreeBSD, sqlite3 is a separate package
import sqlite3
mem_conn = sqlite3.connect(':memory:')
conn.backup(mem_conn)
mem_cursor = mem_conn.cursor()
mem_cursor.execute(delete_query, params)
mem_conn.commit()
mem_conn.isolation_level = None
mem_cursor.execute('VACUUM')
page_count = mem_cursor.execute('PRAGMA page_count').fetchone()[0]
page_size = mem_cursor.execute('PRAGMA page_size').fetchone()[0]
return page_count * page_size
except sqlite3.Error as exc:
logger.debug(
'In-memory size estimation failed for %s: %s', table_name, exc)
return None
finally:
if mem_conn:
mem_conn.close()
def _get_db_disk_size(path):
"""Return total on-disk footprint for a SQLite DB, including WAL/SHM."""
total = 0
for suffix in ('', '-wal', '-shm'):
candidate = f"{path}{suffix}"
if not os.path.exists(candidate):
continue
try:
total += FileUtilities.getsize(candidate)
except OSError as exc:
logger.debug('Failed to get size for %s: %s', candidate, exc)
return total
def _checkpoint_wal(conn, path):
"""Checkpoint and truncate WAL to keep disk footprint accurate."""
import sqlite3
prev_isolation = conn.isolation_level
try:
conn.isolation_level = None
conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
except sqlite3.Error as exc:
logger.debug('WAL checkpoint failed for %s: %s', path, exc)
finally:
conn.isolation_level = prev_isolation
def _delete_auxiliary_journal_files(path, shred_enabled):
"""Delete SQLite auxiliary files (-wal/-shm) if present."""
for suffix in ('-wal', '-shm'):
candidate = f"{path}{suffix}"
if not os.path.exists(candidate):
continue
try:
FileUtilities.delete(candidate, shred_enabled)
except OSError as exc:
logger.debug('Failed to delete %s: %s', candidate, exc)
# SQLite table configurations for different cookie databases
SQLITE_TABLES = {
'moz_cookies': {
'table_name': 'moz_cookies',
'host_column': 'host'
},
'cookies': {
'table_name': 'cookies',
'host_column': 'host_key'
}
}
def detect_browser(path):
"""Detect the browser type based on the cookies database file"""
if not os.path.exists(path):
raise ValueError(f"cookies file not found: {path}")
for table_config in SQLITE_TABLES.values():
if sqlite_table_exists(path, table_config['table_name']):
return table_config['table_name'], table_config['host_column']
raise ValueError(f"invalid cookies file: {path}")
def list_cookies(path):
"""List cookies in the database"""
if IS_MAC:
from bleachbit.Mac import is_safari_binarycookies, list_safari_cookies
if is_safari_binarycookies(path):
return [(domain,) for domain in list_safari_cookies(path)]
import sqlite3
(table_name, host_column) = detect_browser(path)
uri = _sqlite_uri(path)
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
cursor = conn.cursor()
cursor.execute(f"SELECT distinct {host_column} FROM {table_name}")
return cursor.fetchall()
def load_keep_list():
"""Load cookie domains to keep from options directory.
This does not swallow file permission error or JSON parsing errors:
when cleaning, these cases should not be treated equally to an empty
keep list.
"""
path = os.path.join(bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
domains = set()
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except FileNotFoundError:
return domains
if isinstance(data, list):
for item in data:
if isinstance(item, str) and item:
domains.add(item.lstrip('.').lower())
return domains
def delete_cookies(path, keep_list, really_delete=False):
"""Process cookies with optional deletion based on keep list
Args:
path (str): Path to the cookies database file
keep_list (set): Set of hosts to preserve (must not be empty)
really_delete (bool): If True, perform actual deletion. If False, only preview.
Returns:
dict: Results dictionary with deletion statistics
Raises:
ValueError: If keep_list is empty
"""
if not keep_list:
raise ValueError("keep_list must not be empty")
assert isinstance(keep_list, set)
if IS_MAC:
from bleachbit.Mac import is_safari_binarycookies, delete_safari_cookies
if is_safari_binarycookies(path):
return delete_safari_cookies(path, keep_list, really_delete=really_delete)
from bleachbit.Options import options
shred_enabled = options.get('shred')
import sqlite3
# Find the first matching table configuration
(table_name, host_column) = detect_browser(path)
original_size = _get_db_disk_size(path)
if original_size <= 0:
raise RuntimeError(f"cookies database is empty: {path}")
# Set up connection. Preview opens read-only; the percent-encoded URI
# keeps a '?' in the path from defeating the mode.
uri = _sqlite_uri(path, None if really_delete else 'ro')
try:
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
cursor = conn.cursor()
if shred_enabled:
cursor.execute('PRAGMA secure_delete = ON;')
# Get total count
total_before = cursor.execute(
f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
# Build predicate for domain-level keep semantics
# Match exact domain and any subdomain (both Firefox and Chromium)
domains = [str(d).lstrip('.').lower() for d in keep_list]
or_clauses = []
params = []
for d in domains:
or_clauses.append(f"{host_column} = ?")
params.append(d)
or_clauses.append(f"{host_column} LIKE ?")
params.append(f"%.{d}")
keep_predicate = '(' + ' OR '.join(or_clauses) + ')'
# Count cookies that will be kept
kept_count = cursor.execute(
f"SELECT COUNT(*) FROM {table_name} WHERE {keep_predicate}",
tuple(params)
).fetchone()[0]
deleted_count = total_before - kept_count
delete_query = f"DELETE FROM {table_name} WHERE NOT {keep_predicate}"
ratio_estimate = 0
if really_delete and deleted_count > 0:
if kept_count == 0:
# No cookies are being kept: delete the whole database file
conn.close()
if shred_enabled:
_delete_auxiliary_journal_files(path, True)
try:
FileUtilities.delete(path, shred_enabled)
return {
"total_deleted": deleted_count,
"total_kept": 0,
"skipped": False,
"whole_file_deleted": True,
"file_size_reduction": original_size,
}
except OSError as e:
logger.error(
"Failed to delete cookie database %s: %s", path, e)
return {
"total_deleted": 0,
"total_kept": 0,
"skipped": True,
"whole_file_deleted": False,
"file_size_reduction": 0,
}
# Perform actual deletion: delete anything NOT matching keep predicate
cursor.execute(delete_query, tuple(params))
# Commit deletion before VACUUM
conn.commit()
# Run VACUUM in autocommit mode to avoid 'cannot VACUUM from within a transaction'
prev_isolation = conn.isolation_level
try:
conn.isolation_level = None
cursor.execute('VACUUM')
except sqlite3.Error as e:
logger.warning("VACUUM failed on %s: %s", path, e)
finally:
conn.isolation_level = prev_isolation
if shred_enabled:
_checkpoint_wal(conn, path)
conn.close()
_delete_auxiliary_journal_files(path, True)
new_size = _get_db_disk_size(path)
size_reduction = original_size - new_size
estimation_method = 'actual'
ratio_estimate = None
memory_estimate = None
else:
# Preview mode or nothing to delete
if kept_count == 0:
# No cookies being kept: entire file would be deleted
size_reduction = original_size
estimation_method = 'whole_file'
ratio_estimate = None
memory_estimate = None
else:
if total_before > 0:
ratio_estimate = int(
(deleted_count / total_before) * original_size)
memory_estimate = None
if deleted_count > 0 and shred_enabled:
# In-memory method is accurate when shredding is enabled
memory_size = _estimate_in_memory_size(
conn, table_name, delete_query, tuple(params))
if memory_size is not None:
memory_estimate = max(
0, original_size - memory_size)
if memory_estimate is not None:
size_reduction = memory_estimate
estimation_method = 'in_memory'
else:
size_reduction = ratio_estimate
estimation_method = 'ratio'
return {
"total_deleted": deleted_count,
"total_kept": kept_count,
"skipped": False,
"whole_file_deleted": False,
"file_size_reduction": size_reduction,
"file_size_estimation_method": estimation_method,
"file_size_reduction_ratio": ratio_estimate,
"file_size_reduction_in_memory": memory_estimate,
}
except sqlite3.Error as e:
logger.error("SQLite error processing %s: %s", path, e)
return {
"total_deleted": 0,
"total_kept": 0,
"skipped": True,
"whole_file_deleted": False,
"file_size_reduction": 0,
}
def list_unique_cookies():
"""Return unique cookie hostnames across all cleaners with cookie actions.
Iterates through every registered cleaner, locates actions whose
``command="cookie"`` and aggregates the existing cookie database files
they target. Each database is opened using :func:`list_cookies`, and the
distinct host entries are returned as a sorted list.
Returns:
list[str]: Sorted, de-duplicated list of cookie host strings.
"""
import sqlite3
cookie_files = set()
# Import backends here to avoid a circular import.
from bleachbit.Cleaner import backends as cleaner_backends
for cleaner in cleaner_backends.values():
actions = getattr(cleaner, 'actions', ())
for option_id, action in actions:
if getattr(action, 'action_key', None) != 'cookie':
continue
try:
paths = list(action.get_paths())
except (OSError, RuntimeError) as exc:
logger.debug('Unable to enumerate cookie paths for %s.%s: %s',
cleaner.get_id(), option_id, exc)
continue
for path in paths:
if path and os.path.isfile(path):
cookie_files.add(path)
unique_hosts = set()
for path in cookie_files:
try:
rows = list_cookies(path)
except (ValueError, sqlite3.Error, OSError) as exc:
logger.debug('Skipping cookie database %s: %s', path, exc)
continue
for row in rows:
host = row[0] if isinstance(row, (list, tuple)) else row
if host:
unique_hosts.add(str(host))
return sorted(unique_hosts)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/DeepScan.py 0000775 0001750 0001750 00000012475 15265023030 014767 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Scan directory tree for files to delete
"""
import logging
import os
import re
import time
import unicodedata
from collections import namedtuple
from bleachbit import FS_SCAN_RE_FLAGS, IS_MAC
from . import Command
from .FileUtilities import is_normal_directory, whitelisted
def normalized_walk(top, **kwargs):
"""
macOS uses decomposed UTF-8 to store filenames. This functions
is like `os.walk` but recomposes those decomposed filenames on
macOS
"""
if IS_MAC:
for dirpath, dirnames, filenames in os.walk(top, **kwargs):
yield dirpath, dirnames, [
unicodedata.normalize('NFC', fn)
for fn in filenames
]
else:
yield from os.walk(top, **kwargs)
Search = namedtuple(
'Search', ['command', 'regex', 'nregex', 'wholeregex', 'nwholeregex'])
Search.__new__.__defaults__ = (None,) * len(Search._fields)
def directory_prefix(dirpath):
"""Return a prefix such that prefix + filename == os.path.join(dirpath, filename).
Uses os.path.join(dirpath, '') for the common case. Bare UNC roots like
\\\\server\\share are special: join(dir, '') does not add a separator, but
join(dir, name) still inserts one.
"""
prefix = os.path.join(dirpath, '')
if prefix == dirpath:
# join was a no-op (e.g. 'C:' or a bare UNC share). Probe whether a
# real filename would insert a separator that concatenation would miss.
probe = os.path.join(dirpath, 'a')
if probe != dirpath + 'a':
return probe[:-1]
return prefix
class CompiledSearch:
"""Compiled search condition"""
def __init__(self, search):
self.command = search.command
def re_compile(regex):
return re.compile(regex, FS_SCAN_RE_FLAGS) if regex else None
self.regex = re_compile(search.regex)
self.nregex = re_compile(search.nregex)
self.wholeregex = re_compile(search.wholeregex)
self.nwholeregex = re_compile(search.nwholeregex)
def match(self, dirpath, filename, path_prefix=None):
if self.regex and not self.regex.search(filename):
return None
if self.nregex and self.nregex.search(filename):
return None
if path_prefix is None:
full_path = os.path.join(dirpath, filename)
else:
full_path = path_prefix + filename
if self.wholeregex and not self.wholeregex.search(full_path):
return None
if self.nwholeregex and self.nwholeregex.search(full_path):
return None
return full_path
class DeepScan:
"""Advanced directory tree scan"""
def __init__(self, searches):
self.roots = []
self.searches = searches
def scan(self):
"""Perform requested searches and yield each match"""
logging.getLogger(__name__).debug(
'DeepScan.scan: searches=%s', str(self.searches))
yield_time = time.time()
for (top, searches) in self.searches.items():
# This skips top-level directories that are in the keep list
# to reduce unnecessary work.
if whitelisted(top):
continue
compiled_searches = [CompiledSearch(s) for s in searches]
for (dirpath, dirnames, filenames) in normalized_walk(top):
# Prune keep-list dirs, symlinks, and reparse points:
# os.walk's followlinks=False skips POSIX links but not
# junctions, which would otherwise redirect the scan.
kept_dirs = []
for dirname in dirnames:
subdir = os.path.join(dirpath, dirname)
if not whitelisted(subdir) and is_normal_directory(subdir):
kept_dirs.append(dirname)
dirnames[:] = kept_dirs
path_prefix = directory_prefix(dirpath)
for c in compiled_searches:
# fixme, don't match filename twice
for filename in filenames:
full_name = c.match(dirpath, filename, path_prefix)
if full_name is None:
continue
# fixme: support other commands
if c.command == 'delete':
yield Command.Delete(full_name)
elif c.command == 'shred':
yield Command.Shred(full_name)
if time.time() - yield_time > 0.25:
# allow GTK+ to process the idle loop
yield True
yield_time = time.time()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/DesktopMenuOptions.py 0000664 0001750 0001750 00000004216 15265023030 017106 0 ustar 00z z """Helper utilities to install optional desktop service menus."""
import logging
import os
from pathlib import Path
from bleachbit.FileUtilities import open_for_overwrite
from bleachbit.Options import options
logger = logging.getLogger(__name__)
def install_kde_service_menu_file():
"""Create or remove the KDE service menu entry for shredding."""
try:
# Honor the XDG Base Directory Specification first
# and check if $XDG_DATA_HOME has already been defined.
# The path default is $HOME/.local/share
data_home_path = Path(os.environ["XDG_DATA_HOME"])
except KeyError:
data_home_path = Path(os.environ["HOME"], ".local", "share")
service_file_path = data_home_path / "kio" / \
"servicemenus" / "shred_with_bleachbit.desktop"
if options.get("kde_shred_menu_option"):
dir_path = service_file_path.parent
if not dir_path.exists():
dir_path.mkdir(parents=True)
if not service_file_path.exists():
# Service file has dependency on `kdialog` which KDE installations may not provide by default.
try:
with open_for_overwrite(str(service_file_path)) as service_file:
# fchmod on the fd, not .chmod() on the path, so a
# symlink raced in after the exists() check above
# can't redirect the permission change to its target.
os.fchmod(service_file.fileno(), 0o755)
service_file.write(r'''
[Desktop Entry]
Type=Service
Name=Shred With Bleachbit
X-KDE-ServiceTypes=KonqPopupMenu/Plugin
MimeType=all/all
Icon=bleachbit
Actions=BleachbitShred
Terminal=true
[Desktop Action BleachbitShred]
Name=Shred With Bleachbit
Icon=bleachbit
Exec=kdialog --yesno "This action will shred the following:\n\n$(echo %F | tr ' ' '\n')\n\nContinue?" && sh -c 'bleachbit --shred "$@"; echo Press enter/return to close; read' sh %F
''')
except OSError as exc:
logger.warning(
'failed to create KDE service menu file %s: %s',
service_file_path, exc)
else:
service_file_path.unlink(missing_ok=True)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/FileUtilities.py 0000775 0001750 0001750 00000127723 15265023030 016063 0 ustar 00z z # 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.
"""
File-related utilities
"""
# standard imports
import codecs
import collections
import configparser
import contextlib
import errno
import glob
import json
import locale
import logging
import os
import os.path
import re
import stat
import subprocess
import time
import urllib.parse
from pathlib import Path
# local imports
import bleachbit
from bleachbit import IS_FREEBSD, IS_LINUX, IS_MAC, IS_POSIX, IS_WINDOWS
from bleachbit.Language import get_text as _
from bleachbit.PathUtils import path_equal, path_startswith
from bleachbit.Wipe import wipe_contents, wipe_name
logger = logging.getLogger(__name__)
if IS_WINDOWS:
# pylint: disable=import-error, no-name-in-module
from pywintypes import error as pywinerror
import win32file
from win32file import GetFileAttributesW, SetFileAttributesW
from win32con import FILE_ATTRIBUTE_READONLY
# pylint: disable=ungrouped-imports
import bleachbit.Windows
os_path_islink = os.path.islink
os.path.islink = lambda path: os_path_islink(
path) or bleachbit.Windows.is_junction(path)
else:
# pylint: disable-next=redefined-builtin
from bleachbit.General import WindowsError
# pylint: disable-next=invalid-name
pywinerror = WindowsError
# DirEntry.is_junction() was added in Python 3.12. Below that, fall back to
# bleachbit.Windows.is_junction(), which stats the path itself instead of
# reusing the DirEntry's cached data.
# TODO: drop this fallback once the minimum Python version is 3.12+
_DIRENTRY_HAS_IS_JUNCTION = hasattr(os.DirEntry, 'is_junction')
def _remove_windows_readonly(path):
"""Clear Windows read-only attribute so deletion/wiping succeeds
Returns True if file was read-only and was cleared. Otherwise, False.
"""
if not IS_WINDOWS:
return False
try:
# pylint: disable-next=possibly-used-before-assignment
attrs = GetFileAttributesW(path)
except pywinerror:
return False
# pylint: disable-next=possibly-used-before-assignment
if attrs & FILE_ATTRIBUTE_READONLY:
# pylint: disable-next=possibly-used-before-assignment
SetFileAttributesW(path, attrs & ~FILE_ATTRIBUTE_READONLY)
return True
return False
def _delete_path(path, delete_func):
"""
Delete a path with parent lock if on Windows.
"""
if IS_WINDOWS:
return bleachbit.Windows.with_parent_lock(path, delete_func, path)
return delete_func(path)
def _run_with_delete_lock(path, func):
"""Run a function with a lock on the parent directory of pathname.
This prevents race conditions where the parent directory is deleted
while the function is running.
"""
if IS_WINDOWS:
return bleachbit.Windows.with_parent_lock(path, func)
return func()
def close_delete_parent_lock():
"""Close the delete parent lock if on Windows."""
if IS_WINDOWS:
# Private helper of our own package.
# pylint: disable-next=protected-access
bleachbit.Windows._close_delete_parent_lock()
def open_files_linux():
"""Return iterator of open files on Linux"""
return glob.iglob("/proc/*/fd/*")
FilesystemInfo = collections.namedtuple(
'FilesystemInfo', ['fstype', 'device', 'is_readonly', 'is_cdrom'],
defaults=[False])
def get_filesystem_type(path):
"""Get file system type from the given path
path: directory path
Return value:
A FilesystemInfo named tuple of (fstype, device, is_readonly, is_cdrom)
* fstype: vfat, ntfs, tmpfs, etc.
* device: C:, /dev/sda1, etc.
* is_readonly: True if the file system is mounted read-only.
On POSIX, this is from os.statvfs(), which follows firmlinks
and stacked mounts; on Windows, from the mount options
reported by psutil
* is_cdrom: CD-ROM or other optical disc
File system types seen
* On Linux: btrfs, ext4, squashfs, tmpfs, vfat
* On macOS: apfs
* On Windows: NTFS, FAT32, CDFS, unknown
When checking remote file share on Linux server, psutils may return
- fstype = 'unknown' for UNC path
- fstype = 'NTFS' for same path mapped to drive letter
"""
try:
import psutil
except ImportError:
logger.warning(
'To get the file system type from the given path, you need to install psutil package')
return FilesystemInfo("unknown", "none", False)
path_obj = Path(path)
if IS_WINDOWS:
if len(path) == 2 and path[1] == ':':
path_obj = Path(path + '\\')
# Get all partitions with Path objects as keys.
partitions = {}
for partition in psutil.disk_partitions(all=False):
# all=True on Windows
# "may spin up a removable drive or go over the wire for a network one"
# https://github.com/giampaolo/psutil/blob/master/psutil/arch/windows/disk.c
mount_path = Path(partition.mountpoint)
mount_opts = [opt.strip().lower()
for opt in partition.opts.split(',')]
# examples from Windows
# sdiskpart(device='C:\\', mountpoint='C:\\', fstype='NTFS', opts='rw,fixed')
# sdiskpart(device='D:\\', mountpoint='D:\\', fstype='CDFS', opts='ro,readonly,cdrom')
is_readonly = 'ro' in mount_opts or 'readonly' in mount_opts
# Windows reports 'cdrom' in opts; the fstype list covers POSIX.
is_cdrom = 'cdrom' in mount_opts or partition.fstype.lower() in (
'cdfs', 'cddafs', 'cd9660', 'iso9660', 'udf')
partitions[mount_path] = FilesystemInfo(
partition.fstype, partition.device, is_readonly, is_cdrom)
# Exact match
fs_info = None
for mount_path, fs_info_iter in partitions.items():
if path_obj == mount_path:
fs_info = fs_info_iter
break
# Try parent paths
if fs_info is None:
current = path_obj
while current.parent != current and fs_info is None: # Stop at root
current = current.parent
for mount_path, fs_info_iter in partitions.items():
if current == mount_path:
fs_info = fs_info_iter
break
if fs_info is None:
fs_info = FilesystemInfo("unknown", "none", False)
if IS_POSIX:
# On macOS, firmlinks redirect most user paths from the sealed,
# read-only system volume to the read-write data volume, so the
# matched ancestor's mount options are wrong. os.statvfs() asks
# the kernel, which resolves firmlinks and stacked mounts.
try:
is_readonly = bool(os.statvfs(path).f_flag & os.ST_RDONLY)
except OSError:
# The path may not exist; keep the mount options.
pass
else:
fs_info = fs_info._replace(is_readonly=is_readonly)
return fs_info
# FreeBSD lsof appends the mount device to NAME, e.g.
# "/tmp/foo (/dev/gpt/rootfs)".
_LSOF_DEV_SUFFIX = re.compile(r' \(/dev/[^)]+\)$')
def open_files_lsof(run_lsof=None):
"""Return iterator of open files using lsof"""
if IS_LINUX and run_lsof is None:
raise RuntimeError("open_files_lsof() should not be called on Linux")
if run_lsof is None:
# macOS 26 (Tahoe) has /usr/sbin/lsof
# FreeBSD has /usr/local/sbin/lsof
from bleachbit.General import resolve_exe, sanitize_root_env
lsof_path = resolve_exe(
'lsof', '/usr/sbin/lsof' if IS_MAC else '/usr/local/sbin/lsof')
def run_lsof():
# sanitize the env so a hostile inherited LD_*/DYLD_* cannot
# redirect this child when BleachBit runs as root
env = sanitize_root_env(dict(os.environ))
if IS_MAC:
env.pop('DYLD_LIBRARY_PATH', None)
env.pop('DYLD_INSERT_LIBRARIES', None)
return subprocess.check_output(
[lsof_path, "-Fn", "-n"], text=True,
env=env)
output = run_lsof()
if isinstance(output, bytes):
output = output.decode('utf-8', errors='replace')
for f in output.split("\n"):
if f.startswith("n/"):
# example: "n/dev/null"
name = f[1:] # Drop lsof's "n"
if IS_FREEBSD:
# See comment above by definition of _LSOF_DEV_SUFFIX.
name = _LSOF_DEV_SUFFIX.sub('', name)
yield name
def open_files_psutil():
"""Return iterator of open files using psutil
FreeBSD lsof typically lists cwd and the process executable but not
file descriptors unless it is setgid kmem. psutil uses
KERN_PROC_FILEDESC, which a user can read for their own processes.
"""
# pylint: disable=import-outside-toplevel
import psutil
for proc in psutil.process_iter():
try:
open_file_list = proc.open_files()
except (psutil.Error, OSError):
continue
for ofile in open_file_list:
# FreeBSD kinfo_getfile leaves an empty path for a newly
# created O_WRONLY file (Python's 'wb').
if ofile.path:
yield ofile.path
def open_files_freebsd():
"""Return iterator of open files on FreeBSD"""
try:
yield from open_files_psutil()
except ImportError:
logger.debug('psutil not available; listing open files with lsof')
yield from open_files_lsof()
def open_files():
"""Return iterator of open files"""
if IS_LINUX:
files = open_files_linux()
elif IS_FREEBSD:
files = open_files_freebsd()
elif IS_MAC:
files = open_files_lsof()
else:
raise RuntimeError('unsupported platform for open_files()')
for filename in files:
try:
target = os.path.realpath(filename)
except TypeError:
# happens, for example, when link points to
# '/etc/password\x00 (deleted)'
continue
except PermissionError:
# /proc/###/fd/0 with systemd
# https://github.com/bleachbit/bleachbit/issues/1515
continue
except FileNotFoundError:
# fd closed between listing and resolving it (TOCTOU race)
continue
else:
yield target
class OpenFiles:
"""Cached way to determine whether a file is open by active process"""
def __init__(self):
self.last_scan_time = None
self.files = set()
def file_qualifies(self, filename):
"""Return boolean whether filename qualifies to enter cache (check \
against blacklist)"""
return not filename.startswith("/dev") and \
not filename.startswith("/proc")
def scan(self):
"""Update cache"""
self.last_scan_time = time.time()
self.files = set()
for filename in open_files():
if self.file_qualifies(filename):
self.files.add(filename)
def is_open(self, filename):
"""Return boolean whether filename is open by running process"""
if self.last_scan_time is None or (time.time() - self.last_scan_time) > 10:
self.scan()
return os.path.realpath(filename) in self.files
_SI_PREFIXES = ('', 'k', 'M', 'G', 'T', 'P')
_IEC_PREFIXES = ('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi')
def bytes_to_human(bytes_i):
# type: (int) -> str
"""Display a file size in human terms (megabytes, etc.) using preferred standard (SI or IEC)"""
if bytes_i < 0:
return '-' + bytes_to_human(-bytes_i)
from bleachbit.Options import options
if options.get('units_iec'):
prefixes = _IEC_PREFIXES
base = 1024.0
else:
prefixes = _SI_PREFIXES
base = 1000.0
assert isinstance(bytes_i, int)
if 0 == bytes_i:
return '0B'
if bytes_i >= base ** 3:
decimals = 2
elif bytes_i >= base:
decimals = 1
else:
decimals = 0
for prefix in prefixes:
if bytes_i < base:
abbrev = round(bytes_i, decimals)
return locale.str(abbrev) + prefix + 'B'
bytes_i /= base
return 'A lot.'
def _is_junction_entry(entry):
"""Check whether a scandir entry is a Windows junction (mount point)"""
if _DIRENTRY_HAS_IS_JUNCTION:
return entry.is_junction()
return bleachbit.Windows.is_junction(entry.path)
def _scan_children(top, list_directories, pending_dirs):
"""Yield files under `top`, descending into real subdirectories.
Symlinks and, on Windows, junctions are not descended into. When
list_directories is set, every directory found (including those links)
is collected into pending_dirs to be emitted after its contents.
Using os.scandir directly lets us reuse each entry's cached type instead
of re-stat'ing every subdirectory to test for links, as os.walk required.
The stack is explicit rather than recursive so that a deeply nested tree
cannot exhaust the interpreter's recursion limit.
"""
stack = [top]
while stack:
try:
scandir_it = os.scandir(stack.pop())
except OSError:
continue
subdirs = []
try:
with scandir_it:
for entry in scandir_it:
try:
is_dir = entry.is_dir()
except OSError:
# e.g. permission denied; os.walk also treats this as a file
is_dir = False
if not is_dir:
# regular file, symlink to a file, or broken link
yield entry.path
continue
try:
# is_junction_entry() is Windows-only and never reached on
# POSIX thanks to short-circuit evaluation.
is_link = entry.is_symlink() or (
IS_WINDOWS and _is_junction_entry(entry))
except OSError:
is_link = False
if list_directories:
pending_dirs.append(entry.path)
if not is_link:
subdirs.append(entry.path)
except OSError:
# The directory may disappear or become unreadable mid-iteration.
# os.walk silently skips such directories, so do the same instead of
# propagating PermissionError and aborting the whole cleanup.
continue
# Reversed so siblings are visited in the order scandir returned them
stack.extend(reversed(subdirs))
def children_in_directory(top, list_directories=False):
"""Iterate files and, optionally, subdirectories in directory
Directories are returned after children to avoid trying to delete
a non-empty directory. Symlinks and Windows junctions are never
traversed.
"""
if isinstance(top, tuple):
for top_ in top:
yield from children_in_directory(top_, list_directories)
return
pending_dirs = [] if list_directories else None
yield from _scan_children(top, list_directories, pending_dirs)
if list_directories:
pending_dirs.sort(key=len)
while pending_dirs:
yield pending_dirs.pop()
def _open_nofollow_fd(path, flags, mode=0o600):
"""Open path with os.open(), refusing a symlink (or Windows junction).
Adds O_NOFOLLOW to flags on POSIX so a symlink raced in after the
islink() check is also refused, instead of redirecting the open to
its target. Windows has no O_NOFOLLOW; the islink() check is the
only protection there. Returns a raw file descriptor.
"""
if os.path.islink(path):
raise OSError(errno.EACCES, 'refusing to open a link', path)
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
return os.open(path, flags, mode)
def open_for_overwrite(path, mode='w', **kwargs):
"""Open path for overwriting without following a final symlink."""
if not hasattr(os, 'O_NOFOLLOW'):
# Windows gains nothing from os.open() here, and O_TRUNC would empty
# the file before a write error could be reported, losing the old
# contents. islink() (which also catches junctions) is the only
# protection available either way.
if os.path.islink(path):
raise OSError(errno.EACCES, 'refusing to open a link', path)
# encoding comes via **kwargs (e.g. utf-8-sig); don't hardcode it.
# pylint: disable-next=unspecified-encoding
return open(path, mode, **kwargs)
fd = _open_nofollow_fd(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
try:
# pylint: disable-next=unspecified-encoding
return open(fd, mode, **kwargs)
except Exception:
os.close(fd)
raise
def clean_ini(path, section, parameter):
"""Delete sections and parameters (aka option) in the file
Comments are not preserved.
The file is expected to be UTF-8, optionally with a BOM. This matches
how VLC writes its configuration file (verified for over a decade). If
the file cannot be decoded as UTF-8, it is left untouched and an error
is logged so cleaning continues with the next file. The presence (or
absence) of a BOM is preserved on write.
"""
# utf_8_sig transparently strips a BOM when reading, if present.
read_encoding = 'utf_8_sig'
# read file to parser
config = configparser.RawConfigParser(delimiters='=')
config.optionxform = str
try:
with open(path, 'r', encoding=read_encoding) as fp:
config.read_file(fp)
except UnicodeDecodeError:
logger.error(
"Cannot clean INI file because it is not valid UTF-8: %s", path)
return
# change file
changed = False
if config.has_section(section):
if parameter is None:
changed = True
config.remove_section(section)
elif config.has_option(section, parameter):
changed = True
config.remove_option(section, parameter)
if not changed:
return
# Preserve whether the file had a BOM: write with utf_8_sig (which
# re-adds it) only if the original file began with the UTF-8 BOM.
with open(path, 'rb') as bom_fp:
has_bom = bom_fp.read(3) == b'\xef\xbb\xbf'
write_encoding = 'utf_8_sig' if has_bom else 'utf_8'
# write file
from bleachbit.Options import options
if options.get('shred'):
delete(path, True)
with open_for_overwrite(path, encoding=write_encoding, newline='') as fp:
config.write(fp)
def clean_json(path, target):
"""Delete key in the JSON file"""
changed = False
targets = target.split('/')
# read file to parser
with open(path, 'r', encoding='utf-8-sig') as f:
js = json.load(f)
# change file
pos = js
while True:
new_target = targets.pop(0)
if not isinstance(pos, dict):
break
if new_target in pos and targets:
# descend
pos = pos[new_target]
elif new_target in pos:
# delete terminal target
changed = True
del pos[new_target]
else:
# target not found
break
if not targets:
# target not found
break
if changed:
from bleachbit.Options import options
if options.get('shred'):
delete(path, True)
# write file
with open_for_overwrite(path, encoding='utf-8') as f:
json.dump(js, f)
def _truncate_locked_file(path):
"""Best-effort truncate of a file, used on Windows when a lock prevents deletion.
Returns True if truncation succeeded, False otherwise.
Shared locks allow truncation, exclusive locks prevent it.
"""
if os.path.islink(path):
logger.debug("refusing to truncate a link: %s", path)
return False
try:
with open(path, 'r+b') as handle:
handle.truncate(0)
return True
except (OSError, PermissionError):
logger.debug("Unable to truncate locked file %s", path)
return False
def _delete_file_impl(path, shred):
""""Delete a file
- File must exist.
- Not for use with directories.
- Does not check the user's preferences.
Returns True.
"""
# wipe contents
if shred and not is_hard_link(path):
try:
wipe_contents(path)
except pywinerror as e:
# 2 = The system cannot find the file specified.
# This can happen with a broken symlink
# https://github.com/bleachbit/bleachbit/issues/195
if 2 != e.winerror:
raise
# If a broken symlink, try os.remove() below.
except IOError as e:
# permission denied (13) happens shredding MSIE 8 on Windows 7
logger.debug("IOError #%s shredding '%s'",
e.errno, path)
if shred:
# wipe name
os.remove(wipe_name(path))
return True
# Code below is shred == False
try:
os.remove(path)
except PermissionError as e:
if IS_WINDOWS and hasattr(e, 'winerror'):
if e.winerror == 32:
# File is locked, try to truncate it first
_truncate_locked_file(path)
# Command.py watches for this exception.
raise WindowsError(e.errno, e.strerror,
e.filename, e.winerror) from e
if e.errno == errno.EACCES and e.winerror == 5 and \
_remove_windows_readonly(path):
# If read-only attribute was removed, try again.
os.remove(path)
return True
raise
except WindowsError as e:
if e.winerror == 32:
# File is locked, try to truncate it first
_truncate_locked_file(path)
raise
return True
def delete_file(path, shred):
return _run_with_delete_lock(
path, lambda: _delete_file_impl(path, shred))
def truncate_file(path):
"""Truncate a file to zero length.
Runs under the same parent lock as delete() and refuses a symlink
(or Windows reparse point) so the truncation is not redirected
through a link to another file.
"""
def _truncate():
# No O_CREAT: if the file went away, do not recreate it as an empty one
os.close(_open_nofollow_fd(path, os.O_WRONLY | os.O_TRUNC))
_run_with_delete_lock(path, _truncate)
def _file_type(path):
"""Return the file type bits of path, or None if it is missing
On Windows only the type bits are set, and 0 means a type delete()
does not handle.
"""
if IS_POSIX:
try:
return os.lstat(path).st_mode
except (OSError, ValueError):
return None
# os.lstat() returns Access Denied on some Windows files that the
# native os.path checks can still see
if not os.path.lexists(path):
return None
try:
attrs = os.lstat(path).st_file_attributes
except OSError:
attrs = 0
# A junction/symlink's contents belong to the target, not
# this path; isdir() would follow it and judge the target's
# emptiness instead of removing the reparse point itself.
if attrs & stat.FILE_ATTRIBUTE_REPARSE_POINT:
return stat.S_IFLNK
if os.path.isdir(path):
return stat.S_IFDIR
if os.path.isfile(path):
return stat.S_IFREG
if os.path.islink(path):
return stat.S_IFLNK
return 0
def delete(path, shred=False, ignore_missing=False, allow_shred=True):
"""Delete path that is either file, directory, link or FIFO.
If shred is enabled as a function parameter or the BleachBit global
parameter, the path will be shredded unless allow_shred = False.
All links are removed without following the link. This includes:
* Linux symlink
* Windows symlink (soft link)
* Windows hard link
* Windows junction
* Windows .lnk files
Returns True if the path was deleted, False otherwise.
"""
from bleachbit.Options import options
path = extended_path(path)
do_shred = allow_shred and (shred or options.get('shred'))
mode = _file_type(path)
if mode is None:
if ignore_missing:
return False
raise OSError(2, 'No such file or directory', path)
if stat.S_ISLNK(mode) or stat.S_ISFIFO(mode):
_delete_path(path, os.remove)
return True
if stat.S_ISDIR(mode):
delpath = path
# TRANSLATORS: Log message where %s is the pathname.
not_empty_msg = _("Directory is not empty: %s")
if do_shred:
if not is_dir_empty(path):
# Avoid renaming non-empty directory like
# https://github.com/bleachbit/bleachbit/issues/783
logger.info(not_empty_msg, path)
return False
delpath = wipe_name(path)
try:
_delete_path(delpath, os.rmdir)
except OSError as e:
# [Errno 39] Directory not empty
# https://bugs.launchpad.net/bleachbit/+bug/1012930
if errno.ENOTEMPTY == e.errno:
logger.info(not_empty_msg, path)
return False
if errno.EBUSY == e.errno:
if IS_POSIX and os.path.ismount(path):
# TRANSLATORS: Log message where %s is the pathname.
logger.info(_("Skipping mount point: %s"), path)
else:
# TRANSLATORS: Log message where %s is the pathname.
logger.info(_("Device or resource is busy: %s"), path)
return False
if IS_WINDOWS and errno.EACCES == e.errno:
# On Windows, read-only directories cause Access Denied
if _remove_windows_readonly(delpath):
_delete_path(delpath, os.rmdir)
else:
raise
else:
raise
except WindowsError as e:
# WindowsError: [Error 145] The directory is not empty:
# 'C:\\Documents and Settings\\username\\Local Settings\\Temp\\NAILogs'
# Error 145 may happen if the files are scheduled for deletion
# during reboot.
if 145 == e.winerror:
logger.info(not_empty_msg, path)
return False
raise
return True
if stat.S_ISREG(mode):
delete_file(path, do_shred)
return True
# TRANSLATORS: Log message where %s is the pathname.
logger.info(_("Special file type cannot be deleted: %s"), path)
return False
def detect_encoding(fn):
"""Detect the encoding of the file
Returns a codec name or None if it could not be determined.
"""
with open(fn, 'rb') as f:
raw = f.read()
# UTF-8 is unambiguous, so do not guess. This covers ASCII and what
# current applications write, such as VLC since 3.0.
encoding = 'utf_8_sig' if raw.startswith(codecs.BOM_UTF8) else 'utf_8'
try:
raw.decode(encoding)
except UnicodeDecodeError:
pass
else:
return encoding
try:
# pylint: disable=import-outside-toplevel
from charset_normalizer import from_bytes
except ImportError:
logger.warning(
'charset_normalizer module is not available to detect character encoding')
return None
match = from_bytes(raw).best()
if match is None:
return None
if match.bom and 'utf_8' == match.encoding:
# charset_normalizer reports the BOM separately from the codec
return 'utf_8_sig'
return match.encoding
def ego_owner(filename):
"""Return whether current user owns the file
Returns False if the file is gone or unreadable, so callers walking a
busy directory such as /tmp do not abort on a vanished file.
POSIX only"""
assert IS_POSIX
try:
st_uid = os.lstat(filename).st_uid
except OSError:
return False
# pylint: disable=no-member
return st_uid == os.getuid()
def exists_in_path(filename):
"""Returns boolean whether the filename exists in the path"""
path_env = os.getenv('PATH')
if not path_env:
return False
assert not os.path.isabs(filename)
for dirname in path_env.split(os.pathsep):
if os.path.exists(os.path.join(dirname, filename)):
return True
return False
def exe_exists(pathname):
"""Returns boolean whether executable exists"""
if os.path.isabs(pathname):
return os.path.exists(pathname)
return exists_in_path(pathname)
def execute_sqlite3(path, cmds):
"""Execute SQL commands on SQLite database
Args:
path (str): Path to the SQLite database file
cmds (str): SQL commands to execute, separated by semicolons
Raises:
sqlite3.OperationalError: If there's an error executing the SQL commands
sqlite3.DatabaseError: If there's a database-related error
Returns:
None
"""
from bleachbit.Options import options
# In FreeBSD, sqlite3 is a separate package
import sqlite3
assert isinstance(path, str)
assert isinstance(cmds, str)
try:
conn = sqlite3.connect(path)
except sqlite3.OperationalError as exc:
# sqlite3 raises a cryptic "unable to open database file" for a
# variety of causes (permission denied, read-only parent directory,
# locked file, ...). Translate it into the same OSError(EACCES)
# "Access denied" message users already see for locked files, or
# FileNotFoundError if the path is gone, so the Worker can surface a
# clean summary instead of an opaque traceback.
if not os.path.exists(path):
raise FileNotFoundError(
errno.ENOENT, "File not found when opening SQLite database",
path) from exc
raise OSError(
errno.EACCES,
"Access denied when opening SQLite database", path) from exc
with contextlib.closing(conn) as conn:
# overwrites deleted content with zeros
# https://www.sqlite.org/pragma.html#pragma_secure_delete
if options.get('shred'):
conn.execute('PRAGMA secure_delete=ON')
# Without this, shredding can leave freed content recoverable.
if conn.execute('PRAGMA secure_delete').fetchone()[0] != 1:
raise RuntimeError(f'could not enable secure_delete on {path}')
for cmd in cmds.split(';'):
try:
conn.execute(cmd)
except sqlite3.OperationalError as exc:
if str(exc).find('no such function: ') >= 0:
# fixme: determine why randomblob and zeroblob are not
# available
logger.exception(str(exc))
else:
raise sqlite3.OperationalError(f'{exc}: {path}')
except sqlite3.DatabaseError as exc:
raise sqlite3.DatabaseError(f'{exc}: {path}')
conn.commit()
bleachbit.General.gc_collect()
def expand_glob_join(pathname1, pathname2):
"""Join pathname1 and pathname1, expand pathname, glob, and return as list"""
pathname3 = os.path.expanduser(os.path.expandvars(
os.path.join(pathname1, pathname2)))
return list(glob.iglob(pathname3))
def extended_path(path):
r"""Return the extended Windows pathname
example: c:\foo\bar.txt to \\?\c:\foo\bar.txt
The path is returned unchanged if:
* Path was already extended
* Path is a sysnative path
* System is not Windows
"""
# Do not extend the Sysnative paths because on some systems there are
# problems with path resolution. For example:
# https://github.com/bleachbit/bleachbit/issues/1574.
if IS_WINDOWS and 'Sysnative' not in path.split(os.sep):
if path.startswith(r'\\?'):
return path
if path.startswith(r'\\'):
return '\\\\?\\unc\\' + path[2:]
return '\\\\?\\' + path
return path
def extended_path_undo(path):
r"""Undo extended path
For example: \\c:\foo\bar.txt -> c:\foo\bar.txt
"""
if IS_WINDOWS:
if path.startswith(r'\\?\unc'):
return '\\' + path[7:]
if path.startswith(r'\\?'):
return path[4:]
return path
def free_space(pathname):
"""Return free space in bytes
pathname may be any directory within a valid file system.
POSIX systems may reserve space for the root user, and this function
returns the amount available to the current user for accurate
estimation of completion time in wipe_path().
"""
if IS_WINDOWS:
# pylint: disable=import-error
import psutil
return psutil.disk_usage(pathname).free
assert IS_POSIX
# pylint: disable=no-member
mystat = os.statvfs(pathname)
if os.getuid() == 0:
# root
return mystat.f_bfree * mystat.f_bsize
# non-root
return mystat.f_bavail * mystat.f_bsize
def getsize(path):
"""Return the actual file size considering spare files
and symlinks"""
if IS_POSIX:
try:
__stat = os.lstat(path)
except OSError as e:
# OSError: [Errno 13] Permission denied
# can happen when a regular user is trying to find the size of /var/log/hp/tmp
# where /var/log/hp is 0774 and /var/log/hp/tmp is 1774
if errno.EACCES == e.errno:
return 0
raise
return __stat.st_blocks * 512
if IS_WINDOWS:
# On rare files os.path.getsize() returns access denied, so first
# try FindFilesW.
# Also, apply prefix to use extended-length paths to support longer
# filenames.
try:
# pylint: disable=c-extension-no-member
finddata = win32file.FindFilesW(extended_path(path))
except pywinerror as e:
if e.winerror == 3: # 3 = The system cannot find the path specified.
raise OSError(errno.ENOENT, e.strerror, path) from e
raise e
if not finddata:
# FindFilesW does not work for directories, so fall back to
# getsize()
return os.path.getsize(path)
return (finddata[0][4] * (0xffffffff + 1)) + finddata[0][5]
return os.path.getsize(path)
def getsizedir(path):
"""Return the size of the contents of a directory"""
total_bytes = 0
for node in children_in_directory(path, list_directories=False):
try:
total_bytes += getsize(node)
except FileNotFoundError:
# A file may vanish between the walk and the measurement, as when
# a package manager writes to the cache directory being measured
pass
return total_bytes
def globex(pathname, regex):
"""Yield a list of files with pathname and filter by regex"""
if isinstance(pathname, tuple):
for singleglob in pathname:
yield from globex(singleglob, regex)
else:
for path in glob.iglob(pathname):
if re.search(regex, path):
yield path
def guess_overwrite_paths():
"""Guess which partitions to overwrite (to hide deleted files)"""
# In case overwriting leaves large files, placing them in
# ~/.config makes it easy to find them and clean them.
ret = []
if IS_POSIX:
home = os.path.expanduser('~/.cache')
if not os.path.exists(home):
home = os.path.expanduser("~")
ret.append(home)
# Debian on Docker did not have /tmp
if os.path.exists('/tmp'):
if not same_partition(home, '/tmp/'):
ret.append('/tmp')
elif IS_WINDOWS:
localtmp = os.path.expandvars('$TMP')
if not os.path.exists(localtmp):
logger.warning(
# TRANSLATORS: This is a warning log message. %s is the directory path.
_("The environment variable TMP refers to a directory that does not exist: %s"), localtmp)
localtmp = None
for drive in bleachbit.Windows.get_fixed_drives():
if localtmp and same_partition(localtmp, drive):
ret.append(localtmp)
else:
ret.append(drive)
else:
raise NotImplementedError('Unsupported OS in guess_overwrite_paths')
return ret
def human_to_bytes(human, hformat='si'):
"""Convert a string like 10.2GB into bytes. By
default use SI standard (base 10). The format of the
GNU command 'du' (base 2) also supported."""
if 'si' == hformat:
base = 1000
suffixes = 'kMGTPE'
elif 'du' == hformat:
base = 1024
suffixes = 'KMGTPE'
else:
raise ValueError(f"Invalid format: '{hformat}'")
matches = re.match(r'^(\d+(?:\.\d+)?) ?([' + suffixes + ']?)B?$', human)
if matches is None:
raise ValueError(f"Invalid input for '{human}' (hformat='{hformat}')")
(amount, suffix) = matches.groups()
if '' == suffix:
exponent = 0
else:
exponent = suffixes.find(suffix) + 1
return int(float(amount) * base**exponent)
def is_dir_empty(dirname):
"""Returns boolean whether directory is empty.
It assumes the path exists and is a directory.
"""
with os.scandir(dirname) as it:
for _entry in it:
return False
return True
def is_hard_link(path):
"""Check if a file is a hard link."""
return os.path.isfile(path) and os.stat(path).st_nlink > 1
def is_normal_directory(path):
"""Check whether path is a non-link directory
Returns False if:
- path does not exist
- path is a file
- path is a reparse point
Returns True if a normal directory
"""
try:
st = os.stat(path, follow_symlinks=False)
is_dir = stat.S_ISDIR(st.st_mode)
is_reparse = getattr(st, 'st_reparse_tag', 0) != 0
return is_dir and not is_reparse
except (OSError, ValueError):
return False
def listdir(directory):
"""Return full path of files in directory.
Path may be a tuple of directories."""
if isinstance(directory, tuple):
for dirname in directory:
yield from listdir(dirname)
return
dirname = os.path.expanduser(directory)
if not os.path.lexists(dirname):
return
for filename in os.listdir(dirname):
yield os.path.join(dirname, filename)
def same_partition(dir1, dir2):
"""Are both directories on the same partition?"""
if IS_WINDOWS:
try:
return free_space(dir1) == free_space(dir2)
except OSError as e:
# psutil.disk_usage() raises OSError (with .winerror on Windows).
# 5 = access denied: Microsoft Office 2010 Starter Edition has a
# virtual drive that gives access denied.
# https://bugs.launchpad.net/bleachbit/+bug/1372179
# https://bugs.launchpad.net/bleachbit/+bug/1474848
# https://github.com/az0/bleachbit/issues/27
# 1326 = logon failure: disconnected network drive.
if getattr(e, 'winerror', None) in (5, 1326):
return dir1[0] == dir2[0]
raise
# pylint: disable=no-member
stat1 = os.statvfs(dir1)
stat2 = os.statvfs(dir2)
return stat1[stat.ST_DEV] == stat2[stat.ST_DEV]
def truncate_f(f):
"""Truncate the file object"""
try:
f.truncate(0)
f.flush()
os.fsync(f.fileno())
except OSError as e:
if e.errno != errno.ENOSPC:
raise
def uris_to_paths(file_uris):
"""Return a list of paths from text/uri-list"""
assert isinstance(file_uris, (tuple, list))
# Keep urllib.request off the startup path: it drags in ssl and socket.
from urllib.request import url2pathname # pylint: disable=import-outside-toplevel
file_paths = []
for file_uri in file_uris:
if not file_uri:
# ignore blank
continue
parsed_uri = urllib.parse.urlparse(file_uri)
if parsed_uri.scheme == 'file':
file_path = url2pathname(parsed_uri.path)
if len(file_path) > 2 and file_path[2] == ':':
# remove front slash for Windows-style path
file_path = file_path[1:]
if not file_path:
# An empty path (e.g. from "file://") would resolve to the
# current working directory downstream via os.path.abspath('')
# in create_simple_cleaner.
logger.warning('Skipping malformed file URI: %s', file_uri)
continue
file_paths.append(file_path)
else:
logger.warning('Unsupported scheme: %s', file_uri)
return file_paths
def _is_system_critical_posix(path):
"""Check whether a POSIX path is system-critical and must never be deleted.
Applies even when the keep list is empty, so a bad cleaner file cannot
delete the filesystem root or a mounted pseudo-filesystem. Real cleaners
legitimately act under /var and /dev/shm, so only the root itself and
virtual filesystems are off limits.
"""
if not isinstance(path, str) or not path.startswith('/'):
return False
# Strip leading slashes: POSIX leaves '//' and friends implementation-
# defined, so normpath alone will not collapse them to '/'.
norm = os.path.normpath('/' + path.lstrip('/'))
if norm == '/':
return True
for prefix in ('/proc', '/sys', '/run'):
if norm == prefix or path_startswith(norm, prefix):
return True
return False
def whitelisted_posix(path, check_realpath=True, _followed_link=False):
"""Check whether this POSIX path is whitelisted"""
if _is_system_critical_posix(path):
return True
from bleachbit.Options import options
keep_paths = options.get_whitelist_paths()
if not keep_paths:
return False
if check_realpath and os.path.islink(path):
# also check the link name
if whitelisted_posix(path, False):
return True
# resolve symlink
return whitelisted_posix(os.path.realpath(path), False, _followed_link=True)
for (keep_type, keep_path) in keep_paths:
if keep_type == 'file':
if path_equal(path, keep_path):
return True
if _followed_link and path_equal(path, os.path.realpath(keep_path)):
return True
if keep_type == 'folder':
if path_equal(path, keep_path):
return True
if path_startswith(path, keep_path):
return True
if _followed_link:
real_pathname = os.path.realpath(keep_path)
if path_equal(path, real_pathname) or path_startswith(path, real_pathname):
return True
return False
_WINDIR_TEMP = os.path.expandvars(r'%windir%\temp').lower()
def _windows_preserved_temp_dir(path):
"""Return whether this Windows path is a temp directory root to keep."""
path = extended_path_undo(os.path.normpath(path)).lower()
parts = path.split(os.sep)
if path == _WINDIR_TEMP:
return True
if len(parts) >= 3 and parts[-3:] == ['appdata', 'local', 'temp']:
return True
if len(parts) >= 2 and parts[-2:] == ['local settings', 'temp']:
return True
return False
def whitelisted_windows(path):
"""Check whether this Windows path is whitelisted"""
if not isinstance(path, str):
raise TypeError(f"Expected str, got {type(path)}")
if _windows_preserved_temp_dir(path):
return True
from bleachbit.Options import options
for pathname in options.get_whitelist_paths():
# Windows is case insensitive
if (pathname[0] == 'file'
and path_equal(path, pathname[1], case_sensitive=False)):
return True
if pathname[0] == 'folder':
if path_equal(path, pathname[1], case_sensitive=False):
return True
if path_startswith(path, pathname[1], case_sensitive=False):
return True
# Simple drive letter like C:\ matches everything below
if (len(pathname[1]) == 3
and path.lower().startswith(pathname[1].lower())):
return True
return False
if IS_WINDOWS:
whitelisted = whitelisted_windows
else:
whitelisted = whitelisted_posix
def vacuum_sqlite3(path):
"""Vacuum SQLite database"""
execute_sqlite3(path, 'vacuum')
openfiles = OpenFiles()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/FontCheckDialog.py 0000664 0001750 0001750 00000006523 15265023030 016263 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""Dialog used to verify text rendering on Windows.
This is a temporary workaround.
"""
from bleachbit.GtkShim import require_gtk, Gtk
from bleachbit.Language import get_text as _
require_gtk()
# pylint: disable-next=wrong-import-order, wrong-import-position
from gi.repository import Pango
ALT_FONT = "Arial 14"
RESPONSE_TEXT_BLURRY = 1001
RESPONSE_TEXT_UNREADABLE = 1002
def _apply_alt_font(widget):
"""Apply the alternate font to a label or label-like widget."""
desc = Pango.FontDescription(ALT_FONT)
widget.modify_font(desc)
def create_font_check_dialog(parent=None):
"""Return the dialog prompting the user about font rendering."""
dialog = Gtk.Dialog(
# TRANSLATORS: Title of the font check dialog
title=_("Font check"),
transient_for=parent,
modal=True
)
dialog.set_border_width(12)
dialog.set_resizable(False)
dialog.add_buttons(
# TRANSLATORS: Button label in the font check dialog
_("Text is okay"), Gtk.ResponseType.YES,
# TRANSLATORS: Button label in the font check dialog. This is one of
# three buttons the user can click to report how the text appears.
# The user clicks this if the text is readable but appears fuzzy.
_("Text is blurry"), RESPONSE_TEXT_BLURRY,
# TRANSLATORS: Button label in the font check dialog in case the font
# looks like an alien language. This is one of three buttons the user
# can click to report how the text appears.
_("Text is unrecognizable"), RESPONSE_TEXT_UNREADABLE,
)
# To ensure user can read them, apply the alternate font to all buttons.
for response_id in (
Gtk.ResponseType.YES,
RESPONSE_TEXT_BLURRY,
RESPONSE_TEXT_UNREADABLE,
):
button = dialog.get_widget_for_response(response_id)
if button is not None:
_apply_alt_font(button)
content = dialog.get_content_area()
content.set_spacing(8)
# TRANSLATORS: Question shown in the font check dialog displayed
# on startup on Windows. There are bugs that for some users the
# font is blurry or resembles an alien language.
text_quality_msg = _('Does all the text in this window look okay?')
label_default = Gtk.Label(label=text_quality_msg)
label_default.set_xalign(0.0)
label_default.set_line_wrap(True)
label_alt = Gtk.Label(label=text_quality_msg)
label_alt.set_xalign(0.0)
label_alt.set_line_wrap(True)
_apply_alt_font(label_alt)
content.pack_start(label_default, False, False, 0)
content.pack_start(label_alt, False, False, 0)
dialog.show_all()
return dialog
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GUI.py 0000664 0001750 0001750 00000002214 15265023030 013714 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
GTK graphical user interface
"""
# standard library
import logging
from bleachbit.GtkShim import require_gtk
from bleachbit.Log import set_root_log_level
from bleachbit.Options import options # keep
# Ensure GTK is available for this GUI module
require_gtk()
# Now that the configuration is loaded, honor the debug preference there.
set_root_log_level(options.get('debug'))
logger = logging.getLogger(__name__)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/General.py 0000775 0001750 0001750 00000050246 15265023030 014660 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
General code
"""
import gc
import getpass
import logging
import os
import shlex
import shutil
import stat
import subprocess
import sys
import xml.parsers.expat
import bleachbit
from bleachbit import IS_LINUX, IS_POSIX, IS_WINDOWS
from bleachbit.PathUtils import path_startswith
logger = logging.getLogger(__name__)
def _path_dir_is_root_safe(dirpath):
"""Return True if dirpath is absolute, root-owned, and not writable by others."""
if not os.path.isabs(dirpath):
return False
try:
st = os.stat(dirpath)
except OSError:
return False
if st.st_uid != 0:
return False
if st.st_mode & stat.S_IWOTH:
return False
if (st.st_mode & stat.S_IWGRP) and st.st_gid != 0:
return False
return True
# Environment variables that make a child load code from a location the
# caller chose: the dynamic linker (LD_*, DYLD_*), glibc's loadable modules
# (honored because a sudo'd root process is not AT_SECURE), and the
# interpreters we may exec (dnf and yum are Python, paccache is a shell
# script).
_UNSAFE_ROOT_ENV_PREFIXES = ('LD_', 'DYLD_')
_UNSAFE_ROOT_ENV_VARS = (
'GCONV_PATH', 'LOCPATH', 'NLSPATH', 'HOSTALIASES',
'PYTHONPATH', 'PYTHONHOME', 'PYTHONSTARTUP', 'PYTHONEXECUTABLE',
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', 'IFS',
'PERL5LIB', 'PERL5OPT', 'RUBYLIB', 'RUBYOPT', 'NODE_OPTIONS')
def sanitize_root_env(env):
"""Harden a child process's environment when running as root.
Drop the code-loading variables listed above and PATH entries a non-root
user could write, so an inherited hostile environment cannot redirect a
privileged child. No-op unless euid 0.
"""
if not hasattr(os, 'geteuid') or 0 != os.geteuid():
return env
env = {key: value for key, value in env.items()
if key not in _UNSAFE_ROOT_ENV_VARS
and not key.startswith(_UNSAFE_ROOT_ENV_PREFIXES)}
path = env.get('PATH')
if path:
env['PATH'] = os.pathsep.join(
d for d in path.split(os.pathsep) if _path_dir_is_root_safe(d))
return env
def sanitize_surrogates(text):
"""Replace surrogates so the text can be encoded.
Surrogates (like \\udcd6) come from filenames the filesystem returned
as undecodable, and raise UnicodeEncodeError in GTK and on stdout.
"""
return text.encode('utf-8', errors='replace').decode('utf-8')
_STANDARD_EXE_DIRS = ('/usr/bin', '/usr/sbin', '/bin', '/sbin')
def resolve_exe(name, *candidates):
"""Return an absolute path to an executable, or name if none is found.
Checks the standard directories, or the given candidates instead, before
falling back to PATH for layouts that put the tool somewhere else (NixOS,
Alpine, an unmerged /usr). Skips user-writable PATH entries when root.
"""
if not candidates:
candidates = tuple(os.path.join(d, name) for d in _STANDARD_EXE_DIRS)
for candidate in candidates:
if os.path.exists(candidate):
return candidate
search_path = os.environ.get('PATH') or os.defpath
if hasattr(os, 'geteuid') and 0 == os.geteuid():
search_path = os.pathsep.join(
d for d in search_path.split(os.pathsep)
if _path_dir_is_root_safe(d))
return shutil.which(name, path=search_path) or name
#
# XML
#
def boolstr_to_bool(value):
"""Convert a string boolean to a Python boolean"""
if 'true' == value.lower():
return True
if 'false' == value.lower():
return False
raise RuntimeError(f"Invalid boolean: '{value}'")
def getText(nodelist):
"""Return the text data in an XML node
https://docs.python.org/3/library/xml.dom.minidom.html"""
return "".join(
node.data for node in nodelist if node.nodeType == node.TEXT_NODE
)
def reject_xml_dtd(data, description='XML'):
"""Raise ValueError if data declares a DTD with an internal subset, to block entity-expansion attacks"""
def on_doctype(_name, _sysid, _pubid, has_internal_subset):
# external-only doctype (e.g. fontconfig's fonts.conf) is harmless: no ExternalEntityRefHandler is registered, so expat never fetches it
if has_internal_subset:
raise ValueError(
f'DTD with an internal subset is not allowed in {description}')
if isinstance(data, str):
# pyexpat rejects str input carrying an encoding declaration
data = data.encode('utf-8')
parser = xml.parsers.expat.ParserCreate()
parser.StartDoctypeDeclHandler = on_doctype
parser.Parse(data, True)
#
# General
#
# pylint: disable-next=redefined-builtin
class WindowsError(Exception):
"""Dummy class for non-Windows systems"""
# The stand-in mirrors the builtin WindowsError signature.
# pylint: disable-next=keyword-arg-before-vararg
def __init__(self, winerror=None, *args, **kwargs):
self.winerror = winerror
super().__init__(*args, **kwargs)
def __str__(self):
return 'this is a dummy class for non-Windows systems'
def chownself(path):
"""Set path owner to real self when running in sudo.
If sudo creates a path and the owner isn't changed, the
owner may not be able to access the path."""
if not IS_POSIX:
return
uid = get_real_uid()
logger.debug('chown(%s, uid=%s)', path, uid)
normalized_path = os.path.normpath(os.path.abspath(path))
if normalized_path == '/root' or path_startswith(normalized_path, '/root'):
logger.info('chown for path /root aborted')
return
try:
# follow_symlinks=False (lchown) so a symlink planted at this path
# cannot redirect the ownership change to its target.
os.chown(path, uid, -1, follow_symlinks=False)
except Exception:
logger.exception('Error in chown() under chownself()')
def gc_collect():
"""Collect garbage
On Windows after updating from Python 3.11 to Python 3.12 calling
os.unlink() would fail on a file processed by SQLite3.
PermissionError: [WinError 32] The process cannot access the file because it is being used
by another process: '[...].sqlite'
"""
if not IS_WINDOWS:
return
gc.collect()
def get_executable():
"""Return the absolute path to the executable
The executable is either Python or, if frozen, then
bleachbit.exe.
When running under `env -i`, sys.executable is an empty string.
"""
if sys.executable:
# example: /usr/bin/python3
return sys.executable
# When running as unittest, sys.argv may look like this:
# [' -m unittest', '-v', 'tests.TestGeneral']
try:
# example: /usr/bin/python3.12
# Notice it ends with .12.
return os.readlink('/proc/self/exe')
except OSError:
logger.debug('/proc/self/exe is unreadable, so falling back to PATH')
for py in ['python3', 'python']:
py_which = shutil.which(py)
if py_which:
return py_which
raise RuntimeError('Cannot find Python executable')
def get_real_username():
"""Get the real username when running in sudo mode
On GitHub Actions, os.getlogin() returns
OSError: [Errno 25] Inappropriate ioctl for device
In Docker containers, getpass.getuser() may fail with KeyError.
"""
if not IS_POSIX:
raise RuntimeError('get_real_username() requires POSIX')
sudo_user = os.getenv('SUDO_USER')
if sudo_user:
return sudo_user
try:
login = os.getlogin()
except OSError:
login = None
# On macOS (e.g., GitHub Actions), os.getlogin() may return 'root'
# because it reflects the owner of the controlling terminal rather
# than the effective user. Don't trust it in that case; fall through
# to getpass.getuser(), which checks environment variables and pwd.
# This mirrors the 'root' != login guard in get_real_uid().
if login and 'root' != login:
return login
try:
return getpass.getuser()
except (KeyError, OSError):
# Happens inside containers when UID lacks an /etc/passwd entry or
# when getpass gives up because no username-related env vars exist.
pass
for env_var in ('LOGNAME', 'USER'):
fallback = os.getenv(env_var)
if fallback:
return fallback
return str(os.getuid())
def get_real_uid():
"""Get the real user ID when running in sudo mode"""
if not IS_POSIX:
raise RuntimeError('get_real_uid() requires POSIX')
sudo_uid = os.getenv('SUDO_UID')
if sudo_uid:
if sudo_uid.isdecimal():
return int(sudo_uid)
logger.warning('ignoring non-numeric SUDO_UID: %r', sudo_uid)
try:
login = os.getlogin()
# On Ubuntu 9.04 and 25.04, getlogin() under sudo returns non-root user.
# On Fedora 11, getlogin() under sudo returns 'root'.
# On Fedora 41, getlogin() under sudo returns non-root user.
# On Fedora 11 and 41, getlogin() under su returns non-root user.
except Exception:
login = os.getenv('LOGNAME')
if login:
login = login.strip()
if login and 'root' != login:
# pwd does not exist on Windows, so global unconditional import
# would cause a ModuleNotFoundError.
import pwd
try:
return pwd.getpwnam(login).pw_uid
except KeyError:
# Docker containers may set LOGNAME to a raw UID that lacks a passwd entry.
if login.isdecimal():
return int(login)
# os.getuid() returns 0 for sudo, so use it as a last resort.
return os.getuid()
def makedirs(path):
"""Make directory recursively considering sudo permissions.
'Path' should not end in a delimiter."""
logger.debug('makedirs(%s)', path)
if os.path.lexists(path):
return
parentdir = os.path.split(path)[0]
if not os.path.lexists(parentdir):
makedirs(parentdir)
if IS_WINDOWS:
# Python makes 0o700 an owner-only ACL, and an elevated process's owner
# is Administrators, which would lock out the user's normal session
os.mkdir(path)
else:
os.mkdir(path, 0o700)
if sudo_mode():
chownself(path)
# Operating system tokens recognized in the os attribute.
# Keep in sync with the os pattern in doc/cleaner_markup_language.xsd.
VALID_OS_TOKENS = frozenset(
('bsd', 'darwin', 'freebsd', 'linux', 'macos', 'netbsd', 'openbsd',
'unix', 'windows'))
def _os_match_token(os_str, platform):
"""Return whether a single operating system token matches the platform
Returns True or False for a recognized token. An unrecognized token
logs a warning and returns None, which os_match() treats as a
non-match for a positive token and as a disqualifier for a negated
token (fail closed).
Keyword arguments:
os_str -- one operating system as written in XML (e.g., 'linux')
platform -- used only for unit tests
"""
if os_str not in VALID_OS_TOKENS:
logger.warning(
'The os="%s" attribute is not recognized.', os_str)
return None
# "darwin" is accepted as a deprecated alias for "macos"
if os_str == 'darwin':
logger.warning(
'The os="darwin" attribute is deprecated; use os="macos" instead.')
os_str = 'macos'
# Otherwise, check platform.
# Define the current operating system.
if platform == 'darwin':
current_os = ('macos', 'bsd', 'unix')
elif platform == 'linux':
current_os = ('linux', 'unix')
elif platform.startswith('openbsd'):
current_os = ('bsd', 'openbsd', 'unix')
elif platform.startswith('netbsd'):
current_os = ('bsd', 'netbsd', 'unix')
elif platform.startswith('freebsd'):
current_os = ('bsd', 'freebsd', 'unix')
elif platform == 'win32':
current_os = ('windows',)
else:
raise RuntimeError(f'Unknown operating system: {platform}')
# Compare current OS against required OS.
return os_str in current_os
def os_match(os_str, platform=sys.platform):
"""Return boolean whether operating system matches
Keyword arguments:
os_str -- the required operating system as written in XML. It may
be a comma-separated list of tokens, each optionally negated
with a leading '!' (e.g., 'unix,!macos'). If negated tokens
are present, none may match; if positive tokens are present,
at least one must match. An unrecognized token logs a warning:
a positive token is a non-match, and a negated token
disqualifies (fails closed).
platform -- used only for unit tests
"""
# If blank, return true.
if not os_str:
return True
tokens = [token.strip() for token in os_str.split(',') if token.strip()]
neg_tokens = [t[1:] for t in tokens if t.startswith('!')]
pos_tokens = [t for t in tokens if not t.startswith('!')]
# Evaluate every token so each unrecognized token logs a warning.
neg_results = [_os_match_token(t, platform) for t in neg_tokens]
pos_results = [_os_match_token(t, platform) for t in pos_tokens]
# An unrecognized negated token returns None, which disqualifies
# because its intent cannot be verified.
if any(r is not False for r in neg_results):
return False
if pos_results:
return any(pos_results)
return True
def _set_detached_kwargs(kwargs):
"""Add the Popen keywords that detach the child from this process."""
if IS_WINDOWS:
kwargs['creationflags'] = (
kwargs.get('creationflags', 0) |
subprocess.DETACHED_PROCESS |
subprocess.CREATE_NEW_PROCESS_GROUP)
else:
kwargs['start_new_session'] = True
kwargs['close_fds'] = True
def run_external_nowait(args, env=None, kwargs=None):
"""Run an external program in the background. Return immediately.
Do not issue a ResourceWarning.
Ignore the output of the new process.
Returns a boolean whether the process was started successfully.
"""
if kwargs is None:
kwargs = {}
else:
kwargs = dict(kwargs)
if IS_POSIX:
# Sanitized here too (not just in run_external()) since this
# function is also called directly, bypassing that sanitization.
env = sanitize_root_env(dict(os.environ) if env is None else env)
try:
_set_detached_kwargs(kwargs)
# The detached process must outlive this call.
# pylint: disable-next=consider-using-with
process = subprocess.Popen(args,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env, **kwargs)
process.returncode = 0
if IS_WINDOWS:
# Popen offers no public way to release the Windows handle.
# pylint: disable-next=protected-access
process._handle.Close()
# pylint: disable-next=protected-access
process._handle = None
return True
except Exception as e:
logger.warning('Failed to start process %s: %s', args, e)
return False
def run_external(args, stdout=None, env=None, clean_env=True, timeout=None, wait=True):
"""Run external command and return (return code, stdout, stderr)
The caller must expand environment variables before calling this function.
timeout is in seconds. On timeout, this function raises subprocess.TimeoutExpired.
No tuple is returned in this case.
If wait=False, the process will be started but not waited for, and (0, '', '') will be returned.
"""
assert args is not None
assert isinstance(args, (list, tuple))
for arg in args:
if arg is None:
raise ValueError("Command argument cannot be None")
assert args
if not args[0]:
raise ValueError("First command argument cannot be empty")
if clean_env and isinstance(env, dict) and env:
raise ValueError(
"Cannot set environment variables when clean_env is True")
logger.debug('running cmd %s', ' '.join(args))
if stdout is None:
stdout = subprocess.PIPE
kwargs = {}
encoding = bleachbit.stdout_encoding
if IS_WINDOWS:
# hide the 'DOS box' window
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
encoding = 'mbcs'
if clean_env and IS_POSIX:
# Clean environment variables so that that subprocesses use English
# instead of translated text. This helps when checking for certain
# strings in the output.
# https://github.com/bleachbit/bleachbit/issues/167
# https://github.com/bleachbit/bleachbit/issues/168
# dconf reset requires DISPLAY
# https://github.com/bleachbit/bleachbit/issues/1096
# LD_LIBRARY_PATH is dropped by sanitize_root_env() below when root
keep_env = ('PATH', 'HOME', 'TMPDIR',
'BLEACHBIT_TEST_OPTIONS_DIR', 'DISPLAY', 'DBUS_SESSION_BUS_ADDRESS')
env = {key: value for key, value in os.environ.items()
if key in keep_env}
env['LANG'] = 'C'
env['LC_ALL'] = 'C'
if IS_POSIX:
# When root, do not let an inherited PATH/LD_* redirect the child
env = sanitize_root_env(dict(os.environ) if env is None else env)
if not wait:
if run_external_nowait(args, env=env, kwargs=kwargs):
return (0, '', '')
# Use fallback method.
_set_detached_kwargs(kwargs)
# The detached process must outlive this call.
# pylint: disable-next=consider-using-with
process = subprocess.Popen(args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env, **kwargs)
process.returncode = 0
return (0, '', '')
with subprocess.Popen(args, stdout=stdout,
stderr=subprocess.PIPE, env=env, **kwargs) as process:
try:
out = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=timeout)
raise
except KeyboardInterrupt:
out = process.communicate()
print(out[0])
print(out[1])
raise
return (process.returncode,
str(out[0], encoding=encoding) if out[0] else '',
str(out[1], encoding=encoding) if out[1] else '')
def shell_split(cmd):
"""Split a shell command into a list of arguments"""
args0 = shlex.split(cmd, posix=IS_POSIX)
args = []
for arg in args0:
if IS_WINDOWS and arg.startswith('"') and arg.endswith('"'):
arg = arg[1:-1]
args.append(arg)
return args
def sudo_mode():
"""Return whether running in sudo mode"""
if not IS_LINUX:
return False
# if 'root' == os.getenv('USER'):
# gksu in Ubuntu 9.10 changes the username. If the username is root,
# we're practically not in sudo mode.
# Fedora 13: os.getenv('USER') = 'root' under sudo
# return False
return os.getenv('SUDO_UID') is not None
def unset_sslkeylogfile(use_logger):
"""Unset environment variable SSLKEYLOGFILE
Workaround for an OpenSSL crash before checking for updates.
https://github.com/bleachbit/bleachbit/issues/1826
Returns True if unset
"""
if not IS_WINDOWS:
return False
if not os.environ.get('SSLKEYLOGFILE'):
return False
del os.environ['SSLKEYLOGFILE']
if use_logger:
logger.debug('The environment variable SSLKEYLOGFILE is not supported')
return True
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GtkShim.py 0000664 0001750 0001750 00000044570 15265023030 014651 0 ustar 00z z # 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.
"""
Centralized GTK import handling.
This module handles importing GTK and related libraries (e.g., Gdk)
in a way that:
- Avoids crashes when GTK is unavailable
- Suppresses warning messages in non-GUI scenarios
- Avoids opening display sockets when they are not needed
- Provides a single source of truth for GTK availability
Scenarios in which GTK is not needed or not available:
- CLI mode explicitly requested
- Text User Interface (TUI) mode
- Non-GTK GUI environment (e.g., wxWidgets)
- GTK/PyGObject not installed
- chroot
- crontab
- SSH
- Limited environment variables (e.g., missing DISPLAY/XAUTHORITY)
Usage
=====
Consider two axes:
- Cost: cheap preconditions versus an expensive import
- Failure mode: return a Boolean versus raise an exception
Cheap exposure check
--------------------
- Does not open a display socket
- Checks only gi, typelib, and DISPLAY environment variables
::
from bleachbit.GtkShim import gtk_may_be_available
if gtk_may_be_available():
self.add_option('clipboard', ...) # exposure only
Expensive use, raise on failure
-------------------------------
- Opens a Wayland/X11 socket through a gi.repository import
- Raises on failure; use when about to call GTK::
from bleachbit.GtkShim import require_gtk
require_gtk() # raises RuntimeError if GTK is unavailable
from bleachbit.GtkShim import Gtk
Gtk.RecentManager().get_default().purge_items()
Expensive use, return a Boolean
------------------------------
Use when about to import or run other GUI code and want to skip
cleanly instead of raising::
from bleachbit.GtkShim import is_gtk_available
if is_gtk_available():
from bleachbit.GuiApplication import Bleachbit
Lazy attribute access
---------------------
``import bleachbit.GtkShim`` runs only the cheap preconditions. However,
``from bleachbit.GtkShim import Gtk`` (or Gdk/GObject/GLib/Gio) triggers
module-level ``__getattr__``, which calls ``_ensure_gtk_libraries()`` and
opens the display socket on first access. Until that import succeeds,
``is_gtk_available()`` returns ``False`` and ``require_gtk()`` raises.
"""
import ctypes
import logging
import os
import sys
import tempfile
import warnings
import webbrowser
from contextlib import contextmanager
from pathlib import PureWindowsPath
from traceback import format_exc
from bleachbit import bleachbit_exe_path, log_startup_time, IS_MAC, IS_POSIX, IS_WINDOWS
HELP_URL = 'https://link.bleachbit.org/get-help'
PYGOBJECT_URL = 'https://link.bleachbit.org/pygobject-lib-bin-error'
logger = logging.getLogger(__name__)
# Module-level GTK state. See the module docstring for the distinction
# between the cheap preconditions (_gtk_preconditions_met) and the
# expensive gi.repository import (_gtk_libraries_imported).
gi = None
# Gtk, Gdk, GObject, GLib, Gio are intentionally NOT module-level names.
# _import_gtk_libraries() fills this dict and __getattr__ serves them out
# of it, which is what keeps the expensive import deferred.
_gtk_modules = {}
# Whether the cheap, non-GUI GTK preconditions have passed.
_gtk_preconditions_met = False
# Reason that GTK is unavailable
_gtk_unavailable_reason = None
def _fix_arg(arg):
"""Replace unpaired surrogates in an argument with the replacement char.
Gdk.init_check() inside gi.overrides.Gdk reads sys.argv during import
and fails with UnicodeEncodeError if arguments contain unpaired
surrogates (e.g., Windows filenames). This sanitizes each argument.
"""
return arg.encode('utf-8', 'surrogatepass').decode('utf-8', 'replace')
@contextmanager
def _patched_argv(transform):
"""Temporarily replace sys.argv with a transformed copy.
Guarantees restoration even on exceptions.
"""
original = sys.argv
sys.argv = [transform(arg) for arg in sys.argv]
try:
yield
finally:
sys.argv = original
def path_has_lib_or_bin(path):
"""Check if any directory component is exactly 'lib' or 'bin'.
This detects a PyGObject bug where certain directory names cause
Gtk to fail to import.
https://github.com/bleachbit/bleachbit/issues/1822
https://github.com/bleachbit/bleachbit/issues/1978
Returns the matched component name, or None.
"""
if not path:
return None
try:
parts = PureWindowsPath(path).parts
except TypeError:
return None
for part in parts:
if part and part.lower() in ('lib', 'bin'):
return part.lower()
return None
def _build_error_html(error, traceback_text=None):
"""Build an HTML error report string.
Include traceback, system information, and a copyable bug-report block.
It does not write to a file.
"""
# Keep html off the startup path: only the error page needs it
from html import escape as esc # pylint: disable=import-outside-toplevel
try:
# Import here to avoid a circular import.
from bleachbit.SystemInformation import get_system_information
sysinfo_text = get_system_information()
except Exception:
sysinfo_text = '(unavailable)'
bug_info = (
f"Error: {error}\n\n"
f"{traceback_text or ''}\n\n" # It already has "Traceback:" header
f"System information:\n{sysinfo_text}"
)
return f"""
BleachBit Error
BleachBit cannot start
BleachBit failed to load its graphical interface.
Error: {esc(str(error))}
Get help
Copy this for a bug report
Copy to clipboard
Copied!
"""
def _show_windows_error_dialog(title, html_content):
"""Show a concise error dialog on Windows and optionally save an HTML log.
Prompts the user with Yes/No: if Yes, writes an HTML file to %TEMP% and
opens it in the default browser. If No, closes silently.
"""
assert IS_WINDOWS
prompt = (
"BleachBit failed to load the graphical interface.\n\n"
"This may be a bug or a broken installation."
"\n\nSave error details to file and open in browser?"
)
try:
MB_YESNO = 0x00000004
MB_ICONERROR = 0x00000010
IDYES = 6
result = ctypes.windll.user32.MessageBoxW(
0, prompt, title, MB_YESNO | MB_ICONERROR)
if result == IDYES:
fd, html_path = tempfile.mkstemp(
prefix='bleachbit_error_', suffix='.html')
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(html_content)
webbrowser.open(f'file:///{html_path}')
except Exception as e:
logger.error('Failed to show Windows error dialog: %s', e)
def _handle_gtk_import_error(error):
"""On Windows, show a helpful error dialog when GTK import fails."""
if not IS_WINDOWS:
return
logger.error('GTK not available: %s\n%s', error, format_exc())
html_content = _build_error_html(
error, traceback_text=format_exc())
_show_windows_error_dialog('BleachBit', html_content)
def _check_display_available():
"""Check if a display server is available.
Returns:
tuple: (is_available: bool, reason: str or None)
"""
if IS_WINDOWS:
# Windows always has a display
return True, None
# macOS uses GDK Quartz rather than X11 or Wayland.
# GDK will perform the actual display availability check later.
if IS_MAC:
return True, None
# Check for X11 or Wayland display
has_display = bool(
os.environ.get('DISPLAY') or
os.environ.get('WAYLAND_DISPLAY')
)
if not has_display:
return False, 'No DISPLAY or WAYLAND_DISPLAY environment variable set'
return True, None
def _check_gtk_available():
"""Check the cheap GTK preconditions without importing gi.repository.
See the module docstring for why the gi.repository import is deferred.
This checks that ``gi`` is importable, the typelib version can be
required, and a display server appears to be reachable.
Returns:
tuple: (success: bool, reason: str or None)
"""
# gi is imported lazily into a module global.
# pylint: disable-next=global-statement
global gi
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# Always try to import gi first (for gi.version reporting).
# The top-level ``gi`` package itself does not open a display
# connection; only ``gi.repository.Gtk``/``Gdk`` do.
try:
import gi as _gi
gi = _gi
except ImportError:
return False, 'PyGObject (gi) module not installed'
# Check display availability before GTK imports (on POSIX)
display_available, display_reason = _check_display_available()
if not display_available:
return False, display_reason
if path_has_lib_or_bin(bleachbit_exe_path) and hasattr(sys, 'frozen'):
# Setting search path prevents crash when importing GTK
# when application is run from directory with foldername lib or bin.
typelib_dir = os.path.join(
bleachbit_exe_path, 'lib', 'girepository-1.0')
if os.path.isdir(typelib_dir):
logger.debug('Setting typelib search path to: %s', typelib_dir)
# PyGObject exposes Repository only under gi._gi.
# pylint: disable-next=protected-access
gi._gi.Repository.get_default().prepend_search_path(typelib_dir)
else:
logger.warning('Typelib directory not found: %s', typelib_dir)
try:
gi.require_version('Gtk', '3.0')
except Exception as e:
_handle_gtk_import_error(e)
return False, f'GTK 3.0 not available: {e}'
return True, None
def _import_gtk_libraries():
"""Import ``gi.repository`` GTK libraries and verify the display.
This is the expensive counterpart to :func:`_check_gtk_available`.
It is called lazily by :func:`_ensure_gtk_libraries` and thus by
:func:`require_gtk` and module-level ``__getattr__``.
Returns:
tuple: (success: bool, reason: str or None)
"""
if not _gtk_preconditions_met or gi is None:
return False, _gtk_unavailable_reason or 'GTK preconditions not met'
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# Gdk.init_check() inside gi.overrides.Gdk reads sys.argv during
# import and fails with UnicodeEncodeError if arguments contain
# unpaired surrogates (e.g., Windows filenames). Sanitize argv
# temporarily, so the import can proceed.
try:
with _patched_argv(_fix_arg):
from gi.repository import Gtk as _Gtk
from gi.repository import Gdk as _Gdk
from gi.repository import GObject as _GObject
from gi.repository import GLib as _GLib
from gi.repository import Gio as _Gio
_gtk_modules.update(Gtk=_Gtk, Gdk=_Gdk, GObject=_GObject,
GLib=_GLib, Gio=_Gio)
except (ImportError, RuntimeError, ValueError) as e:
_handle_gtk_import_error(e)
return False, f'Failed to import GTK libraries: {e}'
# On POSIX, verify we can actually get a display.
# macOS uses GDK Quartz and has no X11-style root window.
if IS_POSIX and not IS_MAC:
try:
if _Gdk.get_default_root_window() is None:
return False, 'No default root window (display not accessible)'
except Exception as e:
return False, f'Display check failed: {e}'
log_startup_time('GTK imported')
return True, None
# Track whether the expensive gi.repository import has been attempted and
# whether it succeeded. _gtk_libraries_imported gates the one-time
# attempt; _gtk_libraries_available caches its result for repeat callers.
_gtk_libraries_imported = False
_gtk_libraries_available = False
def _ensure_gtk_libraries():
"""Lazily import gi.repository GTK libraries once.
Called by :func:`require_gtk`, :func:`is_gtk_available`, and
module-level ``__getattr__``. Returns whether the GTK libraries and
display are available. Does nothing when the cheap preconditions
have failed.
"""
# The import result is cached for the process.
# pylint: disable-next=global-statement
global _gtk_libraries_imported, _gtk_libraries_available, _gtk_unavailable_reason
if _gtk_libraries_imported:
return _gtk_libraries_available
if not _gtk_preconditions_met:
return False
_gtk_libraries_imported = True
success, reason = _import_gtk_libraries()
_gtk_libraries_available = success
if not success:
_gtk_unavailable_reason = reason
return _gtk_libraries_available
def _init_gtk():
"""Check cheap GTK preconditions when the module loads.
The expensive gi.repository import is deferred to
:func:`_ensure_gtk_libraries`; ``is_gtk_available()`` returns
``False`` and ``require_gtk()`` raises until it succeeds.
"""
# The precondition check runs once per process.
# pylint: disable-next=global-statement
global _gtk_preconditions_met, _gtk_unavailable_reason
_gtk_preconditions_met, _gtk_unavailable_reason = _check_gtk_available()
if not _gtk_preconditions_met and _gtk_unavailable_reason:
logger.debug('GTK not available: %s', _gtk_unavailable_reason)
def get_gtk_unavailable_reason():
"""Return the reason GTK is unavailable, or None if available."""
return _gtk_unavailable_reason
def gtk_may_be_available():
"""Return whether the cheap GTK preconditions passed.
Use this only when exposing operations that call :func:`require_gtk`
immediately before using GTK. GUI code should call
:func:`is_gtk_available` or :func:`require_gtk` instead.
"""
return _gtk_preconditions_met
def is_gtk_available():
"""Return whether GTK libraries and a display are actually available."""
return _ensure_gtk_libraries()
def is_gtk_loaded():
"""Return whether the gi.repository GTK libraries are loaded.
Unlike :func:`is_gtk_available`, this only reports a past import --
it never triggers the lazy import itself. Callers that need to know
whether GTK is part of this process without pulling it in should use
this.
"""
return _gtk_libraries_available or 'gi.repository.Gtk' in sys.modules
def require_gtk():
"""Raise ``RuntimeError`` if GTK is not available.
Triggers the lazy ``gi.repository`` import on its first call. Use
this in modules that absolutely require GTK (e.g., GUI modules).
"""
if not _ensure_gtk_libraries():
reason = _gtk_unavailable_reason or 'unknown reason'
raise RuntimeError(f'GTK is required but not available: {reason}')
# PyGObject 3.56 calls both from inside Gtk.main_iteration_do() and
# Gtk.Application.run(). Python 3.14 deprecated them; 3.16 removes them.
_ASYNCIO_DEPRECATIONS = (
r".*asyncio\.AbstractEventLoopPolicy.*",
r".*asyncio\.get_event_loop_policy.*",
)
def ignore_pygobject_asyncio_warnings():
"""Ignore PyGObject's asyncio deprecation warnings under Python 3.14+.
This changes the active filter list rather than a catch_warnings() copy,
so a thread that copies the list later still sees it. Use the context
manager below to scope the filters instead.
"""
if sys.version_info < (3, 14):
return
for message in _ASYNCIO_DEPRECATIONS:
warnings.filterwarnings(
"ignore", message=message, category=DeprecationWarning)
@contextmanager
def suppress_pygobject_asyncio_warnings():
"""Suppress known PyGObject asyncio warnings under Python 3.14+."""
if sys.version_info < (3, 14):
yield
return
with warnings.catch_warnings():
ignore_pygobject_asyncio_warnings()
yield
@contextmanager
def suppress_pygobject_import_warnings():
"""Suppress ImportWarning from old-style PyGObject import hooks.
Older PyGObject versions use a legacy importer for ``gi.repository``
that triggers ``ImportWarning: DynamicImporter.exec_module() not found;
falling back to load_module()`` under Python's modern import system.
This is harmless but breaks imports when warnings are errors (e.g.,
``PYTHONWARNINGS=error`` in the test suite).
"""
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=".*DynamicImporter.exec_module\\(\\) not found.*",
category=ImportWarning)
yield
# Names whose access triggers the lazy gi.repository import.
_LAZY_GTK_NAMES = frozenset(('Gtk', 'Gdk', 'GObject', 'GLib', 'Gio'))
def __getattr__(name):
"""Lazily import gi.repository GTK libraries on first attribute access.
``from bleachbit.GtkShim import Gtk`` (or Gdk/GObject/GLib/Gio)
triggers this hook because those names are not defined at module
level until :func:`_import_gtk_libraries` populates them. See the
module docstring for why this deferral matters.
"""
if name in _LAZY_GTK_NAMES:
_ensure_gtk_libraries()
# On failure the name is absent; return None so callers see a
# falsy placeholder rather than an AttributeError from this import.
return _gtk_modules.get(name)
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
# Perform initialization at module load
_init_gtk()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiApplication.py 0000664 0001750 0001750 00000043607 15265023030 016213 0 ustar 00z z # 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.
import glob
import os
import sys
import bleachbit
from bleachbit import APP_NAME, Cleaner, GuiBasic, appicon_path, portable_mode, IS_WINDOWS
from bleachbit.Cleaner import backends
from bleachbit.GtkShim import (
GLib, Gdk, Gio, Gtk,
require_gtk,
)
from bleachbit.GUI import logger
from bleachbit.GuiUtil import get_clipboard_paths
from bleachbit.GuiWindow import GUI
from bleachbit.Language import get_text as _, get_active_language_code
from bleachbit.Options import options
# Ensure GTK is available for this GUI module
require_gtk()
# Constants for dialog response codes
RESPONSE_ANONYMIZE = 101
RESPONSE_COPY = 100
if IS_WINDOWS:
from bleachbit import Windows
from bleachbit.FontCheckDialog import (
create_font_check_dialog,
RESPONSE_TEXT_BLURRY,
RESPONSE_TEXT_UNREADABLE,
)
bleachbit.log_startup_time('GuiApplication imported')
class Bleachbit(Gtk.Application):
_window = None
_shred_paths = None
_auto_exit = False
def __init__(self, uac=True, shred_paths=None, auto_exit=False):
application_id_suffix = self._init_windows_misc(
auto_exit, shred_paths, uac)
# Support pytest-xdist parallel workers by making application ID unique
xdist_worker = os.environ.get('PYTEST_XDIST_WORKER', '')
if xdist_worker:
application_id_suffix += xdist_worker
application_id = '{}{}'.format(
'org.gnome.Bleachbit', application_id_suffix)
Gtk.Application.__init__(
self, application_id=application_id, flags=Gio.ApplicationFlags.FLAGS_NONE)
GLib.set_prgname('org.bleachbit.BleachBit')
self._font_check_prompt_scheduled = False
if auto_exit:
# This is used for automated testing of whether the GUI can start.
# It is called from assert_execute_console() in windows/setup.py
self._auto_exit = True
if shred_paths:
self._shred_paths = shred_paths
if IS_WINDOWS:
# clean up nonce files https://github.com/bleachbit/bleachbit/issues/858
import atexit
atexit.register(Windows.cleanup_nonce)
bleachbit.log_startup_time('application created')
def run(self, *args, **kwargs):
"""Run the GTK application."""
return Gtk.Application.run(self, *args, **kwargs)
def _init_windows_misc(self, auto_exit, shred_paths, uac):
application_id_suffix = ''
is_context_menu_executed = auto_exit and shred_paths
if not IS_WINDOWS:
return ''
env_suffix = os.environ.pop('BLEACHBIT_APP_INSTANCE_SUFFIX', '')
if env_suffix:
application_id_suffix = env_suffix
if Windows.elevate_privileges(uac):
# privileges escalated in other process
sys.exit(0)
if is_context_menu_executed:
# When we have a running application and executing the Windows
# context menu command we start a new process with new application_id.
# That is because the command line arguments of the context menu command
# are not passed to the already running instance.
application_id_suffix = 'ContextMenuShred'
return application_id_suffix
def build_app_menu(self):
"""Register the actions used by the application menu
The menu itself is built by the headerbar code in GuiWindow.
"""
from bleachbit.Language import setup_translation
setup_translation()
# set up mappings between in app-menu.ui and methods in this class
actions = {'shredFiles': self.cb_shred_file,
'shredFolders': self.cb_shred_folder,
'shredClipboard': self.cb_shred_clipboard,
'wipeEmptySpace': self.cb_wipe_empty_space,
'makeChaff': self.cb_make_chaff,
'shredQuit': self.cb_shred_quit,
'preferences': self.cb_preferences_dialog,
'systemInformation': self.system_information_dialog,
'help': self.cb_help,
'about': self.about}
for action_name, callback in actions.items():
action = Gio.SimpleAction.new(action_name, None)
action.connect('activate', callback)
self.add_action(action)
def cb_help(self, action, param):
"""Callback for help"""
GuiBasic.open_url(bleachbit.help_contents_url, self._window)
def cb_make_chaff(self, action, param):
"""Callback to make chaff"""
from bleachbit.GuiChaff import ChaffDialog
cd = ChaffDialog(self._window)
cd.run()
def cb_shred_file(self, action, param):
"""Callback for shredding a file"""
# get list of files
# TRANSLATORS: Title of a file chooser dialog.
paths = GuiBasic.browse_files(self._window, _("Choose files to shred"))
if not paths:
return
GUI.shred_paths(self._window, paths)
def cb_shred_folder(self, action, param):
"""Callback for shredding a folder"""
# TRANSLATORS: Title of a folder chooser dialog.
title = _("Choose folder to shred")
# TRANSLATORS: Button label in a folder chooser dialog.
button_label = _('_Delete')
paths = GuiBasic.browse_folder(self._window,
title,
multiple=True,
stock_button=button_label)
if not paths:
return
GUI.shred_paths(self._window, paths)
def cb_shred_clipboard(self, action, param):
"""Callback for menu option: shred paths from clipboard"""
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
# wait_for_targets() avoids GLib warnings from request_targets() when
# the clipboard is empty.
has_targets, targets = clipboard.wait_for_targets()
if not has_targets:
targets = []
self.cb_clipboard_uri_received(clipboard, targets, None)
def cb_clipboard_uri_received(self, clipboard, targets, _data):
"""Callback for when URIs are received from clipboard
With GTK 3.18.9 on Windows, there was no text/uri-list in targets,
but there is with GTK 3.24.34. However, Windows does not have
get_uris().
"""
shred_paths = get_clipboard_paths(clipboard, targets)
# TRANSLATORS: Warning log message when attempting to paste files/folders to shred.
not_found_msg = _('No paths found in clipboard.')
if shred_paths:
GUI.shred_paths(self._window, shred_paths,
should_clear_clipboard=True)
else:
logger.warning(not_found_msg)
def cb_shred_quit(self, action, param):
"""Shred settings (for privacy reasons) and quit"""
# build a list of paths to delete
paths = []
if IS_WINDOWS and portable_mode:
# in portable mode on Windows, the options directory includes
# executables
paths.append(bleachbit.options_file)
if os.path.isdir(bleachbit.personal_cleaners_dir):
paths.append(bleachbit.personal_cleaners_dir)
for f in glob.glob(os.path.join(bleachbit.options_dir, "*.bz2")):
paths.append(f)
else:
paths.append(bleachbit.options_dir)
# prompt the user to confirm
if not GUI.shred_paths(self._window, paths, shred_settings=True):
logger.debug('user aborted shred')
# aborted
return
# Quit the application through the idle loop to allow the worker
# to delete the files. Use the lowest priority because the worker
# uses the standard priority. Otherwise, this will quit before
# the files are deleted.
#
# Rebuild a minimal bleachbit.ini when quitting
GLib.idle_add(self.quit, None, None, True,
priority=GLib.PRIORITY_LOW)
def cb_wipe_empty_space(self, action, param):
"""callback to wipe empty space in arbitrary folder"""
# TRANSLATORS: Title of a folder chooser dialog.
title = _("Choose a folder")
# TRANSLATORS: Button label in a folder chooser dialog.
# Underscore is for accelerator key.
button_label = _('_OK')
path = GuiBasic.browse_folder(self._window,
title,
multiple=False,
stock_button=button_label)
if not path:
# user cancelled
return
backends['_gui'] = Cleaner.create_wipe_empty_space_cleaner(path)
# execute
operations = {'_gui': ['empty_space']}
self._window.preview_or_run_operations(True, operations)
def get_preferences_dialog(self):
return self._window.get_preferences_dialog()
def cb_preferences_dialog(self, action, param):
"""Callback for preferences dialog"""
self._window.show_preferences_dialog()
def get_about_dialog(self):
# TRANSLATORS: Title of the 'About' dialog.
dialog = Gtk.AboutDialog(comments=_("Program to clean unnecessary files"),
copyright=bleachbit.APP_COPYRIGHT,
program_name=APP_NAME,
version=bleachbit.APP_VERSION,
website=bleachbit.APP_URL,
transient_for=self._window)
try:
with open(bleachbit.license_filename, encoding='utf-8') as f_license:
dialog.set_license(f_license.read())
except (IOError, TypeError):
# TRANSLATORS: License text shown in the 'About' dialog.
license_msg = _("GNU General Public License version 3 or later.\n"
"See https://www.gnu.org/licenses/gpl-3.0.txt")
dialog.set_license(license_msg)
# TRANSLATORS: Maintain the names of translators here.
# Launchpad does this automatically for translations
# typed in Launchpad. This is a special string shown
# in the 'About' box.
dialog.set_translator_credits(_("translator-credits"))
if appicon_path and os.path.exists(appicon_path):
icon = Gtk.Image.new_from_file(appicon_path)
dialog.set_logo(icon.get_pixbuf())
return dialog
def about(self, _action, _param):
"""Create and show the about dialog"""
dialog = self.get_about_dialog()
dialog.run()
dialog.destroy()
def do_startup(self):
Gtk.Application.do_startup(self)
bleachbit.log_startup_time('GTK startup done')
self.build_app_menu()
bleachbit.log_startup_time('app menu built')
def quit(self, _action=None, _param=None, init_configuration=False):
if init_configuration:
# After "shred settings and quit", rebuild the minimal configuration.
# which is important for portable mode.
bleachbit.Options.init_configuration()
else:
# Flush any pending preference changes immediately instead of
# relying on the delayed background timer (FLUSH_DELAY_SECS),
# which would otherwise silently lose changes made shortly
# before quitting (e.g. toggling a checkbox and closing the
# app within a few seconds).
options.commit()
self._window.destroy()
def get_system_information_dialog(self):
"""Show system information dialog"""
# TRANSLATORS: Title of the system information dialog.
dialog = Gtk.Dialog(title=_("System information"),
transient_for=self._window)
dialog.set_default_size(600, 400)
txtbuffer = Gtk.TextBuffer()
from bleachbit.SystemInformation import get_system_information
txt = get_system_information()
txtbuffer.set_text(txt)
textview = Gtk.TextView.new_with_buffer(txtbuffer)
textview.set_editable(False)
swindow = Gtk.ScrolledWindow()
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.add(textview)
dialog.get_content_area().pack_start(swindow, True, True, 0)
# TRANSLATORS: Button label in the system information dialog to
# replace the username with a placeholder.
dialog.add_buttons(_("Anonymize"), RESPONSE_ANONYMIZE,
Gtk.STOCK_COPY, RESPONSE_COPY,
Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
return (dialog, txt, txtbuffer)
def system_information_dialog(self, _action, _param):
from bleachbit.SystemInformation import anonymize_system_information
dialog, txt, txtbuffer = self.get_system_information_dialog()
dialog.show_all()
while True:
rc = dialog.run()
if rc == RESPONSE_COPY:
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
current_text = txtbuffer.get_text(
txtbuffer.get_start_iter(),
txtbuffer.get_end_iter(),
True)
clipboard.set_text(current_text, -1)
elif rc == RESPONSE_ANONYMIZE:
anonymized_txt = anonymize_system_information(txt)
txtbuffer.set_text(anonymized_txt)
# The button is single use.
dialog.get_widget_for_response(
RESPONSE_ANONYMIZE).set_sensitive(False)
else:
break
dialog.destroy()
def do_activate(self):
if not self._window:
self._window = GUI(
application=self, title=APP_NAME, auto_exit=self._auto_exit)
self._window.present()
if self._shred_paths:
GLib.idle_add(GUI.shred_paths, self._window,
self._shred_paths, priority=GLib.PRIORITY_LOW)
# When we shred paths and auto exit with the Windows Explorer context menu command we close the
# application in GUI.shred_paths, because if it is closed from here there are problems.
# Most probably this is something related with how GTK handles idle quit calls.
elif self._auto_exit:
GLib.idle_add(self.quit,
priority=GLib.PRIORITY_LOW)
print('Success')
else:
# Check for orphaned wipe files from interrupted operations
GLib.idle_add(self._window.check_orphaned_wipe_files,
priority=GLib.PRIORITY_LOW)
if IS_WINDOWS:
GLib.idle_add(self._window.check_appdata_permissions,
priority=GLib.PRIORITY_LOW)
self._maybe_prompt_font_check()
def _should_show_font_check_dialog(self):
"""Determine whether to show the font check dialog on Windows."""
if not IS_WINDOWS:
return False
if self._auto_exit:
return False
if self._shred_paths:
return False
# User made an explicit choice of a backend.
if os.environ.get('PANGOCAIRO_BACKEND', ''):
return False
if options.get('use_fontconfig_backend'):
return False
if options.get('font_check_completed'):
return False
# Skip for CJK languages (Chinese, Japanese, Korean).
# They do not support Arial font, and they seem not to
# be affected by the font bug.
lang = get_active_language_code()
if lang.startswith(('zh', 'ja', 'ko')):
return False
return True
def _maybe_prompt_font_check(self):
"""Schedule the font check dialog if needed."""
if not self._should_show_font_check_dialog():
return
if self._font_check_prompt_scheduled:
return
self._font_check_prompt_scheduled = True
GLib.idle_add(self._show_font_check_dialog,
priority=GLib.PRIORITY_DEFAULT_IDLE)
def _show_font_check_dialog(self):
"""Show the font check dialog and handle the response."""
if not IS_WINDOWS:
return False
if not self._window:
return False
# pylint: disable-next=possibly-used-before-assignment
dialog = create_font_check_dialog(self._window)
response = dialog.run()
dialog.destroy()
if response == Gtk.ResponseType.YES:
options.set('font_check_completed', True)
# pylint: disable-next=possibly-used-before-assignment
elif response in (RESPONSE_TEXT_BLURRY, RESPONSE_TEXT_UNREADABLE):
self._restart_with_fontconfig_backend()
# If user dismisses the dialog, ask again next time.
return False
def _restart_with_fontconfig_backend(self):
"""Restart BleachBit with the fontconfig Pango backend."""
from bleachbit import General
executable = General.get_executable()
if getattr(sys, 'frozen', False):
cmd = [executable] + sys.argv[1:]
else:
script_path = os.path.abspath(sys.argv[0])
cmd = [executable, script_path] + sys.argv[1:]
logger.info('Restarting BleachBit with fontconfig backend: %s', cmd)
env = os.environ.copy()
env['PANGOCAIRO_BACKEND'] = 'fc'
env['BLEACHBIT_APP_INSTANCE_SUFFIX'] = f'Restart{os.getpid()}'
options.set('font_check_completed', True)
options.set('use_fontconfig_backend', True)
if General.run_external_nowait(cmd, env=env):
self.quit()
else:
# This logs to the main application window
logger.error('Failed to restart BleachBit with fontconfig backend')
options.set('font_check_completed', False)
options.set('use_fontconfig_backend', False)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiBasic.py 0000775 0001750 0001750 00000026327 15265023030 014774 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Basic GUI code
"""
# standard library
import logging
import os
import re
# local import
from bleachbit import IS_POSIX, IS_WINDOWS
from bleachbit.GtkShim import Gtk, Gdk, GLib, require_gtk
from bleachbit.Language import get_text as _
from bleachbit.Options import options
if IS_WINDOWS:
from bleachbit import Windows
# Ensure GTK is available for this GUI module
require_gtk()
logger = logging.getLogger(__name__)
# TRANSLATORS: Label for the Cancel button in several dialog windows:
# file chooser, folder choose, delete confirmation, and warning dialogs.
# The underscore indicates the accelerator key.
CANCEL_BUTTON_LABEL = _("_Cancel")
# TRANSLATORS: Label for the Delete button in the file choose dialog
# and delete confirmation dialog.
DELETE_BUTTON_LABEL = _("_Delete")
def browse_folder(parent, title, multiple, stock_button):
"""Ask the user to select a folder. Return the full path or None."""
if IS_WINDOWS and not os.getenv('BB_NATIVE'):
# pylint: disable-next=possibly-used-before-assignment
ret = Windows.browse_folder(parent, title)
return [ret] if multiple and not ret is None else ret
# fall back to GTK+
chooser = Gtk.FileChooserDialog(transient_for=parent,
title=title,
action=Gtk.FileChooserAction.SELECT_FOLDER)
chooser.add_buttons(CANCEL_BUTTON_LABEL, Gtk.ResponseType.CANCEL,
stock_button, Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_select_multiple(multiple)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
if multiple:
ret = chooser.get_filenames()
else:
ret = chooser.get_filename()
chooser.hide()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return ret
def _browse_open(parent, title, multiple, stock_button):
"""Ask the user to select one or more files. Return path(s) or None."""
if IS_WINDOWS and not os.getenv('BB_NATIVE'):
if multiple:
return Windows.browse_files(parent, title)
return Windows.browse_file(parent, title)
chooser = Gtk.FileChooserDialog(title=title,
transient_for=parent,
action=Gtk.FileChooserAction.OPEN)
chooser.add_buttons(CANCEL_BUTTON_LABEL, Gtk.ResponseType.CANCEL,
stock_button, Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_select_multiple(multiple)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
if multiple:
ret = chooser.get_filenames()
else:
ret = chooser.get_filename()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return ret
def browse_file(parent, title):
"""Prompt user to select a single file"""
# TRANSLATORS: This is a label for the Open button in a file chooser dialog.
return _browse_open(parent, title, False, _("_Open"))
def browse_files(parent, title):
"""Prompt user to select multiple files to delete"""
return _browse_open(parent, title, True, DELETE_BUTTON_LABEL)
def delete_confirmation_dialog(parent, mention_preview, shred_settings=False):
"""Return boolean whether OK to delete files."""
# TRANSLATORS: Title of the delete confirmation dialog.
dialog = Gtk.Dialog(title=_("Delete confirmation"), transient_for=parent,
modal=True,
destroy_with_parent=True)
dialog.set_default_size(300, -1)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
homogeneous=False, spacing=10)
if shred_settings:
# TRANSLATORS: This message appears in a dialog window when the user
# chooses to shred settings and quit.
notice_text = _("This function deletes all BleachBit settings and then quits the "
"application. Use this to hide your use of BleachBit or to reset its "
"settings. The next time you start BleachBit, the settings will "
"initialize to default values.")
notice = Gtk.Label(label=notice_text)
notice.set_line_wrap(True)
vbox.pack_start(notice, False, True, 0)
if mention_preview:
# TRANSLATORS: This message appears in a dialog window when the user
# asks the user to confirm deleting files when a preview was run.
question_text = _("Are you sure you want to permanently delete files "
"according to the selected operations? The actual files that will be "
"deleted may have changed since you ran the preview.")
else:
# TRANSLATORS: This message appears in a dialog window when the user
# asks the user to confirm deleting files when no preview was run.
question_text = _("Are you sure you want to permanently delete "
"these files?")
question = Gtk.Label(label=question_text)
question.set_line_wrap(True)
vbox.pack_start(question, False, True, 0)
if options.get('expert_mode'):
# TRANSLATORS: Check button shown in the confirm delete dialog.
# If unchecked, then it will not confirm next time.
cb_popup = Gtk.CheckButton(label=_("Confirm before delete"))
cb_popup.set_active(options.get('delete_confirmation'))
vbox.pack_start(cb_popup, False, True, 0)
dialog.get_content_area().pack_start(vbox, False, True, 0)
dialog.get_content_area().set_spacing(10)
dialog.add_button(DELETE_BUTTON_LABEL, Gtk.ResponseType.ACCEPT)
dialog.add_button(CANCEL_BUTTON_LABEL, Gtk.ResponseType.CANCEL)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
dialog.show_all()
ret = dialog.run()
if options.get('expert_mode'):
options.set('delete_confirmation', cb_popup.get_active())
dialog.destroy()
return ret == Gtk.ResponseType.ACCEPT
def warning_confirm_dialog(parent, option_name, warning_text, show_checkbox=True):
"""Show a warning dialog when enabling an option.
Returns tuple (confirmed: bool, remember_choice: bool).
"""
# TRANSLATORS: Title of a warning dialog. %(option)s is the name
# of the option being enabled (e.g. "shred files").
dialog = Gtk.Dialog(title=_('Enable %(option)s') % {'option': option_name},
transient_for=parent,
modal=True,
destroy_with_parent=True)
content = dialog.get_content_area()
content.set_border_width(12)
content.set_spacing(10)
warning_label = Gtk.Label(label=warning_text)
warning_label.set_line_wrap(True)
warning_label.set_max_width_chars(80)
warning_label.set_xalign(0.0)
content.pack_start(warning_label, False, False, 0)
remember_cb = Gtk.CheckButton(
# TRANSLATORS: Check button label. %(option)s is the name of
# the option being enabled (e.g. "shred files").
label=_('Remember my choice for %(option)s') % {'option': option_name})
remember_cb.set_halign(Gtk.Align.START)
if show_checkbox:
content.pack_start(remember_cb, False, False, 0)
cancel_button = dialog.add_button(
CANCEL_BUTTON_LABEL, Gtk.ResponseType.CANCEL)
# TRANSLATORS: Button label in a warning dialog to confirm enabling an option.
# The underscore is the accelerator key.
enable_button = dialog.add_button(_('_Enable anyway'), Gtk.ResponseType.OK)
enable_button.get_style_context().add_class('destructive-action')
dialog.set_default_response(Gtk.ResponseType.CANCEL)
cancel_button.grab_focus()
# Disable enable button for a moment to prevent accident by double-clicking.
enable_button.set_sensitive(False)
def enable_button_after_delay():
enable_button.set_sensitive(True)
return False
GLib.timeout_add(500, enable_button_after_delay)
dialog.show_all()
response = dialog.run()
remember_choice = response == Gtk.ResponseType.OK and show_checkbox and remember_cb.get_active()
dialog.destroy()
return response == Gtk.ResponseType.OK, remember_choice
def message_dialog(parent, msg, mtype=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, title=None):
"""Convenience wrapper for Gtk.MessageDialog"""
dialog = Gtk.MessageDialog(transient_for=parent,
modal=True,
destroy_with_parent=True,
message_type=mtype,
buttons=buttons,
text=msg)
if title:
dialog.set_title(title)
resp = dialog.run()
dialog.destroy()
return resp
def open_url(url, parent_window=None, prompt=True):
"""Open an HTTP URL. Try to run as non-root."""
if not url.lower().startswith(('http://', 'https://')):
logger.error('refusing to open URL with disallowed scheme: %s', url)
return
# drop privileges so the web browser is running as a normal process
if IS_POSIX and os.getuid() == 0:
# TRANSLATORS: This is an error message shown to root users.
# %s expands to a web URL.
msg = _("Because you are running as root, please manually open "
"this link in a web browser:\n%s") % url
message_dialog(None, msg, Gtk.MessageType.INFO)
return
if prompt:
# find hostname
ret = re.search(r'^http(s)?://([a-z.]+)', url)
if not ret:
host = url
else:
host = ret.group(2)
# TRANSLATORS: The question appears in a confirmation dialog.
# %s expands to www.bleachbit.org or similar
msg = _("Open web browser to %s?") % host
resp = message_dialog(parent_window,
msg,
Gtk.MessageType.QUESTION,
Gtk.ButtonsType.OK_CANCEL,
# TRANSLATORS: Title of a confirmation dialog.
_('Confirm'))
if Gtk.ResponseType.OK != resp:
return
# open web browser
if IS_WINDOWS:
# in Gtk.show_uri() avoid 'glib.GError: No application is registered as
# handling this file'
import webbrowser
webbrowser.open(url)
elif (Gtk.get_major_version(), Gtk.get_minor_version()) < (3, 22):
# Ubuntu 16.04 LTS ships with GTK 3.18
Gtk.show_uri(None, url, Gdk.CURRENT_TIME)
else:
Gtk.show_uri_on_window(parent_window, url, Gdk.CURRENT_TIME)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiChaff.py 0000775 0001750 0001750 00000054171 15265023030 014760 0 ustar 00z z # 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.
"""
GUI for making chaff
"""
import functools
import logging
import os
import shutil
import tempfile
import threading
from bleachbit.Chaff import generate_emails, generate_2600
from bleachbit.Constant import ABORT_BUTTON_LABEL
from bleachbit.GtkShim import Gtk, GLib
from bleachbit.GuiInfoBar import InfoBarMixin
from bleachbit.Language import get_text as _
logger = logging.getLogger(__name__)
STOP_MODE_FILE_COUNT = 0
STOP_MODE_TOTAL_SIZE = 1
STOP_MODE_FREE_SPACE = 2
MAX_FILE_COUNT = 999999
MAX_MB_COUNT = 999999
MAX_FREE_SPACE_PCT = 99
STOP_MODE_LABELS = {
# TRANSLATORS: Option in combo box to choose to stop after a number
# of files have been generated.
STOP_MODE_FILE_COUNT: _("Number of files"),
# TRANSLATORS: Option in a combo box for choosing the stop condition
# when generating chaff (dummy) files. This option means "stop after
# the total size of generated files reaches a threshold in megabytes."
# "MB" is the megabyte unit abbreviation. Localize it if your language
# has an official abbreviation (e.g., "Mo" in French); otherwise keep
# "MB".
STOP_MODE_TOTAL_SIZE: _("Size (MB)"),
# TRANSLATORS: Option in combo box to choose to stop when free space
# reaches a certain percentage.
# The percentage symbol (%) is a literal character, not a format
# placeholder.
STOP_MODE_FREE_SPACE: _("Free space (%)"),
}
# TRANSLATORS: Used for (1) label for folder chooser button and
# (2) error message shown in the infobar when the folder has not
# been set. 'Select' is a verb.
SELECT_DEST_FOLDER_MSG = _("Select destination folder")
def _make_should_stop(stop_mode, stop_value, output_folder, abort_event):
"""Create a should_stop callback based on the stop mode.
The returned callable accepts generated_file_names (list of paths)
and cumulative_size (total bytes written so far).
Returns (should_stop, file_count).
"""
if stop_mode == STOP_MODE_FILE_COUNT:
def stop_on_file_count(generated_file_names, cumulative_size=0): # pylint: disable=unused-argument
return abort_event.is_set()
return stop_on_file_count, stop_value
if stop_mode == STOP_MODE_TOTAL_SIZE:
target_bytes = stop_value * 1024 * 1024 # MB to bytes
def stop_on_total_size(generated_file_names, cumulative_size=0): # pylint: disable=unused-argument
if abort_event.is_set():
return True
return cumulative_size >= target_bytes
return stop_on_total_size, MAX_FILE_COUNT
if stop_mode == STOP_MODE_FREE_SPACE:
target_free_pct = stop_value
def stop_on_free_space(generated_file_names, cumulative_size=0): # pylint: disable=unused-argument
if abort_event.is_set():
return True
try:
usage = shutil.disk_usage(output_folder)
except FileNotFoundError:
return False
free_pct = 100.0 * usage.free / usage.total
return free_pct <= target_free_pct
return stop_on_free_space, MAX_FILE_COUNT
raise ValueError(f'Invalid stop_mode {stop_mode}')
def _make_progress_cb(stop_mode, stop_value, output_folder, on_progress):
"""Create a progress callback appropriate for the stop mode.
For file count mode, the fraction from Chaff is already correct.
For size/free-space modes, we use cumulative_size passed from Chaff
or compute from disk usage.
"""
if stop_mode == STOP_MODE_FILE_COUNT:
def progress_by_file_count(fraction, generated_file_names=None, cumulative_size=0): # pylint: disable=unused-argument
on_progress(fraction)
return progress_by_file_count
if stop_mode == STOP_MODE_TOTAL_SIZE:
target_bytes = stop_value * 1024 * 1024
def progress_by_total_size(fraction, generated_file_names=None, cumulative_size=0): # pylint: disable=unused-argument
if cumulative_size > 0:
on_progress(min(1.0, cumulative_size / target_bytes))
else:
on_progress(fraction)
return progress_by_total_size
if stop_mode == STOP_MODE_FREE_SPACE:
target_free_pct = stop_value
# Use list to allow mutation in nested function
initial_free_pct = [None]
def progress_by_free_space(_fraction, generated_file_names=None, cumulative_size=0): # pylint: disable=unused-argument
try:
usage = shutil.disk_usage(output_folder)
except FileNotFoundError:
on_progress(0.0)
return
current_free_pct = 100.0 * usage.free / usage.total
if initial_free_pct[0] is None:
initial_free_pct[0] = current_free_pct
if initial_free_pct[0] > target_free_pct:
frac = (initial_free_pct[0] - current_free_pct) / \
(initial_free_pct[0] - target_free_pct)
frac = min(1.0, max(0.0, frac))
else:
frac = 1.0
on_progress(frac)
return progress_by_free_space
raise ValueError(f'Invalid stop_mode {stop_mode}')
def make_files_thread(stop_mode, stop_value, inspiration, output_folder,
delete_when_finished, on_progress, abort_event):
"""Make files in a separate thread"""
should_stop, file_count = _make_should_stop(
stop_mode, stop_value, output_folder, abort_event)
progress_cb = _make_progress_cb(
stop_mode, stop_value, output_folder, on_progress)
try:
if inspiration == 0:
generated_file_names = generate_2600(
file_count, output_folder, on_progress=progress_cb,
should_stop=should_stop)
elif inspiration == 1:
generated_file_names = generate_emails(
file_count, output_folder, on_progress=progress_cb,
should_stop=should_stop)
else:
raise ValueError(f'Invalid inspiration {inspiration}')
except Exception as exc:
logger.exception('Error generating chaff')
# TRANSLATORS: Error message shown when chaff file generation fails.
# The placeholder is for the technical error details.
error_msg = _("Error generating chaff: {error}").format(error=str(exc))
on_progress(1.0, is_done=True, error=error_msg)
return
try:
if delete_when_finished and not abort_event.is_set():
# TRANSLATORS: Progress message shown while deleting chaff files.
# 'Deleting files' is a present participle.
# To indicate an ongoing operation, include the ellipsis as literal
# Unicode (…) or as Unicode escape (\u2026).
on_progress(0, msg=_('Deleting files\u2026'))
count = len(generated_file_names)
for i, fn in enumerate(generated_file_names):
if abort_event.is_set():
break
os.unlink(fn)
on_progress(1.0 * (i + 1) / count)
except Exception as exc:
logger.exception('Error deleting chaff files')
# TRANSLATORS: Error message shown when deleting chaff files fails.
# The placeholder is for the technical error details.
error_msg = _("Error deleting chaff files: {error}").format(
error=str(exc))
on_progress(1.0, is_done=True, error=error_msg)
return
on_progress(1.0, is_done=True)
class ChaffDialog(InfoBarMixin, Gtk.Dialog):
"""Present the dialog to make chaff"""
_download_success = None
_abort_event = None
thread = None
def __init__(self, parent):
self._make_dialog(parent)
def _make_dialog(self, parent):
"""Make the main dialog"""
# TRANSLATORS: Title for dialog window.
# Digital chaff is like physical chaff that airplanes use to protect themselves
# from radar-guided missiles. For more explanation, see the online documentation.
title = _("Make chaff")
# Keep the code comment below from leaking into the translation hint comment above.
# GTK initialisation is deferred to this method.
# pylint: disable-next=unnecessary-dunder-call
Gtk.Dialog.__init__(self, title=title, transient_for=parent)
Gtk.Dialog.set_modal(self, True)
self.set_border_width(10)
self.set_default_size(400, -1)
self.connect('delete-event', self._on_delete_event)
box = self.get_content_area()
box.set_spacing(10)
# Add InfoBar for non-blocking messages
self._build_infobar(box)
# TRANSLATORS: Label at the top of the chaff dialog
dialog_label = _("Make randomly-generated messages "
"inspired by documents.")
label = Gtk.Label(label=dialog_label)
label.set_line_wrap(True)
label.set_xalign(0)
box.pack_start(label, False, False, 0)
grid = Gtk.Grid()
grid.set_column_spacing(12)
grid.set_row_spacing(8)
box.pack_start(grid, False, False, 0)
# TRANSLATORS: Label for the inspiration combo box.
# 'Inspiration' is a choice of documents from which random text will be generated.
inspiration_label = Gtk.Label(label=_("Inspiration"))
inspiration_label.set_xalign(0)
grid.attach(inspiration_label, 0, 0, 1, 1)
self.inspiration_combo = Gtk.ComboBoxText()
self.inspiration_combo.set_hexpand(True)
self.inspiration_combo_options = (
_('2600 Magazine'), _("Hillary Clinton's emails"))
for combo_option in self.inspiration_combo_options:
self.inspiration_combo.append_text(combo_option)
self.inspiration_combo.set_active(0) # Set default
grid.attach(self.inspiration_combo, 1, 0, 1, 1)
# TRANSLATORS: Label for the combo box that selects when to stop
# generating chaff files.
stop_after_label = Gtk.Label(label=_("Stop after"))
stop_after_label.set_xalign(0)
grid.attach(stop_after_label, 0, 1, 1, 1)
self.stop_mode_combo = Gtk.ComboBoxText()
self.stop_mode_combo.set_hexpand(True)
for mode in (STOP_MODE_FILE_COUNT, STOP_MODE_TOTAL_SIZE, STOP_MODE_FREE_SPACE):
self.stop_mode_combo.append_text(STOP_MODE_LABELS[mode])
self.stop_mode_combo.set_active(STOP_MODE_FILE_COUNT)
self.stop_mode_combo.connect('changed', self._on_stop_mode_changed)
grid.attach(self.stop_mode_combo, 1, 1, 1, 1)
# TRANSLATORS: Label for the spin button that selects the stop value.
self.stop_value_label = Gtk.Label(
label=STOP_MODE_LABELS[self.stop_mode_combo.get_active()])
self.stop_value_label.set_xalign(0)
grid.attach(self.stop_value_label, 0, 2, 1, 1)
self._stop_value_adjustments = {
STOP_MODE_FILE_COUNT: Gtk.Adjustment(
value=100, lower=1, upper=MAX_FILE_COUNT,
step_increment=1, page_increment=1000, page_size=0),
STOP_MODE_TOTAL_SIZE: Gtk.Adjustment(
value=100, lower=1, upper=MAX_MB_COUNT,
step_increment=100, page_increment=1000, page_size=0),
STOP_MODE_FREE_SPACE: Gtk.Adjustment(
value=1, lower=1, upper=MAX_FREE_SPACE_PCT,
step_increment=1, page_increment=10, page_size=0),
}
self.stop_value_spin = Gtk.SpinButton(
adjustment=self._stop_value_adjustments[STOP_MODE_FILE_COUNT])
self.stop_value_spin.set_hexpand(True)
grid.attach(self.stop_value_spin, 1, 2, 1, 1)
folder_label = Gtk.Label(label=SELECT_DEST_FOLDER_MSG)
folder_label.set_xalign(0)
grid.attach(folder_label, 0, 3, 1, 1)
# The file chooser button displays a stock GTK icon. When some parts of GTK are not
# set up correctly on Windows, then the application may crash here with the error
# message "No GSettings schemas".
# https://github.com/bleachbit/bleachbit/issues/1780
self.choose_folder_button = Gtk.FileChooserButton()
self.choose_folder_button.set_action(
Gtk.FileChooserAction.SELECT_FOLDER)
self.choose_folder_button.set_filename(tempfile.gettempdir())
self.choose_folder_button.set_hexpand(True)
grid.attach(self.choose_folder_button, 1, 3, 1, 1)
# TRANSLATORS: Label for the combo box that selects what to do
# when chaff generation is finished.
finished_label = Gtk.Label(label=_("When finished"))
finished_label.set_xalign(0)
grid.attach(finished_label, 0, 4, 1, 1)
self.when_finished_combo = Gtk.ComboBoxText()
self.when_finished_combo.set_hexpand(True)
self.combo_options = (
# TRANSLATORS: Option in combo box to delete generated chaff
# files without shredding them.
_('Delete without shredding'),
# TRANSLATORS: Option in combo box to keep generated chaff files.
_('Do not delete'))
for combo_option in self.combo_options:
self.when_finished_combo.append_text(combo_option)
self.when_finished_combo.set_active(0) # Set default
grid.attach(self.when_finished_combo, 1, 4, 1, 1)
# Do not include choose_folder_button: set_sensitive() on
# Gtk.FileChooserButton can crash on Windows when GSettings is not
# fully configured. The destination is read once at start anyway.
# https://github.com/bleachbit/bleachbit/issues/1780
self._option_widgets = (
self.inspiration_combo,
self.stop_mode_combo,
self.stop_value_spin,
self.when_finished_combo,
)
# Loading indicator for download (hidden by default)
self._download_spinner_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._download_spinner_box.set_halign(Gtk.Align.CENTER)
self._download_spinner = Gtk.Spinner()
self._download_spinner.set_size_request(24, 24)
self._download_spinner_box.pack_start(
self._download_spinner, False, False, 0)
# TRANSLATORS: This is a label in a dialog shown when the user
# clicks the button to download models for chaff generation.
# 'Downloading' is a present participle.
# 'Inspiration content' refers to source documents from which
# random text will be generated.
# To indicate an ongoing operation, include the ellipsis as literal
# Unicode (…) or as Unicode escape (\u2026).
download_label_str = _("Downloading inspiration content\u2026")
self._download_label = Gtk.Label(label=download_label_str)
self._download_spinner_box.pack_start(
self._download_label, False, False, 0)
box.pack_start(self._download_spinner_box, False, False, 0)
self._download_spinner_box.set_no_show_all(True)
self._download_spinner_box.hide()
self.progressbar = Gtk.ProgressBar()
box.pack_start(self.progressbar, False, False, 0)
self.progressbar.hide()
# TRANSLATORS: Button label in a dialog window to start making
# chaff files.
self.make_button = Gtk.Button(label=_("Make files"))
self.make_button.get_style_context().add_class('suggested-action')
self.make_button.connect('clicked', self.on_make_files)
box.pack_start(self.make_button, False, False, 0)
self.abort_button = Gtk.Button(label=ABORT_BUTTON_LABEL)
self.abort_button.get_style_context().add_class('destructive-action')
self.abort_button.connect('clicked', self._on_abort)
box.pack_start(self.abort_button, False, False, 0)
self.abort_button.set_sensitive(False)
self._abort_event = None
def _on_stop_mode_changed(self, combo):
"""Update the value spin button when the stop mode changes"""
mode = combo.get_active()
self.stop_value_label.set_text(STOP_MODE_LABELS[mode])
self.stop_value_spin.set_adjustment(
self._stop_value_adjustments[mode])
def _on_abort(self, _widget):
"""Callback for abort button"""
if self._abort_event:
self._abort_event.set()
def _on_delete_event(self, _widget, _event):
"""Handle dialog close (e.g., X button) by aborting the thread."""
if self._abort_event:
self._abort_event.set()
return False # Allow the dialog to close
def download_models_gui(self, on_complete):
"""Download models in a background thread.
Shows a spinner in the main dialog during download.
Calls on_complete(success) when done, where success is boolean.
"""
self._download_success = None
def on_download_error(msg, msg2):
# Use idle_add to show error from main thread
message = f'{msg}: {msg2}'
GLib.idle_add(
functools.partial(self.show_infobar, message, Gtk.MessageType.ERROR))
def download_models_thread(on_error):
"""Download models in a background thread."""
import bleachbit.Chaff
return bleachbit.Chaff.download_models(on_error=on_error)
def _finish_download(success):
"""Called on main thread when download completes."""
self._download_spinner.stop()
self._download_spinner_box.hide()
self._download_spinner_box.set_no_show_all(True)
self.make_button.set_sensitive(True)
on_complete(success)
return False
def on_thread_complete(success):
"""Callback when download thread completes."""
GLib.idle_add(_finish_download, success)
# Show loading state
self._download_spinner.start()
self._download_spinner_box.set_no_show_all(False)
self._download_spinner_box.show_all()
self.make_button.set_sensitive(False)
# Start download in background thread
def _worker():
success = download_models_thread(on_download_error)
on_thread_complete(success)
thread = threading.Thread(target=_worker)
thread.start()
def on_make_files(self, _widget):
"""Callback for make files button"""
self.infobar.hide()
stop_mode = self.stop_mode_combo.get_active()
stop_value = self.stop_value_spin.get_value_as_int()
output_dir = self.choose_folder_button.get_filename()
delete_when_finished = self.when_finished_combo.get_active() == 0
inspiration = self.inspiration_combo.get_active()
if not output_dir:
self.show_infobar(SELECT_DEST_FOLDER_MSG,
Gtk.MessageType.ERROR)
return
from bleachbit.Chaff import have_models
if not have_models():
# Download models first, then proceed to file generation
def on_download_complete(success):
if success:
self._start_file_generation(
stop_mode, stop_value, inspiration, output_dir, delete_when_finished)
else:
# TRANSLATORS: Error message shown when downloading
# chaff models failed.
self.show_infobar(_("Download failed"),
Gtk.MessageType.ERROR)
self.download_models_gui(on_download_complete)
else:
self._start_file_generation(
stop_mode, stop_value, inspiration, output_dir, delete_when_finished)
def _start_file_generation(self, stop_mode, stop_value, inspiration,
output_dir, delete_when_finished):
"""Start generating files after download is complete."""
self._abort_event = threading.Event()
def _on_progress(fraction, msg, is_done, error=None):
"""Update progress bar from GLib main loop"""
if msg:
self.progressbar.set_text(msg)
self.progressbar.set_fraction(fraction)
if is_done:
self.progressbar.hide()
self.abort_button.set_sensitive(False)
self.make_button.set_sensitive(True)
for widget in self._option_widgets:
widget.set_sensitive(True)
if error:
self.show_infobar(error, Gtk.MessageType.ERROR)
elif self._abort_event and self._abort_event.is_set():
# TRANSLATORS: Notification shown in an infobar when
# chaff file generation is aborted by the user.
self.show_infobar(_("Chaff generation aborted"),
Gtk.MessageType.WARNING)
else:
# TRANSLATORS: Notification shown in an infobar when chaff file generation
# is complete.
self.show_infobar(_("Chaff generation complete"),
Gtk.MessageType.INFO)
def on_progress(fraction, msg=None, is_done=False, error=None):
"""Callback for progress bar"""
# Use idle_add() because threads cannot make GDK calls.
GLib.idle_add(_on_progress, fraction, msg, is_done, error)
# TRANSLATORS: Progress message shown while generating chaff files.
# 'Generating' is a present participle.
# To indicate an ongoing operation, include the ellipsis as literal
# Unicode (…) or as Unicode escape (\u2026).
msg = _('Generating files\u2026')
logger.info(msg)
self.progressbar.show()
self.progressbar.set_text(msg)
self.progressbar.set_show_text(True)
self.progressbar.set_fraction(0.0)
self.make_button.set_sensitive(False)
self.abort_button.set_sensitive(True)
for widget in self._option_widgets:
widget.set_sensitive(False)
args = (stop_mode, stop_value, inspiration, output_dir,
delete_when_finished, on_progress, self._abort_event)
self.thread = threading.Thread(target=make_files_thread, args=args)
self.thread.start()
def run(self):
"""Run the dialog"""
self.show_all()
self.infobar.hide()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiCookie.py 0000664 0001750 0001750 00000033667 15265023030 015166 0 ustar 00z z # 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.
# standard library
import json
import logging
import os
import threading
import time
# third party
from bleachbit.GtkShim import GLib, Gtk
# local import
import bleachbit
from bleachbit import General
from bleachbit.Constant import URL_COOKIE_MGR
from bleachbit.Cookie import list_unique_cookies, load_keep_list, COOKIE_KEEP_LIST_FILENAME
from bleachbit.FileUtilities import open_for_overwrite
from bleachbit.GuiBasic import open_url
from bleachbit.Language import get_text as _, nget_text as _n
logger = logging.getLogger(__name__)
COOKIE_DISCOVERY_WARN_THRESHOLD = 15.0 # seconds
COOKIE_DISCOVERY_DEBUG_THRESHOLD = 5.0 # seconds
class CookieManagerPane(Gtk.Box):
"""Widget for managing cookies to keep when cleaning."""
def __init__(self, bottom_widget=None):
Gtk.Box.__init__(
self, orientation=Gtk.Orientation.VERTICAL, spacing=10)
# Instructions label
instructions = Gtk.Label()
instructions.set_markup(
# TRANSLATORS: Instruction label in the manage cookies dialog.
"" + _("Select the cookies to keep when cleaning cookies across browsers.") + " ")
instructions.set_line_wrap(True)
instructions.set_xalign(0)
self.pack_start(instructions, False, False, 0)
# Notice about supported cookie types
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
notice_label = Gtk.Label()
notice_label.set_markup(
# TRANSLATORS: This is a notice in the cookie manager about supported
# cookie types. The hyperlink points to the documentation website.
# Cookies refer to standard HTTP cookies.
# Site data includes LocalStorage, IndexedDB, and other website storage.
_("Only cookies are preserved. Site data is always deleted. "
"Learn more ").format(
url=URL_COOKIE_MGR))
notice_label.set_line_wrap(True)
notice_label.set_xalign(0)
notice_label.connect(
"activate-link",
lambda _label, url: open_url(url, parent_window=None, prompt=True))
notice_box.pack_start(notice_label, False, False, 0)
self.pack_start(notice_box, False, False, 0)
# Search box
search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
self.pack_start(search_box, False, False, 0)
# TRANSLATORS: Label for the search entry in the manage cookies dialog.
search_label = Gtk.Label(label=_("Search:"))
search_box.pack_start(search_label, False, False, 0)
self.search_entry = Gtk.Entry()
# TRANSLATORS: Placeholder text in the search entry in the manage cookies dialog.
# 'Filter' is a verb meaning to narrow down the list by typing.
self.search_entry.set_placeholder_text(_("Filter cookies..."))
self.search_entry.connect("changed", self.on_search_changed)
search_box.pack_start(self.search_entry, True, True, 0)
# TRANSLATORS: Toggle button label in the manage cookies dialog.
# "Show" is a command (imperative).
# 'Selected' refers to selected cookies (the noun "cookies" is implied).
self.selected_toggle = Gtk.ToggleButton(label=_("Show Selected"))
self.selected_toggle.set_tooltip_text(
# TRANSLATORS: Tooltip for the "Show Selected" toggle button.
_("Only show cookies that are currently selected"))
self.selected_toggle.connect("toggled", self.on_selected_toggle)
search_box.pack_start(self.selected_toggle, False, False, 0)
self.show_selected_only = False
# Create scrollable window for cookie list
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
self.pack_start(scrolled, True, True, 0)
self.keep_list_path = os.path.join(
bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
self.saved_domains = load_keep_list()
self._is_loading = False
# Create cookie list store: checkbox, domain
self.cookie_store = Gtk.ListStore(bool, str)
# Create filter for the list store
self.cookie_filter = self.cookie_store.filter_new()
self.cookie_filter.set_visible_func(self.filter_cookies)
# Create the TreeView
self.treeview = Gtk.TreeView(model=self.cookie_filter)
# Create columns
renderer_toggle = Gtk.CellRendererToggle()
renderer_toggle.connect("toggled", self.on_cell_toggled)
column_toggle = Gtk.TreeViewColumn("", renderer_toggle, active=0)
self.treeview.append_column(column_toggle)
renderer_text = Gtk.CellRendererText()
# TRANSLATORS: Column header in the manage cookies dialog.
# 'Host' is a noun meaning the website hostname (domain name) of the cookie.
column_domain = Gtk.TreeViewColumn(_("Host"), renderer_text, text=1)
column_domain.set_sort_column_id(1)
column_domain.set_resizable(True)
column_domain.set_expand(True)
self.treeview.append_column(column_domain)
scrolled.add(self.treeview)
# Button box
button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
button_box.set_halign(Gtk.Align.END)
self.pack_start(button_box, False, False, 0)
# Stat label
self.stat_label = Gtk.Label()
self.update_stat_label()
button_box.pack_start(self.stat_label, True, True, 0)
# Select all / Deselect all buttons
# TRANSLATORS: Button label in the manage cookies dialog.
self.select_all_btn = Gtk.Button.new_with_label(_("Select All"))
self.select_all_btn.connect("clicked", self.on_select_all_clicked)
button_box.pack_start(self.select_all_btn, False, False, 0)
# TRANSLATORS: Button label in the manage cookies dialog.
self.deselect_all_btn = Gtk.Button.new_with_label(_("Deselect All"))
self.deselect_all_btn.connect("clicked", self.on_deselect_all_clicked)
button_box.pack_start(self.deselect_all_btn, False, False, 0)
if bottom_widget is not None:
self.pack_start(bottom_widget, False, False, 0)
self._populate_cookie_store()
def save_changes(self):
"""Save choices to a JSON file."""
keep_list = sorted(self._iter_selected_domains())
try:
mkfile = not os.path.exists(self.keep_list_path)
with open_for_overwrite(self.keep_list_path, encoding="utf-8") as f:
json.dump(keep_list, f, indent=2)
if mkfile and General.sudo_mode():
General.chownself(self.keep_list_path)
self.saved_domains = set(keep_list)
return True
except OSError as exc:
logger.error("Failed to save cookie keep list %s: %s",
self.keep_list_path, exc)
return False
def update_stat_label(self):
"""Update the stat label: how many selected"""
total = len(self.cookie_store)
selected = sum(1 for row in self.cookie_store if row[0])
visible = sum(1 for _row in self.cookie_filter)
if visible < total:
# TRANSLATORS: %(selected)d is the count of selected cookies,
# %(total)d is the total count, %(visible)d is the visible count
self.stat_label.set_text(
_n("%(selected)d of %(total)d cookie kept (%(visible)d visible)",
"%(selected)d of %(total)d cookies kept (%(visible)d visible)",
selected) % {'selected': selected, 'total': total, 'visible': visible})
else:
# TRANSLATORS: %(selected)d is the count of selected cookies,
# %(total)d is the total count
self.stat_label.set_text(
_n("%(selected)d of %(total)d cookie kept",
"%(selected)d of %(total)d cookies kept",
selected) % {'selected': selected, 'total': total})
def on_cell_toggled(self, _widget, path):
"""Toggle the checkbox in the child model"""
# Convert path from filter model to child model
filter_path = Gtk.TreePath.new_from_string(path)
child_path = self.cookie_filter.convert_path_to_child_path(filter_path)
# Toggle the checkbox in the child model
self.cookie_store[child_path][0] = not self.cookie_store[child_path][0]
self.save_changes()
self.update_stat_label()
def on_select_all_clicked(self, _widget):
"""Select all cookies"""
if self._is_loading:
return
self._set_filtered_selection(True)
self.save_changes()
self.update_stat_label()
def on_deselect_all_clicked(self, _widget):
"""Deselect all cookies"""
if self._is_loading:
return
self._set_filtered_selection(False)
self.save_changes()
self.update_stat_label()
def _set_filtered_selection(self, is_selected):
"""Set selection state only for rows visible in the current filter."""
# Collect paths first to prevent iterator invalidation when rows disappear
# from the filter (e.g., deselecting while 'Show Selected' is active).
paths = []
tree_iter = self.cookie_filter.get_iter_first()
while tree_iter:
child_iter = self.cookie_filter.convert_iter_to_child_iter(
tree_iter)
if child_iter:
paths.append(self.cookie_store.get_path(child_iter))
tree_iter = self.cookie_filter.iter_next(tree_iter)
for path in paths:
self.cookie_store[path][0] = is_selected
def filter_cookies(self, model, tree_iter, _data):
"""Filter function for the cookie list"""
search_text = self.search_entry.get_text().lower()
if not search_text:
matches_search = True
else:
domain = model[tree_iter][1].lower()
matches_search = search_text in domain
if not matches_search:
return False
if self.show_selected_only:
return bool(model[tree_iter][0])
return True
def on_selected_toggle(self, widget):
"""Toggle whether only selected cookies should be visible."""
self.show_selected_only = widget.get_active()
self.cookie_filter.refilter()
self.update_stat_label()
def on_search_changed(self, _widget):
"""Called when the search text changes"""
self.cookie_filter.refilter()
self.update_stat_label()
def _populate_cookie_store(self):
"""Populate the list store with discovered and saved cookie hosts."""
self._is_loading = True
self._spinner = Gtk.Spinner()
self._spinner.set_size_request(24, 24)
self._spinner.start()
# Insert spinner row placeholder so the button_box area is not empty
# while loading; we embed the spinner above the treeview instead.
self.treeview.set_sensitive(False)
self.select_all_btn.set_sensitive(False)
self.deselect_all_btn.set_sensitive(False)
# Find the scrolled window's parent vbox and insert a spinner overlay
scrolled = self.treeview.get_parent()
vbox = scrolled.get_parent()
spinner_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
spinner_box.set_halign(Gtk.Align.CENTER)
spinner_box.pack_start(self._spinner, False, False, 0)
# TRANSLATORS: Status message shown while the cookie list is loading
# in the cookie manager dialog window.
# "Loading" is a present participle (ongoing action).
# To indicate an ongoing operation, include the ellipsis as literal
# Unicode (…) or as Unicode escape (\u2026).
self._loading_label = Gtk.Label(label=_("Loading cookies\u2026"))
spinner_box.pack_start(self._loading_label, False, False, 0)
vbox.pack_start(spinner_box, False, False, 0)
vbox.reorder_child(spinner_box, vbox.get_children().index(scrolled))
self._spinner_box = spinner_box
self.show_all()
def _worker():
start = time.monotonic()
discovered = []
try:
discovered = list_unique_cookies()
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover - defensive logging
logger.error("Failed to enumerate cookies: %s", exc)
duration = time.monotonic() - start
if duration >= COOKIE_DISCOVERY_WARN_THRESHOLD:
logger.warning("Enumerating cookie hosts took %.2fs", duration)
elif duration >= COOKIE_DISCOVERY_DEBUG_THRESHOLD:
logger.debug("Enumerating cookie hosts took %.2fs", duration)
GLib.idle_add(self._finish_populate, discovered)
t = threading.Thread(target=_worker, daemon=True)
t.start()
def _finish_populate(self, discovered):
"""Called on the GTK main thread after cookie discovery finishes."""
self._is_loading = False
self._spinner.stop()
self._spinner_box.destroy()
self.treeview.set_sensitive(True)
self.select_all_btn.set_sensitive(True)
self.deselect_all_btn.set_sensitive(True)
all_hosts = {h.strip() for h in discovered if h}
all_hosts.update(self.saved_domains)
sorted_hosts = sorted(all_hosts, key=lambda host: host.lower())
for host in sorted_hosts:
is_saved = host in self.saved_domains
self.cookie_store.append([is_saved, host])
self.update_stat_label()
return False
def _iter_selected_domains(self):
"""Yield domain names for all selected (checked) cookies."""
for row in self.cookie_store:
if row[0]:
yield row[1]
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiInfoBar.py 0000664 0001750 0001750 00000004611 15265023030 015260 0 ustar 00z z # 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.
"""
Non-blocking message bar shared by the main window and the dialogs
"""
from bleachbit.GtkShim import Gtk, GLib
# Seconds a message stays up before it dismisses itself
INFOBAR_TIMEOUT = 15
class InfoBarMixin:
"""Add a self-dismissing Gtk.InfoBar to a window or dialog.
The host calls _build_infobar() once with the container to pack into,
then show_infobar() to display a message. `infobar` and `infobar_label`
stay plain widgets so callers (and tests) can query them directly.
"""
_infobar_timeout_id = None
def _build_infobar(self, container):
"""Create the InfoBar and pack it into container"""
self.infobar = Gtk.InfoBar()
self.infobar.set_show_close_button(True)
self.infobar.connect('response', self._on_infobar_response)
self.infobar_label = Gtk.Label()
self.infobar_label.set_line_wrap(True)
self.infobar.get_content_area().add(self.infobar_label)
container.pack_start(self.infobar, False, False, 0)
self._infobar_timeout_id = None
def _cancel_infobar_timeout(self):
"""Drop the pending auto-dismiss, if any"""
if self._infobar_timeout_id:
GLib.source_remove(self._infobar_timeout_id)
self._infobar_timeout_id = None
def _on_infobar_response(self, _infobar, _response_id):
"""Handle InfoBar close button click"""
self._cancel_infobar_timeout()
self.infobar.hide()
def _hide_infobar(self):
"""Hide the InfoBar (used for auto-dismiss timeout)"""
self._infobar_timeout_id = None
self.infobar.hide()
return False # Remove from GLib timeout
def show_infobar(self, message, message_type=Gtk.MessageType.ERROR):
"""Show a non-blocking InfoBar message that auto-dismisses
Args:
message: The message to display
message_type: Gtk.MessageType (ERROR, WARNING, INFO, etc.)
"""
self._cancel_infobar_timeout()
self.infobar_label.set_text(message)
self.infobar.set_message_type(message_type)
self.infobar.show_all()
self._infobar_timeout_id = GLib.timeout_add_seconds(
INFOBAR_TIMEOUT, self._hide_infobar)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiPreferences.py 0000664 0001750 0001750 00000126461 15265023030 016211 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Preferences dialog
"""
# standard imports
import logging
# first party imports
from bleachbit import GuiBasic, ProtectedPath, IS_POSIX, IS_WINDOWS
from bleachbit import online_update_notification_enabled
from bleachbit.PathUtils import normalize_path
from bleachbit.Constant import EMPTY_SPACE_WARNING, REQUIRES_EXPERT_MODE
from bleachbit.General import sanitize_surrogates
from bleachbit.GtkShim import Gtk, GLib
from bleachbit.GuiInfoBar import InfoBarMixin
from bleachbit.GuiCookie import CookieManagerPane
from bleachbit.GuiUtil import (detect_dark_background, flush_gtk_events,
load_icon_or_fallback,
should_show_dark_mode_warning)
from bleachbit.Language import (find_supported_language_code,
get_active_language_code,
get_supported_language_code_name_dict,
setup_translation)
from bleachbit.Language import get_text as _, pget_text as _p
from bleachbit.Options import options
logger = logging.getLogger(__name__)
LOCATIONS_WHITELIST = 1
LOCATIONS_CUSTOM = 2
# TRANSLATORS: Shown as a tooltip in the preferences window and as
# a description in a confirmation dialog.
EXPERT_MODE_DESCRIPTION = _(
'Expert mode enables advanced features and relaxes guardrails. '
'Use extra caution in expert mode.')
# TRANSLATORS: Used both as (1) a checkbox label in the preferences dialog
# to bypass some safety guardails for advanced users and (2) the title of
# dialog asking to confirm this choice.
EXPERT_MODE_MSG = _('Expert mode')
# TRANSLATORS: Notice shown in an infobar after changing a preference
# that requires starting the application to be effective.
RESTART_APP_MSG = _("Restart BleachBit for full effect.")
class PreferencesDialog(InfoBarMixin):
"""Present the preferences dialog and save changes"""
def __init__(self, parent, cb_refresh_operations, cb_set_windows10_theme):
self.cb_refresh_operations = cb_refresh_operations
self.cb_set_windows10_theme = cb_set_windows10_theme
self.parent = parent
# TRANSLATORS: Title for the preferences dialog
self.dialog = Gtk.Dialog(title=_("Preferences"),
transient_for=parent,
modal=True,
destroy_with_parent=True)
self.dialog.set_default_size(760, 520)
self._locations_notice_css_provider = None
self._default_options_box = None
self._cookie_page_loaded = False
self._cookie_page_container = None
# Add InfoBar for non-blocking messages
self._build_infobar(self.dialog.get_content_area())
content_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=18)
content_box.set_border_width(12)
self.page_stack = Gtk.Stack()
self.page_stack.set_transition_type(Gtk.StackTransitionType.NONE)
self.page_stack.set_hhomogeneous(False)
self.page_stack.set_vhomogeneous(False)
self.page_stack.connect('notify::visible-child-name',
self.__on_stack_page_changed)
sidebar = Gtk.StackSidebar()
sidebar.set_stack(self.page_stack)
sidebar.set_vexpand(True)
sidebar.set_margin_end(6)
pages = [
# TRANSLATORS: Sidebar label for the general settings page of the preferences dialog.
(self.__general_page, 'general', _("General"), True),
# TRANSLATORS: Sidebar label for the software updates page of the preferences dialog.
(self.__updates_page, 'updates', _("Updates"),
online_update_notification_enabled),
# TRANSLATORS: Sidebar label for the "Languages" page in the Preferences dialog.
(self.__languages_page, 'languages', _("Languages"), True),
# TRANSLATORS: Sidebar label for the page managing browser cookies in
# the Preferences dialog.
(self.__cookies_page, 'cookies', _("Cookies"), True),
(lambda: self.__locations_page(LOCATIONS_WHITELIST),
# TRANSLATORS: Sidebar label for the "keep list" (whitelist) page of
# the preferences dialog. This page lists paths that will be preserved
# (not deleted) during cleaning.
'keep-list', _("Keep list"), True),
(lambda: self.__locations_page(LOCATIONS_CUSTOM),
# TRANSLATORS: Sidebar label for the page where users add their own paths to clean.
# Short label meaning "Custom locations".
'custom', _("Custom"), True),
# TRANSLATORS: Sidebar label for the drives page of the preferences dialog.
(self.__drives_page, 'drives', _("Drives"), True),
]
for page_func, page_id, page_title, condition in pages:
if condition:
self.page_stack.add_titled(page_func(), page_id, page_title)
content_box.pack_start(sidebar, False, False, 0)
content_box.pack_start(self.page_stack, True, True, 0)
# pack_start parameters: child, expand (reserve space), fill (actually fill it), padding
self.dialog.get_content_area().pack_start(content_box, True, True, 0)
self.dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
self.refresh_operations = False
def select_page(self, page_name):
"""Show the requested preferences page."""
if not page_name:
return
self.page_stack.set_visible_child_name(page_name)
if page_name == 'cookies':
self.__ensure_cookie_page()
def _create_checkbox(self, label, option_id, vbox=None, tooltip=None,
requires_option=None, store_as_attr=None):
"""Helper to create a checkbox with common patterns.
Args:
label: The checkbox label text
option_id: The option ID to connect to
vbox: Optional vbox to pack the checkbox into
tooltip: Optional tooltip text
requires_option: Optional option ID that must be enabled for sensitivity
store_as_attr: Optional attribute name to store checkbox as self.{name}
Returns:
The created Gtk.CheckButton
"""
cb = Gtk.CheckButton(label=label)
cb.set_active(options.get(option_id))
cb.connect('toggled', self.__toggle_callback, option_id)
if tooltip:
cb.set_tooltip_text(tooltip)
if requires_option is not None and not options.get(requires_option):
cb.set_sensitive(False)
elif options.has_override(option_id):
cb.set_sensitive(False)
if store_as_attr:
setattr(self, store_as_attr, cb)
if vbox is None:
vbox = self._default_options_box
vbox.pack_start(cb, False, True, 0)
return cb
def __on_expert_mode_toggled(self, cb):
"""Callback for expert mode checkbox"""
new_value = cb.get_active()
if new_value:
from bleachbit.GuiBasic import warning_confirm_dialog
confirmed, _remember_choice = warning_confirm_dialog(
self.dialog,
EXPERT_MODE_MSG,
EXPERT_MODE_DESCRIPTION,
show_checkbox=False
)
if not confirmed:
cb.set_active(False)
return
options.set('expert_mode', new_value)
self.reset_warnings_button.set_sensitive(new_value)
if hasattr(self, 'cb_delete_confirmation'):
self.cb_delete_confirmation.set_sensitive(new_value)
if hasattr(self, 'cb_refresh_operations') and self.cb_refresh_operations:
self.refresh_operations = True
def __toggle_callback(self, _cell, path):
"""Callback function to toggle option"""
options.toggle(path)
if online_update_notification_enabled:
self.cb_beta.set_sensitive(options.get('check_online_updates'))
if IS_WINDOWS:
self.cb_winapp2.set_sensitive(
options.get('check_online_updates'))
if 'auto_hide' == path:
self.refresh_operations = True
if 'dark_mode' == path:
logger.debug("Toggling dark mode to %s", options.get('dark_mode'))
theme_widget = self.parent or self.dialog
before_dark = detect_dark_background(theme_widget)
if IS_WINDOWS and options.get('win10_theme'):
self.cb_set_windows10_theme()
settings = self.dialog.get_settings()
if settings:
settings.set_property(
'gtk-application-prefer-dark-theme', options.get('dark_mode'))
else:
logger.warning("Could not get GTK settings to apply dark mode")
flush_gtk_events()
after_dark = detect_dark_background(theme_widget)
if not IS_WINDOWS and should_show_dark_mode_warning(before_dark, after_dark):
self.show_infobar(
# TRANSLATORS: Notice shown in an infobar when toggling
# dark mode on Linux.
_("Some GTK themes do not support both light and dark modes."),
Gtk.MessageType.WARNING)
if 'win10_theme' == path:
self.cb_set_windows10_theme()
if 'debug' == path:
from bleachbit.Log import set_root_log_level
set_root_log_level(options.get('debug'))
if 'kde_shred_menu_option' == path:
from bleachbit.DesktopMenuOptions import install_kde_service_menu_file
install_kde_service_menu_file()
if 'use_fontconfig_backend' == path:
self.show_infobar(RESTART_APP_MSG, Gtk.MessageType.INFO)
def __reset_warning_preferences(self, _button):
"""Reset saved warning confirmations."""
options.clear_warning_preferences()
self.show_infobar(
# TRANSLATORS: Success message shown in the infobar.
_("Warning confirmations reset."),
Gtk.MessageType.INFO)
def __create_update_widgets(self, vbox):
"""Create and configure update-related checkboxes."""
if not online_update_notification_enabled:
return
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
_("Check periodically for software updates via the Internet"),
'check_online_updates',
vbox=vbox,
# TRANSLATORS: Tooltip for the online updates checkbox in the preferences dialog.
tooltip=_("If an update is found, you will be given the option to view information about it. Then, you may manually download and install the update."))
updates_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
updates_box.set_margin_start(18)
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
_("Check for new beta releases"),
'check_beta',
vbox=updates_box,
requires_option='check_online_updates',
store_as_attr='cb_beta')
if IS_WINDOWS:
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
# Winapp2.ini is a set of cleaning rules from this project:
# https://github.com/MoscaDotTo/Winapp2
_("Download and update cleaners from community (winapp2.ini)"),
'update_winapp2',
vbox=updates_box,
requires_option='check_online_updates',
store_as_attr='cb_winapp2')
vbox.pack_start(updates_box, False, True, 0)
def __create_language_widgets(self, vbox, supported_langs):
"""Create and configure language selection widgets.
supported_langs maps language code to native name, or is None if
the scan failed.
"""
lang_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
is_auto_detect = options.get("auto_detect_lang")
# TRANSLATORS: Checkbutton label in the preferences dialog.
self.cb_auto_lang = Gtk.CheckButton(label=_("Auto-detect language"))
self.cb_auto_lang.set_active(is_auto_detect)
self.cb_auto_lang.set_tooltip_text(
# TRANSLATORS: Tooltip explaining the auto-detect language option.
_("Automatically detect the system language"))
lang_box.pack_start(self.cb_auto_lang, False, True, 0)
self.lang_select_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# TRANSLATORS: Label for the language selection dropdown.
self.lang_label = Gtk.Label(label=_("Language:"))
self.lang_label.set_margin_start(20) # Add some indentation
self.lang_select_box.pack_start(self.lang_label, False, True, 5)
self.lang_combo = Gtk.ComboBoxText()
# Add available languages
if supported_langs is None:
lang_items = [('en_US', 'English')]
else:
lang_items = supported_langs.items()
self.lang_codes = []
for lang_code, native in lang_items:
self.lang_codes.append(lang_code)
if native:
self.lang_combo.append_text(f"{native} ({lang_code})")
else:
self.lang_combo.append_text(lang_code)
# set_wrap_width() prevents infinite space to scroll up.
# https://github.com/bleachbit/bleachbit/issues/1764
self.lang_combo.set_wrap_width(1)
self.lang_select_box.pack_start(self.lang_combo, False, True, 0)
lang_box.pack_start(self.lang_select_box, False, True, 0)
vbox.pack_start(lang_box, False, True, 0)
self.cb_auto_lang.connect('toggled', self.on_auto_detect_toggled)
self.lang_combo.connect('changed', self.on_lang_changed)
self.__select_supported_language(get_active_language_code())
self.__set_language_widgets_sensitive(not is_auto_detect)
def __select_supported_language(self, lang_code):
"""Select the best matching supported language in the combobox.
The 'changed' signal is blocked, so the forced_language option
is not changed. Returns the selected language code, or None if
there is no matching entry.
"""
matched = find_supported_language_code(lang_code, self.lang_codes)
if matched is None:
matched = find_supported_language_code('en', self.lang_codes)
if matched is None:
return None
self.lang_combo.handler_block_by_func(self.on_lang_changed)
self.lang_combo.set_active(self.lang_codes.index(matched))
self.lang_combo.handler_unblock_by_func(self.on_lang_changed)
return matched
def __set_language_widgets_sensitive(self, sensitive):
"""Enable or disable the language label and dropdown together."""
self.lang_select_box.set_sensitive(sensitive)
self.lang_label.set_sensitive(sensitive)
self.lang_combo.set_sensitive(sensitive)
def on_lang_changed(self, widget):
"""Callback for when the language combobox is changed."""
text = widget.get_active_text()
if text is None:
return
# Extract language code from the format "Native Name (lang_code)"
lang_code = text.split("(")[-1].rstrip(")")
if lang_code:
options.set("forced_language", lang_code, section="bleachbit")
else:
logger.warning(
"No language code found in combobox for text %s", text)
setup_translation()
self.refresh_operations = True
self.show_infobar(RESTART_APP_MSG, Gtk.MessageType.INFO)
def on_auto_detect_toggled(self, _widget):
"""Callback for when the auto-detect language checkbox is toggled."""
self.__toggle_callback(None, 'auto_detect_lang')
is_auto_detect = options.get("auto_detect_lang")
self.__set_language_widgets_sensitive(not is_auto_detect)
# Clear first so detection below sees the system locale rather
# than a stale forced value.
options.set("forced_language", "", section="bleachbit")
# Show the detected language when auto-detect is enabled, or
# keep it as the default when auto-detect is disabled.
# https://github.com/bleachbit/bleachbit/issues/1800
matched = self.__select_supported_language(
get_active_language_code())
if matched and not is_auto_detect:
# Keep the detected language as the default now that it is
# no longer automatically detected. Save the same code the
# dropdown shows (including its fallback) so the displayed
# and saved languages cannot disagree.
# https://github.com/bleachbit/bleachbit/issues/1799
options.set("forced_language", matched, section="bleachbit")
setup_translation()
self.refresh_operations = True
self.show_infobar(RESTART_APP_MSG, Gtk.MessageType.INFO)
def __create_general_checkboxes(self, vbox):
"""Create and configure general checkboxes."""
# TRANSLATORS: Overwriting is the same as shredding. It is a way
# to prevent recovery of the data. You could also translate
# 'Shred files to prevent recovery.'
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
_("Overwrite contents of files to prevent recovery"),
'shred',
vbox=vbox,
# TRANSLATORS: Tooltip for the shred checkbox in the preferences dialog.
tooltip=_("Overwriting is ineffective on some file systems and with certain BleachBit operations. Overwriting is significantly slower."))
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
_("Exit after cleaning"),
'exit_done',
vbox=vbox)
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
# Ask for confirmation before deleting items.
_("Confirm before delete"),
'delete_confirmation',
vbox=vbox,
tooltip=REQUIRES_EXPERT_MODE,
requires_option='expert_mode',
store_as_attr='cb_delete_confirmation')
self.reset_warnings_button = Gtk.Button.new_with_label(
# TRANSLATORS: Button label in the preferences. 'Reset' is a verb.
label=_("Reset warning confirmations"))
self.reset_warnings_button.set_halign(Gtk.Align.START)
self.reset_warnings_button.set_margin_top(6)
self.reset_warnings_button.set_tooltip_text(REQUIRES_EXPERT_MODE)
self.reset_warnings_button.connect(
'clicked', self.__reset_warning_preferences)
self.reset_warnings_button.set_sensitive(options.get('expert_mode'))
vbox.pack_start(self.reset_warnings_button, False, True, 0)
self._create_checkbox(
# TRANSLATORS: This means to hide cleaners which would do
# nothing. For example, if Firefox were never used on
# this system, this option would hide Firefox to simplify
# the list of cleaners.
_("Hide irrelevant cleaners"),
'auto_hide',
vbox=vbox)
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences.
_("Use IEC sizes (1 KiB = 1024 bytes) instead of SI (1 kB = 1000 bytes)"),
"units_iec",
vbox=vbox)
def __create_page_box(self):
"""Create a standard page container box with consistent spacing and padding."""
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
vbox.set_border_width(12)
return vbox
def __create_section(self, parent, title):
"""Create a titled section with a bold heading and indented body."""
section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
heading = Gtk.Label()
heading.set_markup('%s ' % GLib.markup_escape_text(title))
heading.set_xalign(0)
section.pack_start(heading, False, False, 0)
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
body.set_margin_start(12)
section.pack_start(body, False, False, 0)
parent.pack_start(section, False, False, 0)
return body
def __updates_page(self):
"""Return widget containing the updates page."""
page = self.__create_page_box()
# TRANSLATORS: Section title on the preferences updates page.
updates_box = self.__create_section(page, _("Update notifications"))
self.__create_update_widgets(updates_box)
return page
def __cookies_page(self):
"""Return widget containing the cookies page (lazy-loaded)."""
page = self.__create_page_box()
self._cookie_page_container = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=0)
page.pack_start(self._cookie_page_container, True, True, 0)
return page
def __ensure_cookie_page(self):
"""Lazy-load the cookie manager pane when first accessed."""
if self._cookie_page_loaded:
return
cookie_page = CookieManagerPane()
cookie_page.set_border_width(12)
self._cookie_page_container.pack_start(cookie_page, True, True, 0)
self._cookie_page_container.show_all()
self._cookie_page_loaded = True
def __on_stack_page_changed(self, stack, _param):
"""Callback when the visible page in the stack changes."""
if stack.get_visible_child_name() == 'cookies':
self.__ensure_cookie_page()
def __general_page(self):
"""Return a widget containing the general page"""
page = self.__create_page_box()
# TRANSLATORS: Section title (noun) on the Preferences - General page
# for options related to cleaning stored data.
cleaning_box = self.__create_section(page, _("Cleaning"))
self._default_options_box = cleaning_box
self.__create_general_checkboxes(cleaning_box)
# TRANSLATORS: Section title in Preferences for user interface options
# such as dark mode and window behavior.
interface_box = self.__create_section(page, _("Interface"))
self._create_checkbox(
# TRANSLATORS: Checkbox label to remember the window size and position
# between application launches.
_("Remember window geometry"),
'remember_geometry',
vbox=interface_box)
self._create_checkbox(
# TRANSLATORS: Checkbox label to enable dark mode in the UI.
_("Dark mode"),
'dark_mode',
vbox=interface_box)
if IS_WINDOWS:
self._create_checkbox(
# TRANSLATORS: Checkbox label to use the Windows 10-style visual theme
# for the application interface.
_("Windows 10 theme"),
'win10_theme',
vbox=interface_box)
self._create_checkbox(
# TRANSLATORS: Checkbox label to use the Fontconfig text rendering backend
# instead of the default Windows renderer. The option may improve blurry text.
# Fontconfig, which is software, is a proper noun.
_("Use Fontconfig text rendering backend"),
'use_fontconfig_backend',
vbox=interface_box,
# TRANSLATORS: Tooltip for the fontconfig text rendering checkbox in the preferences dialog.
tooltip=_("May fix blurry or unreadable text. Requires restart."))
integration_box = self.__create_section(
# TRANSLATORS: Section title on the preferences general page,
# labelling options for OS integration and advanced/developer features.
page, _("Integration and advanced"))
if not IS_WINDOWS:
self._create_checkbox(
# TRANSLATORS: Checkbox label in the preferences dialog.
# 'Shred' means securely delete a file to prevent data recovery.
# 'Context menu' is the menu shown on right-click.
# 'KDE Plasma' is a Linux desktop environment (proper noun, do not translate).
_("Add the shred context menu to KDE Plasma"),
'kde_shred_menu_option',
vbox=integration_box)
self._create_checkbox(
# TRANSLATORS: Checkbox label to show diagnostic log messages for
# troubleshooting.
_("Show debug messages"),
'debug',
vbox=integration_box)
self._create_checkbox(
EXPERT_MODE_MSG,
'expert_mode',
vbox=integration_box,
tooltip=EXPERT_MODE_DESCRIPTION,
store_as_attr='cb_expert')
self.cb_expert.connect('toggled', self.__on_expert_mode_toggled)
return page
def __drives_page(self):
"""Return widget containing the drives page"""
def add_drive_cb(button):
"""Callback for adding a drive"""
# TRANSLATORS: Title of a folder chooser dialog.
title = _("Choose a folder")
pathname = GuiBasic.browse_folder(
self.parent, title, multiple=False, stock_button=Gtk.STOCK_ADD)
if pathname:
liststore.append([pathname])
pathnames.append(pathname)
options.set_list('shred_drives', pathnames)
def remove_drive_cb(button):
"""Callback for removing a drive"""
treeselection = treeview.get_selection()
(model, _iter) = treeselection.get_selected()
if _iter is None:
# nothing selected
return
pathname = model[_iter][0]
liststore.remove(_iter)
pathnames.remove(pathname)
options.set_list('shred_drives', pathnames)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
vbox.set_border_width(12)
# TRANSLATORS: 'empty' means 'unallocated'.
# The user should decide which drives to wipe. Then, for each
# chosen drive, the user should specify one folder per drive.
# For example, to wipe C:\, the user may choose %TEMP% which
# is a folder anywhere on C:.
drive_instruction_label = _("Choose a writable folder for each drive for "
"which to wipe empty space.")
notice = Gtk.Label(label=drive_instruction_label)
notice.set_line_wrap(True)
notice.set_xalign(0.0)
notice.set_margin_start(12)
notice.set_margin_end(12)
vbox.pack_start(notice, False, True, 0)
notice2 = Gtk.Label(label=EMPTY_SPACE_WARNING)
notice2.set_line_wrap(True)
notice2.set_xalign(0.0)
notice2.set_margin_start(12)
notice2.set_margin_end(12)
vbox.pack_start(notice2, False, True, 0)
liststore = Gtk.ListStore(str)
pathnames = sorted(options.get_list('shred_drives') or [])
for pathname in pathnames:
liststore.append([pathname])
treeview = Gtk.TreeView.new_with_model(liststore)
crt = Gtk.CellRendererText()
tvc = Gtk.TreeViewColumn(None, crt, text=0)
treeview.append_column(tvc)
vbox.pack_start(treeview, True, True, 0)
# TRANSLATORS: In the preferences dialog, this button adds a path to
# the list of paths
button_add = Gtk.Button.new_with_label(label=_p('button', 'Add'))
button_add.connect("clicked", add_drive_cb)
# TRANSLATORS: In the preferences dialog, this button removes a path
# from the list of paths
button_remove = Gtk.Button.new_with_label(label=_p('button', 'Remove'))
button_remove.connect("clicked", remove_drive_cb)
button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
button_box.set_layout(Gtk.ButtonBoxStyle.START)
button_box.pack_start(button_add, True, True, 0)
button_box.pack_start(button_remove, True, True, 0)
vbox.pack_start(button_box, False, True, 0)
return vbox
def __languages_page(self):
"""Return widget containing the languages page"""
def keep_lang_toggled_cb(cell, path, liststore):
"""Callback for toggling the 'keep' column"""
__iter = liststore.get_iter_from_string(path)
value = not liststore.get_value(__iter, 0)
liststore.set(__iter, 0, value)
langid = liststore[path][1]
options.set_language(langid, value)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
vbox.set_border_width(12)
ui_language_box = self.__create_section(
vbox,
# TRANSLATORS: Section title on the preferences languages page.
_("BleachBit interface language"))
try:
supported_langs = get_supported_language_code_name_dict()
except Exception as e:
logger.error("Failed to get list of supported languages: %s", e)
supported_langs = None
self.__create_language_widgets(ui_language_box, supported_langs)
# Windows does not have locale cleaner.
if not IS_POSIX:
return vbox
cleanup_box = self.__create_section(
vbox,
# TRANSLATORS: Section title on the preferences languages page.
_("Language files to keep when cleaning applications"))
# populate data
liststore = Gtk.ListStore('gboolean', str, str)
if supported_langs is None:
# There is no English fallback here, so let the failure propagate.
supported_langs = get_supported_language_code_name_dict()
for lang, native in supported_langs.items():
liststore.append([(options.get_language(lang)), lang, native])
# create treeview
treeview = Gtk.TreeView.new_with_model(liststore)
# create column views
self.renderer0 = Gtk.CellRendererToggle()
self.renderer0.set_property('activatable', True)
self.renderer0.connect('toggled', keep_lang_toggled_cb, liststore)
# TRANSLATORS: Column header in the languages treeview.
# This column controls whether to keep the language.
self.column0 = Gtk.TreeViewColumn(_("Keep"),
self.renderer0, active=0)
treeview.append_column(self.column0)
self.renderer1 = Gtk.CellRendererText()
# TRANSLATORS: Column header in the languages treeview showing the
# language code (e.g., 'en_US').
self.column1 = Gtk.TreeViewColumn(_("Code"), self.renderer1, text=1)
treeview.append_column(self.column1)
self.renderer2 = Gtk.CellRendererText()
# TRANSLATORS: Column header in the languages treeview showing the
# native name of the language.
self.column2 = Gtk.TreeViewColumn(_("Name"), self.renderer2, text=2)
treeview.append_column(self.column2)
treeview.set_search_column(2)
# finish
swindow = Gtk.ScrolledWindow()
swindow.set_overlay_scrolling(False)
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.set_size_request(300, 200)
swindow.add(treeview)
cleanup_box.pack_start(swindow, True, True, 0)
return vbox
def _check_path_exists(self, pathname):
"""Check if a path exists in either keep lists or custom lists
Returns True if path exists, False otherwise"""
whitelist_paths = options.get_whitelist_paths()
custom_paths = options.get_custom_paths()
# Check in whitelist
for path in whitelist_paths:
if pathname != path[1]:
continue
# TRANSLATORS: Error message shown in the infobar.
msg = _("This path already exists in the keep list.")
self.show_infobar(msg, Gtk.MessageType.ERROR)
return True
# Check in custom
for path in custom_paths:
if pathname != path[1]:
continue
# TRANSLATORS: Error message shown in the infobar.
msg = _("This path already exists in the custom list.")
self.show_infobar(msg, Gtk.MessageType.ERROR)
return True
return False
def _check_protected_path(self, pathname):
"""Check if path is protected and warn user if so.
Returns True if it's safe to proceed, False if user cancelled.
"""
logger.debug("Checking protected path: %s", pathname)
match_info = ProtectedPath.check_protected_path(pathname)
if match_info is None:
return True
if not options.get('expert_mode'):
self.show_infobar(
# TRANSLATORS: Error message shown in the infobar.
_("This path is protected. To bypass protection, enable expert mode."),
Gtk.MessageType.WARNING)
return False
# Check if user already confirmed this path
normalized = normalize_path(
pathname, case_sensitive=match_info['case_sensitive'])
warning_key = 'protected_path:' + normalized
if options.get_warning_preference(warning_key):
return True
# Calculate impact
# FIXME later: this can be very slow with many objects
logger.debug("Checking protected path impact: %s", pathname)
impact = ProtectedPath.calculate_impact(pathname)
# Generate warning message
warning_msg = ProtectedPath.get_warning_message(pathname, impact)
# Show warning dialog
confirmed, remember = GuiBasic.warning_confirm_dialog(
self.dialog,
# TRANSLATORS: Title of warning dialog shown when adding a protected path.
_("Protected Path"),
warning_msg
)
if confirmed and remember:
options.remember_warning_preference(warning_key)
return confirmed
def _add_path(self, pathname, path_type, page_type, liststore, pathnames):
"""Common function to add a path to either whitelist or custom list"""
if self._check_path_exists(pathname):
return
# Check for protected paths when adding to custom (delete) list
if page_type == LOCATIONS_CUSTOM:
if not self._check_protected_path(pathname):
return
# TRANSLATORS: Noun used as a column header in the preferences dialog.
type_str_file = _('File')
# TRANSLATORS: Noun used as a column header in the preferences dialog.
type_str_folder = _('Folder')
type_str = type_str_file if path_type == 'file' else type_str_folder
display_path = sanitize_surrogates(pathname)
liststore.append([type_str, display_path])
pathnames.append([path_type, pathname])
if page_type == LOCATIONS_WHITELIST:
options.set_whitelist_paths(pathnames)
else:
options.set_custom_paths(pathnames)
# TRANSLATORS: %s is a file or folder path that was just added
self.show_infobar(_("Added: %s") % display_path,
Gtk.MessageType.INFO)
def _remove_path(self, treeview, liststore, pathnames, page_type):
"""Common function to remove a path from either whitelist or custom list"""
treeselection = treeview.get_selection()
(model, _iter) = treeselection.get_selected()
if _iter is None:
return
tree_path = model.get_path(_iter)
row_index = tree_path.get_indices()[0]
display_path = model[_iter][1]
liststore.remove(_iter)
pathnames.pop(row_index)
if page_type == LOCATIONS_WHITELIST:
options.set_whitelist_paths(pathnames)
else:
options.set_custom_paths(pathnames)
# TRANSLATORS: %s is a file or folder path that was just removed
self.show_infobar(_("Removed: %s") % display_path,
Gtk.MessageType.INFO)
def __locations_page(self, page_type):
"""Return a widget containing a list of files and folders"""
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
vbox.set_border_width(12)
# load data
pathnames = []
if LOCATIONS_WHITELIST == page_type:
pathnames = options.get_whitelist_paths()
elif LOCATIONS_CUSTOM == page_type:
pathnames = options.get_custom_paths()
else:
raise RuntimeError("Invalid page type: '%s'" % page_type)
liststore = Gtk.ListStore(str, str)
for paths in pathnames:
type_code = paths[0]
type_str = None
if type_code == 'file':
type_str = _('File')
elif type_code == 'folder':
type_str = _('Folder')
else:
raise RuntimeError("Invalid type code: '%s'" % type_code)
path = paths[1]
display_path = sanitize_surrogates(path)
liststore.append([type_str, display_path])
if not self._locations_notice_css_provider:
self._locations_notice_css_provider = Gtk.CssProvider()
self._locations_notice_css_provider.load_from_data(b"""
.bb-locations-notice-whitelist {
background-color: rgba(46, 139, 87, 0.12);
border-radius: 6px;
padding: 6px;
}
.bb-locations-notice-custom {
background-color: rgba(210, 120, 0, 0.12);
border-radius: 6px;
padding: 6px;
}
""")
if LOCATIONS_WHITELIST == page_type:
# TRANSLATORS: Notice label at the top of the keeplist (whitelist)
# page in the preferences dialog.
# "Paths" is used generically to refer to both files and folders.
notice_text = _("These paths will not be deleted or modified.")
notice_icon = "emblem-readonly"
notice_class = "bb-locations-notice-whitelist"
elif LOCATIONS_CUSTOM == page_type:
# TRANSLATORS: Notice label at the top of the custom (delete)
# page in the preferences dialog.
notice_text = _("These locations can be selected for deletion.")
notice_icon = "edit-delete"
notice_class = "bb-locations-notice-custom"
else:
raise RuntimeError(f"Invalid page type: {page_type}")
notice_label = Gtk.Label(label=notice_text)
notice_label.set_line_wrap(True)
notice_label.set_xalign(0.0)
notice_image = load_icon_or_fallback(
notice_icon, Gtk.IconSize.MENU)
notice_image.set_valign(Gtk.Align.START)
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
notice_box.pack_start(notice_image, False, False, 0)
notice_box.pack_start(notice_label, True, True, 0)
notice_frame = Gtk.EventBox()
notice_frame.set_visible_window(True)
notice_frame.add(notice_box)
notice_frame.set_margin_bottom(6)
notice_frame.set_margin_top(6)
style_context = notice_frame.get_style_context()
style_context.add_provider(
self._locations_notice_css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
style_context.add_class(notice_class)
vbox.pack_start(notice_frame, False, False, 0)
# create treeview
treeview = Gtk.TreeView.new_with_model(liststore)
# create column views
self.renderer0 = Gtk.CellRendererText()
# TRANSLATORS: Column header in the preferences dialog showing whether
# the item is a file or folder.
self.column0 = Gtk.TreeViewColumn(_("Type"), self.renderer0, text=0)
treeview.append_column(self.column0)
self.renderer1 = Gtk.CellRendererText()
# TRANSLATORS: In the tree view "Path" is used generically to refer to a
# file, a folder, or a pattern describing either
self.column1 = Gtk.TreeViewColumn(_("Path"), self.renderer1, text=1)
treeview.append_column(self.column1)
treeview.set_search_column(1)
# finish tree view
swindow = Gtk.ScrolledWindow()
swindow.set_overlay_scrolling(False)
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.set_size_request(300, 200)
swindow.add(treeview)
vbox.pack_start(swindow, True, True, 0)
# buttons that modify the list
def add_file_cb(button):
"""Callback for adding a file"""
# TRANSLATORS: Title of a file chooser dialog.
title = _("Choose a file")
pathname = GuiBasic.browse_file(self.parent, title)
if pathname:
self._add_path(pathname, 'file', page_type,
liststore, pathnames)
def add_folder_cb(button):
"""Callback for adding a folder"""
# TRANSLATORS: Title of a folder chooser dialog.
title = _("Choose a folder")
pathname = GuiBasic.browse_folder(self.parent, title,
multiple=False, stock_button=Gtk.STOCK_ADD)
if pathname:
self._add_path(pathname, 'folder', page_type,
liststore, pathnames)
def remove_path_cb(button):
"""Callback for removing a path"""
self._remove_path(treeview, liststore, pathnames, page_type)
button_add_file = Gtk.Button.new_with_label(
# TRANSLATORS: Button label in the preferences for adding a file.
label=_p('button', 'Add file'))
button_add_file.connect("clicked", add_file_cb)
button_add_folder = Gtk.Button.new_with_label(
# TRANSLATORS: Button label in the preferences for adding a folder.
label=_p('button', 'Add folder'))
button_add_folder.connect("clicked", add_folder_cb)
button_remove = Gtk.Button.new_with_label(label=_p('button', 'Remove'))
button_remove.connect("clicked", remove_path_cb)
button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
button_box.set_layout(Gtk.ButtonBoxStyle.START)
button_box.pack_start(button_add_file, True, True, 0)
button_box.pack_start(button_add_folder, True, True, 0)
button_box.pack_start(button_remove, True, True, 0)
vbox.pack_start(button_box, False, True, 0)
# return page
return vbox
def run(self, page_name=None):
"""Run the dialog, optionally opening to a specific page.
Args:
page_name: Optional page ID to select (e.g., 'cookies', 'custom').
"""
self.dialog.show_all()
self.infobar.hide()
self.select_page(page_name)
self.dialog.run()
self.dialog.destroy()
if self.refresh_operations and self.cb_refresh_operations:
self.cb_refresh_operations()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiStartup.py 0000775 0001750 0001750 00000043674 15265023030 015421 0 ustar 00z z # 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.
import logging
import os
import stat
import sys
from importlib.util import find_spec
import bleachbit
from bleachbit import General, IS_MAC, IS_POSIX, IS_WINDOWS
from bleachbit.General import unset_sslkeylogfile
from bleachbit.Language import get_text as _
from bleachbit.Options import options
if IS_WINDOWS:
from bleachbit import Windows
logger = logging.getLogger(__name__)
def _is_version_upgrade(old_version, target_version):
"""Check if upgrading from old_version to >= target_version"""
try:
old_parts = [int(x) for x in old_version.split('.')]
target_parts = [int(x) for x in target_version.split('.')]
# Pad shorter version with zeros
max_len = max(len(old_parts), len(target_parts))
old_parts.extend([0] * (max_len - len(old_parts)))
target_parts.extend([0] * (max_len - len(target_parts)))
# Check if old_version < target_version
return old_parts < target_parts
except (ValueError, AttributeError):
return False
def _get_missing_dependencies():
"""Check for missing optional dependencies.
Returns: list of missing dependency names
"""
# On FreeBSD, sqlite3 is a separate package.
deps = ['psutil', 'requests', 'sqlite3', 'urllib3']
if IS_WINDOWS:
deps.append('plyer')
# find_spec() avoids executing the modules just to test they exist
missing = []
for dep in deps:
try:
if find_spec(dep) is None:
missing.append(dep)
except (ImportError, ValueError):
missing.append(dep)
return sorted(missing)
def _get_posix_permission_issues(fstat, options_file):
"""Check POSIX-specific ownership and access issues.
Returns (has_error, lines).
"""
lines = []
has_error = False
import pwd
from bleachbit.General import get_real_uid, get_real_username
try:
file_owner = pwd.getpwuid(fstat.st_uid).pw_name
except (KeyError, OverflowError):
file_owner = str(fstat.st_uid)
current_uid = get_real_uid()
try:
current_user = get_real_username()
except RuntimeError:
has_error = True
current_user = str(current_uid)
lines.append(f'File owner: {file_owner} (uid {fstat.st_uid})')
lines.append(f'Current user: {current_user} (uid {current_uid})')
if fstat.st_uid != current_uid:
if os.geteuid() == 0:
lines.append('File owner does not match current user '
'(root can access regardless)')
else:
has_error = True
lines.append('File owner does not match current user')
# os.access checks
lines.append(f'os.access R_OK: {os.access(options_file, os.R_OK)}')
lines.append(f'os.access W_OK: {os.access(options_file, os.W_OK)}')
return has_error, lines
def _lookup_account_name_or_sid(sid):
"""Resolve a SID to an account name and its string form.
Returns ``(account_name, sid_str)``. ``sid_str`` is the canonical
string representation of ``sid`` and is always returned, regardless
of whether ``LookupAccountSid`` succeeds.
Issue 2271 observed that ``LookupAccountSid`` failed when the SID
belonged to an account from other Windows instance in dual-boot
system with shared NTFS. In that case ``account_name`` falls back
to ``sid_str`` so the ownership comparison can still proceed by SID.
"""
if not IS_WINDOWS:
raise RuntimeError("This function is only available on Windows")
import win32security # pylint: disable=import-outside-toplevel
import pywintypes # pylint: disable=import-outside-toplevel
sid_str = win32security.ConvertSidToStringSid(sid)
try:
return win32security.LookupAccountSid(None, sid)[0], sid_str
except pywintypes.error as e:
logger.debug(
'LookupAccountSid failed (%s); falling back to SID string', e)
return sid_str, sid_str
def _get_windows_user_info():
"""Get Windows user information.
Returns (current_sid, current_name, token_groups).
"""
if not IS_WINDOWS:
raise RuntimeError("This function is only available on Windows")
import win32api
import win32file
import win32security
process_token = win32security.OpenProcessToken(
win32api.GetCurrentProcess(),
win32security.TOKEN_QUERY)
current_sid = win32security.GetTokenInformation(
process_token, win32security.TokenUser)[0]
current_name, current_sid_str = _lookup_account_name_or_sid(current_sid)
token_groups = win32security.GetTokenInformation(
process_token, win32security.TokenGroups)
win32file.CloseHandle(process_token)
# Convert PySID objects to string representation for hashing
group_sids = {win32security.ConvertSidToStringSid(
g[0]) for g in token_groups}
return current_sid_str, current_name, group_sids
def _get_windows_file_owner(filepath):
"""Get the owner of a file on Windows.
Returns (owner_sid_str, owner_name).
"""
if not IS_WINDOWS:
raise RuntimeError("This function is only available on Windows")
import win32security
file_sd = win32security.GetFileSecurity(
filepath, win32security.OWNER_SECURITY_INFORMATION)
file_owner_sid = file_sd.GetSecurityDescriptorOwner()
file_owner_name, file_owner_sid_str = _lookup_account_name_or_sid(
file_owner_sid)
return file_owner_sid_str, file_owner_name
def _get_windows_permission_issues(options_file, options_dir=None):
"""Check Windows-specific ownership and ACL issues.
Returns (has_error, lines).
"""
if not IS_WINDOWS:
raise RuntimeError("This function is only available on Windows")
lines = []
has_error = False
try:
file_owner_sid_str, file_owner_name = _get_windows_file_owner(
options_file)
current_sid, current_name, group_sids = _get_windows_user_info()
lines.append(f'File owner: {file_owner_name}')
lines.append(f'Current user: {current_name}')
# Administrators owns the directory, and the created file inherits
# from the owner. This is not representative of a normal installation.
if 'GITHUB_ACTIONS' in os.environ and file_owner_name == 'Administrators':
lines.append('Skipping file ownership check in CI')
elif file_owner_sid_str != current_sid and file_owner_sid_str not in group_sids:
if file_owner_name == file_owner_sid_str:
# The file owner's SID could not be resolved to an account
# name (e.g., issue #2271).
lines.append(
'File owner SID could not be resolved to an account name '
'(may be from another Windows installation)')
else:
has_error = True
lines.append('File owner does not match current user')
except Exception as e:
has_error = True
lines.append(f'Could not check file ownership: {e}')
# When elevated, GUI.check_appdata_permissions() offers the repair instead.
if (options_dir and not bleachbit.portable_mode
# pylint: disable-next=possibly-used-before-assignment
and not Windows.shell.IsUserAnAdmin()):
try:
if Windows.has_broken_appdata_acl(options_dir):
has_error = True
lines.append(
'Configuration folder permissions lock out non-elevated '
'processes (issue #2401); run BleachBit with administrator '
'privileges to repair them')
except Exception as e:
has_error = True
lines.append(f'Could not check directory ACL: {e}')
return has_error, lines
def _get_config_permission_issues():
"""Check for permission and file access issues with the
configuration.
Side effects: creates the options directory and an empty
configuration file if they are missing. In sudo mode, chowns
them to the real user.
If no errors, returns False.
If errors found, returns list of strings with info.
"""
lines = []
has_error = False
_options_file = bleachbit.options_file
_options_dir = bleachbit.options_dir
try:
General.makedirs(_options_dir)
except OSError as e:
lines.append(f'Cannot create directory {_options_dir}: '
f'{type(e).__name__}: {e}')
parent = os.path.dirname(_options_dir)
while not os.path.lexists(parent) and parent != os.path.dirname(parent):
parent = os.path.dirname(parent)
try:
pstat = os.stat(parent)
lines.append(f'Nearest existing parent directory: {parent}')
lines.append(
f'Parent directory permissions: {stat.filemode(pstat.st_mode)} '
f'({oct(stat.S_IMODE(pstat.st_mode))})')
if IS_POSIX:
lines.extend(_get_posix_permission_issues(pstat, parent)[1])
elif IS_WINDOWS:
lines.extend(_get_windows_permission_issues(parent)[1])
except OSError as e2:
lines.append(f'Cannot stat parent directory {parent}: {e2}')
return lines
try:
with open(_options_file, 'rb') as f:
f.read(1)
except FileNotFoundError:
# A missing configuration file is normal on first start and is
# not a permission issue. The append test below verifies that
# the file can be created/written.
pass
except (IOError, OSError, PermissionError) as e:
has_error = True
lines.append(f"Read error: {type(e).__name__}: {e}")
try:
mkfile = not os.path.exists(_options_file)
with open(_options_file, 'a', encoding='utf-8') as f:
f.write('')
if mkfile and General.sudo_mode():
General.chownself(_options_file)
except (IOError, OSError, PermissionError) as e:
has_error = True
lines.append(f"Write error: {type(e).__name__}: {e}")
try:
fstat = os.stat(_options_file)
mode = stat.filemode(fstat.st_mode)
lines.append(f'File: {_options_file}')
lines.append(
f'Permissions: {mode} ({oct(stat.S_IMODE(fstat.st_mode))})')
except OSError as e:
has_error = True
lines.append(f'Cannot stat {_options_file}: {e}')
# Directory info
try:
dstat = os.stat(_options_dir)
dmode = stat.filemode(dstat.st_mode)
lines.append(f'Directory: {_options_dir}')
lines.append(
f'Directory permissions: {dmode} ({oct(stat.S_IMODE(dstat.st_mode))})')
except OSError as e:
has_error = True
lines.append(f'Cannot stat directory {_options_dir}: {e}')
# File ownership vs current user
if IS_POSIX and 'fstat' in locals():
_has_error, _lines = _get_posix_permission_issues(
fstat, _options_file)
has_error = has_error or _has_error
lines.extend(_lines)
elif IS_WINDOWS:
_has_error, _lines = _get_windows_permission_issues(
_options_file, _options_dir)
has_error = has_error or _has_error
lines.extend(_lines)
if has_error:
return lines
return False
def _has_selected_warning_option():
"""Check if any selected cleaning option has a warning.
Returns True if any selected option has a warning, False otherwise.
"""
from bleachbit.Cleaner import backends
if not backends:
return False
for cleaner_id, cleaner in backends.items():
for (o_id, _o_name) in cleaner.get_options():
if options.get_tree(cleaner_id, o_id):
if cleaner.get_warning(o_id):
return True
return False
def get_startup_messages(auto_exit):
"""Return a list of startup messages
Can have a side effects
- set first_start to False
- unset environment variable
Return: list of tuples (message, is_error) where is_error is True
for error messages.
"""
ret_msgs = []
perm_issues_list = _get_config_permission_issues()
if perm_issues_list:
perm_issues_str = '\n'.join(perm_issues_list)
ret_msgs.append((
f'The configuration file {bleachbit.options_file} may have a permissions issue, so existing '
'preferences might not be loaded, and changes may not be saved. If your preferences are saved, '
'then you may ignore this error message or report it as a bad error message.\n\n'
f'{perm_issues_str}', True))
missing_deps = _get_missing_dependencies()
if missing_deps:
ret_msgs.append((
f'Missing optional Python packages: {", ".join(missing_deps)}. '
'Some features may be limited.', True))
# Call unset_sslkeylogfile before checking for updates.
if unset_sslkeylogfile(False):
ret_msgs.append((
'The environment variable SSLKEYLOGFILE is not supported', True))
# Check if any selected cleaning option has a warning
if not options.get('expert_mode') and _has_selected_warning_option():
ret_msgs.append((
# TRANSLATORS: "Expert Mode" is the name of a feature/setting found in
# Preferences. Keep it consistent with the translation of "Expert Mode"
# in the preferences dialog. This message appears when cleaning options
# with warnings are selected but expert mode is disabled.
_('One or more selected cleaning options have warnings and '
'require Expert Mode to clean. Enable it in preferences.'), False))
if not options.get('delete_confirmation') and not options.get('expert_mode'):
ret_msgs.append((
# TRANSLATORS: "Expert Mode" is the name of a feature/setting found in
# Preferences. Keep it consistent with the translation of "Expert Mode"
# in the preferences dialog. This message appears when the user tries to
# skip a delete confirmation without enabling Expert Mode.
_('Delete confirmation is disabled but expert mode is not enabled. '
'Enable Expert Mode in preferences to skip delete confirmations.'), False))
# Show information for first start.
# (The first start flag is set also for each new version.)
if options.get("first_start") and not auto_exit:
if IS_POSIX:
ret_msgs.append((
# TRANSLATORS: First-start hint for Linux users shown in
# the log on the main screen.
_('Access the application menu by clicking the hamburger icon on the title bar.'), False))
elif IS_WINDOWS:
ret_msgs.append((
# TRANSLATORS: First-start hint for Windows users shown in
# the log on the main screen.
_('Access the application menu by clicking the logo on the title bar.'), False))
options.set('first_start', False)
# Show notice about admin privileges.
if IS_POSIX and os.path.expanduser('~') == '/root':
ret_msgs.append((
# TRANSLATORS: Warning shown on startup when running BleachBit as root on Linux.
# It means, for example, that cleaning a browser will clean root's browser data.
_('You are running BleachBit with administrative privileges for cleaning '
'shared parts of the system, and references to the user profile folder '
'will clean only the root account.'), False))
if IS_WINDOWS:
from win32com.shell.shell import IsUserAnAdmin
if options.get('shred') and not IsUserAnAdmin():
ret_msgs.append((
# TRANSLATORS: Warning shown on startup on Windows.
_('Run BleachBit with administrator privileges to improve the '
'accuracy of overwriting the contents of files.'), False))
if Windows.is_ots_elevation():
ret_msgs.append((
# TRANSLATORS: Warning shown on startup on Windows when elevated
# with different account. It means, for example, that cleaning a
# browser will clean the browser data of the administrator account.
_('You elevated privileges using a different account to clean '
'shared parts of the system. User-specific paths will refer to '
'the administrator account, so to clean your profile, run '
'BleachBit again as a standard user.'), False))
if 'windowsapps' in sys.executable.lower():
ret_msgs.append((
# TRANSLATORS: Warning shown on startup on Windows when running
# unofficial Microsoft Store version. Advises user to get genuine
# version from official website.
_('There is no official version of BleachBit on the Microsoft Store. '
'Get the genuine version at https://www.bleachbit.org where it is '
'always free of charge.'), False))
if IS_MAC:
from bleachbit.Mac import is_full_disk_access_enabled
if not is_full_disk_access_enabled():
# In the future, we could add button to option system settings.
# This message doesn't handle a corner case if our application
# is running from a terminal: the user would need to grant
# permissions to it instead.
ret_msgs.append((
# TRANSLATORS: Startup warning on macOS when the application
# lacks Full Disk Access. "Full Disk Access", "System
# Settings", and "Privacy & Security" are names of macOS
# features and settings: match Apple's localization.
_('BleachBit may not have Full Disk Access. Without it, macOS blocks '
'access to some files, and some cleaners will not work.\n\n'
'To fix this, open System Settings > Privacy & Security > Full Disk '
'Access, turn on BleachBit, and then restart BleachBit.'), True))
return ret_msgs
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiTreeModels.py 0000664 0001750 0001750 00000024102 15265023030 016000 0 ustar 00z z # 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.
from bleachbit import GuiBasic
from bleachbit.Cleaner import backends
from bleachbit.GUI import logger
from bleachbit.GtkShim import GObject, Gtk
from bleachbit.Language import get_text as _
from bleachbit.Options import options
class TreeDisplayModel:
"""Displays the info model in a view"""
def make_view(self, model, parent, context_menu_event):
"""Create and return a TreeView object"""
self.view = Gtk.TreeView.new_with_model(model)
# hide headers
# These commits disabled the header: 5d0b5b7ab,49ad443.
self.view.set_headers_visible(False)
# listen for right click (context menu)
self.view.connect("button_press_event", context_menu_event)
# first column: cleaner name
renderer_name = Gtk.CellRendererText()
# TRANSLATORS: Column header for the cleaner name.
# Used in the tree view on the main screen.
# 'Name' is a noun.
column_name = Gtk.TreeViewColumn(_("Name"), renderer_name, text=0)
self.view.append_column(column_name)
self.view.set_search_column(0)
# second column: alert icon
renderer_alert = Gtk.CellRendererPixbuf()
column_alert = Gtk.TreeViewColumn("", renderer_alert)
column_alert.add_attribute(renderer_alert, "icon-name", 4)
self.view.append_column(column_alert)
# third column: checkbox
renderer_active = Gtk.CellRendererToggle()
renderer_active.set_property('activatable', True)
renderer_active.connect('toggled', self.col1_toggled_cb, model, parent)
# TRANSLATORS: Column header indicating whether the cleaner is active/enabled.
# Used in the tree view on the main screen.
column_active = Gtk.TreeViewColumn(_("Active"), renderer_active)
column_active.add_attribute(renderer_active, "active", 1)
self.view.append_column(column_active)
# fourth column: size
renderer_size = Gtk.CellRendererText()
renderer_size.set_alignment(1.0, 0.0)
# TRANSLATORS: Column header for the size of the cleaner.
# Used in the tree view on the main screen.
# 'Size' is a noun and refers to the amount of space that the cleaner
# would clean (preview mode) or did clean (cleaning mode).
column_size = Gtk.TreeViewColumn(_("Size"), renderer_size, text=3)
column_size.set_alignment(1.0)
self.view.append_column(column_size)
# finish
self.view.expand_all()
return self.view
def set_cleaner(self, path, model, parent_window, value):
"""Activate or deactivate option of cleaner."""
assert isinstance(value, bool)
assert isinstance(model, Gtk.TreeStore)
cleaner_id = None
option_id = None
i = path
if isinstance(i, str):
# type is either str or gtk.TreeIter
i = model.get_iter(path)
parent = model.iter_parent(i)
if parent:
# this is an option (child), not a cleaner (parent)
cleaner_id = model[parent][2]
option_id = model[path][2]
if cleaner_id and value:
# When enabling an option, present any warnings.
# (When disabling an option, there is no need to present warnings.)
warning = backends[cleaner_id].get_warning(option_id)
if warning and not options.get('expert_mode'):
if hasattr(parent_window, 'show_infobar'):
parent_window.show_infobar(
# TRANSLATORS: Warning shown in the infobar.
_("This option is protected. To bypass protection, enable expert mode."))
# Show an alert icon by the option name.
model[path][4] = "dialog-warning"
return
warning_key = 'cleaner:' + cleaner_id + ':' + option_id
if warning and not options.get_warning_preference(warning_key):
# TRANSLATORS: %(cleaner) may be Firefox, System, etc.
# %(option) may be cache, logs, cookies, etc.
option_name = _("%(cleaner)s - %(option)s") % {
'cleaner': model[parent][0],
'option': model[path][0],
}
confirmed, remember_choice = GuiBasic.warning_confirm_dialog(
parent_window, option_name, warning)
if not confirmed:
# user cancelled, so don't toggle option
return
if remember_choice:
options.remember_warning_preference(warning_key)
model[path][1] = value
model[path][4] = ""
def col1_toggled_cb(self, cell, path, model, parent_window):
"""Callback for toggling cleaners"""
is_toggled_on = not model[path][1] # Is the new state enabled?
self.set_cleaner(path, model, parent_window, is_toggled_on)
i = model.get_iter(path)
parent = model.iter_parent(i)
if parent and is_toggled_on:
# If child is enabled, then also enable the parent.
model[parent][1] = True
# If all siblings were toggled off, then also disable the parent.
if parent and not is_toggled_on:
sibling = model.iter_nth_child(parent, 0)
any_sibling_enabled = False
while sibling:
if model[sibling][1]:
any_sibling_enabled = True
sibling = model.iter_next(sibling)
if not any_sibling_enabled:
model[parent][1] = False
# If toggled and has children, then do the same for each child.
child = model.iter_children(i)
while child:
self.set_cleaner(child, model, parent_window, is_toggled_on)
child = model.iter_next(child)
# If the parent was just enabled but all children were blocked
# by expert mode, leave the parent unchecked.
if not parent and is_toggled_on:
child = model.iter_children(i)
any_child_enabled = False
while child:
if model[child][1]:
any_child_enabled = True
child = model.iter_next(child)
if not any_child_enabled:
model[i][1] = False
class TreeInfoModel:
"""Model holds information to be displayed in the tree view"""
def __init__(self):
self.tree_store = Gtk.TreeStore(
GObject.TYPE_STRING, GObject.TYPE_BOOLEAN, GObject.TYPE_PYOBJECT, GObject.TYPE_STRING, GObject.TYPE_STRING)
if not self.tree_store:
raise RuntimeError("cannot create tree store")
self.row_changed_handler_id = None
self.refresh_rows()
self.tree_store.set_sort_func(3, self.sort_func)
self.tree_store.set_sort_column_id(3, Gtk.SortType.ASCENDING)
def get_model(self):
"""Return the tree store"""
return self.tree_store
def on_row_changed(self, __treemodel, path, __iter):
"""Event handler for when a row changes"""
parent = self.tree_store[path[0]][2]
child = None
if len(path) == 2:
child = self.tree_store[path][2]
value = self.tree_store[path][1]
options.set_tree(parent, child, value)
def refresh_rows(self):
"""Clear rows (cleaners) and add them fresh"""
if self.row_changed_handler_id:
self.tree_store.disconnect(self.row_changed_handler_id)
self.tree_store.clear()
hidden_cleaners = []
auto_hide_enabled = options.get('auto_hide')
for key in sorted(backends):
backend = backends[key]
cleaner_options = list(backend.get_options())
if not cleaner_options:
# localizations has no options, so it should be hidden
# https://github.com/az0/bleachbit/issues/110
continue
c_name = backend.get_name()
c_id = backend.get_id()
c_value = options.get_tree(c_id, None)
if not c_value and auto_hide_enabled and backend.auto_hide():
if not c_id:
logger.error("backends[%s].c_id is missing", key)
continue
hidden_cleaners.append(c_id)
continue
parent = self.tree_store.append(
None, (c_name, c_value, c_id, "", ""))
for (o_id, o_name) in cleaner_options:
o_value = options.get_tree(c_id, o_id)
if not isinstance(o_name, str):
logger.error("Invalid option name type for %s.%s: expected str, got %s: %s",
c_id, o_id, type(o_name).__name__, o_name)
continue
if not isinstance(o_id, str):
logger.error("Invalid option id type for %s: expected str, got %s: %s",
c_id, type(o_id).__name__, o_id)
continue
self.tree_store.append(parent, (o_name, o_value, o_id, "", ""))
if hidden_cleaners:
logger.debug("automatically hid %d cleaners: %s", len(
hidden_cleaners), ', '.join(hidden_cleaners))
self.row_changed_handler_id = self.tree_store.connect("row-changed",
self.on_row_changed)
def sort_func(self, model, iter1, iter2, _user_data):
"""Sort the tree by the id
Index 0 is the display name
Index 2 is the ID (e.g., cookies, vacuum).
Sorting by ID is functionally important, so that vacuuming is done
last, even for other languages. See https://github.com/bleachbit/bleachbit/issues/441
"""
# model[iter][2] would build a TreeModelRow per comparison
value1 = model.get_value(iter1, 2).lower()
value2 = model.get_value(iter2, 2).lower()
if value1 == value2:
return 0
if value1 > value2:
return 1
return -1
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiUtil.py 0000664 0001750 0001750 00000030022 15265023030 014650 0 ustar 00z z # 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.
"""
WindowInfo class and utility functions for GUI
"""
import importlib.util
import os
import threading
from typing import Optional
from bleachbit import APP_NAME, FileUtilities, IS_MAC, IS_WINDOWS
from bleachbit.GUI import logger
from bleachbit.GtkShim import (
GLib, Gdk, Gtk, gi,
suppress_pygobject_import_warnings,
)
class WindowInfo:
def __init__(self, x, y, width, height, monitor_model):
super().__init__()
self.x = x
self.y = y
self.width = width
self.height = height
self.monitor_model = monitor_model
def __str__(self):
return f"WindowInfo(x={self.x}, y={self.y}, width={self.width}, height={self.height}, monitor_model={self.monitor_model})"
def clear_clipboard():
"""Clear the clipboard buffer"""
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(' ', 1)
clipboard.clear()
flush_gtk_events()
# GTK may leave the clipboard locked, so the win32api may
# get an "access denied" error.
if IS_WINDOWS:
import bleachbit.Windows
try:
bleachbit.Windows.clear_clipboard()
except bleachbit.Windows.pywintypes.error as e:
winerror = getattr(e, 'winerror', e.args[0] if e.args else None)
if winerror != 5:
raise
logger.debug(
'Failed to clear Windows clipboard using win32 API',
exc_info=True)
def get_clipboard_paths(clipboard=None, targets=None):
"""Returns paths from clipboard as a list"""
if clipboard is None:
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
if IS_WINDOWS:
# Prefer native CF_HDROP data when available. Query Win32 before
# wait_for_targets() because GTK target queries can race with CF_HDROP.
import bleachbit.Windows
win32_paths = ()
try:
win32_paths = bleachbit.Windows.get_clipboard_paths()
if not win32_paths:
flush_gtk_events()
win32_paths = bleachbit.Windows.get_clipboard_paths()
except bleachbit.Windows.pywintypes.error as e:
winerror = getattr(e, 'winerror', e.args[0] if e.args else None)
if winerror != 5:
raise
if win32_paths:
return list(win32_paths)
if targets is None:
has_targets, targets = clipboard.wait_for_targets()
if not has_targets:
targets = []
# Compare names: an interned Gdk.Atom does not reliably compare equal to
# the atom for the same name in the target list.
targets_by_name = {}
has_unusable_target_name = False
for target in targets:
if target is None:
# On macOS/Quartz, a clipboard target whose NSPasteboard type
# cannot be mapped to a Gdk.Atom can surface here as None
# instead of a valid atom object (seen alongside a
# 'gdk_atom_intern: assertion atom_name != NULL failed'
# warning), rather than raising or being omitted.
has_unusable_target_name = True
logger.debug('Skipping a None clipboard target')
continue
try:
target_name = target.name()
except UnicodeDecodeError:
has_unusable_target_name = True
logger.debug(
'Failed to decode clipboard target name', exc_info=True)
else:
if target_name == 'Gdk.Atom':
has_unusable_target_name = True
else:
targets_by_name[target_name] = target
shred_paths = []
uri_target = targets_by_name.get('text/uri-list')
if uri_target is None and has_unusable_target_name:
# Use intern(), not atom_intern_static_string(), which keeps a raw
# pointer to our string instead of copying it and can corrupt the
# atom once that string is garbage collected.
uri_target = Gdk.Atom.intern('text/uri-list', False)
if uri_target is not None:
# Linux
shred_uri_contents = clipboard.wait_for_contents(uri_target)
if shred_uri_contents:
shred_paths = FileUtilities.uris_to_paths(
shred_uri_contents.get_uris())
if not shred_paths and (
'text/plain' in targets_by_name or
'UTF8_STRING' in targets_by_name):
# Plain text pasted from a text editor
text = clipboard.wait_for_text()
if text:
shred_paths = [p.strip()
for p in text.splitlines() if p.strip()]
return shred_paths
def get_font_size_from_name(font_name):
"""Get the font size from the font name"""
if not isinstance(font_name, str):
return None
if not font_name:
return None
try:
number_part = font_name.split()[-1]
except IndexError:
return None
if '.' in number_part:
return int(float(number_part))
try:
size_int = int(number_part)
except ValueError:
return None
if size_int < 1:
return None
return size_int
def get_window_info(window):
"""Get the geometry and monitor of a window.
window: Gtk.Window
https://docs.gtk.org/gdk3/method.Screen.get_monitor_at_window.html
Deprecated since: 3.22
Use gdk_display_get_monitor_at_window() instead.
https://docs.gtk.org/gdk3/method.Display.get_monitor_at_window.html
Available since: 3.22
https://docs.gtk.org/gdk3/method.Screen.get_monitor_geometry.html
Deprecated since: 3.22
Use gdk_monitor_get_geometry() instead.
https://docs.gtk.org/gdk3/method.Monitor.get_geometry.html
Available since: 3.22
Returns a Rectangle-like object with with extra `monitor_model`
property with the monitor model string.
"""
assert window is not None
assert isinstance(window, Gtk.Window)
gdk_window = window.get_window()
display = Gdk.Display.get_default()
assert display is not None
monitor = display.get_monitor_at_window(gdk_window)
assert monitor is not None
geo = monitor.get_geometry()
assert geo is not None
assert isinstance(geo, Gdk.Rectangle)
if display.get_n_monitors() > 0 and monitor.get_model():
monitor_model = monitor.get_model()
else:
monitor_model = "(unknown)"
return WindowInfo(geo.x, geo.y, geo.width, geo.height, monitor_model)
def detect_dark_background(widget: Optional[Gtk.Widget]) -> Optional[bool]:
"""Return True if the widget background is dark, False if light, None on failure."""
threshold = 0.45
if widget is None:
return None
try:
style_context = widget.get_style_context()
rgba = None
if style_context is not None and hasattr(style_context, 'lookup_color'):
lookup = style_context.lookup_color('theme_bg_color')
if isinstance(lookup, tuple):
# lookup_color returns (found, rgba); honor the found flag
if lookup[0]:
rgba = lookup[-1]
elif lookup:
rgba = lookup
if rgba is None:
return None
luminance = 0.2126 * rgba.red + 0.7152 * rgba.green + 0.0722 * rgba.blue
is_dark = luminance < threshold
# logger.debug("Detected widget luminance=%f -> dark=%s", luminance, is_dark)
return is_dark
except Exception:
logger.debug("Failed to detect widget background", exc_info=True)
return None
def should_show_dark_mode_warning(before_dark: Optional[bool], after_dark: Optional[bool]) -> bool:
"""Return True when we should warn the user about theme toggles."""
# Warn when the theme did not change or when we cannot tell.
return before_dark is None or after_dark is None or before_dark == after_dark
def flush_gtk_events(max_iterations: int = 5):
"""Process pending GTK events to allow style updates to land."""
iterations = 0
while Gtk.events_pending() and (max_iterations is None or iterations < max_iterations):
Gtk.main_iteration_do(False)
iterations += 1
def resolve_icon_name(icon_name, fallback_name='image-missing'):
"""Return ``icon_name`` if it resolves in the current icon theme.
``Gtk.Image.new_from_icon_name`` and ``Gtk.Button.new_from_icon_name``
silently substitute a fallback icon when the requested name is not found
in the current icon theme, which makes missing icons hard to notice.
This helper checks the theme first, logs a warning when the icon is
missing, and returns ``fallback_name`` (the standard "image-missing"
icon) so the substitution is explicit.
"""
theme = Gtk.IconTheme.get_default()
if theme is None:
logger.warning(
'No icon theme available, cannot validate icon: %s', icon_name)
return fallback_name
if not theme.has_icon(icon_name):
logger.warning('Icon not found in theme, falling back: %s', icon_name)
return fallback_name
return icon_name
def load_icon_or_fallback(icon_name, size=None,
fallback_name='image-missing'):
"""Return a ``Gtk.Image`` for ``icon_name``, falling back if missing.
``size`` is a ``Gtk.IconSize``, defaulting to ``Gtk.IconSize.MENU``
when None (resolved at call time, not import time).
See :func:`resolve_icon_name` for the fallback behavior.
"""
if size is None:
size = Gtk.IconSize.MENU
return Gtk.Image.new_from_icon_name(
resolve_icon_name(icon_name, fallback_name), size)
def notify(msg):
"""Show a popup-notification"""
if IS_MAC:
# The macOS GTK stack has no libnotify typelib, so use AppleScript.
from bleachbit.Mac import notify_macos
notify_macos(msg)
return
if importlib.util.find_spec('plyer'):
# On Windows, use Plyer.
notify_plyer(msg)
return
# On Linux, use GTK Notify.
notify_gi(msg)
def notify_gi(msg):
"""Show a pop-up notification.
The Windows pygy-aio installer does not include notify, so this is just for Linux.
"""
try:
gi.require_version('Notify', '0.7')
except ValueError as e:
logger.debug('gi.require_version("Notify", "0.7") failed: %s', e)
return
# On Ubuntu 22.10 with Python 3.10.7, this import throws warning
# ImportWarning: DynamicImporter.exec_module() not found; falling back to load_module()
with suppress_pygobject_import_warnings():
from gi.repository import Notify
if Notify.init(APP_NAME):
notification_obj = Notify.Notification.new(
'BleachBit', msg, 'bleachbit')
notification_obj.set_hint(
"desktop-entry", GLib.Variant('s', 'bleachbit'))
try:
notification_obj.show()
except gi.repository.GLib.GError as e:
logger.debug('Notify.Notification.show() failed: %s', e)
return
notification_obj.set_timeout(10000)
def notify_plyer(msg):
"""Show a pop-up notification.
Linux distributions do not include plyer, so this is just for Windows.
"""
if not IS_WINDOWS:
raise RuntimeError("notify_plyer() is only for Windows")
from bleachbit import bleachbit_exe_path
# On Windows 10, PNG does not work.
__icon_fns = (
os.path.normpath(os.path.join(bleachbit_exe_path,
'share\\bleachbit.ico')),
os.path.normpath(os.path.join(bleachbit_exe_path,
'windows\\bleachbit.ico')))
icon_fn = None
for __icon_fn in __icon_fns:
if os.path.exists(__icon_fn):
icon_fn = __icon_fn
break
from plyer import notification
notification.notify(
title=APP_NAME,
message=msg,
app_name=APP_NAME, # not shown on Windows 10
app_icon=icon_fn,
)
def threaded(func):
"""Decoration to create a threaded function"""
def wrapper(*args):
thread = threading.Thread(target=func, args=args)
thread.start()
return wrapper
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/GuiWindow.py 0000664 0001750 0001750 00000167672 15265023030 015230 0 ustar 00z z # 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.
import logging
import os
import sys
import threading
import time
import bleachbit
from bleachbit import APP_NAME, Cleaner, FileUtilities, GuiBasic, Language, appicon_path, windows10_theme_path, IS_MAC, IS_WINDOWS
from bleachbit.Cleaner import backends, register_cleaners
from bleachbit.Constant import ABORT_BUTTON_LABEL, REQUIRES_EXPERT_MODE
from bleachbit.GUI import logger
from bleachbit.General import sanitize_surrogates
from bleachbit.GtkShim import GLib, Gdk, Gio, Gtk, require_gtk
from bleachbit.GuiInfoBar import InfoBarMixin
from bleachbit.GuiStartup import get_startup_messages
from bleachbit.GuiTreeModels import TreeDisplayModel, TreeInfoModel
from bleachbit.GuiUtil import (clear_clipboard, detect_dark_background, get_font_size_from_name,
get_window_info, notify, resolve_icon_name, threaded)
from bleachbit.Language import get_text as _
from bleachbit.Options import options
from bleachbit.Wipe import detect_orphaned_wipe_files
if IS_WINDOWS:
from bleachbit import Windows
# TRANSLATORS: Button label on the headerbar and context menu item
# in the treeview.
# 'Preview' is a verb.
PREVIEW_MSG = _('Preview')
# TRANSLATORS: Button label on the headerbar and context menu item
# in the treeview.
# 'Clean' is a verb.
CLEAN_MSG = _('Clean')
# TRANSLATORS: Button in tree view's context menu to open the cookie
# manager.
# Preserve the ellipsis as literal Unicode (…) or as Unicode escape (\u2026).
MANAGE_COOKIES_TO_KEEP = _("Manage cookies to keep\u2026")
# Ensure GTK is available for this GUI module
require_gtk()
bleachbit.log_startup_time('GuiWindow imported')
def _iter_rows(model, parent=None):
"""Yield the tree iter of each row under parent, or of each top-level row"""
if parent is None:
tree_iter = model.get_iter_first()
else:
tree_iter = model.iter_children(parent)
while tree_iter:
yield tree_iter
tree_iter = model.iter_next(tree_iter)
class GUI(InfoBarMixin, Gtk.ApplicationWindow):
"""The main application GUI"""
_style_provider = None
_style_provider_regular = None
_style_provider_dark = None
_error_tag_color = None
_showed_startup_messages = False
_scroll_pending = False
_scroll_again = False
_register_generation = 0
_app_menu_generation = None
recognized_cleanerml = False
def __init__(self, auto_exit, *args, **kwargs):
super().__init__(*args, **kwargs)
bleachbit.log_startup_time('window created')
self._show_splash_screen()
bleachbit.log_startup_time('splash checked')
self._auto_exit = auto_exit
self._gui_cleaner_cleanup_pending = None
self.set_property('name', APP_NAME)
self.set_property('role', APP_NAME)
self.populate_window()
# Redirect logging to the GUI.
bb_logger = logging.getLogger('bleachbit')
from bleachbit.Log import GtkLoggerHandler
self.gtklog = GtkLoggerHandler(self.append_text)
bb_logger.addHandler(self.gtklog)
# process any delayed logs
from bleachbit.Log import DelayLog
if isinstance(sys.stderr, DelayLog):
for msg in sys.stderr.read():
self.append_text(msg)
# if stderr was redirected - keep redirecting it
sys.stderr = self.gtklog
dark_mode = options.get('dark_mode')
settings = self.get_settings() or Gtk.Settings.get_default()
if settings:
settings.set_property(
'gtk-application-prefer-dark-theme', dark_mode)
else:
logger.warning("Could not get GTK settings to apply dark mode")
if options.is_corrupt():
logger.error(
# TRANSLATORS: Error message shown in the log on the main window.
# %s is the file path.
_('Resetting the configuration file because it is corrupt: %s'),
bleachbit.options_file)
bleachbit.Options.init_configuration()
GLib.idle_add(self.cb_refresh_operations)
# Close the application when user presses CTRL+Q or CTRL+W.
accel = Gtk.AccelGroup()
self.add_accel_group(accel)
key, mod = Gtk.accelerator_parse("Q")
accel.connect(key, mod, Gtk.AccelFlags.VISIBLE, self.on_quit)
key, mod = Gtk.accelerator_parse("W")
accel.connect(key, mod, Gtk.AccelFlags.VISIBLE, self.on_quit)
if IS_MAC:
# Gtk.AccelGroup does not reliably fire for Cmd+Q on this
# GTK/Quartz build (the key-press-event carries the correct
# META_MASK|MOD2_MASK combination, but the accelerator group
# never activates), so handle it directly instead.
mac_cmd_q_mods = Gdk.ModifierType.META_MASK | Gdk.ModifierType.MOD2_MASK
def _on_mac_key_press(_widget, event):
if event.keyval == Gdk.KEY_q and event.state & mac_cmd_q_mods == mac_cmd_q_mods:
self.on_quit(None)
return True
return False
self.connect("key-press-event", _on_mac_key_press)
# Enable the user to change font size with keyboard or mouse.
gtk_font_name = None
try:
gtk_font_name = Gtk.Settings.get_default().get_property('gtk-font-name')
except TypeError as e:
logger.debug("Error getting font name from GTK settings: %s", e)
self.font_size = get_font_size_from_name(gtk_font_name) or 12
self.default_font_size = self.font_size
self.textview.connect("scroll-event", self.on_scroll_event)
self.connect("key-press-event", self.on_key_press_event)
self._font_css_provider = None
if options.has_option("window_font_size"):
self.set_font_size(absolute_size=options.get("window_font_size"))
bleachbit.log_startup_time('window init done')
def populate_window(self):
"""Create the main application window"""
screen = self.get_screen()
display = screen.get_display()
monitor = display.get_primary_monitor()
if monitor is None:
# See https://github.com/bleachbit/bleachbit/issues/1793
if display.get_n_monitors() > 0:
monitor = display.get_monitor(0)
if monitor is None:
self.set_default_size(800, 600)
else:
geometry = monitor.get_geometry()
self.set_default_size(min(geometry.width, 800),
min(geometry.height, 600))
self.set_position(Gtk.WindowPosition.CENTER)
self.connect("configure-event", self.on_configure_event)
self.connect("window-state-event", self.on_window_state_event)
self.connect("delete-event", self.on_delete_event)
self.connect("show", self.on_show)
if appicon_path and os.path.exists(appicon_path):
self.set_icon_from_file(appicon_path)
bleachbit.log_startup_time('icon set')
# add headerbar
self.headerbar = self.create_headerbar()
self.set_titlebar(self.headerbar)
bleachbit.log_startup_time('headerbar built')
# split main window twice
hbox = Gtk.Box(homogeneous=False)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, homogeneous=False)
self.add(vbox)
# add InfoBar for non-blocking messages
self._build_infobar(vbox)
vbox.add(hbox)
# add operations to left
operations = self.create_operations_box()
hbox.pack_start(operations, False, True, 0)
# create the right side of the window
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.progressbar = Gtk.ProgressBar()
right_box.pack_start(self.progressbar, False, True, 0)
# add output display on right
self.textbuffer = Gtk.TextBuffer()
swindow = Gtk.ScrolledWindow()
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.set_property('expand', True)
self.textview = Gtk.TextView.new_with_buffer(self.textbuffer)
self.textview.set_editable(False)
self.textview.set_wrap_mode(Gtk.WrapMode.WORD)
swindow.add(self.textview)
right_box.add(swindow)
hbox.add(right_box)
# add markup tags
tt = self.textbuffer.get_tag_table()
style_operation = Gtk.TextTag.new('operation')
style_operation.set_property('size-points', 14)
style_operation.set_property('weight', 700)
style_operation.set_property('pixels-above-lines', 10)
style_operation.set_property('justification', Gtk.Justification.CENTER)
tt.add(style_operation)
style_description = Gtk.TextTag.new('description')
style_description.set_property(
'justification', Gtk.Justification.CENTER)
tt.add(style_description)
style_option_label = Gtk.TextTag.new('option_label')
style_option_label.set_property('weight', 700)
style_option_label.set_property('left-margin', 20)
tt.add(style_option_label)
style_operation = Gtk.TextTag.new('error')
tt.add(style_operation)
# This event fires when the theme changes, e.g., the system
# theme changes or the application changes its style.
self.textview.connect('style-updated', self._update_error_tag_color)
self._update_error_tag_color()
self.status_bar = Gtk.Statusbar()
vbox.add(self.status_bar)
# setup drag&drop
self.setup_drag_n_drop()
bleachbit.log_startup_time('widgets built')
# done
self.show_all()
self.progressbar.hide()
self.infobar.hide()
bleachbit.log_startup_time('window shown')
def _update_error_tag_color(self, *_args):
"""Ensure error messages stay high contrast in current theme"""
if not self.textbuffer:
return
tag_table = self.textbuffer.get_tag_table()
if not tag_table:
return
error_tag = tag_table.lookup('error')
if not error_tag:
return
light_theme_color = '#b00000'
dark_theme_color = '#ff6b6b'
dark_background = options.get('dark_mode')
detected_dark = detect_dark_background(self.textview)
if detected_dark is not None:
dark_background = detected_dark
chosen_color = dark_theme_color if dark_background else light_theme_color
if self._error_tag_color is not None and not self._error_tag_color == chosen_color:
logger.debug("Updating error tag color from %s to %s",
self._error_tag_color, chosen_color)
error_tag.set_property('foreground', chosen_color)
self._error_tag_color = chosen_color
def _get_windows10_theme_css(self):
"""Load the Windows 10 theme CSS files (if not already loaded)"""
if not IS_WINDOWS:
return None
if not options.get("win10_theme"):
return None
# Load regular theme CSS
dark_mode = options.get('dark_mode')
if not dark_mode:
if not self._style_provider_regular:
self._style_provider_regular = Gtk.CssProvider()
regular_path = os.path.join(windows10_theme_path, 'gtk.css')
logger.debug(
'Loading Windows 10 regular theme from: %s', regular_path)
try:
self._style_provider_regular.load_from_path(regular_path)
logger.debug('Successfully loaded regular theme')
except Exception as e:
logger.error(
'Failed to load Windows 10 regular theme: %s', e)
self._style_provider_regular = None
return self._style_provider_regular
# Load dark theme CSS
if not self._style_provider_dark:
self._style_provider_dark = Gtk.CssProvider()
dark_path = os.path.join(windows10_theme_path, 'gtk-dark.css')
logger.debug('Loading Windows 10 dark theme from: %s', dark_path)
try:
self._style_provider_dark.load_from_path(dark_path)
logger.debug('Successfully loaded dark theme')
except Exception as e:
logger.error('Failed to load Windows 10 dark theme: %s', e)
self._style_provider_dark = None
return self._style_provider_dark
def set_windows10_theme(self):
"""Apply or remove the Windows 10 theme based on current settings"""
if not IS_WINDOWS:
return
# Get the screen - if not available yet (e.g., during __init__), return early
screen = self.get_screen()
if screen is None:
logger.info(
'Screen not available yet, deferring theme application')
return
# Remove the current style provider if it's active
if self._style_provider is not None:
Gtk.StyleContext.remove_provider_for_screen(
screen, self._style_provider)
self._style_provider = None
# Apply the theme if the option is enabled
if options.get("win10_theme"):
self._style_provider = self._get_windows10_theme_css()
if self._style_provider is not None:
Gtk.StyleContext.add_provider_for_screen(
screen, self._style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
else:
logger.warning(
'Windows 10 theme style provider is None, cannot apply theme')
else:
logger.debug('Windows 10 theme disabled, using default theme')
def _show_splash_screen(self):
"""Show the splash screen on Windows because startup may be slow"""
if not IS_WINDOWS:
return
# Check if splash screen is forced via environment variable
splash_delay = os.environ.get('BLEACHBIT_SPLASH_SCREEN_DELAY')
if splash_delay is not None:
# pylint: disable-next=possibly-used-before-assignment
Windows.splash_thread.start()
return
# Only Pango's fontconfig backend builds the font cache on startup
if os.environ.get('PANGOCAIRO_BACKEND') not in ('fc', 'fontconfig'):
return
font_conf_file = Windows.get_font_conf_file()
if not os.path.exists(font_conf_file):
logger.error('No fonts.conf file %s', font_conf_file)
return
has_cache = Windows.has_fontconfig_cache(font_conf_file)
if not has_cache:
Windows.splash_thread.start()
def set_font_size(self, absolute_size=None, relative_size=None):
"""Set the font size of the entire application"""
assert absolute_size is not None or relative_size is not None
if absolute_size is None:
absolute_size = self.font_size + relative_size
absolute_size = max(5, min(25, absolute_size))
self.font_size = absolute_size
options.set("window_font_size", absolute_size)
css = f"* {{ font-size: {absolute_size}pt; }}"
provider = Gtk.CssProvider()
provider.load_from_data(css.encode())
# Remove any previous provider to avoid stacking rules.
screen = self.get_screen()
if self._font_css_provider is not None:
Gtk.StyleContext.remove_provider_for_screen(
screen, self._font_css_provider)
# Add the new provider globally so it affects all widgets.
Gtk.StyleContext.add_provider_for_screen(
screen,
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
self._font_css_provider = provider
def on_key_press_event(self, _widget, event):
"""Handle key press events"""
ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
if event.keyval == Gdk.KEY_F11 and not ctrl:
is_fullscreen = self.get_window().get_state() & Gdk.WindowState.FULLSCREEN
if is_fullscreen:
self.unfullscreen()
else:
self.fullscreen()
options.set("window_fullscreen", not is_fullscreen)
return True
if not ctrl:
return False
if event.keyval in (Gdk.KEY_plus, Gdk.KEY_KP_Add):
self.set_font_size(relative_size=1)
return True
if event.keyval in (Gdk.KEY_minus, Gdk.KEY_KP_Subtract):
self.set_font_size(relative_size=-1)
return True
if event.keyval == Gdk.KEY_0:
self.set_font_size(absolute_size=self.default_font_size)
return True
return False
def on_scroll_event(self, _widget, event):
"""Handle mouse scroll events
The first smooth scroll event, whether up or down, has dy=0.
"""
if not event.get_state() & Gdk.ModifierType.CONTROL_MASK:
return False
relative_size = 0
if event.direction == Gdk.ScrollDirection.UP:
logger.debug('scroll event ctrl + Gdk.ScrollDirection.UP')
relative_size = 1
elif event.direction == Gdk.ScrollDirection.DOWN:
logger.debug('scroll event ctrl + Gdk.ScrollDirection.DOWN')
relative_size = -1
elif event.direction == Gdk.ScrollDirection.SMOOTH:
try:
finished, dx, dy = event.get_scroll_deltas()
except TypeError as e:
logger.warning("Could not unpack scroll deltas: %s", e)
return False # event not handled
if dy < 0:
relative_size = 1
elif dy > 0:
relative_size = -1
else:
logger.debug(
"Smooth scroll: finished=%s, dx=%s, dy=%s", finished, dx, dy)
else:
logger.debug(
'scroll event ctrl + unknown direction %s', event.direction)
if relative_size != 0:
self.set_font_size(relative_size=relative_size)
return True # Event handled
return False
def on_quit(self, *_args):
"""Quit the application, used with CTRL+Q, CTRL+W, or Cmd+Q on macOS"""
# Unlike closing via the window's own close button (which fires
# delete-event -> on_delete_event() -> options.close()), calling
# Gtk.main_quit() directly here never emits delete-event, so any
# setting change still waiting on the delayed flush timer
# (see Options.__schedule_flush) would otherwise be lost if the
# process exits before that timer fires.
options.commit()
if Gtk.main_level() > 0:
Gtk.main_quit()
else:
self.destroy()
def _confirm_delete(self, mention_preview, shred_settings=False):
if options.get("delete_confirmation") or not options.get('expert_mode'):
return GuiBasic.delete_confirmation_dialog(self, mention_preview, shred_settings=shred_settings)
return True
def destroy(self, *_args):
"""Prevent textbuffer usage during UI destruction"""
self._destroyed = True
self.textbuffer = None
super().destroy()
def get_preferences_dialog(self):
# Keep the dialog and its minidom/cookie deps off the startup path.
from bleachbit.GuiPreferences import PreferencesDialog # pylint: disable=import-outside-toplevel
return PreferencesDialog(
self,
self.cb_refresh_operations,
self.set_windows10_theme)
def show_preferences_dialog(self, page_name=None):
"""Show the preferences dialog.
Args:
page_name: The name of the page to open, or None for the default page.
"""
pref = self.get_preferences_dialog()
pref.run(page_name)
self.update_log_level()
def shred_paths(self, paths, shred_settings=False, should_clear_clipboard=False):
"""Shred file or folders
This function has several uses:
1. Shred files or folders from the application menu.
2. Shred objects in the clipboard, trigger by application menu.
3. Shred objects in the clipboard, trigger by pasting.
4. Shred objects by drag and drop.
5. Shred application settings and quit.
6. Integration with the Windows Explorer context menu.
When shred_settings=True, the caller checks if the user confirmed to delete,
so return True or False, depending on the user's confirmation.
Otherwise, return False to remove from idle queue.
"""
# create a temporary cleaner object
backends['_gui'] = Cleaner.create_simple_cleaner(paths)
operations = {'_gui': ['files']}
# If no confirmation is requested, skip the preview.
if options.get("delete_confirmation"):
self.preview_or_run_operations(False, operations)
# Set the pending flag before the confirmation dialog because
# the dialog runs a nested GTK main loop in which the preview
# worker may finish and call worker_done(). If the flag is set
# by then, worker_done() removes _gui from backends. Otherwise
# it is removed here or when the preview finishes.
self._gui_cleaner_cleanup_pending = self.worker
if not self._confirm_delete(False, shred_settings):
# User dis-confirmed the deletion.
return False
# User confirmed. If the preview already finished during the
# confirmation dialog, worker_done() removed _gui from backends.
# Re-create it so the real delete worker can use it.
self._gui_cleaner_cleanup_pending = None
if '_gui' not in backends:
backends['_gui'] = Cleaner.create_simple_cleaner(paths)
if should_clear_clipboard:
clear_clipboard()
# Either confirmation was not required or user approved, so
# continue with deletion.
self.preview_or_run_operations(True, operations)
if shred_settings:
return True
if self._auto_exit:
GLib.idle_add(self.close,
priority=GLib.PRIORITY_LOW)
# Return False to remove from idle queue.
return False
def append_text(self, text, tag=None, __iter=None, scroll=True):
"""Add some text to the main log"""
if threading.current_thread() is not threading.main_thread():
# GTK isn't thread-safe, so push work from worker threads back
# onto the main loop instead of touching the buffer directly.
GLib.idle_add(self.append_text, text, tag, None, scroll)
return
if self.textbuffer is None:
# textbuffer was destroyed.
return
if not __iter:
__iter = self.textbuffer.get_end_iter()
text = sanitize_surrogates(text)
if tag:
self.textbuffer.insert_with_tags_by_name(__iter, text, tag)
else:
self.textbuffer.insert(__iter, text)
# Scroll to end. If the command is run directly instead of
# through the idle loop, it may only scroll most of the way
# as seen on Ubuntu 9.04 with Italian and Spanish.
if scroll:
self._queue_scroll()
def _queue_scroll(self):
"""Scroll the log to the end from the idle loop"""
if self._scroll_pending:
self._scroll_again = True
return
self._scroll_pending = True
GLib.idle_add(self._scroll_to_end)
def _scroll_to_end(self):
"""Scroll the log to the insert mark"""
# While text keeps arriving, keep one scroll queued behind
# whatever appends next, as one idle per line used to. Set the
# flags first so a failed scroll cannot leave them stuck.
again = self._scroll_again
self._scroll_again = False
self._scroll_pending = again
if again:
GLib.idle_add(self._scroll_to_end)
if self.textbuffer is not None:
self.textview.scroll_mark_onscreen(self.textbuffer.get_insert())
return False
def update_log_level(self):
"""This gets called when the log level might have changed via the preferences."""
self.gtklog.update_log_level()
def on_selection_changed(self, selection):
"""When the tree view selection changed"""
model = self.view.get_model()
selected_rows = selection.get_selected_rows()
if not selected_rows[1]: # empty
# happens when searching in the tree view
return
paths = selected_rows[1][0]
row = paths[0]
name = model[row][0]
cleaner_id = model[row][2]
self.progressbar.hide()
description = backends[cleaner_id].get_description()
self.textbuffer.set_text("")
self.append_text(name + "\n", 'operation', scroll=False)
if not description:
description = ""
self.append_text(description + "\n\n\n", 'description', scroll=False)
for (label, description) in backends[cleaner_id].get_option_descriptions():
self.append_text(label, 'option_label', scroll=False)
if description:
self.append_text(': ', 'option_label', scroll=False)
self.append_text(description, scroll=False)
self.append_text("\n\n", scroll=False)
def get_selected_operations(self):
"""Return a list of the IDs of the selected operations in the tree view"""
model = self.tree_store.get_model()
return [model[__iter][2]
for __iter in _iter_rows(model) if model[__iter][1]]
def get_operation_options(self, operation):
"""For the given operation ID, return a list of the selected option IDs."""
model = self.tree_store.get_model()
if model.get_iter_first() is None:
return []
for __iter in _iter_rows(model):
if operation != model[__iter][2]:
continue
if not model.iter_children(__iter):
return None
# only the enabled options
return [model[iterc][2]
for iterc in _iter_rows(model, __iter) if model[iterc][1]]
return None
def set_sensitive(self, is_sensitive):
"""Disable commands while an operation is running"""
self.view.set_sensitive(is_sensitive)
self.preview_button.set_sensitive(is_sensitive)
self.run_button.set_sensitive(is_sensitive)
self.stop_button.set_sensitive(not is_sensitive)
def run_button_get_sensitive(self):
"""Return whether commands are enabled
set_sensitive() leaves the window itself sensitive, so ask the button.
"""
return self.run_button.get_sensitive()
def run_operations(self, __widget):
"""Event when the 'delete' toolbar button is clicked."""
# fixme: should present this dialog after finding operations
# Disable delete confirmation message.
# if the option is selected under preference.
if self._confirm_delete(True):
self.preview_or_run_operations(True)
def _filter_operations_for_expert_mode(self, operations, really_delete):
"""Filter out options with warnings when expert mode is disabled.
When cleaning (really_delete=True) without expert mode, options
with warnings are removed and a log message is shown for each.
Preview is always allowed.
"""
if not really_delete or options.get('expert_mode'):
return operations
filtered = {}
for cleaner_id, option_ids in operations.items():
if option_ids is None:
filtered[cleaner_id] = option_ids
continue
safe_options = []
for option_id in option_ids:
if not backends[cleaner_id].get_warning(option_id):
safe_options.append(option_id)
continue
cleaner_name = backends[cleaner_id].get_name()
option_name = option_id
# Find the friendly option_name for option_id
for (oid, oname) in backends[cleaner_id].get_options():
if oid == option_id:
option_name = oname
break
self.append_text(
# TRANSLATORS: Error message shown when a cleaner option
# cannot be used because expert mode is disabled.
# %(cleaner)s is the cleaner name, %(option)s is the option name.
_("%(cleaner)s - %(option)s cannot be cleaned because expert mode is disabled.") % {
'cleaner': cleaner_name, 'option': option_name} + "\n",
'error')
if safe_options:
filtered[cleaner_id] = safe_options
return filtered
def preview_or_run_operations(self, really_delete, operations=None):
"""Preview operations or run operations (delete files)"""
assert isinstance(really_delete, bool)
from bleachbit import Worker
self.start_time = None
if not operations:
operations = {
operation: self.get_operation_options(operation)
for operation in self.get_selected_operations()
}
assert isinstance(operations, dict)
if not operations: # empty
self.show_infobar(
# TRANSLATORS: Error message shown in the infobar when the user clicks
# the preview or clean button without selecting any cleaner options.
_("You must select an operation"),
Gtk.MessageType.ERROR)
return
try:
self.set_sensitive(False)
self.textbuffer.set_text("")
self.progressbar.show()
operations = self._filter_operations_for_expert_mode(
operations, really_delete)
if not operations:
self.set_sensitive(True)
self.progressbar.hide()
return
self.worker = Worker.Worker(self, really_delete, operations)
except Exception:
logger.exception('Error in Worker()')
else:
self.start_time = time.time()
worker = self.worker.run()
GLib.idle_add(worker.__next__)
def worker_done(self, worker, really_delete):
"""Callback for when Worker is done"""
# Remove the temporary _gui cleaner used for shred-paths and
# wipe-empty-space operations, so it does not leak into the tree
# view on the next refresh. For confirmed deletes, keep it through
# the preview so the real delete worker can use it. If confirmation
# is canceled, remove it when that preview worker finishes.
if really_delete or worker is self._gui_cleaner_cleanup_pending:
backends.pop('_gui', None)
self._gui_cleaner_cleanup_pending = None
# TRANSLATORS: Status message shown on the progress bar and in a popup
# notification.
done_msg = _("Done.")
if self.progressbar is not None:
self.progressbar.set_text("")
self.progressbar.set_fraction(1)
self.progressbar.set_text(done_msg)
# No scroll here: append_text() has queued one, and scrolling
# before GTK lays out the new text makes it do that twice.
self.set_sensitive(True)
# Close the program after cleaning is completed.
# if the option is selected under preference.
if really_delete:
if options.get("exit_done"):
sys.exit()
# notification for long-running process
if self.start_time is None:
return
elapsed = time.time() - self.start_time
logger.debug('elapsed time: %d seconds', elapsed)
if elapsed < 10 or self.is_active():
return
notify(done_msg)
def create_operations_box(self):
"""Create and return the operations box (which holds a tree view)"""
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.set_policy(
Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scrolled_window.set_overlay_scrolling(False)
self.tree_store = TreeInfoModel()
display = TreeDisplayModel()
mdl = self.tree_store.get_model()
self.view = display.make_view(
mdl, self, self.context_menu_event)
self.view.get_selection().connect("changed", self.on_selection_changed)
scrollbar_width = scrolled_window.get_vscrollbar().get_preferred_width()[
1]
# avoid conflict with scrollbar
self.view.set_margin_end(scrollbar_width)
scrolled_window.add(self.view)
return scrolled_window
def cb_refresh_operations(self):
"""Callback to refresh the list of cleaners and header bar labels"""
if getattr(self, '_destroyed', False) or self.in_destruction():
return False
bleachbit.log_startup_time('refresh started')
# Only the newest registration may advance. A refresh can arrive
# mid-way, e.g. from Preferences, and two would both fill backends.
self._register_generation += 1
generation = self._register_generation
# In case language changed, update the header bar labels.
self.update_headerbar_labels()
# Is this the first time in this session?
allow_local = True
if not self.recognized_cleanerml and not self._auto_exit:
from bleachbit import RecognizeCleanerML
try:
RecognizeCleanerML.RecognizeCleanerML(self)
except Exception:
logger.exception(
'Error recognizing CleanerML files, so they will not be loaded')
allow_local = False
else:
self.recognized_cleanerml = True
# reload cleaners from disk
self.progressbar.show()
rc = register_cleaners(self.update_progress_bar,
self.cb_register_cleaners_done,
allow_local=allow_local)
def pump():
if generation != self._register_generation:
rc.close()
return False
return next(rc)
GLib.idle_add(pump)
return False
def cb_register_cleaners_done(self):
"""Called from register_cleaners()"""
bleachbit.log_startup_time('cleaners registered')
self.progressbar.hide()
# update tree view
self.tree_store.refresh_rows()
# expand tree view
self.view.expand_all()
bleachbit.log_startup_time('tree filled')
if self._showed_startup_messages:
# remove from idle loop (see GObject.idle_add)
return False
self._showed_startup_messages = True
# Let the tree paint first: the checks can block on antivirus or a
# domain controller. Not PRIORITY_LOW, where --exit queues quit().
GLib.idle_add(self._show_startup_messages)
return False
def _show_startup_messages(self):
"""Show startup messages once the cleaner tree is on screen"""
if self.textbuffer is None:
# window was destroyed before this callback ran
return False
try:
startup_msgs = get_startup_messages(self._auto_exit)
except Exception:
# must not also skip the update check below
logger.exception('Error getting startup messages')
startup_msgs = []
for (msg, is_error) in startup_msgs:
self.append_text(msg + '\n', 'error' if is_error else None)
# Check for online updates.
# Do this after unset_sslkeylogfile.
if not self._auto_exit and \
bleachbit.online_update_notification_enabled and \
options.get("check_online_updates"):
self.check_online_updates()
bleachbit.log_startup_time('startup messages done')
return False
def cb_run_option(self, widget, really_delete, cleaner_id, option_id):
"""Callback from context menu to delete/preview a single option"""
operations = {cleaner_id: [option_id]}
# preview
if not really_delete:
self.preview_or_run_operations(False, operations)
return
# block cleaning of warning options without expert mode
if not options.get('expert_mode') and backends[cleaner_id].get_warning(option_id):
self.show_infobar(REQUIRES_EXPERT_MODE)
return
# delete
if self._confirm_delete(False):
self.preview_or_run_operations(True, operations)
return
def cb_stop_operations(self, __widget):
"""Callback to stop the preview/cleaning process"""
self.worker.abort()
def cb_manage_cookies(self, widget):
"""Callback to launch the preferences dialog with Cookies page"""
self.show_preferences_dialog('cookies')
def cb_manage_custom_paths(self, widget):
"""Callback to launch the preferences dialog with Custom page"""
self.show_preferences_dialog('custom')
def _option_has_cookie_command(self, cleaner_id, option_id):
"""Return True if the given option runs a cookie command."""
cleaner = backends.get(cleaner_id)
return bool(cleaner) and cleaner.has_action_key(option_id, 'cookie')
def context_menu_event(self, treeview, event):
"""When user right clicks on the tree view"""
if event.button != 3:
return False
pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y))
if not pathinfo:
return False
path, col, _cellx, _celly = pathinfo
treeview.grab_focus()
treeview.set_cursor(path, col, 0)
# context menu applies only to children, not parents
if len(path) != 2:
return False
# find the selected option
model = treeview.get_model()
option_id = model[path][2]
cleaner_id = model[path[0]][2]
# make a menu
menu = Gtk.Menu()
menu.connect('hide', lambda widget: widget.detach())
preview_item = Gtk.MenuItem(label=PREVIEW_MSG)
preview_item.connect('activate', self.cb_run_option,
False, cleaner_id, option_id)
menu.append(preview_item)
clean_item = Gtk.MenuItem(label=CLEAN_MSG)
clean_item.connect('activate', self.cb_run_option,
True, cleaner_id, option_id)
menu.append(clean_item)
# Check if this option has a cookie command
if self._option_has_cookie_command(cleaner_id, option_id):
menu.append(Gtk.SeparatorMenuItem())
cookie_item = Gtk.MenuItem(label=MANAGE_COOKIES_TO_KEEP)
cookie_item.connect('activate', self.cb_manage_cookies)
menu.append(cookie_item)
# Check if this is the system.custom option
if cleaner_id == 'system' and option_id == 'custom':
menu.append(Gtk.SeparatorMenuItem())
# TRANSLATORS: Context menu item in the tree view that opens the preferences dialog.
# Preserve the ellipsis as literal Unicode (…) or as Unicode escape (\u2026).
custom_paths_label = _("Manage custom paths\u2026")
custom_paths_item = Gtk.MenuItem(label=custom_paths_label)
custom_paths_item.connect('activate', self.cb_manage_custom_paths)
menu.append(custom_paths_item)
# show the context menu
menu.attach_to_widget(treeview)
menu.show_all()
menu.popup_at_pointer(event)
return True
def setup_drag_n_drop(self):
def cb_drag_data_received(widget, _context, _x, _y, data, info, _time):
if info == 80:
uris = data.get_uris()
paths = FileUtilities.uris_to_paths(uris)
self.shred_paths(paths)
# GtkTextView installs its own ::drag-data-received handler that
# calls gtk_drag_finish(FALSE) when the view is not editable. On
# Wayland that tears down the data offer in addition to the
# GTK_DEST_DEFAULT_DROP handler, so stop emission and let the
# default DROP handler finalize the drag exactly once.
widget.stop_emission('drag-data-received')
def setup_widget(widget):
widget.drag_dest_set(Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP,
[Gtk.TargetEntry.new("text/uri-list", 0, 80)], Gdk.DragAction.COPY)
widget.connect('drag_data_received', cb_drag_data_received)
setup_widget(self)
setup_widget(self.textview)
# The text view is not editable, so GtkTextView's own ::drag-motion
# and ::drag-drop handlers reject drops (calling gtk_drag_finish(FALSE))
# and, on Wayland, cancel the in-flight selection read with
# "error reading selection buffer: Operation was cancelled". Returning
# True stops those handlers via the boolean accumulator; the
# GTK_DEST_DEFAULT_DROP handling then drives the data transfer.
self.textview.connect('drag_motion', lambda *_: True)
self.textview.connect('drag_drop', lambda *_: True)
def update_progress_bar(self, status):
"""Callback to update the progress bar with number or text"""
if self.progressbar is None:
return
if isinstance(status, float):
self.progressbar.set_fraction(status)
elif isinstance(status, str):
self.progressbar.set_show_text(True)
self.progressbar.set_text(status)
else:
raise RuntimeError('unexpected type: ' + str(type(status)))
def update_item_size(self, option, option_id, bytes_removed):
"""Update size in tree control"""
model = self.view.get_model()
if model is None:
# The tree view has been destroyed (e.g. window is closing).
return
text = FileUtilities.bytes_to_human(bytes_removed)
if bytes_removed == 0:
text = ""
treepath = Gtk.TreePath(0)
# get_iter() raises ValueError when the tree is empty
try:
model.get_iter(treepath)
except ValueError:
logger.warning(
'ValueError in get_iter() when updating file size for tree path=%s', treepath)
return
for __iter in _iter_rows(model):
if model[__iter][2] != option:
continue
if option_id == -1:
model[__iter][3] = text
else:
for child in _iter_rows(model, __iter):
if model[child][2] == option_id:
model[child][3] = text
def update_total_size(self, bytes_removed):
"""Callback to update the total size cleaned"""
if self.status_bar is None:
return
context_id = self.status_bar.get_context_id('size')
text = FileUtilities.bytes_to_human(bytes_removed)
if bytes_removed == 0:
text = ""
self.status_bar.push(context_id, text)
def update_headerbar_labels(self):
"""Update the labels and tooltips in the headerbar buttons"""
# The hamburger menu is not just a label -- it's a whole
# Gtk.Builder-loaded Gio.MenuModel that only picks up a
# language change if explicitly reloaded (see the comment in
# _reload_app_menu()).
if hasattr(self, 'menu_button'):
self._reload_app_menu()
# Preview button
self.preview_button.set_label(PREVIEW_MSG)
self.preview_button.set_tooltip_text(
# TRANSLATORS: Tooltip for the preview button on the headerbar.
# 'Preview' is a verb, and 'selected operations' refers to
# the cleaning options (e.g., Firefox - Cache).
_("Preview files in the selected operations (without deleting any files)"))
# Clean button
self.run_button.set_label(CLEAN_MSG)
self.run_button.set_tooltip_text(
# TRANSLATORS: Tooltip for the clean button on the headerbar.
# 'Clean' is a verb, and 'operations' are cleaning options (e.g.,
# Firefox - Cache).
_("Clean files in the selected operations"))
self.stop_button.set_label(ABORT_BUTTON_LABEL)
self.stop_button.set_tooltip_text(
# TRANSLATORS: Tooltip for the abort button on the headerbar,
# and 'abort' ia a verb.
_('Abort the preview or cleaning process'))
def on_update_button_clicked(self, _widget):
"""Callback when the update button on the headerbar is clicked"""
if not (hasattr(self, '_available_updates') and self._available_updates):
return
self.update_button.get_style_context().remove_class('update-available')
updates = self._available_updates
if len(updates) == 1:
_ver, url = updates[0]
GuiBasic.open_url(url, self, False)
return
# If multiple updates are available, find out which one the user wants.
from bleachbit import Update
Update.update_dialog(self, updates)
def create_headerbar(self):
"""Create the headerbar"""
hbar = Gtk.HeaderBar()
# The update button is on the right side of the headerbar.
# It is hidden until an update is available.
self.update_button = Gtk.Button()
self.update_button.set_visible(False)
self.update_button.connect('clicked', self.on_update_button_clicked)
# TRANSLATORS: Button in headerbar to update the application.
# 'Update' is a verb.
self.update_button.set_label(_('Update'))
self.update_button.set_tooltip_text(
# TRANSLATORS: Tooltip for the update button in the headerbar.
# 'Update' is a verb.
_('Update BleachBit to the latest version'))
# Add CSS to animate the update button.
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
@keyframes update-pulse {
0% { opacity: 1; }
50% { opacity: 0.2; }
100% { opacity: 1; }
}
.update-available {
background: @theme_selected_bg_color;
color: @theme_selected_fg_color;
animation: update-pulse 2s ease-in-out;
animation-delay: 5s; /* delay until the animation starts */
animation-iteration-count: 1; /* only animate once */
}
""")
Gtk.StyleContext.add_provider_for_screen(
self.get_screen(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
self.update_button.get_style_context().add_class('update-available')
hbar.pack_end(self.update_button)
self.update_button.set_no_show_all(True)
self.update_button.hide()
hbar.props.show_close_button = True
hbar.props.title = APP_NAME
box = Gtk.Box()
Gtk.StyleContext.add_class(box.get_style_context(), "linked")
if IS_WINDOWS:
icon_size = Gtk.IconSize.BUTTON
else:
icon_size = Gtk.IconSize.LARGE_TOOLBAR
# create the preview button
self.preview_button = Gtk.Button.new_from_icon_name(
resolve_icon_name('edit-find'), icon_size)
self.preview_button.set_always_show_image(True)
self.preview_button.connect(
'clicked', lambda *dummy: self.preview_or_run_operations(False))
box.add(self.preview_button)
# create the delete button
self.run_button = Gtk.Button.new_from_icon_name(
resolve_icon_name('edit-clear-all'), icon_size)
self.run_button.set_always_show_image(True)
self.run_button.connect("clicked", self.run_operations)
box.add(self.run_button)
# stop cleaning
self.stop_button = Gtk.Button.new_from_icon_name(
resolve_icon_name('process-stop'), icon_size)
self.stop_button.set_always_show_image(True)
self.stop_button.set_sensitive(False)
self.stop_button.connect('clicked', self.cb_stop_operations)
box.add(self.stop_button)
hbar.pack_start(box)
# Add hamburger menu on the right
self.menu_button = Gtk.MenuButton()
app_menu_path = bleachbit.get_share_path('app-menu.ui')
if app_menu_path:
icon = Gio.ThemedIcon(name="open-menu-symbolic")
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
self.menu_button.add(image)
hbar.pack_end(self.menu_button)
else:
hbar.pack_end(Gtk.Label('error: app-menu.ui not found'))
# Update all labels and tooltips
self.update_headerbar_labels()
return hbar
def _reload_app_menu(self, app_menu_path=None):
"""(Re)load app-menu.ui into self.menu_button.
Gtk.Builder translates the .ui file's own translatable strings
via the C locale set by locale.setlocale() (see the comment in
Language.setup_translation()), baked in at load time -- unlike
the rest of the headerbar, which re-reads its labels through
Python's own gettext calls on every call to
update_headerbar_labels() and so picks up a later language
change immediately, a Gtk.Builder-loaded menu stays frozen in
whatever language was active the one time it was originally
loaded unless it is explicitly reloaded like this.
"""
# Keyed on setup_translation() runs rather than the language code:
# a rerun with the same code can still change the C locale.
generation = Language.translation_generation
if generation == self._app_menu_generation:
return
if app_menu_path is None:
app_menu_path = bleachbit.get_share_path('app-menu.ui')
if not app_menu_path:
return
builder = Gtk.Builder()
builder.add_from_file(app_menu_path)
self.menu_button.set_menu_model(builder.get_object('app-menu'))
self._app_menu_generation = generation
def on_configure_event(self, _widget, _event):
(x, y) = self.get_position()
(width, height) = self.get_size()
# fixup maximized window position:
# on Windows if a window is maximized on a secondary monitor it is moved off the screen
if IS_WINDOWS:
window = self.get_window()
if window.get_state() & Gdk.WindowState.MAXIMIZED != 0:
g = get_window_info(self)
if x < g.x or x >= g.x + g.width or y < g.y or y >= g.y + g.height:
logger.debug("Maximized window %s+%s: %s",
(x, y), (width, height), str(g))
self.move(g.x, g.y)
return True
# save window position and size
options.set("window_x", x)
options.set("window_y", y)
options.set("window_width", width)
options.set("window_height", height)
return False
def on_window_state_event(self, _widget, event):
"""Save window state
GTK version 3.24.34 on Windows 11 behaves strangely:
* It reports maximized only when application starts.
* Later, it reports window is fullscreen when neither
full screen nor maximized.
Because of this issue, we check the tiling state.
"""
tiling_states = (Gdk.WindowState.TILED |
Gdk.WindowState.TOP_TILED |
Gdk.WindowState.RIGHT_TILED |
Gdk.WindowState.BOTTOM_TILED |
Gdk.WindowState.LEFT_TILED)
is_tiled = event.new_window_state & tiling_states != 0
fullscreen = (event.new_window_state &
Gdk.WindowState.FULLSCREEN != 0) and not is_tiled
options.set("window_fullscreen", fullscreen)
maximized = event.new_window_state & Gdk.WindowState.MAXIMIZED != 0
options.set("window_maximized", maximized)
if IS_WINDOWS:
logger.debug(
'window state = %s, full screen = %s, maximized = %s', event.new_window_state, fullscreen, maximized)
return False
def on_delete_event(self, _widget, _event):
# commit options to disk
options.close()
return False
def on_show(self, _widget):
"""Handle the show event.
The event is triggered when the window is first shown.
It is not emitted when the window is moved or unminimized.
"""
# "show" is RUN_FIRST, so the window is already realized and mapped
bleachbit.log_startup_time('show handler')
if IS_WINDOWS and Windows.splash_thread.is_alive():
Windows.splash_thread.join(0)
# restore window position, size and state
if not options.get('remember_geometry'):
return
if options.has_option("window_x") and options.has_option("window_y") and \
options.has_option("window_width") and options.has_option("window_height"):
r = Gdk.Rectangle()
(r.x, r.y) = (options.get("window_x"), options.get("window_y"))
(r.width, r.height) = (options.get(
"window_width"), options.get("window_height"))
g = get_window_info(self)
# only restore position and size if window left corner
# is within the closest monitor
if g.x <= r.x < g.x + g.width and \
g.y <= r.y < g.y + g.height:
logger.debug("closest monitor %s, prior window geometry = %s+%s",
str(g), (r.x, r.y), (r.width, r.height))
self.move(r.x, r.y)
self.resize(r.width, r.height)
if options.get("window_fullscreen"):
self.fullscreen()
self.append_text(
# TRANSLATORS: Hint shown when starting in fullscreen mode.
_("Press F11 to exit fullscreen mode.") + '\n')
elif options.get("window_maximized"):
self.maximize()
# Apply Windows 10 theme if on Windows and enabled
if IS_WINDOWS:
self.set_windows10_theme()
def check_orphaned_wipe_files(self):
"""Check for orphaned wipe files and offer to delete them.
These files are created by wipe_path() to fill empty disk space."""
# Scan off the main loop: a sleeping drive can block it for seconds.
# Read the option here, since Options.get_list() takes no lock.
shred_drives = options.get_list('shred_drives')
def _worker():
try:
orphaned_files = detect_orphaned_wipe_files(shred_drives)
except Exception:
logger.exception('Error detecting orphaned wipe files')
return
if orphaned_files:
GLib.idle_add(self._prompt_orphaned_wipe_files, orphaned_files)
threading.Thread(target=_worker, daemon=True).start()
return False
def _prompt_orphaned_wipe_files(self, orphaned_files):
"""Ask whether to preview orphaned wipe files, on the main thread"""
if self.textbuffer is None:
# window was destroyed while the scan was running
return False
if not self.run_button_get_sensitive():
# an operation is running, and shred_paths() would replace it
logger.debug(
'skipping orphaned wipe file prompt: operation running')
return False
# TRANSLATORS: This message is shown when orphaned temporary files
# from an interrupted disk wipe operation are detected.
msg = _("BleachBit detected leftover files from an interrupted "
"disk wipe operation. Would you like to preview them with an option to delete them?")
# TRANSLATORS: Title of confirmation dialog for deleting orphaned wipe files.
title = _("Confirm")
resp = GuiBasic.message_dialog(self,
msg,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.YES_NO,
title)
if resp == Gtk.ResponseType.YES:
self.shred_paths(orphaned_files)
return False
def check_appdata_permissions(self):
"""Check for broken %APPDATA%\\BleachBit permissions and offer to fix them."""
# pylint: disable-next=possibly-used-before-assignment
if not IS_WINDOWS or not Windows.shell.IsUserAnAdmin():
return False
def _worker():
try:
has_broken_acl = Windows.has_broken_appdata_acl()
except Exception:
logger.exception(
'Error checking configuration folder permissions')
return
if has_broken_acl:
GLib.idle_add(self._prompt_appdata_permissions)
threading.Thread(target=_worker, daemon=True).start()
return False
def _prompt_appdata_permissions(self):
"""Ask whether to repair %APPDATA%\\BleachBit permissions, on the main thread."""
if self.textbuffer is None:
# window was destroyed while the check was running
return False
# TRANSLATORS: This message is shown on Windows when the configuration
# folder (%APPDATA%\BleachBit) has broken file permissions that prevent
# non-administrator processes from accessing it. %s is the folder path.
msg = _("BleachBit detected a permissions issue with its configuration "
"folder (%s). Would you like to repair it?") % bleachbit.options_dir
# TRANSLATORS: Title of confirmation dialog for repairing folder permissions.
title = _("Confirm")
resp = GuiBasic.message_dialog(self,
msg,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.YES_NO,
title)
if resp == Gtk.ResponseType.YES:
repaired = False
try:
# pylint: disable-next=possibly-used-before-assignment
Windows.fix_appdata_acl()
# The reset is a no-op if the parent folder's ACL is
# itself broken, so verify instead of assuming success.
repaired = not Windows.has_broken_appdata_acl()
except Exception:
logger.exception(
'Error repairing permissions for %s', bleachbit.options_dir)
if repaired:
self.append_text(
# TRANSLATORS: Message shown in the log after repairing
# folder permissions. %s is the folder path.
_("Repaired permissions for %s.") % bleachbit.options_dir + '\n')
else:
self.append_text(
# TRANSLATORS: Message shown in the log when repairing
# folder permissions did not fully succeed. %s is the
# folder path.
_("Could not fully repair permissions for %s.")
% bleachbit.options_dir + '\n')
return False
@threaded
def check_online_updates(self):
"""Check for software updates in background"""
# TRANSLATORS: Error message shown when checking for updates fails.
# %s is the error details.
log_msg = _("Error when checking for updates: %s")
try:
from bleachbit import Update
except ImportError as e:
logger.error(log_msg, e)
return
try:
updates = Update.check_updates(options.get('check_beta'),
options.get('update_winapp2'),
self.append_text,
lambda: GLib.idle_add(self.cb_refresh_operations))
except Exception as e:
logger.exception(log_msg, e)
else:
self._available_updates = updates
def update_button_state():
if updates:
self.update_button.show()
self.set_sensitive(True)
GLib.idle_add(update_button_state)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Language.py 0000664 0001750 0001750 00000055210 15265023030 015017 0 ustar 00z z # 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.
import gettext
import locale
import os
import logging
from bleachbit import IS_MAC, IS_POSIX, IS_WINDOWS
logger = logging.getLogger(__name__)
class LocaleCode:
"""Parsed locale name like 'de_DE.ISO8859-15@euro'.
Locale names have the shape language[_territory][.codeset][@modifier].
Missing pieces are None, not empty strings.
"""
__slots__ = ('raw', 'language', 'territory', 'encoding', 'modifier')
def __init__(self, raw):
# Split '@' before '.', because a modifier may itself contain a
# dot ('en@boldquot.header' in locale.alias).
pre, _, modifier = raw.partition('@')
code, _, encoding = pre.partition('.')
language, _, territory = code.replace('-', '_').partition('_')
self.raw = raw
self.language = language
self.territory = territory or None
self.encoding = encoding or None
self.modifier = modifier or None
def __str__(self):
return self.raw
@property
def normalized(self):
"""Return 'de_DE': language[_territory], without codeset or modifier."""
if self.territory:
return f'{self.language}_{self.territory}'
return self.language
@property
def is_utf8(self):
"""Return whether the locale name specifies a UTF-8 codeset.
Compared case- and punctuation-insensitively because `locale -a`
prints '.utf8' on Linux and '.UTF-8' on macOS. A name without a
codeset is not assumed to be UTF-8.
"""
if not self.encoding:
return False
return self.encoding.replace(
'-', '').replace('_', '').lower() == 'utf8'
@property
def is_special(self):
"""Return whether this is C/POSIX (with optional codeset/modifier)."""
return self.language in ('C', 'POSIX') and self.territory is None
native_locale_names = \
{'aa': 'Afaraf',
'ab': 'аҧсуа бызшәа',
'ace': 'بهسا اچيه',
'ach': 'Acoli',
'ae': 'avesta',
'af': 'Afrikaans',
'ak': 'Akan',
'am': 'አማርኛ',
'an': 'aragonés',
'ang': 'Old English',
'anp': 'Angika',
'ar': 'العربية',
'as': 'অসমীয়া',
'ast': 'Asturianu',
'av': 'авар мацӀ',
'ay': 'aymar aru',
'az': 'azərbaycan dili',
'ba': 'башҡорт теле',
'bal': 'Baluchi',
'be': 'Беларуская мова',
'bg': 'български език',
'bh': 'भोजपुरी',
'bi': 'Bislama',
'bm': 'bamanankan',
'bn': 'বাংলা',
'bo': 'བོད་ཡིག',
'br': 'brezhoneg',
'brx': 'Bodo (India)',
'bs': 'босански',
'byn': 'Bilin',
'ca': 'català',
'ce': 'нохчийн мотт',
'cgg': 'Chiga',
'ch': 'Chamoru',
'ckb': 'Central Kurdish',
'co': 'corsu',
'cr': 'ᓀᐦᐃᔭᐍᐏᐣ',
'crh': 'Crimean Tatar',
'cs': 'česky',
'csb': 'Cashubian',
'cu': 'ѩзыкъ словѣньскъ',
'cv': 'чӑваш чӗлхи',
'cy': 'Cymraeg',
'da': 'dansk',
'de': 'Deutsch',
'doi': 'डोगरी; ڈوگرى',
'dv': 'ދިވެހި',
'dz': 'རྫོང་ཁ',
'ee': 'Eʋegbe',
'el': 'Ελληνικά', # modern Greek
'en': 'English',
'en_AU': 'Australian English',
'en_CA': 'Canadian English',
'en_GB': 'British English',
'en_US': 'United States English',
'eo': 'Esperanto',
'es': 'Español',
'et': 'eesti',
'eu': 'euskara',
'fa': 'فارسی',
'ff': 'Fulfulde',
'fi': 'suomen kieli',
'fil': 'Wikang Filipino',
'fin': 'suomen kieli',
'fj': 'vosa Vakaviti',
'fo': 'føroyskt',
'fr': 'Français',
'frp': 'Arpitan',
'fur': 'Frilian',
'fy': 'Frysk',
'ga': 'Gaeilge',
'gd': 'Gàidhlig',
'gez': 'Geez',
'gl': 'galego',
'gn': 'Avañeẽ',
'grc': 'Ἑλληνική', # ancient Greek
'gu': 'Gujarati',
'gv': 'Gaelg',
'ha': 'هَوُسَ',
'haw': 'Hawaiian',
'he': 'עברית',
'hi': 'हिन्दी',
'hne': 'Chhattisgarhi',
'ho': 'Hiri Motu',
'hr': 'Hrvatski',
'hsb': 'Upper Sorbian',
'ht': 'Kreyòl ayisyen',
'hu': 'Magyar',
'hy': 'Հայերեն',
'hz': 'Otjiherero',
'ia': 'Interlingua',
'id': 'Indonesian',
'ie': 'Interlingue',
'ig': 'Asụsụ Igbo',
'ii': 'ꆈꌠ꒿',
'ik': 'Iñupiaq',
'ilo': 'Ilokano',
'ina': 'Interlingua',
'io': 'Ido',
'is': 'Íslenska',
'it': 'Italiano',
'iu': 'ᐃᓄᒃᑎᑐᑦ',
'iw': 'עברית',
'ja': '日本語',
'jv': 'basa Jawa',
'ka': 'ქართული',
'kab': 'Tazwawt',
'kac': 'Jingpho',
'kg': 'Kikongo',
'ki': 'Gĩkũyũ',
'kj': 'Kuanyama',
'kk': 'қазақ тілі',
'kl': 'kalaallisut',
'km': 'ខ្មែរ',
'kn': 'ಕನ್ನಡ',
'ko': '한국어',
'kok': 'Konkani',
'kr': 'Kanuri',
'ks': 'कश्मीरी',
'ku': 'Kurdî',
'kv': 'коми кыв',
'kw': 'Kernewek',
'ky': 'Кыргызча',
'la': 'latine',
'lb': 'Lëtzebuergesch',
'lg': 'Luganda',
'li': 'Limburgs',
'ln': 'Lingála',
'lo': 'ພາສາລາວ',
'lt': 'lietuvių kalba',
'lu': 'Tshiluba',
'lv': 'latviešu valoda',
'mai': 'Maithili',
'mg': 'fiteny malagasy',
'mh': 'Kajin M̧ajeļ',
'mhr': 'Eastern Mari',
'mi': 'te reo Māori',
'mk': 'македонски јазик',
'ml': 'മലയാളം',
'mn': 'монгол',
'mni': 'Manipuri',
'mr': 'मराठी',
'ms': 'بهاس ملايو',
'mt': 'Malti',
'my': 'ဗမာစာ',
'na': 'Ekakairũ Naoero',
'nb': 'Bokmål',
'nd': 'isiNdebele',
'nds': 'Plattdüütsch',
'ne': 'नेपाली',
'ng': 'Owambo',
'nl': 'Nederlands',
'nn': 'Norsk nynorsk',
'no': 'Norsk',
'nr': 'isiNdebele',
'nso': 'Pedi',
'nv': 'Diné bizaad',
'ny': 'chiCheŵa',
'oc': 'occitan',
'oj': 'ᐊᓂᔑᓈᐯᒧᐎᓐ',
'om': 'Afaan Oromoo',
'or': 'ଓଡ଼ିଆ',
'os': 'ирон æвзаг',
'pa': 'ਪੰਜਾਬੀ',
'pap': 'Papiamentu',
'pau': 'a tekoi er a Belau',
'pi': 'पाऴि',
'pl': 'polski',
'ps': 'پښتو',
'pt': 'Português',
'pt_BR': 'Português do Brasil',
'qu': 'Runa Simi',
'rm': 'rumantsch grischun',
'rn': 'Ikirundi',
'ro': 'română',
'ru': 'Pусский',
'rw': 'Ikinyarwanda',
'sa': 'संस्कृतम्',
'sat': 'ᱥᱟᱱᱛᱟᱲᱤ',
'sc': 'sardu',
'sd': 'सिन्धी',
'se': 'Davvisámegiella',
'sg': 'yângâ tî sängö',
'shn': 'Shan',
'si': 'සිංහල',
'sk': 'slovenčina',
'sl': 'slovenščina',
'sm': 'gagana faa Samoa',
'sn': 'chiShona',
'so': 'Soomaaliga',
'sq': 'Shqip',
'sr': 'Српски',
'ss': 'SiSwati',
'st': 'Sesotho',
'su': 'Basa Sunda',
'sv': 'svenska',
'sw': 'Kiswahili',
'ta': 'தமிழ்',
'te': 'తెలుగు',
'tet': 'Tetum',
'tg': 'тоҷикӣ',
'th': 'ไทย',
'ti': 'ትግርኛ',
'tig': 'Tigre',
'tk': 'Türkmen',
'tl': 'ᜏᜒᜃᜅ᜔ ᜆᜄᜎᜓᜄ᜔',
'tn': 'Setswana',
'to': 'faka Tonga',
'tr': 'Türkçe',
'ts': 'Xitsonga',
'tt': 'татар теле',
'tw': 'Twi',
'ty': 'Reo Tahiti',
'ug': 'Uyghur',
'uk': 'Українська',
'ur': 'اردو',
'uz': 'Ўзбек',
've': 'Tshivenḓa',
'vi': 'Tiếng Việt',
'vo': 'Volapük',
'wa': 'walon',
'wae': 'Walser',
'wal': 'Wolaytta',
'wo': 'Wollof',
'xh': 'isiXhosa',
'yi': 'ייִדיש',
'yo': 'Yorùbá',
'za': 'Saɯ cueŋƅ',
'zh': '中文',
'zh_CN': '中文',
'zh_TW': '中文',
'zu': 'isiZulu'}
def get_supported_language_codes():
"""Return list of supported languages as language codes
Supported means a translation may be available.
"""
supported_langs = []
# Use local import to avoid circular import.
from bleachbit import locale_dir
# The locale_dir may not exist, especially on Windows.
if not os.path.isdir(locale_dir):
return ['en_US', 'en']
lang_codes = sorted(set(os.listdir(locale_dir) + ['en_US', 'en']))
for lang in lang_codes:
if lang in ('en', 'en_US'):
supported_langs.append(lang)
continue
if os.path.isdir(os.path.join(locale_dir, lang)):
try:
translation = gettext.translation(
'bleachbit', locale_dir, languages=[lang])
if translation:
supported_langs.append(lang)
except FileNotFoundError:
logger.debug('no compiled translation for language %s', lang)
return supported_langs
def get_supported_language_code_name_dict():
"""Return dictionary of supported languages as language codes and names
Supported means a translation is available.
"""
supported_langs = {}
for lang in get_supported_language_codes():
supported_langs[lang] = native_locale_names.get(lang, None)
return supported_langs
def find_supported_language_code(lang_code, supported_codes):
"""Return the supported language code best matching lang_code.
lang_code is a detected code like 'en_US' or 'hi_IN', and
supported_codes is an iterable of codes like 'en', 'en_US', 'hi'.
For example, 'hi_IN' matches 'hi', 'en-US' matches 'en_US', and
'pt' may match 'pt_BR'.
Returns None if there is no match.
"""
if not lang_code:
return None
loc = LocaleCode(lang_code)
if not loc.language or loc.is_special or len(loc.language) < 2:
return None
codes = list(supported_codes)
# Try the full code ('hi_IN'), then the primary subtag ('hi').
lowered = {code.lower(): code for code in codes}
for candidate in (lang_code, loc.normalized, loc.language):
if candidate in codes:
return candidate
if candidate.lower() in lowered:
return lowered[candidate.lower()]
# Try a supported regional variant like 'pt_BR' for 'pt'. Prefer the
# variant whose region is implied by the detected code's region or
# script subtags: 'zh_Hant_TW', 'zh_Hant', and 'zh_HK' all imply
# Traditional Chinese, so they match 'zh_TW' rather than 'zh_CN'.
subtags = set(loc.normalized.lower().split('_')[1:])
if subtags & {'hant', 'tw', 'hk', 'mo'}:
preferred_region = 'tw'
elif loc.territory:
preferred_region = loc.territory.lower().rsplit('_', 1)[-1]
else:
preferred_region = None
prefix = loc.language.lower() + '_'
first_match = None
for code in codes:
if not code.lower().startswith(prefix):
continue
if first_match is None:
first_match = code
if preferred_region and code.lower() == prefix + preferred_region:
return code
return first_match
_UNSET = object()
# locale.getlocale() reports whatever setlocale() set, so the real system
# locale is captured here before setup_translation() forces a language.
_locale_fallback = _UNSET
def _get_locale_fallback():
"""Return the system locale as seen before setup_translation() ran."""
# Captured once per process.
# pylint: disable-next=global-statement
global _locale_fallback
if _locale_fallback is _UNSET:
# locale.getlocale() can raise ValueError for a locale name it
# cannot normalize.
try:
_locale_fallback = locale.getlocale()[0]
except ValueError:
_locale_fallback = None
return _locale_fallback
def normalize_locale_code(code):
"""Return the language code normalized like 'en_US'.
Strips the codeset ('.UTF-8') and modifier ('@latin'), and converts
hyphens to underscores, so a BCP 47 code like 'en-US' matches the
underscore form used in locale directory names.
"""
return LocaleCode(code).normalized
def get_active_language_code():
"""Return the language ID to use for translations
The language ID is a code like: en, en_US, nds, C
There may be an underscore or no underscore. The first part may
contain two or three letters.
There will not be a dot like `en_US.UTF-8`.
"""
try:
from bleachbit.Options import options
except ImportError:
logger.error("Failed to get language options")
else:
if not options.get('auto_detect_lang') and options.has_option('forced_language') and options.get('forced_language'):
return normalize_locale_code(options.get('forced_language'))
# locale.getdefaultlocale() will be removed in Python 3.15, so
# use getlocale() instead.
# However, on Windows, getlocale() may return values like
# 'English_United States' instead of RFC1766 codes.
if IS_WINDOWS:
import ctypes
kernel32 = ctypes.windll.kernel32
lcid = kernel32.GetUserDefaultLCID()
# Convert Windows LCID (e.g., 1033) to RFC1766 (e.g., en-US).
user_locale = normalize_locale_code(
locale.windows_locale.get(lcid, ''))
else:
# On macOS, locale.getlocale() always returns *something* (e.g.
# a built-in default) even with no LANG/LC_ALL in the
# environment at all, so its truthiness cannot detect "nothing
# was explicitly set". Check os.environ directly instead: if the
# caller (a shell, a test suite) put LANG/LC_ALL/LC_MESSAGES
# there on purpose, honor it via locale.getlocale(); otherwise
# (Finder launches the app with none of these set) prefer the
# real system preference from AppleLocale.
env_locale_set = any(
os.environ.get(name) for name in ("LC_ALL", "LC_MESSAGES", "LANG"))
if IS_MAC and not env_locale_set:
from bleachbit.Mac import get_macos_locale
user_locale = get_macos_locale()
elif env_locale_set:
# Read the environment variables instead of
# locale.getlocale(), which reports the locale set by
# locale.setlocale() in setup_translation(). Without this,
# after manually forcing a language once, the detected
# language would stay stuck on the forced language even
# after re-enabling auto-detection.
# LANGUAGE is deliberately not read: setup_translation()
# sets it on POSIX, so it has the same problem.
user_locale = None
for name in ("LC_ALL", "LC_MESSAGES", "LANG"):
env_value = os.environ.get(name)
if env_value:
candidate = normalize_locale_code(env_value)
if len(candidate) >= 2 or candidate == 'C':
user_locale = candidate
break
else:
# Not locale.getlocale(): setlocale() has already poisoned it.
user_locale = _get_locale_fallback()
if not user_locale:
user_locale = 'C'
logger.warning("no default locale found. Assuming '%s'", user_locale)
loc = LocaleCode(user_locale)
if loc.encoding or loc.modifier:
# This should never happen.
logger.warning(
'locale contains a codeset or modifier: %s', user_locale)
user_locale = loc.normalized
assert isinstance(user_locale, str)
assert len(
user_locale) >= 2 or user_locale == 'C', f"user_locale: {user_locale}"
return user_locale
# Bumped by every setup_translation(), so callers can tell it ran again
translation_generation = 0
def setup_translation():
"""Do a one-time setup of translations"""
# Translation setup runs once per process.
# pylint: disable-next=global-statement
global attempted_setup_translation, t, translation_generation
attempted_setup_translation = True
translation_generation += 1
# Use local import to avoid circular import.
from bleachbit import locale_dir
# On POSIX, capture the system locale before setlocale() below poisons
# getlocale(). This must precede get_active_language_code(), which
# returns early on a forced language without capturing it.
if IS_POSIX:
_get_locale_fallback()
user_locale = get_active_language_code()
logger.debug("user_locale: %s, locale_dir: %s", user_locale, locale_dir)
assert isinstance(user_locale, str)
assert isinstance(locale_dir, str), f"locale_dir: {locale_dir}"
if IS_WINDOWS and user_locale:
os.environ['LANG'] = user_locale
elif IS_POSIX and user_locale:
# GLib's own g_get_language_names() (used by Gtk.Builder to
# translate .ui files like the hamburger menu) reads LANGUAGE
# directly from the environment with top priority -- it does
# not consult locale.setlocale()'s C-level locale state at
# all, so without this, a Gtk.Builder-loaded menu stays frozen
# in whatever locale the OS environment happened to have at
# process launch (e.g. always the real macOS AppleLocale
# preference), never following a later in-app language change.
# Confirmed by hand: without setting this, the app-menu.ui
# hamburger menu stayed in Spanish through three different
# manually-selected languages and a full app restart, on a
# machine whose real System Settings > Language is Spanish;
# setting it here made it follow the selected language
# immediately.
#
# Only LANGUAGE is set, deliberately not LANG/LC_ALL: LANGUAGE
# is a GNU gettext-specific extension used purely as a
# translation-priority hint and has no effect on the process's
# actual C locale/encoding, whereas LANG/LC_ALL are read by
# every other locale-aware consumer in the process, including
# any subprocess spawned afterward -- and a bare code without
# an explicit encoding (which GLib's own parsing of these
# variables specifically requires; a '.UTF-8' suffix breaks it,
# verified by hand) crashed a subprocess Python interpreter
# started later with 'Fatal Python error:
# config_get_locale_encoding: ... nl_langinfo(CODESET) failed'
# when this was tried with LANG/LC_ALL instead.
os.environ['LANGUAGE'] = user_locale
text_domain = 'bleachbit'
try:
t = gettext.translation(
domain=text_domain, localedir=locale_dir, languages=[user_locale], fallback=True)
except FileNotFoundError as e:
logger.error(
"Error in setup_translation() with language code %s: %s", user_locale, e)
t = None
return
# bindtextdomain() is for C gettext consumers (e.g., Gtk.Builder
# translating .ui files). CLI/TUI processes have no consumer.
from bleachbit.GtkShim import is_gtk_loaded
gtk_loaded = is_gtk_loaded()
if gtk_loaded and hasattr(locale, 'bindtextdomain'):
locale.bindtextdomain(text_domain, locale_dir)
locale.textdomain(text_domain)
elif gtk_loaded and IS_WINDOWS:
from bleachbit.Windows import flush_gettext_cache, load_i18n_dll
libintl = load_i18n_dll()
if not libintl:
logger.error(
'The internationalization library is not available.')
return
assert isinstance(text_domain, str)
encoded_domain = text_domain.encode('utf-8')
# wbindtextdomain(char, wchar): first parameter is encoded
if hasattr(libintl, 'libintl_wbindtextdomain'):
libintl.libintl_wbindtextdomain(encoded_domain, locale_dir)
libintl.textdomain(encoded_domain)
libintl.bind_textdomain_codeset(encoded_domain, b'UTF-8')
# Without this flush, Gtk.Builder / g_dgettext keep serving the
# .mo loaded for the previous language for the rest of the
# process (issue #1801). Env-var changes and re-binding the
# domain are not enough on the Windows gettext build.
if not flush_gettext_cache(libintl):
logger.warning(
'Failed to flush gettext cache; the GUI may stay in the '
'previous language.')
else:
logger.error(
'The function wbindtextdomain() is not available.')
# Log for debugging
logger.debug("Windows translation domain set to: %s, dir: %s",
text_domain, locale_dir)
elif gtk_loaded and IS_MAC:
# Python builds for macOS lack locale.bindtextdomain(), so call
# libintl directly, so Gtk.Builder finds bleachbit.mo translations.
# Import here because bleachbit.Mac imports this module.
from bleachbit.Mac import bind_libintl_domain
if not bind_libintl_domain(text_domain, locale_dir):
logger.warning('Failed to bind the translation domain; '
'the GUI may not be translated.')
elif gtk_loaded:
logger.warning('The function bindtextdomain() is not available.')
# locale.setlocale() on Linux will throw an exception if the locale is not
# available, so find the best matching locale. When set, Gtk.Builder is
# translated.
# On Windows, locale.setlocale() accepts any values without raising an exception.
if IS_POSIX:
from bleachbit.Unix import find_best_locale
setlocale_local = find_best_locale(user_locale)
if 'C' == setlocale_local and not user_locale == 'C':
logger.warning(
'locale %s is not available. You may wish to run sudo locale-gen to generate it, or set LC_ALL=C.', user_locale)
try:
locale.setlocale(locale.LC_ALL, setlocale_local)
except locale.Error as e:
logger.error('locale.setlocale(%s): %s:', setlocale_local, e)
def get_text(text):
"""Return translated string
The name has an underscore to avoid conflicting with gettext module.
"""
if not attempted_setup_translation:
setup_translation()
if not t:
return text
return t.gettext(text)
def nget_text(singular, plural, n):
"""Return translated string with plural variant"""
if not t:
if 1 == n:
return singular
return plural
return t.ngettext(singular, plural, n)
def pget_text(msgctxt, msgid):
"""Return translated string with context
Example context is button
"""
if not t:
return msgid
return t.pgettext(msgctxt, msgid)
attempted_setup_translation = False
t = None
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Log.py 0000664 0001750 0001750 00000012543 15265023030 014017 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Logging
"""
import logging
import os
import sys
def is_debugging_enabled_via_cli():
"""Return boolean whether user required debugging on the command line"""
if 'unittest' in sys.modules:
return True
if os.getenv('BLEACHBIT_DEBUG') in ('1', 'true', 'True'):
# Set by the parent process when launching a child (e.g. the
# memory-wiping child via systemd-run) so that the child inherits
# the parent's --debug state, which sys.argv alone would not
# reflect because the child is launched with python -c.
return True
return any(arg.startswith('--debug') for arg in sys.argv)
class DelayLog:
def __init__(self):
self.queue = []
self.msg = ''
def read(self):
yield from self.queue
self.queue = []
def write(self, msg):
self.msg += msg
if self.msg and self.msg[-1] == '\n':
self.queue.append(self.msg)
self.msg = ''
def init_log():
"""Set up the root logger
This is one of the first steps in __init___
"""
logger = logging.getLogger('bleachbit')
# On Microsoft Windows when running frozen without the console,
# avoid py2exe redirecting stderr to bleachbit.exe.log by not
# writing to stderr because py2exe redirects stderr to a file.
#
# sys.frozen = 'console_exe' means the console is shown, which
# does not require special handling.
if hasattr(sys, 'frozen') and sys.frozen == 'windows_exe': # pylint: disable=no-member
sys.stderr = DelayLog()
# debug if command line asks for it or if this a non-final release
if is_debugging_enabled_via_cli():
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
logger_sh = logging.StreamHandler()
console_formatter = logging.Formatter('%(message)s')
logger_sh.setFormatter(console_formatter)
logger.addHandler(logger_sh)
# If --debug-log parameter was passed, set up the file handler here instead
# of in CLI.py, so logs are captured from the very beginning.
debug_log_path = None
for i, arg in enumerate(sys.argv):
# --debug-log /path/file format (space delimited)
if arg == '--debug-log' and i + 1 < len(sys.argv):
debug_log_path = sys.argv[i + 1]
break
# --debug-log=/path/file format (delimited with equals sign)
if arg.startswith('--debug-log='):
debug_log_path = arg.split('=', 1)[1]
break
if debug_log_path:
if os.name != 'nt' and not os.path.exists(debug_log_path):
# This log may contain sensitive system info (paths, username),
# so keep it from being created world-readable.
fd = os.open(debug_log_path,
os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
os.close(fd)
file_handler = logging.FileHandler(debug_log_path)
# Always use DEBUG level for log file.
file_handler.setLevel(logging.DEBUG)
# removed: %(name)s
file_formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s')
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
logger.debug('Debug log file initialized at %s', debug_log_path)
return logger
def set_root_log_level(is_debug=False):
"""Adjust the root log level
This runs later in the application's startup process when the
configuration is loaded or after a change via the GUI.
"""
root_logger = logging.getLogger('bleachbit')
is_debug_effective = is_debug or is_debugging_enabled_via_cli()
root_logger.setLevel(logging.DEBUG if is_debug_effective else logging.INFO)
class GtkLoggerHandler(logging.Handler):
def __init__(self, append_text):
logging.Handler.__init__(self)
self.append_text = append_text
self.msg = ''
self.update_log_level()
def update_log_level(self):
"""Set the log level"""
from bleachbit.Options import options
if is_debugging_enabled_via_cli() or options.get('debug'):
self.min_level = logging.DEBUG
else:
self.min_level = logging.WARNING
def emit(self, record):
if record.levelno < self.min_level:
return
tag = 'error' if record.levelno >= logging.WARNING else None
msg = record.getMessage()
if record.exc_text:
msg = msg + '\n' + record.exc_text
self.append_text(msg + '\n', tag)
def write(self, msg):
self.msg += msg
if self.msg and self.msg[-1] == '\n':
tag = None
self.append_text(self.msg, tag)
self.msg = ''
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Mac.py 0000664 0001750 0001750 00000062660 15265023030 014003 0 ustar 00z z # 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.
"""
Integration specific to macOS
"""
import ctypes
import ctypes.util
import errno
import logging
import os
import platform
import plistlib
import subprocess
import xml.parsers.expat
from pathlib import Path
from bleachbit import APP_NAME, FileUtilities, IS_MAC
from bleachbit.Language import normalize_locale_code
logger = logging.getLogger(__name__)
# Shared between the Safari BinaryCookies reader and writer, so the
# reader can validate that a page's structure actually matches what
# the writer (and real Safari) produces before trusting cookie_count.
SAFARI_PAGE_MARKER = b"\x00\x00\x01\x00"
SAFARI_END_OF_TABLE_MARKER = b"\x00\x00\x00\x00"
# macOS 10.0-10.15 (Cheetah..Catalina) used '10.x' with the release
# identified by the SECOND component (the old scheme this dict was
# originally written for). Since macOS 11 (Big Sur), Apple dropped
# the '10.' prefix and the release is identified by the FIRST
# component instead (11, 12, ..., 26); using split('.')[1] against a
# modern version string reads the minor/patch number instead of the
# release identifier, so e.g. '26.6.2' incorrectly looked up '6' and
# showed 'Snow Leopard'. Two separate dicts, keyed by the field that
# actually identifies the release in each scheme.
MACOSX_DICT_LEGACY = {
'5': 'Leopard',
'6': 'Snow Leopard',
'7': 'Lion',
'8': 'Mountain Lion',
'9': 'Mavericks',
'10': 'Yosemite',
'11': 'El Capitan',
'12': 'Sierra',
'13': 'High Sierra',
'14': 'Mojave',
'15': 'Catalina',
}
MACOSX_DICT_MODERN = {
'11': 'Big Sur',
'12': 'Monterey',
'13': 'Ventura',
'14': 'Sonoma',
'15': 'Sequoia',
'26': 'Tahoe',
'27': 'Golden Gate',
}
def _read_global_preferences_plist():
"""Return the parsed contents of ~/Library/Preferences/
.GlobalPreferences.plist as a dict, or None if it cannot be read
or does not parse to a dictionary."""
path = os.path.expanduser(
'~/Library/Preferences/.GlobalPreferences.plist')
try:
with open(path, 'rb') as f:
data = plistlib.load(f)
if isinstance(data, dict):
return data
logger.debug('unexpected non-dict root in %s: %r',
path, type(data))
return None
except (OSError, ValueError, xml.parsers.expat.ExpatError) as e:
logger.debug('failed to read %s: %s', path, e)
return None
def _get_apple_locale_via_defaults():
"""Return AppleLocale by shelling out to `defaults read -g
AppleLocale`, or None on any failure.
This is the fallback path: reliable when tested from a terminal,
but observed to consistently fail (falling through to the 'C'
locale) when the app is launched via an actual Finder double-click
rather than a terminal or `open` -- `_read_global_preferences_plist`
above does not depend on spawning a subprocess at all, so it is
unaffected by whatever session/TCC context differs between those
two launch paths, and is tried first.
"""
try:
result = subprocess.run(
['defaults', 'read', '-g', 'AppleLocale'],
capture_output=True, text=True, timeout=2, check=False)
except (OSError, ValueError, subprocess.SubprocessError) as e:
# SubprocessError covers TimeoutExpired, which is not an OSError.
logger.debug('failed to read AppleLocale: %s', e)
return None
if result.returncode != 0:
return None
value = result.stdout.strip()
return value or None
def get_macos_locale():
"""Return the user's preferred locale on macOS, e.g. 'es_ES'.
On macOS, locale.getlocale() reflects POSIX environment variables
(LANG/LC_ALL), which are not set when the app is launched from
Finder (as opposed to a terminal) and can be stale or inconsistent
with the user's actual System Settings > General > Language & Region
preference. AppleLocale reflects the real system preference
regardless of how the app was launched.
Prefer reading AppleLocale directly from .GlobalPreferences.plist
(pure file I/O, no subprocess); if AppleLocale is absent from the
plist, fall back to the first entry of AppleLanguages in the same
plist, and finally to `defaults read -g AppleLocale` if the plist
itself cannot be read.
"""
value = None
prefs = _read_global_preferences_plist()
if isinstance(prefs, dict):
apple_locale = prefs.get('AppleLocale')
if isinstance(apple_locale, str) and apple_locale:
value = apple_locale
else:
languages = prefs.get('AppleLanguages')
if isinstance(languages, (list, tuple)) and languages:
first_lang = languages[0]
if isinstance(first_lang, str) and first_lang:
# AppleLanguages uses hyphens ('es-ES'); AppleLocale and
# everywhere else in this codebase use underscores.
value = first_lang.replace('-', '_')
if not value or not isinstance(value, str):
value = _get_apple_locale_via_defaults()
if not isinstance(value, str) or not value:
return None
# AppleLocale can include a variant suffix like 'es_ES@currency=EUR'.
value = normalize_locale_code(value)
return value or None
def bind_libintl_domain(text_domain, locale_dir):
"""Bind the gettext text domain through GNU libintl using ctypes.
Gtk.Builder translates .ui files (such as the hamburger menu) with the
C library libintl, not with Python's gettext. Python builds for macOS
(Homebrew, python.org) lack locale.bindtextdomain(), so without this
call the .mo files are never found and these strings stay in English.
The candidates are tried in order, so that the libintl that GTK uses
is found first (inside an app bundle, DYLD_LIBRARY_PATH resolves the
bare name to the bundled copy).
Returns True if the domain was bound.
"""
candidates = [
'libintl.8.dylib',
'libintl.dylib',
]
found = ctypes.util.find_library('intl')
if found and found not in candidates:
candidates.append(found)
candidates.extend((
'/opt/homebrew/lib/libintl.8.dylib', # Homebrew on Apple silicon
'/usr/local/lib/libintl.8.dylib', # Homebrew on Intel
'/opt/local/lib/libintl.8.dylib', # MacPorts
))
libintl = None
for name in candidates:
try:
libintl = ctypes.CDLL(name)
break
except OSError as e:
logger.debug('could not load %s: %s', name, e)
if libintl is None:
logger.warning(
'internationalization library (libintl) was not found, '
'so translations will not work.')
return False
try:
libintl.libintl_bindtextdomain.argtypes = [
ctypes.c_char_p, ctypes.c_char_p]
libintl.libintl_bindtextdomain.restype = ctypes.c_char_p
libintl.libintl_bind_textdomain_codeset.argtypes = [
ctypes.c_char_p, ctypes.c_char_p]
libintl.libintl_bind_textdomain_codeset.restype = ctypes.c_char_p
libintl.libintl_textdomain.argtypes = [ctypes.c_char_p]
libintl.libintl_textdomain.restype = ctypes.c_char_p
encoded_domain = text_domain.encode('utf-8')
if libintl.libintl_bindtextdomain(
encoded_domain, os.fsencode(locale_dir)) is None:
logger.warning('libintl_bindtextdomain() failed.')
return False
libintl.libintl_bind_textdomain_codeset(encoded_domain, b'UTF-8')
libintl.libintl_textdomain(encoded_domain)
except AttributeError as e:
logger.warning('libintl does not export the expected functions: %s', e)
return False
logger.debug('libintl text domain %s bound to %s', text_domain, locale_dir)
return True
def notify_macos(msg):
"""Show a pop-up notification on macOS via osascript.
The macOS GTK stack ships no libnotify typelib, so route the message
through AppleScript's ``display notification``, which posts it to the
system Notification Center.
"""
if not IS_MAC:
raise RuntimeError("notify_macos() is only for macOS")
def _escape(text):
# AppleScript string literals: escape backslash then double quote.
return text.replace('\\', '\\\\').replace('"', '\\"')
script = 'display notification "{}" with title "{}"'.format(
_escape(msg), _escape(APP_NAME))
try:
subprocess.run(['osascript', '-e', script],
capture_output=True, timeout=5, check=False)
except (OSError, subprocess.SubprocessError) as e:
logger.debug('osascript notification failed: %s', e)
# Paths that macOS guards with TCC (Transparency, Consent and Control)
# when Full Disk Access is not granted to the calling process. Each is
# TCC-protected and expected to exist on a typical macOS install, so an
# EPERM when scanning one of them is a strong signal that FDA is off.
_FDA_PROBE_PATHS = (
'~/Library/Safari/',
'~/Library/Caches/com.apple.Safari/',
'~/Library/Containers/com.apple.Safari/',
)
def is_full_disk_access_enabled():
"""Detect whether Full Disk Access is enabled on macOS.
Returns False if a TCC-protected probe path is blocked with EPERM.
Returns True on non-macOS, when no probe exists, or when accessible.
"""
if not IS_MAC:
return True
for probe in _FDA_PROBE_PATHS:
path = os.path.expanduser(probe)
if not os.path.exists(path):
logger.debug('FDA probe skipped: %s (does not exist)', path)
continue
try:
# Consume the iterator so the scandir() call actually opens
# the directory and triggers the TCC check.
list(os.scandir(path))
except OSError as e:
if e.errno == errno.EPERM:
logger.debug('FDA probe blocked: %s', path)
return False
# EACCES is a normal Unix mode issue, not a TCC guard.
# Any other OSError is also not a TCC signal.
logger.debug('FDA probe skipped %s: %s', path, e)
return True
def macos_version_name(version=None):
"""Return the marketing/release name for a macOS version string, e.g. 'Tahoe'."""
if version is None:
if not hasattr(platform, 'mac_ver'):
return None
version = platform.mac_ver()[0]
if not version:
return None
version_parts = version.split('.')
version_major = version_parts[0]
name = MACOSX_DICT_MODERN.get(version_major)
if name is None and version_major == '10' and len(version_parts) > 1:
name = MACOSX_DICT_LEGACY.get(version_parts[1])
return name
def _get_cookie_disk_size(path):
"""Return total on-disk footprint for a cookie file."""
if not os.path.exists(path):
return 0
try:
return FileUtilities.getsize(path)
except OSError as exc:
logger.debug('Failed to get size for %s: %s', path, exc)
return 0
def is_safari_binarycookies(path):
"""Return True if path is a Safari Binary Cookies database."""
try:
with open(path, 'rb') as f:
return f.read(4) == b'cook'
except OSError:
return False
def list_safari_cookies(path):
"""List unique cookie domains from a Safari Binary Cookies database."""
import struct
with open(path, 'rb') as f:
data = f.read()
if len(data) < 8 or data[:4] != b'cook':
raise ValueError(f"invalid Safari cookies file: {path}")
# Binary Cookies file header:
# 4 bytes: magic "cook"
# 4 bytes: page count (big endian)
# 4 * page_count: page sizes (big endian)
page_count = struct.unpack_from(">I", data, 4)[0]
if page_count == 0:
return []
page_table_end = 8 + (page_count * 4)
if page_table_end > len(data):
raise ValueError(f"invalid Safari cookies page table: {path}")
page_sizes = struct.unpack_from(
f">{page_count}I", data, 8)
domains = set()
offset = page_table_end
for page_size in page_sizes:
if page_size < 8 or offset + page_size > len(data):
raise ValueError(f"invalid Safari cookies page size: {path}")
page = data[offset:offset + page_size]
# Safari BinaryCookies page header is little endian:
# +0: page marker (constant)
# +4: cookie count
# +8: cookie record offsets
if page[0:4] != SAFARI_PAGE_MARKER:
raise ValueError(f"invalid Safari cookies page marker: {path}")
cookie_count = struct.unpack_from(" len(page):
raise ValueError(f"invalid Safari cookie offsets: {path}")
if page[offsets_end:offsets_end + 4] != SAFARI_END_OF_TABLE_MARKER:
raise ValueError(
f"invalid Safari cookies end-of-table marker: {path}")
for i in range(cookie_count):
cookie_offset = struct.unpack_from(
" len(page):
continue
cookie_size = struct.unpack_from(
" len(page):
continue
# Cookie record:
# +0 size
# +4 unknown
# +8 flags
# +12 unknown
# +16 domain string offset
# +20 name string offset
# +24 path string offset
# +28 value string offset
domain_offset = struct.unpack_from(
"= len(page):
continue
end = page.find(b'\x00', domain_pos)
if end == -1:
continue
domain = page[domain_pos:end].decode(
'utf-8', errors='ignore').strip().lower()
if domain:
domains.add(domain.lstrip('.'))
offset += page_size
return sorted(domains)
def _read_safari_cookie_records(path):
"""Read Safari BinaryCookies and return pages with cookie records."""
import struct
with open(path, 'rb') as f:
data = f.read()
if len(data) < 8 or data[:4] != b'cook':
raise ValueError(f"invalid Safari cookies file: {path}")
page_count = struct.unpack_from(">I", data, 4)[0]
page_table_end = 8 + (page_count * 4)
if page_table_end > len(data):
raise ValueError(f"invalid Safari cookies page table: {path}")
page_sizes = struct.unpack_from(
f">{page_count}I", data, 8)
pages = []
offset = page_table_end
for page_size in page_sizes:
if page_size < 8 or offset + page_size > len(data):
raise ValueError(f"invalid Safari cookies page size: {path}")
page = data[offset:offset + page_size]
# The page marker and end-of-table marker are constants the
# writer always emits (see safari_pages_to_bytes below); the
# writer always produces both correctly, so relying only on
# cookie_count without checking them would silently trust a
# page that a hand-crafted or corrupted file never actually
# laid out this way.
if page[0:4] != SAFARI_PAGE_MARKER:
raise ValueError(f"invalid Safari cookies page marker: {path}")
cookie_count = struct.unpack_from(" len(page):
raise ValueError(f"invalid Safari cookie offsets: {path}")
if page[offsets_end:offsets_end + 4] != SAFARI_END_OF_TABLE_MARKER:
raise ValueError(
f"invalid Safari cookies end-of-table marker: {path}")
records = []
for i in range(cookie_count):
cookie_offset = struct.unpack_from(
" len(page):
continue
cookie_size = struct.unpack_from(
" len(page):
continue
record = page[cookie_offset:cookie_offset + cookie_size]
domain_offset = struct.unpack_from(
"= len(record):
continue
end = record.find(b'\x00', domain_pos)
if end == -1:
continue
domain = record[domain_pos:end].decode(
'utf-8', errors='ignore').strip().lower()
if domain:
records.append((domain.lstrip('.'), record))
# Not preserved: the real per-page layout is
# +0 4 bytes page marker constant 0x00000100
# +4 4 bytes cookie count
# +8 N*4 offset table
# +8+N*4 4 end-of-table marker 0x00000000
# ... cookie records
# There is no meaningful "unknown" field at +8: on write, the
# marker and offset table are always regenerated from scratch.
pages.append({
"records": records,
})
offset += page_size
return pages
def _serialize_safari_cookie_records(pages):
"""Serialize Safari BinaryCookies pages to bytes.
Per-page layout (all fields little endian), confirmed against
independent implementations of the format:
+0 4 bytes page marker, constant 0x00000100
+4 4 bytes cookie count (N)
+8 N*4 offset table (absolute, page-relative)
+8+N*4 4 bytes end-of-table marker, constant 0x00000000
+12+N*4 ... cookie records, back to back
Note this does NOT store the page's own size inside the page: the
size of each page is only recorded in the file-level page-size
table (see the caller), exactly as real Safari-written files do.
"""
import struct
output_pages = []
for page in pages:
records = page["records"]
header = bytearray()
# Page marker (constant, NOT a page-size field).
header.extend(SAFARI_PAGE_MARKER)
# Cookie count.
header.extend(struct.pack("I", len(output_pages)))
for page in output_pages:
data.extend(struct.pack(">I", len(page)))
for page in output_pages:
data.extend(page)
return bytes(data)
def _write_safari_cookie_records(path, pages):
"""Rewrite a Safari BinaryCookies database with selected records."""
import tempfile
data = _serialize_safari_cookie_records(pages)
directory = str(Path(path).parent)
fd, temp_path = tempfile.mkstemp(
prefix=".Cookies.binarycookies.",
dir=directory
)
try:
with open(fd, 'wb', closefd=True) as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, path)
except Exception:
try:
os.unlink(temp_path)
except OSError:
pass # best-effort cleanup, original error takes priority
raise
def _cookie_result(total_deleted, total_kept, skipped, whole_file_deleted,
file_size_reduction, method=None, ratio=None, in_memory=None):
"""Build a Safari cookie deletion result dict with a stable schema.
Every branch returns the same set of keys; estimation fields default
to None when not applicable (e.g. whole-file delete, error fallback).
"""
return {
"total_deleted": total_deleted,
"total_kept": total_kept,
"skipped": skipped,
"whole_file_deleted": whole_file_deleted,
"file_size_reduction": file_size_reduction,
"file_size_estimation_method": method,
"file_size_reduction_ratio": ratio,
"file_size_reduction_in_memory": in_memory,
}
def _filter_safari_cookie_records(pages, keep_domains):
"""Split cookie records into kept and deleted sets by domain.
A record is kept when its domain equals or is a subdomain of any
domain in keep_domains.
Returns (new_pages, total_before, kept_count, deleted_count).
"""
total_before = 0
kept_count = 0
deleted_count = 0
new_pages = []
for page in pages:
new_records = []
for domain, record in page["records"]:
total_before += 1
keep = any(
domain == d or domain.endswith("." + d)
for d in keep_domains
)
if keep:
kept_count += 1
new_records.append((domain, record))
else:
deleted_count += 1
new_pages.append({"records": new_records})
return new_pages, total_before, kept_count, deleted_count
def _preview_safari_cookie_deletion(deleted_count, kept_count, total_before,
original_size):
"""Build a preview result dict without modifying the file."""
if kept_count == 0:
return _cookie_result(
total_deleted=deleted_count,
total_kept=0,
skipped=False,
whole_file_deleted=True,
file_size_reduction=original_size,
method="whole_file",
)
ratio_estimate = 0
if total_before > 0:
ratio_estimate = int((deleted_count / total_before) * original_size)
return _cookie_result(
total_deleted=deleted_count,
total_kept=kept_count,
skipped=False,
whole_file_deleted=False,
file_size_reduction=ratio_estimate,
method="ratio",
ratio=ratio_estimate,
)
def _apply_safari_cookie_deletion(path, new_pages, kept_count, deleted_count,
original_size, shred_enabled):
"""Perform real deletion and return a result dict."""
if deleted_count == 0:
return _cookie_result(
total_deleted=0,
total_kept=kept_count,
skipped=False,
whole_file_deleted=False,
file_size_reduction=0,
method="actual",
)
if kept_count == 0:
try:
FileUtilities.delete(path, shred_enabled)
return _cookie_result(
total_deleted=deleted_count,
total_kept=0,
skipped=False,
whole_file_deleted=True,
file_size_reduction=original_size,
)
except OSError as e:
logger.error(
"Failed to delete Safari cookie database %s: %s",
path, e)
return _cookie_result(
total_deleted=0,
total_kept=0,
skipped=True,
whole_file_deleted=False,
file_size_reduction=0,
)
try:
_write_safari_cookie_records(path, new_pages)
except (OSError, ValueError) as e:
logger.error(
"Failed to rewrite Safari cookie database %s: %s",
path, e)
return _cookie_result(
total_deleted=0,
total_kept=kept_count,
skipped=True,
whole_file_deleted=False,
file_size_reduction=0,
)
new_size = _get_cookie_disk_size(path)
return _cookie_result(
total_deleted=deleted_count,
total_kept=kept_count,
skipped=False,
whole_file_deleted=False,
file_size_reduction=max(0, original_size - new_size),
method="actual",
)
def delete_safari_cookies(path, keep_list, really_delete=False):
"""Process Safari cookies with optional deletion based on keep list
Args:
path (str): Path to the cookies database file
keep_list (set): Set of hosts to preserve (must not be empty)
really_delete (bool): If True, perform actual deletion. If False, only preview.
Returns:
dict: Results dictionary with deletion statistics
Raises:
ValueError: If keep_list is empty
"""
if not keep_list:
raise ValueError("keep_list must not be empty")
assert isinstance(keep_list, set)
from bleachbit.Options import options
shred_enabled = options.get('shred')
original_size = _get_cookie_disk_size(path)
if original_size <= 0:
raise RuntimeError(
f"cookies database is empty: {path}")
pages = _read_safari_cookie_records(path)
keep_domains = [str(d).lstrip('.').lower() for d in keep_list]
new_pages, total_before, kept_count, deleted_count = \
_filter_safari_cookie_records(pages, keep_domains)
if not really_delete:
return _preview_safari_cookie_deletion(
deleted_count, kept_count, total_before, original_size)
return _apply_safari_cookie_deletion(
path, new_pages, kept_count, deleted_count,
original_size, shred_enabled)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Memory.py 0000775 0001750 0001750 00000044050 15265023030 014547 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Wipe memory
"""
import logging
import os
import re
import subprocess
import sys
from bleachbit import FileUtilities
from bleachbit import General
from bleachbit import IS_LINUX, IS_MAC, IS_WINDOWS
from bleachbit import Log
from bleachbit.Language import get_text as _
from bleachbit.Wipe import wipe_write
logger = logging.getLogger(__name__)
# Do not match "SwapCached".
_MEMINFO_FREE_RE = re.compile(r'^(MemFree|Cached):[ ]*([0-9]*) kB')
def count_swap_linux():
"""Count the number of swap devices in use"""
count = 0
with open("/proc/swaps", encoding="utf-8") as f:
for line in f:
if line[0] == '/':
count += 1
return count
def get_proc_swaps():
"""Return the output of 'swapon -s'"""
# Usually 'swapon -s' is identical to '/proc/swaps'
# Here is one exception:
# https://bugs.launchpad.net/ubuntu/+source/bleachbit/+bug/1092792
(rc, stdout, _stderr) = General.run_external(
[General.resolve_exe('swapon'), '-s'])
if 0 == rc:
return stdout
logger.debug(
_("The command 'swapon -s' failed, so falling back to /proc/swaps for swap information."))
with open("/proc/swaps", encoding="utf-8") as f:
return f.read()
def parse_swapoff(swapoff):
"""Parse the output of swapoff and return the device name"""
# English is 'swapoff on /dev/sda5' but German is 'swapoff für ...'
# Example output in English with LVM and hyphen: 'swapoff on /dev/mapper/lubuntu-swap_1'
# This matches swap devices and swap files
ret = re.search(r'^swapoff (\w* )?(/[\w/.-]+)$', swapoff)
if not ret:
# no matches
return None
return ret.group(2)
def disable_swap_linux():
"""Disable Linux swap and return list of devices"""
if 0 == count_swap_linux():
return None
logger.debug(_("Disabling swap."))
args = [General.resolve_exe('swapoff'), "-a", "-v"]
(rc, stdout, stderr) = General.run_external(args)
if 0 != rc:
raise RuntimeError(stderr.replace("\n", ""))
devices = []
for line in stdout.split('\n'):
line = line.replace('\n', '')
if '' == line:
continue
ret = parse_swapoff(line)
if ret is None:
raise RuntimeError(f"Unexpected output:\nargs='{args}'\n"
f"stdout='{stdout}'\nstderr='{stderr}'")
devices.append(ret)
return devices
def enable_swap_linux():
"""Enable Linux swap"""
logger.debug(_("Re-enabling swap."))
args = [General.resolve_exe('swapon'), "-a"]
(rc, _stdout, stderr) = General.run_external(args)
if 0 != rc:
raise RuntimeError(stderr.replace("\n", ""))
def make_self_oom_target_linux(uid=None):
"""Make the current process the primary target for Linux out-of-memory killer
The optional ``uid`` avoids relying on environment variables such as
SUDO_UID, which systemd-run does not forward to transient units by
default. When ``uid`` is None, the real user ID is looked up as before.
"""
path = f'/proc/{os.getpid()}/oom_score_adj'
if os.path.exists(path):
with open(path, 'w', encoding='utf-8') as f:
f.write('1000')
# OOM likes nice processes
logger.debug(_("Setting nice value %d for this process."), os.nice(19))
# OOM prefers non-privileged processes
try:
if uid is None:
uid = General.get_real_uid()
if uid > 0:
drop_msg = _(
# TRANSLATORS: Debug message when a process gives up root/admin privileges.
# %(pid)d is the integer process ID; %(uid)d is the integer user ID to switch to.
"Dropping privileges of process ID %(pid)d to user ID %(uid)d.")
logger.debug(drop_msg, {'pid': os.getpid(), 'uid': uid})
os.seteuid(uid)
except Exception:
logger.exception('Error when dropping privileges')
def fill_memory_linux():
"""Fill unallocated memory"""
report_free()
allocbytes = int(physical_free() * 0.4)
if allocbytes < 1024:
return
bytes_str = FileUtilities.bytes_to_human(allocbytes)
# TRANSLATORS: The variable is a quantity like 5kB
logger.info(_("Allocating and wiping %s of memory."),
bytes_str)
try:
buf = '\x00' * allocbytes
except MemoryError:
logger.debug('could not allocate %s, so stopping here', bytes_str)
else:
fill_memory_linux()
# TRANSLATORS: The variable is a quantity like 5kB
logger.debug(_("Freeing %s of memory."), bytes_str)
del buf
report_free()
def _memory_child_script(real_uid):
"""Return the Python source executed by the memory-wiping child process
The child makes itself the OOM target and fills memory. ``real_uid`` is
passed explicitly because systemd-run does not forward the caller's
environment (e.g. SUDO_UID) to transient units. When ``real_uid`` is
None the child re-derives it via ``General.get_real_uid()``, which
inside a systemd scope typically resolves to 0 (root) and skips the
privilege drop -- this is acceptable because the scope already runs
with the caller's credentials.
"""
return (
"from bleachbit.Memory import make_self_oom_target_linux, "
"fill_memory_linux; "
f"make_self_oom_target_linux({real_uid!r}); "
"fill_memory_linux()"
)
def _run_memory_child_systemd_scope():
"""Run the memory-wiping child in its own transient systemd scope.
The child is isolated via a separate scope so that systemd's default
OOMPolicy=stop (see DefaultOOMPolicy in systemd-system.conf(5)) does
not kill the parent (and the rest of the parent's session unit) when
the kernel OOM-kills the child. Without this isolation the parent
dies before it can re-enable swap, leaving the system without swap.
Returns the child's exit status (0 on success, the signal number if
killed by a signal such as the OOM killer, or None if the child could
not be started this way -- in which case the caller should fall back
to ``_run_memory_child_fork``).
"""
systemd_run = General.resolve_exe('systemd-run')
if not FileUtilities.exe_exists(systemd_run):
return None
try:
real_uid = General.get_real_uid()
except Exception:
real_uid = None
# This child runs Python as root, so keep an inherited PATH/LD_*/PYTHONPATH
# from redirecting it. sanitize_root_env drops LD_* and unsafe PATH entries;
# PYTHONPATH is set to only our package's parent (enough to import
# bleachbit) rather than appending the inherited value.
pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
env = General.sanitize_root_env(os.environ.copy())
env['PYTHONPATH'] = pkg_parent
# The child is launched via "python -c", so it cannot see --debug in
# sys.argv. Forward the parent's debug state via an environment variable
# so the child's logger (initialized on import) matches the parent's.
if Log.is_debugging_enabled_via_cli():
env['BLEACHBIT_DEBUG'] = '1'
# Include the PID so concurrent runs do not collide on a fixed unit
# name (systemd-run refuses to create a unit that already exists).
args = [
systemd_run, '--scope', '--collect', '--quiet',
f'--unit=bleachbit-wipe-memory-{os.getpid()}',
'--property=OOMPolicy=kill',
'--', sys.executable, '-c', _memory_child_script(real_uid),
]
def run_scope(scope_args):
logger.debug('Running command: %s', ' '.join(scope_args))
try:
# cwd=pkg_parent so "python -c"'s implicit '' sys.path entry
# resolves there, not an attacker-controlled working directory.
return subprocess.run(
scope_args, env=env, cwd=pkg_parent,
capture_output=True, text=True, check=False)
except FileNotFoundError:
return None
proc = run_scope(args)
if proc is None:
return None
# Ubuntu 22.04 LTS (supported until April 2027) uses systemd 249, which
# does not support OOMPolicy= for scope units. A separate scope still
# isolates the child from the parent, so retry without that property.
if proc.returncode != 0 and 'OOMPolicy' in (proc.stderr or ''):
legacy_args = [
arg for arg in args if arg != '--property=OOMPolicy=kill']
proc = run_scope(legacy_args)
if proc is None:
return None
rc = proc.returncode
# subprocess.run reports -N when the child died from signal N (e.g. the
# OOM killer sends SIGKILL, reported as -9). Normalize to the signal
# number so the caller can recognize OOM death as 9.
if rc < 0:
return -rc
# A non-zero, non-signal exit code means systemd-run itself failed to
# create the scope (e.g. no systemd as PID 1, no D-Bus session, or a
# permission error) -- the child never ran. Fall back to fork so
# memory is still wiped in those environments.
if rc != 0:
err = (proc.stderr or '').strip()
if err:
logger.debug(
'systemd-run returned %d (%s); falling back to fork()',
rc, err)
else:
logger.debug(
'systemd-run returned %d; falling back to fork()', rc)
return None
return rc
def _run_memory_child_fork():
"""Run the memory-wiping child via os.fork() (fallback used when
systemd-run is unavailable). Returns the child's exit status."""
try:
real_uid = General.get_real_uid()
except Exception:
real_uid = None
child_pid = os.fork()
if 0 == child_pid:
make_self_oom_target_linux(real_uid)
fill_memory_linux()
os._exit(0)
else:
# The else is load-bearing: tests mock os._exit, so without it the
# child path would fall through into the parent's waitpid().
# TRANSLATORS: This is a debugging message that the parent process
# is waiting for the child process. %(parent_pid)d is the parent
# process ID; %(child_pid)d is the child process ID.
logger.debug(_("The function wipe_memory() with process ID %(parent_pid)d is "
"waiting for child process ID %(child_pid)d."),
{'parent_pid': os.getpid(), 'child_pid': child_pid})
status = os.waitpid(child_pid, 0)[1]
if os.WIFSIGNALED(status):
return os.WTERMSIG(status)
return os.WEXITSTATUS(status)
def get_swap_size_linux(device, proc_swaps=None):
"""Return the size of the partition in bytes"""
if proc_swaps is None:
proc_swaps = get_proc_swaps()
line = proc_swaps.split('\n')[0]
if not re.search(r'Filename\s+Type\s+Size', line):
raise RuntimeError("Unexpected first line in swap summary '%s'" % line)
size_re = re.compile(r"%s\s+\w+\s+([0-9]+)\s" % device)
for line in proc_swaps.split('\n')[1:]:
ret = size_re.search(line)
if ret:
return int(ret.group(1)) * 1024
raise RuntimeError("error: cannot find size of swap device '%s'\n%s" %
(device, proc_swaps))
def get_swap_uuid(device):
"""Find the UUID for the swap device"""
uuid = None
args = [General.resolve_exe('blkid'),
device, '-s', 'UUID']
(_rc, stdout, _stderr) = General.run_external(args)
uuid_re = re.compile(r"^%s: UUID=\"([a-z0-9-]+)\"" % device)
for line in stdout.split('\n'):
# example: /dev/sda5: UUID="ee0e85f6-6e5c-42b9-902f-776531938bbf"
ret = uuid_re.search(line)
if ret is not None:
uuid = ret.group(1)
# TRANSLATORS: Debug message. 'Found' is a past tense verb (short for
# "Found [that] the UUID for swap device ..."). %(device)s is the device
# path (e.g., /dev/sda5); %(uuid)s is a UUID string
# (e.g., ee0e85f6-6e5c-42b9-902f-776531938bbf). Do not translate variables.
logger.debug(_("Found UUID for swap device %(device)s is %(uuid)s."),
{'device': device, 'uuid': uuid})
return uuid
def physical_free_darwin(run_vmstat=None):
def parse_line(k, v):
return k, int(v.strip(" ."))
def get_page_size(line):
m = re.match(
r"Mach Virtual Memory Statistics: \(page size of (\d+) bytes\)",
line)
if m is None:
raise RuntimeError("Can't parse vm_stat output")
return int(m.groups()[0])
if run_vmstat is None:
def run_vmstat():
# sanitize the env so a hostile inherited LD_*/DYLD_* cannot
# redirect this child when BleachBit runs as root
return subprocess.check_output(
[General.resolve_exe('vm_stat')],
text=True, env=General.sanitize_root_env(dict(os.environ)))
output = iter(run_vmstat().split("\n"))
page_size = get_page_size(next(output))
vm_stat = dict(parse_line(*l.split(":")) for l in output if l != "")
return vm_stat["Pages free"] * page_size
def _parse_meminfo_free(meminfo_text):
"""Parse the contents of /proc/meminfo and return the total free
bytes (MemFree + Cached) plus the number of matched lines.
Returns a tuple (free_bytes, num_matches).
"""
free_bytes = 0
num_matches = 0
for line in meminfo_text.splitlines():
ret = _MEMINFO_FREE_RE.search(line)
if ret is not None:
kb = int(ret.group(2))
free_bytes += kb * 1024
num_matches += 1
return free_bytes, num_matches
def physical_free_linux():
"""Return the physical free memory on Linux"""
with open("/proc/meminfo", encoding="utf-8") as f:
free_bytes, _ = _parse_meminfo_free(f.read())
if free_bytes > 0:
return free_bytes
raise RuntimeError("unknown")
def physical_free_windows():
"""Return physical free memory on Windows"""
from ctypes import c_ulong, c_ulonglong, Structure, sizeof, windll, byref
class MEMORYSTATUSEX(Structure):
_fields_ = [
('dwLength', c_ulong),
('dwMemoryLoad', c_ulong),
('ullTotalPhys', c_ulonglong),
('ullAvailPhys', c_ulonglong),
('ullTotalPageFile', c_ulonglong),
('ullAvailPageFile', c_ulonglong),
('ullTotalVirtual', c_ulonglong),
('ullAvailVirtual', c_ulonglong),
('ullExtendedVirtual', c_ulonglong),
]
def GlobalMemoryStatusEx():
x = MEMORYSTATUSEX()
x.dwLength = sizeof(x)
windll.kernel32.GlobalMemoryStatusEx(byref(x))
return x
z = GlobalMemoryStatusEx()
return z.ullAvailPhys
def physical_free():
if IS_LINUX:
return physical_free_linux()
if IS_WINDOWS:
return physical_free_windows()
if IS_MAC:
return physical_free_darwin()
raise RuntimeError('unsupported platform for physical_free()')
def report_free():
"""Report free memory"""
bytes_free = physical_free()
bytes_str = FileUtilities.bytes_to_human(bytes_free)
# TRANSLATORS: The variable is a quantity like 5kB
logger.debug(_("Physical free memory is %s."),
bytes_str)
def wipe_swap_linux(devices, proc_swaps):
"""Shred the Linux swap file and then reinitialize it"""
if devices is None:
return
if 0 < count_swap_linux():
raise RuntimeError('Cannot wipe swap while it is in use')
for device in devices:
# TRANSLATORS: The variable is a device like /dev/sda2
logger.info(_("Wiping the swap device %s."), device)
safety_limit_bytes = 29 * 1024 ** 3 # 29 gibibytes
actual_size_bytes = get_swap_size_linux(device, proc_swaps)
if actual_size_bytes > safety_limit_bytes:
raise RuntimeError(
f'swap device {device} is larger ({actual_size_bytes})'
f' than expected ({safety_limit_bytes})')
uuid = get_swap_uuid(device)
# overwrite with zeros without truncating the device
wipe_write(device).close()
# reinitialize
# TRANSLATORS: The variable is a device like /dev/sda2
logger.debug(_("Reinitializing the swap device %s."), device)
args = [General.resolve_exe('mkswap'), device]
if uuid:
args.append("-U")
args.append(uuid)
(rc, _stdout, stderr) = General.run_external(args)
if 0 != rc:
raise RuntimeError(stderr.replace("\n", ""))
def wipe_memory():
"""Wipe unallocated memory"""
if not IS_LINUX:
raise RuntimeError(
'wipe_memory() requires Linux')
for name in ('swapon', 'swapoff', 'blkid'):
cmd = General.resolve_exe(name)
if not FileUtilities.exe_exists(cmd):
raise RuntimeError(f"wipe_memory: Command {cmd} not found")
# cache the file because 'swapoff' changes it
proc_swaps = get_proc_swaps()
devices = disable_swap_linux()
yield True # process GTK+ idle loop
# TRANSLATORS: The variable is a device like /dev/sda2
logger.debug(_("Detected these swap devices: %s"), str(devices))
wipe_swap_linux(devices, proc_swaps)
yield True
# Prefer a separate systemd scope so that systemd's unit-level OOM
# policy cannot kill the parent when the child is OOM-killed. Fall back
# to a plain fork where systemd-run is unavailable.
rc = _run_memory_child_systemd_scope()
if rc is None:
rc = _run_memory_child_fork()
if rc not in (0, 9):
logger.warning(
_("The child memory-wiping process returned code %d."), rc)
enable_swap_linux()
yield 0 # how much disk space was recovered
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Network.py 0000664 0001750 0001750 00000031443 15265023030 014727 0 ustar 00z z # 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.
"""
Check for updates via the Internet
"""
# standard library
from bisect import bisect_right
import hashlib
import logging
import os
import re
import socket
import sys
import platform
import warnings
from collections.abc import Callable
from urllib.parse import quote
# local imports
from bleachbit import bleachbit_exe_path, comms_check_url, APP_VERSION, ARCH_BITS, General, IS_LINUX, IS_MAC, IS_NETBSD, IS_WINDOWS
from bleachbit.FileUtilities import delete, open_for_overwrite
from bleachbit.General import unset_sslkeylogfile
from bleachbit.Language import get_active_language_code, get_text as _
# urllib3 v2 warns when the ssl module is not OpenSSL (e.g. macOS
# LibreSSL). The warning is informational and does not prevent
# urllib3 from functioning, so suppress it before importing urllib3.
warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
try:
from urllib3.util.retry import Retry
HAVE_URLLIB3 = True
except ImportError:
HAVE_URLLIB3 = False
# third party
try:
import requests
HAVE_REQUESTS = True
except ImportError:
HAVE_REQUESTS = False
if HAVE_REQUESTS:
RequestException = requests.exceptions.RequestException
else:
class RequestException(Exception):
pass
logger = logging.getLogger(__name__)
def download_url_to_fn(url, fn, expected_sha512=None, on_error=None,
max_retries=3, backoff_factor=0.5, timeout=60):
"""Download a URL to the given filename
fn: target filename
expected_sha512: expected SHA-512 hash
on_error: callback function in case of error
max_retries: retry count
backoff_factor: how long to wait before retries
timeout: number of seconds to wait to establish connection
return: True if succeeded, False if failed
"""
logger.info('Downloading %s to %s', url, fn)
assert isinstance(url, str)
assert isinstance(fn, str), f'fn is not a string: {repr(fn)}'
assert isinstance(expected_sha512, (type(None), str))
assert isinstance(on_error, (type(None), Callable))
assert isinstance(max_retries, int)
assert isinstance(backoff_factor, float)
assert isinstance(timeout, int)
unset_sslkeylogfile(True)
msg = _('Downloading URL failed: %s') % url
def do_error(msg2):
if on_error:
on_error(msg, msg2)
delete(fn, ignore_missing=True) # delete any partial download
try:
response = fetch_url(url, max_retries=max_retries,
backoff_factor=backoff_factor, timeout=timeout)
except RequestException as exc:
# For retryable errors (like 503), use a simplified error message
if HAVE_REQUESTS and isinstance(exc, requests.exceptions.RetryError):
msg2 = 'Server temporarily unavailable (retries exceeded)'
logger.warning("%s: %s", msg, type(exc).__name__)
else:
msg2 = f'{type(exc).__name__}: {exc}'
logger.exception(msg)
do_error(msg2)
return False
if response.status_code != 200:
logger.error(msg)
msg2 = f'HTTP status code: {response.status_code}'
do_error(msg2)
return False
if expected_sha512:
hash_actual = hashlib.sha512(response.content).hexdigest()
if hash_actual != expected_sha512:
msg2 = f"SHA-512 mismatch: expected {expected_sha512}, got {hash_actual}"
do_error(msg2)
return False
General.makedirs(os.path.dirname(fn))
with open_for_overwrite(fn, 'wb') as f:
f.write(response.content)
return True
def fetch_url(url, max_retries=3, backoff_factor=0.5, timeout=60,
headers=None):
"""Fetch a URL using requests library
Args:
url (str): URL to fetch content from
max_retries (int, optional): Maximum number of retry attempts
backoff_factor (float, optional): A backoff factor to apply between attempts
timeout (int, optional): How many seconds to wait for the server before giving up
headers (dict, optional): Extra HTTP headers appended to default headers
Returns:
requests.Response: Response object from requests library
Raises:
requests.RequestException: If there is an error fetching the URL
"""
assert isinstance(url, str)
if not url.startswith('http'):
raise ValueError(f'URL must start with http, got {url}')
assert isinstance(max_retries, int)
assert max_retries >= 0
assert isinstance(backoff_factor, float)
assert backoff_factor > 0
assert isinstance(timeout, int)
assert timeout > 0
if not HAVE_REQUESTS:
raise RequestException(
'The requests package is not installed: network features are disabled.')
if hasattr(sys, 'frozen'):
# when frozen by py2exe, certificates are in alternate location
ca_bundle = os.path.join(bleachbit_exe_path, 'cacert.pem')
if os.path.exists(ca_bundle):
requests.utils.DEFAULT_CA_BUNDLE_PATH = ca_bundle
requests.adapters.DEFAULT_CA_BUNDLE_PATH = ca_bundle
else:
logger.error(
'Application is frozen but certificate file not found: %s', ca_bundle)
assert headers is None or isinstance(headers, dict)
request_headers = {'User-Agent': get_user_agent()}
if headers:
request_headers.update(headers)
unset_sslkeylogfile(True)
# 408: request timeout
# 429: too many requests
# 500: internal server error
# 502: bad gateway
# 503: service unavailable
# 504: gateway_timeout
status_forcelist = (408, 429, 500, 502, 503, 504)
with requests.Session() as session:
if HAVE_URLLIB3:
retries = Retry(total=max_retries, backoff_factor=backoff_factor,
status_forcelist=status_forcelist, redirect=5)
session.mount(
'http://', requests.adapters.HTTPAdapter(max_retries=retries))
session.mount(
'https://', requests.adapters.HTTPAdapter(max_retries=retries))
response = session.get(url, headers=request_headers,
timeout=timeout, verify=True)
return response
COMMS_CHECK_PHRASE = 'STATION CHECK, STANDBY'
def comms_check(timeout=15, max_retries=1):
"""Smoke test the network stack (requests, TLS, and the CA bundle)"""
url = f'{comms_check_url}?msg={quote(COMMS_CHECK_PHRASE)}'
try:
response = fetch_url(url, max_retries=max_retries, timeout=timeout,
headers=get_update_request_headers())
except (RequestException, OSError) as exc:
req_exc = requests.exceptions if HAVE_REQUESTS else None
if req_exc and isinstance(exc, req_exc.Timeout):
kind = 'Drop track is jammed.'
elif req_exc and isinstance(exc, req_exc.ConnectionError):
kind = 'Dropship is offline.'
else:
kind = "That's it, man. Game over, man. Game over!"
print(f'SMOKE TEST FAILED: {kind} ({type(exc).__name__}: {exc})')
return 1
try:
payload = response.json()
except ValueError:
payload = None
if not isinstance(payload, dict):
payload = {}
speaker = payload.get('speaker')
quote_text = payload.get('quote')
reply = (f'{speaker}: {quote_text}' if speaker and quote_text
else response.text)
if response.status_code == 200 and payload.get('status') == 'ok':
print(f'SMOKE TEST SUCCESS: {reply}\n'
'Express elevator to hell, going down!')
return 0
logger.error('comms-check returned HTTP %s: %r',
response.status_code, reply[:200])
print("SMOKE TEST FAILED: That's it, man. Game over, man. Game over!")
return 1
def _version_major_minor(version):
"""Reduce one version token to at most major.minor precision
For more examples, see test_version_major_minor.
"""
# ISO date: YYYY-MM-DD or YYYY-MM -> YYYY-MM (e.g., '2008-12-21' -> '2008-12')
if re.match(r'^\d{4}-\d{2}(-\d{2})?$', version):
return version[:7]
# Compact date or Arch build: YYYYMMDD... -> YYYYMM (e.g., '20081221' -> '200812')
if re.match(r'^\d{8}', version):
return version[:6]
# Strip suffix like '-generic', '+deb14', '_1', '-STABLE' (e.g., '6.18.50_1' -> '6.18')
version = re.split(r'[-+_]', version)[0]
# Keep major.minor (e.g., '7.0.12' -> '7.0')
return '.'.join(version.split('.')[:2])
def _coarsen_os_version(os_version):
"""Reduce precision of an 'OS-name version' string
Keeps the OS/distribution name and reduces the version to
- major.minor (e.g., Linux 7.2)
- year-month (e.g., 'arch 200812')
For more examples, see test_coarsen_os_version.
"""
if not os_version:
return os_version
name, sep, rest = os_version.partition(' ')
if not sep:
# Bare token: reduce if it is a version, else it is a name
return _version_major_minor(name) if name[0].isdigit() else name
version = rest.partition(' ')[0]
if not version or not version[0].isdigit():
return os_version
return f'{name} {_version_major_minor(version)}'
def _coarsen_windows_build(os_version):
"""Reduce precision of a Windows version like '10.0.26461'
For Windows 10/11 releases within the known range, round the
build number down.
For future, unknown build numbers, keep the first two digits.
For more examples, see test_coarsen_windows_build.
"""
# Known Windows "10.0.x" versions:
# Windows 10 1507 (2015) through Windows 11 26H2 (2026).
windows10_release_builds = (
10240, 10586, 14393, 15063, 16299, 17134, 17763, 18362, 18363,
19041, 19042, 19043, 19044, 19045, 22000, 22621, 22631,
26100, 26200, 26300, 28000
)
parts = os_version.split('.')
if len(parts) != 3 or not all(p.isdigit() for p in parts):
return os_version
build = parts[2]
if int(build) > windows10_release_builds[-1]:
# Future release beyond the known builds
parts[2] = build[:2].ljust(len(build), '0')
return '.'.join(parts)
idx = bisect_right(windows10_release_builds, int(build)) - 1
if idx < 0:
return os_version
parts[2] = str(windows10_release_builds[idx])
return '.'.join(parts)
def _get_os_name_version():
"""Return (os_name, os_version) tuple for network requests."""
os_name = platform.system() # 'Linux', 'Windows', etc.
if IS_LINUX:
from bleachbit.Unix import get_distribution_name_version
os_version = get_distribution_name_version()
elif IS_MAC:
# platform.uname().version returns too much info on macOS.
# Also, drop the patch version (e.g. 26.6.2 -> 26.6).
os_version = '.'.join(platform.mac_ver()[0].split('.')[:2])
elif IS_NETBSD:
os_version = os_name + '/' + platform.machine() + ' ' + platform.release()
else:
os_version = platform.uname().version
if IS_WINDOWS:
os_version = _coarsen_windows_build(os_version)
else:
os_version = _coarsen_os_version(os_version)
return os_name, os_version
def get_update_request_headers():
"""Return headers specific to update checks."""
os_name, os_version = _get_os_name_version()
headers = {
'X-BleachBit-Version': APP_VERSION,
'X-OS-Type': os_name,
'X-OS-Version': os_version,
'X-Locale': get_active_language_code(),
}
if (gtk_version := get_gtk_version()):
headers['X-GTK-Version'] = gtk_version
if IS_WINDOWS:
headers['X-Python-Version'] = platform.python_version()
headers['X-Pointer-Bits'] = str(ARCH_BITS)
return headers
def get_gtk_version():
"""Return the version of GTK
If GTK is not available, returns None.
"""
# pylint: disable=import-error
from bleachbit.GtkShim import Gtk, is_gtk_available
if not is_gtk_available():
return None
gtk_version = (Gtk.get_major_version(),
Gtk.get_minor_version(), Gtk.get_micro_version())
return '.'.join([str(x) for x in gtk_version])
def get_ip_for_url(url):
"""Given an https URL, return the IP address"""
if not url:
return '(no URL)'
url_split = url.split('/')
if len(url_split) < 3:
return '(bad URL)'
hostname = url.split('/')[2]
try:
ip_address = socket.gethostbyname(hostname)
except socket.gaierror:
return '(socket.gaierror)'
return ip_address
def get_user_agent():
"""Return the user agent string"""
os_name, os_ver = _get_os_name_version()
locale = get_active_language_code()
parts = [os_name, os_ver, locale]
if (gtk_ver := get_gtk_version()):
parts.append(f'GTK {gtk_ver}')
return f"BleachBit/{APP_VERSION} ({'; '.join(parts)})"
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Options.py 0000664 0001750 0001750 00000064404 15265023030 014734 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Store and retrieve user preferences
"""
# standard library imports
import atexit
import configparser
import errno
import logging
import os
import re
import threading
# local application imports
import bleachbit
from bleachbit import General, IS_WINDOWS
from bleachbit.FileUtilities import open_for_overwrite
from bleachbit.Language import get_text as _
# third-party imports
if IS_WINDOWS:
from win32file import GetLongPathName
logger = logging.getLogger(__name__)
FLUSH_DELAY_SECS = 15.0 # decimal seconds
# Matches a Windows drive-letter key that lost its colon to ConfigParser
_HASHPATH_DRIVE_RE = re.compile(r'^[a-z]\\')
OPTION_DEFAULTS = {
'auto_hide': {'value': True},
'auto_detect_lang': {'value': True},
'check_beta': {'value': False},
'check_online_updates': {'value': True},
'dark_mode': {'value': True},
'debug': {'value': False},
'delete_confirmation': {'value': True},
'expert_mode': {'value': False},
'exit_done': {'value': False},
'first_start': {'value': False},
'kde_shred_menu_option': {'value': False},
'load_cleaners': {'value': True},
'remember_geometry': {'value': True},
'shred': {'value': False},
'units_iec': {'value': False},
'window_maximized': {'value': False},
'window_fullscreen': {'value': False},
'font_check_completed': {'value': False, 'platforms': ('nt',)},
'update_winapp2': {'value': False, 'platforms': ('nt',)},
'use_fontconfig_backend': {'value': False, 'platforms': ('nt',)},
'win10_theme': {'value': False, 'platforms': ('nt',)},
}
def _platform_allows(meta):
platforms = meta.get('platforms')
return not platforms or os.name in platforms
def _get_default_value(option):
meta = OPTION_DEFAULTS.get(option)
if not meta or not _platform_allows(meta):
return None
return meta['value']
boolean_keys = frozenset(
key for key, meta in OPTION_DEFAULTS.items()
if _platform_allows(meta)
)
int_keys = frozenset(('window_x', 'window_y', 'window_width', 'window_height',
'window_font_size'))
def _option_index(option_name):
"""Extract the numeric index from an option name.
Handles both plain integer keys (e.g. '0', '1') used by get_list
and compound keys (e.g. '1_type', '2_path') used by get_paths.
"""
return int(option_name.split('_')[0])
def _section_sort_key(section_name):
"""Sort key for section names: alphabetical, ignoring case"""
return (section_name.lower(), section_name)
def _option_sort_key(option_name):
"""Sort key for option names within a section.
Keys with a numeric prefix (e.g. '2_path') are ordered by that number
so lists do not come out as 1, 10, 2.
"""
prefix = option_name.split('_', 1)[0]
if prefix.isdigit():
return (0, int(prefix), option_name.lower(), option_name)
return (1, 0, option_name.lower(), option_name)
def path_to_option(pathname):
"""Change a pathname to a .ini option name (a key)"""
# On Windows change to lowercase and use backwards slashes.
pathname = os.path.normcase(pathname)
# On Windows expand DOS-8.3-style pathnames.
if IS_WINDOWS and os.path.exists(pathname):
# pylint: disable-next=possibly-used-before-assignment
pathname = GetLongPathName(pathname)
if len(pathname) > 1 and ':' == pathname[1]:
# ConfigParser treats colons in a special way
pathname = pathname[0] + pathname[2:]
return pathname
def _open_config_write(path):
"""Open the config file for writing without following a final symlink."""
return open_for_overwrite(
path, encoding='utf-8-sig', errors='surrogateescape')
def init_configuration(*, log=True):
"""Initialize an empty configuration, if necessary"""
if not os.path.exists(bleachbit.options_dir):
General.makedirs(bleachbit.options_dir)
if os.path.lexists(bleachbit.options_file):
if log:
logger.debug('Deleting configuration: %s', bleachbit.options_file)
os.remove(bleachbit.options_file)
with _open_config_write(bleachbit.options_file) as f_ini:
f_ini.write('[bleachbit]\n')
if IS_WINDOWS and bleachbit.portable_mode:
f_ini.write('[Portable]\n')
for section in options.config.sections():
options.config.remove_section(section)
options.restore()
class Options:
"""Store and retrieve user preferences"""
def __init__(self):
self.purged = False
self.config = configparser.RawConfigParser(delimiters='=')
self.config.optionxform = str # make keys case sensitive for hashpath purging
self.config.BOOLEAN_STATES['t'] = True
self.config.BOOLEAN_STATES['f'] = False
self.overrides = {}
# Cache of get_paths() results, keyed by section. The keep list is read
# once per file during a scan, so recomputing it every time is costly.
self._paths_cache = {}
self.old_version = None # Store previous version in memory
self._dirty = False
self._closed = False
self._flush_generation = 0
self._flush_lock = threading.RLock()
self._flush_timer = None
self._atexit_close = self.close
atexit.register(self._atexit_close)
self.restore()
old_option = 'system.free_disk_space'
try:
if self.config.has_section("tree") and self.config.has_option('tree', old_option):
logger.debug(
"Migrating legacy option '%s' to 'system.empty_space'", old_option)
self.config.set('tree', 'system.empty_space', 'true')
self.config.remove_option('tree', old_option)
self.__schedule_flush()
except Exception:
logger.exception("Error migrating legacy option '%s'", old_option)
def __cancel_flush_timer(self):
"""Invalidate and stop any pending delayed flush timer."""
self._flush_generation += 1
timer = self._flush_timer
self._flush_timer = None
if timer is not None:
timer.cancel()
def __flush_after_delay(self, generation):
"""Timer callback that flushes if the timer is still current."""
with self._flush_lock:
if generation != self._flush_generation:
return
self._flush_timer = None
self.__flush_locked()
def __schedule_flush(self):
"""Mark configuration dirty and schedule a delayed flush."""
with self._flush_lock:
self._dirty = True
self._flush_generation += 1
generation = self._flush_generation
if self._flush_timer is not None:
self._flush_timer.cancel()
self._flush_timer = threading.Timer(
FLUSH_DELAY_SECS, self.__flush_after_delay, args=(generation,))
self._flush_timer.daemon = True
self._flush_timer.start()
def __flush(self, force=False):
"""Acquire the lock and flush to disk."""
with self._flush_lock:
self.__flush_locked(force=force)
def __flush_locked(self, force=False):
"""Write configuration to disk while holding the flush lock."""
if not force and not self._dirty:
return
if not self.purged:
self.__purge()
self.__sort()
try:
if not os.path.exists(bleachbit.options_dir):
General.makedirs(bleachbit.options_dir)
mkfile = not os.path.exists(bleachbit.options_file)
with _open_config_write(bleachbit.options_file) as _file:
self.config.write(_file)
if mkfile and General.sudo_mode():
General.chownself(bleachbit.options_file)
except (OSError, IOError, PermissionError) as e:
if e.errno == errno.ENOSPC:
logger.error(
_("Disk was full when writing configuration to file: %s"), bleachbit.options_file)
elif e.errno == errno.EACCES:
logger.error(
_("Permission denied when writing configuration to file: %s"), bleachbit.options_file)
else:
raise
else:
self._dirty = False
def __sort(self):
"""Sort sections and their keys so the file is easy to read
ConfigParser writes in insertion order, so reorder its sections
in place instead of building a copy, which would flatten any
[DEFAULT] section.
"""
# ConfigParser has no public API for reordering sections.
# pylint: disable-next=protected-access
sections = self.config._sections
for section_name in sorted(sections, key=_section_sort_key):
section = sections.pop(section_name)
for option in sorted(section, key=_option_sort_key):
section[option] = section.pop(option)
sections[section_name] = section
def __apply_defaults(self):
"""Store the default value of every preference not already set
This way the file shows all preferences, not only the ones the
user has changed.
"""
changed = False
for option, meta in OPTION_DEFAULTS.items():
if not _platform_allows(meta):
continue
if self.config.has_option('bleachbit', option):
continue
self.config.set('bleachbit', option, str(meta['value']))
changed = True
if changed:
self.__schedule_flush()
def __purge(self):
"""Clear out obsolete data"""
self.purged = True
if not self.config.has_section('hashpath'):
return
for option in self.config.options('hashpath'):
pathname = option
if IS_WINDOWS and _HASHPATH_DRIVE_RE.search(option):
# restore colon lost because ConfigParser treats colon special
# in keys
pathname = pathname[0] + ':' + pathname[1:]
exists = False
try:
exists = os.path.lexists(pathname)
except Exception:
# this deals with corrupt keys
# https://www.bleachbit.org/forum/bleachbit-wont-launch-error-startup
logger.error(
_("Error checking whether path exists: %s"), pathname)
if not exists:
# the file does not on exist, so forget it
self.config.remove_option('hashpath', option)
def __migrate_warning_preferences(self):
"""Recover warning preferences corrupted by legacy colon parsing.
This fixes an issue introduced in version 6.0.0 and fixed for 6.0.2.
See https://github.com/bleachbit/bleachbit/issues/2110
"""
section = 'warnings'
if not self.config.has_section(section):
return
with self._flush_lock:
migrated = False
for option in tuple(self.config.options(section)):
if option not in ('cleaner', 'protected_path'):
continue
value = self.config.get(section, option)
if '=' not in value:
continue
suffix, warning_value = value.rsplit('=', 1)
suffix = suffix.strip()
warning_value = warning_value.strip()
if not suffix or \
warning_value.lower() not in self.config.BOOLEAN_STATES:
continue
migrated_option = f'{option}:{suffix}'
if not self.config.has_option(section, migrated_option):
self.config.set(section, migrated_option, warning_value)
self.config.remove_option(section, option)
migrated = True
if migrated:
self.__schedule_flush()
def __auto_preserve_languages(self):
"""Automatically preserve the active language"""
active_lang = bleachbit.Language.get_active_language_code()
for lang_id in set([active_lang.split('_')[0], 'en']):
# TRANSLATORS: %s is a ISO language code of two or three letters.
# "Preserving" means the language's localization files will be
# kept and not deleted when cleaning.
logger.info(_("Automatically preserving language %s."), lang_id)
self.set_language(lang_id, True)
def has_option(self, option, section='bleachbit'):
"""Check if option is set"""
return self.config.has_option(section, option)
def has_override(self, option, section='bleachbit'):
"""Check if option is overridden"""
return (section, option) in self.overrides
def get(self, option, section='bleachbit'):
"""Retrieve a general option"""
if not IS_WINDOWS and 'update_winapp2' == option:
return False
if section == 'bleachbit' and option == 'debug':
from bleachbit.Log import is_debugging_enabled_via_cli
if is_debugging_enabled_via_cli():
# command line overrides stored configuration
return True
override_key = (section, option)
if override_key in self.overrides:
return self.overrides[override_key]
if section == 'hashpath' and len(option) > 1 and option[1] == ':':
option = option[0] + option[2:]
if self.config.has_option(section, option):
if option in boolean_keys:
return self.config.getboolean(section, option)
if option in int_keys:
return self.config.getint(section, option)
return self.config.get(section, option)
if section == 'bleachbit':
default = _get_default_value(option)
if default is not None:
return default
return None
def get_hashpath(self, pathname):
"""Recall the hash for a file"""
return self.get(path_to_option(pathname), 'hashpath')
def get_language(self, langid):
"""Retrieve value for whether to preserve the language"""
if not self.config.has_section('preserve_languages'):
self.__auto_preserve_languages()
if not self.config.has_option('preserve_languages', langid):
return False
return self.config.getboolean('preserve_languages', langid)
def get_languages(self):
"""Return a list of all selected languages"""
if not self.config.has_section('preserve_languages'):
self.__auto_preserve_languages()
return self.config.options('preserve_languages')
def get_list(self, option):
"""Return an option which is a list data type"""
section = f"list/{option}"
if not self.config.has_section(section):
return None
return [
self.config.get(section, opt)
for opt in sorted(self.config.options(section), key=_option_index)
]
def get_paths(self, section):
"""Abstracts get_whitelist_paths and get_custom_paths"""
if not self.config.has_section(section):
return []
cached = self._paths_cache.get(section)
if cached is not None:
return list(cached)
myoptions = []
for opt in sorted(self.config.options(section), key=_option_index):
pos = opt.find('_')
if -1 == pos:
continue
myoptions.append(opt[0:pos])
values = []
for opt in sorted(set(myoptions), key=_option_index):
p_type = self.config.get(section, opt + '_type')
p_path = self.config.get(section, opt + '_path')
values.append((p_type, p_path))
self._paths_cache[section] = values
return list(values)
def get_whitelist_paths(self):
"""Return the keep list (formerly whitelist) of paths
Returns a list of tuples (type, path) where type is 'file' or 'folder'
"""
return self.get_paths("whitelist/paths")
def get_custom_paths(self):
"""Return list of custom paths
Returns a list of tuples (type, path) where type is 'file' or 'folder'
"""
return self.get_paths("custom/paths")
def get_warning_preference(self, key):
"""Return whether a specific warning is already confirmed."""
section = "warnings"
if not self.config.has_section(section):
return False
if not self.config.has_option(section, key):
return False
try:
return self.config.getboolean(section, key)
except ValueError:
logger.exception("Error reading warning preference for %s", key)
return False
def remember_warning_preference(self, key):
"""Persist that a specific warning was confirmed once."""
section = "warnings"
with self._flush_lock:
if not self.config.has_section(section):
self.config.add_section(section)
self.config.set(section, key, 'True')
self.__schedule_flush()
def clear_warning_preferences(self):
"""Clear all saved warning confirmations."""
section = "warnings"
with self._flush_lock:
if self.config.has_section(section):
self.config.remove_section(section)
self.__schedule_flush()
def get_tree(self, parent, child):
"""Retrieve an option for the tree view.
Args:
parent: The cleaner ID (e.g., 'system', 'firefox')
child: The option ID within the cleaner (e.g., 'cache'), or None for the cleaner itself
Returns:
bool: True if the option is enabled, False otherwise
"""
option = parent
if child is not None:
option += "." + child
if not self.config.has_option('tree', option):
return False
try:
return self.config.getboolean('tree', option)
except Exception:
# in case of corrupt configuration (Launchpad #799130)
logger.exception('Error in get_tree()')
return False
def is_corrupt(self):
"""Perform a self-check for corruption of the configuration"""
# no boolean key must raise an exception
for boolean_key in boolean_keys:
try:
if self.config.has_option('bleachbit', boolean_key):
self.config.getboolean('bleachbit', boolean_key)
except ValueError:
return True
# no int key must raise an exception
for int_key in int_keys:
try:
if self.config.has_option('bleachbit', int_key):
self.config.getint('bleachbit', int_key)
except ValueError:
return True
return False
def reset_overrides(self):
"""Clear all overrides"""
self.overrides.clear()
def restore(self):
"""Restore saved options from disk
Abandons any changes not written to disk.
"""
with self._flush_lock:
self.__cancel_flush_timer()
self._dirty = False
self._paths_cache.clear()
# Reading configuration merges with existing data,
# so clear it first.
for section in self.config.sections():
self.config.remove_section(section)
try:
with open(bleachbit.options_file, 'r', encoding='utf-8-sig', errors='surrogateescape') as _file:
self.config.read_file(_file, bleachbit.options_file)
except FileNotFoundError:
if not bleachbit.options_file.startswith('/tmp'):
logger.debug("Configuration file does not exist yet: %s",
bleachbit.options_file)
except Exception:
logger.exception("Error reading application's configuration")
if not self.config.has_section("bleachbit"):
self.config.add_section("bleachbit")
if not self.config.has_section("hashpath"):
self.config.add_section("hashpath")
self.__apply_defaults()
self.__migrate_warning_preferences()
if not self.config.has_section("list/shred_drives"):
from bleachbit.FileUtilities import guess_overwrite_paths
try:
self.set_list('shred_drives', guess_overwrite_paths())
except Exception:
logger.exception(
_("Error when setting the default drives to shred."))
# BleachBit upgrade or first start ever
if not self.config.has_option('bleachbit', 'version') or \
self.get('version') != bleachbit.APP_VERSION:
if self.config.has_option('bleachbit', 'version'):
self.old_version = self.get('version')
self.set('first_start', True)
# set version
self.set("version", bleachbit.APP_VERSION)
def set(self, key, value, section='bleachbit'):
"""Set a general option"""
assert isinstance(key, str), f"key must be a string: {key}"
assert isinstance(section, str), f"section must be a string: {section}"
override_key = (section, key)
if override_key not in self.overrides:
value = str(value)
with self._flush_lock:
if self.config.has_option(section, key) and self.config.get(section, key) == value:
return
self.config.set(section, key, value)
self.__schedule_flush()
def commit(self):
"""Cancel times and write changes"""
with self._flush_lock:
self.__cancel_flush_timer()
self.__flush(force=True)
def cancel_pending_flush(self):
"""Cancel any pending delayed flush and clear the dirty flag.
Unlike close(), this does not mark the options object as closed,
so it can be called repeatedly (e.g. between test classes that
share a single Options singleton).
"""
with self._flush_lock:
self.__cancel_flush_timer()
self._dirty = False
def close(self):
"""Cancel times and write changes"""
with self._flush_lock:
if self._closed:
return
self._closed = True
atexit.unregister(self._atexit_close)
self.__cancel_flush_timer()
self.__flush(force=False)
def set_hashpath(self, pathname, hashvalue):
"""Remember the hash of a path"""
self.set(path_to_option(pathname), hashvalue, 'hashpath')
def set_list(self, key, values):
"""Set a value which is a list data type"""
assert isinstance(key, str), f"key must be a string: {key}"
section = f"list/{key}"
with self._flush_lock:
# Remove existing section first to clear old values
# before writing new ones.
if self.config.has_section(section):
self.config.remove_section(section)
self.config.add_section(section)
for counter, value in enumerate(values):
self.config.set(section, str(counter), value)
self.__schedule_flush()
def __set_paths(self, section, values):
"""Abstracts set_whitelist_paths and set_custom_paths
@param values: list of tuples containing (path_type, path)
where path_type is either 'file' or 'folder'
"""
with self._flush_lock:
# Remove existing section first to clear old values
# before writing new ones.
if self.config.has_section(section):
self.config.remove_section(section)
self.config.add_section(section)
for counter, (path_type, path) in enumerate(values):
assert path_type in ('file', 'folder')
self.config.set(section, str(counter) + '_type', path_type)
self.config.set(section, str(counter) + '_path', path)
self._paths_cache.pop(section, None)
self.__schedule_flush()
def set_whitelist_paths(self, values):
"""Save the keep list (formerly whitelist)"""
self.__set_paths("whitelist/paths", values)
def set_custom_paths(self, values):
"""Save the custom paths"""
self.__set_paths("custom/paths", values)
def set_language(self, langid, value):
"""Set the value for a locale (whether to preserve it)"""
with self._flush_lock:
if not self.config.has_section('preserve_languages'):
self.config.add_section('preserve_languages')
if self.config.has_option('preserve_languages', langid) and not value:
self.config.remove_option('preserve_languages', langid)
else:
self.config.set('preserve_languages', langid, str(value))
self.__schedule_flush()
def set_tree(self, parent, child, value):
"""Set an option for the tree view. The child may be None."""
with self._flush_lock:
if not self.config.has_section("tree"):
self.config.add_section("tree")
option = parent
if child is not None:
option = option + "." + child
if self.config.has_option('tree', option) and not value:
self.config.remove_option('tree', option)
else:
self.config.set('tree', option, str(value))
self.__schedule_flush()
def toggle(self, key):
"""Toggle a boolean key"""
self.set(key, not self.get(key))
def set_override(self, key, value, section='bleachbit'):
"""Set a CLI override that will never be written to disk"""
override_key = (section, key)
self.overrides[override_key] = value
options = Options()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/PathUtils.py 0000664 0001750 0001750 00000013126 15265023030 015211 0 ustar 00z z # 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.
"""
Low-level path comparison, normalization, and expansion helpers.
This module is a leaf module: it depends only on the standard library
and on the constants exported by the `bleachbit` package (e.g., IS_WINDOWS).
This module avoids importing internal modules (e.g., Options) to prevent
circular imports.
"""
import os
import re
import stat
from bleachbit import FS_CASE_SENSITIVE, IS_WINDOWS
def is_world_writable(path):
"""Return True if others (not the owner or group) can write to path.
Meaningless on Windows, where POSIX mode bits do not apply. Returns
False if the path cannot be stat'd rather than hiding the caller's file.
"""
try:
return bool(stat.S_IMODE(os.stat(path).st_mode) & stat.S_IWOTH)
except OSError:
return False
# On Windows both backslash and forward slash are path separators.
# On POSIX only os.sep is a separator (backslash is a legal filename
# character), so we must not treat it as a boundary there.
_PATH_SEPARATORS = ('\\', '/') if IS_WINDOWS else (os.sep,)
_ENV_VAR_ONLY_PATTERN = re.compile(
r"""
^
(?:
\$(?P[A-Za-z_][A-Za-z0-9_]*)
|
\${(?P[A-Za-z_][A-Za-z0-9_]*)}
|
%(?P[A-Za-z_][A-Za-z0-9_]*)%
)
$
""",
re.VERBOSE,
)
def path_equal(path1, path2, case_sensitive=None):
"""Compare two paths respecting file-system case sensitivity.
This is a raw string comparison; it does not normalize paths. For
example, "/home/foo" equals neither "/home/foo/" nor "/home/.foo".
Instead, the caller is responsible for normalization: see normalize_path.
Args:
path1: First path string.
path2: Second path string.
case_sensitive: If `None`, defaults to `FS_CASE_SENSITIVE`.
Pass an explicit bool to override per call.
"""
if case_sensitive is None:
case_sensitive = FS_CASE_SENSITIVE
if case_sensitive:
return path1 == path2
return path1.lower() == path2.lower()
def path_startswith(path, prefix, case_sensitive=None):
"""Check whether `path` is a child of `prefix`.
Strict path-component-boundary check: `prefix` matches `path`
only when `path` is `prefix` followed by a path separator and at
least one more component. This means `/home/folder` does **not**
match `/home/folder2`. On Windows both backslash and forward slash
are treated as separators.
This is a raw string comparison; it does not normalize paths. The
caller is responsible for normalization (see `normalize_path`).
Args:
path: The path to test.
prefix: The directory prefix to test against.
case_sensitive: If `None`, defaults to `FS_CASE_SENSITIVE`.
Pass an explicit bool to override per call.
"""
if case_sensitive is None:
case_sensitive = FS_CASE_SENSITIVE
if not case_sensitive:
path = path.lower()
prefix = prefix.lower()
return any(path.startswith(prefix + sep) for sep in _PATH_SEPARATORS)
def path_has_relative_suffix(path, suffix, case_sensitive=None):
"""Check whether `path` exactly matches or ends with `suffix` at a boundary.
This is a raw string comparison; it does not normalize paths. A suffix
only matches when it begins at a path-component boundary.
"""
if path_equal(path, suffix, case_sensitive=case_sensitive):
return True
if case_sensitive is None:
case_sensitive = FS_CASE_SENSITIVE
if not case_sensitive:
path = path.lower()
suffix = suffix.lower()
suffix = suffix.lstrip(''.join(_PATH_SEPARATORS))
return any(path.endswith(sep + suffix) for sep in _PATH_SEPARATORS)
def normalize_path(path, case_sensitive=None):
"""Normalize a path for comparison.
Args:
path: The path to normalize.
case_sensitive: If `None`, defaults to `FS_CASE_SENSITIVE`.
When `False`, the normalized path is lowercased so that
case-insensitive comparisons match.
"""
if case_sensitive is None:
case_sensitive = FS_CASE_SENSITIVE
normalized = os.path.normpath(path)
if not case_sensitive:
normalized = normalized.lower()
return normalized
def expand_path(raw_path):
"""Expand environment variables, user home, and normalize path.
Returns one string.
"""
assert isinstance(raw_path, str)
# Expand user home (~)
path = os.path.expanduser(raw_path)
# Expand environment variables
path = os.path.expandvars(path)
# Normalize path separators
if path:
path = os.path.normpath(path)
return path
def expand_path_entries(raw_path):
"""Expand a raw path into one or more normalized paths.
Handles environment variables whose values are lists separated by
`os.pathsep` (e.g., "XDG_DATA_DIRS" = "/usr/share:/usr/local/share")
by returning a separate entry per value when the raw path consists
of the environment variable placeholder alone.
Returns a tuple of strings.
"""
expanded = expand_path(raw_path)
if not expanded:
return tuple()
if (_ENV_VAR_ONLY_PATTERN.match(raw_path)
and os.pathsep in expanded):
candidates = [segment.strip()
for segment in expanded.split(os.pathsep)]
normalized = [os.path.normpath(segment)
for segment in candidates if segment]
if normalized:
return tuple(normalized)
return (expanded,)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Process.py 0000664 0001750 0001750 00000021416 15265023030 014713 0 ustar 00z z # 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.
"""
Enumerate and terminate processes
"""
import signal
import glob
import logging
import subprocess
import time
from collections import namedtuple
import os
from bleachbit import IS_LINUX, IS_POSIX, IS_WINDOWS
logger = logging.getLogger(__name__)
ProcessInfo = namedtuple('ProcessInfo', ['pid', 'name', 'same_user'])
# Resolved on first use, not at import
_psutil = None
_has_psutil = None
def _import_psutil():
"""Return the psutil module, or None when it is not installed"""
global _psutil, _has_psutil # pylint: disable=global-statement
if _has_psutil is None:
try:
import psutil # pylint: disable=import-outside-toplevel
except ImportError:
_psutil, _has_psutil = None, False
else:
_psutil, _has_psutil = psutil, True
return _psutil
def enumerate_processes():
"""Yield ProcessInfo(pid, name, same_user) for all accessible processes.
'same_user' is True if the process owner matches the current (real) user.
On Unix with sudo, compares against the non-root user.
"""
if _import_psutil() and IS_POSIX:
yield from _enumerate_psutil_posix()
return
# Windows should always have psutil.
if _import_psutil() and IS_WINDOWS:
yield from _enumerate_psutil_windows()
return
if IS_LINUX:
yield from _enumerate_proc_fs()
return
if IS_POSIX:
yield from _enumerate_ps_aux()
return
raise RuntimeError('no method to enumerate processes on this system')
def _enumerate_psutil_posix():
"""Enumerate processes with psutils on POSIX"""
from bleachbit.General import get_real_uid
psutil = _import_psutil()
target_uid = get_real_uid()
for proc in psutil.process_iter(['name', 'exe', 'uids', 'cmdline']):
try:
name = proc.info['name']
if not name:
continue
exe = proc.info.get('exe')
uids = proc.info.get('uids')
same_user = uids is not None and uids.real == target_uid
names = {name}
# exe basename may differ from name (e.g. truncated comm)
if exe:
names.add(os.path.basename(exe))
# argv[0] may differ from exe (e.g. a versioned binary like
# ~/.local/share/claude/versions/2.1.270 launched as 'claude')
cmdline = proc.info.get('cmdline')
if cmdline:
names.add(os.path.basename(cmdline[0]))
for proc_name in names:
if proc_name:
yield ProcessInfo(proc.pid, proc_name, same_user)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
def _enumerate_psutil_windows():
"""Enumerate processes with psutils on Windows"""
psutil = _import_psutil()
current_user = psutil.Process().username().lower()
# On Windows, do not fetch 'cmdline' because it can be much slower
# sometimes, and it does not add useful information.
start_time = time.time()
for proc in psutil.process_iter(['name', 'username']):
try:
name = proc.info['name']
if not name:
continue
same_user = (proc.info['username'] or '').lower() == current_user
yield ProcessInfo(proc.pid, name, same_user)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
elapsed = time.time() - start_time
if elapsed >= 0.5:
log_func = logger.warning if elapsed >= 2.0 else logger.debug
log_func('process enumeration took %.1f seconds', elapsed)
def _enumerate_proc_fs():
"""/proc filesystem strategy (Linux)"""
from bleachbit.General import get_real_uid
target_uid = get_real_uid()
for filename in glob.iglob("/proc/*/exe"):
pid_dir = os.path.dirname(filename)
base = os.path.basename(pid_dir)
if not base.isdigit():
# skip /proc/self and /proc/thread-self
continue
pid = int(base)
name = None
try:
target = os.path.realpath(filename)
# Google Chrome 74 on Ubuntu 19.04 showed up as
# /opt/google/chrome/chrome (deleted)
name = os.path.basename(target).replace(' (deleted)', '')
except OSError:
# 13 = permission denied
pass
except TypeError:
# TypeError happens, for example, when link points to
# '/etc/password\x00 (deleted)'
pass
if not name:
try:
with open(os.path.join(pid_dir, 'stat'), 'r',
encoding='utf-8') as f:
stat_content = f.read()
# comm is field 2, wrapped in parens, and may contain spaces
name = stat_content[stat_content.index('(') + 1:
stat_content.rindex(')')]
except (OSError, ValueError):
continue
try:
same_user = os.stat(pid_dir).st_uid == target_uid
except OSError:
# the process exited between listing and stat
same_user = False
yield ProcessInfo(pid, name, same_user)
# argv[0] may differ from the exe name (e.g. a versioned binary
# launched through a symlink)
try:
with open(os.path.join(pid_dir, 'cmdline'), 'rb') as f:
argv0 = os.path.basename(
f.read().split(b'\x00')[0].decode('utf-8', 'replace'))
except OSError:
argv0 = ''
if argv0 and argv0 != name:
yield ProcessInfo(pid, argv0, same_user)
def _enumerate_ps_aux():
"""ps aux strategy (BSD/macOS)"""
from bleachbit.General import (get_real_username, resolve_exe,
sanitize_root_env)
current_user = get_real_username()
# sanitize the env so a hostile inherited LD_*/DYLD_* cannot redirect
# this child when BleachBit runs as root.
ps_out = subprocess.check_output(
[resolve_exe('ps'), "aux", "-c"], universal_newlines=True,
env=sanitize_root_env(dict(os.environ)))
first_line = ps_out.split('\n', maxsplit=1)[0].strip()
if "USER" not in first_line or "COMMAND" not in first_line:
raise RuntimeError("Unexpected ps header format")
for line in ps_out.split("\n")[1:]:
# COMMAND is the last column and may contain spaces
parts = line.split(None, 10)
if len(parts) < 11:
continue
yield ProcessInfo(int(parts[1]), parts[10].strip(), parts[0] == current_user)
class ProcessCache:
"""Cached way to determine whether a process is running"""
def __init__(self, max_age_seconds=10):
self.max_age_seconds = max_age_seconds
self.last_scan_time = None
self.processes = ()
def invalidate(self):
"""Drop the cache so the next get() rescans"""
self.last_scan_time = None
self.processes = ()
def get(self):
"""Return the process list, rescanning when stale.
The scan expires so an application started mid-run is still noticed.
"""
if self.last_scan_time is None or \
(time.time() - self.last_scan_time) > self.max_age_seconds:
self.processes = tuple(enumerate_processes())
self.last_scan_time = time.time()
return self.processes
process_cache = ProcessCache()
def is_process_running(exename, require_same_user):
"""Check whether exename is running"""
ci = IS_WINDOWS # case-insensitive on Windows
if ci:
exename = exename.lower()
for proc in process_cache.get():
name = proc.name.lower() if ci else proc.name
if name == exename and (not require_same_user or proc.same_user):
return True
return False
def terminate_process(exename, require_same_user):
"""Terminate processes matching exename. Returns list of affected PIDs."""
ci = IS_WINDOWS
if ci:
exename = exename.lower()
terminated = []
# Not the cache: a stale PID may since have been recycled
for proc in enumerate_processes():
name = proc.name.lower() if ci else proc.name
if name == exename and (not require_same_user or proc.same_user):
if proc.pid in terminated:
continue
try:
if IS_WINDOWS:
_import_psutil().Process(proc.pid).kill()
else:
os.kill(proc.pid, signal.SIGTERM)
terminated.append(proc.pid)
except (ProcessLookupError, PermissionError, OSError):
continue
if terminated:
process_cache.invalidate()
return terminated
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/ProtectedPath.py 0000664 0001750 0001750 00000024317 15265023030 016046 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
The protected path warning system is a safety net
This module loads protected path definitions from XML and checks whether
user-specified paths match protected paths, warning users before they
accidentally delete important system or application files.
"""
import logging
import os
import bleachbit
from bleachbit import FileUtilities, FS_CASE_SENSITIVE
from bleachbit.General import getText, os_match, reject_xml_dtd
from bleachbit.Language import get_text as _
from bleachbit.PathUtils import (
expand_path,
expand_path_entries,
normalize_path,
path_equal,
path_has_relative_suffix,
path_startswith,
)
logger = logging.getLogger(__name__)
# Cache for loaded protected paths
_protected_paths_cache = None
def _get_protected_path_xml():
"""Return the path to the protected_path.xml file."""
return bleachbit.get_share_path('protected_path.xml')
def load_protected_paths(force_reload=False):
"""Load protected path definitions from XML.
Returns a list of dictionaries with keys:
- path: The expanded, normalized path
- depth: How many levels deep to protect (0=exact, 1=children, etc.)
- case_sensitive: Whether matching should be case-sensitive
Args:
force_reload: If True, reload from XML even if cached
"""
# The parsed XML is cached for the process.
# pylint: disable-next=global-statement
global _protected_paths_cache
if _protected_paths_cache is not None and not force_reload:
return _protected_paths_cache
xml_path = _get_protected_path_xml()
if xml_path is None:
logger.warning("Protected path XML file not found")
return []
protected_paths = []
# Keep minidom off the startup path: this is the only place it is needed.
import xml.dom.minidom # pylint: disable=import-outside-toplevel
try:
with open(xml_path, 'rb') as f:
data = f.read()
reject_xml_dtd(data, 'protected_path.xml')
dom = xml.dom.minidom.parseString(data)
except Exception as e:
logger.error("Error parsing protected path XML: %s", e)
return []
for paths_node in dom.getElementsByTagName('paths'):
# Check OS match for this group
os_attr = paths_node.getAttribute('os') or ''
if not os_match(os_attr):
continue
for path_node in paths_node.getElementsByTagName('path'):
# Get path attributes (inherit from parent when omitted)
depth_attr = (path_node.getAttribute('depth') or
paths_node.getAttribute('depth') or '0')
if depth_attr == 'any':
depth = None
else:
try:
depth = int(depth_attr)
except ValueError:
depth = 0
case_attr = (path_node.getAttribute('case') or
paths_node.getAttribute('case') or '')
if case_attr == 'insensitive':
case_sensitive = False
elif case_attr == 'sensitive':
case_sensitive = True
else:
case_sensitive = FS_CASE_SENSITIVE
# Get the path text
raw_path = getText(path_node.childNodes).strip()
if not raw_path:
continue
# Expand the path (possibly into multiple entries)
for expanded_path in expand_path_entries(raw_path):
protected_paths.append({
'path': expanded_path,
'depth': depth,
'case_sensitive': case_sensitive,
})
_protected_paths_cache = protected_paths
logger.debug("Loaded %d protected paths", len(protected_paths))
return _protected_paths_cache
def _check_exempt(user_path):
"""Check if path is exempt from protection
For ignoring paths like .git under ~/.cache/
"""
assert isinstance(user_path, str)
exempt_paths = ('~/.cache', '%temp%', '%tmp%', '/tmp')
user_path_normalized = normalize_path(user_path)
for path in exempt_paths:
exempt_expanded = expand_path(path)
if not exempt_expanded:
continue
exempt_normalized = normalize_path(exempt_expanded)
if path_equal(user_path_normalized, exempt_normalized):
return True
if path_startswith(user_path_normalized, exempt_normalized):
return True
return False
def check_protected_path(user_path):
"""Check if a user path matches a protected path.
Args:
user_path: The path the user wants to add to delete list
Returns:
A dictionary with match info if protected, None otherwise:
- protected_path: The matched protected path
- depth: The depth of the protection
- case_sensitive: Whether the match was case-sensitive
"""
if _check_exempt(user_path):
return None
protected_paths = load_protected_paths()
if not protected_paths:
return None
for ppath in protected_paths:
protected = ppath['path']
depth = ppath['depth']
case_sensitive = ppath['case_sensitive']
user_cmp = normalize_path(user_path, case_sensitive=case_sensitive)
protected_cmp = normalize_path(
protected, case_sensitive=case_sensitive)
protected_is_absolute = os.path.isabs(ppath['path'])
if not protected_is_absolute:
# Relative protected paths should match when user path ends with them
if path_has_relative_suffix(user_cmp, protected_cmp,
case_sensitive=case_sensitive):
return ppath
continue
# Exact match
if path_equal(user_cmp, protected_cmp, case_sensitive=case_sensitive):
return ppath
# Check if user path is a parent of protected path
# (user wants to delete a folder that contains protected items)
if path_startswith(protected_cmp, user_cmp,
case_sensitive=case_sensitive):
return ppath
# Check if user path is a child of protected path (within depth)
if ((depth is None or depth > 0)
and path_startswith(user_cmp, protected_cmp,
case_sensitive=case_sensitive)):
if depth is None:
return ppath
# Calculate how many levels deep the user path is
protected_with_sep = protected_cmp + os.sep
relative = user_cmp[len(protected_with_sep):]
levels = relative.count(os.sep) + 1
if levels <= depth:
return ppath
return None
def calculate_impact(path):
"""Calculate the impact of deleting a path.
Args:
path: The path to calculate impact for
Returns:
A dictionary with:
- file_count: Number of files
- total_size: Total size in bytes
- size_human: Human-readable size string
"""
if not os.path.exists(path):
return {
'file_count': 0,
'total_size': 0,
'size_human': '0B',
}
file_count = 0
total_size = 0
try:
if os.path.isfile(path):
file_count = 1
total_size = FileUtilities.getsize(path)
elif os.path.isdir(path):
for child in FileUtilities.children_in_directory(path, list_directories=False):
file_count += 1
try:
total_size += FileUtilities.getsize(child)
except (OSError, PermissionError) as e:
logger.debug('skipping %s in the impact total: %s',
child, e)
except (OSError, PermissionError) as e:
logger.debug("Error calculating impact for %s: %s", path, e)
return {
'file_count': file_count,
'total_size': total_size,
'size_human': FileUtilities.bytes_to_human(total_size),
}
def get_warning_message(user_path, impact):
"""Generate a warning message for a protected path.
Args:
user_path: The path the user wants to add
impact: The impact info from calculate_impact
Returns:
A formatted warning message string
"""
if impact['file_count'] > 0:
# TRANSLATORS: Warning shown when user tries to add a protected path.
# %(path)s is the path, %(files)d is number of files, %(size)s is human-readable size
# Do not translate the placeholders.
# Adapt quotation marks around the path placeholder to the typographic conventions of
# your language.
msg = _("Warning: '%(path)s' may contain important files.\n\n"
"Impact: %(files)d file(s), %(size)s\n\n"
"Are you sure you want to add this path?") % {
'path': user_path,
'files': impact['file_count'],
'size': impact['size_human'],
}
else:
# TRANSLATORS: Warning shown when user tries to add a protected path (no files found).
# Do not translate the placeholder %(path)s.
# Adapt quotation marks around the path placeholder to the typographic conventions of
# your language.
msg = _("Warning: '%(path)s' may contain important files.\n\n"
"Are you sure you want to add this path?") % {
'path': user_path,
}
return msg
def clear_cache():
"""Clear the protected paths cache."""
# Clearing the cache means rebinding it.
# pylint: disable-next=global-statement
global _protected_paths_cache
_protected_paths_cache = None
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/RecognizeCleanerML.py 0000775 0001750 0001750 00000013102 15265023030 016741 0 ustar 00z z # 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.
"""
Check local CleanerML files as a security measure
"""
# standard imports
import hashlib
import logging
import os
import sys
# first party imports
from bleachbit.Language import get_text as _, pget_text as _p
from bleachbit.CleanerML import list_cleanerml_files
from bleachbit.Options import options
logger = logging.getLogger(__name__)
KNOWN = 1
CHANGED = 2
NEW = 3
def cleaner_change_dialog(changes, parent):
"""Present a dialog regarding the change of cleaner definitions"""
def toggled(_cell, path, model):
"""Callback for clicking the checkbox"""
__iter = model.get_iter_from_string(path)
value = not model.get_value(__iter, 0)
model.set(__iter, 0, value)
# TODO: move to GuiBasic
from bleachbit.GtkShim import Gtk, GObject
from bleachbit.GuiUtil import load_icon_or_fallback
dialog = Gtk.Dialog(title=_("Security warning"),
transient_for=parent,
modal=True, destroy_with_parent=True)
dialog.set_default_size(600, 500)
# create warning
warnbox = Gtk.Box()
image = load_icon_or_fallback("dialog-warning", Gtk.IconSize.DIALOG)
warnbox.pack_start(image, False, True, 0)
# TRANSLATORS: Cleaner definitions are XML data files that define
# which files will be cleaned.
label = Gtk.Label(
label=_("These cleaner definitions are new or have changed. Malicious definitions can damage your system. If you do not trust these changes, delete the files or quit."))
label.set_line_wrap(True)
warnbox.pack_start(label, True, True, 0)
dialog.vbox.pack_start(warnbox, False, True, 0)
# create tree view
liststore = Gtk.ListStore(GObject.TYPE_BOOLEAN, GObject.TYPE_STRING)
treeview = Gtk.TreeView(model=liststore)
renderer0 = Gtk.CellRendererToggle()
renderer0.set_property('activatable', True)
renderer0.connect('toggled', toggled, liststore)
# TRANSLATORS: This is the column label (header) in the tree view for the
# security dialog
treeview.append_column(
Gtk.TreeViewColumn(_p('column_label', 'Delete'), renderer0, active=0))
renderer1 = Gtk.CellRendererText()
# TRANSLATORS: This is the column label (header) in the tree view for the
# security dialog
treeview.append_column(
Gtk.TreeViewColumn(_p('column_label', 'Filename'), renderer1, text=1))
# populate tree view
for change in changes:
liststore.append([False, change[0]])
# populate dialog with widgets
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.add(treeview)
dialog.vbox.pack_start(scrolled_window, True, True, 0)
dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT)
dialog.add_button(Gtk.STOCK_QUIT, Gtk.ResponseType.CLOSE)
# run dialog
dialog.show_all()
while True:
if Gtk.ResponseType.ACCEPT != dialog.run():
sys.exit(0)
delete = []
for row in liststore:
b = row[0]
path = row[1]
if b:
delete.append(path)
if not delete:
# no files selected to delete
break
from . import GuiBasic
if not GuiBasic.delete_confirmation_dialog(parent, mention_preview=False):
# confirmation not accepted, so do not delete files
continue
for path in delete:
logger.info("deleting unrecognized CleanerML '%s'", path)
os.remove(path)
break
dialog.destroy()
def hashdigest(string):
"""Return hex digest of hash for a string"""
if isinstance(string, str):
string = string.encode()
return hashlib.sha512(string).hexdigest()
class RecognizeCleanerML:
"""Check local CleanerML files as a security measure"""
def __init__(self, parent_window=None):
self.parent_window = parent_window
self.salt = options.get('hashsalt')
if self.salt is None:
self.salt = hashdigest(os.urandom(512))
options.set('hashsalt', self.salt)
self.__scan()
def __recognized(self, pathname):
"""Is pathname recognized?"""
# __init__ should set the salt.
if not self.salt:
raise RuntimeError('salt is not set')
try:
with open(pathname, 'rb') as f:
body = f.read()
except OSError: # The file is locked, what to do next?
return NEW, hashdigest(b"")
new_hash = hashdigest(str.encode(self.salt, encoding='utf-8') + body)
known_hash = options.get_hashpath(pathname)
if known_hash is None:
return NEW, new_hash
if new_hash == known_hash:
return KNOWN, new_hash
return CHANGED, new_hash
def __scan(self):
"""Look for files and act accordingly"""
changes = []
for pathname in sorted(list_cleanerml_files(local_only=True)):
pathname = os.path.abspath(pathname)
(status, myhash) = self.__recognized(pathname)
if status in (NEW, CHANGED):
changes.append([pathname, status, myhash])
if changes:
cleaner_change_dialog(changes, self.parent_window)
for change in changes:
pathname = change[0]
myhash = change[2]
logger.info("remembering CleanerML file '%s'", pathname)
if os.path.exists(pathname):
options.set_hashpath(pathname, myhash)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Revision.py 0000664 0001750 0001750 00000000025 15265023030 015064 0 ustar 00z z revision = "5dad073"
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Special.py 0000775 0001750 0001750 00000056467 15265023030 014676 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Cross-platform, special cleaning operations
"""
# standard library imports
import contextlib
import errno
import json
import logging
import os
from urllib.parse import quote, urlparse, urlunparse
# local application imports
from bleachbit import FileUtilities, IS_WINDOWS
from bleachbit.General import reject_xml_dtd
from bleachbit.Options import options
logger = logging.getLogger(__name__)
# SQLite primary result code for "unable to open database file".
SQLITE_CANTOPEN = 14
def __get_chrome_history(path, fn='History'):
"""Get Google Chrome or Chromium history version.
'path' is name of any file in same directory"""
path_history = os.path.join(os.path.dirname(path), fn)
ver = get_sqlite_int(
path_history, "select value from meta where key='version'")[0]
if ver <= 1:
raise RuntimeError(f'unexpected Chrome history version: {ver}')
return ver
def _sqlite_uri(pathname, mode=None):
"""Return a safe SQLite file: URI for pathname.
The path is percent-encoded so a character such as '?' cannot be
misparsed as the start of the URI query and defeat the mode. mode is
an optional open mode ('ro', 'rw', ...); None omits it (default rwc).
"""
assert isinstance(pathname, str)
abs_path = os.path.abspath(pathname)
if IS_WINDOWS:
abs_path = abs_path.replace('\\', '/')
quoted = quote(abs_path, safe='/:')
uri = f'file:{quoted}'
if mode:
uri += f'?mode={mode}'
return uri
def _sqlite_readonly_uri(pathname):
"""Return a proper SQLite URI for read-only access to pathname"""
return _sqlite_uri(pathname, 'ro')
def _quote_sqlite_identifier(name):
"""Return a safely-quoted SQLite identifier."""
if not isinstance(name, str):
raise TypeError('SQLite identifier must be a string')
return '"' + name.replace('"', '""') + '"'
def _escape_sqlite_str_literal(value):
"""Escape a value for use in a single-quoted SQLite string literal.
Double quotes are an identifier in standard SQL; SQLite only accepts
them as a string when built with SQLITE_DQS enabled.
"""
return value.replace("'", "''")
def sqlite_table_exists(pathname, table):
"""Check whether a table exists in the SQLite database
Returns True if the table exists, False if it does not.
Raises PermissionError if the file exists but cannot be opened.
"""
# In FreeBSD, sqlite3 is a separate package
import sqlite3
cmd = "select name from sqlite_master where type='table' and name=?;"
try:
uri = _sqlite_readonly_uri(pathname)
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
if conn.execute(cmd, (table,)).fetchone():
return True
except sqlite3.OperationalError as exc:
# SQLITE_CANTOPEN (14) and extended variants are raised when
# Norton blocks access to browser cookies. The primary code
# is the low byte.
# sqlite_errorcode requires Python 3.11+, which is satisfied on
# Windows where the antivirus issue occurs.
errorcode = getattr(exc, 'sqlite_errorcode', None)
if os.path.exists(pathname) and errorcode is not None and \
(errorcode & 0xff) == SQLITE_CANTOPEN:
# Worker shows prettier message for PermissionError
raise PermissionError(
errno.EACCES,
f"Cannot open database file (possibly blocked by "
f"antivirus or another process): {pathname}",
pathname) from exc
# Database may be locked, busy, corrupt, or not a valid SQLite DB.
logger.debug('sqlite_table_exists: %s: %s', pathname, exc)
return False
return False
def _sqlite_is_valid_database(pathname):
"""Return boolean indicating whether pathname points to a readable SQLite database."""
import sqlite3
try:
uri = _sqlite_readonly_uri(pathname)
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
conn.execute('select 1 from sqlite_master limit 1;')
return True
except (sqlite3.DatabaseError, sqlite3.OperationalError):
return False
def __shred_sqlite_char_columns(table, cols=None, where="", path=None):
"""Create an SQL command to shred character columns"""
if path and not sqlite_table_exists(path, table):
return ""
cmd = ""
if not where:
# If None, set to empty string.
where = ""
quoted_table = _quote_sqlite_identifier(table)
if cols and options.get('shred'):
for blob_type in ('randomblob', 'zeroblob'):
updates = [f'{_quote_sqlite_identifier(col)} = '
f'{blob_type}(length({_quote_sqlite_identifier(col)}))'
for col in cols]
cmd += f"update or ignore {quoted_table} set {', '.join(updates)} {where};"
cmd += f"delete from {quoted_table} {where};"
return cmd
def get_sqlite_int(path, sql, parameters=()):
"""Run SQL on database in 'path' and return the integers"""
def row_factory(_cursor, row):
"""Convert row to integer"""
return int(row[0])
return _get_sqlite_values(path, sql, row_factory, parameters)
def _get_sqlite_values(path, sql, row_factory=None, parameters=()):
"""Run SQL on database in 'path' and return the integers"""
import sqlite3
with contextlib.closing(sqlite3.connect(_sqlite_readonly_uri(path), uri=True)) as conn:
if row_factory is not None:
conn.row_factory = row_factory
cursor = conn.execute(sql, parameters)
return cursor.fetchall()
def delete_chrome_autofill(path):
"""Delete autofill table in Chromium/Google Chrome 'Web Data' database"""
cols = ('name', 'value', 'value_lower')
cmds = __shred_sqlite_char_columns('autofill', cols, path=path)
# autofill_profile_* existed for years until Google Chrome stable released August 2023
cols = ('first_name', 'middle_name', 'last_name', 'full_name')
cmds += __shred_sqlite_char_columns(
'autofill_profile_names', cols, path=path)
cmds += __shred_sqlite_char_columns('autofill_profile_emails',
('email',), path=path)
cmds += __shred_sqlite_char_columns('autofill_profile_phones',
('number',), path=path)
cols = ('company_name', 'street_address', 'dependent_locality',
'city', 'state', 'zipcode', 'country_code')
cmds += __shred_sqlite_char_columns('autofill_profiles', cols, path=path)
# local_addresses* appeared in Google Chrome stable versions released August 2023
cols = ('guid', 'use_count', 'use_date', 'date_modified',
'language_code', 'label', 'initial_creator_id', 'last_modifier_id')
cmds += __shred_sqlite_char_columns('local_addresses', cols, path=path)
cols = ('guid', 'type', 'value', 'verification_status')
cmds += __shred_sqlite_char_columns(
'local_addresses_type_tokens', cols, path=path)
cols = (
'company_name', 'street_address', 'address_1', 'address_2', 'address_3', 'address_4',
'postal_code', 'country_code', 'language_code', 'recipient_name', 'phone_number')
cmds += __shred_sqlite_char_columns('server_addresses', cols, path=path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_databases_db(path):
"""Delete remote HTML5 cookies (avoiding extension data) from the Databases.db file"""
cols = ('origin', 'name', 'description')
where = "where origin not like 'chrome-%'"
cmds = __shred_sqlite_char_columns('Databases', cols, where, path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_favicons(path):
"""Delete Google Chrome and Chromium favicons not use in in history for bookmarks"""
path_history = os.path.join(os.path.dirname(path), 'History')
if os.path.exists(path_history):
ver = __get_chrome_history(path)
else:
# assume it's the newer version
ver = 38
cmds = ""
if ver >= 4:
# Version 4 includes Chromium 12
# Version 20 includes Chromium 14, Google Chrome 15, Google Chrome 19
# Version 22 includes Google Chrome 20
# Version 25 is Google Chrome 26
# Version 26 is Google Chrome 29
# Version 28 is Google Chrome 30
# Version 29 is Google Chrome 37
# Version 32 is Google Chrome 51
# Version 36 is Google Chrome 60
# Version 38 is Google Chrome 64
# Version 42 is Google Chrome 79
# icon_mapping
cols = ('page_url',)
where = None
if os.path.exists(path_history):
cmds += f"attach database '{_escape_sqlite_str_literal(path_history)}' as History;"
where = "where page_url not in (select distinct url from History.urls)"
cmds += __shred_sqlite_char_columns('icon_mapping', cols, where, path)
# favicon images
cols = ('image_data', )
where = "where icon_id not in (select distinct icon_id from icon_mapping)"
cmds += __shred_sqlite_char_columns('favicon_bitmaps',
cols, where, path)
# favicons
# Google Chrome 30 (database version 28): image_data moved to table
# favicon_bitmaps
if ver < 28:
cols = ('url', 'image_data')
else:
cols = ('url', )
where = "where id not in (select distinct icon_id from icon_mapping)"
cmds += __shred_sqlite_char_columns('favicons', cols, where, path)
elif 3 == ver:
# Version 3 includes Google Chrome 11
cols = ('url', 'image_data')
where = None
if os.path.exists(path_history):
cmds += f"attach database '{_escape_sqlite_str_literal(path_history)}' as History;"
where = "where id not in(select distinct favicon_id from History.urls)"
cmds += __shred_sqlite_char_columns('favicons', cols, where, path)
else:
raise RuntimeError(f'{path} is version {ver}')
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_history(path):
"""Clean history from History and Favicon files without affecting bookmarks"""
if not os.path.exists(path):
logger.debug(
'aborting delete_chrome_history() because history does not exist: %s', path)
return
cols = ('url', 'title')
where = ""
ids_int = get_chrome_bookmark_ids(path)
if ids_int:
ids_str = ",".join([str(id0) for id0 in ids_int])
where = f"where id not in ({ids_str})"
cmds = __shred_sqlite_char_columns('urls', cols, where, path)
cmds += __shred_sqlite_char_columns('visits', path=path)
# Google Chrome 79 no longer has lower_term in keyword_search_terms
cols = ('term',)
cmds += __shred_sqlite_char_columns('keyword_search_terms',
cols, path=path)
ver = __get_chrome_history(path)
if ver >= 20:
# downloads, segments, segment_usage first seen in Chrome 14,
# Google Chrome 15 (database version = 20).
# Google Chrome 30 (database version 28) doesn't have full_path, but it
# does have current_path and target_path
if ver >= 28:
cmds += __shred_sqlite_char_columns(
'downloads', ('current_path', 'target_path'), path=path)
cmds += __shred_sqlite_char_columns(
'downloads_url_chains', ('url', ), path=path)
else:
cmds += __shred_sqlite_char_columns(
'downloads', ('full_path', 'url'), path=path)
cmds += __shred_sqlite_char_columns('segments', ('name',), path=path)
cmds += __shred_sqlite_char_columns('segment_usage', path=path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_keywords(path):
"""Delete keywords table in Chromium/Google Chrome 'Web Data' database"""
cols = ('short_name', 'keyword', 'favicon_url',
'originating_url', 'suggest_url')
where = "where not date_created = 0"
cmds = __shred_sqlite_char_columns('keywords', cols, where, path)
cmds += "update keywords set usage_count = 0;"
ver = __get_chrome_history(path, 'Web Data')
if 43 <= ver < 49:
# keywords_backup table first seen in Google Chrome 17 / Chromium 17
# which is Web Data version 43.
# In Google Chrome 25, the table is gone.
cmds += __shred_sqlite_char_columns('keywords_backup',
cols, where, path)
cmds += "update keywords_backup set usage_count = 0;"
FileUtilities.execute_sqlite3(path, cmds)
def delete_office_registrymodifications(path):
"""Erase LibreOffice 3.4 and Apache OpenOffice.org 3.4 MRU in registrymodifications.xcu"""
# Keep minidom out of module scope: Special is on the CLI and GUI startup
# paths, and this is the only function that needs it.
import xml.dom.minidom # pylint: disable=import-outside-toplevel
with open(path, 'rb') as f:
data = f.read()
reject_xml_dtd(data, 'registrymodifications.xcu')
dom1 = xml.dom.minidom.parseString(data)
modified = False
pathprefix = '/org.openoffice.Office.Histories/Histories/'
for node in dom1.getElementsByTagName("item"):
if not node.hasAttribute("oor:path"):
continue
if not node.getAttribute("oor:path").startswith(pathprefix):
continue
node.parentNode.removeChild(node)
node.unlink()
modified = True
if modified:
with FileUtilities.open_for_overwrite(path, encoding='utf-8') as xml_file:
dom1.writexml(xml_file)
def delete_mozilla_url_history(path):
"""Delete URL history in Mozilla places.sqlite (Firefox 3 and family)"""
import sqlite3
if not os.path.exists(path):
raise sqlite3.DatabaseError(f"{path} does not exist")
if os.path.getsize(path) == 0:
raise sqlite3.DatabaseError(f"{path} is an empty file")
if not _sqlite_is_valid_database(path):
raise sqlite3.DatabaseError(f"{path} is not a valid SQLite database")
cmds = ""
have_places = sqlite_table_exists(path, 'moz_places')
if have_places:
# delete the URLs in moz_places
places_suffix = "where id in (select " \
"moz_places.id from moz_places " \
"left join moz_bookmarks on moz_bookmarks.fk = moz_places.id " \
"where moz_bookmarks.id is null); "
cols = ('url', 'rev_host', 'title')
cmds += __shred_sqlite_char_columns('moz_places',
cols, places_suffix, path)
# For any bookmarks that remain in moz_places, reset the non-character values.
cmds += "update moz_places set visit_count=0, frecency=-1, last_visit_date=null;"
# delete any orphaned annotations in moz_annos
annos_suffix = "where id in (select moz_annos.id " \
"from moz_annos " \
"left join moz_places " \
"on moz_annos.place_id = moz_places.id " \
"where moz_places.id is null); "
cmds += __shred_sqlite_char_columns(
'moz_annos', ('content', ), annos_suffix, path)
# Delete any orphaned favicons.
# Firefox 78 no longer has a table named moz_favicons, and it no
# longer has a column favicon_id in the table moz_places. This
# change probably happened before version 78.
if have_places and sqlite_table_exists(path, 'moz_favicons'):
fav_suffix = "where id not in (select favicon_id " \
"from moz_places where favicon_id is not null ); "
cols = ('url', 'data')
cmds += __shred_sqlite_char_columns('moz_favicons',
cols, fav_suffix, path)
# Delete orphaned origins.
if have_places and sqlite_table_exists(path, 'moz_origins'):
origins_where = 'where id not in (select distinct origin_id from moz_places)'
cmds += __shred_sqlite_char_columns('moz_origins',
('host',), origins_where, path)
# For any remaining origins, reset the statistic.
cmds += "update moz_origins set frecency=-1;"
if sqlite_table_exists(path, 'moz_meta'):
cmds += "delete from moz_meta where key like 'origin_frecency_%';"
# Delete all history visits.
if sqlite_table_exists(path, "moz_historyvisits"):
cmds += "delete from moz_historyvisits;"
# delete any orphaned input history
if have_places:
input_suffix = "where place_id not in (select distinct id from moz_places)"
cols = ('input',)
cmds += __shred_sqlite_char_columns('moz_inputhistory',
cols, input_suffix, path)
# delete the whole moz_hosts table
# Reference: https://bugzilla.mozilla.org/show_bug.cgi?id=932036
# Reference:
# https://support.mozilla.org/en-US/questions/937290#answer-400987
if sqlite_table_exists(path, 'moz_hosts'):
cmds += __shred_sqlite_char_columns('moz_hosts', ('host',), path=path)
cmds += "delete from moz_hosts;"
# execute the commands
FileUtilities.execute_sqlite3(path, cmds)
def _remove_path_from_url(url):
"""Prepare URL for comparison in delete_mozilla_favicons()"""
prefix = 'fake-favicon-uri:'
if url.startswith(prefix):
url = url[len(prefix):]
url = urlparse(url)
if not url.netloc:
return url.geturl()
return urlunparse((url.scheme, url.netloc, '', '', '', ''))
def delete_mozilla_favicons(path):
"""Delete favorites icons in Mozilla places.favicons
Bookmarks are not deleted."""
cmds = ""
places_path = os.path.join(os.path.dirname(path), 'places.sqlite')
cmds += f"attach database '{_escape_sqlite_str_literal(places_path)}' as places;"
bookmarked_urls_query = ("select url from {db}moz_places where id in "
"(select distinct fk from {db}moz_bookmarks "
"where fk is not null){filter}")
# delete all not bookmarked pages with icons
urls_where = f"where page_url not in ({bookmarked_urls_query.format(db='places.', filter='')})"
cmds += __shred_sqlite_char_columns('moz_pages_w_icons',
('page_url',), urls_where, path)
# delete all not bookmarked icons to pages mapping
mapping_where = "where page_id not in (select id from moz_pages_w_icons)"
cmds += __shred_sqlite_char_columns('moz_icons_to_pages',
where=mapping_where, path=path)
# This intermediate cleaning is needed for the next query to favicons
# db which collects icon ids that don't have a bookmark or have domain
# level bookmark.
FileUtilities.execute_sqlite3(path, cmds)
cmds = ""
# Collect favicons that are not bookmarked with their full url,
# which collects also domain level bookmarks.
id_and_url_pairs = _get_sqlite_values(path,
"select id, icon_url from moz_icons where "
"(id not in (select icon_id from moz_icons_to_pages))")
# We query twice the bookmarked urls and this is a kind of
# duplication. This is because the first usage of bookmarks
# is for refining further queries to favicons db and if we
# first extract the bookmarks as a Python list and give them
# to the query we could cause an error in execute_sqlite3 since
# it splits the cmds string by ';' and bookmarked url could
# contain a ';'. Also if we have a Python list with urls we
# need to pay attention to escaping JavaScript strings in some
# bookmarks and probably other things. So the safer way for now
# is to not compose a query with Python list of extracted urls.
def row_factory(_cursor, row):
return row[0]
# With the row_factory bookmarked_urls is a list of urls, instead
# of list of tuples with first element a url
bookmarked_urls = _get_sqlite_values(places_path,
bookmarked_urls_query.format(
db='', filter=" and url NOT LIKE 'javascript:%'"),
row_factory)
bookmarked_urls_domains = set(map(_remove_path_from_url, bookmarked_urls))
# Delete orphaned favicons whose domain is not bookmarked.
# Favicons for bookmarked domains are kept regardless of URL path
# depth, because Firefox stores site favicons at deep paths (e.g.
# https://example.com/media/img/favicon.ico) and deleting them causes
# bookmark icons to revert to generic icons.
# https://github.com/bleachbit/bleachbit/issues/1537
ids_to_delete = [id for id, url in id_and_url_pairs
if _remove_path_from_url(url) not in bookmarked_urls_domains
]
# delete all not bookmarked icons
if ids_to_delete:
if not all(isinstance(i, int) for i in ids_to_delete):
raise ValueError('icon IDs must be integers')
ids_str = ','.join(str(i) for i in ids_to_delete)
icons_where = f'where (id in ({ids_str}))'
cols = ('icon_url', 'data')
cmds += __shred_sqlite_char_columns('moz_icons',
cols, icons_where, path)
FileUtilities.execute_sqlite3(path, cmds)
def get_chrome_bookmark_ids(history_path):
"""Given the path of a history file, return the ids in the
urls table that are bookmarks"""
bookmark_path = os.path.join(os.path.dirname(history_path), 'Bookmarks')
if not os.path.exists(bookmark_path):
return []
urls = get_chrome_bookmark_urls(bookmark_path)
ids = []
# Look up ids in batches. SQLite caps host parameters (default 999), so
# chunk the URLs instead of opening a connection per bookmark.
chunk_size = 500
for i in range(0, len(urls), chunk_size):
chunk = urls[i:i + chunk_size]
placeholders = ','.join('?' * len(chunk))
ids += get_sqlite_int(
history_path,
f'select id from urls where url in ({placeholders})',
tuple(chunk))
return ids
def get_chrome_bookmark_urls(path):
"""Return a list of bookmarked URLs in Google Chrome/Chromium"""
# read file to parser
with open(path, 'r', encoding='utf-8') as f:
js = json.load(f)
# empty list
urls = []
# local recursive function
def get_chrome_bookmark_urls_helper(node):
if not isinstance(node, dict):
return
if 'type' not in node:
return
if node['type'] == "folder":
# folders have children
for child in node['children']:
get_chrome_bookmark_urls_helper(child)
if node['type'] == "url" and 'url' in node:
urls.append(node['url'])
# find bookmarks
for node in js['roots'].values():
get_chrome_bookmark_urls_helper(node)
return list(set(urls)) # unique
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/SystemInformation.py 0000775 0001750 0001750 00000017721 15265023030 016776 0 ustar 00z z # 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.
"""
Show system information
"""
# standard library
import logging
import locale
import os
import platform
import re
import sys
from collections import OrderedDict
# local
import bleachbit
from bleachbit import IS_LINUX, IS_MAC, IS_POSIX, IS_WINDOWS
from bleachbit.General import get_executable, get_real_uid
logger = logging.getLogger(__name__)
def get_gtk_info():
"""Get dictionary of information about GTK"""
from bleachbit.GtkShim import (
gi, Gtk, is_gtk_available, get_gtk_unavailable_reason)
info = {}
if gi is None:
logger.debug('gi module not available')
return info
if not hasattr(gi, 'version_info'):
logger.debug('gi.version_info not available')
else:
info['gi.version'] = gi.__version__
if not is_gtk_available():
logger.debug('GTK not available: %s', get_gtk_unavailable_reason())
return info
settings = Gtk.Settings.get_default()
if not settings:
logger.debug('GTK settings not found')
return info
info['GTK version'] = f"{Gtk.get_major_version()}.{Gtk.get_minor_version()}.{Gtk.get_micro_version()}"
info['GTK theme'] = settings.get_property('gtk-theme-name')
info['GTK icon theme'] = settings.get_property('gtk-icon-theme-name')
info['GTK prefer dark theme'] = settings.get_property(
'gtk-application-prefer-dark-theme')
return info
def _get_home_dirs_to_anonymize():
"""Return home directories that should be anonymized."""
home_dirs = []
home_dir = os.path.expanduser('~') or ''
home_dirs.append(home_dir)
if IS_POSIX:
try:
# reminder: pwd is not available on Windows
import pwd
real_home_dir = pwd.getpwuid(get_real_uid()).pw_dir
except (ImportError, KeyError, RuntimeError, ValueError):
real_home_dir = ''
home_dirs.append(real_home_dir)
if IS_WINDOWS:
# Windows may return short (8.3) paths for environment variables
# like TMP when the username contains Unicode characters.
# Include the short path form of the home directory to ensure
# anonymization covers both long and short path forms.
try:
import win32api
short_home_dir = win32api.GetShortPathName(home_dir)
if short_home_dir and short_home_dir != home_dir:
home_dirs.append(short_home_dir)
except (ImportError, OSError, ValueError) as e:
logger.debug('no short path for the home directory: %s', e)
# Filter out root directories and duplicates
filtered_dirs = []
for d in home_dirs:
if not d:
continue
if d in ('/', '/root'):
continue
if d not in filtered_dirs:
filtered_dirs.append(d)
return filtered_dirs
def anonymize_system_information(text):
"""Anonymize non-generic username in system information text.
root is not anonymized.
"""
home_dirs = _get_home_dirs_to_anonymize()
home_token = '~' if IS_POSIX else '%userprofile%'
def mask_user_line(line):
"""Mask username for environment variables"""
if not (line.startswith('os.getenv(LOGNAME)') or line.startswith('os.getenv(USER)')):
return line
key, _, value = line.partition(' = ')
if value and value != 'None' and value.strip() != 'root':
# replace specific non-root username with *non-root*
return f'{key} = *non-root*'
return line
anonymized_lines = []
for line in text.split('\n'):
for home_dir in home_dirs:
if IS_WINDOWS:
# Windows paths are case-insensitive
line = re.sub(re.escape(home_dir), home_token,
line, flags=re.IGNORECASE)
else:
line = line.replace(home_dir, home_token)
anonymized_lines.append(mask_user_line(line))
return '\n'.join(anonymized_lines)
def get_version(four_parts=False):
"""Return version information as a string.
CI builds will have an integer build number.
If four_parts is True, always return a four-part version string.
If False, return three or four parts, depending on available information.
"""
build_number_env = os.getenv('GITHUB_RUN_NUMBER')
try:
from bleachbit.Revision import build_number as build_number_src
except ImportError:
# Revision.py only exists in CI and tarball builds
build_number_src = None
build_number = build_number_src or build_number_env
if build_number and not str(build_number).isdigit():
logger.warning('ignoring non-numeric build number: %r', build_number)
build_number = None
if not build_number:
if not four_parts:
return bleachbit.APP_VERSION
return f'{bleachbit.APP_VERSION}.0'
return f'{bleachbit.APP_VERSION}.{build_number}'
def _escape_invalid_unicode(value):
return str(value).encode('utf-8', errors='backslashreplace').decode('utf-8')
def get_system_information():
"""Return system information as a string."""
info = OrderedDict()
# Application and library versions
info['BleachBit version'] = get_version()
try:
# CI builds and Linux tarball will have a revision.
from bleachbit.Revision import revision
info['Git revision'] = revision
except ImportError:
logger.debug('no Revision module, so no git revision to report')
info.update(get_gtk_info())
# Variables defined in __init__.py
info['local_cleaners_dir'] = bleachbit.local_cleaners_dir
info['locale_dir'] = bleachbit.locale_dir
info['options_dir'] = bleachbit.options_dir
info['personal_cleaners_dir'] = bleachbit.personal_cleaners_dir
info['system_cleaners_dir'] = bleachbit.system_cleaners_dir
# System environment information
info['locale.getlocale'] = str(locale.getlocale())
# Environment variables
if IS_POSIX:
envs = ('DESKTOP_SESSION', 'LOGNAME', 'USER', 'SUDO_UID')
elif IS_WINDOWS:
envs = ('APPDATA', 'cd', 'LocalAppData', 'LocalAppDataLow', 'Music',
'USERPROFILE', 'ProgramFiles', 'ProgramW6432', 'TMP', '__COMPAT_LAYER')
else:
envs = ()
for env in envs:
info[f'os.getenv({env})'] = os.getenv(env)
info['os.path.expanduser("~")'] = os.path.expanduser('~')
if IS_LINUX:
from bleachbit.Unix import get_distribution_name_version
info['get_distribution_name_version()'] = get_distribution_name_version()
elif IS_MAC:
if hasattr(platform, 'mac_ver'):
mac_version = platform.mac_ver()[0]
from bleachbit.Mac import macos_version_name
name = macos_version_name(mac_version)
# Always show the raw version, even when the codename is
# unknown (e.g. a macOS release newer than this codebase's
# MACOSX_DICT_MODERN, such as macOS 27.0 at the time of
# writing) -- previously this line was silently omitted
# from the report entirely whenever the codename lookup
# came up empty, hiding the version number itself along
# with the missing codename.
if name:
info['platform.mac_ver()'] = f'{mac_version} ({name})'
else:
info['platform.mac_ver()'] = mac_version
else:
info['platform.uname().version'] = platform.uname().version
# System information
info['sys.argv'] = sys.argv
info['sys.executable'] = get_executable()
info['sys.version'] = sys.version
if IS_WINDOWS:
from win32com.shell import shell
info['IsUserAnAdmin()'] = shell.IsUserAnAdmin()
info['__file__'] = __file__
# Render the information as a string
return '\n'.join(
f'{key} = {_escape_invalid_unicode(value)}'
for key, value in info.items())
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Unix.py 0000775 0001750 0001750 00000132457 15265023030 014233 0 ustar 00z z # 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.
"""
Integration specific to Unix-like operating systems
"""
import configparser
import errno
import glob
import logging
import os
import platform
import posixpath
import re
import shlex
import subprocess
import bleachbit
from bleachbit import FileUtilities, General, IS_MAC, IS_POSIX
from bleachbit.FileUtilities import children_in_directory, exe_exists
from bleachbit.Language import get_text as _, native_locale_names, \
LocaleCode
from bleachbit.VFS import RealVFS
logger = logging.getLogger(__name__)
# Cache for snapd_is_active() to avoid repeated systemctl calls.
_snapd_is_active_cache = None
JOURNALD_REGEX = r'^Vacuuming done, freed ([\d.]+[BKMGT]?) of archived journals (on disk|from [\w/]+).$'
_real_vfs = RealVFS()
class LocaleCleanerPath:
"""This represents a path with either a specific folder name or a folder name pattern.
It also may contain several compiled regex patterns for localization items (folders or files)
and additional LocaleCleanerPaths that get traversed when asked to supply a list of localization
items"""
def __init__(self, location, vfs=None):
if location is None:
raise RuntimeError("location is none")
self.pattern = location
self.children = []
self._vfs = vfs
def _get_vfs(self):
return self._vfs if self._vfs is not None else _real_vfs
def add_child(self, child):
"""Adds a child LocaleCleanerPath"""
# child is the same class, so _vfs is not foreign.
# pylint: disable-next=protected-access
if isinstance(child, LocaleCleanerPath) and child._vfs is None:
# pylint: disable-next=protected-access
child._vfs = self._vfs
self.children.append(child)
return child
def add_path_filter(self, pre, post):
r"""Adds a filter consisting of a prefix and a postfix
(e.g. 'foobar_' and '\.qm' to match 'foobar_en_US.utf-8.qm)"""
try:
regex = re.compile('^' + pre + Locales.localepattern + post + '$')
except Exception as errormsg:
raise RuntimeError(
f"Malformed regex '{pre}' or '{post}': {errormsg}") from errormsg
self.add_child(regex)
def _listdir(self, path):
"""List a directory, treating one that cannot be read as empty"""
try:
return self._get_vfs().listdir(path)
except OSError:
# Such as /usr/share/empty.sshd, which is mode 0711
return []
def get_subpaths(self, basepath):
"""Returns direct subpaths for this object, i.e. either the named subfolder or all
subfolders matching the pattern"""
vfs = self._get_vfs()
if isinstance(self.pattern, re.Pattern):
names = self._listdir(basepath)
# posixpath is easy way to test also from Windows.
return (posixpath.join(basepath, p) for p in names
if self.pattern.match(p) and vfs.isdir(posixpath.join(basepath, p)))
path = posixpath.join(basepath, self.pattern)
return [path] if vfs.isdir(path) else []
def get_localizations(self, basepath):
"""Returns all localization items for this object and all descendant objects"""
for path in self.get_subpaths(basepath):
for child in self.children:
if isinstance(child, LocaleCleanerPath):
yield from child.get_localizations(path)
elif isinstance(child, re.Pattern):
for element in self._listdir(path):
match = child.match(element)
if match is not None:
yield (match.group('locale'),
match.group('specifier'),
posixpath.join(path, element))
class Locales:
"""Find languages and localization files"""
# The regular expression to match locale strings and extract the langcode.
# See test_locale_regex() in tests/TestUnix.py for examples
# This doesn't match all possible valid locale strings to avoid
# matching filenames you might want to keep, e.g. the regex
# to match jp.eucJP might also match jp.importantfileextension
localepattern =\
r'(?P[a-z]{2,3})' \
r'(?P[_-][A-Z]{2,4})?(?:\.[\w]+[\d-]+|@\w+)?' \
r'(?P[-._](?:(?:ISO|iso|UTF|utf|us-ascii)[\d-]+|(?:euc|EUC)[A-Z]+))?'
def __init__(self, vfs=None):
self._paths = LocaleCleanerPath(location='/', vfs=vfs)
def add_xml(self, xml_node, parent=None):
"""Parses the xml data and adds nodes to the LocaleCleanerPath-tree"""
if parent is None:
parent = self._paths
if xml_node.ELEMENT_NODE != xml_node.nodeType:
return
# if a pattern is supplied, we recurse into all matching subdirectories
if 'regexfilter' == xml_node.nodeName:
pre = xml_node.getAttribute('prefix') or ''
post = xml_node.getAttribute('postfix') or ''
parent.add_path_filter(pre, post)
elif 'path' == xml_node.nodeName:
if xml_node.hasAttribute('directoryregex'):
pattern = xml_node.getAttribute('directoryregex')
if '/' in pattern:
raise RuntimeError(
'directoryregex may not contain slashes.')
pattern = re.compile(pattern)
parent = parent.add_child(LocaleCleanerPath(pattern))
# a combination of directoryregex and filter could be too much
else:
if xml_node.hasAttribute("location"):
# if there's a filter attribute, it should apply to this path
parent = parent.add_child(LocaleCleanerPath(
xml_node.getAttribute('location')))
if xml_node.hasAttribute('filter'):
userfilter = xml_node.getAttribute('filter')
if 1 != userfilter.count('*'):
raise RuntimeError(
f"Filter string '{userfilter}' must contain the placeholder * exactly once")
# we can't use re.escape, because it escapes too much
(pre, post) = (re.sub(r'([\[\]()^$.])', r'\\\1', p)
for p in userfilter.split('*'))
parent.add_path_filter(pre, post)
else:
raise RuntimeError(
f"Invalid node '{xml_node.nodeName}', expected '' or ''")
# handle child nodes
for child_xml in xml_node.childNodes:
self.add_xml(child_xml, parent)
def localization_paths(self, locales_to_keep):
"""Returns all localization items matching the previously added xml configuration"""
purgeable_locales = get_purgeable_locales(locales_to_keep)
for (locale, specifier, path) in self._paths.get_localizations('/'):
specific = locale + (specifier or '')
if specific in purgeable_locales or \
(locale in purgeable_locales and specific not in locales_to_keep):
yield path
def _is_broken_xdg_desktop_application(config, desktop_pathname):
"""Returns whether application .desktop file is critically broken
This function tests only .desktop files with Type=Application.
"""
if not config.has_option('Desktop Entry', 'Exec'):
logger.info(
"is_broken_xdg_menu: missing required option 'Exec' in '%s'", desktop_pathname)
return True
exec_val = config.get('Desktop Entry', 'Exec')
try:
exec_parts = shlex.split(exec_val)
except ValueError as e:
# Malformed quoting in the Exec value. Per the XDG Desktop Entry
# spec this is undefined behavior; be conservative and keep the
# file rather than risk deleting a working launcher.
logger.warning(
"is_broken_xdg_menu: cannot parse 'Exec' key (%s) in '%s'", e, desktop_pathname)
return False
if not exec_parts:
logger.info(
"is_broken_xdg_menu: empty 'Exec' value in '%s'", desktop_pathname)
return True
exe = exec_parts[0]
if not os.path.isabs(exe) and not os.environ.get('PATH'):
raise RuntimeError(
f"Cannot find executable '{exe}' because PATH environment variable is not set")
if not FileUtilities.exe_exists(exe):
logger.info(
"is_broken_xdg_menu: executable '%s' does not exist in '%s'", exe, desktop_pathname)
return True
if 'env' == exe:
# Wine v1.0 creates .desktop files like this
# Exec=env WINEPREFIX="/home/z/.wine" wine "C:\\Program
# Files\\foo\\foo.exe"
execs = list(exec_parts)
wineprefix = None
del execs[0]
while execs and execs[0].find("=") >= 0:
(name, value) = execs[0].split("=", 1)
if name == 'WINEPREFIX':
wineprefix = value
del execs[0]
if not execs:
# env with only assignments, no command; keep the launcher
return False
if not FileUtilities.exe_exists(execs[0]):
logger.info(
"is_broken_xdg_menu: executable '%s' does not exist in '%s'", execs[0], desktop_pathname)
return True
# check the Windows executable exists
if wineprefix and len(execs) > 1:
windows_exe = wine_to_linux_path(wineprefix, execs[1])
if not os.path.exists(windows_exe):
logger.info("is_broken_xdg_menu: Windows executable '%s' does not exist in '%s'",
windows_exe, desktop_pathname)
return True
return False
def find_available_locales():
"""Returns a list of available locales using locale -a"""
rc, stdout, stderr = General.run_external(
[General.resolve_exe('locale'), '-a'])
if rc == 0:
return stdout.strip().split('\n')
logger.warning("Failed to get available locales: %s", stderr)
return []
def find_best_locale(user_locale):
"""Find closest match to available locales, preferring UTF-8
BleachBit's code and .po files are UTF-8 and GTK 3 requires UTF-8
for display strings, so a non-UTF-8 locale is returned only when
UTF-8 is not available.
"""
assert isinstance(user_locale, str)
req = LocaleCode(user_locale)
if not req.raw:
return 'C'
if req.is_special:
return req.raw
available_raw = find_available_locales()
available = [LocaleCode(a) for a in available_raw]
# Exact match if already UTF-8.
if req.is_utf8 and req.raw in available_raw:
return req.raw
# Prefer current system locale if compatible and UTF-8
# (e.g., system is 'es_MX.UTF-8' when requesting 'es').
# Import here for mock patch.
import locale
lang, codeset = locale.getlocale()
if lang and codeset and lang.startswith(req.normalized) and \
codeset.replace('-', '').replace('_', '').lower() == 'utf8':
return '.'.join((lang, codeset))
# Otherwise search installed locales for UTF-8 (e.g., 'es_ES.utf8').
for avail in available:
if avail.normalized.startswith(req.normalized) and avail.is_utf8:
return avail.raw
# Fall back to exact non-UTF-8 match (e.g., 'de_DE@euro').
for candidate in (req.raw, req.normalized):
if candidate in available_raw:
return candidate
# Fall back to any installed match (e.g., 'en_US.iso88591').
for avail in available:
if avail.normalized.startswith(req.normalized):
return avail.raw
return 'C'
def get_distribution_name_version_platform_freedesktop():
"""Returns the name and version of the distribution using
platform.freedesktop_os_release()
Example return value: 'ubuntu 24.10' or None
Python 3.10 added platform.freedesktop_os_release().
"""
if hasattr(platform, 'freedesktop_os_release'):
try:
release = platform.freedesktop_os_release()
except FileNotFoundError:
return None
dist_id = release.get('ID')
# Arch Linux omits VERSION_ID, so fall back to BUILD_ID.
# Ubuntu has VERSION_ID but not BUILD_ID.
dist_version_id = release.get('VERSION_ID') or release.get('BUILD_ID')
if dist_id and dist_version_id:
return f"{dist_id} {dist_version_id}"
return None
def get_distribution_name_version_distro():
"""Returns the name and version of the distribution using the distro
package
Example return value: 'ubuntu 24.10' or None
distro is a third-party package recommended here:
https://docs.python.org/3.7/library/platform.html
"""
try:
# Import here in case of ImportError.
import distro
# example 'ubuntu 24.10'
# Arch Linux returns id='arch' and version=''.
dist_version = distro.version()
if dist_version:
return distro.id() + ' ' + dist_version
except ImportError:
return None
return None
def get_distribution_name_version_os_release():
"""Returns the name and version of the distribution using /etc/os-release
Example return value: 'ubuntu 24.10' or None
"""
if not os.path.exists('/etc/os-release'):
return None
try:
with open('/etc/os-release', 'r', encoding='utf-8') as f:
os_release = {}
for line in f:
if '=' in line:
key, value = line.rstrip().split('=', 1)
os_release[key] = value.strip('"\'')
except Exception as e:
logger.debug("Error reading /etc/os-release: %s", e)
return None
if 'ID' in os_release:
dist_name = os_release['ID']
# ArchLinux has BUILD_ID='rolling' but not VERSION_ID.
# Ubuntu has VERSION_ID like '26.04' but does not have BUILD_ID.
dist_version = os_release.get(
'VERSION_ID') or os_release.get('BUILD_ID')
if dist_version:
return f"{dist_name} {dist_version}"
return None
def get_distribution_name_version():
"""Returns the name and version of the distribution
Depending on system capabilities, return value may be:
* 'ubuntu 24.10'
* 'Linux 6.12.3'
* 'Linux'
Python 3.7 had platform.linux_distribution(), but it
was removed in Python 3.8.
"""
for get_dist in (get_distribution_name_version_platform_freedesktop,
get_distribution_name_version_distro,
get_distribution_name_version_os_release):
if ret := get_dist():
return ret
for name, get_release in (('platform.release()', platform.release),
('os.uname()', lambda: os.uname().release)):
try:
# example '6.12.3-061203-generic'
linux_version = get_release().split('-')[0]
return f"Linux {linux_version}"
except Exception as e:
logger.debug("Error calling %s: %s", name, e)
return "Linux"
def get_mount_points():
"""Return read-write mount points that may have trash"""
try:
import psutil
except ImportError:
logger.warning('install psutil for better trash detection')
return []
mount_points = []
try:
for partition in psutil.disk_partitions():
mountpoint = partition.mountpoint
if re.match(r'^/(proc|sys|dev|run|boot)', mountpoint):
continue
if 'ro' in partition.opts.split(','):
continue
mount_points.append(mountpoint)
except (OSError, psutil.Error) as e:
logger.warning("Error getting mount points: %s", e)
return mount_points
def get_purgeable_locales(locales_to_keep):
"""Returns all locales to be purged"""
if not locales_to_keep:
raise RuntimeError('Found no locales to keep')
assert isinstance(locales_to_keep, list)
# Start with all locales as potentially purgeable
purgeable_locales = set(native_locale_names.keys())
# Remove the locales we want to keep
for keep in locales_to_keep:
keep_loc = LocaleCode(keep)
purgeable_locales.discard(keep)
# If keeping a variant (e.g. 'en_US'), also keep the base locale (e.g. 'en')
if keep_loc.territory:
purgeable_locales.discard(keep_loc.language)
# If keeping a base locale (e.g. 'en'), also keep all its variants (e.g. 'en_US')
else:
purgeable_locales = {locale for locale in purgeable_locales
if LocaleCode(locale).language != keep_loc.language}
return frozenset(purgeable_locales)
def get_trash_paths():
"""Iterate over all trash on POSIX systems"""
# Import here to avoid a circular import.
from bleachbit import Command
# macOS-style flat trash. list_directories=True is required so that
# a folder sent to Trash is itself removed after its contents are
# deleted; with False, only files inside it are yielded and the now-
# empty folder is left behind forever.
dirname = os.path.expanduser("~/.Trash")
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
# Freedesktop trash spec directories
# https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
home_trash = os.path.join(
os.environ.get('XDG_DATA_HOME', os.path.expanduser('~/.local/share')),
'Trash')
fallback_trash = os.path.expanduser('~/.local/share/Trash')
trash_dirs = [home_trash]
if home_trash != fallback_trash:
trash_dirs.append(fallback_trash)
# Snap apps store trash under ~/snap///.local/share/Trash
for d in glob.glob(os.path.expanduser("~/snap/*/*/.local/share/Trash")):
# Do not follow revision symlinks. For example, current -> 238 would match twice.
rev_dir = os.path.dirname(os.path.dirname(os.path.dirname(d)))
if not os.path.islink(rev_dir):
trash_dirs.append(d)
# Per-mountpoint trash (method 1: .Trash/$uid, method 2: .Trash-$uid)
uid = os.getuid()
for mountpoint in get_mount_points():
trash_dirs.append(os.path.join(mountpoint, '.Trash', str(uid)))
trash_dirs.append(os.path.join(mountpoint, f'.Trash-{uid}'))
# Deduplicate while preserving order
seen = set()
for trash_dir in trash_dirs:
real_path = os.path.realpath(trash_dir)
if real_path in seen:
continue
seen.add(real_path)
for subdir in ('files', 'info', 'expunged'):
dirname = os.path.join(trash_dir, subdir)
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
def is_unregistered_mime(mimetype):
"""Returns True if the MIME type is known to be unregistered. If
registered or unknown, conservatively returns False."""
try:
from bleachbit.GtkShim import Gio
if 0 == len(Gio.app_info_get_all_for_type(mimetype)):
return True
except ImportError:
logger.warning(
'error calling gio.app_info_get_all_for_type(%s)', mimetype)
return False
def is_broken_xdg_desktop(pathname):
"""Returns whether the given XDG .desktop file is critically broken.
Reference: https://specifications.freedesktop.org/desktop-entry/latest/"""
config = configparser.RawConfigParser()
try:
config.read(pathname)
except UnicodeDecodeError:
logger.info(
"is_broken_xdg_menu: cannot decode file: '%s'", pathname)
return True
except (configparser.Error) as e:
logger.info(
"is_broken_xdg_menu: %s: '%s'", e, pathname)
return True
if not config.has_section('Desktop Entry'):
logger.info(
"is_broken_xdg_menu: missing required section 'Desktop Entry': '%s'", pathname)
return True
if not config.has_option('Desktop Entry', 'Type'):
logger.info(
"is_broken_xdg_menu: missing required option 'Type': '%s'", pathname)
return True
if not config.has_option('Desktop Entry', 'Name'):
logger.info(
"is_broken_xdg_menu: missing required option 'Name': '%s'", pathname)
return True
file_type = config.get('Desktop Entry', 'Type').strip().lower()
if 'link' == file_type:
if not config.has_option('Desktop Entry', 'URL') and \
not config.has_option('Desktop Entry', 'URL[$e]'):
logger.info(
"is_broken_xdg_menu: missing required option 'URL': '%s'", pathname)
return True
return False
if 'mimetype' == file_type:
if not config.has_option('Desktop Entry', 'MimeType'):
logger.info(
"is_broken_xdg_menu: missing required option 'MimeType': '%s'", pathname)
return True
mimetype = config.get('Desktop Entry', 'MimeType').strip().lower()
if is_unregistered_mime(mimetype):
logger.info(
"is_broken_xdg_menu: MimeType '%s' not registered '%s'", mimetype, pathname)
return True
return False
if 'application' == file_type:
return _is_broken_xdg_desktop_application(config, pathname)
logger.warning("unhandled type '%s': file '%s'", file_type, pathname)
return False
def rotated_logs():
"""Yield a list of rotated (i.e., old) logs in /var/log/
See:
https://bugs.launchpad.net/bleachbit/+bug/367575
https://github.com/bleachbit/bleachbit/issues/1744
"""
keep_lists = [re.compile(r'/var/log/(removed_)?(packages|scripts)'),
re.compile(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}')]
positive_re = re.compile(r'(\.(\d+|bz2|gz|xz|old)|\-\d{8}?)')
for path in bleachbit.FileUtilities.children_in_directory('/var/log'):
if bleachbit.FileUtilities.whitelisted(path):
continue
# On macOS, skip a path only if its parent dir exists and we
# truly lack write access (a missing parent must not count as
# 'no permission', since os.access() returns False for both).
parent_dir = os.path.dirname(path)
if IS_MAC and os.path.isdir(parent_dir) and not os.access(parent_dir, os.W_OK):
continue
if any(keep_list.search(path) for keep_list in keep_lists):
continue
if positive_re.search(path):
yield path
def orphaned_framework_versions(versions_dir):
"""Yield paths of files (and, once emptied, the folder itself)
inside each orphaned version folder under a macOS .app's
Contents/Frameworks/*.framework/Versions/ directory.
Individual files are yielded rather than each orphaned folder as a
single unit because Command.Delete sizes a directory via a single
lstat(), reflecting only the directory inode itself (near 0 bytes)
rather than its actual recursive disk usage.
Chromium-based browsers on macOS (e.g. Google Chrome) keep every
version they have ever been updated to/from inside this directory,
with a 'Current' symlink pointing at the one actually in use.
Google's own auto-update mechanism has never reliably cleaned up
the old ones (a bug reported publicly since at least 2011), so
these can accumulate to several GB over the lifetime of an
installation.
Only 'Current' identifies the version genuinely in use -- version
numbers are not orderable across schemes (e.g. '152.0.7977.83' vs
'152.1.94.117') and a folder's modification time can be misleading
(e.g. after restoring from a backup), so both are deliberately
ignored here. If 'Current' cannot be read as a valid symlink to an
existing sibling directory, nothing is yielded at all, erring on
the side of not deleting anything rather than guessing which
version is safe to remove.
"""
current_link = os.path.join(versions_dir, 'Current')
if not os.path.islink(current_link):
return
try:
current_target = os.readlink(current_link)
except OSError:
return
# The symlink is typically relative (e.g. '152.0.7977.83'), but
# tolerate an absolute target too.
current_name = os.path.basename(current_target.rstrip('/'))
current_real = os.path.realpath(current_link)
if not os.path.isdir(current_real):
# 'Current' points at something that doesn't exist -- do not
# guess which folder is safe to remove.
return
try:
entries = os.listdir(versions_dir)
except OSError:
return
for name in entries:
if name in ('Current', current_name):
continue
full_path = os.path.join(versions_dir, name)
if not os.path.isdir(full_path) or os.path.islink(full_path):
continue
# Yield each file individually (rather than the folder as one
# unit) so Command.Delete reports the real disk space freed --
# it sizes a directory via a single lstat(), which reflects
# only the directory inode itself (near 0 bytes), not its
# recursive content. children_in_directory() only yields items
# found INSIDE full_path (with list_directories=True also
# yielding any nested subdirectories, once emptied) -- it never
# yields full_path itself, so that is yielded explicitly last,
# once its own contents have all been accounted for above.
yield from bleachbit.FileUtilities.children_in_directory(
full_path, list_directories=True)
yield full_path
def wine_to_linux_path(wineprefix, windows_pathname):
"""Return a Linux pathname from an absolute Windows pathname and Wine prefix"""
drive_letter = windows_pathname[0]
windows_pathname = windows_pathname.replace(drive_letter + ":",
"drive_" + drive_letter.lower())
windows_pathname = windows_pathname.replace("\\", "/")
return os.path.join(wineprefix, windows_pathname)
def run_cleaner_cmd(cmd, args, freed_space_regex=r'[\d.]+[kMGTE]?B?', error_line_regexes=None):
"""Runs a specified command and returns how much space was (reportedly) freed.
The subprocess shouldn't need any user input and the user should have the
necessary rights.
freed_space_regex gets applied to every output line, if the re matches,
add values captured by the single group in the regex"""
if not FileUtilities.exe_exists(cmd):
raise RuntimeError(_('Executable not found: %s') % cmd)
freed_space_regex = re.compile(freed_space_regex)
error_line_regexes = [re.compile(regex)
for regex in error_line_regexes or []]
env = General.sanitize_root_env(
{'LC_ALL': 'C', 'PATH': os.getenv('PATH') or os.defpath})
output = subprocess.check_output([cmd] + args, stderr=subprocess.STDOUT,
universal_newlines=True, env=env)
freed_space = 0
for line in output.split('\n'):
m = freed_space_regex.match(line)
if m is not None:
freed_space += FileUtilities.human_to_bytes(m.group(1))
for error_re in error_line_regexes:
if error_re.search(line):
raise RuntimeError('Invalid output from %s: %s' % (cmd, line))
return freed_space
def journald_clean():
"""Clean the system journals"""
try:
return run_cleaner_cmd(General.resolve_exe('journalctl'), ['--vacuum-size=1'], JOURNALD_REGEX)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Error calling '{' '.join(e.cmd)}':\n{e.output}") from e
def apt_autoremove():
"""Run 'apt-get autoremove' and return the size (un-rounded, in bytes) of freed space"""
args = ['--yes', 'autoremove']
# After this operation, 74.7MB disk space will be freed.
# After this operation, 44.0 kB disk space will be freed.
freed_space_regex = r'.*, ([\d.]+ ?[a-zA-Z]{2}) disk space will be freed.'
try:
return run_cleaner_cmd(General.resolve_exe('apt-get'), args, freed_space_regex, ['^E: '])
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Error calling '{' '.join(e.cmd)}':\n{e.output}") from e
def apt_autoclean():
"""Run 'apt-get autoclean' and return the size (un-rounded, in bytes) of freed space"""
try:
return run_cleaner_cmd(General.resolve_exe('apt-get'), ['autoclean'], r'^Del .*\[([\d.]+ ?[a-zA-Z]{2})\]', ['^E: '])
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Error calling '{' '.join(e.cmd)}':\n{e.output}") from e
def apt_clean():
"""Run 'apt-get clean' and return the size in bytes of freed space"""
old_size = get_apt_size()
try:
run_cleaner_cmd(General.resolve_exe('apt-get'),
['clean'], '^unused regex$', ['^E: '])
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Error calling '{' '.join(e.cmd)}':\n{e.output}") from e
new_size = get_apt_size()
return old_size - new_size
def get_apt_size():
"""Return the size of the apt cache (in bytes)"""
(_rc, stdout, _stderr) = General.run_external(
[General.resolve_exe('apt-get'), '-s', 'clean'])
paths = re.findall(r'/[/a-z\.\*]+', stdout)
return get_globs_size(paths)
def get_globs_size(paths):
"""Get the cumulative size (in bytes) of a list of globs"""
total_size = 0
for path in paths:
for p in glob.iglob(path):
try:
total_size += FileUtilities.getsize(p)
except FileNotFoundError:
logger.debug('%s vanished while totalling sizes', p)
return total_size
def yum_clean():
"""Run 'yum clean all' and return size in bytes recovered"""
if os.path.exists('/var/run/yum.pid'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "Yum"
raise RuntimeError(msg)
old_size = FileUtilities.getsizedir('/var/cache/yum')
args = ['--enablerepo=*', 'clean', 'all']
invalid = ['You need to be root', 'Cannot remove rpmdb file']
try:
run_cleaner_cmd(General.resolve_exe('yum'),
args, '^unused regex$', invalid)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Error calling '{' '.join(str(part) for part in e.cmd)}':\n{e.output}") from e
new_size = FileUtilities.getsizedir('/var/cache/yum')
return old_size - new_size
def dnf_clean():
"""Run 'dnf clean all' and return size in bytes recovered"""
if os.path.exists('/var/run/dnf.pid'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "Dnf"
raise RuntimeError(msg)
if not FileUtilities.exe_exists(General.resolve_exe('dnf')):
raise RuntimeError(_('Executable not found: %s') % 'dnf')
# DNF5 permits "clean all" as a non-root user, but it then cleans the
# per-user cache (~/.cache/libdnf5) instead of the system cache
# (/var/cache/dnf) that this function measures. Require root so the
# returned byte count reflects real system-cache cleaning rather than
# silently returning 0.
if os.geteuid() != 0:
raise RuntimeError('dnf clean requires root permissions')
# DNF4 does not report freed space in its output, so infer effect
# by measuring the delta in directory size.
old_size = FileUtilities.getsizedir('/var/cache/dnf')
args = [General.resolve_exe('dnf'), '--enablerepo=*', 'clean', 'all']
invalid = ['You need to be root', 'Cannot remove rpmdb file']
(rc, stdout, stderr) = General.run_external(args)
allout = stdout + stderr
for line in allout.split('\n'):
for error_re in invalid:
if re.search(error_re, line):
raise RuntimeError(f'Invalid output from dnf: {line}')
if rc > 0:
raise RuntimeError(f'dnf clean raised error {rc}: {stderr}')
match = DNF5_CLEAN_ERRORS_RE.search(allout)
if match:
raise RuntimeError(f'dnf clean reported {match.group(1)} errors')
# DNF5 reports the freed space directly in its summary line.
freed_bytes = parse_dnf_freed_space(allout)
if freed_bytes is not None:
logger.debug('dnf_clean >> parsed freed bytes: %s', freed_bytes)
return freed_bytes
# DNF4 fallback: measure the cache directory.
new_size = FileUtilities.getsizedir('/var/cache/dnf')
return old_size - new_size
units = {"B": 1, "k": 10**3, "M": 10**6, "G": 10**9,
"KiB": 2**10, "MiB": 2**20, "GiB": 2**30, "TiB": 2**40}
_DNF_UNITS = {
' ': 1, 'k': 1024, 'M': 1024**2, 'G': 1024**3,
'T': 1024**4, 'P': 1024**5, 'E': 1024**6,
'Z': 1024**7, 'Y': 1024**8,
}
# DNF5 (a total rewrite of DNF4) uses explicit 1024-based binary unit
# symbols produced by libdnf5::cli::utils::units::to_size().
_DNF5_UNITS = {
'B': 1,
'KiB': 1024,
'MiB': 1024**2,
'GiB': 1024**3,
'TiB': 1024**4,
'PiB': 1024**5,
'EiB': 1024**6,
'ZiB': 1024**7,
'YiB': 1024**8,
}
_DNF5_UNIT_NAMES = '|'.join(_DNF5_UNITS)
# DNF5 autoremove summary, e.g.
# "After this operation, 299 MiB will be freed (install 0 B, remove 299 MiB)."
DNF5_AUTOREMOVE_RE = re.compile(
r'After this operation, '
r'([0-9]+(?:\.[0-9]+)?) '
r'(' + _DNF5_UNIT_NAMES + r') '
r'will be freed')
# DNF5 "clean all" summary, e.g.
# "Removed 12 files, 3 directories (total of 25 MiB). 0 errors occurred."
DNF5_CLEAN_RE = re.compile(
r'Removed \d+ files, \d+ directories '
r'\(total of ([0-9]+(?:\.[0-9]+)?) '
r'(' + _DNF5_UNIT_NAMES + r')\)')
DNF5_CLEAN_ERRORS_RE = re.compile(
r'Removed \d+ files, \d+ directories '
r'\(total of [0-9]+(?:\.[0-9]+)? '
r'(?:' + _DNF5_UNIT_NAMES + r')\)\. '
r'([1-9]\d*) errors occurred\.')
def parse_size(size):
"""Parse the size returned when cleaning pacman cache"""
number, unit = [string.strip() for string in size.split()]
return int(float(number) * units[unit])
def dnf_autoremove():
"""Run 'dnf autoremove' and return size in bytes recovered."""
if os.path.exists('/var/run/dnf.pid'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "Dnf"
raise RuntimeError(msg)
if not FileUtilities.exe_exists(General.resolve_exe('dnf')):
raise RuntimeError(_('Executable not found: %s') % 'dnf')
cmd = [General.resolve_exe('dnf'), '-y', 'autoremove']
(rc, stdout, stderr) = General.run_external(cmd)
freed_bytes = 0
allout = stdout + stderr
if 'Error: This command has to be run under the root user.' in allout:
raise RuntimeError('dnf autoremove requires root permissions')
if rc > 0:
raise RuntimeError(f'dnf autoremove raised error {rc}: {stderr}')
freed_bytes = parse_dnf_freed_space(allout) or 0
logger.debug(
'dnf_autoremove >> total freed bytes: %s', freed_bytes)
return freed_bytes
def parse_dnf_freed_space(output):
"""Parse dnf output for the freed-space line and return bytes freed.
Handles both DNF4 and DNF5:
DNF4 autoremove ("Freed space: 299 M"):
- dnf's format_number() divides by 1024 and uses unit symbols
' ' (bytes), k, M, G, T, P, E, Z, Y. The byte tier uses a literal
space as the symbol, so byte values print with two trailing spaces.
DNF5 autoremove ("After this operation, 299 MiB will be freed "
"(install 0 B, remove 299 MiB).") reports the net installed size of
removed RPMs (remove - install).
DNF5 "clean all" ("Removed 12 files, 3 directories (total of 25 MiB). "
"0 errors occurred.") reports the apparent size of deleted cache files.
DNF5 uses 1024-based binary units (B, KiB, MiB, ...).
Returns None when no freed-space line is found.
"""
# DNF4 autoremove
match = re.search(
r"Freed space: (\d+(?:\.\d+)?) ([ kMGTPEZY])", output)
if match:
return int(float(match.group(1)) * _DNF_UNITS[match.group(2)])
# DNF5 autoremove
match = DNF5_AUTOREMOVE_RE.search(output)
if match:
return int(float(match.group(1)) * _DNF5_UNITS[match.group(2)])
# DNF5 clean all
match = DNF5_CLEAN_RE.search(output)
if match:
return int(float(match.group(1)) * _DNF5_UNITS[match.group(2)])
return None
def pacman_cache():
"""Clean cache in pacman"""
if os.path.exists('/var/lib/pacman/db.lck'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "pacman"
raise RuntimeError(msg)
paccache = General.resolve_exe('paccache')
if not exe_exists(paccache):
raise RuntimeError('paccache not found')
cmd = [paccache, '-rk0']
(rc, stdout, stderr) = General.run_external(cmd)
if rc > 0:
raise RuntimeError(f'paccache raised error {rc}: {stderr}')
# parse line like this: "==> finished: 3 packages removed (42.31 MiB freed)"
cregex = re.compile(
r"==> finished: ([\d.]+) packages removed \(([\d.]+\s+(?:[KMGT]iB|[BkMG])) freed\)")
match = cregex.search(stdout)
if match:
return parse_size(match.group(2))
return 0
def snap_parse_list(stdout):
"""Parse output of `snap list --all`"""
disabled_snaps = []
lines = stdout.strip().split('\n')
if not lines:
return disabled_snaps
# Example output: "No snaps are installed yet. Try 'snap install hello-world'."
raw_header = lines[0]
header = raw_header.lower()
if 'no snaps' in header and 'install' in header:
return disabled_snaps
if "name" not in header or "rev" not in header or "notes" not in header:
logger.warning(
"Unexpected 'snap list --all' output; returning 0. First line: %r", raw_header)
return disabled_snaps
for line in lines[1:]: # Skip header line
parts = line.split()
if len(parts) >= 4 and 'disabled' in line:
snapname = parts[0]
revision = parts[2]
disabled_snaps.append((snapname, revision))
return disabled_snaps
def snapd_is_active():
"""Return True if snap is installed and snapd is active.
The result is cached in a module-level variable to avoid repeated
systemctl calls during a single BleachBit run.
"""
def check_snapd():
if not exe_exists(General.resolve_exe('snap')):
return False
if not exe_exists(General.resolve_exe('systemctl')):
return False
# When snap is installed but snapd is inactive, then `snap list --all`
# or `snap version` may have a long delay, so we check the service status first
try:
(rc, _stdout, _stderr) = General.run_external(
[General.resolve_exe('systemctl'), 'is-active',
'--quiet', 'snapd.socket'],
timeout=5)
except subprocess.TimeoutExpired:
logger.warning(
'systemctl is-active snapd.socket timed out: it seems snap is installed but snapd is inactive')
return False
except (FileNotFoundError, OSError) as exc:
logger.warning('systemctl is-active snapd.socket failed: %s', exc)
return False
return rc == 0
global _snapd_is_active_cache # pylint: disable=global-statement
if _snapd_is_active_cache is None:
_snapd_is_active_cache = check_snapd()
return _snapd_is_active_cache
def clear_snapd_cache():
"""Clear the snapd_is_active() cache."""
global _snapd_is_active_cache # pylint: disable=global-statement
_snapd_is_active_cache = None
def snap_disabled_full(really_delete):
"""Remove disabled snaps"""
assert isinstance(really_delete, bool)
if not snapd_is_active():
raise RuntimeError('snap not found or snapd is not active')
# Get list of all snaps.
cmd = [General.resolve_exe('snap'), 'list', '--all']
try:
(rc, stdout, stderr) = General.run_external(
cmd, clean_env=True, timeout=15)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
'snap list --all timed out after 15 seconds; is snapd running?') from exc
if rc > 0:
raise RuntimeError(f'snap list raised error {rc}: {stderr}')
# Parse output to find disabled snaps.
disabled_snaps = snap_parse_list(stdout)
if not disabled_snaps:
return 0
# Remove disabled snaps.
total_freed = 0
for snapname, revision in disabled_snaps:
# `snap info` returns info only about active snaps.
# Instead, get size from the snap file directly.
snap_file = f'/var/lib/snapd/snaps/{snapname}_{revision}.snap'
try:
snap_size = os.path.getsize(snap_file)
logger.debug('Found snap file: %s, size: %s',
snap_file, f"{snap_size:,}")
except OSError as e:
if e.errno == errno.ENOENT:
logger.warning('Could not find snap file: %s', snap_file)
else:
logger.warning('Could not measure snap file %s: %s',
snap_file, e)
snap_size = 0
# Remove the snap revision
if really_delete:
# Consider there may be a slow system with a large snap.
remove_cmd = [General.resolve_exe('snap'), 'remove',
snapname, f'--revision={revision}']
try:
(rc, _, remove_stderr) = General.run_external(
remove_cmd, clean_env=True, timeout=60)
except subprocess.TimeoutExpired:
logger.error(
'Timeout removing snap %s revision %s', snapname, revision)
break
if rc > 0:
logger.error(
'Failed to remove snap %s revision %s: %s', snapname, revision, remove_stderr)
break
total_freed += snap_size
logger.debug(
'Removed snap %s revision %s, freed %s bytes', snapname, revision, snap_size)
else:
total_freed += snap_size
return total_freed
def snap_disabled_clean():
"""Remove disabled snaps"""
return snap_disabled_full(True)
def snap_disabled_preview():
"""Preview snaps that would be removed"""
return snap_disabled_full(False)
def is_unix_display_protocol_wayland():
"""Return True if the display protocol is Wayland."""
assert IS_POSIX
if 'XDG_SESSION_TYPE' in os.environ:
# If not wayland, then x11, mir, etc.
return os.environ['XDG_SESSION_TYPE'] == 'wayland'
if 'WAYLAND_DISPLAY' in os.environ:
return True
# Ubuntu 24.10 showed "ubuntu-xorg".
# openSUSE Tumbleweed and Fedora 41 showed "gnome".
# Fedora 41 also showed "plasma".
if os.environ.get('DESKTOP_SESSION') in ('ubuntu-xorg', 'gnome', 'plasma'):
return False
# Wayland (Ubuntu 23.10) sets DISPLAY=:0 like x11, so do not check DISPLAY.
try:
(rc, stdout, _stderr) = General.run_external(
[General.resolve_exe('loginctl')])
except FileNotFoundError:
return False
if rc != 0:
logger.warning('logintctl returned rc %s', rc)
return False
try:
session = stdout.split('\n')[1].strip().split(' ')[0]
except (IndexError, ValueError):
logger.warning('unexpected output from loginctl: %s', stdout)
return False
if not session.isdigit():
logger.warning('unexpected session loginctl: %s', session)
return False
result = General.run_external(
[General.resolve_exe('loginctl'), 'show-session', session, '-p', 'Type'])
return 'wayland' in result[1].lower()
def root_is_not_allowed_to_X_session():
"""Return True if root is not allowed to X session.
This function is called only with root on Wayland.
"""
assert IS_POSIX
try:
result = General.run_external(
[General.resolve_exe('xhost')], clean_env=False)
xhost_returned_error = result[0] == 1
return xhost_returned_error
except (FileNotFoundError, OSError) as exc:
logger.debug(
'xhost check failed (%s); assuming root is not allowed to X session', exc)
return True
def is_display_protocol_wayland_and_root_not_allowed():
"""Return True if the display protocol is Wayland and root is not allowed to X session"""
try:
is_wayland = bleachbit.Unix.is_unix_display_protocol_wayland()
except Exception as e:
logger.exception(e)
return False
return (
is_wayland and
os.environ.get('USER') == 'root' and
bleachbit.Unix.root_is_not_allowed_to_X_session()
)
def _dns_flush_command():
"""Return the command to flush the DNS resolver cache, or None if there is none"""
for exe, arg in (('resolvectl', 'flush-caches'),
('systemd-resolve', '--flush-caches')):
path = General.resolve_exe(exe)
if exe_exists(path):
return [path, arg]
return None
def can_flush_dns():
"""Return whether the DNS resolver cache can be flushed"""
return _dns_flush_command() is not None
def flush_dns():
"""Flush the DNS resolver cache
Returns 0 on success.
Raises RuntimeError on failure.
"""
args = _dns_flush_command()
if args is None:
raise RuntimeError('Neither resolvectl nor systemd-resolve found')
(rc, stdout, stderr) = General.run_external(args)
if 0 != rc:
# If service is not found, then there may be nothing to flush.
if 'Unit dbus-org.freedesktop.resolve1.service not found' in stderr:
logger.warning(stderr)
return 0
raise RuntimeError(
f'Command: {args}\nReturn code: {rc}\nStdout: {stdout}\nStderr: {stderr}')
return 0
locales = Locales()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Update.py 0000775 0001750 0001750 00000013655 15265023030 014530 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Check for updates via the Internet
"""
# standard library
import hashlib
import logging
import os
import sys
import xml.dom.minidom
# local
import bleachbit
from bleachbit.General import reject_xml_dtd
from bleachbit.Language import get_text as _
from bleachbit.Network import (download_url_to_fn, fetch_url,
get_ip_for_url, get_update_request_headers,
RequestException, HAVE_REQUESTS)
logger = logging.getLogger(__name__)
def update_winapp2(url, hash_expected, append_text, cb_success):
"""Download latest winapp2.ini file. Hash is sha512 or None to disable checks"""
if not url.lower().startswith('https://'):
logger.warning('refusing insecure winapp2 URL: %s', url)
return
# first, determine whether an update is necessary
fn = os.path.join(bleachbit.personal_cleaners_dir, 'winapp2.ini')
if os.path.exists(fn):
with open(fn, 'rb') as f:
hash_current = hashlib.sha512(f.read()).hexdigest()
if not hash_expected or hash_current == hash_expected:
# update is same as current
return
# download update
# Define error handler to propagate download errors
def on_error(msg, msg2):
raise RuntimeError(f"{msg}: {msg2}")
if download_url_to_fn(url, fn, hash_expected, on_error):
# TRANSLATORS: Status message after a successful winapp2.ini update.
append_text(_('New winapp2.ini was downloaded.'))
cb_success()
def update_dialog(parent, updates):
"""Updates contains the version numbers and URLs"""
# import these here to allow headless mode.
from bleachbit.GtkShim import Gtk
from bleachbit.GuiBasic import open_url
dlg = Gtk.Dialog(
# TRANSLATORS: Title of the software update dialog.
title=_("Update BleachBit"),
transient_for=parent,
modal=True,
destroy_with_parent=True)
dlg.set_default_size(250, 125)
# TRANSLATORS: Message in the software update dialog.
label = Gtk.Label(label=_("A new version is available."))
dlg.vbox.pack_start(label, True, True, 0)
for (ver, url) in updates:
box_update = Gtk.Box()
# TRANSLATORS: %s expands to version such as '4.6.0'
button_stable = Gtk.Button(_("Update to version %s") % ver)
# Bind url now: the callback runs after the loop, when url would
# otherwise be the last update's.
button_stable.connect(
'clicked', lambda dummy, url=url: open_url(url, parent, False))
button_stable.connect('clicked', lambda dummy: dlg.response(0))
box_update.pack_start(button_stable, False, True, 10)
dlg.vbox.pack_start(box_update, False, True, 0)
dlg.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
dlg.show_all()
dlg.run()
dlg.destroy()
return False
def check_updates(check_beta, check_winapp2, append_text, cb_success):
"""Check for updates via the Internet
Returns a list of (ver, url) pairs, empty when there is no update.
"""
url = bleachbit.update_check_url
if 'windowsapp' in sys.executable.lower():
url += '?windowsapp=1'
try:
response = fetch_url(url,
headers=get_update_request_headers())
except RequestException as e:
logger.error(
_('Error when opening a network connection to check for updates. Please verify the network is working and that a firewall is not blocking this application. Error message: {}').format(e))
logger.debug('URL %s has IP address %s', url, get_ip_for_url(url))
if HAVE_REQUESTS and hasattr(e, 'response') and e.response is not None:
logger.debug(e.response.headers)
return []
try:
# pyexpat rejects a str carrying an encoding declaration; pass bytes
reject_xml_dtd(response.content, 'update XML')
dom = xml.dom.minidom.parseString(response.content)
except Exception:
logger.exception(
'The update information does not parse: %s', response.text)
return []
def parse_updates(element):
"""Return a (ver, url) pair, or None when there is nothing usable"""
if not element:
return None
ver = element[0].getAttribute('ver')
child = element[0].firstChild
url = child.data.strip() if child is not None and hasattr(child, 'data') else ''
if not (isinstance(ver, str) and isinstance(url, str)
and url.lower().startswith('https://')):
logger.warning('ignoring malformed update entry: ver=%r, url=%r',
ver, url)
return None
return ver, url
stable = parse_updates(dom.getElementsByTagName("stable"))
beta = parse_updates(dom.getElementsByTagName("beta"))
wa_element = dom.getElementsByTagName('winapp2')
if check_winapp2 and wa_element:
wa_sha512 = wa_element[0].getAttribute('sha512')
wa_url = wa_element[0].getAttribute('url')
update_winapp2(wa_url, wa_sha512, append_text, cb_success)
dom.unlink()
if stable and beta and check_beta:
return [stable, beta]
if stable:
return [stable]
if beta and check_beta:
return [beta]
return []
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/VFS.py 0000664 0001750 0001750 00000005326 15265023030 013735 0 ustar 00z z # 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.
"""
Virtual File System abstraction for testing filesystem traversal
without requiring real files and directories.
"""
import os
class VFS:
"""Abstract base class for virtual file systems"""
def listdir(self, path):
"""Return a list of the entries in the directory given by path"""
raise NotImplementedError
def isdir(self, path):
"""Return True if path is an existing directory"""
raise NotImplementedError
class RealVFS(VFS):
"""Pass-through to the real filesystem"""
def listdir(self, path):
return os.listdir(path)
def isdir(self, path):
return os.path.isdir(path)
class ListVFS(VFS):
"""Virtual filesystem built from a flat list of full paths.
Derives directory structure automatically. Any path prefix that
appears as a parent of another path is treated as a directory.
Leaf paths (with no children in the list) are treated as files,
unless they end with a slash.
"""
def __init__(self, paths):
self._dirs = {'/'}
self._files = set()
self._children = {} # parent_dir -> set of child names
for path in paths:
path = path.strip()
if not path or path.startswith('#'):
continue
is_dir = path.endswith('/')
path = path.rstrip('/')
if not path:
continue
if not path.startswith('/'):
path = '/' + path
parts = path.split('/')
# parts[0] is '' because path starts with '/'
for i in range(2, len(parts) + 1):
current = '/'.join(parts[:i])
parent = '/'.join(parts[:i - 1]) or '/'
child_name = parts[i - 1]
self._children.setdefault(parent, set()).add(child_name)
if i < len(parts) or is_dir:
self._dirs.add(current)
self._files.discard(current)
elif current not in self._dirs:
self._files.add(current)
def listdir(self, path):
path = path.rstrip('/')
if path == '':
path = '/'
if path in self._files:
raise NotADirectoryError(f"Not a directory: {path}")
if path not in self._dirs:
raise FileNotFoundError(f"No such directory: {path}")
return sorted(self._children.get(path, set()))
def isdir(self, path):
path = path.rstrip('/')
if path == '':
path = '/'
return path in self._dirs
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Winapp.py 0000775 0001750 0001750 00000052005 15265023030 014534 0 ustar 00z z # 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.
"""
Import Winapp2.ini files
"""
import configparser
import fnmatch
import glob
import logging
import os
import re
import time
import bleachbit
from bleachbit import Cleaner, IS_WINDOWS, Windows
from bleachbit.Action import Delete, Winreg
from bleachbit.CleanerML import reject_world_writable
from bleachbit.FileUtilities import detect_encoding
from bleachbit.Language import get_text as _
logger = logging.getLogger(__name__)
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
langsecref_map = {
'3001': ('winapp2_internet_explorer', 'Internet Explorer'),
'3005': ('winapp2_edge_classic', 'Microsoft Edge'),
'3006': ('winapp2_edge_chromium', 'Microsoft Edge'),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3021': ('winapp2_applications', _('Applications')),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3022': ('winapp2_internet', _('Internet')),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3023': ('winapp2_multimedia', _('Multimedia')),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3024': ('winapp2_utilities', _('Utilities')),
'3025': ('winapp2_windows', 'Microsoft Windows'),
'3026': ('winapp2_mozilla', 'Firefox/Mozilla'),
'3027': ('winapp2_opera', 'Opera'),
'3028': ('winapp2_safari', 'Safari'),
'3029': ('winapp2_google_chrome', 'Google Chrome'),
'3030': ('winapp2_thunderbird', 'Thunderbird'),
'3031': ('winapp2_windows_store', 'Windows Store'),
'3033': ('winapp2_vivaldi', 'Vivaldi'),
'3034': ('winapp2_brave', 'Brave'),
# Section=Games (technically not langsecref)
'Games': (
'winapp2_games',
# TRANSLATORS: Cleaner category name for games imported from winapp2.ini.
_('Games'))}
# Compiled once; these run for every section/filekey when importing winapp2.ini
_S2O_NONALNUM = re.compile(r'[^a-z0-9]')
_S2O_UNDERSCORES = re.compile(r'_+')
_S2O_EDGE_UNDERSCORE = re.compile(r'(^_|_$)')
_FNMATCH_END = re.compile(r'\\[Zz](\(\?ms\))?$')
_EXCLUDEKEY_TRAILING_SEP = re.compile(r'\\\\((?:\))?)$')
_WINAPP_VAR_SUBS = (
(re.compile('%ProgramFiles%', re.IGNORECASE), '%ProgramW6432%'),
(re.compile('%CommonProgramFiles%', re.IGNORECASE), '%CommonProgramW6432%'),
)
class _ActionNode:
"""Stand-in for a minidom node, without the parse per key"""
def __init__(self, attrs):
self._attrs = attrs
def getAttribute(self, name):
return self._attrs.get(name, '')
def section2option(s):
"""Normalize section name to appropriate option name"""
ret = _S2O_NONALNUM.sub('_', s.lower())
ret = _S2O_UNDERSCORES.sub('_', ret)
ret = _S2O_EDGE_UNDERSCORE.sub('', ret)
return ret
def _noop_progress(_fraction):
"""Default progress callback used when one is not provided."""
return None
# Longest a winapp2.ini load runs before yielding to the GUI main loop
_YIELD_SECONDS = 0.05
def detectos(required_ver, mock=False):
"""Returns boolean whether the detectos is compatible with the
current operating system, or the mock version, if given."""
# Do not compare as string because Windows 10 (build 10.0) comes after
# Windows 8.1 (build 6.3).
assert isinstance(required_ver, str)
current_os = mock or Windows.parse_windows_build()
required_ver = required_ver.strip()
if '|' not in required_ver:
# Exact version
return Windows.parse_windows_build(required_ver) == current_os
# Format of min|max
req_parts = required_ver.split('|')
req_min = req_parts[0]
req_max = req_parts[1]
if req_min and current_os < Windows.parse_windows_build(req_min):
return False
if req_max and current_os > Windows.parse_windows_build(req_max):
return False
return True
def winapp_expand_vars(pathname):
"""Expand environment variables using special Winapp2.ini rules
Returns the list of candidate paths to try, which is one or two long.
"""
# This is the regular expansion
expand1 = os.path.expandvars(pathname)
# Winapp2.ini expands %ProgramFiles% to %ProgramW6432%, etc.
for pattern, sub_repl in _WINAPP_VAR_SUBS:
if pattern.match(pathname):
expand2 = pattern.sub(sub_repl, pathname)
return [expand1, os.path.expandvars(expand2)]
return [expand1]
def detect_file(pathname):
"""Check whether a path exists for DetectFile#="""
for expanded in winapp_expand_vars(pathname):
for _i in glob.iglob(expanded):
return True
return False
def special_detect(code):
"""Check whether the SpecialDetect== software exists"""
# The last two are used only for testing
sd_keys = {'DET_CHROME': r'HKCU\Software\Google\Chrome',
'DET_MOZILLA': r'HKCU\Software\Mozilla\Firefox',
'DET_OPERA': r'HKCU\Software\Opera Software',
'DET_THUNDERBIRD': r'HKLM\SOFTWARE\Clients\Mail\Mozilla Thunderbird',
'DET_WINDOWS': r'HKCU\Software\Microsoft',
'DET_SPACE_QUEST': r'HKCU\Software\Sierra Games\Space Quest'}
if code in sd_keys:
return Windows.detect_registry_key(sd_keys[code])
logger.error('Unknown SpecialDetect=%s', code)
return False
# fnmatch.translate() only got atomic groups (avoiding catastrophic
# regex backtracking) in Python 3.11, but BleachBit supports 3.8+, so
# cap the wildcard count instead of trusting the stdlib on older versions.
# TODO: drop this once the minimum supported Python is 3.11+
MAX_GLOB_WILDCARDS = 10
def _check_wildcard_count(pattern):
"""Raise if pattern has enough wildcards to risk a regex backtracking blowup"""
wildcard_count = pattern.count('*') + pattern.count('?')
if wildcard_count > MAX_GLOB_WILDCARDS:
raise ValueError(f'too many wildcards in pattern: {pattern!r}')
def fnmatch_translate(pattern):
"""Same as the original without the end"""
_check_wildcard_count(pattern)
ret = fnmatch.translate(pattern)
if ret.endswith('$'):
return ret[:-1]
# fnmatch.translate ends the regex with \Z before Python 3.14 and \z after
return _FNMATCH_END.sub('', ret)
class Winapp:
"""Create cleaners from a Winapp2.ini-style file"""
def __init__(self, pathname, cb_progress=_noop_progress, load_now=True):
"""Create cleaners from a Winapp2.ini-style file
Pass load_now=False to drive load_sections() yourself, which lets a
GUI caller keep painting between sections.
"""
self.cleaners = {}
self.cleaner_ids = []
for langsecref in set(langsecref_map.values()):
self.add_section(langsecref[0], langsecref[1])
self.errors = 0
self.parser = configparser.RawConfigParser()
encoding = detect_encoding(pathname) or 'utf_8_sig'
self.parser.read(pathname, encoding=encoding)
self.re_detect = re.compile(r'^detect(\d+)?$')
self.re_detectfile = re.compile(r'^detectfile(\d+)?$')
self.re_excludekey = re.compile(r'^excludekey\d+$')
# An app's sections repeat Detect keys; cache the probes for this load
self._detect_cache = {}
if not load_now:
return
for _dummy in self.load_sections(cb_progress):
pass
def load_sections(self, cb_progress=_noop_progress):
"""Parse each section, yielding so a GUI caller can keep painting"""
sections = self.parser.sections()
section_total_count = len(sections)
section_done_count = 0
deadline = time.monotonic() + _YIELD_SECONDS
for section in sections:
try:
self.handle_section(section)
except Exception:
self.errors += 1
logger.exception('parsing error in section %s', section)
else:
section_done_count += 1
cb_progress(1.0 * section_done_count / section_total_count)
if time.monotonic() >= deadline:
yield True
deadline = time.monotonic() + _YIELD_SECONDS
def add_section(self, cleaner_id, name):
"""Add a section (cleaners)"""
self.cleaner_ids.append(cleaner_id)
self.cleaners[cleaner_id] = Cleaner.Cleaner()
self.cleaners[cleaner_id].id = cleaner_id
self.cleaners[cleaner_id].name = name
assert name.strip() == name
# TRANSLATORS: Description shown for a cleaner imported from winapp2.ini,
# which is a database of cleaning definitions.
self.cleaners[cleaner_id].description = _('Imported from winapp2.ini')
# The detect() function in this module effectively does what
# auto_hide() does, so this avoids redundant, slow processing.
self.cleaners[cleaner_id].auto_hide_supported = False
def section_to_cleanerid(self, langsecref):
"""Given a langsecref (or section name), find the internal
BleachBit cleaner ID."""
# pre-defined, such as 3021
if langsecref in langsecref_map:
return langsecref_map[langsecref][0]
# custom, such as games
cleanerid = 'winapp2_' + section2option(langsecref)
if cleanerid not in self.cleaners:
# never seen before
self.add_section(cleanerid, langsecref)
return cleanerid
def excludekey_to_nwholeregex(self, excludekey):
r"""Translate one ExcludeKey to CleanerML nwholeregex or return None for REG
Supported examples
FILE=%LocalAppData%\BleachBit\BleachBit.ini
FILE=%LocalAppData%\BleachBit\|BleachBit.ini
FILE=%LocalAppData%\BleachBit\|*.ini
FILE=%LocalAppData%\BleachBit\|*.ini;*.bak
PATH=%LocalAppData%\BleachBit\
PATH=%LocalAppData%\BleachBit\|*.*
REG|HKCU\Software\BleachBit
"""
parts = excludekey.split('|')
parts[0] = parts[0].upper()
if parts[0] == 'REG':
return None # REG exclusions are handled separately
# the last part contains the filename(s)
files = None
files_regex = ''
if len(parts) == 3:
files = parts[2].split(';')
if len(files) == 1:
# one file pattern like *.* or *.log
if files[0] == '*.*':
# exclude the whole folder, matching __make_file_provider
files = None
else:
files_regex = fnmatch_translate(files[0])
elif len(files) > 1:
# multiple file patterns like *.log;*.bak
files_regex = f"({'|'.join(fnmatch_translate(f) for f in files)})"
# the middle part contains the file
regexes = []
for expanded in winapp_expand_vars(parts[1]):
if files:
# match one or more file types, directly in this tree or in any
# sub folder
regex = r'%s\\%s' % (
_EXCLUDEKEY_TRAILING_SEP.sub(r'\1', fnmatch_translate(expanded)), files_regex)
else:
# There is no third part, so this is either just a folder,
# or sometimes the file is specified directly.
regex = fnmatch_translate(expanded)
regexes.append(regex)
if len(regexes) == 1:
return regexes[0]
return f"({'|'.join(regexes)})"
def _detect_cached(self, kind, probe, key):
"""Run a Detect probe, reusing the result within this load"""
cache_key = (kind, key)
if cache_key not in self._detect_cache:
self._detect_cache[cache_key] = probe(key)
return self._detect_cache[cache_key]
def detect(self, section):
"""Check whether to show the section
The logic:
If the DetectOS does not match, the section is inactive.
If any Detect or DetectFile matches, the section is active.
If neither Detect or DetectFile was given, the section is active.
Otherwise, the section is inactive.
"""
if self.parser.has_option(section, 'detectos'):
required_ver = self.parser.get(section, 'detectos')
if not detectos(required_ver):
return False
any_detect_option = False
if self.parser.has_option(section, 'specialdetect'):
any_detect_option = True
sd_code = self.parser.get(section, 'specialdetect')
if special_detect(sd_code):
return True
for option in self.parser.options(section):
if self.re_detect.match(option):
# Detect= checks for a registry key
any_detect_option = True
key = self.parser.get(section, option)
if self._detect_cached('reg', Windows.detect_registry_key, key):
return True
elif self.re_detectfile.match(option):
# DetectFile= checks for a file
any_detect_option = True
key = self.parser.get(section, option)
if self._detect_cached('file', detect_file, key):
return True
return not any_detect_option
def handle_section(self, section):
"""Parse a section"""
# check whether the section is active (i.e., whether it will be shown)
if not self.detect(section):
return
# excludekeys ignores a file, path, or registry key
# FILE/PATH exclusions use regex patterns for file matching
# REG exclusions are handled separately as registry key patterns
file_excludekeys = []
reg_excludekeys = []
section_options = self.parser.options(section)
for option in section_options:
if self.re_excludekey.match(option):
excludekey_val = self.parser.get(section, option)
nwholeregex = self.excludekey_to_nwholeregex(excludekey_val)
if nwholeregex is None:
# REG exclusion: extract the registry path
parts = excludekey_val.split('|')
if len(parts) >= 2:
reg_excludekeys.append(parts[1])
else:
file_excludekeys.append(nwholeregex)
# there are two ways to specify sections: langsecref= and section=
if self.parser.has_option(section, 'langsecref'):
# verify the langsecref number is known
# langsecref_num is 3021, games, etc.
langsecref_num = self.parser.get(section, 'langsecref')
elif self.parser.has_option(section, 'section'):
langsecref_num = self.parser.get(section, 'section')
else:
logger.error(
'neither option LangSecRef nor Section found in section %s', section)
return
# find the BleachBit internal cleaner ID
lid = self.section_to_cleanerid(langsecref_num)
option_name = section.replace('*', '').strip()
self.cleaners[lid].add_option(
section2option(section), option_name, '')
for option in section_options:
if (
option
in {
"default",
"langsecref",
"section",
"detectos",
"specialdetect",
}
or self.re_detect.match(option)
or self.re_detectfile.match(option)
or self.re_excludekey.match(option)
):
continue
if option.startswith('filekey'):
self.handle_filekey(lid, section, option, file_excludekeys)
elif option.startswith('regkey'):
self.handle_regkey(lid, section, option, reg_excludekeys)
elif option == 'warning':
self.cleaners[lid].set_warning(
section2option(section), self.parser.get(section, 'warning'))
else:
logger.warning(
'unknown option %s in section %s', option, section)
def __make_file_provider(self, dirname, filename, recurse, removeself, excludekeys):
"""Change parsed FileKey to action provider"""
attrs = {'command': 'delete'}
if recurse:
search = 'walk.files'
path = dirname
if filename == '*.*':
if removeself:
search = 'walk.all'
else:
attrs['regex'] = f'^{fnmatch_translate(filename)}$'
else:
search = 'glob'
path = os.path.join(dirname, filename)
if path.find('*') == -1:
search = 'file'
if excludekeys:
if len(excludekeys) > 1:
# multiple
exclude_str = f"({'|'.join(excludekeys)})"
else:
# just one
exclude_str = excludekeys[0]
attrs['nwholeregex'] = exclude_str
attrs['search'] = search
attrs['path'] = path
yield Delete(_ActionNode(attrs))
if removeself:
search = 'file'
if dirname.find('*') > -1:
search = 'glob'
yield Delete(_ActionNode({'command': 'delete', 'search': search,
'path': dirname, 'type': 'd'}))
def handle_filekey(self, lid, ini_section, ini_option, excludekeys):
"""Parse a FileKey# option.
Section is [Application Name] and option is the FileKey#"""
elements = self.parser.get(
ini_section, ini_option).strip().split('|')
dirnames = winapp_expand_vars(elements.pop(0))
filenames = ""
if elements:
filenames = elements.pop(0)
recurse = False
removeself = False
for element in elements:
element = element.upper()
if element == 'RECURSE':
recurse = True
elif element == 'REMOVESELF':
recurse = True
removeself = True
else:
logger.warning(
'unknown file option %s in section %s', element, ini_section)
option_id = section2option(ini_section)
for filename in filenames.split(';'):
for dirname in dirnames:
# If dirname is a drive letter it needs a special treatment on Windows:
# https://www.reddit.com/r/learnpython/comments/gawqne/why_cant_i_ospathjoin_on_a_drive_letterc/
if os.path.splitdrive(dirname)[0] == dirname:
dirname = f'{dirname}{os.path.sep}'
for provider in self.__make_file_provider(dirname, filename, recurse, removeself, excludekeys):
self.cleaners[lid].add_action(option_id, provider)
def handle_regkey(self, lid, ini_section, ini_option, reg_excludekeys):
"""Parse a RegKey# option"""
elements = self.parser.get(
ini_section, ini_option).strip().split('|')
path = elements[0]
# Check if this registry key is excluded (exact match or starts with exclusion)
normalized_path = path.upper().replace('\\', '\\\\')
for exclude_path in reg_excludekeys:
# Normalize paths for comparison
normalized_exclude = exclude_path.upper().replace('\\', '\\\\')
# Check if the path matches the exclusion (exact match or starts with exclusion)
if normalized_path == normalized_exclude or \
normalized_path.startswith(normalized_exclude + '\\\\'):
logger.debug('Skipping excluded registry key: %s', path)
return
attrs = {'command': 'winreg', 'path': path}
if len(elements) == 2:
attrs['name'] = elements[1]
provider = Winreg(_ActionNode(attrs))
provider.excludekeys = reg_excludekeys
self.cleaners[lid].add_action(section2option(ini_section), provider)
def get_cleaners(self):
"""Return the created cleaners"""
for cleaner_id in self.cleaner_ids:
if self.cleaners[cleaner_id].is_usable():
yield self.cleaners[cleaner_id]
def list_winapp_files():
"""List winapp2.ini files"""
check_world_writable = not IS_WINDOWS
for dirname in (bleachbit.personal_cleaners_dir, bleachbit.system_cleaners_dir):
fname = os.path.join(dirname, 'winapp2.ini')
if not os.path.exists(fname):
continue
if check_world_writable and reject_world_writable(fname):
continue
yield fname
def load_cleaners(cb_progress=_noop_progress):
"""Scan for winapp2.ini files and load them"""
cb_progress(0.0)
for pathname in list_winapp_files():
try:
inicleaner = Winapp(pathname, load_now=False)
yield True
yield from inicleaner.load_sections(cb_progress)
except Exception:
logger.exception(
"Error reading winapp2.ini cleaner '%s'", pathname)
else:
for cleaner in inicleaner.get_cleaners():
Cleaner.backends[cleaner.id] = cleaner
yield True
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Windows.py 0000775 0001750 0001750 00000220437 15265023030 014736 0 ustar 00z z # 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"""
Functionality specific to Microsoft Windows
The Windows Registry terminology can be confusing. Take for example
the reference
* HKCU\\Software\\BleachBit
* CurrentVersion
These are the terms:
* 'HKCU' is an abbreviation for the hive HKEY_CURRENT_USER.
* 'HKCU\Software\BleachBit' is the key name.
* 'Software' is a sub-key of HCKU.
* 'BleachBit' is a sub-key of 'Software.'
* 'CurrentVersion' is the value name.
* '0.5.1' is the value data.
"""
# standard imports
import atexit
import base64
import ctypes
import errno
import functools
import glob
import hashlib
import logging
import ntpath
import os
import re
import shutil
import sys
import time
from ctypes import wintypes
from decimal import Decimal
from pathlib import Path
from threading import Thread, Event
from uuid import UUID
# first party imports
import bleachbit
from bleachbit import FileUtilities, General, ARCH_BITS, IS_WINDOWS
from bleachbit.Language import get_text as _
if IS_WINDOWS:
import winreg
import pywintypes
import win32api
import win32clipboard
import win32con
import win32file
import win32gui
import win32process
import win32security
import win32service
import win32serviceutil
# Ensure GetClassInfo exists for compatibility and testing
# Some win32gui builds don't have GetClassInfo, so we create a stub
# In the future, consider GetClassInfoEx instead.
# The signature mirrors win32gui.GetClassInfo.
# pylint: disable-next=unused-argument
def _get_class_info_fallback(hInstance, className):
"""Fallback GetClassInfo - returns a default atom value"""
return (1234,) # Return tuple with default atom
# Force GetClassInfo to exist on the module for mocking compatibility
# This ensures tests can patch it even if it doesn't exist natively
setattr(win32gui, 'GetClassInfo', getattr(
win32gui, 'GetClassInfo', _get_class_info_fallback))
from win32com.shell import shell, shellcon
logger = logging.getLogger(__name__)
IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
SPLASH_ICON_SIZE_PX = 256 # 256x256 pixels
SPLASH_CLOSE_TIMEOUT_MS = 1000
WINDOWS_SYSTEM_VAR = 'WindowsSystem'
_WINDOWS_SYSTEM_VAR_RE = re.compile(
rf'%{WINDOWS_SYSTEM_VAR}%', flags=re.IGNORECASE)
_delete_parent_lock_admin = None
_delete_parent_lock_handle = None
_delete_parent_lock_key = None
class _POINT(ctypes.Structure):
_fields_ = [('x', wintypes.LONG), ('y', wintypes.LONG)]
class _SIZE(ctypes.Structure):
_fields_ = [('cx', wintypes.LONG), ('cy', wintypes.LONG)]
class _BLENDFUNCTION(ctypes.Structure):
_fields_ = [
('BlendOp', wintypes.BYTE),
('BlendFlags', wintypes.BYTE),
('SourceConstantAlpha', wintypes.BYTE),
('AlphaFormat', wintypes.BYTE),
]
class _BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [
('biSize', wintypes.DWORD),
('biWidth', wintypes.LONG),
('biHeight', wintypes.LONG),
('biPlanes', wintypes.WORD),
('biBitCount', wintypes.WORD),
('biCompression', wintypes.DWORD),
('biSizeImage', wintypes.DWORD),
('biXPelsPerMeter', wintypes.LONG),
('biYPelsPerMeter', wintypes.LONG),
('biClrUsed', wintypes.DWORD),
('biClrImportant', wintypes.DWORD),
]
class _BITMAPINFO(ctypes.Structure):
_fields_ = [('bmiHeader', _BITMAPINFOHEADER),
('bmiColors', wintypes.DWORD * 3)]
_BI_RGB = 0
_DIB_RGB_COLORS = 0
_AC_SRC_OVER = 0
_AC_SRC_ALPHA = 1
_ULW_ALPHA = 2
def get_splash_screen_delay_seconds():
value = os.environ.get('BLEACHBIT_SPLASH_SCREEN_DELAY')
if not value:
return 0.0
try:
delay = float(value)
except ValueError:
logger.warning(
'invalid BLEACHBIT_SPLASH_SCREEN_DELAY value: %r', value)
return 0.0
if delay < 0:
logger.warning(
'negative BLEACHBIT_SPLASH_SCREEN_DELAY value: %r', value)
return 0.0
return delay
def _get_environ_case_insensitive(environ, varname):
"""Get a Windows environment variable from a mapping."""
if varname in environ:
return environ[varname]
varname_lower = varname.lower()
for key, value in environ.items():
if key.lower() == varname_lower:
return value
return None
def _is_64_bit_windows(environ):
"""Return whether the environment indicates a 64-bit Windows OS."""
if _get_environ_case_insensitive(environ, 'ProgramW6432'):
return True
if _get_environ_case_insensitive(environ, 'PROCESSOR_ARCHITEW6432'):
return True
arch = _get_environ_case_insensitive(environ, 'PROCESSOR_ARCHITECTURE')
return bool(arch and arch.upper() in ('AMD64', 'ARM64', 'IA64'))
def get_windows_system_paths(environ=None, process_bits=None):
"""Return Windows system directories for native and 32-bit targets.
On a 32-bit process running on 64-bit Windows, System32 is redirected to
SysWOW64, so Sysnative is used for the native 64-bit system directory.
Returns:
list: A list of Windows system directory paths. On 64-bit Windows,
this typically includes both the native system directory
(System32 or Sysnative) and SysWOW64. On 32-bit Windows,
only System32 is returned. Returns an empty list if the
Windows directory cannot be determined.
"""
environ = environ or os.environ
process_bits = process_bits or ARCH_BITS
windir = (_get_environ_case_insensitive(environ, 'WinDir') or
_get_environ_case_insensitive(environ, 'SystemRoot'))
if not windir:
return []
windir = windir.rstrip('\\/')
if _is_64_bit_windows(environ):
native = 'Sysnative' if process_bits == 32 else 'System32'
paths = (ntpath.join(windir, native), ntpath.join(windir, 'SysWOW64'))
else:
paths = (ntpath.join(windir, 'System32'), )
return list(paths)
def expand_windows_system_vars(pathname, system_paths=None):
"""Expand the multi-value %WindowsSystem% variable in a path.
Args:
pathname: The path string containing the %WindowsSystem% variable.
system_paths: Optional list of system paths to use. If not provided,
the function will determine the appropriate system paths
based on the current environment.
Returns:
list: A list of expanded path strings, one for each system path.
"""
if not _WINDOWS_SYSTEM_VAR_RE.search(pathname):
return [pathname]
system_paths = system_paths or get_windows_system_paths()
if not system_paths:
return [pathname]
return [
_WINDOWS_SYSTEM_VAR_RE.sub(
lambda _match, system_path=system_path: system_path, pathname)
for system_path in system_paths
]
def browse_file(_, title):
"""Ask the user to select a single file. Return full path"""
try:
ret = win32gui.GetOpenFileNameW(None,
# pylint: disable-next=possibly-used-before-assignment
Flags=win32con.OFN_EXPLORER
| win32con.OFN_FILEMUSTEXIST
| win32con.OFN_HIDEREADONLY,
Title=title)
# pylint: disable-next=possibly-used-before-assignment
except pywintypes.error as e:
if 0 == e.winerror:
logger.debug('browse_file(): user cancelled')
else:
logger.exception('exception in browse_file()')
return None
return ret[0]
def browse_files(_, title):
"""Ask the user to select files. Return full paths"""
try:
# The File parameter is a hack to increase the buffer length.
ret = win32gui.GetOpenFileNameW(None,
File='\x00' * 10240,
Flags=win32con.OFN_ALLOWMULTISELECT
| win32con.OFN_EXPLORER
| win32con.OFN_FILEMUSTEXIST
| win32con.OFN_HIDEREADONLY,
Title=title)
except pywintypes.error as e:
if 0 == e.winerror:
logger.debug('browse_files(): user cancelled')
else:
logger.exception('exception in browse_files()')
return None
_split = ret[0].split('\x00')
if 1 == len(_split):
# only one filename
return _split
dirname = _split[0]
return [os.path.join(dirname, fname) for fname in _split[1:]]
def browse_folder(_, title):
"""Ask the user to select a folder. Return full path."""
flags = 0x0010 # SHBrowseForFolder path input
# pylint: disable-next=possibly-used-before-assignment
pidl = shell.SHBrowseForFolder(None, None, title, flags)[0]
if pidl is None:
# user cancelled
return None
return shell.SHGetPathFromIDListW(pidl)
def cleanup_nonce():
"""On exit, clean up GTK junk files"""
if os.environ.get('BLEACHBIT_TEST_OPTIONS_DIR'):
# Under the test suite this runs at every worker and subprocess exit
# and deletes shared %TEMP% files with a %TEMP% parent lock, racing
# other workers.
return
for fn in glob.glob(os.path.expandvars(r'%TEMP%\gdbus-nonce-file-*')):
logger.debug('cleaning GTK nonce file: %s', fn)
FileUtilities.delete(fn)
def csidl_to_environ(varname, csidl):
"""Define an environment variable from a CSIDL for use in CleanerML and Winapp2.ini"""
try:
sppath = shell.SHGetSpecialFolderPath(None, csidl)
except Exception:
logger.info(
'exception when getting special folder path for %s', varname)
return
# there is exception handling in set_environ()
set_environ(varname, sppath)
def _delete_parent_lock_needed(pathname):
"""
Check if a parent directory lock is needed.
This is only needed on Windows for administrator users
when the path is not in the user's profile directory.
"""
if not IS_WINDOWS:
return False
# The admin check is cached for the process.
# pylint: disable-next=global-statement
global _delete_parent_lock_admin
if _delete_parent_lock_admin is None:
try:
_delete_parent_lock_admin = shell.IsUserAnAdmin()
except Exception:
logger.exception(
'error checking whether current user is an administrator')
_delete_parent_lock_admin = True
return _delete_parent_lock_admin and not _path_in_user_profile(pathname)
def _path_for_comparison(pathname):
"""
Normalize a path for comparison.
"""
return os.path.normcase(os.path.abspath(
FileUtilities.extended_path_undo(pathname)))
def _path_in_user_profile(pathname):
"""
Check if a path is within the user's profile directory.
"""
userprofile = os.environ.get('USERPROFILE')
if not userprofile:
return False
try:
profile_path = _path_for_comparison(userprofile)
compare_path = _path_for_comparison(pathname)
return os.path.commonpath((profile_path, compare_path)) == profile_path
except (OSError, ValueError):
return False
def _delete_parent_directory(pathname):
"""
Get the parent directory of a file or directory.
"""
path = os.path.abspath(FileUtilities.extended_path_undo(pathname))
return FileUtilities.extended_path(os.path.dirname(path))
def _close_delete_parent_lock():
"""Close the parent lock handle."""
# The parent lock is process-wide state.
# pylint: disable-next=global-statement
global _delete_parent_lock_handle
# pylint: disable-next=global-statement
global _delete_parent_lock_key
if _delete_parent_lock_handle is not None:
logger.debug('Closing parent lock handle for %s',
_delete_parent_lock_key)
# pylint: disable-next=possibly-used-before-assignment
win32file.CloseHandle(_delete_parent_lock_handle)
_delete_parent_lock_handle = None
_delete_parent_lock_key = None
def _lock_delete_parent(pathname):
"""
Lock the parent directory of pathname to prevent it from being deleted.
This function does not perform the deletion.
"""
# The parent lock is process-wide state.
# pylint: disable-next=global-statement
global _delete_parent_lock_handle
# pylint: disable-next=global-statement
global _delete_parent_lock_key
parent = _delete_parent_directory(pathname)
parent_key = os.path.normcase(parent)
if _delete_parent_lock_handle is not None and _delete_parent_lock_key == parent_key:
logger.debug('Reusing parent lock handle for %s', parent_key)
return
_close_delete_parent_lock()
flags = win32con.FILE_FLAG_BACKUP_SEMANTICS | getattr(
win32con, 'FILE_FLAG_OPEN_REPARSE_POINT', 0x00200000)
access = getattr(win32con, 'FILE_READ_ATTRIBUTES', 0x80)
try:
handle = win32file.CreateFile(
parent,
access,
win32con.FILE_SHARE_READ,
None,
win32con.OPEN_EXISTING,
flags,
None)
except pywintypes.error as e:
raise OSError(errno.EACCES,
"Access denied locking directory before delete()",
pathname) from e
if handle == win32file.INVALID_HANDLE_VALUE:
raise OSError(errno.EACCES,
"Access denied locking directory before delete()",
pathname)
try:
attrs = win32file.GetFileAttributesW(parent)
except pywintypes.error:
win32file.CloseHandle(handle)
raise
if attrs & FILE_ATTRIBUTE_REPARSE_POINT:
win32file.CloseHandle(handle)
raise OSError(errno.EACCES,
"Refusing to delete through a directory link",
pathname)
logger.debug('Opened parent lock handle for %s', parent_key)
_delete_parent_lock_handle = handle
_delete_parent_lock_key = parent_key
def with_parent_lock(pathname, func, *args, **kwargs):
"""
Run a function with a lock on the parent directory of pathname.
This prevents race conditions where the parent directory is deleted
while the function is running.
If args/kwargs are provided, passes them to func.
Otherwise, calls func() with no arguments.
"""
if not _delete_parent_lock_needed(pathname):
return func(*args, **kwargs)
logger.debug('with_parent_lock(%s): acquiring lock', pathname)
_lock_delete_parent(pathname)
logger.debug('lock acquired: calling clean function with parent lock on %s',
_delete_parent_lock_key)
try:
return func(*args, **kwargs)
except Exception as e:
_close_delete_parent_lock()
raise e from e
if IS_WINDOWS:
atexit.register(_close_delete_parent_lock)
def delete_locked_file(pathname):
"""Delete a file that is currently in use"""
if not os.path.exists(pathname):
return
MOVEFILE_DELAY_UNTIL_REBOOT = 4
if 0 == ctypes.windll.kernel32.MoveFileExW(pathname, None, MOVEFILE_DELAY_UNTIL_REBOOT):
# WinError throws the right exception based on last error.
try:
raise ctypes.WinError()
except PermissionError as e:
# OSError has special handling in Worker.py
# Use a special message for flagging files for later deletion
raise OSError(
errno.EACCES,
"Access denied in delete_locked_file()", pathname) from e
def delete_registry_value(key, value_name, really_delete):
"""Delete named value under the registry key.
Return boolean indicating whether reference found and
successful. If really_delete is False (meaning preview),
just check whether the value exists."""
(hive, sub_key) = split_registry_key(key)
try:
if really_delete:
# pylint: disable-next=possibly-used-before-assignment
hkey = winreg.OpenKey(hive, sub_key, 0, winreg.KEY_SET_VALUE)
winreg.DeleteValue(hkey, value_name)
else:
hkey = winreg.OpenKey(hive, sub_key)
winreg.QueryValueEx(hkey, value_name)
except PermissionError as e:
raise OSError(
errno.EACCES,
"Access denied in delete_registry_value()", key) from e
# WindowsError is a real builtin; this file only runs on Windows.
# pylint: disable-next=undefined-variable
except WindowsError as e:
if e.winerror == errno.ENOENT:
# ENOENT = 'file not found' means value does not exist
return False
raise
return True
def delete_registry_key(parent_key, really_delete, excludekeys=None):
"""Delete registry key including any values and sub-keys.
Return boolean whether found and success. If really
delete is False (meaning preview), just check whether
the key exists."""
parent_key = str(parent_key) # Unicode to byte string
excludekeys = excludekeys or []
# Check if this key is excluded
for exclude_path in excludekeys:
# Normalize paths for comparison (case-insensitive)
normalized_parent = parent_key.upper()
normalized_exclude = exclude_path.upper()
# Check if the key matches the exclusion (exact match or is a child of exclusion)
if normalized_parent == normalized_exclude or \
normalized_parent.startswith(normalized_exclude + '\\'):
logger.debug('Skipping excluded registry key: %s', parent_key)
return False
(hive, parent_sub_key) = split_registry_key(parent_key)
hkey = None
try:
hkey = winreg.OpenKey(hive, parent_sub_key)
# WindowsError is a real builtin; this file only runs on Windows.
# pylint: disable-next=undefined-variable
except WindowsError as e:
if e.winerror == 2:
# 2 = 'file not found' happens when key does not exist
return False
if not really_delete:
return True
if not hkey:
# key not found
return False
keys_size = winreg.QueryInfoKey(hkey)[0]
child_keys = [
parent_key + '\\' + winreg.EnumKey(hkey, i) for i in range(keys_size)
]
# Check if any child keys are excluded
has_excluded_children = False
for child_key in child_keys:
child_deleted = delete_registry_key(child_key, True, excludekeys)
if not child_deleted:
has_excluded_children = True
# If any child is excluded, preserve this parent key
if has_excluded_children:
logger.debug(
'Preserving parent key with excluded children: %s', parent_key)
return False
try:
winreg.DeleteKey(hive, parent_sub_key)
except PermissionError as e:
raise OSError(
errno.EACCES,
"Access denied in delete_registry_key()", parent_key) from e
return True
def delete_updates():
"""Returns commands for deleting Windows Updates files
Reference:
https://learn.microsoft.com/en-us/troubleshoot/windows-client/installing-updates-features-roles/additional-resources-for-windows-update
Yields commands
"""
# Import here to avoid a circular import.
from bleachbit import Command
if not shell.IsUserAnAdmin():
logger.warning(
# TRANSLATORS: Warning shown when cleaning Windows Updates without administrator access.
_("Administrator privileges are required to clean Windows Updates"))
return
windir = os.path.expandvars('%windir%')
dirs = glob.glob(os.path.join(windir, '$NtUninstallKB*'))
for path_to_add in [r'%windir%\SoftwareDistribution.old',
r'%windir%\SoftwareDistribution.bak',
r'%windir%\ie7updates',
r'%windir%\ie8updates',
# see https://github.com/bleachbit/bleachbit/issues/1215 about catroot2
# r'%WindowsSystem%\catroot2',
r'%systemdrive%\windows.old',
r'%systemdrive%\$windows.~bt',
r'%systemdrive%\$windows.~ws']:
dirs.extend(os.path.expandvars(p)
for p in expand_windows_system_vars(path_to_add))
# First, delete objects that do not require services to be stopped.
for path1 in dirs:
for path2 in FileUtilities.children_in_directory(path1, True):
yield Command.Delete(path2)
if os.path.exists(path1):
yield Command.Delete(path1)
# Closure to bind service/start into a zero-arg callback for Command.Function
def make_run_service(service, start):
def run_wu_service():
return run_net_service_command(service, start)
return run_wu_service
all_services = ('wuauserv', 'cryptsvc', 'bits', 'msiserver')
restart_services = []
for service in all_services:
if is_service_running(service):
restart_services.append(service)
services_stopped = False
sdist_dir = os.path.expandvars(r'%windir%\SoftwareDistribution')
if not os.path.exists(sdist_dir):
return
for path2 in FileUtilities.children_in_directory(sdist_dir, True):
# If we find any files, stop services.
if not services_stopped:
services_stopped = True
for service in restart_services:
# TRANSLATORS: Message in log file when stopping a Windows service.
# The placeholder is the code name of the service.
label = _("stop Windows service %(service)s") % {
'service': service}
yield Command.Function(None, make_run_service(service, False), label)
yield Command.Delete(path2)
yield Command.Delete(sdist_dir)
if not services_stopped:
return
for service in restart_services:
# TRANSLATORS: Message in log file when starting a Windows service.
# The placeholder is the code name of the service.
label = _("start Windows service %(service)s") % {'service': service}
yield Command.Function(None, make_run_service(service, True), label)
def is_service_running(service):
"""Return True if service is running."""
assert isinstance(service, str)
# pylint: disable-next=possibly-used-before-assignment
service_status_code = win32serviceutil.QueryServiceStatus(service)[1]
logger.debug('Windows service %s has current state %d',
service, service_status_code)
# Throw error if status is pending.
if service_status_code not in (1, 4, 7):
raise RuntimeError(
f'Unexpected service status code: {service_status_code}')
return service_status_code == 4 # running
def run_net_service_command(service, start):
"""Start or stop a Windows service
Args:
service (str): Service name, e.g. 'wuauserv'.
start (bool): True to start, False to stop.
Behavior:
- On success, return 0 because no space was freed.
- Treat "already running" (start) and "not active/not started" (stop) like a success.
- On other errors, raise RuntimeError.
- If service has dependencies, this will stop them too.
Reference:
- https://github.com/bleachbit/bleachbit/issues/1932
- https://github.com/bleachbit/bleachbit/issues/1854
"""
assert isinstance(service, str)
assert isinstance(start, bool)
if start:
action = win32serviceutil.StartService
ignore_codes = (1056,) # already running
ignore_msgs = ('already',)
verb = 'start'
# pylint: disable-next=possibly-used-before-assignment
desired = win32service.SERVICE_RUNNING
state_txt = 'RUNNING'
else:
action = win32serviceutil.StopServiceWithDeps
ignore_codes = (
1052, # ERROR_INVALID_SERVICE_CONTROL: control not valid for this service
1062, # ERROR_SERVICE_NOT_ACTIVE: not active
)
ignore_msgs = ('not active', 'not been started', 'is not started',
'control is not valid')
verb = 'stop'
state_txt = 'STOPPED'
desired = win32service.SERVICE_STOPPED
try:
action(service)
except pywintypes.error as e:
# Treat common benign states as success
msg = str(e).lower()
if getattr(e, 'winerror', None) in ignore_codes or any(s in msg for s in ignore_msgs):
return 0
raise RuntimeError(f'Failed to {verb} service {service}: {e}') from e
try:
win32serviceutil.WaitForServiceStatus(service, desired, 60)
except Exception as wait_err:
logger.info('WaitForServiceStatus(%s, %s, ...) had an issue: %s',
service, state_txt, wait_err)
return 0
def detect_registry_key(parent_key):
"""Detect whether registry key exists"""
try:
parent_key = str(parent_key) # Unicode to byte string
except UnicodeEncodeError:
return False
(hive, parent_sub_key) = split_registry_key(parent_key)
hkey = None
try:
hkey = winreg.OpenKey(hive, parent_sub_key)
# WindowsError is a real builtin; this file only runs on Windows.
# pylint: disable-next=undefined-variable
except WindowsError as e:
if e.winerror == 2:
# 2 = 'file not found' happens when key does not exist
return False
if not hkey:
# key not found
return False
return True
def get_sid_token_48():
"""Return a 48-bit token for the current user"""
# pylint: disable-next=possibly-used-before-assignment
htoken = win32security.OpenProcessToken(
# pylint: disable-next=possibly-used-before-assignment
win32api.GetCurrentProcess(), win32security.TOKEN_QUERY)
try:
token_user = win32security.GetTokenInformation(
htoken, win32security.TokenUser)
sid_obj = token_user[0]
sid_str = win32security.ConvertSidToStringSid(sid_obj)
finally:
win32file.CloseHandle(htoken)
digest = hashlib.blake2b(sid_str.encode('ascii'), digest_size=6).digest()
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
def is_ots_elevation():
"""Return True if UAC changed credentials"""
if not IS_WINDOWS:
return False
argv = sys.argv
for i, arg in enumerate(argv):
if arg == '--uac-sid-token' and i + 1 < len(argv):
parent_token = argv[i + 1]
try:
return get_sid_token_48() != parent_token
except Exception:
return False
return False
def elevate_privileges(uac):
"""On Windows Vista and later, try to get administrator
privileges. If successful, return True (so original process
can exit). If failed or not applicable, return False."""
if shell.IsUserAnAdmin():
logger.debug('already an admin (UAC not required)')
htoken = win32security.OpenProcessToken(
win32api.GetCurrentProcess(), win32security.TOKEN_ADJUST_PRIVILEGES | win32security.TOKEN_QUERY)
newPrivileges = [
(win32security.LookupPrivilegeValue(None, "SeBackupPrivilege"),
win32security.SE_PRIVILEGE_ENABLED),
(win32security.LookupPrivilegeValue(None, "SeRestorePrivilege"),
win32security.SE_PRIVILEGE_ENABLED),
]
win32security.AdjustTokenPrivileges(htoken, 0, newPrivileges)
win32file.CloseHandle(htoken)
return False
if not uac:
return False
if hasattr(sys, 'frozen'):
# running frozen in py2exe
exe = sys.executable
parameters = ['--gui', '--no-uac']
else:
pyfile = os.path.join(bleachbit.bleachbit_exe_path, 'bleachbit.py')
# If the Python file is on a network drive, do not offer the UAC because
# the administrator may not have privileges and user will not be
# prompted.
if pyfile and path_on_network(pyfile):
logger.debug(
"debug: skipping UAC because '%s' is on network", pyfile)
return False
parameters = [pyfile, '--gui', '--no-uac']
exe = sys.executable
try:
token = get_sid_token_48()
parameters = parameters + ['--uac-sid-token', token]
except Exception as e:
logger.error('could not compute SID token: %s', e)
parameters = _add_command_line_parameters(parameters)
logger.debug('elevate_privileges() exe=%s, parameters=%s', exe, parameters)
rc = None
try:
rc = shell.ShellExecuteEx(lpVerb='runas',
lpFile=exe,
lpParameters=parameters,
nShow=win32con.SW_SHOW)
except pywintypes.error as e:
if 1223 == e.winerror:
logger.debug('user denied the UAC dialog')
return False
raise
logger.debug('ShellExecuteEx=%s', rc)
if isinstance(rc, dict):
return True
return False
def _add_command_line_parameters(parameters):
"""Add any command line parameters such as --debug-log."""
import subprocess
# parameters already has --gui, so drop it from argv to avoid a duplicate
args = [arg for arg in sys.argv[1:] if arg != '--gui']
return subprocess.list2cmdline(parameters + args)
def _current_user_sid():
"""Return the PySID of the user running this process."""
token = win32security.OpenProcessToken(
win32api.GetCurrentProcess(), win32security.TOKEN_QUERY)
try:
return win32security.GetTokenInformation(
token, win32security.TokenUser)[0]
finally:
win32api.CloseHandle(token)
def _is_path_acl_broken(path, user_sid):
"""Return True if an OWNER RIGHTS ACE on path locks out user_sid, or
its ACL cannot be read."""
try:
sd = win32security.GetFileSecurity(
path, win32security.DACL_SECURITY_INFORMATION |
win32security.OWNER_SECURITY_INFORMATION)
except pywintypes.error as exc:
if getattr(exc, 'winerror', None) == 5: # ERROR_ACCESS_DENIED
return True
raise
if sd.GetSecurityDescriptorOwner() == user_sid:
return False
dacl = sd.GetSecurityDescriptorDacl()
if dacl is None:
return False
owner_rights_sid = win32security.ConvertStringSidToSid('S-1-3-4')
return any(dacl.GetAce(i)[2] == owner_rights_sid
for i in range(dacl.GetAceCount()))
def _is_single_link_file(path):
"""Return True if path is not a hard link."""
try:
return os.lstat(path).st_nlink == 1
except OSError:
return False
def _appdata_acl_targets(options_dir):
"""Yield options_dir, the files directly in it, and the cleaners subtree.
Symlinks, junctions, and hard-linked files are skipped.
"""
yield options_dir
try:
entries = list(os.scandir(options_dir))
except OSError:
return
for entry in entries:
if entry.is_symlink():
continue
if entry.is_file(follow_symlinks=False) and _is_single_link_file(entry.path):
yield entry.path
cleaners_dir = os.path.join(options_dir, 'cleaners')
if os.path.isdir(cleaners_dir) and not os.path.islink(cleaners_dir):
yield cleaners_dir
for root, dirs, files in os.walk(cleaners_dir, followlinks=False):
for name in dirs:
target = os.path.join(root, name)
if not os.path.islink(target):
yield target
for name in files:
target = os.path.join(root, name)
if not os.path.islink(target) and _is_single_link_file(target):
yield target
def has_broken_appdata_acl(options_dir=None, user_sid=None):
"""Return True if options_dir (or a child) locks out user_sid.
Before commit c3007aa0e818e905abb3dcb3035cb3dd40b715d1, General.makedirs()
called os.mkdir(path, 0o700), which on Windows sets a protected DACL with
an OWNER RIGHTS ACE. If an elevated process created the directory, its
owner is Administrators, and non-elevated processes are locked out
(issue #2401). A 0o700 directory owned by the user works, so it is not
flagged.
user_sid defaults to the user running this process.
"""
if not IS_WINDOWS:
return False
if options_dir is None:
if bleachbit.portable_mode:
return False
options_dir = bleachbit.options_dir
if not options_dir or not os.path.exists(options_dir) or os.path.islink(options_dir):
return False
if user_sid is None:
user_sid = _current_user_sid()
for target in _appdata_acl_targets(options_dir):
if _is_path_acl_broken(target, user_sid):
return True
return False
def fix_appdata_acl(options_dir=None):
"""Reset the DACL on options_dir and its children to inherit from the parent.
Equivalent to ``icacls "%APPDATA%\\BleachBit" /reset /T``.
"""
if not IS_WINDOWS:
return
if options_dir is None:
if bleachbit.portable_mode:
return
options_dir = bleachbit.options_dir
if not options_dir or not os.path.exists(options_dir) or os.path.islink(options_dir):
return
empty_dacl = win32security.ACL()
flags = (win32security.DACL_SECURITY_INFORMATION |
win32security.UNPROTECTED_DACL_SECURITY_INFORMATION)
failed = []
for target in _appdata_acl_targets(options_dir):
try:
win32security.SetNamedSecurityInfo(
target, win32security.SE_FILE_OBJECT, flags,
None, None, empty_dacl, None)
except Exception:
failed.append(target)
logger.exception('Error resetting ACL on %s', target)
if failed:
raise OSError(
f'Failed to reset ACL on {len(failed)} path(s), '
f'first: {failed[0]}')
def empty_recycle_bin(path, really_delete):
"""Empty the recycle bin or preview its size.
If the recycle bin is empty, it is not emptied again to avoid an error.
Keyword arguments:
path -- A drive, folder or None. None refers to all recycle bins.
really_delete -- If True, then delete. If False, then just preview.
Returns:
The number of bytes that were freed or would be freed.
"""
(recycle_bin_size, num_files) = shell.SHQueryRecycleBin(path)
if really_delete and num_files > 0:
# Trying to delete an empty Recycle Bin on Vista/7 causes a
# 'catastrophic failure'
# pylint: disable-next=possibly-used-before-assignment
flags = shellcon.SHERB_NOSOUND | shellcon.SHERB_NOCONFIRMATION | shellcon.SHERB_NOPROGRESSUI
try:
shell.SHEmptyRecycleBin(None, path, flags)
except Exception:
logger.exception(
'error in SHEmptyRecycleBin(): recycle_bin_size=%s, num_files=%r, path=%r',
FileUtilities.bytes_to_human(recycle_bin_size), num_files, path)
raise
return recycle_bin_size
def flush_dns():
"""Flush the DNS resolver cache
Returns 0 on success.
Raises RuntimeError on failure.
"""
ipconfig = os.path.join(get_windows_system_paths()[0], 'ipconfig.exe')
args = [ipconfig, '/flushdns']
(rc, stdout, stderr) = General.run_external(args)
if 0 != rc:
raise RuntimeError(
f'Command: {args}\nReturn code: {rc}\nStdout: {stdout}\nStderr: {stderr}')
return 0
def clear_clipboard():
"""Clear the clipboard"""
_open_clipboard()
try:
# pylint: disable-next=possibly-used-before-assignment
win32clipboard.EmptyClipboard()
except Exception:
logger.exception('error clearing clipboard')
finally:
win32clipboard.CloseClipboard()
def get_clipboard_paths():
"""Return a tuple of Unicode pathnames from the clipboard"""
_open_clipboard()
try:
path_list = win32clipboard.GetClipboardData(win32clipboard.CF_HDROP)
except TypeError:
# the clipboard holds something that is not a file drop
path_list = ()
finally:
win32clipboard.CloseClipboard()
return path_list
def _open_clipboard(timeout=1.0):
"""Open the clipboard with retry logic"""
deadline = time.time() + timeout
while True:
try:
return win32clipboard.OpenClipboard()
except pywintypes.error as e:
winerror = getattr(e, 'winerror', e.args[0] if e.args else None)
if winerror != 5 or time.time() >= deadline:
raise
time.sleep(0.05)
def get_fixed_drives():
"""Yield each fixed drive"""
for drive in win32api.GetLogicalDriveStrings().split('\x00'):
if win32file.GetDriveType(drive) == win32file.DRIVE_FIXED:
# Microsoft Office 2010 Starter creates a virtual drive that
# looks much like a fixed disk but isdir() returns false
# and free_space() returns access denied.
# https://bugs.launchpad.net/bleachbit/+bug/1474848
if os.path.isdir(drive):
yield drive
def get_known_folder_path(folder_name):
"""Return the path of a folder by its Folder ID
Requires Windows Vista, Server 2008, or later
Based on the code Michael Kropat (mkropat) from
licensed under the GNU GPL"""
class GUID(ctypes.Structure):
_fields_ = [
("Data1", wintypes.DWORD),
("Data2", wintypes.WORD),
("Data3", wintypes.WORD),
("Data4", wintypes.BYTE * 8)
]
def __init__(self, uuid_):
ctypes.Structure.__init__(self)
self.Data1, self.Data2, self.Data3, self.Data4[
0], self.Data4[1], rest = uuid_.fields
for i in range(2, 8):
self.Data4[i] = rest >> (8 - i - 1) * 8 & 0xff
class FOLDERID:
LocalAppDataLow = UUID(
'{A520A1A4-1780-4FF6-BD18-167343C5AF16}')
Fonts = UUID('{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}')
class UserHandle:
current = wintypes.HANDLE(0)
_CoTaskMemFree = ctypes.windll.ole32.CoTaskMemFree
_CoTaskMemFree.restype = None
_CoTaskMemFree.argtypes = [ctypes.c_void_p]
try:
_SHGetKnownFolderPath = ctypes.windll.shell32.SHGetKnownFolderPath
except AttributeError:
# Not supported on Windows XP
return None
_SHGetKnownFolderPath.argtypes = [
ctypes.POINTER(GUID), wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(
ctypes.c_wchar_p)
]
class PathNotFoundException(Exception):
pass
folderid = getattr(FOLDERID, folder_name)
fid = GUID(folderid)
pPath = ctypes.c_wchar_p()
S_OK = 0
if _SHGetKnownFolderPath(ctypes.byref(fid), 0, UserHandle.current, ctypes.byref(pPath)) != S_OK:
raise PathNotFoundException(folder_name)
path = pPath.value
_CoTaskMemFree(pPath)
return path
def get_recycle_bin():
"""Yield a list of files in the recycle bin"""
pidl = shell.SHGetSpecialFolderLocation(0, shellcon.CSIDL_BITBUCKET)
desktop = shell.SHGetDesktopFolder()
h = desktop.BindToObject(pidl, None, shell.IID_IShellFolder)
for item in h:
path = h.GetDisplayNameOf(item, shellcon.SHGDN_FORPARSING)
if FileUtilities.is_normal_directory(path):
# Return the contents of a normal directory, but do
# not recurse Windows symlinks in the Recycle Bin.
yield from FileUtilities.children_in_directory(path, True)
yield path
@functools.lru_cache(maxsize=None)
def get_windows_version():
"""Get the Windows major and minor version in a decimal like 10.0"""
v = win32api.GetVersionEx(0)
vstr = '%d.%d' % (v[0], v[1])
return Decimal(vstr)
def is_junction(path):
"""Check whether the path is a junction (mount point) on Windows.
https://docs.python.org/3/library/os.path.html#os.path.isjunction
"""
if not IS_WINDOWS:
return False
# os.path.isjunction() was added in Python 3.12. Below that, read the
# reparse tag ourselves.
# TODO: drop this fallback once the minimum Python version is 3.12+
if hasattr(os.path, 'isjunction'):
return os.path.isjunction(path)
# Get reparse tag from stat result
try:
stat_result = os.stat(path, follow_symlinks=False)
tag = getattr(stat_result, 'st_reparse_tag', None)
if tag is not None:
return tag == IO_REPARSE_TAG_MOUNT_POINT
except (OSError, AttributeError) as e:
logger.debug('no reparse tag for %s, so falling back to '
'GetFileAttributesW: %s', path, e)
attr = ctypes.windll.kernel32.GetFileAttributesW(path)
# INVALID_FILE_ATTRIBUTES (0xFFFFFFFF) indicates GetFileAttributesW failed
# On 64-bit Python, ctypes may interpret this as signed -1 instead of unsigned 0xFFFFFFFF
if attr in (0xFFFFFFFF, -1):
error_code = ctypes.windll.kernel32.GetLastError()
logger.error(
'GetFileAttributesW() failed for path %s with error code %d', path, error_code)
return False
return bool(attr & FILE_ATTRIBUTE_REPARSE_POINT)
def load_i18n_dll():
"""Load internationalization library
BleachBit 4.6.2 with Python 3.4 and GTK 3.18 had libintl-8.dll.
BleachBit 5.0.0 with Python 3.10 and GTK 3.24 built on vcpkg
has intl-8.dll.
Either way, it comes from gettext.
Returns None if the dll is not available.
"""
dirs = set([bleachbit.bleachbit_exe_path, os.path.dirname(sys.executable)])
lib_path = None
for dirname in dirs:
lib_path = os.path.join(dirname, 'intl-8.dll')
if os.path.exists(lib_path):
break
if not lib_path:
logger.warning(
'internationalization library was not found, so translations will not work.')
return None
try:
libintl = ctypes.cdll.LoadLibrary(lib_path)
except Exception as e:
logger.warning('error in LoadLibrary(%s): %s', lib_path, e)
return None
# Configure DLL function prototypes
libintl.bindtextdomain.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
libintl.bindtextdomain.restype = ctypes.c_char_p
libintl.bind_textdomain_codeset.argtypes = [
ctypes.c_char_p, ctypes.c_char_p]
libintl.textdomain.argtypes = [ctypes.c_char_p]
if hasattr(libintl, "libintl_wbindtextdomain"):
libintl.libintl_wbindtextdomain.argtypes = [
ctypes.c_char_p, ctypes.c_wchar_p]
libintl.libintl_wbindtextdomain.restype = ctypes.c_wchar_p
return libintl
def flush_gettext_cache(libintl=None):
"""Force GNU libintl to reload message catalogs after a language change.
libintl caches the loaded .mo file per text domain. Changing LANG or
LANGUAGE (or calling bindtextdomain again) does not by itself reload
that cache on Windows. GNU gettext exposes `_nl_msg_cat_cntr` for this:
incrementing it invalidates the catalog cache so the next dgettext /
Gtk.Builder translation lookup reloads for the new language.
Returns True if the counter was incremented.
"""
if libintl is None:
libintl = load_i18n_dll()
if not libintl:
logger.warning(
'Could not flush gettext cache: intl-8.dll is not loaded.')
return False
if not hasattr(libintl, '_nl_msg_cat_cntr'):
logger.warning(
'Could not flush gettext cache: _nl_msg_cat_cntr is not exported '
'by this libintl. Language changes will not take effect (issue #1801).')
return False
try:
cntr = ctypes.c_int.in_dll(libintl, '_nl_msg_cat_cntr')
cntr.value += 1
return True
except (ValueError, AttributeError, TypeError) as e:
logger.debug('Could not flush gettext cache: %s', e)
return False
def move_to_recycle_bin(path):
"""Move 'path' into recycle bin"""
shell.SHFileOperation(
(0, shellcon.FO_DELETE, path, None, shellcon.FOF_ALLOWUNDO | shellcon.FOF_NOCONFIRMATION))
def parse_windows_build(build=None):
"""
Parse build string like 1.2.3 or 1.2 to numeric,
ignoring the third part, if present.
"""
if not build:
# If not given, default to current system's version
return get_windows_version()
return Decimal('.'.join(build.split('.')[0:2]))
def path_on_network(path):
"""Check whether 'path' is on a network drive"""
drive = os.path.splitdrive(path)[0]
if drive.startswith(r'\\'):
return True
return win32file.GetDriveType(drive) == win32file.DRIVE_REMOTE
def shell_change_notify():
"""Notify the Windows shell of update.
Used in windows_explorer.xml."""
shell.SHChangeNotify(shellcon.SHCNE_ASSOCCHANGED, shellcon.SHCNF_IDLIST,
None, None)
return 0
def set_environ(varname, path):
"""Define an environment variable for use in CleanerML and Winapp2.ini"""
if not path:
return
if varname in os.environ:
# logger.debug('set_environ(%s, %s): skipping because environment variable is already defined', varname, path)
if IS_WINDOWS:
os.environ[varname] = os.path.expandvars('%%%s%%' % varname)
# Do not redefine the environment variable when it already exists
# But re-encode them with utf-8 instead of mbcs
return
try:
if not os.path.exists(path):
raise RuntimeError(
'Variable %s points to a non-existent path %s' % (varname, path))
os.environ[varname] = path
except Exception:
logger.exception(
'set_environ(%s, %s): exception when setting environment variable', varname, path)
def setup_environment():
"""Define any extra environment variables"""
# These variables are for use in CleanerML and Winapp2.ini.
csidl_to_environ('commonappdata', shellcon.CSIDL_COMMON_APPDATA)
csidl_to_environ('documents', shellcon.CSIDL_PERSONAL)
# Windows XP does not define localappdata, but Windows Vista and 7 do
csidl_to_environ('localappdata', shellcon.CSIDL_LOCAL_APPDATA)
csidl_to_environ('music', shellcon.CSIDL_MYMUSIC)
csidl_to_environ('pictures', shellcon.CSIDL_MYPICTURES)
csidl_to_environ('video', shellcon.CSIDL_MYVIDEO)
# LocalLowAppData does not have a CSIDL for use with
# SHGetSpecialFolderPath. Instead, it is identified using
# SHGetKnownFolderPath in Windows Vista and later
try:
path = get_known_folder_path('LocalAppDataLow')
except Exception:
logger.exception('exception identifying LocalAppDataLow')
else:
set_environ('LocalAppDataLow', path)
# %cd% can be helpful for cleaning portable applications when
# BleachBit is portable. It is the same variable name as defined by
# cmd.exe .
set_environ('cd', os.getcwd())
# XDG_DATA_DIRS environment variable needs to be set with both GTK icons and
# `glib-2.0\schemas\gschemas.compiled`.
# The latter is required by the make chaff dialog.
# https://github.com/bleachbit/bleachbit/issues/1444
# https://github.com/bleachbit/bleachbit/issues/1780
if os.environ.get('XDG_DATA_DIRS'):
return
xdg_data_dirs = [os.path.dirname(sys.executable) +
'\\share',
os.getcwd() + '\\share']
found_dir = False
for xdg_data_dir in xdg_data_dirs:
xdg_data_fn = os.path.join(
xdg_data_dir, 'glib-2.0', 'schemas', 'gschemas.compiled')
if os.path.exists(xdg_data_fn):
found_dir = True
break
if found_dir:
logger.debug('XDG_DATA_DIRS=%s', xdg_data_dir)
set_environ('XDG_DATA_DIRS', xdg_data_dir)
else:
logger.warning('XDG_DATA_DIRS not set and gschemas.compiled not found')
def split_registry_key(full_key):
r"""Given a key like HKLM\Software split into tuple (hive, key).
Used internally."""
assert len(full_key) >= 6
[k1, k2] = full_key.split("\\", 1)
hive_map = {
'HKCR': winreg.HKEY_CLASSES_ROOT,
'HKCU': winreg.HKEY_CURRENT_USER,
'HKLM': winreg.HKEY_LOCAL_MACHINE,
'HKU': winreg.HKEY_USERS}
if k1 not in hive_map:
raise RuntimeError("Invalid Windows registry hive '%s'" % k1)
return hive_map[k1], k2
def read_registry_key(full_key, value_name):
try:
(hive, sub_key) = split_registry_key(full_key)
except RuntimeError:
return None
try:
with winreg.OpenKey(hive, sub_key, 0, winreg.KEY_QUERY_VALUE) as hkey:
(reg_value, reg_type) = winreg.QueryValueEx(hkey, value_name)
if reg_type in (winreg.REG_EXPAND_SZ, winreg.REG_SZ):
return reg_value
return None
except OSError as e:
if e.winerror == errno.ENOENT:
# ENOENT = 'file not found' means value does not exist
return None
raise
def symlink_or_copy(src, dst):
"""Symlink with fallback to copy
Symlink is faster and uses virtually no storage, but it it requires administrator
privileges or Windows developer mode.
If symlink is not available, just copy the file.
"""
try:
os.symlink(src, dst)
logger.debug('linked %s to %s', src, dst)
except (PermissionError, OSError):
shutil.copy(src, dst)
logger.debug('copied %s to %s', src, dst)
def has_fontconfig_cache(font_conf_file):
# Keep minidom out of module scope: FileUtilities imports Windows on every
# Windows run, and this is the only function that needs it.
import xml.dom.minidom # pylint: disable=import-outside-toplevel
with open(font_conf_file, 'rb') as f:
data = f.read()
General.reject_xml_dtd(data, 'fonts.conf')
dom = xml.dom.minidom.parseString(data)
fc_element = dom.getElementsByTagName('fontconfig')[0]
expanded_localdata = os.path.expandvars('%LOCALAPPDATA%')
expanded_homepath = os.path.join(os.path.expandvars(
'%HOMEDRIVE%'), os.path.expandvars('%HOMEPATH%'))
for dir_element in fc_element.getElementsByTagName('cachedir'):
if dir_element.firstChild.nodeValue == 'LOCAL_APPDATA_FONTCONFIG_CACHE':
dirpath = os.path.join(expanded_localdata, 'fontconfig', 'cache')
elif dir_element.firstChild.nodeValue == 'fontconfig' and dir_element.getAttribute('prefix') == 'xdg':
dirpath = os.path.join(expanded_homepath, '.cache', 'fontconfig')
elif dir_element.firstChild.nodeValue == '~/.fontconfig':
dirpath = os.path.join(expanded_homepath, '.fontconfig')
elif dir_element.firstChild.nodeValue.startswith('/'):
# fontconfig puts its install folder, which holds etc\fonts,
# in front of a path starting with /
prefix = os.path.dirname(os.path.dirname(
os.path.dirname(font_conf_file)))
dirpath = os.path.normpath(os.path.join(
prefix, dir_element.firstChild.nodeValue.lstrip('/')))
else:
# user has entered a custom directory
dirpath = dir_element.firstChild.nodeValue
# Match any cache version (fontconfig 2.17 writes .cache-9)
if dirpath and glob.glob(
os.path.join(glob.escape(dirpath), '*.cache-*')):
return True
return False
def get_font_conf_file():
"""Return the full path to fonts.conf
This function should be called only on Windows with GTK
"""
if not IS_WINDOWS:
raise RuntimeError("get_font_conf_file() requires Windows")
from bleachbit.GtkShim import gtk_may_be_available
if not gtk_may_be_available():
raise RuntimeError("get_font_conf_file() requires GTK")
if hasattr(sys, 'frozen'):
# running inside py2exe
return os.path.join(bleachbit.bleachbit_exe_path, 'etc', 'fonts', 'fonts.conf')
import gi
gnome_dir = os.path.join(os.path.dirname(
os.path.dirname(gi.__file__)), 'gnome')
if not os.path.isdir(gnome_dir):
# BleachBit is running from a stand-alone Python installation.
gnome_dir = os.path.join(sys.exec_prefix, '..', '..')
return os.path.join(gnome_dir, 'etc', 'fonts', 'fonts.conf')
if IS_WINDOWS:
# ctypes caches this on the DLL, so set argtypes once
_peek_message = ctypes.windll.user32.PeekMessageW
_peek_message.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND,
wintypes.UINT, wintypes.UINT, wintypes.UINT]
# Under the 300 ms default timeout for low-level hooks
_SPLASH_PUMP_SECONDS = 0.01
class SplashThread(Thread):
_class_atom = None
def __init__(self, group=None, name=None, args=(), kwargs=None):
super().__init__(group, self._show_splash_screen, name, args, kwargs)
self.daemon = True
self._splash_screen_started = Event()
self._splash_screen_closed = Event()
self._splash_screen_handle = None
self._splash_screen_height = None
self._splash_screen_width = None
self._startup_error = None
self._thread_id = None
def start(self):
if self.ident is not None:
logger.debug('SplashThread was already started')
return
try:
Thread.start(self)
except RuntimeError:
logger.debug('SplashThread could not be started', exc_info=True)
return
started = self._wait_started(timeout=10)
if not started:
logger.warning('SplashThread did not start within timeout')
else:
logger.debug('SplashThread started')
if self._startup_error:
logger.debug('Splash screen disabled due to startup error')
def _wait_started(self, timeout):
"""Wait for the splash window, running messages sent to this thread"""
msg = wintypes.MSG()
deadline = time.monotonic() + timeout
while not self._splash_screen_started.wait(_SPLASH_PUMP_SECONDS):
if time.monotonic() >= deadline:
return False
# Run GDK's keyboard hook, which the splash's ALT keys wait on.
# PM_NOREMOVE leaves posted messages for GTK.
# pylint: disable-next=possibly-used-before-assignment
_peek_message(ctypes.byref(msg), None, 0, 0, win32con.PM_NOREMOVE)
return True
def run(self):
try:
self._thread_id = win32api.GetCurrentThreadId()
self._splash_screen_handle = self._show_splash_screen()
except Exception as exc:
self._startup_error = exc
logger.exception('SplashThread failed to initialize splash screen')
finally:
self._splash_screen_started.set()
if self._startup_error:
self._splash_screen_closed.set()
return
# Dispatch messages
try:
win32gui.PumpMessages()
except Exception:
logger.exception('SplashThread message pump failed')
finally:
self._splash_screen_handle = None
self._splash_screen_closed.set()
def join(self, timeout=None):
self.close(timeout)
def close(self, timeout=None):
if not self.is_alive():
return
splash_delay = get_splash_screen_delay_seconds()
if splash_delay > 0:
logger.debug(
'Delaying splash screen close by %s seconds', splash_delay)
time.sleep(splash_delay)
self._request_close()
Thread.join(self, timeout=timeout)
def _request_close(self):
"""Ask the splash window to close without risking a GUI crash."""
if not self._splash_screen_handle:
self._post_quit_message()
return
hWindow = self._splash_screen_handle
try:
if not win32gui.IsWindow(hWindow):
self._splash_screen_handle = None
self._post_quit_message()
return
except Exception:
logger.debug(
'Could not verify splash screen window', exc_info=True)
self._hide_window(hWindow)
if self._send_close_message(hWindow):
return
try:
win32gui.PostMessage(hWindow, win32con.WM_CLOSE, 0, 0)
except Exception:
logger.debug('Failed to post close message to splash screen',
exc_info=True)
self._post_quit_message()
def _hide_window(self, hWindow):
"""Hide the splash window before closing it."""
try:
show_window_async = ctypes.windll.user32.ShowWindowAsync
show_window_async.argtypes = [wintypes.HWND, ctypes.c_int]
show_window_async.restype = wintypes.BOOL
show_window_async(wintypes.HWND(hWindow), win32con.SW_HIDE)
except Exception:
logger.debug('Failed to hide splash screen', exc_info=True)
def _send_close_message(self, hWindow):
"""Synchronously close the splash window with a timeout."""
flags = getattr(win32con, 'SMTO_ABORTIFHUNG', 0x0002)
try:
send_message_timeout_w = ctypes.windll.user32.SendMessageTimeoutW
send_message_timeout_w.argtypes = [
wintypes.HWND,
wintypes.UINT,
wintypes.WPARAM,
wintypes.LPARAM,
wintypes.UINT,
wintypes.UINT,
ctypes.POINTER(ctypes.c_void_p),
]
send_message_timeout_w.restype = wintypes.LPARAM
message_result = ctypes.c_void_p()
result = send_message_timeout_w(
wintypes.HWND(hWindow), win32con.WM_CLOSE, 0, 0, flags,
SPLASH_CLOSE_TIMEOUT_MS, ctypes.byref(message_result))
if result:
return True
logger.debug('SendMessageTimeoutW failed: %s',
ctypes.get_last_error())
except Exception:
logger.debug('Failed to send close message with ctypes',
exc_info=True)
return False
def _post_quit_message(self):
"""Exit the splash message pump when the window cannot be addressed."""
if self._thread_id is None:
return
post_thread_message = getattr(win32api, 'PostThreadMessage', None)
if post_thread_message is None:
return
try:
post_thread_message(self._thread_id, win32con.WM_QUIT, 0, 0)
except Exception:
logger.debug('Failed to post quit message to splash thread',
exc_info=True)
def get_icon_path(self):
"""Return the full path to icon file"""
if hasattr(sys, 'frozen'):
# running frozen in py2exe
icon_dir = Path(sys.argv[0]).parent / 'share'
else:
# running from source, and `__file__`` may be at either level
# - `tests/TestWindows.py`
# - `bleachbit.py``
module_dir = Path(__file__).absolute().parent
icon_dir = module_dir / 'windows'
icon_path = module_dir / 'bleachbit.ico'
if not icon_path.exists():
icon_dir = module_dir.parent / 'windows'
return (icon_dir / 'bleachbit.ico').absolute()
def calculate_window_position(self, display_width, display_height):
"""Calculate centered window position and size"""
x = (display_width - SPLASH_ICON_SIZE_PX) // 2
y = (display_height - SPLASH_ICON_SIZE_PX) // 2
return (x, y, SPLASH_ICON_SIZE_PX, SPLASH_ICON_SIZE_PX)
def _render_splash(self, hWnd):
"""Load image and render the splash screen"""
filename = self.get_icon_path()
if not filename.exists():
logger.warning(
'Icon file not found: %s with current working directory: %s.', filename, os.getcwd())
return
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
icon_size = SPLASH_ICON_SIZE_PX
flags = win32con.LR_LOADFROMFILE
hIcon = win32gui.LoadImage(
0, str(filename), win32con.IMAGE_ICON, icon_size, icon_size, flags)
if not hIcon:
return
user32.UpdateLayeredWindow.argtypes = [
wintypes.HWND,
wintypes.HDC,
ctypes.POINTER(_POINT),
ctypes.POINTER(_SIZE),
wintypes.HDC,
ctypes.POINTER(_POINT),
wintypes.COLORREF,
ctypes.POINTER(_BLENDFUNCTION),
wintypes.DWORD,
]
user32.UpdateLayeredWindow.restype = wintypes.BOOL
gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC]
gdi32.CreateCompatibleDC.restype = wintypes.HDC
gdi32.CreateDIBSection.argtypes = [
wintypes.HDC,
ctypes.POINTER(_BITMAPINFO),
wintypes.UINT,
ctypes.POINTER(ctypes.c_void_p),
wintypes.HANDLE,
wintypes.DWORD,
]
gdi32.CreateDIBSection.restype = wintypes.HBITMAP
gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
gdi32.SelectObject.restype = wintypes.HGDIOBJ
gdi32.DeleteObject.argtypes = [wintypes.HGDIOBJ]
gdi32.DeleteObject.restype = wintypes.BOOL
gdi32.DeleteDC.argtypes = [wintypes.HDC]
gdi32.DeleteDC.restype = wintypes.BOOL
hdc_screen = None
hdc_mem = None
hbm = None
hold = None
try:
hdc_screen = win32gui.GetDC(0)
if not hdc_screen:
return
hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
if not hdc_mem:
return
bitmap_info = _BITMAPINFO()
bitmap_info.bmiHeader.biSize = ctypes.sizeof(_BITMAPINFOHEADER)
bitmap_info.bmiHeader.biWidth = icon_size
bitmap_info.bmiHeader.biHeight = -icon_size
bitmap_info.bmiHeader.biPlanes = 1
bitmap_info.bmiHeader.biBitCount = 32
bitmap_info.bmiHeader.biCompression = _BI_RGB
bits = ctypes.c_void_p()
hbm = gdi32.CreateDIBSection(
hdc_screen,
ctypes.byref(bitmap_info),
_DIB_RGB_COLORS,
ctypes.byref(bits),
None,
0)
if not hbm or not bits.value:
return
hold = gdi32.SelectObject(hdc_mem, hbm)
ctypes.memset(bits.value, 0, icon_size * icon_size * 4)
try:
win32gui.DrawIconEx(
hdc_mem, 0, 0, hIcon, icon_size, icon_size, 0, 0, win32con.DI_NORMAL)
rect = win32gui.GetWindowRect(hWnd)
pt_dst = _POINT(rect[0], rect[1])
pt_src = _POINT(0, 0)
size_wnd = _SIZE(icon_size, icon_size)
blend = _BLENDFUNCTION(_AC_SRC_OVER, 0, 255, _AC_SRC_ALPHA)
success = user32.UpdateLayeredWindow(
hWnd,
hdc_screen,
ctypes.byref(pt_dst),
ctypes.byref(size_wnd),
hdc_mem,
ctypes.byref(pt_src),
0,
ctypes.byref(blend),
_ULW_ALPHA)
if not success:
logger.warning('UpdateLayeredWindow failed: %s',
ctypes.get_last_error())
finally:
if hold:
gdi32.SelectObject(hdc_mem, hold)
finally:
if hbm:
gdi32.DeleteObject(hbm)
if hdc_mem:
gdi32.DeleteDC(hdc_mem)
if hdc_screen:
win32gui.ReleaseDC(0, hdc_screen)
try:
win32gui.DestroyIcon(hIcon)
except Exception:
logger.debug('could not destroy the splash icon',
exc_info=True)
def _safe_render_splash(self, hWnd):
"""Render the splash screen without letting paint failures escape."""
try:
self._render_splash(hWnd)
except Exception:
logger.exception('Failed to render splash screen')
def _register_window_class(self, wndClass):
"""Register splash screen window class, handling reuse."""
# _class_atom belongs to this class.
# pylint: disable-next=protected-access
cached_atom = self.__class__._class_atom
if cached_atom:
return cached_atom
try:
atom = win32gui.RegisterClass(wndClass)
except pywintypes.error as err:
if getattr(err, 'winerror', None) != 1410: # ERROR_CLASS_ALREADY_EXISTS
raise
# Try to get class info if function exists
try:
existing = win32gui.GetClassInfo(
wndClass.hInstance, wndClass.lpszClassName)
if isinstance(existing, tuple):
atom = existing[0]
else:
atom = existing
if not atom:
raise
except AttributeError:
# GetClassInfo doesn't exist, use a fallback atom value
atom = 1234 # Fallback atom for testing/compatibility
except Exception:
# GetClassInfo failed, use fallback
atom = 1234
# _class_atom belongs to this class.
# pylint: disable-next=protected-access
self.__class__._class_atom = atom
return atom
def _show_splash_screen(self):
"""Create window and render the splash screen."""
# get instance handle
hInstance = win32api.GetModuleHandle()
# the class name
className = 'SimpleWin32'
# create and initialize window class
wndClass = win32gui.WNDCLASS()
wndClass.style = win32con.CS_HREDRAW | win32con.CS_VREDRAW
wndClass.lpfnWndProc = self.wndProc
wndClass.hInstance = hInstance
wndClass.hIcon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
wndClass.hCursor = win32gui.LoadCursor(0, win32con.IDC_ARROW)
wndClass.hbrBackground = win32gui.GetStockObject(win32con.NULL_BRUSH)
wndClass.lpszClassName = className
# register window class
wndClassAtom = self._register_window_class(wndClass)
displayWidth = win32api.GetSystemMetrics(0)
displayHeight = win32api.GetSystemMetrics(1)
windowPosX, windowPosY, self._splash_screen_width, self._splash_screen_height = self.calculate_window_position(
displayWidth, displayHeight)
hWindow = None
try:
hWindow = win32gui.CreateWindowEx(
win32con.WS_EX_LAYERED | win32con.WS_EX_TOOLWINDOW | win32con.WS_EX_TOPMOST,
wndClassAtom, # it seems message dispatching only works with the atom, not the class name
'Bleachbit splash screen',
win32con.WS_POPUP |
win32con.WS_VISIBLE,
windowPosX,
windowPosY,
self._splash_screen_width,
self._splash_screen_height,
0,
0,
hInstance,
None)
if not hWindow:
raise RuntimeError('CreateWindowEx returned no splash window')
self._splash_screen_handle = hWindow
except Exception:
if hWindow:
try:
win32gui.DestroyWindow(hWindow)
except Exception:
logger.debug('Failed to destroy incomplete splash window',
exc_info=True)
raise
try:
win32gui.UpdateWindow(hWindow)
except Exception:
logger.debug('Failed to update splash screen window',
exc_info=True)
self._safe_render_splash(hWindow)
try:
is_splash_screen_on_top = self._force_set_foreground_window(
hWindow)
except Exception:
logger.debug('Failed to foreground splash screen', exc_info=True)
is_splash_screen_on_top = False
logger.debug('Is splash screen on top: %s', is_splash_screen_on_top)
return hWindow
def _force_set_foreground_window(self, hWindow):
"""Keep the splash screen on top"""
# As there are some restrictions about which processes can call SetForegroundWindow as described here:
# https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow
# we try consecutively three different ways to show the splash screen on top of all other windows.
# Solution 1: Pressing alt key unlocks SetForegroundWindow
# https://stackoverflow.com/questions/14295337/win32gui-setactivewindow-error-the-specified-procedure-could-not-be-found
# Not using win32com.client.Dispatch like in the link because there are problems when building with py2exe.
ALT_KEY = win32con.VK_MENU
RIGHT_ALT = 0xb8
win32api.keybd_event(ALT_KEY, RIGHT_ALT, 0, 0)
win32api.keybd_event(ALT_KEY, RIGHT_ALT, win32con.KEYEVENTF_KEYUP, 0)
win32gui.ShowWindow(hWindow, win32con.SW_SHOW)
try:
win32gui.SetForegroundWindow(hWindow)
except Exception as e:
exc_message = str(e)
logger.debug(
'Failed attempt to show splash screen with keybd_event: %s',
exc_message)
if win32gui.GetForegroundWindow() == hWindow:
return True
# Solution 2: Attaching current thread to the foreground thread in order to use BringWindowToTop
# https://shlomio.wordpress.com/2012/09/04/solved-setforegroundwindow-win32-api-not-always-works/
# pylint: disable-next=possibly-used-before-assignment
foreground_thread_id, _foreground_process_id = win32process.GetWindowThreadProcessId(
win32gui.GetForegroundWindow())
appThread = win32api.GetCurrentThreadId()
if foreground_thread_id != appThread:
try:
win32process.AttachThreadInput(
foreground_thread_id, appThread, True)
win32gui.BringWindowToTop(hWindow)
win32gui.ShowWindow(hWindow, win32con.SW_SHOW)
win32process.AttachThreadInput(
foreground_thread_id, appThread, False)
except Exception as e:
exc_message = str(e)
logger.debug(
'Failed attempt to show splash screen with '
'AttachThreadInput: %s', exc_message)
else:
win32gui.BringWindowToTop(hWindow)
win32gui.ShowWindow(hWindow, win32con.SW_SHOW)
if win32gui.GetForegroundWindow() == hWindow:
return True
# Solution 3: Working with timers that lock/unlock SetForegroundWindow
# https://gist.github.com/EBNull/1419093
try:
timeout = win32gui.SystemParametersInfo(
win32con.SPI_GETFOREGROUNDLOCKTIMEOUT)
win32gui.SystemParametersInfo(
win32con.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, win32con.SPIF_SENDCHANGE)
win32gui.BringWindowToTop(hWindow)
win32gui.SetForegroundWindow(hWindow)
win32gui.SystemParametersInfo(
win32con.SPI_SETFOREGROUNDLOCKTIMEOUT, timeout, win32con.SPIF_SENDCHANGE)
except Exception as e:
exc_message = str(e)
logger.debug(
'Failed attempt to show splash screen with '
'SystemParametersInfo: %s', exc_message)
if win32gui.GetForegroundWindow() == hWindow:
return True
# Solution 4: If on some machines the splash screen still doesn't come on top, we can try
# the following solution that combines attaching to a thread and timers:
# https://www.codeproject.com/Tips/76427/How-to-Bring-Window-to-Top-with-SetForegroundWindo
return False
def wndProc(self, hWnd, message, wParam, lParam):
"""Window procedure for handling messages"""
if message == win32con.WM_CREATE:
self._safe_render_splash(hWnd)
return 0
if message == win32con.WM_ERASEBKGND:
return 1
if message == win32con.WM_PAINT:
_, paintStruct = win32gui.BeginPaint(hWnd)
try:
self._safe_render_splash(hWnd)
finally:
win32gui.EndPaint(hWnd, paintStruct)
return 0
if message == win32con.WM_CLOSE:
try:
win32gui.DestroyWindow(hWnd)
return 0
except Exception:
logger.debug('Failed to destroy splash screen window',
exc_info=True)
return win32gui.DefWindowProc(hWnd, message, wParam, lParam)
elif message == win32con.WM_DESTROY:
if hWnd == self._splash_screen_handle:
self._splash_screen_handle = None
self._splash_screen_closed.set()
win32gui.PostQuitMessage(0)
return 0
return win32gui.DefWindowProc(hWnd, message, wParam, lParam)
splash_thread = SplashThread()
atexit.register(splash_thread.close)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/WindowsWipe.py 0000664 0001750 0001750 00000144435 15265023030 015563 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
***
*** Owner: Andrew Ziem
*** Author: Peter Marshall
***
*** References:
*** Windows Internals (Russinovich, Solomon, Ionescu), 6th edition
*** https://web.archive.org/web/20170227200225/http://windowsitpro.com/systems-management/inside-windows-nt-disk-defragmenting
*** https://learn.microsoft.com/en-us/sysinternals/downloads/sdelete
*** https://learn.microsoft.com/en-us/archive/blogs/jeffrey_wall/defrag-api-c-wrappers
*** https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_get_retrieval_pointers
***
***
*** Algorithm
*** --Phase 1
*** - Check if the file has special characteristics (sparse, encrypted,
*** compressed), determine file system (NTFS or FAT), Windows version.
*** - Read the on-disk locations of the file using defrag API.
*** - If file characteristics don't rule it out, just do a direct write
*** of zero-fill on entire file size and flush to disk.
*** - Read back the on-disk locations of the file using defrag API.
*** - If locations are exactly the same, we are done.
*** - Otherwise, enumerate clusters that did not get overwritten in place
*** ("missed clusters").
*** They are probably still untouched, we need to wipe them.
*** - If it was a special file that wouldn't be wiped by a direct write,
*** we will truncate the file and treat it all as missed clusters.
***
*** --Phase 2
*** - (*) Get volume bitmap of free/allocated clusters using defrag API.
*** Figure out if checkpoint has made our missed clusters available
*** for use again (this is potentially delayed by a few seconds in NTFS).
*** - If they have not yet been made available, wait 0.1s then repeat
*** previous check (*), up to a limit of 7s in polling.
*** - Figure out if it is better to bridge the extents, wiping more clusters
*** but gaining a performance boost from reduced total cycles and overhead.
*** - Recurse over the extents we need to wipe, breaking them down into
*** smaller extents if necessary.
*** - Write a zero-fill file that will provide enough clusters to
*** completely overwrite each extent in turn.
*** - Iterate over the zero-fill file, moving clusters from our zero file
*** to the missed clusters using defrag API.
*** - If the defrag move operation did not succeed, it was probably because
*** another process has grabbed a cluster on disk that we wanted to
*** write to. This can also happen when, by chance, the move's source and
*** target ranges overlap.
*** - In response, we can break the extent down into sub-sections and
*** attempt to wipe each subsection (eventually down to a granularity
*** of one cluster). We also inspect allocated/free sectors to look ahead
*** and avoid making move calls that we know will fail.
*** - If a cluster was allocated by some other Windows process before we could
*** explicitly wipe it, it is assumed to be wiped. Even if Windows writes a
*** small amount of explicit data to a cluster, it seems to write zero-fill
*** out to the end of the cluster to round it out.
***
***
*** TO DO
*** - Test working correctly if per-user disk quotas are in place
***
"""
# standard library
import sys
import os
import errno
import struct
import logging
from operator import itemgetter
from random import randint
from collections import namedtuple
# third-party
# pylint: disable=no-name-in-module
from win32api import (GetVolumeInformation, GetDiskFreeSpace,
GetVersionEx, Sleep)
from win32file import (CreateFile, CreateFileW,
CloseHandle, GetDriveType,
GetFileSize, GetFileAttributesW,
SetFileAttributesW,
DeviceIoControl, SetFilePointer,
WriteFile,
LockFile, DeleteFile,
SetEndOfFile, FlushFileBuffers)
from win32file import (GENERIC_READ, GENERIC_WRITE, FILE_BEGIN,
FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE,
OPEN_EXISTING, CREATE_ALWAYS, FILE_FLAG_BACKUP_SEMANTICS,
DRIVE_REMOTE, DRIVE_CDROM, DRIVE_UNKNOWN)
from winioctlcon import (FSCTL_GET_RETRIEVAL_POINTERS,
FSCTL_GET_VOLUME_BITMAP,
FSCTL_GET_NTFS_VOLUME_DATA,
FSCTL_MOVE_FILE,
FSCTL_SET_COMPRESSION,
FSCTL_SET_SPARSE,
FSCTL_SET_ZERO_DATA)
from win32con import (FILE_ATTRIBUTE_ENCRYPTED,
FILE_ATTRIBUTE_COMPRESSED,
FILE_ATTRIBUTE_SPARSE_FILE,
FILE_ATTRIBUTE_HIDDEN,
FILE_ATTRIBUTE_READONLY,
FILE_FLAG_RANDOM_ACCESS,
FILE_FLAG_NO_BUFFERING,
FILE_FLAG_WRITE_THROUGH,
COMPRESSION_FORMAT_DEFAULT)
# local import
from bleachbit import IS_WINDOWS
from bleachbit.FileUtilities import extended_path, extended_path_undo
# Constants.
VER_SUITE_PERSONAL = 0x200 # doesn't seem to be present in win32con.
SIMULATE_CONCURRENCY = False # remove this test function when QA complete
# drive_letter_safety = "E" # protection to only use removable drives
# don't use C: or D:, but E: and beyond OK.
TMP_FILE_NAME = "bbtemp.dat"
SPIKE_FILE_NAME = "bbspike" # cluster number will be appended
WRITE_BUF_SIZE = 512 * 1024 # 512 kilobytes
ZERO_FILL_BUFFER = bytearray(WRITE_BUF_SIZE)
# Set up logging
logger = logging.getLogger(__name__)
def unpack_element(fmt, structure):
"""Unpacks the next element in a structure, using format requested.
Args:
fmt: Format string for struct.unpack
structure: Structure to unpack
Returns:
tuple: Element and remaining content of the structure
"""
chunk_size = struct.calcsize(fmt)
element = struct.unpack(fmt, structure[:chunk_size])
if element:
element = element[0] # convert from tuple to single element
structure = structure[chunk_size:]
return element, structure
# Convert VCN/LCN tuples into cluster start/end tuples.
def logical_ranges_to_extents(ranges, bridge_compressed=False):
"""Convert a list of VCN/LCN tuples into a list of cluster start/end tuples.
Args:
ranges: List of VCN/LCN tuples from GET_RETRIEVAL_POINTERS
bridge_compressed: If True, combines nearly contiguous extents in compressed files
even if there are unrelated clusters between them
Yields:
Tuples of (start_cluster, end_cluster) representing extents on disk
"""
if not bridge_compressed:
vcn_count = 0
for vcn, lcn in ranges:
# If we encounter an LCN of -1, we have reached a
# "space-saved" part of a compressed file. These clusters
# don't map to clusters on disk, just advance beyond them.
if lcn < 0:
vcn_count = vcn
continue
# Figure out length for this cluster range.
# Keep track of VCN inside this file.
this_vcn_span = vcn - vcn_count
vcn_count = vcn
if this_vcn_span < 0:
raise RuntimeError(f'negative VCN span: {this_vcn_span}')
yield (lcn, lcn + this_vcn_span - 1)
else:
vcn_count = 0
last_record = len(ranges)
index = 0
while index < last_record:
vcn, lcn = ranges[index]
# If we encounter an LCN of -1, we have reached a
# "space-saved" part of a compressed file. These clusters
# don't map to clusters on disk, just advance beyond them.
if lcn < 0:
vcn_count = vcn
index += 1
continue
# Figure out if we have a block of clusters that can
# be merged together. The pattern is regular disk
# clusters interspersed with -1 space-saver sections
# that are arranged with gaps of 16 clusters or less.
merge_index = index
while (merge_index + 2 < last_record and
ranges[merge_index + 1][1] < 0 <= ranges[merge_index + 2][1] and
0 < ranges[merge_index + 2][1] - ranges[merge_index][1] <= 16):
merge_index += 2
# Figure out length for this cluster range.
# Keep track of VCN inside this file.
if merge_index == index:
index += 1
this_vcn_span = vcn - vcn_count
vcn_count = vcn
if this_vcn_span < 0:
raise RuntimeError(f'negative VCN span: {this_vcn_span}')
yield (lcn, lcn + this_vcn_span - 1)
else:
index = merge_index + 1
last_vcn_span = (ranges[merge_index][0] -
ranges[merge_index - 1][0])
vcn = ranges[merge_index][0]
vcn_count = vcn
if last_vcn_span < 0:
raise RuntimeError(f'negative VCN span: {last_vcn_span}')
yield (lcn, ranges[merge_index][1] + last_vcn_span - 1)
# Determine clusters that are in extents list A but not in B.
# Generator function, will return results one tuple at a time.
def extents_a_minus_b(a, b):
"""Calculate clusters that exist in extents list A but not in B.
Args:
a: List of tuples (start_cluster, end_cluster) representing extents
b: List of tuples (start_cluster, end_cluster) to exclude from a
Yields:
Tuples of (start_cluster, end_cluster) for extents in a but not in b
"""
# Sort the lists of start/end points.
a_sorted = sorted(a, key=itemgetter(0))
b_sorted = sorted(b, key=itemgetter(0))
for a_begin, a_end in a_sorted:
for b_begin, b_end in b_sorted:
if b_begin > a_end:
# Already gone beyond current A range and no matches.
# Return this range of A unbroken.
yield (a_begin, a_end)
break
if b_end < a_begin:
# Too early in list, keep searching.
continue
if b_begin <= a_begin:
if b_end >= a_end:
# This range of A is completely covered by B.
# Do nothing and pass on to next range of A.
break
# This range of A is partially covered by B.
# Remove the covered range from A and loop
a_begin = b_end + 1
else:
# This range of A is partially covered by B.
# Return the first part of A not covered.
# Either process remainder of A range or move to next A.
yield (a_begin, b_begin - 1)
if b_end >= a_end:
break
a_begin = b_end + 1
else:
# Loop exhausted without a break: nothing in B covered the
# rest of this A range, so yield what's left of it.
yield (a_begin, a_end)
def choose_if_bridged(volume_handle, total_clusters,
orig_extents, bridged_extents):
"""Decide if it will be more efficient to bridge the extents and wipe
some additional clusters that weren't strictly part of the file.
Args:
volume_handle: Handle to the volume
total_clusters: Total number of clusters on the volume
orig_extents: Original extents of the file
bridged_extents: Bridged extents of the file
Returns:
List of tuples (start_cluster, end_cluster) for extents to wipe
"""
logger.debug('bridged extents: %s', bridged_extents)
allocated_extents = []
volume_bitmap, _bitmap_size = get_volume_bitmap(volume_handle,
total_clusters)
_count_ofree, count_oallocated = check_extents(
orig_extents, volume_bitmap)
_count_bfree, count_ballocated = check_extents(
bridged_extents,
volume_bitmap,
allocated_extents)
bridged_extents = list(extents_a_minus_b(bridged_extents,
allocated_extents))
extra_allocated_clusters = count_ballocated - count_oallocated
saving_in_extents = len(orig_extents) - len(bridged_extents)
logger.debug("Bridged extents would require us to work around %d allocated clusters.",
extra_allocated_clusters)
logger.debug("It would reduce extent count from %d to %d.",
len(orig_extents), len(bridged_extents))
# Use a penalty of 10 extents for each extra allocated cluster.
# Why 10? Assuming our next granularity above 1 cluster is a 10 cluster
# extent, a single allocated cluster would cause us to perform 8
# additional write/move cycles due to splitting that extent into single
# clusters.
# If we had a notion of distribution of extra allocated clusters,
# we could make this calc more exact. But it's just a rule of thumb.
tradeoff = saving_in_extents - extra_allocated_clusters * 10
if tradeoff > 0:
logger.debug("Quickest method should be bridged extents")
return bridged_extents
logger.debug("Quickest method should be original extents")
return orig_extents
def split_extent(lcn_start, lcn_end):
"""Break an extent into smaller portions.
Args:
lcn_start: Start of the extent
lcn_end: End of the extent
Yields:
Tuples of (start_cluster, end_cluster) for extents to wipe
"""
split_factor = 10
exponent = 0
count = lcn_end - lcn_start + 1
while count > split_factor**(exponent + 1.3):
exponent += 1
extent_size = split_factor**exponent
for x in range(lcn_start, lcn_end + 1, extent_size):
yield (x, min(x + extent_size - 1, lcn_end))
def check_extents(extents, volume_bitmap, allocated_extents=None):
"""Check extents to see if they are marked as free.
Args:
extents: List of tuples (start_cluster, end_cluster) representing extents
volume_bitmap: Bitmap of clusters on the volume
allocated_extents: List to store allocated extents (optional)
Returns:
Tuple of (count_free, count_allocated)
"""
count_free, count_allocated = (0, 0)
for lcn_start, lcn_end in extents:
for cluster in range(lcn_start, lcn_end + 1):
if check_mapped_bit(volume_bitmap, cluster):
count_allocated += 1
if allocated_extents is not None:
# Modified by Marvin [12/05/2020] The extents should have (start, end) format
allocated_extents.append((cluster, cluster))
else:
count_free += 1
logger.debug("Extents checked: clusters free %d; allocated %d",
count_free, count_allocated)
return (count_free, count_allocated)
# Simulate concurrency for testing by occasionally allocating clusters during checking.
def check_extents_concurrency(extents, volume_bitmap,
tmp_file_path, volume_handle,
total_clusters,
allocated_extents=None):
"""Check extents to see if they are marked as free, with concurrency simulation.
Like check_extents(), but simulates a concurrent process.
Args:
extents: List of tuples (start_cluster, end_cluster) representing extents
volume_bitmap: Bitmap of clusters on the volume
tmp_file_path: Path to a temporary file
volume_handle: Handle to the volume
total_clusters: Total number of clusters on the volume
allocated_extents: List to store allocated extents (optional)
Returns:
Tuple of (count_free, count_allocated)
"""
odds_to_allocate = 1200 # 1 in 1200
count_free, count_allocated = (0, 0)
for lcn_start, lcn_end in extents:
for cluster in range(lcn_start, lcn_end + 1):
# Every once in a while, occupy a particular cluster on disk.
if randint(1, odds_to_allocate) == odds_to_allocate:
spike_cluster(volume_handle, cluster, tmp_file_path)
if bool(randint(0, 1)):
# Simulate allocated before the check, by refetching
# the volume bitmap.
logger.debug("Simulate known allocated")
volume_bitmap, _ = get_volume_bitmap(
volume_handle, total_clusters)
else:
# Simulate allocated after the check.
logger.debug("Simulate unknown allocated")
if check_mapped_bit(volume_bitmap, cluster):
count_allocated += 1
if allocated_extents is not None:
# extents use (start, end) format, like check_extents
allocated_extents.append((cluster, cluster))
else:
count_free += 1
logger.debug("Extents checked: clusters free %d; allocated %d",
count_free, count_allocated)
return (count_free, count_allocated)
def spike_cluster(volume_handle, cluster, tmp_file_path):
"""Allocate a specific cluster on disk by creating a file at that location.
This is only used for testing concurrency issues.
Args:
volume_handle: Handle to the volume
cluster: The cluster number to allocate
tmp_file_path: Path used as a reference for creating the spike file
This function simulates another process grabbing a cluster while our
algorithm is working, which helps test concurrency handling.
"""
spike_file_path = os.path.dirname(tmp_file_path)
if spike_file_path[-1] != os.sep:
spike_file_path += os.sep
spike_file_path += SPIKE_FILE_NAME + str(cluster)
file_handle = CreateFile(spike_file_path,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None, CREATE_ALWAYS, 0, None)
# 2000 bytes is enough to direct the file to its own cluster and not
# land entirely in the MFT.
write_zero_fill(file_handle, 2000)
move_file(volume_handle, file_handle, 0, cluster, 1)
CloseHandle(file_handle)
logger.debug("Spiked cluster %d with %s", cluster, spike_file_path)
def check_mapped_bit(volume_bitmap, lcn):
"""Check if an LCN is allocated (True) or free (False).
The LCN determines at what index into the bytes/bits structure we
should look.
Args:
volume_bitmap: Bitmap of clusters on the volume
lcn: Logical Cluster Number to check
Returns:
Boolean indicating whether the cluster is allocated
"""
assert isinstance(lcn, int)
mapped_bit = volume_bitmap[lcn // 8]
bit_location = lcn % 8 # zero-based
if bit_location > 0:
mapped_bit = mapped_bit >> bit_location
mapped_bit = mapped_bit & 1
return mapped_bit > 0
def check_os():
"""Check if the current operating system is Windows NT or later.
Raises:
RuntimeError: If not running on Windows NT or later
"""
if not IS_WINDOWS:
raise RuntimeError("This function requires Windows NT or later")
def determine_win_version():
"""Determine which version of Windows we are running.
Not currently used, except to control encryption test case
depending on whether it's Windows Home Edition or something higher end.
Returns:
Tuple of (version, is_home)
"""
ver_info = GetVersionEx(1)
is_home = bool(ver_info[7] & VER_SUITE_PERSONAL)
if ver_info[:2] == (6, 0):
return "Vista", is_home
if ver_info[0] >= 6:
return "Later than Vista", is_home
return "Something else", is_home
def open_file(file_name, mode=GENERIC_READ):
"""Open the file to get a Windows file handle, ensuring it exists.
Uses CreateFileW for Unicode support.
Refuses a symlink so the wipe is not redirected through a link to
another file; CreateFileW would otherwise follow it transparently
since FILE_FLAG_OPEN_REPARSE_POINT is not passed.
Args:
file_name: Path to the file to open
mode: Access mode (default: GENERIC_READ)
Returns:
Windows file handle
"""
if os.path.islink(file_name):
raise OSError(errno.EACCES, 'refusing to wipe a link', file_name)
return CreateFileW(file_name, mode, 0, None,
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, None)
def close_file(file_handle):
"""Close the file handle."""
CloseHandle(file_handle)
def get_file_basic_info(file_name, file_handle):
"""Get some basic information about a file.
Args:
file_name: Path to the file
file_handle: Windows file handle
Returns:
Tuple of (file_attributes, file_size)
"""
file_attributes = GetFileAttributesW(file_name)
file_size = GetFileSize(file_handle)
is_compressed = bool(file_attributes & FILE_ATTRIBUTE_COMPRESSED)
is_encrypted = bool(file_attributes & FILE_ATTRIBUTE_ENCRYPTED)
is_sparse = bool(file_attributes & FILE_ATTRIBUTE_SPARSE_FILE)
is_special = is_compressed | is_encrypted | is_sparse
if is_special:
logger.debug('%s: %s %s %s', file_name,
'compressed' if is_compressed else '',
'encrypted' if is_encrypted else '',
'sparse' if is_sparse else '')
return file_size, is_special
def truncate_file(file_handle):
"""Truncate a file.
Do this when to release its clusters."""
SetFilePointer(file_handle, 0, FILE_BEGIN)
SetEndOfFile(file_handle)
FlushFileBuffers(file_handle)
def volume_from_file(file_name):
r"""Given a Windows file path, determine the volume that contains it.
Append the separator \ to it (more useful for subsequent calls).
Args:
file_name: Path to the file
Returns:
Volume path
"""
# strip \\?\
split_path = os.path.splitdrive(extended_path_undo(file_name))
volume = split_path[0]
if volume and volume[-1] != os.sep:
volume += os.sep
return volume
class UnsupportedFileSystemError(Exception):
"""An exception for an unsupported file system"""
# Given a volume, get the relevant volume information.
# We are interested in:
# First call: Drive Name; Max Path; File System.
# Second call: Sectors per Cluster; Bytes per Sector; Total # of Clusters.
# Third call: Drive Type.
def get_volume_information(volume):
"""Get volume information.
Args:
volume: Volume path
Returns:
Volume information
"""
# If it's a UNC path, raise an error.
if not volume:
raise UnsupportedFileSystemError(
"Only files with a Local File System path can be wiped.")
result1 = GetVolumeInformation(volume)
result2 = GetDiskFreeSpace(volume)
result3 = GetDriveType(volume)
for drive_enum, error_reason in [
(DRIVE_REMOTE, "a network drive"),
(DRIVE_CDROM, "a CD-ROM"),
(DRIVE_UNKNOWN, "an unknown drive type")]:
if result3 == drive_enum:
raise UnsupportedFileSystemError(
f"This file is on {error_reason} and can't be wiped.")
# Only NTFS and FAT variations are supported.
# UDF (file system for CD-RW etc) is not supported.
if result1[4].upper() == "UDF":
raise UnsupportedFileSystemError(
"This file system (UDF) is not supported.")
volume_info = namedtuple('VolumeInfo', [
'drive_name', 'max_path', 'file_system',
'sectors_per_cluster', 'bytes_per_sector', 'total_clusters'])
return volume_info(result1[0], result1[2], result1[4],
result2[0], result2[1], result2[3])
def obtain_readwrite(volume):
"""Get read/write access to a volume.
Args:
volume: Volume path
Returns:
Volume handle
"""
assert volume
volume = '\\\\.\\' + volume
if volume[-1] == os.sep:
volume = volume.rstrip(os.sep)
# We need the FILE_SHARE flags so that this open call can succeed
# despite something on the volume being in use by another process.
volume_handle = CreateFile(volume, GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None, OPEN_EXISTING,
FILE_FLAG_RANDOM_ACCESS |
FILE_FLAG_NO_BUFFERING |
FILE_FLAG_WRITE_THROUGH,
None)
# logger.debug("Opened volume %s", volume)
return volume_handle
def get_extents(file_handle, translate_to_extents=True, filename=""):
"""Retrieve a list of pointers to the file location on disk.
If translate_to_extents is False, return the Windows VCN/LCN format.
If True, do an extra conversion to get a list of extents on disk.
Args:
file_handle: Windows file handle
translate_to_extents: Whether to translate to extents
filename: Name of the file
Returns:
List of extents
"""
# Assemble input structure and query Windows for retrieval pointers.
# The input structure is the number 0 as a signed 64 bit integer.
input_struct = struct.pack('q', 0)
# 4K, 32K, 256K, 2M step ups in buffer size, until call succeeds.
# Compressed/encrypted/sparse files tend to have more chopped up extents.
buf_retry_sizes = [4 * 1024, 32 * 1024, 256 * 1024, 2 * 1024**2]
rp_struct = None
for retrieval_pointers_buf_size in buf_retry_sizes:
try:
rp_struct = DeviceIoControl(file_handle,
FSCTL_GET_RETRIEVAL_POINTERS,
input_struct,
retrieval_pointers_buf_size)
except Exception:
err_info = sys.exc_info()[1]
err_code = err_info.winerror
if err_code == 38: # when file size is 0.
# (38, 'DeviceIoControl', 'Reached the end of the file.')
return []
if err_code in [122, 234]: # when buffer not large enough.
# (122, 'DeviceIoControl',
# 'The data area passed to a system call is too small.')
# (234, 'DeviceIoControl', 'More data is available.')
pass
else:
logger.error("Unhandled error code %d in get_extents for file '%s': %s",
err_code, filename, str(err_info))
raise
else:
# Call succeeded, break out from for loop.
break
if rp_struct is None:
raise RuntimeError(
f"Failed to get retrieval pointers for file '{filename}'")
# At this point we have a FSCTL_GET_RETRIEVAL_POINTERS (rp) structure.
# Process content of the first part of structure.
# Separate the retrieval pointers list up front, so we are not making
# too many string copies of it.
chunk_size = struct.calcsize('IIq')
rp_list = rp_struct[chunk_size:]
rp_struct = rp_struct[:chunk_size]
record_count, rp_struct = unpack_element('I', rp_struct) # 4 bytes
_, rp_struct = unpack_element('I', rp_struct) # 4 bytes
starting_vcn, rp_struct = unpack_element('q', rp_struct) # 8 bytes
# 4 empty bytes were consumed above.
# This is for reasons of 64-bit alignment inside structure.
# If we make the GET_RETRIEVAL_POINTERS request with 0,
# this should always come back 0.
if starting_vcn != 0:
raise RuntimeError(f'unexpected starting VCN: {starting_vcn}')
# Populate the extents array with the ranges from rp structure.
ranges = []
c = record_count
i = 0
chunk_size = struct.calcsize('q')
buf_size = len(rp_list)
while c > 0 and i < buf_size:
next_vcn = struct.unpack_from('q', rp_list, offset=i)
lcn = struct.unpack_from('q', rp_list, offset=i + chunk_size)
ranges.append((next_vcn[0], lcn[0]))
i += chunk_size * 2
c -= 1
if translate_to_extents:
return list(logical_ranges_to_extents(ranges))
return ranges
def file_make_compressed(file_handle):
"""Make this file compressed on disk.
Used only for test suite.
Args:
file_handle: Windows file handle
"""
# Assemble input structure.
# Just tell Windows to use standard compression.
input_struct = struct.pack('H', COMPRESSION_FORMAT_DEFAULT)
buf_size = struct.calcsize('H')
_ = DeviceIoControl(file_handle, FSCTL_SET_COMPRESSION,
input_struct, buf_size)
def file_make_sparse(file_handle):
"""Make this file sparse on disk.
Used only for test suite.
Args:
file_handle: Windows file handle
"""
_ = DeviceIoControl(file_handle, FSCTL_SET_SPARSE, None, None)
def file_add_sparse_region(file_handle, byte_start, byte_end):
"""Add a zero region to a sparse file.
Used only for test suite.
"""
# Assemble input structure.
input_struct = struct.pack('qq', byte_start, byte_end)
buf_size = struct.calcsize('qq')
_ = DeviceIoControl(file_handle, FSCTL_SET_ZERO_DATA,
input_struct, buf_size)
def get_volume_bitmap(volume_handle, total_clusters):
"""Retrieve a bitmap of whether clusters on disk are free/allocated.
Args:
volume_handle: Windows volume handle
total_clusters: Total number of clusters on the volume
Returns:
Tuple of volume bitmap and bitmap size
"""
# Assemble input structure and query Windows for volume bitmap.
# The input structure is the number 0 as a signed 64 bit integer.
input_struct = struct.pack('q', 0)
# Figure out the buffer size. Add small fudge factor to ensure success.
buf_size = int(total_clusters / 8) + 16 + 64
vb_struct = DeviceIoControl(volume_handle, FSCTL_GET_VOLUME_BITMAP,
input_struct, buf_size)
# At this point we have a FSCTL_GET_VOLUME_BITMAP (vb) structure.
# Process content of the first part of structure.
# Separate the volume bitmap up front, so we are not making too
# many string copies of it.
chunk_size = struct.calcsize('2q')
volume_bitmap = vb_struct[chunk_size:]
vb_struct = vb_struct[:chunk_size]
starting_lcn, vb_struct = unpack_element('q', vb_struct) # 8 bytes
bitmap_size, vb_struct = unpack_element('q', vb_struct) # 8 bytes
# If we make the GET_VOLUME_BITMAP request with 0,
# this should always come back 0.
if starting_lcn != 0:
raise RuntimeError(f'unexpected starting LCN: {starting_lcn}')
# The remaining part of the structure is the actual bitmap.
return volume_bitmap, bitmap_size
def get_ntfs_volume_data(volume_handle):
"""Retrieve info about an NTFS volume.
We are mainly interested in the locations of the Master File Table.
This call is currently not necessary, but has been left in to address any
future need.
Args:
volume_handle: Windows volume handle
Returns:
Tuple of Master File Table start and end
"""
# 512 bytes will be comfortably enough to store return object.
vd_struct = DeviceIoControl(volume_handle, FSCTL_GET_NTFS_VOLUME_DATA,
None, 512)
# At this point we have a FSCTL_GET_NTFS_VOLUME_DATA (vd) structure.
# Pick out the elements from structure that are useful to us.
_, vd_struct = unpack_element('q', vd_struct) # 8 bytes
_number_sectors, vd_struct = unpack_element('q', vd_struct) # 8 bytes
_total_clusters, vd_struct = unpack_element('q', vd_struct) # 8 bytes
_free_clusters, vd_struct = unpack_element('q', vd_struct) # 8 bytes
_total_reserved, vd_struct = unpack_element('q', vd_struct) # 8 bytes
_, vd_struct = unpack_element('4I', vd_struct) # 4*4 bytes
_, vd_struct = unpack_element('3q', vd_struct) # 3*8 bytes
mft_zone_start, vd_struct = unpack_element('q', vd_struct) # 8 bytes
mft_zone_end, vd_struct = unpack_element('q', vd_struct) # 8 bytes
# Quick sanity check that we got something reasonable for MFT zone.
if not 0 < mft_zone_start < mft_zone_end:
raise RuntimeError(
f'invalid MFT zone bounds: {mft_zone_start}, {mft_zone_end}')
logger.debug("MFT from %d to %d", mft_zone_start, mft_zone_end)
return mft_zone_start, mft_zone_end
def poll_clusters_freed(volume_handle, total_clusters, orig_extents):
"""Poll to confirm that our clusters were freed.
Check ten times per second for a duration of seven seconds.
According to Windows Internals book, it may take several seconds
until NTFS does a checkpoint and releases the clusters.
In later versions of Windows, this seems to be instantaneous.
Args:
volume_handle: Windows volume handle
total_clusters: Total number of clusters on the volume
orig_extents: Original extents of the file
Returns:
True if clusters were freed, False otherwise
"""
polling_duration_seconds = 7
attempts_per_second = 10
if not orig_extents:
return True
for _ in range(polling_duration_seconds * attempts_per_second):
volume_bitmap, _bitmap_size = get_volume_bitmap(volume_handle,
total_clusters)
count_free, count_allocated = check_extents(
orig_extents, volume_bitmap)
# Some inexact measure to determine if our clusters were freed
# by the OS, knowing that another process may grab some clusters
# in between our polling attempts.
if count_free > count_allocated:
return True
Sleep(1000 / attempts_per_second)
return False
def move_file(volume_handle, file_handle, starting_vcn,
starting_lcn, cluster_count):
"""Move file clusters to a specific location on disk using the Defrag API.
Args:
volume_handle: Handle to the volume containing the file
file_handle: Handle to the file to be moved
starting_vcn: Starting Virtual Cluster Number within the file
starting_lcn: Target Logical Cluster Number on disk
cluster_count: Number of clusters to move
Raises:
Exception: If clusters are not free or if the move operation fails
"""
assert file_handle is not None
# Assemble input structure for our request.
# Split 64-bit handle into two 32-bit integers to support both 32-bit and 64-bit Python.
# On 32-bit Windows, HANDLE is 4 bytes + 4 bytes padding before 8-byte aligned LARGE_INTEGER.
# On 64-bit Windows, HANDLE is 8 bytes (stored as two consecutive 32-bit values in little-endian).
# This single code path works for both architectures without runtime branching.
handle_val = int(file_handle)
handle_lo = handle_val & 0xFFFFFFFF
handle_hi = (handle_val >> 32) & 0xFFFFFFFF
input_struct = struct.pack('IIqqII', handle_lo, handle_hi, starting_vcn,
starting_lcn, cluster_count, 0)
DeviceIoControl(volume_handle, FSCTL_MOVE_FILE, input_struct, None)
def write_zero_fill(file_handle, write_length_bytes):
"""Write to fill a file with zeroes.
write_length_bytes: number of bytes to write.
This function writes a specified number of zero bytes to a file using the
provided file handle. The process works as follows:
1. The function uses the current file pointer position (wherever it was set before
this function was called).
2. It writes zeros in chunks of up to WRITE_BUF_SIZE bytes (512KB by default)
to optimize performance.
3. Each WriteFile operation automatically advances the file pointer by the
number of bytes written.
4. The function continues writing until the specified write_length_bytes is reached.
5. Finally, it flushes the buffers to ensure all data is written to disk.
This function doesn't explicitly set or move the file pointer - it relies on
the file pointer being positioned correctly before the function is called and
the automatic advancement of the pointer during write operations.
"""
assert len(ZERO_FILL_BUFFER) == WRITE_BUF_SIZE
assert WRITE_BUF_SIZE > 0
assert file_handle is not None
# Loop and perform writes of WRITE_BUF_SIZE bytes or less.
# Continue until write_length_bytes have been written.
# There is no need to explicitly move the file pointer while
# writing. We are writing contiguously.
while write_length_bytes > 0:
if write_length_bytes >= WRITE_BUF_SIZE:
write_string = ZERO_FILL_BUFFER
write_length_bytes -= WRITE_BUF_SIZE
else:
write_string = ZERO_FILL_BUFFER[:write_length_bytes]
write_length_bytes = 0
# Write buffer to file.
# The WriteFile operation automatically advances the file pointer
# by the number of bytes written (bytes_written).
# logger.debug("Write %d bytes", len(write_string))
_, bytes_written = WriteFile(file_handle, write_string)
if bytes_written != len(write_string):
raise OSError(
f'incomplete wipe write: {bytes_written} of '
f'{len(write_string)} bytes')
# Ensure all data is written to disk, not just cached in memory
FlushFileBuffers(file_handle)
def wipe_file_direct(file_handle, extents, cluster_size, file_size):
"""Wipe a file by directly writing zeros to its clusters.
This function overwrites the file's data by writing zeros to each of its
clusters on disk. The process works as follows:
1. The file pointer starts at position zero in the file.
2. For each extent (a contiguous range of clusters), we calculate how many
bytes to write based on the cluster size and number of clusters.
3. We write zeros to the file using the current file pointer position.
4. The file system writes to the original clusters rather than allocating
new ones because we're writing in-place with an open file handle.
5. This ensures the original data is completely overwritten on disk.
6. The file pointer automatically advances after each write operation.
If the last extent was not originally full, the file size will increase
to be a multiple of the cluster size.
If the file had no extents (very small files stored in the MFT),
we write zeros for the entire file size.
"""
if cluster_size <= 0:
raise RuntimeError(f'invalid cluster size: {cluster_size}')
# Remember that file_size measures full expanded content of the file,
# which may not always match with size on disk (eg. if file compressed).
# LockFile(handle, offsetLow, offsetHigh, lengthLow, lengthHigh)
# Split 64-bit file_size into low and high 32-bit DWORDs for 64-bit file support
LockFile(file_handle, 0, 0, file_size & 0xFFFFFFFF,
(file_size >> 32) & 0xFFFFFFFF)
if extents:
# Use size on disk to determine how many clusters of zeros we write.
# Sum every extent so a fragmented file is fully overwritten, not just
# its last extent.
write_length = 0
for lcn_start, lcn_end in extents:
write_length += (lcn_end - lcn_start + 1) * cluster_size
else:
# Special case - file so small it can be contained within the
# directory entry in the MFT part of the disk.
# logger.debug("Wiping tiny file that fits entirely on MFT")
write_length = file_size
write_zero_fill(file_handle, write_length)
# Wipe an extent by making calls to the defrag API.
def wipe_extent_by_defrag(volume_handle, lcn_start, lcn_end, cluster_size,
total_clusters, tmp_file_path):
"""Wipe disk clusters by creating a zero-filled file and moving it to target location.
This function uses the Windows Defragmentation API to precisely overwrite specific
clusters on disk. The process works as follows:
1. Check if target clusters are free using the volume bitmap
2. If clusters are allocated or the extent is too large, split into smaller pieces
3. Create a zero-filled temporary file
4. Move the temporary file's clusters to the target location on disk
Args:
volume_handle: Handle to the volume containing the clusters
lcn_start: Starting Logical Cluster Number (absolute position on disk)
lcn_end: Ending Logical Cluster Number (inclusive)
cluster_size: Size of each cluster in bytes
total_clusters: Total number of clusters on the volume
tmp_file_path: Path to use for temporary zero-filled file
Returns:
bool: True if wiping succeeded, False otherwise
"""
if cluster_size <= 0:
raise RuntimeError(f'invalid cluster size: {cluster_size}')
logger.debug("Examining extent from %d to %d for wipe...",
lcn_start, lcn_end)
write_length = (lcn_end - lcn_start + 1) * cluster_size
# Check the state of the volume bitmap for the extent we want to
# overwrite. If any sectors are allocated, reduce the task
# into smaller parts.
# We also reduce to smaller pieces if the extent is larger than
# 2 megabytes. For no particular reason except to avoid the entire
# request failing because one cluster became allocated.
volume_bitmap, _bitmap_size = get_volume_bitmap(volume_handle,
total_clusters)
# This option simulates another process that grabs clusters on disk
# from time to time.
# It should be moved away after QA is complete.
if not SIMULATE_CONCURRENCY:
count_free, count_allocated = check_extents(
[(lcn_start, lcn_end)], volume_bitmap)
else:
count_free, count_allocated = check_extents_concurrency(
[(lcn_start, lcn_end)], volume_bitmap,
tmp_file_path, volume_handle, total_clusters)
if count_allocated > 0 and count_free == 0:
return False
if count_allocated > 0 or write_length > WRITE_BUF_SIZE * 4:
if lcn_start >= lcn_end:
return False
for split_s, split_e in split_extent(lcn_start, lcn_end):
wipe_extent_by_defrag(volume_handle, split_s, split_e,
cluster_size, total_clusters,
tmp_file_path)
return True
# Put the zero-fill file in place.
file_handle = CreateFile(tmp_file_path, GENERIC_READ | GENERIC_WRITE,
0, None, CREATE_ALWAYS,
FILE_ATTRIBUTE_HIDDEN, None)
write_zero_fill(file_handle, write_length)
new_extents = get_extents(file_handle)
# We know the original extent was contiguous.
# The new zero-fill file may not be contiguous, so it requires a
# loop to be sure of reaching the end of the new file's clusters.
new_vcn = 0
target_clusters = lcn_end - lcn_start + 1
clusters_moved = 0
for new_lcn_start, new_lcn_end in new_extents:
# logger.debug("Zero-fill wrote from %d to %d",
# new_lcn_start, new_lcn_end)
cluster_count = new_lcn_end - new_lcn_start + 1
# Prevent moving more clusters than necessary, which can happen if
# the file system allocates more clusters than the file size.
remaining_target = target_clusters - clusters_moved
if remaining_target <= 0:
break
count_to_move = min(cluster_count, remaining_target)
cluster_dest = lcn_start + clusters_moved
if new_lcn_start != cluster_dest:
logger.debug("Move %d clusters to %d",
count_to_move, cluster_dest)
try:
move_file(volume_handle, file_handle, new_vcn,
cluster_dest, count_to_move)
except Exception as e:
# Move file failed, probably because another process
# has allocated a cluster on disk.
# Break into smaller pieces and do what we can.
logger.debug("!! Move encountered an error: %s !!", e)
CloseHandle(file_handle)
if lcn_start >= lcn_end:
return False
for split_s, split_e in split_extent(lcn_start, lcn_end):
wipe_extent_by_defrag(volume_handle, split_s, split_e,
cluster_size, total_clusters,
tmp_file_path)
return True
else:
# If Windows put the zero-fill extent on the exact clusters we
# intended to place it, no need to attempt a move.
logging.debug("No need to move extent from %d",
new_lcn_start)
new_vcn += cluster_count
clusters_moved += count_to_move
CloseHandle(file_handle)
DeleteFile(tmp_file_path)
return True
def clean_up(file_handle, volume_handle, tmp_file_path):
"""Safely close open handles and delete temporary files.
Args:
file_handle: Handle to an open file
volume_handle: Handle to an open volume
tmp_file_path: Path to a temporary file that should be deleted
This function handles exceptions internally to ensure cleanup operations
continue even if individual operations fail.
"""
try:
if file_handle:
CloseHandle(file_handle)
if volume_handle:
CloseHandle(volume_handle)
if tmp_file_path:
DeleteFile(tmp_file_path)
except Exception:
logger.debug('error while cleaning up wipe handles', exc_info=True)
def file_wipe(file_name):
"""Main function to wipe a file.
Args:
file_name: Path to the file to be wiped
This function handles the entire wiping process, including:
1. Opening the file and volume
2. Obtaining file and volume information
3. Obtaining original extents of the file
4. Wiping the file
5. Closing handles and deleting temporary files
Raises:
Exception: If any step fails, the function will raise an exception
"""
# add \\?\ if it does not exist to support Unicode and long paths
file_name = extended_path(file_name)
check_os()
# win_version, _ = determine_win_version()
volume = volume_from_file(file_name)
volume_info = get_volume_information(volume)
cluster_size = (volume_info.sectors_per_cluster *
volume_info.bytes_per_sector)
# Track handles so an exception below still releases them; each is
# nulled out right after its own CloseHandle to avoid double-closing.
file_handle = None
volume_handle = None
tmp_file_path = None
try:
file_handle = open_file(file_name)
file_size, is_special = get_file_basic_info(file_name, file_handle)
orig_extents = get_extents(file_handle, True, file_name)
if is_special:
bridged_extents = list(logical_ranges_to_extents(
get_extents(file_handle, False, file_name), True))
CloseHandle(file_handle)
file_handle = None
# logger.debug('Original extents: %s', orig_extents)
volume_handle = obtain_readwrite(volume)
attrs = GetFileAttributesW(file_name)
if attrs & FILE_ATTRIBUTE_READONLY:
# Remove read-only attribute to avoid "access denied" in CreateFileW().
SetFileAttributesW(file_name, attrs & ~FILE_ATTRIBUTE_READONLY)
file_handle = open_file(file_name, GENERIC_READ | GENERIC_WRITE)
if not is_special:
# Direct overwrite when it's a regular file.
# logger.info("Attempting direct file wipe.")
wipe_file_direct(file_handle, orig_extents,
cluster_size, file_size)
new_extents = get_extents(file_handle, True, file_name)
CloseHandle(file_handle)
file_handle = None
# logger.debug('New extents: %s', new_extents)
if orig_extents == new_extents:
return
# Expectation was that extents should be identical and file is wiped.
# If OS didn't give that to us, continue below and use defrag wipe.
# Any extent within new_extents has now been wiped by above.
# It can be subtracted from the orig_extents list, and now we will
# just clean up anything not yet overwritten.
# Materialize the generator: it is iterated again below by
# poll_clusters_freed and the wipe loop, and a generator would be
# exhausted after the first pass, leaving clusters un-wiped.
orig_extents = list(extents_a_minus_b(orig_extents, new_extents))
else:
# File needs special treatment. We can't just do a basic overwrite.
# First we will truncate it. Then chase down the freed clusters to
# wipe them, now that they are no longer part of the file.
truncate_file(file_handle)
CloseHandle(file_handle)
file_handle = None
# Poll to confirm that our clusters were freed.
poll_clusters_freed(volume_handle, volume_info.total_clusters,
orig_extents)
# Chase down all the freed clusters we can, and wipe them.
# logger.debug("Attempting defrag file wipe.")
# Put the temp file in the same folder as the target wipe file.
# Should be able to write this path if user can write the wipe file.
tmp_file_path = os.path.dirname(file_name) + os.sep + TMP_FILE_NAME
if is_special:
orig_extents = choose_if_bridged(volume_handle,
volume_info.total_clusters,
orig_extents, bridged_extents)
for lcn_start, lcn_end in orig_extents:
wipe_extent_by_defrag(volume_handle, lcn_start, lcn_end,
cluster_size, volume_info.total_clusters,
tmp_file_path)
finally:
# Closes whatever is still open; clean_up() swallows its own
# errors, so the original exception still propagates.
clean_up(file_handle, volume_handle, tmp_file_path)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Wipe.py 0000664 0001750 0001750 00000062715 15265023030 014210 0 ustar 00z z # 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.
"""
File wiping
"""
import atexit
import ctypes
import errno
import os
import string
import struct
import tempfile
import time
import warnings
import logging
import bleachbit
from bleachbit import IS_LINUX, IS_MAC, IS_POSIX, IS_WINDOWS
from bleachbit.Language import get_text as _
if IS_WINDOWS:
# pylint: disable=import-error
from win32com.shell.shell import IsUserAnAdmin
if IS_POSIX:
import fcntl
logger = logging.getLogger(__name__)
# Same buffer size as WindowsWipe.py.
WRITE_BUF_SIZE = 512 * 1024 # 512 kilobytes
ZERO_FILL_BUFFER = bytes(WRITE_BUF_SIZE)
FILENAME_CHARS = string.ascii_lowercase + \
string.digits + '_.-+~!@#$%^&()=[]{},'
if bleachbit.FS_CASE_SENSITIVE:
FILENAME_CHARS += string.ascii_uppercase
WINDOWS_RESERVED_FILENAMES = {
'CON',
'PRN',
'AUX',
'NUL',
'CONIN$',
'CONOUT$',
}
WINDOWS_RESERVED_FILENAMES.update(
{'COM%d' % number for number in range(1, 10)})
WINDOWS_RESERVED_FILENAMES.update(
{'LPT%d' % number for number in range(1, 10)})
def __random_string(length):
"""Return random alphanumeric characters of given length"""
# Keep secrets off the startup path: it drags in hmac and _hashlib
import secrets # pylint: disable=import-outside-toplevel
return ''.join(secrets.choice(FILENAME_CHARS)
for i in range(length))
def __valid_random_filename(filename):
"""Check if a filename is valid for use as a temporary file.
This function is intended for limited scope for use with
__random_string(), so this function assumes
- Input characters are limited to FILENAME_CHARS.
- No null bytes, path separators, or low ASCII characters.
- The argument is a filename (not a path).
On Windows, this checks for invalid characters, reserved names, and other
restrictions.
"""
if not filename:
return False
if filename in ('.', '..'):
return False
if not IS_WINDOWS:
return True
if filename.endswith((' ', '.')):
return False
# After updating to Python 3.13, use os.path.isreserved() here.
if filename.rstrip(' .').split('.', 1)[0].upper() in WINDOWS_RESERVED_FILENAMES:
return False
if set(filename) & set('<>:"/\\|?*'):
return False
return True
def detect_orphaned_wipe_files(shred_drives=None):
"""Detect orphaned temporary files from interrupted wipe_path operations.
These files are created by wipe_path() to fill free disk space with zeros.
Pass shred_drives from a worker thread: Options.get_list() is not locked.
Detection criteria:
- Located in directories from options shred_drives
- Filename starts with 'empty_'
- Filename has >100 characters (due to random suffix)
- No file extension
- Contains only null bytes when sampled
Returns:
list: Paths to detected orphaned wipe files
"""
orphaned_files = []
if shred_drives is None:
from bleachbit.Options import options
shred_drives = options.get_list('shred_drives')
if not shred_drives:
return orphaned_files
for drive_path in shred_drives:
if not os.path.isdir(drive_path):
continue
try:
for entry in os.scandir(drive_path):
if not entry.is_file():
continue
filename = entry.name
# Check criteria: starts with 'empty_', >100 chars, no extension
if not filename.startswith('empty_'):
continue
if len(filename) <= 100:
continue
if '.' in filename:
continue
# Read a small sample to check for null bytes
try:
with open(entry.path, 'rb') as f:
sample = f.read(4096)
if sample and not sample.strip(b'\0'):
orphaned_files.append(entry.path)
except (IOError, OSError) as e:
logger.debug('Could not read file %s: %s', entry.path, e)
except (IOError, OSError) as e:
logger.debug('Could not scan directory %s: %s', drive_path, e)
return orphaned_files
def fitrim(pathname):
"""Perform FITRIM (fstrim) to discard unused blocks on a supported filesystem
pathname: path to directory
"""
if not IS_POSIX:
return False
fitrim_id = 0xC0185879
try:
try:
# Open the directory
fd = os.open(pathname, os.O_RDONLY)
# Get filesystem stats to determine range
stats = os.statvfs(pathname)
# struct fstrim_range {
# __u64 start;
# __u64 len;
# __u64 minlen;
# };
# Set range to the entire filesystem
trim_range = struct.pack(
'QQQ', 0, stats.f_blocks * stats.f_bsize, 0)
# pylint: disable-next=possibly-used-before-assignment
fcntl.ioctl(fd, fitrim_id, trim_range)
logger.debug(
"Successfully performed FITRIM on filesystem at %s", pathname)
return True
finally:
os.close(fd)
except Exception as e:
if os.geteuid() == 0:
logger.info("FITRIM failed: %s", e)
else:
logger.debug("FITRIM failed: %s", e)
return False
def sync():
"""Flush file system buffers. sync() is different than fsync()"""
if IS_LINUX:
rc = ctypes.cdll.LoadLibrary('libc.so.6').sync()
if 0 != rc:
logger.error('sync() returned code %d', rc)
elif IS_MAC:
# On macOS, sync() is in libSystem.B.dylib
libc = ctypes.cdll.LoadLibrary('libSystem.B.dylib')
libc.sync()
elif IS_WINDOWS:
# pylint: disable=protected-access
ctypes.cdll.LoadLibrary('msvcrt.dll')._flushall()
def wipe_write(path):
"""Overwrite a file's contents with zeros without truncating it.
Return the open file handle; the caller must close it."""
from bleachbit.FileUtilities import getsize, _open_nofollow_fd
size = getsize(path)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
try:
fd = _open_nofollow_fd(path, flags)
except OSError as e:
# Only retry on a genuine permission error; a symlink also raises
# EACCES here, and chmod() on a symlink path follows it, which
# would mutate an attacker-controlled target's permissions.
if e.errno == errno.EACCES and not os.path.islink(path):
os.chmod(path, 0o200) # user write only
fd = _open_nofollow_fd(path, flags)
else:
raise
f = os.fdopen(fd, 'wb')
try:
blanks = memoryview(ZERO_FILL_BUFFER)
while size > 0:
f.write(blanks[:min(size, WRITE_BUF_SIZE)])
size -= WRITE_BUF_SIZE
f.flush() # flush to OS buffer
os.fsync(f.fileno()) # force write to disk
except BaseException:
f.close()
raise
return f
def wipe_contents(path):
"""Wipe files contents
https://en.wikipedia.org/wiki/Data_remanence
2006 NIST Special Publication 800-88 (p. 7): "Studies have
shown that most of today's media can be effectively cleared
by one overwrite"
"""
from bleachbit.FileUtilities import truncate_f
# pylint: disable-next=possibly-used-before-assignment
if IS_WINDOWS and IsUserAnAdmin():
# The import placement here avoids a circular import.
from bleachbit.WindowsWipe import file_wipe, UnsupportedFileSystemError
from bleachbit.FileUtilities import pywinerror
try:
file_wipe(path)
except pywinerror as e:
# 32=The process cannot access the file because it is being used by another process.
# 33=The process cannot access the file because another process has
# locked a portion of the file.
if not e.winerror in (32, 33):
# handle only locking errors
raise
# Try to truncate the file. This makes the behavior consistent
# with Linux and with Windows when IsUserAdmin=False.
if os.path.islink(path):
logger.debug('refusing to truncate a link: %s', path)
else:
try:
with open(path, 'wb') as f:
truncate_f(f)
except IOError as e2:
if errno.EACCES == e2.errno:
# Common when the file is locked
# Errno 13 Permission Denied
pass
# translate exception to mark file to deletion in Command.py
# WindowsError is a real builtin, reachable only under IS_WINDOWS.
# pylint: disable-next=undefined-variable
raise WindowsError(e.winerror, e.strerror) from e
except UnsupportedFileSystemError:
warnings.warn(
_('There was at least one file on a file system that does not support advanced overwriting.'), UserWarning)
f = wipe_write(path)
else:
# The wipe succeeded and already overwrote the file in place.
# Reopen with 'wb' to truncate it to zero. Re-check for a link:
# file_wipe() checked before the wipe, so one could have been
# planted since.
if os.path.islink(path):
raise OSError(
errno.EACCES, 'refusing to truncate a link', path)
# f is closed by the try/finally below.
# pylint: disable-next=consider-using-with
f = open(path, 'wb')
else:
f = wipe_write(path)
try:
truncate_f(f)
finally:
f.close()
def wipe_name(pathname1):
"""Wipe the original filename and return the new pathname
File systems vary in how they store a pathname and how they respond
to renaming. In general, renaming to a longer name can cause more
data remnance.
This function tries to rename the file a single time to a random
name with the identical length.
"""
(head, tail) = os.path.split(pathname1)
target_length = len(tail)
attempt_count = 0
while True:
attempt_count += 1
if attempt_count > 100:
logger.info('exhausted same-length rename: %s', pathname1)
pathname2 = pathname1
break
new_tail = __random_string(target_length)
if not __valid_random_filename(new_tail):
continue
pathname2 = os.path.join(head, new_tail)
if os.path.lexists(pathname2):
continue
try:
os.rename(pathname1, pathname2)
break
except OSError as e:
if e.errno in (errno.EACCES, errno.EPERM, errno.EROFS):
pathname2 = pathname1
break
continue
return pathname2
def is_wipe_path_readonly(pathname, fs_info=None):
"""Quick check for some directories that should not be wiped
Returns True for cases including:
- CD-ROM and optical drives (including writable types)
- read-only USB flash drive
- special file systems like /dev, /proc, snap squashfs
This function is not exhaustive: it may have false negatives,
but it should not have false positives, which is why it is
not named `is_wipe_path_writable`.
"""
# pylint: disable=import-outside-toplevel
from bleachbit.FileUtilities import get_filesystem_type
if IS_POSIX:
# /dev exists on FreeBSD, Linux, and macOS
# /proc exists on Linux and FreeBSD
real = os.path.realpath(pathname)
if any(real == dirname or real.startswith(dirname + os.sep)
for dirname in ('/dev', '/proc')):
return True
if fs_info is None:
fs_info = get_filesystem_type(pathname)
if fs_info.is_cdrom:
return True
if IS_WINDOWS:
return fs_info.is_readonly
if fs_info.fstype in ('devfs', 'devpts', 'devtmpfs', 'proc', 'procfs'):
return True
return fs_info.is_readonly
def wipe_path(pathname, idle=False):
"""Wipe the free space in the path
This function uses an iterator to update the GUI."""
from bleachbit.FileUtilities import delete, free_space, get_filesystem_type, truncate_f
def temporaryfile():
# reference
# https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
maxlen = 185
f = None
tmp_max_orig = tempfile.TMP_MAX
if IS_WINDOWS:
# On Windows/Python 3.12, tempfile retries PermissionError up to
# TMP_MAX (2^31-1) times when os.access() wrongly reports a
# read-only mount as writable; bound it so wiping fails fast.
# On POSIX, tempfile raises immediately instead of retrying.
tempfile.TMP_MAX = min(tmp_max_orig, 20)
try:
while True:
try:
# The temporary file outlives the retry loop and is deleted at exit.
# pylint: disable-next=consider-using-with
f = tempfile.NamedTemporaryFile(
dir=pathname,
suffix=__random_string(maxlen),
delete=False,
prefix="empty_"
)
# In case the application closes prematurely, make sure this
# file is deleted
atexit.register(
delete, f.name, allow_shred=False, ignore_missing=True)
break
except OSError as e:
if e.errno in (errno.ENAMETOOLONG, errno.ENOSPC, errno.ENOENT, errno.EINVAL):
# ext3 on Linux 3.5 returns ENOSPC if the full path is greater than 264.
# Shrinking the size helps.
# Microsoft Windows returns ENOENT "No such file or directory"
# or EINVAL "Invalid argument"
# when the path is too long such as %TEMP% but not in C:\
if maxlen > 5:
maxlen -= 5
continue
raise
finally:
tempfile.TMP_MAX = tmp_max_orig
return f
def estimate_completion():
"""Return (percent, seconds) to complete"""
remaining_bytes = free_space(pathname)
done_bytes = start_free_bytes - remaining_bytes
# maybe user deleted large file after starting wipe
done_bytes = max(done_bytes, 0)
if 0 == start_free_bytes:
done_percent = 0
else:
done_percent = 1.0 * done_bytes / (start_free_bytes + 1)
done_time = time.time() - start_time
rate = done_bytes / (done_time + 0.0001) # bytes per second
remaining_seconds = int(remaining_bytes / (rate + 0.0001))
return 1, done_percent, remaining_seconds
# Get the file system type from the given path
fs_info = get_filesystem_type(pathname)
# TRANSLATORS: Debug log message shown during file wiping. 'Wiping' is a
# present participle (ongoing action) refering to secure overwrite.
# %(pathname)s is the file/directory path; %(fstype)s is the file system type
# (e.g., ext3, ntfs). Do not translate placeholders.
logger.debug(_("Wiping path %(pathname)s with file system type %(fstype)s"),
{"pathname": pathname, "fstype": fs_info.fstype})
if not os.path.isdir(pathname):
logger.error(
_("Path to wipe must be an existing directory: %s"), pathname)
return
if is_wipe_path_readonly(pathname, fs_info):
logger.warning(
'Not wiping %s: the %s file system is read-only.',
pathname, fs_info.fstype)
return
if IS_POSIX:
# Another user could tamper with wipe files in a world-writable dir
from bleachbit.PathUtils import is_world_writable
if is_world_writable(pathname):
logger.warning(
# TRANSLATORS: Warning in the log. BleachBit wipes free space by
# creating temporary files in the chosen folder. This folder can be
# changed by any user on the computer, so another user could tamper
# with those files. %s is the folder path.
_("Wiping free space here is unsafe because any user can change this folder: %s"), pathname)
if fs_info.fstype in ('ext4', 'btrfs'):
fitrim(pathname)
# FAT12/16/32 limit file size to 4 GB; exFAT does not. Linux reports
# 'vfat', while Windows reports 'FAT' or 'FAT32'.
is_fat = fs_info.fstype.lower() in ('vfat', 'fat', 'fat12', 'fat16', 'fat32')
files = []
total_bytes = 0
start_free_bytes = free_space(pathname)
start_time = time.time()
done_wiping = False
disk_full = False
try:
# Because FAT32 has a maximum file size of 4,294,967,295 bytes,
# this loop is sometimes necessary to create multiple files.
while True:
try:
logger.debug(
# TRANSLATORS: Status message while wiping a drive's empty space.
_('Creating new, temporary file for wiping empty space.'))
f = temporaryfile()
except OSError as e:
# Linux gives errno 24
# Windows gives errno 28 No space left on device
# Linux gives errno 122 Disk quota exceeded (EDQUOT)
if e.errno in (errno.EMFILE, errno.ENOSPC, errno.EDQUOT):
break
if e.errno == errno.EROFS or \
(IS_WINDOWS and e.errno in (errno.EACCES, errno.EPERM, errno.EEXIST)):
# Read-only mount missed by the check above. POSIX always
# reports it as EROFS, so EACCES/EPERM there are genuine
# permission errors worth raising. On Windows, tempfile
# retries a denied write and finally raises EEXIST.
logger.warning(
'Cannot create a temporary file for wiping; '
'the file system may be read-only: %s', e)
break
raise
# Remember to delete
files.append(f)
last_idle = time.time()
# Write large blocks to quickly fill the disk.
blanks = b'\0' * 65536
writtensize = 0
while True:
try:
if not is_fat:
f.write(blanks)
# On Ubuntu, the size of file should be less than
# 4GB. If not, there should be EFBIG error, so the
# maximum file size should be less than or equal to
# "4GB - 65536byte".
elif writtensize < 4 * 1024 * 1024 * 1024 - 65536:
writtensize += f.write(blanks)
else:
break
except IOError as e:
# Windows 11 25H2 (10.0.26220.9472) raised ENOSPC (not EFBIG) on
# 4GB VFAT, but the `is_fat` guard above should prevent it.
if e.errno in (errno.ENOSPC, errno.EDQUOT):
if len(blanks) > 1:
# Try writing smaller blocks
blanks = blanks[0:len(blanks) // 2]
else:
disk_full = True
break
elif e.errno == errno.EFBIG:
# Normally the `is_fat` guard stops first, so this
# is a fallback.
# Writes beyond a filesystem's s_maxbytes fail with
# EFBIG (generic_write_check_limits).
# VFAT sets s_maxbytes = 0xffffffff (4 GiB - 1).
# RLIMIT_FSIZE produces the same errno on any FS.
# EFBIG with VFAT verified with Linux 6.12.
break
else:
raise
if idle and (time.time() - last_idle) > 2:
# Keep the GUI responding, and allow the user to abort.
# Also display the ETA.
yield estimate_completion()
last_idle = time.time()
# Write to OS buffer
try:
f.flush()
except IOError as e:
# IOError: [Errno 28] No space left on device
# seen on Microsoft Windows XP SP3 with ~30GB free space but
# not on another XP SP3 with 64MB free space
# [Errno 122] Disk quota exceeded (EDQUOT) is treated the same
# way because the file is as full as the quota allows.
if e.errno in (errno.ENOSPC, errno.EDQUOT):
disk_full = True
else:
logger.error(
_("Error #%d when flushing the file buffer."), e.errno)
try:
os.fsync(f.fileno()) # write to disk
except OSError as e:
if e.errno in (errno.ENOSPC, errno.EDQUOT):
disk_full = True
else:
raise
# For statistics
total_bytes += f.tell()
# sync to disk
sync()
# statistics
elapsed_sec = time.time() - start_time
rate_mbs = (total_bytes / (1000 * 1000)) / \
elapsed_sec if elapsed_sec else 0
# TRANSLATORS: Debug message summarizing a write operation. All placeholders
# are numeric: %(file_count)d = integer number of files written;
# %(total_bytes)d = integer total bytes written; %(elapsed_sec)d = integer
# elapsed seconds; %(rate_mbs).2f = decimal megabytes per second (e.g., 3.14).
# Do not translate variables.
logger.debug(_('Wrote %(file_count)d files and %(total_bytes)d bytes '
'in %(elapsed_sec)d seconds at %(rate_mbs).2f MB/s'),
{"file_count": len(files), "total_bytes": total_bytes,
"elapsed_sec": int(elapsed_sec), "rate_mbs": rate_mbs})
# how much free space is left (should be near zero)
if IS_POSIX:
# pylint: disable=no-member
stats = os.statvfs(pathname)
# TRANSLATORS: Debug message showing disk space available to regular (non-root) users.
# %(bytes)d is an integer count of bytes free; %(inodes)d is an integer count of inodes free.
# Do not translate variables.
logger.debug(_("%(bytes)d bytes and %(inodes)d inodes available to non-super-user"),
{"bytes": stats.f_bsize * stats.f_bavail, "inodes": stats.f_favail})
# TRANSLATORS: Debug message showing disk space available to the root (admin) user.
# %(bytes)d is an integer count of bytes free; %(inodes)d is an integer count of inodes free.
# Do not translate variables.
logger.debug(_("%(bytes)d bytes and %(inodes)d inodes available to super-user"),
{"bytes": stats.f_bsize * stats.f_bfree, "inodes": stats.f_ffree})
# If no bytes were written to this file, then do not try to create another file.
# Linux allows writing several 4K files when free_space() = 0,
# so do not check free_space() < 1.
# See
# * https://github.com/bleachbit/bleachbit/issues/502
# Replace `f.tell() < 2` with `len(blanks) < 2`
# * https://github.com/bleachbit/bleachbit/issues/1051
# Replace `len(blanks) < 2` with `estimated_free_space < 2`
if disk_full:
logger.debug('Disk full (ENOSPC/EDQUOT), stopping wipe')
break
estimated_free_space = start_free_bytes - total_bytes
if estimated_free_space < 2:
logger.debug(
'Estimated free space %s is less than 2 bytes, breaking', estimated_free_space)
break
done_wiping = True
finally:
# Ensure files are closed and deleted even if an exception
# occurs or generator is not fully consumed.
# Truncate and close files.
for f in files:
if done_wiping:
try:
truncate_f(f)
except Exception as e:
logger.error(
'After wiping, truncating file %s failed: %s', f.name, e)
while True:
try:
# Nikita: I noticed a bug that prevented file handles from
# being closed on FAT32. It sometimes takes two .close() calls
# to do actually close (and therefore delete) a temporary file
f.close()
break
except IOError as e:
if e.errno == 0:
logger.debug(
_("Encountered unknown error #0 while truncating file."))
time.sleep(0.1)
# explicitly delete
try:
delete(f.name, ignore_missing=True)
except Exception as e:
logger.error(
'After wiping, error deleting file %s: %s', f.name, e)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/Worker.py 0000775 0001750 0001750 00000043044 15265023030 014552 0 ustar 00z z # 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.
"""
Perform the preview or delete operations
"""
# standard imports
import errno
import logging
import math
import os
import sys
import time
import warnings
# first party imports
from bleachbit import DeepScan, FileUtilities, IS_WINDOWS
from bleachbit.Cleaner import backends
from bleachbit.Constant import EMPTY_SPACE_WARNING
from bleachbit.GtkShim import ignore_pygobject_asyncio_warnings
from bleachbit.Language import get_text as _, nget_text as ngettext
from bleachbit.FileUtilities import close_delete_parent_lock
from bleachbit.Process import process_cache
logger = logging.getLogger(__name__)
# Delayed ops run last, sorted ascending: memory, then empty space
_DELAY_PRIORITY = {'empty_space': 100, 'memory': 99}
def format_minutes_remaining(msg2, eta_mins):
"""Format the localized time-remaining message.
Some translations (e.g., Arabic and Hebrew) intentionally omit the
%d placeholder for certain plural forms, so tolerate a failed
formatting and show the message as-is. (Issue #2305)
"""
try:
return msg2 % eta_mins
except (TypeError, ValueError):
return msg2
class Worker:
"""Perform the preview or delete operations"""
def __init__(self, ui, really_delete, operations):
"""Create a Worker
ui: an instance with methods
append_text()
update_progress_bar()
update_total_size()
update_item_size()
worker_done()
really_delete: (boolean) preview or make real changes?
operations: dictionary where operation-id is the key and
operation-id are values
"""
self.ui = ui
self.really_delete = really_delete
assert isinstance(operations, dict)
self.operations = operations
self.size = 0
self.total_bytes = 0
self.total_deleted = 0
self.total_errors = 0
self.total_special = 0 # special operations
self.yield_time = None
self.is_aborted = False
if not self.operations:
raise RuntimeError("No work to do")
def abort(self):
"""Stop the preview/cleaning operation"""
self.is_aborted = True
def print_exception(self, operation):
"""Display exception"""
# TRANSLATORS: This indicates an error. The special keyword
# %(operation)s will be replaced by 'firefox' or 'opera' or
# some other cleaner ID. The special keyword %(msg)s will be
# replaced by a message such as 'Permission denied.'
err = _("Exception while running operation '%(operation)s': '%(msg)s'") \
% {'operation': operation, 'msg': str(sys.exc_info()[1])}
logger.error(err, exc_info=True)
self.total_errors += 1
def execute(self, cmd, operation_option):
"""Execute or preview the command"""
ret = None
try:
for ret in cmd.execute(self.really_delete):
if ret is True or isinstance(ret, tuple):
# Temporarily pass control to the GTK idle loop,
# allow user to abort, and
# display progress (if applicable).
yield ret
if self.is_aborted:
return
except SystemExit:
logger.debug('%s raised SystemExit, which we do not honor', cmd)
except Exception as e:
if isinstance(e, OSError) and e.errno == errno.ENOENT:
# ENOENT (Error NO ENTry) means file not found.
# Normalize Windows extended paths (\\?\) before logging
# so the user sees the canonical form and tests avoid
# double-backslash sequences.
filename = e.filename
if IS_WINDOWS and filename:
filename = FileUtilities.extended_path_undo(filename)
# Do not show traceback.
logger.error(_("File not found: %s"), filename)
elif isinstance(e, OSError) and e.errno == errno.EACCES:
# EACCES (Error ACCESS) means access denied.
# Do not show traceback.
if e.strerror == "Access denied in delete_locked_file()":
# This comes from Windows.delete_locked_file()
logger.error(
_("Access denied when flagging file for later delete: %s"), e.filename)
elif e.strerror == "Access denied in delete_registry_value()":
# This comes from Windows.delete_registry_value()
logger.error(
_("Access denied when deleting registry value: %s"), e.filename)
elif e.strerror == "Access denied in delete_registry_key()":
# This comes from Windows.delete_registry_key()
logger.error(
_("Access denied when deleting registry key: %s"), e.filename)
else:
logger.error(_("Access denied: %s"), e.filename)
elif (e.__class__.__module__ == 'sqlite3'
and e.__class__.__name__ == 'OperationalError'
and str(e).startswith('database is locked')):
logger.error(_("Database is locked: %s"),
getattr(cmd, 'path', cmd))
else:
# For other errors, show the traceback.
# TRANSLATORS: The special keyword {operation_option} will be
# replaced by a cleaner and option such as 'firefox.cache'.
# The special keyword {command} will be replaced by the
# cleaning action that failed. Do not translate the placeholders.
msg = _('Error: {operation_option}: {command}')
data = {'command': cmd, 'operation_option': operation_option}
logger.error(msg.format(**data), exc_info=True)
self.total_errors += 1
else:
if ret is None:
return
if isinstance(ret['size'], int):
size = FileUtilities.bytes_to_human(ret['size'])
self.size += ret['size']
self.total_bytes += ret['size']
else:
size = "?B"
path = ret['path'] or ''
line = "%s %s %s\n" % (ret['label'], size, path)
self.total_deleted += ret['n_deleted']
self.total_special += ret['n_special']
if ret['label']:
# the label may be a hidden operation
# (e.g., win.shell.change.notify)
self.ui.append_text(line)
def clean_operation(self, operation):
"""Perform a single cleaning operation"""
operation_options = self.operations[operation]
assert isinstance(operation_options, list)
logger.debug("clean_operation('%s'), options = '%s'",
operation, operation_options)
if not operation_options:
return
if self.really_delete and backends[operation].is_process_running():
# TRANSLATORS: %s expands to a name such as 'Firefox' or 'System'.
err = _("%s cannot be cleaned because it is currently running. Close it, and try again.") \
% backends[operation].get_name()
self.ui.append_text(err + "\n", 'error')
self.total_errors += 1
return
self.yield_time = time.time()
total_size = 0
for option_id in operation_options:
self.size = 0
assert isinstance(option_id, str)
# normal scan
for cmd in backends[operation].get_commands(option_id):
for ret in self.execute(cmd, '%s.%s' % (operation, option_id)):
if ret is True:
# Return control to PyGTK idle loop to keep
# it responding allow the user to abort
self.yield_time = time.time()
yield True
if self.is_aborted:
break
if time.time() - self.yield_time > 0.25:
if self.really_delete:
self.ui.update_total_size(self.total_bytes)
yield True
self.yield_time = time.time()
self.ui.update_item_size(operation, option_id, self.size)
total_size += self.size
# deep scan
for (path, search) in backends[operation].get_deep_scan(option_id):
if '' == path:
path = os.path.expanduser('~')
if search.command not in ('delete', 'shred'):
raise NotImplementedError(
'Deep scan only supports deleting or shredding now')
if path not in self.deepscans:
self.deepscans[path] = []
self.deepscans[path].append(search)
self.ui.update_item_size(operation, -1, total_size)
def run_delayed_op(self, operation, option_id):
"""Run one delayed operation"""
self.ui.update_progress_bar(0.0)
if 'empty_space' == option_id:
# TRANSLATORS: 'empty' means 'unallocated'
msg = _("Please wait. Wiping empty space.")
self.ui.append_text(EMPTY_SPACE_WARNING)
self.ui.append_text('\n\n')
self.ui.append_text(
# TRANSLATORS: Instruction shown while wiping a drive's empty space.
_('To stop this process, press the abort button on the toolbar and wait.'))
self.ui.append_text('\n\n')
self.ui.append_text(
# TRANSLATORS: Warning shown while wiping a drive's
# empty space.
_('If the application is force closed before the process is complete, '
'large files may remain on the disk, and they may use all available space. '
'To remove them, start BleachBit again.'))
self.ui.append_text('\n')
elif 'memory' == option_id:
msg = _("Please wait. Cleaning %s.") % _("Memory")
else:
raise RuntimeError("Unexpected option_id in delayed ops")
self.ui.update_progress_bar(msg)
for cmd in backends[operation].get_commands(option_id):
for ret in self.execute(cmd, '%s.%s' % (operation, option_id)):
if isinstance(ret, tuple):
# Display progress (for free disk space)
phase = ret[0]
# A while ago there were other phase numbers. Currently it's just 1
if phase != 1:
raise RuntimeError(
'While wiping empty space, unexpected phase %d' % phase)
percent_done = ret[1]
eta_seconds = ret[2]
self.ui.update_progress_bar(percent_done)
if isinstance(eta_seconds, int):
eta_mins = math.ceil(eta_seconds / 60)
# xgettext: no-python-format
# TRANSLATORS: %d is the estimated number of minutes remaining.
msg2 = ngettext("About %d minute remaining.",
"About %d minutes remaining.", eta_mins)
msg2 = format_minutes_remaining(msg2, eta_mins)
self.ui.update_progress_bar(msg + ' ' + msg2)
else:
self.ui.update_progress_bar(msg)
if self.is_aborted:
break
if ret is True or isinstance(ret, tuple):
# Return control to PyGTK idle loop to keep
# it responding and allow the user to abort.
yield True
def run(self):
"""Perform the main cleaning process which has these phases
1. General cleaning
2. Deep scan
3. Memory
4. Empty space"""
# Otherwise a scan from an earlier run still reports an application
# the user has just closed.
process_cache.invalidate()
self.deepscans = {}
# prioritize
self.delayed_ops = []
for operation in self.operations:
if operation not in ('system', '_gui'):
continue
for delayable, priority in _DELAY_PRIORITY.items():
if delayable in self.operations[operation]:
self.operations[operation].remove(delayable)
self.delayed_ops.append((priority, operation, delayable))
# standard operations
with warnings.catch_warnings(record=True) as ws:
# This warning system allows general warnings. Duplicate will
# be removed, and the warnings will show near the end of
# the log.
warnings.simplefilter('once')
# simplefilter('once') prepends a catch-all filter that would
# capture PyGObject's asyncio deprecation warnings and re-log
# them as red errors in the GUI. Re-install the ignore filter
# so it takes precedence over the 'once' filter.
ignore_pygobject_asyncio_warnings()
for _dummy in self.run_operations(self.operations):
# yield to GTK+ idle loop
yield True
for w in ws:
logger.warning(w.message)
# run deep scan
if self.deepscans:
yield from self.run_deep_scan()
# After standard operations and deep scan, close the lock
# of the parent directory.
close_delete_parent_lock()
# delayed operations
for _priority, operation, option_id in sorted(
self.delayed_ops, key=lambda op: op[0]):
for _ret in self.run_delayed_op(operation, option_id):
# yield to GTK+ idle loop
yield True
# print final stats
bytes_delete = FileUtilities.bytes_to_human(self.total_bytes)
if self.really_delete:
# TRANSLATORS: This refers to disk space that was
# really recovered (in other words, not a preview)
line = _("Disk space recovered: %s") % bytes_delete
else:
# TRANSLATORS: This refers to a preview (no real
# changes were made yet)
line = _("Disk space to be recovered: %s") % bytes_delete
self.ui.append_text("\n%s" % line)
if self.really_delete:
# TRANSLATORS: This refers to the number of files really
# deleted (in other words, not a preview).
line = _("Files deleted: %d") % self.total_deleted
else:
# TRANSLATORS: This refers to the number of files that
# would be deleted (in other words, simply a preview).
line = _("Files to be deleted: %d") % self.total_deleted
self.ui.append_text("\n%s" % line)
if self.total_special > 0:
# TRANSLATORS: %d is the number of special cleaning operations
# completed. Special operations are cleaning actions that do
# not delete a file, such as overwriting a file's contents,
# truncating a file, or deleting a Windows registry key.
line = _("Special operations: %d") % self.total_special
self.ui.append_text("\n%s" % line)
if self.total_errors > 0:
# TRANSLATORS: %d is the count of errors that occurred
# during the cleaning or preview operation.
line = _("Errors: %d") % self.total_errors
self.ui.append_text("\n%s" % line, 'error')
self.ui.append_text('\n')
if self.really_delete:
self.ui.update_total_size(self.total_bytes)
self.ui.worker_done(self, self.really_delete)
yield False
def run_deep_scan(self):
"""Run deep scans"""
logger.debug(' deepscans=%s', self.deepscans)
# TRANSLATORS: The "deep scan" feature searches over broad
# areas of the file system such as the user's whole home directory
# or all the system executables.
self.ui.update_progress_bar(_("Please wait. Running deep scan."))
yield True # allow GTK to update the screen
ds = DeepScan.DeepScan(self.deepscans)
for cmd in ds.scan():
if cmd is True:
yield True
continue
for _ret in self.execute(cmd, 'deepscan'):
yield True
def run_operations(self, my_operations):
"""Run a set of operations (general, memory, free disk space)"""
for count, operation in enumerate(my_operations):
self.ui.update_progress_bar(1.0 * count / len(my_operations))
name = backends[operation].get_name()
if self.really_delete:
# TRANSLATORS: %s is replaced with Firefox, System, etc.
msg = _("Please wait. Cleaning %s.") % name
else:
# TRANSLATORS: %s is replaced with Firefox, System, etc.
msg = _("Please wait. Previewing %s.") % name
self.ui.update_progress_bar(msg)
yield True # show the progress bar message now
try:
for _dummy in self.clean_operation(operation):
yield True
except BrokenPipeError:
# Propagate to the top-level handler (e.g., when the
# downstream pipe consumer like `less` closes early).
raise
except Exception:
self.print_exception(operation)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/__init__.py 0000664 0001750 0001750 00000025503 15265023030 015035 0 ustar 00z z # 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.
"""
Code that is commonly shared throughout BleachBit
"""
import os
import re
import sys
import time
from bleachbit import Log
APP_VERSION = "6.0.6"
APP_NAME = "BleachBit"
APP_URL = "https://www.bleachbit.org"
APP_COPYRIGHT = "Copyright (C) 2008-2026 Andrew Ziem"
socket_timeout = 10
if sys.version_info < (3, 8, 0):
sys.stderr.write('BleachBit requires Python version 3.8 or later\n')
sys.exit(1)
if hasattr(sys, 'frozen'):
stdout_encoding = 'utf-8'
else:
stdout_encoding = getattr(sys.stdout, 'encoding', None) or 'utf-8'
logger = Log.init_log()
_startup_t0 = time.monotonic()
_startup_profile = os.getenv(
'BLEACHBIT_STARTUP_PROFILE') in ('1', 'true', 'True')
def log_startup_time(name):
"""Log time since package import when BLEACHBIT_STARTUP_PROFILE is set"""
if not _startup_profile:
return
logger.info('startup %s: %.1f ms', name,
(time.monotonic() - _startup_t0) * 1000)
# Setting below value to false disables update notification (useful
# for packages in repositories).
online_update_notification_enabled = True
#
# Platform
#
# platform
IS_WINDOWS = os.name == 'nt'
IS_POSIX = os.name == 'posix'
IS_LINUX = sys.platform.startswith('linux')
IS_MAC = sys.platform == 'darwin'
IS_BSD = sys.platform.startswith(('freebsd', 'openbsd', 'netbsd'))
IS_FREEBSD = sys.platform.startswith('freebsd')
IS_NETBSD = sys.platform[:6] == 'netbsd'
ARCH_BITS = 64 if sys.maxsize > 2**32 else 32
def _harden_dll_search_path():
"""Drop the current directory (and PATH when frozen) from the DLL search path.
Blocks DLL-preloading privilege escalation. bootstrap() calls it before
the first DLL loads, not at import, so build tooling that imports the
package does not disturb the search path.
"""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
# Frozen builds bundle every dependency next to the exe, so restrict to
# app dir + System32 + add_dll_directory() dirs (also drops PATH).
# SetDefaultDllDirectories needs Win8+ (or 7 with KB2533623)
if hasattr(sys, 'frozen'):
LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000
try:
set_default = kernel32.SetDefaultDllDirectories
set_default.argtypes = [wintypes.DWORD]
set_default.restype = wintypes.BOOL
if set_default(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS):
return
except (AttributeError, OSError) as e:
logger.debug('SetDefaultDllDirectories is unavailable, so falling '
'back to SetDllDirectoryW: %s', e)
# drops the current directory but keeps PATH; NULL would restore the default
try:
set_dir = kernel32.SetDllDirectoryW
set_dir.argtypes = [wintypes.LPCWSTR]
set_dir.restype = wintypes.BOOL
set_dir("")
except (AttributeError, OSError) as e:
logger.debug('could not drop the current directory from the DLL '
'search path: %s', e)
# file system attributes
FS_CASE_SENSITIVE = not (IS_WINDOWS or IS_MAC)
FS_SCAN_RE_FLAGS = 0 if FS_CASE_SENSITIVE else re.IGNORECASE
#
# Paths
#
# Windows
bleachbit_exe_path = None
if hasattr(sys, 'frozen'):
# running frozen in py2exe
bleachbit_exe_path = os.path.dirname(sys.executable)
bleachbit_package_path = bleachbit_exe_path
else:
# __file__ is absolute path to __init__.py
bleachbit_package_path = os.path.dirname(__file__)
bleachbit_exe_path = os.path.dirname(bleachbit_package_path)
# license
license_filename = None
license_filenames = ('/usr/share/common-licenses/GPL-3', # Debian, Ubuntu
# Microsoft Windows
os.path.join(bleachbit_exe_path, 'COPYING'),
'/usr/share/doc/bleachbit-' + APP_VERSION + '/COPYING', # CentOS, Fedora, RHEL
'/usr/share/licenses/bleachbit/COPYING', # Fedora 21+, RHEL 7+
'/usr/share/doc/packages/bleachbit/COPYING', # OpenSUSE 11.1
'/usr/pkg/share/doc/bleachbit/COPYING', # NetBSD 5
'/usr/share/licenses/common/GPL3/license.txt') # Arch Linux
for lf in license_filenames:
if os.path.exists(lf):
license_filename = lf
break
def _home_dir():
"""Return home directory with fallback for missing HOME and passwd entry."""
home = os.getenv('HOME')
if home:
return home
# expanduser() falls back to a lookup in passwd database.
home = os.path.expanduser('~')
if home != '~':
return home
return '/tmp'
# configuration
portable_mode = False
options_dir = None
if IS_POSIX:
options_dir = os.path.join(_home_dir(), ".config/bleachbit")
elif IS_WINDOWS:
if os.path.exists(os.path.join(bleachbit_exe_path, 'bleachbit.ini')):
# portable mode
portable_mode = True
options_dir = bleachbit_exe_path
else:
# installed mode
options_dir = os.path.expandvars(r"${APPDATA}\BleachBit")
options_dir = os.environ.get('BLEACHBIT_TEST_OPTIONS_DIR', options_dir)
options_file = os.path.join(options_dir, "bleachbit.ini")
# check whether the application is running from the source tree
if not portable_mode:
paths = (
'../cleaners',
'../Makefile',
'../COPYING')
existing = (
os.path.exists(os.path.join(bleachbit_exe_path, path))
for path in paths)
portable_mode = all(existing)
# personal cleaners
personal_cleaners_dir = os.path.join(options_dir, "cleaners")
# system cleaners
# On Windows in portable mode, the bleachbit_exe_path is equal to
# options_dir, so be careful that system_cleaner_dir is not set to
# personal_cleaners_dir.
_exe_cleaners_dir = os.path.join(bleachbit_exe_path, 'cleaners')
_package_cleaners_dir = os.path.join(bleachbit_package_path, 'cleaners')
if os.path.isdir(_exe_cleaners_dir) and not portable_mode:
system_cleaners_dir = _exe_cleaners_dir
elif os.path.isdir(_package_cleaners_dir) and not portable_mode:
# AppImage
system_cleaners_dir = _package_cleaners_dir
elif IS_LINUX or IS_MAC:
system_cleaners_dir = '/usr/share/bleachbit/cleaners'
elif IS_WINDOWS:
system_cleaners_dir = os.path.join(bleachbit_exe_path, 'share\\cleaners\\')
elif IS_NETBSD:
system_cleaners_dir = '/usr/pkg/share/bleachbit/cleaners'
elif IS_BSD:
system_cleaners_dir = '/usr/local/share/bleachbit/cleaners'
else:
system_cleaners_dir = None
logger.warning(
'unknown system cleaners directory for platform %s ', sys.platform)
# local cleaners directory for running without installation (Windows or Linux)
local_cleaners_dir = None
if portable_mode:
local_cleaners_dir = os.path.join(bleachbit_exe_path, 'cleaners')
def get_share_dirs():
"""Return ordered list of directories to search for shared data files."""
if hasattr(sys, 'frozen'):
# frozen in py2exe
base_dirs = [
os.path.join(bleachbit_exe_path, 'share'),
bleachbit_exe_path,
]
else:
# installed .deb or .rpm has `__file__` = "/usr/share/bleachbit/__init__.py",
# so that dirname() is "/usr/share/bleachbit"
package_dir = bleachbit_package_path
# When running from source, share directory is `../share/` from `__init__.py`.
repo_root = os.path.normpath(os.path.join(package_dir, '..'))
base_dirs = [
os.path.join(package_dir, 'share'),
os.path.join(repo_root, 'share')
]
if system_cleaners_dir:
# One directory up from the system cleaners directory.
# This works when installed, like under `/usr/share`.
base_dirs.append(os.path.dirname(system_cleaners_dir))
# Remove duplicates while preserving the order.
return list(dict.fromkeys(base_dirs))
def get_share_path(filename):
"""Return path to a shared data file if it exists, else None."""
for base_dir in get_share_dirs():
candidate = os.path.normpath(os.path.join(base_dir, filename))
if os.path.exists(candidate):
return candidate
logger.error('unknown location for %s', filename)
return None
# windows10 theme
windows10_theme_path = os.path.normpath(
os.path.join(bleachbit_exe_path, 'themes/windows10'))
# application icon
__icons = (
# AppImage
os.path.normpath(os.path.join(bleachbit_exe_path,
'pixmaps/bleachbit.png')),
# Linux
'/usr/share/pixmaps/bleachbit.png',
# NetBSD
'/usr/pkg/share/pixmaps/bleachbit.png',
# FreeBSD and OpenBSD
os.path.normpath(os.path.join(bleachbit_exe_path,
'share\\bleachbit.png')), # Windows
# When running from source (i.e., not installed).
os.path.normpath(os.path.join(bleachbit_exe_path, 'bleachbit.png')),
)
appicon_path = None
for __icon in __icons:
if os.path.exists(__icon):
appicon_path = __icon
def _resolve_locale_dir(exe_path, is_linux, is_mac, is_windows, is_netbsd, is_bsd,
path_exists=os.path.exists):
"""Return the locale directory to use, given the platform and the
directory containing the running executable.
In the macOS .app bundle, exe_path is Contents/Resources (the parent
of the bleachbit package directory), while locale/ lives one level up
at Contents/locale -- neither the './locale/' nor the AppImage-style
'next to the executable' check below finds it, which previously fell
through to the Linux/macOS '/usr/share/locale/' fallback and silently
lost all bundled translations.
"""
exe_locale_dir = os.path.join(exe_path, 'locale')
bundle_locale_dir = os.path.normpath(
os.path.join(exe_path, '..', 'locale')) if is_mac else None
if path_exists("./locale/"):
# local locale (personal)
return os.path.abspath("./locale/")
if path_exists(exe_locale_dir):
# AppImage
return exe_locale_dir
if is_mac and bundle_locale_dir and path_exists(bundle_locale_dir):
# macOS .app bundle: Contents/locale, one level above
# Contents/Resources
return bundle_locale_dir
# system-wide installed locale
if is_linux or is_mac:
return "/usr/share/locale/"
if is_windows:
return os.path.join(exe_path, "share\\locale\\")
if is_netbsd:
return "/usr/pkg/share/locale/"
if is_bsd:
return "/usr/local/share/locale/"
return "/usr/share/locale/"
# locale directory
locale_dir = _resolve_locale_dir(
bleachbit_exe_path, IS_LINUX, IS_MAC, IS_WINDOWS, IS_NETBSD, IS_BSD)
#
# URLs
#
base_url = "https://update.bleachbit.org"
comms_check_url = f"{base_url}/comms-check" # build-time smoke test
help_contents_url = "https://www.bleachbit.org/help"
update_check_url = f"{base_url}/update/{APP_VERSION}"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1792288280.9555566
bleachbit-6.0.6/bleachbit/markovify/ 0000775 0001750 0001750 00000000000 15265023031 014727 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/markovify/__init__.py 0000664 0001750 0001750 00000000302 15265023030 017032 0 ustar 00z z # version is not needed
#from .__version__ import __version__
from .chain import Chain
from .text import Text, NewlineText
from .splitters import split_into_sentences
from .utils import combine
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/markovify/chain.py 0000664 0001750 0001750 00000012047 15265023030 016366 0 ustar 00z z # markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
import random
import operator
import bisect
import json
# Python3 compatibility
try: # pragma: no cover
basestring
except NameError: # pragma: no cover
basestring = str
BEGIN = "___BEGIN__"
END = "___END__"
def accumulate(iterable, func=operator.add):
"""
Cumulative calculations. (Summation, by default.)
Via: https://docs.python.org/3/library/itertools.html#itertools.accumulate
"""
it = iter(iterable)
total = next(it)
yield total
for element in it:
total = func(total, element)
yield total
class Chain(object):
"""
A Markov chain representing processes that have both beginnings and ends.
For example: Sentences.
"""
def __init__(self, corpus, state_size, model=None):
"""
`corpus`: A list of lists, where each outer list is a "run"
of the process (e.g., a single sentence), and each inner list
contains the steps (e.g., words) in the run. If you want to simulate
an infinite process, you can come very close by passing just one, very
long run.
`state_size`: An integer indicating the number of items the model
uses to represent its state. For text generation, 2 or 3 are typical.
"""
self.state_size = state_size
self.model = model or self.build(corpus, self.state_size)
self.precompute_begin_state()
def build(self, corpus, state_size):
"""
Build a Python representation of the Markov model. Returns a dict
of dicts where the keys of the outer dict represent all possible states,
and point to the inner dicts. The inner dicts represent all possibilities
for the "next" item in the chain, along with the count of times it
appears.
"""
# Using a DefaultDict here would be a lot more convenient, however the memory
# usage is far higher.
model = {}
for run in corpus:
items = ([ BEGIN ] * state_size) + run + [ END ]
for i in range(len(run) + 1):
state = tuple(items[i:i+state_size])
follow = items[i+state_size]
if state not in model:
model[state] = {}
if follow not in model[state]:
model[state][follow] = 0
model[state][follow] += 1
return model
def precompute_begin_state(self):
"""
Caches the summation calculation and available choices for BEGIN * state_size.
Significantly speeds up chain generation on large corpuses. Thanks, @schollz!
"""
begin_state = tuple([ BEGIN ] * self.state_size)
choices, weights = zip(*self.model[begin_state].items())
cumdist = list(accumulate(weights))
self.begin_cumdist = cumdist
self.begin_choices = choices
def move(self, state):
"""
Given a state, choose the next item at random.
"""
if state == tuple([ BEGIN ] * self.state_size):
choices = self.begin_choices
cumdist = self.begin_cumdist
else:
choices, weights = zip(*self.model[state].items())
cumdist = list(accumulate(weights))
r = random.random() * cumdist[-1]
selection = choices[bisect.bisect(cumdist, r)]
return selection
def gen(self, init_state=None):
"""
Starting either with a naive BEGIN state, or the provided `init_state`
(as a tuple), return a generator that will yield successive items
until the chain reaches the END state.
"""
state = init_state or (BEGIN,) * self.state_size
while True:
next_word = self.move(state)
if next_word == END: break
yield next_word
state = tuple(state[1:]) + (next_word,)
def walk(self, init_state=None):
"""
Return a list representing a single run of the Markov model, either
starting with a naive BEGIN state, or the provided `init_state`
(as a tuple).
"""
return list(self.gen(init_state))
def to_json(self):
"""
Dump the model as a JSON object, for loading later.
"""
return json.dumps(list(self.model.items()))
@classmethod
def from_json(cls, json_thing):
"""
Given a JSON object or JSON string that was created by `self.to_json`,
return the corresponding markovify.Chain.
"""
if isinstance(json_thing, basestring):
obj = json.loads(json_thing)
else:
obj = json_thing
if isinstance(obj, list):
rehydrated = {tuple(item[0]): item[1] for item in obj}
elif isinstance(obj, dict):
rehydrated = obj
else:
raise ValueError("Object should be dict or list")
state_size = len(list(rehydrated.keys())[0])
inst = cls(None, state_size, rehydrated)
return inst
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/markovify/splitters.py 0000664 0001750 0001750 00000004256 15265023030 017340 0 ustar 00z z # -*- coding: utf-8 -*-
# markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
import re
ascii_lowercase = "abcdefghijklmnopqrstuvwxyz"
ascii_uppercase = ascii_lowercase.upper()
# States w/ with thanks to https://github.com/unitedstates/python-us
# Titles w/ thanks to https://github.com/nytimes/emphasis and @donohoe
abbr_capped = "|".join([
"ala|ariz|ark|calif|colo|conn|del|fla|ga|ill|ind|kan|ky|la|md|mass|mich|minn|miss|mo|mont|neb|nev|okla|ore|pa|tenn|vt|va|wash|wis|wyo", # States
"u.s",
"mr|ms|mrs|msr|dr|gov|pres|sen|sens|rep|reps|prof|gen|messrs|col|sr|jf|sgt|mgr|fr|rev|jr|snr|atty|supt", # Titles
"ave|blvd|st|rd|hwy", # Streets
"jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec", # Months
"|".join(ascii_lowercase) # Initials
]).split("|")
abbr_lowercase = "etc|v|vs|viz|al|pct"
exceptions = "U.S.|U.N.|E.U.|F.B.I.|C.I.A.".split("|")
def is_abbreviation(dotted_word):
clipped = dotted_word[:-1]
if clipped[0] in ascii_uppercase:
if clipped.lower() in abbr_capped: return True
else: return False
else:
if clipped in abbr_lowercase: return True
else: return False
def is_sentence_ender(word):
if word in exceptions: return False
if word[-1] in [ "?", "!" ]:
return True
if len(re.sub(r"[^A-Z]", "", word)) > 1:
return True
if word[-1] == "." and (not is_abbreviation(word)):
return True
return False
def split_into_sentences(text):
potential_end_pat = re.compile(r"".join([
r"([\w\.'’&\]\)]+[\.\?!])", # A word that ends with punctuation
r"([‘’“”'\"\)\]]*)", # Followed by optional quote/parens/etc
r"(\s+(?![a-z\-–—]))", # Followed by whitespace + non-(lowercase or dash)
]), re.U)
dot_iter = re.finditer(potential_end_pat, text)
end_indices = [ (x.start() + len(x.group(1)) + len(x.group(2)))
for x in dot_iter
if is_sentence_ender(x.group(1)) ]
spans = zip([None] + end_indices, end_indices + [None])
sentences = [ text[start:end].strip() for start, end in spans ]
return sentences
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/markovify/text.py 0000664 0001750 0001750 00000021153 15265023030 016266 0 ustar 00z z # markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
import re
import random
from .splitters import split_into_sentences
from .chain import Chain, BEGIN
# BleachBit does not use unidecode
#from unidecode import unidecode
DEFAULT_MAX_OVERLAP_RATIO = 0.7
DEFAULT_MAX_OVERLAP_TOTAL = 15
DEFAULT_TRIES = 10
class ParamError(Exception):
pass
class Text(object):
def __init__(self, input_text, state_size=2, chain=None, parsed_sentences=None, retain_original=True):
"""
input_text: A string.
state_size: An integer, indicating the number of words in the model's state.
chain: A trained markovify.Chain instance for this text, if pre-processed.
parsed_sentences: A list of lists, where each outer list is a "run"
of the process (e.g. a single sentence), and each inner list
contains the steps (e.g. words) in the run. If you want to simulate
an infinite process, you can come very close by passing just one, very
long run.
"""
can_make_sentences = parsed_sentences is not None or input_text is not None
self.retain_original = retain_original and can_make_sentences
self.state_size = state_size
if self.retain_original:
raise NotImplementedError("retain_original=True is not used in BleachBit")
else:
self.chain = chain
def to_dict(self):
"""
Returns the underlying data as a Python dict.
"""
raise NotImplementedError("to_dict is not used in BleachBit")
def to_json(self):
"""
Returns the underlying data as a JSON string.
"""
raise NotImplementedError("to_json is not used in BleachBit")
@classmethod
def from_dict(cls, obj, **kwargs):
return cls(
None,
state_size=obj["state_size"],
chain=Chain.from_json(obj["chain"]),
parsed_sentences=obj.get("parsed_sentences")
)
@classmethod
def from_json(cls, json_str):
raise NotImplementedError("from_json is not used in BleachBit")
def sentence_split(self, text):
"""
Splits full-text string into a list of sentences.
"""
return split_into_sentences(text)
def sentence_join(self, sentences):
"""
Re-joins a list of sentences into the full text.
"""
return " ".join(sentences)
word_split_pattern = re.compile(r"\s+")
def word_split(self, sentence):
"""
Splits a sentence into a list of words.
"""
return re.split(self.word_split_pattern, sentence)
def word_join(self, words):
"""
Re-joins a list of words into a sentence.
"""
return " ".join(words)
def test_sentence_input(self, sentence):
"""
A basic sentence filter. This one rejects sentences that contain
the type of punctuation that would look strange on its own
in a randomly-generated sentence.
"""
raise NotImplementedError("test_sentence_input is not used in BleachBit")
def generate_corpus(self, text):
"""
Given a text string, returns a list of lists; that is, a list of
"sentences," each of which is a list of words. Before splitting into
words, the sentences are filtered through `self.test_sentence_input`
"""
raise NotImplementedError("generate_corpus is not used in BleachBit")
def test_sentence_output(self, words, max_overlap_ratio, max_overlap_total):
"""
Given a generated list of words, accept or reject it. This one rejects
sentences that too closely match the original text, namely those that
contain any identical sequence of words of X length, where X is the
smaller number of (a) `max_overlap_ratio` (default: 0.7) of the total
number of words, and (b) `max_overlap_total` (default: 15).
"""
raise NotImplementedError("test_sentence_output is not used in BleachBit")
def make_sentence(self, init_state=None, **kwargs):
"""
Attempts `tries` (default: 10) times to generate a valid sentence,
based on the model and `test_sentence_output`. Passes `max_overlap_ratio`
and `max_overlap_total` to `test_sentence_output`.
If successful, returns the sentence as a string. If not, returns None.
If `init_state` (a tuple of `self.chain.state_size` words) is not specified,
this method chooses a sentence-start at random, in accordance with
the model.
If `test_output` is set as False then the `test_sentence_output` check
will be skipped.
If `max_words` is specified, the word count for the sentence will be
evaluated against the provided limit.
"""
tries = kwargs.get('tries', DEFAULT_TRIES)
mor = kwargs.get('max_overlap_ratio', DEFAULT_MAX_OVERLAP_RATIO)
mot = kwargs.get('max_overlap_total', DEFAULT_MAX_OVERLAP_TOTAL)
test_output = kwargs.get('test_output', True)
max_words = kwargs.get('max_words', None)
if init_state != None:
prefix = list(init_state)
for word in prefix:
if word == BEGIN:
prefix = prefix[1:]
else:
break
else:
prefix = []
for _ in range(tries):
words = prefix + self.chain.walk(init_state)
if max_words != None and len(words) > max_words:
continue
if test_output and hasattr(self, "rejoined_text"):
if self.test_sentence_output(words, mor, mot):
return self.word_join(words)
else:
return self.word_join(words)
return None
def make_short_sentence(self, max_chars, min_chars=0, **kwargs):
"""
Tries making a sentence of no more than `max_chars` characters and optionally
no less than `min_chars` charcaters, passing **kwargs to `self.make_sentence`.
"""
tries = kwargs.get('tries', DEFAULT_TRIES)
for _ in range(tries):
sentence = self.make_sentence(**kwargs)
if sentence and len(sentence) <= max_chars and len(sentence) >= min_chars:
return sentence
def make_sentence_with_start(self, beginning, strict=True, **kwargs):
"""
Tries making a sentence that begins with `beginning` string,
which should be a string of one to `self.state` words known
to exist in the corpus.
If strict == True, then markovify will draw its initial inspiration
only from sentences that start with the specified word/phrase.
If strict == False, then markovify will draw its initial inspiration
from any sentence containing the specified word/phrase.
**kwargs are passed to `self.make_sentence`
"""
split = tuple(self.word_split(beginning))
word_count = len(split)
if word_count == self.state_size:
init_states = [ split ]
elif word_count > 0 and word_count < self.state_size:
if strict:
init_states = [ (BEGIN,) * (self.state_size - word_count) + split ]
else:
init_states = [ key for key in self.chain.model.keys()
# check for starting with begin as well ordered lists
if tuple(filter(lambda x: x != BEGIN, key))[:word_count] == split ]
random.shuffle(init_states)
else:
err_msg = "`make_sentence_with_start` for this model requires a string containing 1 to {0} words. Yours has {1}: {2}".format(self.state_size, word_count, str(split))
raise ParamError(err_msg)
for init_state in init_states:
output = self.make_sentence(init_state, **kwargs)
if output is not None:
return output
return None
@classmethod
def from_chain(cls, chain_json, corpus=None, parsed_sentences=None):
"""
Init a Text class based on an existing chain JSON string or object
If corpus is None, overlap checking won't work.
"""
chain = Chain.from_json(chain_json)
return cls(corpus or None, parsed_sentences=parsed_sentences, state_size=chain.state_size, chain=chain)
class NewlineText(Text):
"""
A (usable) example of subclassing markovify.Text. This one lets you markovify
text where the sentences are separated by newlines instead of ". "
"""
def sentence_split(self, text):
return re.split(r"\s*\n\s*", text)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit/markovify/utils.py 0000664 0001750 0001750 00000004031 15265023030 016436 0 ustar 00z z # markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
from .chain import Chain
from .text import Text
def get_model_dict(thing):
if isinstance(thing, Chain):
return thing.model
if isinstance(thing, Text):
return thing.chain.model
if isinstance(thing, list):
return dict(thing)
if isinstance(thing, dict):
return thing
raise ValueError("`models` should be instances of list, dict, markovify.Chain, or markovify.Text")
def combine(models, weights=None):
if weights is None:
weights = [ 1 for _ in range(len(models)) ]
if len(models) != len(weights):
raise ValueError("`models` and `weights` lengths must be equal.")
model_dicts = list(map(get_model_dict, models))
state_sizes = [ len(list(md.keys())[0])
for md in model_dicts ]
if len(set(state_sizes)) != 1:
raise ValueError("All `models` must have the same state size.")
if len(set(map(type, models))) != 1:
raise ValueError("All `models` must be of the same type.")
c = {}
for m, w in zip(model_dicts, weights):
for state, options in m.items():
current = c.get(state, {})
for subseq_k, subseq_v in options.items():
subseq_prev = current.get(subseq_k, 0)
current[subseq_k] = subseq_prev + (subseq_v * w)
c[state] = current
ret_inst = models[0]
if isinstance(ret_inst, Chain):
return Chain.from_json(c)
if isinstance(ret_inst, Text):
if not any(m.retain_original for m in models):
return ret_inst.from_chain(c)
combined_sentences = []
for m in models:
if m.retain_original:
combined_sentences += m.parsed_sentences
return ret_inst.from_chain(c, parsed_sentences=combined_sentences)
if isinstance(ret_inst, list):
return list(c.items())
if isinstance(ret_inst, dict):
return c
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1792288280.9901266
bleachbit-6.0.6/bleachbit.egg-info/ 0000775 0001750 0001750 00000000000 15265023031 014412 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit.egg-info/PKG-INFO 0000644 0001750 0001750 00000001503 15265023030 015503 0 ustar 00z z Metadata-Version: 2.4
Name: bleachbit
Version: 6.0.6
Summary: BleachBit
Home-page: https://www.bleachbit.org
Download-URL: https://www.bleachbit.org/download
Author: Andrew Ziem
Author-email: andrew@bleachbit.org
License: GPL-3.0-or-later
Platform: Linux and Windows
Platform: Python v3.8+
Platform: GTK v3.24+
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
License-File: COPYING
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: download-url
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: platform
Dynamic: summary
BleachBit frees space and maintains privacy by quickly wiping files you don't need and didn't know you had.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit.egg-info/SOURCES.txt 0000664 0001750 0001750 00000014164 15265023030 016303 0 ustar 00z z COPYING
MANIFEST.in
Makefile
README.md
bleachbit.png
bleachbit.py
bleachbit.spec
bleachbit.svg
org.bleachbit.BleachBit.desktop
org.bleachbit.BleachBit.metainfo.xml
org.bleachbit.policy
setup.cfg
setup.py
bleachbit/Action.py
bleachbit/Bootstrap.py
bleachbit/CLI.py
bleachbit/Chaff.py
bleachbit/Cleaner.py
bleachbit/CleanerML.py
bleachbit/Command.py
bleachbit/Constant.py
bleachbit/Cookie.py
bleachbit/DeepScan.py
bleachbit/DesktopMenuOptions.py
bleachbit/FileUtilities.py
bleachbit/FontCheckDialog.py
bleachbit/GUI.py
bleachbit/General.py
bleachbit/GtkShim.py
bleachbit/GuiApplication.py
bleachbit/GuiBasic.py
bleachbit/GuiChaff.py
bleachbit/GuiCookie.py
bleachbit/GuiInfoBar.py
bleachbit/GuiPreferences.py
bleachbit/GuiStartup.py
bleachbit/GuiTreeModels.py
bleachbit/GuiUtil.py
bleachbit/GuiWindow.py
bleachbit/Language.py
bleachbit/Log.py
bleachbit/Mac.py
bleachbit/Memory.py
bleachbit/Network.py
bleachbit/Options.py
bleachbit/PathUtils.py
bleachbit/Process.py
bleachbit/ProtectedPath.py
bleachbit/RecognizeCleanerML.py
bleachbit/Revision.py
bleachbit/Special.py
bleachbit/SystemInformation.py
bleachbit/Unix.py
bleachbit/Update.py
bleachbit/VFS.py
bleachbit/Winapp.py
bleachbit/Windows.py
bleachbit/WindowsWipe.py
bleachbit/Wipe.py
bleachbit/Worker.py
bleachbit/__init__.py
bleachbit.egg-info/PKG-INFO
bleachbit.egg-info/SOURCES.txt
bleachbit.egg-info/dependency_links.txt
bleachbit.egg-info/top_level.txt
bleachbit/markovify/__init__.py
bleachbit/markovify/chain.py
bleachbit/markovify/splitters.py
bleachbit/markovify/text.py
bleachbit/markovify/utils.py
cleaners/Makefile
cleaners/adobe_reader.xml
cleaners/amsn.xml
cleaners/amule.xml
cleaners/android_studio.xml
cleaners/apt.xml
cleaners/audacious.xml
cleaners/bash.xml
cleaners/beagle.xml
cleaners/brave.xml
cleaners/chromium.xml
cleaners/claude.xml
cleaners/d4x.xml
cleaners/deepscan.xml
cleaners/discord.xml
cleaners/dnf.xml
cleaners/docker.xml
cleaners/easytag.xml
cleaners/elinks.xml
cleaners/emesene.xml
cleaners/epiphany.xml
cleaners/evolution.xml
cleaners/exaile.xml
cleaners/filezilla.xml
cleaners/firefox.xml
cleaners/fish.xml
cleaners/flash.xml
cleaners/geary.xml
cleaners/gedit.xml
cleaners/gftp.xml
cleaners/gimp.xml
cleaners/gl-117.xml
cleaners/gnome.xml
cleaners/google_chrome.xml
cleaners/google_earth.xml
cleaners/google_toolbar.xml
cleaners/gpodder.xml
cleaners/gwenview.xml
cleaners/helium.xml
cleaners/hexchat.xml
cleaners/hippo_opensim_viewer.xml
cleaners/internet_explorer.xml
cleaners/java.xml
cleaners/journald.xml
cleaners/kde.xml
cleaners/konqueror.xml
cleaners/libreoffice.xml
cleaners/librewolf.xml
cleaners/liferea.xml
cleaners/links2.xml
cleaners/localizations.xml
cleaners/mc.xml
cleaners/microsoft_edge.xml
cleaners/microsoft_office.xml
cleaners/miro.xml
cleaners/nautilus.xml
cleaners/nexuiz.xml
cleaners/octave.xml
cleaners/openoffice.xml
cleaners/opera.xml
cleaners/pacman.xml
cleaners/paint.xml
cleaners/palemoon.xml
cleaners/pidgin.xml
cleaners/profanity.xml
cleaners/python.xml
cleaners/realplayer.xml
cleaners/recoll.xml
cleaners/rhythmbox.xml
cleaners/safari.xml
cleaners/safari_windows.xml
cleaners/screenlets.xml
cleaners/seamonkey.xml
cleaners/secondlife_viewer.xml
cleaners/silverlight.xml
cleaners/skype.xml
cleaners/slack.xml
cleaners/smartftp.xml
cleaners/snap.xml
cleaners/sqlite3.xml
cleaners/teamviewer.xml
cleaners/thumbnails.xml
cleaners/thunderbird.xml
cleaners/tortoisesvn.xml
cleaners/transmission.xml
cleaners/tremulous.xml
cleaners/vim.xml
cleaners/vivaldi.xml
cleaners/vlc.xml
cleaners/vscode.xml
cleaners/vuze.xml
cleaners/warzone2100.xml
cleaners/waterfox.xml
cleaners/winamp.xml
cleaners/windows_defender.xml
cleaners/windows_explorer.xml
cleaners/windows_media_player.xml
cleaners/wine.xml
cleaners/winetricks.xml
cleaners/winrar.xml
cleaners/winzip.xml
cleaners/wordpad.xml
cleaners/x11.xml
cleaners/xine.xml
cleaners/yahoo_messenger.xml
cleaners/yum.xml
cleaners/zen.xml
cleaners/zoom.xml
cleaners/zsh.xml
debian/bleachbit.dsc
debian/compat
debian/copyright
debian/debian.changelog
debian/debian.control
debian/debian.rules
doc/CONTRIBUTING.md
doc/cleaner_markup_language.xsd
doc/example_cleaner.xml
doc/protected_path.xsd
po/Makefile
po/af.po
po/ar.po
po/ast.po
po/be.po
po/bg.po
po/bn.po
po/bs.po
po/ca.po
po/cs.po
po/da.po
po/de.po
po/el.po
po/en_AU.po
po/en_CA.po
po/en_GB.po
po/eo.po
po/es.po
po/et.po
po/eu.po
po/fa.po
po/fi.po
po/fr.po
po/ga.po
po/gl.po
po/he.po
po/hi.po
po/hr.po
po/hu.po
po/id.po
po/it.po
po/ja.po
po/ka.po
po/kk.po
po/ko.po
po/lt.po
po/lv.po
po/ms.po
po/my.po
po/nb.po
po/nl.po
po/pl.po
po/pt.po
po/pt_BR.po
po/ro.po
po/ru.po
po/si.po
po/sk.po
po/sl.po
po/sq.po
po/sr.po
po/sv.po
po/ta.po
po/te.po
po/th.po
po/tr.po
po/uk.po
po/uz.po
po/vi.po
po/yi.po
po/zh_CN.po
po/zh_TW.po
share/app-menu.ui
share/protected_path.xml
tests/TestAction.py
tests/TestAll.py
tests/TestCLI.py
tests/TestChaff.py
tests/TestCleaner.py
tests/TestCleanerML.py
tests/TestCommand.py
tests/TestCommon.py
tests/TestCookie.py
tests/TestDeepScan.py
tests/TestDesktopMenuOptions.py
tests/TestExternalCommand.py
tests/TestFileUtilities.py
tests/TestGUI.py
tests/TestGeneral.py
tests/TestGtkShim.py
tests/TestGuiBasic.py
tests/TestGuiChaff.py
tests/TestGuiCookie.py
tests/TestGuiStartup.py
tests/TestGuiUtil.py
tests/TestInit.py
tests/TestLanguage.py
tests/TestLog.py
tests/TestMac.py
tests/TestMakefile.py
tests/TestMemory.py
tests/TestNetwork.py
tests/TestNsisUtilities.py
tests/TestOptions.py
tests/TestPathUtils.py
tests/TestProcess.py
tests/TestProtectedPath.py
tests/TestRecognizeCleanerML.py
tests/TestSpecial.py
tests/TestSystemInformation.py
tests/TestTranslations.py
tests/TestUnix.py
tests/TestUpdate.py
tests/TestVFS.py
tests/TestWinapp.py
tests/TestWindows.py
tests/TestWindowsWipe.py
tests/TestWipe.py
tests/TestWipePath.py
tests/TestWorker.py
tests/__init__.py
tests/common.py
tests/conftest.py
tests/localization_paths_negative.txt
tests/localization_paths_positive.txt
tests/test_with_sudo.py
windows/bleachbit.ico
windows/bleachbit.nsi
windows/build-environment-install.bat
windows/gtk30.pot
windows/msys-install.ps1
windows/psexec-low-integrity-tests.bat
windows/psexec-nonadmin-tests.bat
windows/python-gtk3-install.ps1
windows/requirements.txt
windows/run-psexec-low-integrity-tests.bat
windows/run-psexec-nonadmin-tests.bat ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit.egg-info/dependency_links.txt 0000664 0001750 0001750 00000000001 15265023030 020457 0 ustar 00z z
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit.egg-info/top_level.txt 0000664 0001750 0001750 00000000012 15265023030 017134 0 ustar 00z z bleachbit
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1792288280.0
bleachbit-6.0.6/bleachbit.png 0000664 0001750 0001750 00000034065 15265023030 013435 0 ustar 00z z ‰PNG
IHDR k¬XT PLTEGpLPPPLLJkT6PPPQQQOOORRRSSSSSSMMMZZZìììêêêGGGAAA=<<777éééèèèçççæææäääâââáááàààßßßÞÞÞÝÝÝÜÜÜÛÛÛÚÚÚØØØ×××ÖÖÖÕÕÕÔÔÔÓÓÓÿÿÿÍÍÍccbllk¿¾½»»»ººº¹¹¹¸¸¸···¶¶¶´´´³³³°°°ÂÂÂÈÈȪªª¦¦¦¤¤¤¢¢¢ŸŸŸžžžttsyyy}}|€€€ƒƒƒ†††‰‰‰��Œ���”””———šš™›››���é©�X †W ·””€[�^�X£�tlV,ÍÀ¦ÞØÆǴ�º¦{˜TKûVVôLLíBBå99Þ//©×''ÓÇÍ¼Ä Ši u
ƒ-·‹Xª{<�Y—`¢r%دl۴uԧ_ѠR˘E:½†,²{#Á}¤k
©p®rµv¼z¸¡_ÙѶëåӎnî⮥‡ ¶” —‹Sª–9¼ (Ø˖·•
ÔÂqѹSͱ6àÌvţɩĠ ƢͯßÊNÛÃ;ȥּ)ʨͭѳҲոٽ ټÚÂÝÄßÄàÉ+äÑAåÓJæÕSãÎ8çÖXìÞwèÙhðç›ñè¢íà‹ïä”ïãÞÆâÊêÚYãÊåÍê×JîÝ]òádöé†üï�þò•þó�èÐ-ìÕ6èÏ%èÐëÓîÖ#òÚ'õÝ,òÙ1÷à1öåkðÚ@óÞMúã5ýæ:øá?þçBþèKþêU÷ãWýê_÷èvþìkþîtýï}ýðˆþõªþö¶110 888 ??? EEE
þþþûûûùùøöööóóóðððîîî &Œü8