.
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 'win32' != sys.platform:
continue
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 list_cleanerml_files(local_only=False):
"""List CleanerML files"""
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, )
for pathname in listdir(cleanerdirs):
if not pathname.lower().endswith('.xml'):
continue
st = os.stat(pathname)
if sys.platform != 'win32' and stat.S_IMODE(st[stat.ST_MODE]) & 2:
# TRANSLATORS: Warning printed to the log.
# %s expands to the path of the XML cleaner file that was skipped
warning_msg = _("Ignoring cleaner because it is "
"world writable: %s")
logger.warning(warning_msg, pathname)
continue
yield pathname
def load_cleaners(cb_progress=lambda x: None):
"""Scan for CleanerML and load them"""
cleanerml_files = list(list_cleanerml_files())
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)
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('\\', '/')
ret = f'''{translators}#: {pathname}
msgid "{msgid}"
msgstr ""
'''
return ret
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=1777139431.0
bleachbit-6.0.0/bleachbit/Command.py 0000775 0001750 0001750 00000030643 15173177347 014675 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 sqlite3
import types
import warnings
from bleachbit import FileUtilities
from bleachbit.Constant import CLEAN_FILE_LABEL
from bleachbit.Language import get_text as _
if 'nt' == os.name:
import bleachbit.Windows
else:
from bleachbit.General import WindowsError
logger = logging.getLogger(__name__)
def ret_keep_list(path):
"""Return information that this file matched by keep list"""
ret = {
# 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}
return ret
class Delete:
"""Delete a single file or directory. Obey the user
preference regarding shredding."""
def __init__(self, path):
"""Create a Delete instance to delete 'path'"""
self.path = path
self.shred = False
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.')
if hasattr(e, 'winerror'):
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)
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
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 and preview_func take no arguments and 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"""
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 self.func():
if True == func_ret 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
logger.exception(e)
return
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 Ini:
"""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 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)
FileUtilities.clean_ini(self.path, self.section, self.parameter)
newsize = FileUtilities.getsize(self.path)
ret['size'] = oldsize - newsize
yield ret
class Json:
"""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 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)
FileUtilities.clean_json(self.path, self.address)
newsize = FileUtilities.getsize(self.path)
ret['size'] = oldsize - newsize
yield ret
class Shred(Delete):
"""Shred a single file"""
def __init__(self, path):
"""Create an instance to shred 'path'"""
Delete.__init__(self, path)
self.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:
with open(self.path, 'w', encoding='ascii') as f:
f.truncate(0)
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 'nt' != os.name:
return
_str = None # string representation
ret = None # return value meaning 'deleted' or 'delete-able'
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=1777139431.0
bleachbit-6.0.0/bleachbit/Constant.py 0000664 0001750 0001750 00000003626 15173177347 015106 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=1777139431.0
bleachbit-6.0.0/bleachbit/Cookie.py 0000664 0001750 0001750 00000031221 15173177347 014516 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 .
"""
Cookie module for selective deletion of cookies
"""
import contextlib
import logging
import os
import sqlite3
from bleachbit import FileUtilities
from bleachbit.Special import sqlite_table_exists
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:
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 os.path.exists(candidate):
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."""
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"""
(table_name, host_column) = detect_browser(path)
uri = f'file:{path}'
with contextlib.closing(sqlite3.connect(uri, uri=bool(uri.startswith('file:')))) as conn:
cursor = conn.cursor()
cursor.execute(f"SELECT distinct {host_column} FROM {table_name}")
return cursor.fetchall()
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)
# Find the first matching table configuration
(table_name, host_column) = detect_browser(path)
original_size = _get_db_disk_size(path)
assert original_size > 0
# Set up connection
uri = f'file:{path}'
if not really_delete:
uri += '?mode=ro'
from bleachbit.Options import options
shred_enabled = options.get('shred')
try:
with contextlib.closing(sqlite3.connect(uri, uri=bool(uri.startswith('file:')))) 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.
"""
cookie_files = set()
# Import 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=1777139431.0
bleachbit-6.0.0/bleachbit/DeepScan.py 0000775 0001750 0001750 00000007672 15173177347 015007 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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 platform
import re
import time
import unicodedata
from collections import namedtuple
from bleachbit import fs_scan_re_flags
from . import Command
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
"""
try:
from scandir import walk
except:
# there is a warning in FileUtilities, so don't warn again here
from os import walk
if 'Darwin' == platform.system():
for dirpath, dirnames, filenames in walk(top, **kwargs):
yield dirpath, dirnames, [
unicodedata.normalize('NFC', fn)
for fn in filenames
]
else:
yield from walk(top, **kwargs)
Search = namedtuple(
'Search', ['command', 'regex', 'nregex', 'wholeregex', 'nwholeregex'])
Search.__new__.__defaults__ = (None,) * len(Search._fields)
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):
full_path = os.path.join(dirpath, filename)
if self.regex and not self.regex.search(filename):
return None
if self.nregex and self.nregex.search(filename):
return None
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():
compiled_searches = [CompiledSearch(s) for s in searches]
for (dirpath, _dirnames, filenames) in normalized_walk(top):
for c in compiled_searches:
# fixme, don't match filename twice
for filename in filenames:
full_name = c.match(dirpath, filename)
if full_name is not None:
# 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=1777139431.0
bleachbit-6.0.0/bleachbit/DesktopMenuOptions.py 0000664 0001750 0001750 00000003227 15173177347 017124 0 ustar 00z z """Helper utilities to install optional desktop service menus."""
import os
from pathlib import Path
from bleachbit.Options import options
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.
with service_file_path.open('w') as service_file:
service_file_path.chmod(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
''')
else:
try:
service_file_path.unlink()
except FileNotFoundError:
pass
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/FileUtilities.py 0000775 0001750 0001750 00000073670 15173177347 016101 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 contextlib
import errno
import glob
import json
import locale
import logging
import os
import os.path
import re
import sqlite3
import stat
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from os import walk
from pathlib import Path
# local imports
import bleachbit
from bleachbit.Language import get_text as _
from bleachbit.Wipe import wipe_contents, wipe_name
logger = logging.getLogger(__name__)
if 'nt' == os.name:
# 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)
if 'posix' == os.name:
# pylint: disable=redefined-builtin
from bleachbit.General import WindowsError
# pylint: disable=invalid-name
pywinerror = WindowsError
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 os.name != 'nt':
return False
try:
attrs = GetFileAttributesW(path)
except pywinerror:
return False
if attrs & FILE_ATTRIBUTE_READONLY:
SetFileAttributesW(path, attrs & ~FILE_ATTRIBUTE_READONLY)
return True
return False
def open_files_linux():
"""Return iterator of open files on Linux"""
return glob.iglob("/proc/*/fd/*")
def get_filesystem_type(path):
"""Get file system type from the given path
path: directory path
Return value:
A tuple of (file_system_type, device_name)
file_system_type: vfat, ntfs, etc.
device_name: C:, D:, etc.
File system types seen
* On Linux: btrfs,ext4, vfat, squashfs
* On Windows: NTFS, FAT32, CDFS
"""
try:
# pylint: disable=import-outside-toplevel
import psutil
except ImportError:
logger.warning(
'To get the file system type from the given path, you need to install psutil package')
return ("unknown", "none")
path_obj = Path(path)
if os.name == 'nt':
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():
mount_path = Path(partition.mountpoint)
partitions[mount_path] = (partition.fstype, partition.device)
# Exact match
for mount_path, fs_info in partitions.items():
if path_obj == mount_path:
return fs_info
# Try parent paths
current = path_obj
while current.parent != current: # Stop at root
current = current.parent
for mount_path, fs_info in partitions.items():
if current == mount_path:
return fs_info
return ("unknown", "none")
def open_files_lsof(run_lsof=None):
"""Return iterator of open files using lsof"""
if run_lsof is None:
def run_lsof():
return subprocess.check_output(["lsof", "-Fn", "-n"])
for f in run_lsof().split("\n"):
if f.startswith("n/"):
yield f[1:] # Drop lsof's "n"
def open_files():
"""Return iterator of open files"""
if sys.platform == 'linux':
files = open_files_linux()
elif 'darwin' == sys.platform or sys.platform.startswith('freebsd'):
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
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 = []
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 = []
for filename in open_files():
if self.file_qualifies(filename):
self.files.append(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
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 = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi']
base = 1024.0
else:
prefixes = ['', 'k', 'M', 'G', 'T', 'P']
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 _exponent, prefix in enumerate(prefixes):
if bytes_i < base:
abbrev = round(bytes_i, decimals)
suf = prefix
return locale.str(abbrev) + suf + 'B'
bytes_i /= base
return 'A lot.'
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.
"""
if isinstance(top, tuple):
for top_ in top:
yield from children_in_directory(top_, list_directories)
return
def _normalized_prefix(path):
norm_path = os.path.normpath(path)
if os.name == 'nt':
norm_path = norm_path.lower()
if not norm_path.endswith(os.sep):
norm_path += os.sep
return norm_path
pending_dirs = [] if list_directories else None
for (dirpath, dirnames, filenames) in walk(top, topdown=True, followlinks=False):
if 'nt' == os.name and dirnames:
# Avoid traversing Windows symlinks or junctions.
link_dirnames = []
for dirname in list(dirnames):
if os.path.islink(os.path.join(dirpath, dirname)):
link_dirnames.append(dirname)
dirnames.remove(dirname)
if list_directories:
for dirname in link_dirnames:
yield os.path.join(dirpath, dirname)
if list_directories:
for dirname in dirnames:
full_path = os.path.join(dirpath, dirname)
pending_dirs.append((full_path, _normalized_prefix(full_path)))
for filename in filenames:
yield os.path.join(dirpath, filename)
if list_directories:
pending_dirs.sort(key=lambda x: len(x[1]))
while pending_dirs:
yield pending_dirs.pop()[0]
def clean_ini(path, section, parameter):
"""Delete sections and parameters (aka option) in the file
Comments are not preserved.
"""
def write(parser, ini_file):
"""
Reimplementation of the original RowConfigParser write function.
This function is 99% same as its origin. The only change is
removing a cast to str. This is needed to handle unicode chars.
"""
if parser._defaults:
ini_file.write("[DEFAULT]\n")
for (key, value) in parser._defaults.items():
value_str = str(value).replace('\n', '\n\t')
ini_file.write(f"{key} = {value_str}\n")
ini_file.write("\n")
for section in parser._sections:
ini_file.write(f"[{section}]\n")
for (key, value) in parser._sections[section].items():
if key == "__name__":
continue
if (value is not None) or (parser._optcre == parser.OPTCRE):
# The line below is the only changed line of the original function.
# This is the original line for reference:
# key = " = ".join((key, str(value).replace('\n', '\n\t')))
key = " = ".join((key, value.replace('\n', '\n\t')))
ini_file.write(f"{key}\n")
ini_file.write("\n")
encoding = detect_encoding(path) or 'utf_8_sig'
# read file to parser
config = bleachbit.RawConfigParser(delimiters='=')
config.optionxform = lambda option: option
config.write = write
with open(path, 'r', encoding=encoding) as fp:
config.read_file(fp)
# 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)
# write file
if changed:
from bleachbit.Options import options
fp.close()
if options.get('shred'):
delete(path, True)
with open(path, 'w', encoding=encoding, newline='') as fp:
config.write(config, 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 len(targets) > 0:
# 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 0 == len(targets):
# target not found
break
if changed:
from bleachbit.Options import options
if options.get('shred'):
delete(path, True)
# write file
with open(path, 'w', encoding='utf-8') as f:
json.dump(js, f)
def _truncate_locked_file(path):
"""Best-effort truncate of a locked file (Windows).
Returns True if truncation succeeded, False otherwise.
Shared locks allow truncation, exclusive locks prevent it.
"""
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(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: # pylint: disable=possibly-used-before-assignment
# 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 os.name == 'nt' 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(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
is_special = False
path = extended_path(path)
do_shred = allow_shred and (shred or options.get('shred'))
if not os.path.lexists(path):
if ignore_missing:
return False
raise OSError(2, 'No such file or directory', path)
if 'posix' == os.name:
# With certain (relatively rare) files on Windows os.lstat()
# may return Access Denied
mode = os.lstat(path)[stat.ST_MODE]
is_special = stat.S_ISFIFO(mode) or stat.S_ISLNK(mode)
if is_special:
os.remove(path)
return True
if os.path.isdir(path):
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:
os.rmdir(delpath)
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
elif errno.EBUSY == e.errno:
if os.name == '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
elif os.name == 'nt' and errno.EACCES == e.errno:
# On Windows, read-only directories cause Access Denied
if _remove_windows_readonly(delpath):
os.rmdir(delpath)
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
else:
raise
return True
elif os.path.isfile(path):
delete_file(path, do_shred)
return True
elif os.path.islink(path):
os.remove(path)
return True
else:
# 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"""
try:
# pylint: disable=import-outside-toplevel
import chardet
except ImportError:
logger.warning(
'chardet module is not available to detect character encoding')
return None
with open(fn, 'rb') as f:
detector = chardet.universaldetector.UniversalDetector()
for line in f.readlines():
detector.feed(line)
if detector.done:
break
detector.close()
return detector.result['encoding']
def ego_owner(filename):
"""Return whether current user owns the file
POSIX only"""
assert 'posix' == os.name
# pylint: disable=no-member
return os.lstat(filename).st_uid == os.getuid()
def exists_in_path(filename):
"""Returns boolean whether the filename exists in the path"""
delimiter = ':'
if 'nt' == os.name:
delimiter = ';'
path_env = os.getenv('PATH')
if not path_env:
return False
assert not os.path.isabs(filename)
for dirname in path_env.split(delimiter):
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)
else:
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
assert isinstance(path, str)
assert isinstance(cmds, str)
with contextlib.closing(sqlite3.connect(path)) 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')
assert conn.execute('PRAGMA secure_delete').fetchone()[0] == 1
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(exc.message)
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)))
ret = [pathname4 for pathname4 in glob.iglob(pathname3)]
return ret
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 'nt' == os.name 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 'nt' == os.name:
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 'nt' == os.name:
# pylint: disable=import-error,import-outside-toplevel
import psutil
return psutil.disk_usage(pathname).free
assert 'posix' == os.name
# 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 'posix' == os.name:
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 'nt' == os.name:
# 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)
raise e
if not finddata:
# FindFilesW does not work for directories, so fall back to
# getsize()
return os.path.getsize(path)
else:
size = (finddata[0][4] * (0xffffffff + 1)) + finddata[0][5]
return size
return os.path.getsize(path)
def getsizedir(path):
"""Return the size of the contents of a directory"""
total_bytes = sum(
getsize(node)
for node in children_in_directory(path, list_directories=False)
)
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 'posix' == os.name:
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 'nt' == os.name:
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 = 'kMGTE'
elif 'du' == hformat:
base = 1024
suffixes = 'KMGTE'
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 'nt' == os.name:
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))
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 = urllib.request.url2pathname(parsed_uri.path)
if file_path[2] == ':':
# remove front slash for Windows-style path
file_path = file_path[1:]
file_paths.append(file_path)
else:
logger.warning('Unsupported scheme: %s', file_uri)
return file_paths
def whitelisted_posix(path, check_realpath=True):
"""Check whether this POSIX path is whitelisted"""
from bleachbit.Options import options
if check_realpath and os.path.islink(path):
# also check the link name
if whitelisted_posix(path, False):
return True
# resolve symlink
path = os.path.realpath(path)
for pathname in options.get_whitelist_paths():
if pathname[0] == 'file' and path == pathname[1]:
return True
if pathname[0] == 'folder':
if path == pathname[1]:
return True
if path.startswith(pathname[1] + os.sep):
return True
return False
def whitelisted_windows(path):
"""Check whether this Windows path is whitelisted"""
from bleachbit.Options import options
for pathname in options.get_whitelist_paths():
# Windows is case insensitive
if pathname[0] == 'file' and path.lower() == pathname[1].lower():
return True
if pathname[0] == 'folder':
if path.lower() == pathname[1].lower():
return True
if path.lower().startswith(pathname[1].lower() + os.sep):
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 'nt' == os.name:
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=1777139431.0
bleachbit-6.0.0/bleachbit/FontCheckDialog.py 0000664 0001750 0001750 00000006421 15173177347 016275 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()
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=1777139431.0
bleachbit-6.0.0/bleachbit/GUI.py 0000664 0001750 0001750 00000002213 15173177347 013730 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=1777139431.0
bleachbit-6.0.0/bleachbit/General.py 0000775 0001750 0001750 00000031523 15173177347 014672 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 getpass
import logging
import os
import shlex
import shutil
import subprocess
import sys
import bleachbit
logger = logging.getLogger(__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
http://docs.python.org/library/xml.dom.minidom.html"""
rc = "".join(
node.data for node in nodelist if node.nodeType == node.TEXT_NODE
)
return rc
#
# General
#
class WindowsError(Exception):
"""Dummy class for non-Windows systems"""
def __init__(self, winerror=None, *args, **kwargs):
self.winerror = winerror
super(WindowsError, self).__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 'posix' != os.name:
return
uid = get_real_uid()
logger.debug('chown(%s, uid=%s)', path, uid)
if 0 == path.find('/root'):
logger.info('chown for path /root aborted')
return
try:
os.chown(path, uid, -1)
except:
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 os.name == 'nt':
return
import gc
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 Exception:
pass
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 'posix' != os.name:
raise RuntimeError('get_real_username() requires POSIX')
sudo_user = os.getenv('SUDO_USER')
if sudo_user:
return sudo_user
try:
return os.getlogin()
except OSError:
pass
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 'posix' != os.name:
raise RuntimeError('get_real_uid() requires POSIX')
if os.getenv('SUDO_UID'):
return int(os.getenv('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:
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 # pylint: disable=import-outside-toplevel
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.isdigit():
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)
os.mkdir(path, 0o700)
if sudo_mode():
chownself(path)
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
platform -- used only for unit tests
"""
# If blank, return true.
if len(os_str) == 0:
return True
# Otherwise, check platform.
# Define the current operating system.
if platform == 'darwin':
current_os = ('darwin', '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: {sys.platform}')
# Compare current OS against required OS.
return os_str in current_os
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 = {}
try:
if sys.platform == 'win32':
kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
# Set close_fds to True to prevent Python from tracking the process
# Also prevents ResourceWarnings
kwargs['close_fds'] = True
try:
# Start the process with explicit None for stdio to prevent handle inheritance
process = subprocess.Popen(args,
stdin=None,
stdout=None,
stderr=None,
env=env, **kwargs)
# Close the process handle to prevent ResourceWarning
process.returncode = 0
# This prevents the ResourceWarning in __del__
process._handle.Close()
process._handle = None
return True
except Exception as e:
logger.warning('Failed to start process %s: %s', args, e)
return False
# Unix/Linux
pid = os.fork()
if pid == 0:
# Child process
try:
# Detach from parent session
os.setsid()
# Redirect standard streams to devnull using raw fd numbers
# (0=stdin, 1=stdout, 2=stderr) to avoid issues with pytest
# or other frameworks that replace sys.stdout/stderr objects.
devnull_fd = os.open(os.devnull, os.O_RDWR)
os.dup2(devnull_fd, 0) # stdin
os.dup2(devnull_fd, 1) # stdout
os.dup2(devnull_fd, 2) # stderr
os.close(devnull_fd)
# Set environment if needed
if env:
os.environ.clear()
os.environ.update(env)
os.execvp(args[0], args)
except Exception:
os._exit(1)
else:
return True
except subprocess.TimeoutExpired:
# This is good on Windows.
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 len(args) > 0
if not args[0]:
raise ValueError("First command argument cannot be empty")
if clean_env and isinstance(env, dict) and len(env) > 0:
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 sys.platform == 'win32':
# hide the 'DOS box' window
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
encoding = 'mbcs'
if clean_env and 'posix' == os.name:
# 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
keep_env = ('PATH', 'HOME', 'LD_LIBRARY_PATH', '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 not wait:
if run_external_nowait(args, env=env, kwargs=kwargs):
return (0, '', '')
# Use fallback method.
kwargs['start_new_session'] = True
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
if not wait:
return (0, '', '')
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=os.name == 'posix')
args = []
for arg in args0:
if os.name == 'nt' 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 sys.platform == '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
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/GtkShim.py 0000664 0001750 0001750 00000022107 15173177347 014656 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
- Provides a single source of truth for GTK availability
Scenarios when GTK is not needed or not available:
- CLI mode requested
- GTK/PyGObject not installed
- chroot
- crontab
- ssh
- limited environment variables (e.g., missing DISPLAY/XAUTHORITY)
Usage:
from bleachbit.GtkShim import Gtk, Gdk, GObject, GLib, Gio, HAVE_GTK
if HAVE_GTK:
# do GTK stuff
"""
import ctypes
import logging
import os
import sys
import tempfile
import warnings
import webbrowser
from pathlib import PureWindowsPath
from html import escape as esc
from traceback import format_exc
from bleachbit import bleachbit_exe_path
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 availability flag and placeholder objects
HAVE_GTK = False
gi = None
Gtk = None
Gdk = None
GObject = None
GLib = None
Gio = None
# Reason that GTK is unavailable
_gtk_unavailable_reason = None
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.
"""
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 os.name == 'nt'
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:
tmp_dir = os.environ.get('TEMP', tempfile.gettempdir())
html_path = os.path.join(tmp_dir, 'bleachbit_error.html')
with open(html_path, '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 os.name != 'nt':
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 os.name == 'nt':
# Windows always has a display
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 _try_import_gtk():
"""Attempt to import GTK and related libraries.
Returns:
tuple: (success: bool, reason: str or None)
"""
global gi, Gtk, Gdk, GObject, GLib, Gio
# Suppress GTK warning messages during import
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# Always try to import gi first (for gi.version reporting)
try:
import 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 typelib_dir:
logger.debug('Setting typelib search path to: %s', typelib_dir)
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}'
try:
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 = _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
if os.name == 'posix':
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}'
return True, None
def _init_gtk():
"""Initialize GTK imports. Called once at module load."""
global HAVE_GTK, _gtk_unavailable_reason
success, reason = _try_import_gtk()
HAVE_GTK = success
_gtk_unavailable_reason = reason
if not success and reason:
logger.debug('GTK not available: %s', reason)
def get_gtk_unavailable_reason():
"""Return the reason GTK is unavailable, or None if available."""
return _gtk_unavailable_reason
def require_gtk():
"""Raise an exception if GTK is not available.
Use this in modules that absolutely require GTK (e.g., GUI modules).
"""
if not HAVE_GTK:
reason = _gtk_unavailable_reason or 'unknown reason'
raise RuntimeError(f'GTK is required but not available: {reason}')
# Perform initialization at module load
_init_gtk()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/GuiApplication.py 0000664 0001750 0001750 00000043761 15173177347 016231 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, FileUtilities, GuiBasic, appicon_path, portable_mode
from bleachbit.Cleaner import backends
from bleachbit.GUI import logger
from bleachbit.GtkShim import GLib, Gdk, Gio, Gtk, require_gtk
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 os.name == 'nt':
from bleachbit import Windows
from bleachbit.FontCheckDialog import (
create_font_check_dialog,
RESPONSE_TEXT_BLURRY,
RESPONSE_TEXT_UNREADABLE,
)
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 os.name == 'nt':
# clean up nonce files https://github.com/bleachbit/bleachbit/issues/858
import atexit
atexit.register(Windows.cleanup_nonce)
def _init_windows_misc(self, auto_exit, shred_paths, uac):
application_id_suffix = ''
is_context_menu_executed = auto_exit and shred_paths
if not os.name == 'nt':
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):
"""Build the application menu
On Linux with GTK 3.24, this code is necessary but not sufficient for
the menu to work. The headerbar code is also needed.
On Windows with GTK 3.18, this code is sufficient for the menu to work.
"""
from bleachbit.Language import setup_translation
setup_translation()
# set_translation_domain() seems to have no effect.
# builder.set_translation_domain('bleachbit')
app_menu_path = bleachbit.get_share_path('app-menu.ui')
if app_menu_path:
builder = Gtk.Builder()
builder.add_from_file(app_menu_path)
menu = builder.get_object('app-menu')
self.set_app_menu(menu)
else:
logger.error('build_app_menu(): app-menu.ui not found')
# 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)
clipboard.request_targets(self.cb_clipboard_uri_received)
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 = None
# TRANSLATORS: Warning log message when attempting to paste files/folders to shred.
not_found_msg = _('No paths found in clipboard.')
if 'nt' == os.name and Gdk.atom_intern_static_string('FileNameW') in targets:
# Windows
# Use non-GTK+ functions because because GTK+ 2 does not work.
shred_paths = Windows.get_clipboard_paths()
elif Gdk.atom_intern_static_string('text/uri-list') in targets:
# Linux
shred_uris = clipboard.wait_for_contents(
Gdk.atom_intern_static_string('text/uri-list')).get_uris()
shred_paths = FileUtilities.uris_to_paths(shred_uris)
elif Gdk.atom_intern_static_string('text/plain') in targets or \
Gdk.atom_intern_static_string('UTF8_STRING') in targets:
# 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()]
if shred_paths:
GUI.shred_paths(self._window, shred_paths)
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 os.name == 'nt' 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) 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)
self.build_app_menu()
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()
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)
self._maybe_prompt_font_check()
def _should_show_font_check_dialog(self):
"""Determine whether to show the font check dialog on Windows."""
if os.name != 'nt':
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 os.name != 'nt':
return False
if not self._window:
return False
dialog = create_font_check_dialog(self._window)
response = dialog.run()
dialog.destroy()
if response == Gtk.ResponseType.YES:
options.set('font_check_completed', True)
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=1777139431.0
bleachbit-6.0.0/bleachbit/GuiBasic.py 0000775 0001750 0001750 00000026342 15173177347 015006 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 os
# local import
from bleachbit.GtkShim import Gtk, Gdk, GLib, require_gtk
from bleachbit.Language import get_text as _
from bleachbit.Options import options
if os.name == 'nt':
from bleachbit import Windows
# Ensure GTK is available for this GUI module
require_gtk()
# 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 os.name == 'nt' and not os.getenv('BB_NATIVE'):
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_file(parent, title):
"""Prompt user to select a single file"""
if os.name == 'nt' and not os.getenv('BB_NATIVE'):
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,
# TRANSLATORS: This is a label for the Open button in a file chooser dialog.
_("_Open"), Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
path = chooser.get_filename()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return path
def browse_files(parent, title):
"""Prompt user to select multiple files to delete"""
if os.name == 'nt' and not os.getenv('BB_NATIVE'):
return Windows.browse_files(parent, title)
chooser = Gtk.FileChooserDialog(title=title,
transient_for=parent,
action=Gtk.FileChooserAction.OPEN)
chooser.add_buttons(CANCEL_BUTTON_LABEL, Gtk.ResponseType.CANCEL,
DELETE_BUTTON_LABEL, Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_select_multiple(True)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
paths = chooser.get_filenames()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return paths
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_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."""
# drop privileges so the web browser is running as a normal process
if os.name == '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
import re
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 os.name == 'nt':
# 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=1777139431.0
bleachbit-6.0.0/bleachbit/GuiChaff.py 0000775 0001750 0001750 00000054704 15173177347 014777 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.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 should_stop(generated_file_names, cumulative_size=0): # pylint: disable=unused-argument
return abort_event.is_set()
return should_stop, stop_value
if stop_mode == STOP_MODE_TOTAL_SIZE:
target_bytes = stop_value * 1024 * 1024 # MB to bytes
def should_stop(generated_file_names, cumulative_size=0): # pylint: disable=unused-argument
if abort_event.is_set():
return True
return cumulative_size >= target_bytes
return should_stop, MAX_FILE_COUNT
if stop_mode == STOP_MODE_FREE_SPACE:
target_free_pct = stop_value
def should_stop(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 should_stop, 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_cb(fraction, generated_file_names=None, cumulative_size=0): # pylint: disable=unused-argument
on_progress(fraction)
return progress_cb
if stop_mode == STOP_MODE_TOTAL_SIZE:
target_bytes = stop_value * 1024 * 1024
def progress_cb(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_cb
if stop_mode == STOP_MODE_FREE_SPACE:
target_free_pct = stop_value
initial_free_pct = [None] # Use list to allow mutation in nested function
def progress_cb(_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_cb
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(Gtk.Dialog):
"""Present the dialog to make chaff"""
_infobar_timeout_id = None
_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.
Gtk.Dialog.__init__(self, title=_("Make chaff"), 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.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)
box.pack_start(self.infobar, False, False, 0)
self._infobar_timeout_id = None
# 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)
# 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_infobar_response(self, _infobar, _response_id):
"""Handle InfoBar close button click"""
if self._infobar_timeout_id:
GLib.source_remove(self._infobar_timeout_id)
self._infobar_timeout_id = None
self.infobar.hide()
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 _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"""
if self._infobar_timeout_id:
GLib.source_remove(self._infobar_timeout_id)
self._infobar_timeout_id = None
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(
15, self._hide_infobar)
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)
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)
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=1777139431.0
bleachbit-6.0.0/bleachbit/GuiCookie.py 0000664 0001750 0001750 00000036144 15173177347 015174 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 .
# 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.Constant import URL_COOKIE_MGR
from bleachbit.Cookie import list_unique_cookies, COOKIE_KEEP_LIST_FILENAME
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 = self._load_saved_domains()
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:
with open(self.keep_list_path, "w", encoding="utf-8") as f:
json.dump(keep_list, f, indent=2)
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]
def _load_saved_domains(self):
"""Load saved cookie hostnames from disk."""
try:
with open(self.keep_list_path, "r", encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return set()
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Failed to read cookie keep list %s: %s",
self.keep_list_path, exc)
return set()
domains = set()
if isinstance(data, list):
for item in data:
if isinstance(item, str):
candidate = item
elif isinstance(item, dict):
candidate = item.get("domain")
else:
candidate = None
if isinstance(candidate, str) and candidate:
domains.add(candidate.strip())
return domains
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/GuiPreferences.py 0000664 0001750 0001750 00000124662 15173177347 016227 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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
import os
# first party imports
from bleachbit import GuiBasic
from bleachbit import online_update_notification_enabled
from bleachbit import ProtectedPath
from bleachbit.Constant import EMPTY_SPACE_WARNING, REQUIRES_EXPERT_MODE
from bleachbit.GtkShim import Gtk, GLib
from bleachbit.GuiCookie import CookieManagerPane
from bleachbit.GuiUtil import (detect_dark_background, flush_gtk_events,
should_show_dark_mode_warning)
from bleachbit.Language import 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:
"""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.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)
self.dialog.get_content_area().pack_start(self.infobar, False, False, 0)
self._infobar_timeout_id = None
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 __del__(self):
"""Destructor called when the dialog is closing"""
if self.refresh_operations:
# refresh the list of cleaners
self.cb_refresh_operations()
def _on_infobar_response(self, _infobar, _response_id):
"""Handle InfoBar close button click"""
if self._infobar_timeout_id:
GLib.source_remove(self._infobar_timeout_id)
self._infobar_timeout_id = None
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.)
"""
# Cancel any existing timeout
if self._infobar_timeout_id:
GLib.source_remove(self._infobar_timeout_id)
self._infobar_timeout_id = None
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(
15, self._hide_infobar)
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 'nt' == os.name:
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 'nt' == os.name 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 os.name == 'nt' 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 'nt' == os.name:
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):
"""Create and configure language selection widgets."""
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.
lang_label = Gtk.Label(label=_("Language:"))
lang_label.set_margin_start(20) # Add some indentation
self.lang_select_box.pack_start(lang_label, False, True, 5)
self.lang_combo = Gtk.ComboBoxText()
current_lang_code = get_active_language_code()
# Add available languages
lang_idx = 0
active_language_idx = None
try:
supported_langs = get_supported_language_code_name_dict().items()
except (KeyError, ValueError, Exception) as e:
logger.error("Failed to get list of supported languages: %s", e)
supported_langs = [('en_us', 'English')]
for lang_code, native in supported_langs:
if native:
self.lang_combo.append_text(f"{native} ({lang_code})")
else:
self.lang_combo.append_text(lang_code)
if lang_code == current_lang_code:
active_language_idx = lang_idx
lang_idx += 1
if active_language_idx is not None:
self.lang_combo.set_active(active_language_idx)
# 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.lang_select_box.set_sensitive(not is_auto_detect)
self.cb_auto_lang.connect('toggled', self.on_auto_detect_toggled)
self.lang_combo.connect('changed', self.on_lang_changed)
def on_lang_changed(self, widget):
"""Callback for when the language combobox is changed."""
text = widget.get_active_text()
# 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.lang_select_box.set_sensitive(not is_auto_detect)
if is_auto_detect:
options.set("forced_language", "", 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)
# 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.
self._create_checkbox(
_("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 'nt' == os.name:
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 'nt' != os.name:
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 None == _iter:
# 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 = options.get_list('shred_drives')
if pathnames:
pathnames = sorted(pathnames)
if not pathnames:
pathnames = []
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"))
self.__create_language_widgets(ui_language_box)
# Windows does not have locale cleaner.
if 'posix' != os.name:
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)
for lang, native in get_supported_language_code_name_dict().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]:
# 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]:
# 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 = ProtectedPath._normalize_for_comparison(
pathname, 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
liststore.append([type_str, pathname])
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") % pathname,
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 None == _iter:
return
pathname = model[_iter][1]
liststore.remove(_iter)
for this_pathname in pathnames:
if this_pathname[1] == pathname:
pathnames.remove(this_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 removed
self.show_infobar(_("Removed: %s") % pathname,
Gtk.MessageType.INFO)
break
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]
liststore.append([type_str, 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 = Gtk.Image.new_from_icon_name(
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(
label=_p('button', 'Add file'))
button_add_file.connect("clicked", add_file_cb)
button_add_folder = Gtk.Button.new_with_label(
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()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/GuiStartup.py 0000664 0001750 0001750 00000012701 15173177347 015416 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 os
import sys
from importlib import import_module
from bleachbit.Language import get_text as _
from bleachbit.Network import unset_sslkeylogfile
from bleachbit.Options import options
if os.name == 'nt':
from bleachbit import Windows
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
"""
deps = ['chardet', 'psutil', 'requests', 'urllib3']
if os.name == 'nt':
deps.append('plyer')
missing = []
for dep in deps:
try:
import_module(dep)
except ImportError:
missing.append(dep)
return sorted(missing)
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 = []
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))
# Show version update advice for upgrades from <5.1.0 to >=5.1.0
old_version = options.get_old_version()
if old_version and _is_version_upgrade(old_version, "5.1.0"):
ret_msgs.append((
# TRANSLATORS: Update notification for users upgrading from versions
# older than 5.1.0.
_('Cleaning options with warnings now require expert mode. Enable it in '
'preferences to clean them.'), 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 os.name == '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 os.name == 'nt':
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 os.name == '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 os.name == 'nt':
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))
return ret_msgs
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/GuiTreeModels.py 0000664 0001750 0001750 00000022436 15173177347 016025 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
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
return
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 Exception("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 = []
for key in sorted(backends):
if not any(backends[key].get_options()):
# localizations has no options, so it should be hidden
# https://github.com/az0/bleachbit/issues/110
continue
c_name = backends[key].get_name()
c_id = backends[key].get_id()
c_value = options.get_tree(c_id, None)
if not c_value and options.get('auto_hide') and backends[key].auto_hide():
hidden_cleaners.append(c_id)
continue
parent = self.tree_store.append(
None, (c_name, c_value, c_id, "", ""))
for (o_id, o_name) in backends[key].get_options():
o_value = options.get_tree(c_id, o_id)
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
"""
value1 = model[iter1][2].lower()
value2 = model[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=1777139431.0
bleachbit-6.0.0/bleachbit/GuiUtil.py 0000664 0001750 0001750 00000015444 15173177347 014700 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 os
import threading
from enum import Enum
from typing import Optional
from bleachbit import APP_NAME
from bleachbit.GUI import logger
from bleachbit.GtkShim import GLib, Gdk, Gtk, gi
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 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)
class ThemeChangeStatus(Enum):
"""State machine for detecting whether a theme update occurred."""
CHANGED = "changed"
UNCHANGED = "unchanged"
UNKNOWN = "unknown"
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 lookup:
rgba = lookup[-1] if isinstance(lookup, tuple) else 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 classify_theme_change(before_dark: Optional[bool], after_dark: Optional[bool]) -> ThemeChangeStatus:
"""Compare observations before and after a toggle to classify change."""
if before_dark is None or after_dark is None:
return ThemeChangeStatus.UNKNOWN
if before_dark == after_dark:
return ThemeChangeStatus.UNCHANGED
return ThemeChangeStatus.CHANGED
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."""
status = classify_theme_change(before_dark, after_dark)
# Warn when the theme did not change or when we cannot tell (UNKNOWN).
return status != ThemeChangeStatus.CHANGED
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 notify(msg):
"""Show a popup-notification"""
import importlib
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
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 os.name == 'nt':
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=1777139431.0
bleachbit-6.0.0/bleachbit/GuiWindow.py 0000664 0001750 0001750 00000141474 15173177347 015235 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 time
import bleachbit
from bleachbit import APP_NAME, Cleaner, FileUtilities, GuiBasic, appicon_path, windows10_theme_path
from bleachbit.Cleaner import backends, register_cleaners
from bleachbit.Constant import ABORT_BUTTON_LABEL, REQUIRES_EXPERT_MODE
from bleachbit.GUI import logger
from bleachbit.GtkShim import GLib, Gdk, Gio, Gtk, require_gtk
from bleachbit.GuiPreferences import PreferencesDialog
from bleachbit.GuiStartup import get_startup_messages
from bleachbit.GuiTreeModels import TreeDisplayModel, TreeInfoModel
from bleachbit.GuiUtil import (detect_dark_background, get_font_size_from_name,
get_window_info, notify, threaded)
from bleachbit.Language import get_text as _
from bleachbit.Options import options
from bleachbit.Wipe import detect_orphaned_wipe_files
if os.name == 'nt':
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()
class GUI(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
def __init__(self, auto_exit, *args, **kwargs):
super(GUI, self).__init__(*args, **kwargs)
self._show_splash_screen()
self._auto_exit = auto_exit
self._infobar_timeout_id = 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)
# Enable the user to change font size with keyboard or mouse.
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
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)
# add headerbar
self.headerbar = self.create_headerbar()
self.set_titlebar(self.headerbar)
# 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.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)
vbox.pack_start(self.infobar, False, False, 0)
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()
# done
self.show_all()
self.progressbar.hide()
self.infobar.hide()
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 'nt' == os.name:
return
if not options.get("win10_theme"):
return
# 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 'nt' == os.name:
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 os.name != 'nt':
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:
Windows.splash_thread.start()
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
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", 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 or CTRL+W"""
if Gtk.main_level() > 0:
Gtk.main_quit()
else:
self.destroy()
def _on_infobar_response(self, _infobar, _response_id):
"""Handle InfoBar close button click"""
timeout_id = getattr(self, '_infobar_timeout_id', None)
if timeout_id is not None:
GLib.source_remove(timeout_id)
self._infobar_timeout_id = None
self.infobar.hide()
def _hide_infobar(self):
"""Hide the InfoBar (used for auto-dismiss timeout)"""
self.infobar.hide()
self._infobar_timeout_id = None
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.)
"""
# Cancel any existing timeout before creating a new one
timeout_id = getattr(self, '_infobar_timeout_id', None)
if timeout_id is not None:
GLib.source_remove(timeout_id)
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(
15, self._hide_infobar)
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.textbuffer = None
super(GUI, self).destroy()
def get_preferences_dialog(self):
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)
if pref.refresh_operations:
self.cb_refresh_operations()
self.update_log_level()
def shred_paths(self, paths, shred_settings=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)
if not self._confirm_delete(False, shred_settings):
# User dis-confirmed the deletion.
return False
# 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 self.textbuffer is None:
# textbuffer was destroyed.
return
if not __iter:
__iter = self.textbuffer.get_end_iter()
# Sanitize text to handle surrogate characters that GTK cannot encode.
# Surrogates (like \udcd6) can appear in filenames from the filesystem
# and cause UnicodeEncodeError when GTK tries to insert them.
text = text.encode('utf-8', errors='replace').decode('utf-8')
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:
GLib.idle_add(lambda: self.textbuffer is not None and
self.textview.scroll_mark_onscreen(
self.textbuffer.get_insert()))
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"""
ret = []
model = self.tree_store.get_model()
path = Gtk.TreePath(0)
__iter = model.get_iter(path)
while __iter:
if model[__iter][1]:
ret.append(model[__iter][2])
__iter = model.iter_next(__iter)
return ret
def get_operation_options(self, operation):
"""For the given operation ID, return a list of the selected option IDs."""
ret = []
model = self.tree_store.get_model()
path = Gtk.TreePath(0)
__iter = model.get_iter(path)
while __iter:
if operation == model[__iter][2]:
iterc = model.iter_children(__iter)
if not iterc:
return None
while iterc:
if model[iterc][1]:
# option is enabled
ret.append(model[iterc][2])
iterc = model.iter_next(iterc)
return ret
__iter = model.iter_next(__iter)
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_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 backends[cleaner_id].get_warning(option_id):
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')
else:
safe_options.append(option_id)
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"""
self.progressbar.set_text("")
self.progressbar.set_fraction(1)
# TRANSLATORS: Status message shown on the progress bar and in a popup
# notification.
done_msg = _("Done.")
self.progressbar.set_text(done_msg)
self.textview.scroll_mark_onscreen(self.textbuffer.get_insert())
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"""
# In case language changed, update the header bar labels.
self.update_headerbar_labels()
# Is this the first time in this session?
if not hasattr(self, 'recognized_cleanerml') and not self._auto_exit:
from bleachbit import RecognizeCleanerML
RecognizeCleanerML.RecognizeCleanerML()
self.recognized_cleanerml = True
# reload cleaners from disk
self.view.expand_all()
self.progressbar.show()
rc = register_cleaners(self.update_progress_bar,
self.cb_register_cleaners_done)
GLib.idle_add(rc.__next__)
return False
def cb_register_cleaners_done(self):
"""Called from register_cleaners()"""
self.progressbar.hide()
# update tree view
self.tree_store.refresh_rows()
# expand tree view
self.view.expand_all()
if self._showed_startup_messages:
# remove from idle loop (see GObject.idle_add)
return False
startup_msgs = get_startup_messages(self._auto_exit)
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()
self._showed_startup_messages = True
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)
if not cleaner:
return False
actions = getattr(cleaner, 'actions', ())
for opt_id, action in actions:
if opt_id == option_id and getattr(action, 'action_key', None) == 'cookie':
return True
return False
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(None, None, None, None, event.button, event.time)
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)
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)
self.textview.connect('drag_motion', lambda *_: True)
def update_progress_bar(self, status):
"""Callback to update the progress bar with number or text"""
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()
text = FileUtilities.bytes_to_human(bytes_removed)
if bytes_removed == 0:
text = ""
treepath = Gtk.TreePath(0)
try:
__iter = model.get_iter(treepath)
except ValueError:
logger.warning(
'ValueError in get_iter() when updating file size for tree path=%s', treepath)
return
while __iter:
if model[__iter][2] == option:
if option_id == -1:
model[__iter][3] = text
else:
child = model.iter_children(__iter)
while child:
if model[child][2] == option_id:
model[child][3] = text
child = model.iter_next(child)
__iter = model.iter_next(__iter)
def update_total_size(self, bytes_removed):
"""Callback to update the total size cleaned"""
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"""
# 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 os.name == 'nt':
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(
'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(
'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(
'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.
# This is not needed for Microsoft Windows because other code places its
# menu on the left side.
if os.name == 'nt':
return hbar
menu_button = Gtk.MenuButton()
icon = Gio.ThemedIcon(name="open-menu-symbolic")
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
builder = Gtk.Builder()
app_menu_path = bleachbit.get_share_path('app-menu.ui')
if app_menu_path:
builder.add_from_file(app_menu_path)
menu_button.set_menu_model(builder.get_object('app-menu'))
menu_button.add(image)
hbar.pack_end(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 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 'nt' == os.name:
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 'nt' == os.name:
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.
"""
if 'nt' == os.name 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 r.x >= g.x and r.x < g.x + g.width and \
r.y >= g.y and 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 os.name == 'nt':
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."""
orphaned_files = detect_orphaned_wipe_files()
if not orphaned_files:
return
# 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)
@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=1777139431.0
bleachbit-6.0.0/bleachbit/Language.py 0000664 0001750 0001750 00000032630 15173177347 015035 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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 .
import gettext
import os
import logging
import sys
logger = logging.getLogger(__name__)
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',
'es_419': 'Latin American Spanish',
'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:
pass
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 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 options.get('forced_language')
import locale
# 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 os.name == 'nt':
import ctypes
kernel32 = ctypes.windll.kernel32
lcid = kernel32.GetUserDefaultLCID()
# Convert Windows LCID (e.g., 1033) to RFC1766 (e.g., en-US).
user_locale = locale.windows_locale.get(lcid, '')
else:
user_locale = locale.getlocale()[0]
if not user_locale:
user_locale = 'C'
logger.warning("no default locale found. Assuming '%s'", user_locale)
if '.' in user_locale:
# This should never happen.
logger.warning('locale contains a dot: %s', user_locale)
user_locale = user_locale.split('.')[0]
assert isinstance(user_locale, str)
assert len(
user_locale) >= 2 or user_locale == 'C', f"user_locale: {user_locale}"
return user_locale
def setup_translation():
"""Do a one-time setup of translations"""
global attempted_setup_translation, t
attempted_setup_translation = True
# Use local import to avoid circular import.
from bleachbit import locale_dir
user_locale = get_active_language_code()
logger.debug(f"user_locale: {user_locale}, locale_dir: {locale_dir}")
assert isinstance(user_locale, str)
assert isinstance(locale_dir, str), f"locale_dir: {locale_dir}"
if 'win32' == sys.platform and user_locale:
os.environ['LANG'] = 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
import locale
if hasattr(locale, 'bindtextdomain'):
locale.bindtextdomain(text_domain, locale_dir)
locale.textdomain(text_domain)
elif 'nt' == os.name:
from bleachbit.Windows import load_i18n_dll
libintl = load_i18n_dll()
if not libintl:
logger.error(
'The internationalization library is not available.')
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')
else:
logger.error(
'The function wbindtextdomain() is not available.')
# Log for debugging
logger.debug(
f"Windows translation domain set to: {text_domain}, dir: {locale_dir}")
else:
logger.error('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 os.name == '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(str):
"""Return translated string
The name has an underscore to avoid conflicting with gettext module.
"""
global attempted_setup_translation, t
if not attempted_setup_translation:
setup_translation()
if not t:
return str
return t.gettext(str)
def nget_text(singular, plural, n):
"""Return translated string with plural variant"""
global t
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
"""
global t
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=1777139431.0
bleachbit-6.0.0/bleachbit/Log.py 0000664 0001750 0001750 00000011206 15173177347 014027 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 sys
def is_debugging_enabled_via_cli():
"""Return boolean whether user required debugging on the command line"""
if 'unittest' in sys.modules:
return True
return any(arg.startswith('--debug') for arg in sys.argv)
class DelayLog(object):
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[-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:
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[-1] == '\n':
tag = None
self.append_text(msg, tag)
self.msg = ''
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/Memory.py 0000775 0001750 0001750 00000026561 15173177347 014573 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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.Language import get_text as _
from bleachbit.Wipe import wipe_contents
logger = logging.getLogger(__name__)
def count_swap_linux():
"""Count the number of swap devices in use"""
count = 0
with open("/proc/swaps") 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(['swapon', '-s'])
if 0 == rc:
return stdout
logger.debug(
_("The command 'swapoff -s' failed, so falling back to /proc/swaps for swap information."))
return open("/proc/swaps").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
logger.debug(_("Disabling swap."))
args = ["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 = ["swapon", "-a"]
p = subprocess.Popen(args, stderr=subprocess.PIPE)
p.wait()
outputs = p.communicate()
if 0 != p.returncode:
raise RuntimeError(outputs[1].replace("\n", ""))
def make_self_oom_target_linux():
"""Make the current process the primary target for Linux out-of-memory killer"""
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:
uid = General.get_real_uid()
if uid > 0:
# 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.
drop_msg = _("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:
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:
pass
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 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)
for line in proc_swaps.split('\n')[1:]:
ret = re.search(r"%s\s+\w+\s+([0-9]+)\s" % device, 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 = ['blkid', device, '-s', 'UUID']
(_rc, stdout, _stderr) = General.run_external(args)
for line in stdout.split('\n'):
# example: /dev/sda5: UUID="ee0e85f6-6e5c-42b9-902f-776531938bbf"
ret = re.search(r"^%s: UUID=\"([a-z0-9-]+)\"" % device, 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():
return subprocess.check_output(["vm_stat"])
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 physical_free_linux():
"""Return the physical free memory on Linux"""
free_bytes = 0
with open("/proc/meminfo") as f:
for line in f:
line = line.replace("\n", "")
ret = re.search(r'(MemFree|Cached):[ ]*([0-9]*) kB', line)
if ret is not None:
kb = int(ret.group(2))
free_bytes += kb * 1024
if free_bytes > 0:
return free_bytes
else:
raise Exception("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 sys.platform == 'linux':
return physical_free_linux()
elif 'win32' == sys.platform:
return physical_free_windows()
elif 'darwin' == sys.platform:
return physical_free_darwin()
else:
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:
# if '/cryptswap' in device:
# logger.info('Skipping encrypted swap device %s.', device)
# continue
# 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)
# wipe
wipe_contents(device, truncate=False)
# reinitialize
# TRANSLATORS: The variable is a device like /dev/sda2
logger.debug(_("Reinitializing the swap device %s."), device)
args = ['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"""
for cmd in ('swapon', 'swapoff', 'blkid'):
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
child_pid = os.fork()
if 0 == child_pid:
make_self_oom_target_linux()
fill_memory_linux()
os._exit(0)
else:
# 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})
rc = os.waitpid(child_pid, 0)[1]
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=1777139431.0
bleachbit-6.0.0/bleachbit/Network.py 0000664 0001750 0001750 00000021157 15173177347 014745 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 socket
import struct
import sys
import platform
from collections.abc import Callable
try:
from urllib3.util.retry import Retry
HAVE_URLLIB3 = True
except ImportError:
HAVE_URLLIB3 = False
# third party
import requests
# local imports
from bleachbit import bleachbit_exe_path, APP_VERSION
from bleachbit.FileUtilities import delete
from bleachbit.Language import get_active_language_code, get_text as _
logger = logging.getLogger(__name__)
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 os.name == 'nt':
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
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)
except requests.exceptions.RequestException as exc:
# For retryable errors (like 503), use a simplified error message
if 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
fn_dir = os.path.dirname(fn)
if not os.path.exists(fn_dir):
os.makedirs(fn_dir)
with open(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)
assert url.startswith('http'), 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 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
def _get_os_name_version():
"""Return (os_name, os_version) tuple for network requests."""
os_name = platform.system() # 'Linux', 'Windows', etc.
if sys.platform == 'linux':
# pylint: disable=import-outside-toplevel
from bleachbit.Unix import get_distribution_name_version
os_version = get_distribution_name_version()
elif sys.platform[:6] == 'netbsd':
os_version = os_name + '/' + platform.machine() + ' ' + platform.release()
else:
os_version = platform.uname().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 os.name == 'nt':
headers['X-Python-Version'] = platform.python_version()
headers['X-Pointer-Bits'] = str(8 * struct.calcsize('P'))
return headers
def get_gtk_version():
"""Return the version of GTK
If GTK is not available, returns None.
"""
# pylint: disable=import-outside-toplevel, import-error
from bleachbit.GtkShim import Gtk, HAVE_GTK
if not HAVE_GTK:
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}')
agent = f"BleachBit/{APP_VERSION} ({'; '.join(parts)})"
return agent
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/Options.py 0000664 0001750 0001750 00000051655 15173177347 014755 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 errno
import logging
import os
import re
import threading
# third-party imports
if 'nt' == os.name:
from win32file import GetLongPathName
# local application imports
import bleachbit
from bleachbit import General
from bleachbit.Language import get_text as _
logger = logging.getLogger(__name__)
FLUSH_DELAY_SECS = 15.0 # decimal seconds
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 = [
key for key, meta in OPTION_DEFAULTS.items()
if _platform_allows(meta)
]
int_keys = ['window_x', 'window_y', 'window_width', 'window_height', ]
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 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 'nt' == os.name and os.path.exists(pathname):
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 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(bleachbit.options_file, 'w', encoding='utf-8-sig') as f_ini:
f_ini.write('[bleachbit]\n')
if os.name == 'nt' 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 = bleachbit.RawConfigParser()
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 = {}
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()
try:
if not os.path.exists(bleachbit.options_dir):
General.makedirs(bleachbit.options_dir)
mkfile = not os.path.exists(bleachbit.options_file)
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') 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 __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 'nt' == os.name and re.search(r'^[a-z]\\', 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:
# 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 __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']):
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 'nt' == os.name 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
values = [
self.config.get(section, opt)
for opt in sorted(self.config.options(section), key=_option_index)
]
return values
def get_paths(self, section):
"""Abstracts get_whitelist_paths and get_custom_paths"""
if not self.config.has_section(section):
return []
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))
return values
def get_whitelist_paths(self):
"""Return the keep list (formerly whitelist) of paths"""
return self.get_paths("whitelist/paths")
def get_custom_paths(self):
"""Return list of custom paths"""
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"
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"
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:
# 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
# Reading configuration merges with existing data,
# so clear it first.
for section in self.config.sections():
self.config.remove_section(section)
try:
self.config.read(bleachbit.options_file, encoding='utf-8-sig')
except:
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")
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:
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)
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 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}"
# 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_whitelist_paths(self, values):
"""Save the keep list (formerly whitelist)"""
section = "whitelist/paths"
# 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) + '_type', value[0])
self.config.set(section, str(counter) + '_path', value[1])
self.__schedule_flush()
def set_custom_paths(self, values):
"""Save the custom paths
@param values: list of tuples containing (path_type, path)
where path_type is either 'file' or 'folder'
"""
section = "custom/paths"
# 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):
path_type, path = value
assert path_type in ('file', 'folder')
self.config.set(section, str(counter) + '_type', path_type)
self.config.set(section, str(counter) + '_path', path)
self.__schedule_flush()
def set_language(self, langid, value):
"""Set the value for a locale (whether to preserve it)"""
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."""
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
def get_old_version(self):
"""Get the previous version before current upgrade"""
return self.old_version
options = Options()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/ProtectedPath.py 0000664 0001750 0001750 00000027274 15173177347 016070 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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 re
import xml.dom.minidom
import bleachbit
from bleachbit import FileUtilities
from bleachbit.General import getText, os_match
from bleachbit.Language import get_text as _
logger = logging.getLogger(__name__)
# Cache for loaded protected paths
_protected_paths_cache = None
_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 _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.
"""
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,)
# this function is used in GuiPreferences.py
def _normalize_for_comparison(path, case_sensitive):
"""Normalize a path for comparison.
Args:
path: The path to normalize
case_sensitive: If False, convert to lowercase for comparison
"""
normalized = os.path.normpath(path)
if not case_sensitive:
normalized = normalized.lower()
return normalized
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
"""
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 = []
try:
dom = xml.dom.minidom.parse(xml_path)
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:
# Default: Windows is case-insensitive, others are case-sensitive
case_sensitive = os.name != 'nt'
# 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
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')
case_sensitive = os.name != 'nt'
user_path_normalized = _normalize_for_comparison(
user_path, case_sensitive=case_sensitive)
for path in exempt_paths:
exempt_expanded = _expand_path(path)
if not exempt_expanded:
continue
exempt_normalized = _normalize_for_comparison(
exempt_expanded, case_sensitive=case_sensitive)
if user_path_normalized == exempt_normalized:
return True
exempt_with_sep = exempt_normalized + os.sep
if user_path_normalized.startswith(exempt_with_sep):
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
# Normalize the user path
user_path_norm = os.path.normpath(user_path)
for ppath in protected_paths:
protected = ppath['path']
depth = ppath['depth']
case_sensitive = ppath['case_sensitive']
# Normalize both paths for comparison
if case_sensitive:
user_cmp = user_path_norm
protected_cmp = protected
else:
user_cmp = user_path_norm.lower()
protected_cmp = protected.lower()
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 user_cmp == protected_cmp:
return ppath
relative_suffix = os.sep + protected_cmp.lstrip(os.sep)
if user_cmp.endswith(relative_suffix):
return ppath
continue
# Exact match
if user_cmp == protected_cmp:
return ppath
# Check if user path is a parent of protected path
# (user wants to delete a folder that contains protected items)
protected_with_sep = protected_cmp + os.sep
user_with_sep = user_cmp + os.sep
if protected_cmp.startswith(user_with_sep):
return ppath
# Check if user path is a child of protected path (within depth)
if (depth is None or depth > 0) and user_cmp.startswith(protected_with_sep):
if depth is None:
return ppath
# Calculate how many levels deep the user path is
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):
pass
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."""
global _protected_paths_cache
_protected_paths_cache = None
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/RecognizeCleanerML.py 0000775 0001750 0001750 00000014064 15173177347 016766 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 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
import bleachbit
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
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 = Gtk.Image()
image.set_from_icon_name("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 0 == len(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"""
# hashlib requires Python 2.5
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
try:
self.salt = options.get('hashsalt')
except bleachbit.NoOptionError:
self.salt = hashdigest(os.urandom(512))
options.set('hashsalt', self.salt)
self.__scan()
def __recognized(self, pathname):
"""Is pathname recognized?"""
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)
try:
known_hash = options.get_hashpath(pathname)
except bleachbit.NoOptionError:
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 NEW == status or CHANGED == status:
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=1777139432.0
bleachbit-6.0.0/bleachbit/Revision.py 0000664 0001750 0001750 00000000025 15173177350 015073 0 ustar 00z z revision = "0c2e7d4"
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/Special.py 0000775 0001750 0001750 00000050672 15173177347 014703 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 json
import logging
import os
import sqlite3
import xml.dom.minidom
from urllib.parse import quote, urlparse, urlunparse
# local application imports
from bleachbit import FileUtilities
from bleachbit.Options import options
logger = logging.getLogger(__name__)
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]
assert ver > 1
return ver
def _sqlite_readonly_uri(pathname):
"""Return a proper SQLite URI for read-only access to pathname"""
assert isinstance(pathname, str)
abs_path = os.path.abspath(pathname)
if os.name == 'nt':
abs_path = abs_path.replace('\\', '/')
quoted = quote(abs_path, safe='/:')
return f'file:{quoted}?mode=ro'
def sqlite_table_exists(pathname, table):
"""Check whether a table exists in the SQLite database"""
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:
# Database does not exist or cannot be opened in read-only mode
return False
return False
def _sqlite_is_valid_database(pathname):
"""Return boolean indicating whether pathname points to a readable SQLite database."""
try:
with contextlib.closing(sqlite3.connect(f'file:{pathname}?mode=ro', 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 = ""
if cols and options.get('shred'):
for blob_type in ('randomblob', 'zeroblob'):
updates = [f'{col} = {blob_type}(length({col}))' for col in cols]
cmd += f"update or ignore {table} set {', '.join(updates)} {where};"
cmd += f"delete from {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"""
with contextlib.closing(sqlite3.connect(f'file:{path}?mode=ro', 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 \"{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 \"{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"""
dom1 = xml.dom.minidom.parse(path)
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 open(path, 'w', 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)"""
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 delete_mozilla_favicons(path):
"""Delete favorites icons in Mozilla places.favicons
Bookmarks are not deleted."""
def remove_path_from_url(url):
url = urlparse(url.lstrip('fake-favicon-uri:'))
return urlunparse((url.scheme, url.netloc, '', '', '', ''))
cmds = ""
places_path = os.path.join(os.path.dirname(path), 'places.sqlite')
cmds += f'attach database "{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)
# 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 = list(map(remove_path_from_url, bookmarked_urls))
ids_to_delete = [id for id, url in id_and_url_pairs
if (
# Collect only favicons with not bookmarked
# urls with same domain or their domain is a
# part of a bookmarked url but the favicons are
# not domain level. In other words, collect all
# that are not bookmarked.
remove_path_from_url(url) not in bookmarked_urls_domains or
urlparse(url).path.count('/') > 1
)
]
# delete all not bookmarked icons
icons_where = f"where (id in ({str(ids_to_delete).replace('[', '').replace(']', '')}))"
cols = ('icon_url', 'data')
cmds += __shred_sqlite_char_columns('moz_icons', cols, icons_where, path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_ooo_history(path):
"""Erase the OpenOffice.org MRU in Common.xcu. No longer valid in Apache OpenOffice.org 3.4."""
dom1 = xml.dom.minidom.parse(path)
changed = False
for node in dom1.getElementsByTagName("node"):
if node.hasAttribute("oor:name"):
if "History" == node.getAttribute("oor:name"):
node.parentNode.removeChild(node)
node.unlink()
changed = True
break
if changed:
dom1.writexml(open(path, "w", encoding='utf-8'))
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 = []
for url in urls:
ids += get_sqlite_int(
history_path, 'select id from urls where url=?', (url,))
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']:
get_chrome_bookmark_urls_helper(js['roots'][node])
return list(set(urls)) # unique
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/SystemInformation.py 0000775 0001750 0001750 00000016633 15173177347 017014 0 ustar 00z z
# vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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 .
"""
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.General import get_executable, get_real_uid
logger = logging.getLogger(__name__)
def get_gtk_info():
"""Get dictionary of information about GTK"""
# pylint: disable=import-outside-toplevel
from bleachbit.GtkShim import gi, Gtk, HAVE_GTK, 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 HAVE_GTK:
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 os.name == 'posix':
real_home_dir = ''
try:
# reminder: pwd is not available on Windows
import pwd # pylint: disable=import-outside-toplevel
real_home_dir = pwd.getpwuid(get_real_uid()).pw_dir
except (ImportError, KeyError, RuntimeError, ValueError):
pass
home_dirs.append(real_home_dir)
# 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 os.name == '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 os.name == 'nt':
# 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('APPVEYOR_BUILD_NUMBER')
build_number_src = None
try:
# pylint: disable=import-outside-toplevel
from bleachbit.Revision import build_number as build_number_import
build_number_src = build_number_import
except ImportError:
pass
build_number = build_number_src or build_number_env
if not build_number:
if not four_parts:
return bleachbit.APP_VERSION
return f'{bleachbit.APP_VERSION}.0'
assert build_number.isdigit()
return f'{bleachbit.APP_VERSION}.{build_number}'
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.
# pylint: disable=import-outside-toplevel
from bleachbit.Revision import revision
info['Git revision'] = revision
except ImportError:
pass
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 'posix' == os.name:
envs = ('DESKTOP_SESSION', 'LOGNAME', 'USER', 'SUDO_UID')
elif 'nt' == os.name:
envs = ('APPDATA', 'cd', 'LocalAppData', 'LocalAppDataLow', 'Music',
'USERPROFILE', 'ProgramFiles', 'ProgramW6432', 'TMP')
else:
envs = ()
for env in envs:
info[f'os.getenv({env})'] = os.getenv(env)
info['os.path.expanduser(~")'] = os.path.expanduser('~')
# Mac Version Name - Dictionary
macosx_dict = {'5': 'Leopard', '6': 'Snow Leopard', '7': 'Lion', '8': 'Mountain Lion',
'9': 'Mavericks', '10': 'Yosemite', '11': 'El Capitan', '12': 'Sierra'}
if sys.platform == 'linux':
from bleachbit.Unix import get_distribution_name_version
info['get_distribution_name_version()'] = get_distribution_name_version()
elif sys.platform.startswith('darwin'):
if hasattr(platform, 'mac_ver'):
mac_version = platform.mac_ver()[0]
version_minor = mac_version.split('.')[1]
if version_minor in macosx_dict:
info['platform.mac_ver()'] = f'{mac_version} ({macosx_dict[version_minor]})'
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 'nt' == os.name:
from win32com.shell import shell
info['IsUserAnAdmin()'] = shell.IsUserAnAdmin()
info['__file__'] = __file__
# Render the information as a string
return '\n'.join(f'{key} = {value}' for key, value in info.items())
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/Unix.py 0000775 0001750 0001750 00000102400 15173177347 014231 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# 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 .
"""
Integration specific to Unix-like operating systems
"""
import configparser
import glob
import logging
import os
import platform
import re
import shlex
import subprocess
import sys
import bleachbit
from bleachbit import FileUtilities, General
from bleachbit.General import get_real_uid, get_real_username
from bleachbit.FileUtilities import exe_exists
from bleachbit.Language import get_text as _, native_locale_names
logger = logging.getLogger(__name__)
try:
Pattern = re.Pattern
except AttributeError:
Pattern = re._pattern_type
JOURNALD_REGEX = r'^Vacuuming done, freed ([\d.]+[BKMGT]?) of archived journals (on disk|from [\w/]+).$'
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):
if location is None:
raise RuntimeError("location is none")
self.pattern = location
self.children = []
def add_child(self, child):
"""Adds a child LocaleCleanerPath"""
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 get_subpaths(self, basepath):
"""Returns direct subpaths for this object, i.e. either the named subfolder or all
subfolders matching the pattern"""
if isinstance(self.pattern, Pattern):
return (os.path.join(basepath, p) for p in os.listdir(basepath)
if self.pattern.match(p) and os.path.isdir(os.path.join(basepath, p)))
path = os.path.join(basepath, self.pattern)
return [path] if os.path.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, Pattern):
for element in os.listdir(path):
match = child.match(element)
if match is not None:
yield (match.group('locale'),
match.group('specifier'),
os.path.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):
self._paths = LocaleCleanerPath(location='/')
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
exe = config.get('Desktop Entry', 'Exec').split(" ")[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"
exec_val = config.get('Desktop Entry', 'Exec')
try:
execs = shlex.split(exec_val)
except ValueError as e:
logger.info(
"is_broken_xdg_menu: error splitting 'Exec' key '%s' in '%s'", e, desktop_pathname)
return True
wineprefix = None
del execs[0]
while True:
if execs[0].find("=") < 0:
break
(name, value) = execs[0].split("=")
if name == 'WINEPREFIX':
wineprefix = value
del execs[0]
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:
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(['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"""
assert isinstance(user_locale, str)
if not user_locale:
return 'C'
if user_locale in ('C', 'C.utf8', 'POSIX'):
return user_locale
available_locales = find_available_locales()
# If requesting a language like 'es' and current locale is compatible
# like 'es_MX', then return that.
# Import here for mock patch.
import locale # pylint: disable=import-outside-toplevel
current_locale = locale.getlocale()[0]
if current_locale and current_locale.startswith(user_locale.split('.')[0]):
return '.'.join(locale.getlocale())
# Check for exact match.
if user_locale in available_locales:
return user_locale
# Next, match like 'en' to 'en_US.utf8' (if available) because
# of preference for UTF-8.
for avail_locale in available_locales:
if avail_locale.startswith(user_locale) and avail_locale.endswith('.utf8'):
return avail_locale
# Next, match like 'en' to 'en_US' or 'en_US.iso88591'.
for avail_locale in available_locales:
if avail_locale.startswith(user_locale):
return avail_locale
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')
dist_version_id = release.get('VERSION_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 # pylint: disable=import-outside-toplevel
# example 'ubuntu 24.10'
return distro.id() + ' ' + distro.version()
except ImportError:
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 and 'VERSION_ID' in os_release:
dist_name = os_release['ID']
return f"{dist_name} {os_release['VERSION_ID']}"
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 (unknown distribution)'
* 'Linux (unknown version and distribution)'
Python 3.7 had platform.linux_distribution(), but it
was removed in Python 3.8.
"""
ret = get_distribution_name_version_platform_freedesktop()
if ret:
return ret
ret = get_distribution_name_version_distro()
if ret:
return ret
ret = get_distribution_name_version_os_release()
if ret:
return ret
try:
linux_version = platform.release()
# example '6.12.3-061203-generic'
linux_version = linux_version.split('-')[0]
return f"Linux {linux_version} (unknown distribution)"
except Exception as e1:
logger.debug("Error calling platform.release(): %s", e1)
try:
linux_version = os.uname().release
# example '6.12.3-061203-generic'
linux_version = linux_version.split('-')[0]
return f"Linux {linux_version} (unknown distribution)"
except Exception as e2:
logger.debug("Error calling os.uname(): %s", e2)
return "Linux (unknown version and distribution)"
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:
purgeable_locales.discard(keep)
# If keeping a variant (e.g. 'en_US'), also keep the base locale (e.g. 'en')
if '_' in keep:
purgeable_locales.discard(keep[:keep.find('_')])
# If keeping a base locale (e.g. 'en'), also keep all its variants (e.g. 'en_US')
if '_' not in keep:
purgeable_locales = {locale for locale in purgeable_locales
if not locale.startswith(keep + '_')}
return frozenset(purgeable_locales)
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 # pylint: disable=import-outside-toplevel
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: http://standards.freedesktop.org/desktop-entry-spec/latest/"""
config = bleachbit.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
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:
logger.warning("unhandled type '%s': file '%s'", file_type, pathname)
return False
if _is_broken_xdg_desktop_application(config, pathname):
return True
return False
def is_process_running_ps_aux(exename, require_same_user):
"""Check whether exename is running by calling 'ps aux -c'
exename: name of the executable
require_same_user: if True, ignore processes run by other users
When running under sudo, this uses the non-root username.
"""
ps_out = subprocess.check_output(["ps", "aux", "-c"],
universal_newlines=True)
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:]:
parts = line.split()
if len(parts) < 11:
continue
process_user = parts[0]
process_cmd = parts[10]
if process_cmd != exename:
continue
if not require_same_user or process_user == get_real_username():
return True
return False
def is_process_running_linux(exename, require_same_user):
"""Check whether exename is running
The exename is checked two different ways.
When running under sudo, this uses the non-root user ID.
"""
for filename in glob.iglob("/proc/*/exe"):
does_exe_match = False
try:
target = os.path.realpath(filename)
except TypeError:
# happens, for example, when link points to
# '/etc/password\x00 (deleted)'
pass
except OSError:
# 13 = permission denied
pass
else:
# Google Chrome 74 on Ubuntu 19.04 shows up as
# /opt/google/chrome/chrome (deleted)
found_exename = os.path.basename(target).replace(' (deleted)', '')
does_exe_match = exename == found_exename
if not does_exe_match:
with open(os.path.join(os.path.dirname(filename), 'stat'), 'r', encoding='utf-8') as stat_file:
proc_name = stat_file.read().split()[1].strip('()')
if proc_name == exename:
does_exe_match = True
else:
continue
if not require_same_user:
return True
try:
uid = os.stat(os.path.dirname(filename)).st_uid
except OSError:
# permission denied means not the same user
continue
# In case of sudo, use the regular user's ID.
if uid == get_real_uid():
return True
return False
def is_process_running(exename, require_same_user):
"""Check whether exename is running
exename: name of the executable
require_same_user: if True, ignore processes run by other users
"""
if sys.platform == 'linux':
return is_process_running_linux(exename, require_same_user)
if sys.platform == 'darwin' or sys.platform.startswith('openbsd') or sys.platform.startswith('freebsd'):
return is_process_running_ps_aux(exename, require_same_user)
raise RuntimeError('unsupported platform for is_process_running()')
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'):
keep_list_match = False
for keep_list in keep_lists:
if keep_list.search(path) or bleachbit.FileUtilities.whitelisted(path):
keep_list_match = True
break
if keep_list_match:
continue
if positive_re.search(path):
yield 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 = {'LC_ALL': 'C', 'PATH': os.getenv('PATH')}
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('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('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('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('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(['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):
total_size += FileUtilities.getsize(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('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)
old_size = FileUtilities.getsizedir('/var/cache/dnf')
args = ['--enablerepo=*', 'clean', 'all']
invalid = ['You need to be root', 'Cannot remove rpmdb file']
try:
run_cleaner_cmd('dnf', 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/dnf')
return old_size - new_size
units = {"B": 1, "k": 10**3, "M": 10**6, "G": 10**9}
def parse_size(size):
"""Parse the size returned by dnf"""
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)
cmd = ['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}')
cregex = re.compile(r"Freed space: ([\d.]+[\s]+[BkMG])")
match = cregex.search(allout)
if match:
freed_bytes = parse_size(match.group(1))
logger.debug(
'dnf_autoremove >> total freed bytes: %s', freed_bytes)
return freed_bytes
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)
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+[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 snap_disabled_full(really_delete):
"""Remove disabled snaps"""
assert isinstance(really_delete, bool)
if not exe_exists('snap'):
raise RuntimeError('snap not found')
# Get list of all snaps.
cmd = ['snap', 'list', '--all']
(rc, stdout, stderr) = General.run_external(cmd, clean_env=True)
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'
if os.path.exists(snap_file):
snap_size = os.path.getsize(snap_file)
logger.debug('Found snap file: %s, size: %s',
snap_file, f"{snap_size:,}")
else:
logger.warning('Could not find snap file: %s', snap_file)
snap_size = 0
# Remove the snap revision
if really_delete:
remove_cmd = ['snap', 'remove', snapname, f'--revision={revision}']
(rc, _, remove_stderr) = General.run_external(
remove_cmd, clean_env=True)
if rc > 0:
logger.warning(
'Failed to remove snap %s revision %s: %s', snapname, revision, remove_stderr)
break
else:
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 os.name == 'posix'
if 'XDG_SESSION_TYPE' in os.environ:
if os.environ['XDG_SESSION_TYPE'] == 'wayland':
return True
# If not wayland, then x11, mir, etc.
return False
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(['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(
['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 os.name == 'posix'
try:
result = General.run_external(['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()
)
locales = Locales()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/Update.py 0000775 0001750 0001750 00000012006 15173177347 014532 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
# third-party
import requests
# local
import bleachbit
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)
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"""
# 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):
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 # pylint: disable=import-outside-toplevel
from bleachbit.GuiBasic import open_url # pylint: disable=import-outside-toplevel
dlg = Gtk.Dialog(title=_("Update BleachBit"),
transient_for=parent,
modal=True,
destroy_with_parent=True)
dlg.set_default_size(250, 125)
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)
button_stable.connect(
'clicked', lambda dummy: 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"""
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 requests.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 hasattr(e, 'response') and e.response is not None:
logger.debug(e.response.headers)
return ()
try:
dom = xml.dom.minidom.parseString(response.text)
except:
logger.exception(
'The update information does not parse: %s', response.text)
return ()
def parse_updates(element):
if element:
ver = element[0].getAttribute('ver')
url = element[0].firstChild.data
assert isinstance(ver, str)
assert isinstance(url, str)
assert url.startswith('http')
return ver, url
return ()
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=1777139431.0
bleachbit-6.0.0/bleachbit/Winapp.py 0000775 0001750 0001750 00000044614 15173177347 014560 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 fnmatch
import glob
import logging
import os
import re
from xml.dom.minidom import parseString
import bleachbit
from bleachbit import Cleaner, Windows
from bleachbit.Action import Delete, Winreg
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', _('Games'))}
def xml_escape(s):
"""Lightweight way to escape XML entities"""
return s.replace('&', '&').replace('"', '"').replace('<', '<').replace('>', '>')
def section2option(s):
"""Normalize section name to appropriate option name"""
ret = re.sub(r'[^a-z0-9]', '_', s.lower())
ret = re.sub(r'_+', '_', ret)
ret = re.sub(r'(^_|_$)', '', ret)
return ret
def _noop_progress(_fraction):
"""Default progress callback used when one is not provided."""
return None
def _always_false():
"""Return False for cleaner auto_hide overrides."""
return False
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_min = required_ver.split('|')[0]
req_max = required_ver.split('|')[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"""
# This is the regular expansion
expand1 = os.path.expandvars(pathname)
# Winapp2.ini expands %ProgramFiles% to %ProgramW6432%, etc.
subs = (('ProgramFiles', 'ProgramW6432'),
('CommonProgramFiles', 'CommonProgramW6432'))
for (sub_orig, sub_repl) in subs:
pattern = re.compile(f'%{sub_orig}%', flags=re.IGNORECASE)
if pattern.match(pathname):
expand2 = pattern.sub(f'%{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])
else:
logger.error('Unknown SpecialDetect=%s', code)
return False
def fnmatch_translate(pattern):
"""Same as the original without the end"""
ret = fnmatch.translate(pattern)
if ret.endswith('$'):
return ret[:-1]
return re.sub(r'\\Z(\(\?ms\))?$', '', ret)
class Winapp:
"""Create cleaners from a Winapp2.ini-style file"""
def __init__(self, pathname, cb_progress=_noop_progress):
"""Create cleaners from a Winapp2.ini-style file"""
self.cleaners = {}
self.cleaner_ids = []
for langsecref in set(langsecref_map.values()):
self.add_section(langsecref[0], langsecref[1])
self.errors = 0
self.parser = bleachbit.RawConfigParser()
self.parser.read(pathname)
self.re_detect = re.compile(r'^detect(\d+)?$')
self.re_detectfile = re.compile(r'^detectfile(\d+)?$')
self.re_excludekey = re.compile(r'^excludekey\d+$')
section_total_count = len(self.parser.sections())
section_done_count = 0
for section in self.parser.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)
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
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 = _always_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.keys():
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
files_regex = fnmatch_translate(files[0])
if files_regex == '*.*':
files = None
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]):
regex = None
if not files:
# There is no third part, so this is either just a folder,
# or sometimes the file is specified directly.
regex = fnmatch_translate(expanded)
if files:
# match one or more file types, directly in this tree or in any
# sub folder
regex = r'%s\\%s' % (
re.sub(r'\\\\((?:\))?)$', r'\1', fnmatch_translate(expanded)), files_regex)
regexes.append(regex)
if len(regexes) == 1:
return regexes[0]
else:
return f"({'|'.join(regexes)})"
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 re.match(self.re_detect, option):
# Detect= checks for a registry key
any_detect_option = True
key = self.parser.get(section, option)
if Windows.detect_registry_key(key):
return True
elif re.match(self.re_detectfile, option):
# DetectFile= checks for a file
any_detect_option = True
key = self.parser.get(section, option)
if 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 = []
for option in self.parser.options(section):
if re.match(self.re_excludekey, 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 self.parser.options(section):
if (
option
in {
"default",
"langsecref",
"section",
"detectos",
"specialdetect",
}
or re.match(self.re_detect, option)
or re.match(self.re_detectfile, option)
or re.match(self.re_excludekey, 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)
return
def __make_file_provider(self, dirname, filename, recurse, removeself, excludekeys):
"""Change parsed FileKey to action provider"""
regex = ''
if recurse:
search = 'walk.files'
path = dirname
if filename == '*.*':
if removeself:
search = 'walk.all'
else:
regex = f' regex="^{xml_escape(fnmatch.translate(filename))}$" '
else:
search = 'glob'
path = os.path.join(dirname, filename)
if path.find('*') == -1:
search = 'file'
excludekeysxml = ''
if excludekeys:
if len(excludekeys) > 1:
# multiple
exclude_str = f"({'|'.join(excludekeys)})"
else:
# just one
exclude_str = excludekeys[0]
excludekeysxml = f'nwholeregex="{xml_escape(exclude_str)}"'
action_str = f' '
yield Delete(parseString(action_str).childNodes[0])
if removeself:
search = 'file'
if dirname.find('*') > -1:
search = 'glob'
action_str = f' '
yield Delete(parseString(action_str).childNodes[0])
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)
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(
section2option(ini_section), 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)
for exclude_path in reg_excludekeys:
# Normalize paths for comparison
normalized_path = path.upper().replace('\\', '\\\\')
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
path = xml_escape(path)
name = ""
if len(elements) == 2:
name = f' name="{xml_escape(elements[1])}"'
action_str = f' '
provider = Winreg(parseString(action_str).childNodes[0])
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"""
for dirname in (bleachbit.personal_cleaners_dir, bleachbit.system_cleaners_dir):
fname = os.path.join(dirname, 'winapp2.ini')
if os.path.exists(fname):
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, 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=1777139431.0
bleachbit-6.0.0/bleachbit/Windows.py 0000775 0001750 0001750 00000142133 15173177347 014747 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 .
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 base64
import ctypes
import decimal
import errno
import glob
import hashlib
import logging
import os
import pathlib
import shutil
import sys
import threading
import time
import uuid
import xml.dom.minidom
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
from bleachbit.Language import get_text as _
if 'win32' == sys.platform:
import winreg
import pywintypes
import win32api
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.
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 ctypes import windll, byref
from win32com.shell import shell, shellcon
psapi = windll.psapi
kernel = windll.kernel32
logger = logging.getLogger(__name__)
IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
SPLASH_ICON_SIZE_PX = 256 # 256x256 pixels
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 browse_file(_, title):
"""Ask the user to select a single file. Return full path"""
try:
ret = win32gui.GetOpenFileNameW(None,
Flags=win32con.OFN_EXPLORER
| win32con.OFN_FILEMUSTEXIST
| win32con.OFN_HIDEREADONLY,
Title=title)
except pywintypes.error as e:
logger = logging.getLogger(__name__)
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]
pathnames = [os.path.join(dirname, fname) for fname in _split[1:]]
return pathnames
def browse_folder(_, title):
"""Ask the user to select a folder. Return full path."""
flags = 0x0010 # SHBrowseForFolder path input
pidl = shell.SHBrowseForFolder(None, None, title, flags)[0]
if pidl is None:
# user cancelled
return None
fullpath = shell.SHGetPathFromIDListW(pidl)
return fullpath
def cleanup_nonce():
"""On exit, clean up GTK junk files"""
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:
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_locked_file(pathname):
"""Delete a file that is currently in use"""
if os.path.exists(pathname):
MOVEFILE_DELAY_UNTIL_REBOOT = 4
if 0 == windll.kernel32.MoveFileExW(pathname, None, MOVEFILE_DELAY_UNTIL_REBOOT):
from ctypes import WinError
# WinError throws the right exception based on last error.
try:
raise WinError()
except PermissionError:
# 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)
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:
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:
raise OSError(
errno.EACCES, "Access denied in delete_registry_value()", key)
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)
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:
raise OSError(
errno.EACCES, "Access denied in delete_registry_key()", parent_key)
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.
# pylint: disable=import-outside-toplevel
from bleachbit import Command
if not shell.IsUserAnAdmin():
logger.warning(
_("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'%windir%\system32\catroot2',
r'%systemdrive%\windows.old',
r'%systemdrive%\$windows.~bt',
r'%systemdrive%\$windows.~ws']:
dirs.append(os.path.expandvars(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:
label = _(f"stop Windows 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:
label = _(f"start Windows 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)
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'
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)
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"""
htoken = win32security.OpenProcessToken(
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 os.name != 'nt':
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
elif 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 len(pyfile) > 0 and path_on_network(pyfile):
logger.debug(
"debug: skipping UAC because '%s' is on network", pyfile)
return False
parameters = '"%s" --gui --no-uac' % pyfile
exe = sys.executable
try:
token = get_sid_token_48()
parameters = f"{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.
"""
if '--context-menu' in sys.argv:
return '{} {} "{}"'.format(parameters, ' '.join(sys.argv[1:-1]), sys.argv[-1])
return '{} {}'.format(parameters, ' '.join(sys.argv[1:]))
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.
"""
(bytes_used, 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'
flags = shellcon.SHERB_NOSOUND | shellcon.SHERB_NOCONFIRMATION | shellcon.SHERB_NOPROGRESSUI
shell.SHEmptyRecycleBin(None, path, flags)
return bytes_used
def get_clipboard_paths():
"""Return a tuple of Unicode pathnames from the clipboard"""
import win32clipboard
win32clipboard.OpenClipboard()
path_list = ()
try:
path_list = win32clipboard.GetClipboardData(win32clipboard.CF_HDROP)
except TypeError:
pass
finally:
win32clipboard.CloseClipboard()
return path_list
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 = windll.ole32.CoTaskMemFree
_CoTaskMemFree.restype = None
_CoTaskMemFree.argtypes = [ctypes.c_void_p]
try:
_SHGetKnownFolderPath = 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
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.
Python 3.12 added os.is_junction()
https://docs.python.org/3/library/os.html#os.DirEntry.is_junction
"""
if sys.platform != 'win32':
return False
if hasattr(os, 'is_junction'):
return os.is_junction(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):
pass
attr = 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 == 0xFFFFFFFF or attr == -1:
error_code = 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 is_process_running(exename, require_same_user):
"""Return boolean whether process (like firefox.exe) is running
exename: name of the executable
require_same_user: if True, ignore processes run by other users
"""
import psutil
exename = exename.lower()
current_username = psutil.Process().username().lower()
for proc in psutil.process_iter():
try:
proc_name = proc.name().lower()
except psutil.NoSuchProcess:
continue
if not proc_name == exename:
continue
if not require_same_user:
return True
try:
proc_username = proc.username().lower()
except psutil.AccessDenied:
continue
if proc_username == current_username:
return True
return False
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 dir in dirs:
lib_path = os.path.join(dir, '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
try:
libintl = ctypes.cdll.LoadLibrary(lib_path)
except Exception as e:
logger.warning('error in LoadLibrary(%s): %s', lib_path, e)
return
# 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 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 'nt' == os.name:
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:
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:
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 as e:
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 == winreg.REG_EXPAND_SZ or reg_type == winreg.REG_SZ:
return reg_value
else:
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):
dom = xml.dom.minidom.parse(font_conf_file)
fc_element = dom.getElementsByTagName('fontconfig')[0]
cachefile = 'd031bbba323fd9e5b47e0ee5a0353f11-le32d8.cache-6'
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')
else:
# user has entered a custom directory
dirpath = dir_element.firstChild.nodeValue
if dirpath and os.path.exists(os.path.join(dirpath, cachefile)):
return True
return False
def get_font_conf_file():
"""Return the full path to fonts.conf"""
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')
class SplashThread(Thread):
_class_atom = None
def __init__(self, group=None, target=None, name=None,
args=(), kwargs={}, Verbose=None):
super().__init__(group, self._show_splash_screen, name, args, kwargs)
self._splash_screen_started = Event()
self._splash_screen_handle = None
self._splash_screen_height = None
self._splash_screen_width = None
self._startup_error = None
def start(self):
Thread.start(self)
started = self._splash_screen_started.wait(timeout=10)
if not started:
logger.warning('SplashThread did not start within timeout')
else:
logger.debug('SplashThread started')
if self._startup_error:
raise self._startup_error
def run(self):
try:
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:
return
# Dispatch messages
win32gui.PumpMessages()
def join(self, timeout=None):
import win32con
import win32gui
if not self.is_alive():
return
if not self._splash_screen_handle:
Thread.join(self, timeout=timeout)
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)
win32gui.PostMessage(self._splash_screen_handle,
win32con.WM_CLOSE, 0, 0)
Thread.join(self, timeout=timeout)
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 = 256
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:
pass
def _register_window_class(self, wndClass):
"""Register splash screen window class, handling reuse."""
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
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 = 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)
win32gui.UpdateWindow(hWindow)
self._render_splash(hWindow)
is_splash_screen_on_top = self._force_set_foreground_window(hWindow)
logger.debug(
'Is splash screen on top: {}'.format(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: {}'.format(
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/
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: {}'.format(
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: {}'.format(
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._render_splash(hWnd)
return 0
if message == win32con.WM_ERASEBKGND:
return 1
if message == win32con.WM_PAINT:
hDC, paintStruct = win32gui.BeginPaint(hWnd)
try:
self._render_splash(hWnd)
finally:
win32gui.EndPaint(hWnd, paintStruct)
return 0
elif message == win32con.WM_DESTROY:
win32gui.PostQuitMessage(0)
return 0
else:
return win32gui.DefWindowProc(hWnd, message, wParam, lParam)
splash_thread = SplashThread()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/WindowsWipe.py 0000664 0001750 0001750 00000141737 15173177347 015602 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
*** http://windowsitpro.com/systems-management/inside-windows-nt-disk-defragmenting
*** https://technet.microsoft.com/en-us/sysinternals/sdelete.aspx
*** https://blogs.msdn.microsoft.com/jeffrey_wall/2004/09/13/defrag-api-c-wrappers/
*** https://msdn.microsoft.com/en-us/library/windows/desktop/aa364572(v=vs.85).aspx
***
***
*** 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 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 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 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 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.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 and len(element) > 0:
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
assert this_vcn_span >= 0
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 (lcn >= 0 and
merge_index + 2 < last_record and
ranges[merge_index + 1][1] < 0 and
ranges[merge_index + 2][1] >= 0 and
ranges[merge_index + 2][1] - ranges[merge_index][1] <= 16 and
ranges[merge_index + 2][1] - ranges[merge_index][1] > 0):
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
assert this_vcn_span >= 0
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
assert last_vcn_span >= 0
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))
b_is_empty = not b
for a_begin, a_end in a_sorted:
# If B is an empty list, each item of A will be unchanged.
if b_is_empty:
yield (a_begin, a_end)
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
elif b_end < a_begin:
# Too early in list, keep searching.
continue
elif 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
else:
# 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
else:
a_begin = b_end + 1
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 = [x for x in 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:
allocated_extents.append(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 os.name.lower() != "nt":
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
elif ver_info[0] >= 6:
return "Later than Vista", is_home
else:
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.
Args:
file_name: Path to the file to open
mode: Access mode (default: GENERIC_READ)
Returns:
Windows file handle
"""
file_handle = CreateFileW(file_name, mode, 0, None,
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, None)
return file_handle
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
"""
# Optional protection that we are running on removable media only.
assert volume
# if drive_letter_safety:
# drive_containing_file = volume[0].upper()
# assert drive_containing_file >= drive_letter_safety.upper()
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:
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 []
elif 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 Exception(
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.
assert starting_vcn == 0
# 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.
assert starting_lcn == 0
# 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.
assert mft_zone_start < mft_zone_end and mft_zone_start > 0 and mft_zone_end > 0
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)
_vb_struct = 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:
assert 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)
assert bytes_written == len(write_string)
# 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.
"""
assert cluster_size > 0
# 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.
for lcn_start, lcn_end in extents:
# logger.debug("Wiping extent from %d to %d...",
# lcn_start, lcn_end)
# Calculate write length based on the number of clusters in this extent.
# The file pointer is positioned at the start of the current extent.
# Each write operation will advance the file pointer automatically.
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
"""
assert cluster_size > 0
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:
pass
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)
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)
# 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)
# logger.debug('New extents: %s', new_extents)
if orig_extents == new_extents:
clean_up(None, volume_handle, None)
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.
orig_extents = 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)
# 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)
# Clean up.
clean_up(None, volume_handle, tmp_file_path)
return
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/Wipe.py 0000664 0001750 0001750 00000042623 15173177347 014221 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 random
import string
import struct
import tempfile
import time
import warnings
import logging
from bleachbit.Language import get_text as _
if 'nt' == os.name:
# pylint: disable=import-error
from win32com.shell.shell import IsUserAnAdmin
if 'posix' == os.name:
import fcntl
logger = logging.getLogger(__name__)
def __random_string(length):
"""Return random alphanumeric characters of given length"""
return ''.join(random.choice(string.ascii_letters + '0123456789_.-')
for i in range(length))
def detect_orphaned_wipe_files():
"""Detect orphaned temporary files from interrupted wipe_path operations.
These files are created by wipe_path() to fill free disk space with zeros.
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
"""
from bleachbit.Options import options
orphaned_files = []
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 all(b == 0 for b in sample):
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 os.name != '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)
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 'posix' == os.name:
rc = ctypes.cdll.LoadLibrary('libc.so.6').sync()
if 0 != rc:
logger.error('sync() returned code %d', rc)
elif 'nt' == os.name:
# pylint: disable=protected-access
ctypes.cdll.LoadLibrary('msvcrt.dll')._flushall()
def wipe_contents(path, truncate=True):
"""Wipe files contents
http://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"
"""
# pylint: disable=import-outside-toplevel
from bleachbit.FileUtilities import truncate_f
def wipe_write():
from bleachbit.FileUtilities import getsize as _getsize
size = _getsize(path)
try:
f = open(path, 'wb')
except IOError as e:
if e.errno == errno.EACCES: # permission denied
os.chmod(path, 0o200) # user write only
f = open(path, 'wb')
else:
raise
blanks = b'\0' * 4096
while size > 0:
f.write(blanks)
size -= 4096
f.flush() # flush to OS buffer
os.fsync(f.fileno()) # force write to disk
return f
# pylint: disable=possibly-used-before-assignment
if 'nt' == os.name and IsUserAnAdmin():
# The import placement here avoids a circular import.
# pylint: disable=import-outside-toplevel
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.
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
raise WindowsError(e.winerror, e.strerror)
except UnsupportedFileSystemError:
warnings.warn(
_('There was at least one file on a file system that does not support advanced overwriting.'), UserWarning)
f = wipe_write()
else:
# The wipe succeed, so prepare to truncate.
f = open(path, 'wb')
else:
f = wipe_write()
if truncate:
truncate_f(f)
f.close()
def wipe_name(pathname1):
"""Wipe the original filename and return the new pathname"""
(head, _tail) = os.path.split(pathname1)
# reference http://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
maxlen = 226
# first, rename to a long name
i = 0
while True:
try:
pathname2 = os.path.join(head, __random_string(maxlen))
os.rename(pathname1, pathname2)
break
except OSError:
if maxlen > 10:
maxlen -= 10
i += 1
if i > 100:
logger.info('exhausted long rename: %s', pathname1)
pathname2 = pathname1
break
# finally, rename to a short name
i = 0
while True:
try:
pathname3 = os.path.join(head, __random_string(i + 1))
os.rename(pathname2, pathname3)
break
except:
i += 1
if i > 100:
logger.info('exhausted short rename: %s', pathname2)
pathname3 = pathname2
break
return pathname3
def wipe_path(pathname, idle=False):
"""Wipe the free space in the path
This function uses an iterator to update the GUI."""
# pylint: disable=import-outside-toplevel
from bleachbit.FileUtilities import delete, free_space, get_filesystem_type, truncate_f
def temporaryfile():
# reference
# http://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
maxlen = 185
f = None
while True:
try:
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
return f
def estimate_completion():
"""Return (percent, seconds) to complete"""
remaining_bytes = free_space(pathname)
done_bytes = start_free_bytes - remaining_bytes
if done_bytes < 0:
# maybe user deleted large file after starting wipe
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
fstype = get_filesystem_type(pathname)[0]
# 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": fstype})
if not os.path.isdir(pathname):
logger.error(
_("Path to wipe must be an existing directory: %s"), pathname)
return
if fstype in ('ext4', 'btrfs'):
fitrim(pathname)
files = []
total_bytes = 0
start_free_bytes = free_space(pathname)
start_time = time.time()
done_wiping = 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(
_('Creating new, temporary file for wiping free space.'))
f = temporaryfile()
except OSError as e:
# Linux gives errno 24
# Windows gives errno 28 No space left on device
if e.errno in (errno.EMFILE, errno.ENOSPC):
break
else:
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 fstype != 'vfat':
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:
if e.errno == errno.ENOSPC:
if len(blanks) > 1:
# Try writing smaller blocks
blanks = blanks[0:len(blanks) // 2]
else:
break
elif e.errno == errno.EFBIG:
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
if e.errno != errno.ENOSPC:
logger.error(
_("Error #%d when flushing the file buffer."), e.errno)
os.fsync(f.fileno()) # write to disk
# 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
# 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 'posix' == os.name:
# 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`
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=1777139431.0
bleachbit-6.0.0/bleachbit/Worker.py 0000775 0001750 0001750 00000037121 15173177347 014566 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 .
"""
Perform the preview or delete operations
"""
# standard imports
import logging
import math
import os
import sys
# first party imports
from bleachbit import DeepScan, FileUtilities
from bleachbit.Cleaner import backends
from bleachbit.Constant import EMPTY_SPACE_WARNING
from bleachbit.Language import get_text as _, nget_text as ngettext
logger = logging.getLogger(__name__)
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 0 == len(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 True == ret 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:
pass
except Exception as e:
from errno import ENOENT, EACCES
if isinstance(e, OSError) and e.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 os.name == 'nt' 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 == 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)
else:
# For other errors, show the traceback.
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"
if ret['path']:
path = ret['path']
else:
path = ''
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
import time
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 True == ret:
# 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(
_('To stop this process, press the abort button on the toolbar and wait.'))
self.ui.append_text('\n\n')
self.ui.append_text(
_('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)
msg2 = ngettext("About %d minute remaining.",
"About %d minutes remaining.", eta_mins) \
% eta_mins
self.ui.update_progress_bar(msg + ' ' + msg2)
else:
self.ui.update_progress_bar(msg)
if self.is_aborted:
break
if True == ret 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"""
self.deepscans = {}
# prioritize
self.delayed_ops = []
for operation in self.operations:
delayables = ['empty_space', 'memory']
for delayable in delayables:
if operation not in ('system', '_gui'):
continue
if delayable in self.operations[operation]:
i = self.operations[operation].index(delayable)
del self.operations[operation][i]
priority = 99
if 'empty_space' == delayable:
priority = 100
new_op = (priority, {operation: [delayable]})
self.delayed_ops.append(new_op)
# standard operations
import warnings
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')
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()
# delayed operations
for op in sorted(self.delayed_ops):
operation = list(op[1].keys())[0]
for option_id in list(op[1].values())[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:
line = _("Special operations: %d") % self.total_special
self.ui.append_text("\n%s" % line)
if self.total_errors > 0:
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 True == cmd:
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:
self.print_exception(operation)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/__init__.py 0000664 0001750 0001750 00000020735 15173177347 015054 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 getpass
import os
import re
import sys
from configparser import NoOptionError, RawConfigParser # used in other files
from bleachbit import Log
APP_VERSION = "6.0.0"
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):
print('BleachBit requires Python version 3.8 or later')
sys.exit(1)
if hasattr(sys, 'frozen'):
stdout_encoding = 'utf-8'
else:
stdout_encoding = sys.stdout.encoding
logger = Log.init_log()
# Setting below value to false disables update notification (useful
# for packages in repositories).
online_update_notification_enabled = True
#
# Paths
#
# Windows
bleachbit_exe_path = None
if hasattr(sys, 'frozen'):
# running frozen in py2exe
bleachbit_exe_path = os.path.dirname(sys.executable)
else:
# __file__ is absolute path to __init__.py
bleachbit_exe_path = os.path.dirname(os.path.dirname(__file__))
# 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
# configuration
portable_mode = False
options_dir = None
if 'posix' == os.name:
# os.path.expanduser('~') returns '~' unchanged when HOME is unset
# and the user has no passwd entry (e.g., Docker containers).
if not os.getenv('HOME'):
_home = os.path.expanduser('~')
if _home == '~':
_home = '/tmp'
logger.warning('HOME not set and no passwd entry; using %s', _home)
os.environ['HOME'] = _home
options_dir = os.path.expanduser("~/.config/bleachbit")
elif 'nt' == os.name:
os.environ.pop('FONTCONFIG_FILE', None)
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")
try:
options_dir = os.environ['BLEACHBIT_TEST_OPTIONS_DIR']
except KeyError:
pass
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.
if os.path.isdir(os.path.join(bleachbit_exe_path, 'cleaners')) and not portable_mode:
system_cleaners_dir = os.path.join(bleachbit_exe_path, 'cleaners')
elif sys.platform in ('linux', 'darwin'):
system_cleaners_dir = '/usr/share/bleachbit/cleaners'
elif sys.platform == 'win32':
system_cleaners_dir = os.path.join(bleachbit_exe_path, 'share\\cleaners\\')
elif sys.platform[:6] == 'netbsd':
system_cleaners_dir = '/usr/pkg/share/bleachbit/cleaners'
elif sys.platform.startswith('openbsd') or sys.platform.startswith('freebsd'):
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 = os.path.dirname(__file__)
# 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.
seen = set()
unique_dirs = []
for base_dir in base_dirs:
if base_dir in seen:
continue
seen.add(base_dir)
unique_dirs.append(base_dir)
return unique_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 = (
'/usr/share/pixmaps/bleachbit.png', # Linux
'/usr/pkg/share/pixmaps/bleachbit.png', # NetBSD
'/usr/local/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
# locale directory
if os.path.exists("./locale/"):
# local locale (personal)
locale_dir = os.path.abspath("./locale/")
# system-wide installed locale
elif sys.platform in ('linux', 'darwin'):
locale_dir = "/usr/share/locale/"
elif sys.platform == "win32":
locale_dir = os.path.join(bleachbit_exe_path, "share\\locale\\")
elif sys.platform[:6] == "netbsd":
locale_dir = "/usr/pkg/share/locale/"
elif sys.platform.startswith("openbsd") or sys.platform.startswith("freebsd"):
locale_dir = "/usr/local/share/locale/"
#
# URLs
#
base_url = "https://update.bleachbit.org"
help_contents_url = "https://www.bleachbit.org/help"
update_check_url = f"{base_url}/update/{APP_VERSION}"
# set up environment variables
if 'nt' == os.name:
from bleachbit import Windows
Windows.setup_environment()
if 'posix' == os.name:
# Set fallbacks for environment variables.
envs = {
'PATH': '/usr/bin:/bin:/usr/sbin:/sbin',
'XDG_CACHE_HOME': os.path.expanduser('~/.cache'),
'XDG_CONFIG_HOME': os.path.expanduser('~/.config'),
'XDG_DATA_HOME': os.path.expanduser('~/.local/share')
}
if not os.getenv('USER'):
try:
envs['USER'] = getpass.getuser()
except (OSError, KeyError):
pass
for varname, value in envs.items():
if not os.getenv(varname):
os.environ[varname] = value
# should be re.IGNORECASE on macOS
fs_scan_re_flags = 0 if os.name == 'posix' else re.IGNORECASE
if 'win32' == sys.platform:
import win32process
for process in win32process.EnumProcessModules(-1):
name = win32process.GetModuleFileNameEx(-1, process)
if re.search(r'python\d+.dll$', name, re.IGNORECASE):
bindir = os.path.dirname(name)
os.environ['GDK_PIXBUF_MODULE_FILE'] = os.path.join(
bindir, 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders.cache')
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1777139432.4775698
bleachbit-6.0.0/bleachbit/markovify/ 0000775 0001750 0001750 00000000000 15173177350 014735 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit/markovify/__init__.py 0000664 0001750 0001750 00000000302 15173177347 017047 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=1777139431.0
bleachbit-6.0.0/bleachbit/markovify/chain.py 0000664 0001750 0001750 00000012047 15173177347 016403 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=1777139431.0
bleachbit-6.0.0/bleachbit/markovify/splitters.py 0000664 0001750 0001750 00000004256 15173177347 017355 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=1777139431.0
bleachbit-6.0.0/bleachbit/markovify/text.py 0000664 0001750 0001750 00000021153 15173177347 016303 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=1777139431.0
bleachbit-6.0.0/bleachbit/markovify/utils.py 0000664 0001750 0001750 00000004031 15173177347 016453 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=1777139432.5118568
bleachbit-6.0.0/bleachbit.egg-info/ 0000775 0001750 0001750 00000000000 15173177351 014421 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139432.0
bleachbit-6.0.0/bleachbit.egg-info/PKG-INFO 0000644 0001750 0001750 00000001503 15173177350 015512 0 ustar 00z z Metadata-Version: 2.4
Name: bleachbit
Version: 6.0.0
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=1777139432.0
bleachbit-6.0.0/bleachbit.egg-info/SOURCES.txt 0000664 0001750 0001750 00000012616 15173177350 016312 0 ustar 00z z COPYING
MANIFEST.in
Makefile
README.md
bleachbit.png
bleachbit.py
bleachbit.spec
org.bleachbit.BleachBit.desktop
org.bleachbit.BleachBit.metainfo.xml
org.bleachbit.policy
setup.cfg
setup.py
bleachbit/Action.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/GuiPreferences.py
bleachbit/GuiStartup.py
bleachbit/GuiTreeModels.py
bleachbit/GuiUtil.py
bleachbit/GuiWindow.py
bleachbit/Language.py
bleachbit/Log.py
bleachbit/Memory.py
bleachbit/Network.py
bleachbit/Options.py
bleachbit/ProtectedPath.py
bleachbit/RecognizeCleanerML.py
bleachbit/Revision.py
bleachbit/Special.py
bleachbit/SystemInformation.py
bleachbit/Unix.py
bleachbit/Update.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/apt.xml
cleaners/audacious.xml
cleaners/bash.xml
cleaners/beagle.xml
cleaners/brave.xml
cleaners/chromium.xml
cleaners/d4x.xml
cleaners/deepscan.xml
cleaners/discord.xml
cleaners/dnf.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/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/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/opera.xml
cleaners/pacman.xml
cleaners/paint.xml
cleaners/palemoon.xml
cleaners/pidgin.xml
cleaners/realplayer.xml
cleaners/recoll.xml
cleaners/rhythmbox.xml
cleaners/safari.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/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
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/ckb.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/grc.po
po/he.po
po/hi.po
po/hr.po
po/hu.po
po/ia.po
po/id.po
po/ie.po
po/it.po
po/ja.po
po/ka.po
po/kk.po
po/kn.po
po/ko.po
po/ku.po
po/ky.po
po/lt.po
po/lv.po
po/ms.po
po/my.po
po/nb.po
po/nds.po
po/nl.po
po/nn.po
po/pl.po
po/pt.po
po/pt_BR.po
po/ro.po
po/ru.po
po/se.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/ug.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/TestExternalCommand.py
tests/TestFileUtilities.py
tests/TestGUI.py
tests/TestGeneral.py
tests/TestGtkShim.py
tests/TestGuiChaff.py
tests/TestGuiStartup.py
tests/TestInit.py
tests/TestLanguage.py
tests/TestMakefile.py
tests/TestMemory.py
tests/TestNetwork.py
tests/TestNsisUtilities.py
tests/TestOptions.py
tests/TestProtectedPath.py
tests/TestRecognizeCleanerML.py
tests/TestSpecial.py
tests/TestSystemInformation.py
tests/TestUnix.py
tests/TestUpdate.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/test_with_sudo.py
windows/bleachbit.ico
windows/bleachbit.nsi
windows/build-environment-install.bat
windows/gtk30.pot
windows/msys-install.ps1
windows/python-gtk3-install.ps1
windows/requirements.txt ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139432.0
bleachbit-6.0.0/bleachbit.egg-info/dependency_links.txt 0000664 0001750 0001750 00000000001 15173177350 020466 0 ustar 00z z
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139432.0
bleachbit-6.0.0/bleachbit.egg-info/top_level.txt 0000664 0001750 0001750 00000000012 15173177350 017143 0 ustar 00z z bleachbit
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1777139431.0
bleachbit-6.0.0/bleachbit.png 0000664 0001750 0001750 00000102770 15173177347 013451 0 ustar 00z z PNG
IHDR \rf gAMA a IDATx}VouűBA*B+pG$^IHEOeIzk[2Ip;q{ w w w. pp p;\ p;; w w w. pp p;\ p;; w w w. pp p;\ p;1y<_m`jFfߌ~W~蛌>RFߺqo;q c*FṏyÌFb Fqˎr8 zync3FfTJFa?Iع~pnj ڲnj&73z;_iq;1C1Mm</FTt#T7=&gwHmwڀ;1 MnFg>(gV]fUo xkވK_
7Wsyف,Foߺw;1 [ySvPg/>,*EeM-|>H{hiK+9ހꦥv᎓ գy54{:A+qֹoW)ۯ
p fװ/KFUK`2%IBdb#CA,,VY˯AYE?1'OQ'/
PaD<][{~GxG}<8 2Zqo@((P>uBGw` `?4p7BU}{]Gt\C;?GDiQ]We7oyp q粗Eɰ_߱X!=^Gؘ峚V,?*x~9dq'x#8ϞBς"06<}?4źĨ0{Ju~}\pljK?jۖ߳m;R6Y7xͧ>0^t0p?x?ұQx1o*S୷_>2w {sG|ޜzNNcV.[n\~݇܋۾W7}}