././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1760921547.736172
bleachbit-5.0.2/ 0000775 0001750 0001750 00000000000 15075303714 011006 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/COPYING 0000664 0001750 0001750 00000104517 15075303713 012050 0 ustar 00z z GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
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 .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/MANIFEST.in 0000664 0001750 0001750 00000001660 15075303713 012546 0 ustar 00z z include doc/CONTRIBUTING.md
include COPYING
include MANIFEST
include MANIFEST.in
include Makefile
include README.md
include bleachbit-indicator.svg
include bleachbit.png
include bleachbit.py
include bleachbit.spec
include cleaners/*xml
include cleaners/Makefile
include data/app-menu.ui
include debian/bleachbit.dsc
include debian/compat
include debian/copyright
include debian/debian.changelog
include debian/debian.control
include debian/debian.rules
include doc/cleaner_markup_language.xsd
include doc/example_cleaner.xml
include org.bleachbit.BleachBit.desktop
include org.bleachbit.BleachBit.metainfo.xml
include org.bleachbit.policy
include po/*po
include po/Makefile
include setup.py
# openSUSE Build Service uses these tests.
include tests/*.py
include windows/*bat
include windows/*ps1
include windows/bleachbit.ico
include windows/bleachbit.nsi
include windows/gtk*pot
include windows/requirements.txt
recursive-include bleachbit *py
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/Makefile 0000664 0001750 0001750 00000010654 15075303713 012453 0 ustar 00z z # Copyright (C) 2008-2025 Andrew Ziem. All rights reserved.
# License GPLv3+: GNU GPL version 3 or later .
# This is free software: You are free to change and redistribute it.
# There is NO WARRANTY, to the extent permitted by law.
#
# Makefile edited by https://github.com/Tobias-B-Besemer
# Done on 2019-03-13
# On some systems if not explicitly given, make uses /bin/sh
SHELL := /bin/bash
.PHONY: clean install tests build tests-with-sudo lint delete_windows_files pretty
prefix ?= /usr/local
bindir ?= $(prefix)/bin
datadir ?= $(prefix)/share
INSTALL = install
INSTALL_DATA = $(INSTALL) -m 644
INSTALL_SCRIPT = $(INSTALL) -m 755
# if not specified, do not check coverage
PYTHON ?= python3
COVERAGE ?= $(PYTHON)
build:
echo Nothing to build
clean:
@rm -vf {.,bleachbit,tests,windows,bleachbit/markovify}/*{pyc,pyo,~} # files
@rm -vrf {.,bleachbit,tests,windows,bleachbit/markovify}/__pycache__ # directories
@rm -vrf build dist # created by py2exe
@rm -rf BleachBit-Portable # created by windows/setup.bat
@rm -rf BleachBit-*-portable.zip
@rm -vf MANIFEST # created by setup.py
$(MAKE) -C po clean
@rm -vrf locale
@rm -vrf {*/,./}*.{pylint,pyflakes}.log
@rm -vrf windows/BleachBit-*-setup*.{exe,zip}
@rm -vrf htmlcov .coverage # code coverage reports
@rm -vrf *.egg-info # Python package metadata
install:
# "binary"
mkdir -p $(DESTDIR)$(bindir)
$(INSTALL_SCRIPT) bleachbit.py $(DESTDIR)$(bindir)/bleachbit
# application launcher
mkdir -p $(DESTDIR)$(datadir)/applications
$(INSTALL_DATA) org.bleachbit.BleachBit.desktop $(DESTDIR)$(datadir)/applications/
# AppStream metadata
mkdir -p $(DESTDIR)$(datadir)/metainfo
$(INSTALL_DATA) org.bleachbit.BleachBit.metainfo.xml $(DESTDIR)$(datadir)/metainfo/
# Python code
mkdir -p $(DESTDIR)$(datadir)/bleachbit/markovify
$(INSTALL_DATA) bleachbit/*.py $(DESTDIR)$(datadir)/bleachbit
$(INSTALL_DATA) bleachbit/markovify/*.py $(DESTDIR)$(datadir)/bleachbit/markovify
#note: compileall is recursive
cd $(DESTDIR)$(datadir)/bleachbit && \
$(PYTHON) -O -c "import compileall; compileall.compile_dir('.')" && \
$(PYTHON) -c "import compileall; compileall.compile_dir('.')"
# cleaners
mkdir -p $(DESTDIR)$(datadir)/bleachbit/cleaners
$(INSTALL_DATA) cleaners/*.xml $(DESTDIR)$(datadir)/bleachbit/cleaners
# menu
$(INSTALL_DATA) data/app-menu.ui $(DESTDIR)$(datadir)/bleachbit
# icon
mkdir -p $(DESTDIR)$(datadir)/pixmaps
$(INSTALL_DATA) bleachbit.png $(DESTDIR)$(datadir)/pixmaps/
$(INSTALL_DATA) bleachbit-indicator.svg $(DESTDIR)$(datadir)/pixmaps/
# translations
$(MAKE) -C po install DESTDIR=$(DESTDIR)
# PolicyKit
mkdir -p $(DESTDIR)$(datadir)/polkit-1/actions
$(INSTALL_DATA) org.bleachbit.policy $(DESTDIR)$(datadir)/polkit-1/actions/
lint:
command -v pyflakes3 >/dev/null 2>&1 || echo "WARNING: Missing pyflakes3. APT users, try: sudo apt install pyflakes3"
command -v pylint >/dev/null 2>&1 || echo "WARNING: Missing pylint. APT users, try: sudo apt install pylint"
for f in *py */*py; \
do \
echo "$$f"; \
( pyflakes3 "$$f" > "$$f".pyflakes.log ); \
( pylint "$$f" > "$$f".pylint.log ); \
done; \
exit 0
delete_windows_files:
# This is used for building .deb and .rpm packages.
# Remove Windows-specific cleaners.
grep -l "cleaner id=\"\w*\" os=\"windows\"" cleaners/*xml | xargs rm -f
# Remove Windows-specific modules.
rm -f bleachbit/{Winapp,Windows*}.py
tests:
# Catch warnings as errors. Also set in `tests/common.py`.
$(MAKE) -C cleaners tests; cleaners_status=$$?; \
PYTHONWARNINGS=error $(COVERAGE) -m unittest discover -p Test*.py -v; py_status=$$?; \
exit $$(($$cleaners_status + $$py_status))
tests-with-sudo:
# Run tests marked with @test_also_with_sudo using sudo
PYTHONWARNINGS=error $(PYTHON) tests/test_with_sudo.py
pretty:
@if command -v autopep8 >/dev/null 2>&1; then \
autopep8 -i {.,bleachbit,tests}/*py; \
else \
echo "WARNING: Missing autopep8. APT users, try: sudo apt install python3-autopep8"; \
fi
@if command -v dos2unix >/dev/null 2>&1; then \
dos2unix {.,bleachbit,tests}/*py; \
else \
echo "WARNING: Missing dos2unix. APT users, try: sudo apt install dos2unix"; \
fi
$(MAKE) -C cleaners pretty
if command -v xmllint >/dev/null 2>&1; then \
xmllint --format doc/cleaner_markup_language.xsd > doc/cleaner_markup_language.xsd.tmp; \
mv doc/cleaner_markup_language.xsd.tmp doc/cleaner_markup_language.xsd; \
else \
echo "WARNING: Missing xmllint. APT users, try: sudo apt install libxml2-utils"; \
fi
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1760921547.7361324
bleachbit-5.0.2/PKG-INFO 0000644 0001750 0001750 00000001455 15075303714 012106 0 ustar 00z z Metadata-Version: 2.2
Name: bleachbit
Version: 5.0.2
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: 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=1760921547.0
bleachbit-5.0.2/README.md 0000664 0001750 0001750 00000003672 15075303713 012274 0 ustar 00z z # BleachBit
BleachBit cleans files to free disk space and to maintain privacy.
## Running from source
To run BleachBit without installation, unpack the tarball and then run these
commands:
make -C po local # build translations
python3 bleachbit.py
Then, review the preferences.
Then, select some options, and click Preview. Review the files, toggle options accordingly, and click Delete.
For information regarding the command line interface, run:
python3 bleachbit.py --help
Read more about [running from source](https://docs.bleachbit.org/dev/running-from-source-code.html).
## Links
* [BleachBit home page](https://www.bleachbit.org)
* [Support](https://www.bleachbit.org/help)
* [Documentation](https://docs.bleachbit.org)
## Localization
Read [translation documentation](https://www.bleachbit.org/contribute/translate) or translate now in [Weblate](https://hosted.weblate.org/projects/bleachbit/), a web-based translation platform.
## Licenses
BleachBit itself, including source code and cleaner definitions, is licensed under the [GNU General Public License version 3](COPYING), or at your option, any later version.
markovify is licensed under the [MIT License](https://github.com/jsvine/markovify/blob/master/LICENSE.txt).
## Development
* [BleachBit on AppVeyor](https://ci.appveyor.com/project/az0/bleachbit) 
* [BleachBit on Travis CI](https://travis-ci.com/github/bleachbit/bleachbit) 
* [CleanerML Repository](https://github.com/bleachbit/cleanerml)
* [BleachBit Miscellaneous Repository](https://github.com/bleachbit/bleachbit-misc)
* [Winapp2.ini Repository](https://github.com/bleachbit/winapp2.ini)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1760921547.7046576
bleachbit-5.0.2/bleachbit/ 0000775 0001750 0001750 00000000000 15075303714 012723 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/Action.py 0000775 0001750 0001750 00000056150 15075303713 014523 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 .
"""
Actions that perform cleaning
"""
# standard imports
import glob
import logging
import os
import re
from itertools import product
# first party imports
from bleachbit import Command, FileUtilities, General, Special, DeepScan
from bleachbit import fs_scan_re_flags
from bleachbit.Language import get_text as _
if os.name == 'posix':
from bleachbit import Unix
if os.name == 'nt':
from bleachbit import Windows
logger = logging.getLogger(__name__)
def has_glob(s):
"""Checks whether the string contains any glob characters"""
return re.search(r'[?*\[\]]', s) is not None
def expand_multi_var(s, variables):
"""Expand strings with potentially-multiple values.
The placeholder is written in the format $$foo$$.
The function always returns a list of one or more strings.
"""
if not variables or s.find('$$') == -1:
# The input string is missing $$ or no variables are given.
return (s,)
var_keys_used = []
ret = []
for var_key in variables.keys():
sub = f'$${var_key}$$'
if s.find(sub) > -1:
var_keys_used.append(var_key)
if not var_keys_used:
# No matching variables used, so return input string unmodified.
return (s,)
# filter the dictionary to the keys used
vars_used = {key: value for key,
value in variables.items() if key in var_keys_used}
# create a product of combinations
vars_product = (dict(zip(vars_used, x))
for x in product(*vars_used.values()))
for var_set in vars_product:
ms = s # modified version of input string
for var_key, var_value in var_set.items():
sub = f'$${var_key}$$'
ms = ms.replace(sub, var_value)
ret.append(ms)
if ret:
return ret
# The string has $$, but it did not match anything
return (s,)
#
# Plugin framework
# http://martyalchin.com/2008/jan/10/simple-plugin-framework/
#
class PluginMount(type):
"""A simple plugin framework"""
def __init__(cls, _name, _bases, _attrs):
if not hasattr(cls, 'plugins'):
cls.plugins = []
else:
cls.plugins.append(cls)
class ActionProvider(metaclass=PluginMount):
"""Abstract base class for performing individual cleaning actions"""
def __init__(self, action_node, path_vars=None):
"""Create ActionProvider from CleanerML """
def get_deep_scan(self):
"""Return a dictionary used to construct a deep scan"""
raise StopIteration
def get_commands(self):
"""Yield each command (which can be previewed or executed)"""
#
# base class
#
class FileActionProvider(ActionProvider):
"""Base class for providers which work on individual files"""
action_key = '_file'
CACHEABLE_SEARCHERS = ('walk.files',)
# global cache
cache = ('nothing', '', tuple())
def __init__(self, action_element, path_vars=None):
"""Initialize file search"""
ActionProvider.__init__(self, action_element, path_vars)
self.regex = action_element.getAttribute('regex')
assert (isinstance(self.regex, (str, type(None))))
self.nregex = action_element.getAttribute('nregex')
assert (isinstance(self.nregex, (str, type(None))))
self.wholeregex = action_element.getAttribute('wholeregex')
assert (isinstance(self.wholeregex, (str, type(None))))
self.nwholeregex = action_element.getAttribute('nwholeregex')
assert (isinstance(self.nwholeregex, (str, type(None))))
self.search = action_element.getAttribute('search')
self.object_type = action_element.getAttribute('type')
self._set_paths(action_element.getAttribute('path'), path_vars)
self.ds = None
if 'deep' == self.search:
self.ds = (self.paths[0], DeepScan.Search(
command=action_element.getAttribute('command'),
regex=self.regex, nregex=self.nregex,
wholeregex=self.wholeregex, nwholeregex=self.nwholeregex))
if len(self.paths) != 1:
logger.warning(
# TRANSLATORS: Multi-value variables are explained
# in the online documentation. Basically, they are like
# an environment variable, but each multi-value variable
# can have multiple values. They're a way to make CleanerML
# files more concise.
_("Deep scan does not support multi-value variable."))
if not any([self.object_type, self.regex, self.nregex,
self.wholeregex, self.nwholeregex]):
# If the filter is not needed, bypass it for speed.
self.get_paths = self._get_paths
def _set_paths(self, raw_path, path_vars):
"""Set the list of paths to work on"""
self.paths = []
# expand special $$foo$$ which may give multiple values
for path2 in expand_multi_var(raw_path, path_vars):
path3 = os.path.expanduser(os.path.expandvars(path2))
if os.name == 'nt' and path3:
# convert forward slash to backslash for compatibility with getsize()
# and for display. Do not convert an empty path, or it will become
# the current directory (.).
path3 = os.path.normpath(path3)
self.paths.append(path3)
def get_deep_scan(self):
if self.ds is None:
return
yield self.ds
def get_paths(self):
"""Process the filters: regex, nregex, type
If a filter is defined and it fails to match, this function
returns False. Otherwise, this function returns True."""
# optimize tight loop, avoid slow python "."
regex = self.regex
nregex = self.nregex
wholeregex = self.wholeregex
nwholeregex = self.nwholeregex
basename = os.path.basename
object_type = self.object_type
if self.regex:
regex_c_search = re.compile(self.regex, fs_scan_re_flags).search
else:
regex_c_search = None
if self.nregex:
nregex_c_search = re.compile(self.nregex, fs_scan_re_flags).search
else:
nregex_c_search = None
if self.wholeregex:
wholeregex_c_search = re.compile(
self.wholeregex, fs_scan_re_flags).search
else:
wholeregex_c_search = None
if self.nwholeregex:
nwholeregex_c_search = re.compile(
self.nwholeregex, fs_scan_re_flags).search
else:
nwholeregex_c_search = None
for path in self._get_paths():
if regex and not regex_c_search(basename(path)):
continue
if nregex and nregex_c_search(basename(path)):
continue
if wholeregex and not wholeregex_c_search(path):
continue
if nwholeregex and nwholeregex_c_search(path):
continue
if object_type:
if 'f' == object_type and not os.path.isfile(path):
continue
elif 'd' == object_type and not os.path.isdir(path):
continue
yield path
def _get_paths(self):
"""Return a filtered list of files"""
def get_file(path):
if os.path.lexists(path):
yield path
def get_walk_all(top):
"""Delete files and directories inside a directory but not the top directory"""
for expanded in glob.iglob(top):
path = None # sentinel value
yield from FileUtilities.children_in_directory(expanded, True)
# This condition executes when there are zero iterations
# in the loop above.
if path is None:
# This is a lint checker because this scenario may
# indicate the cleaner developer made a mistake.
if os.path.isfile(expanded):
logger.debug(
# TRANSLATORS: This is a lint-style warning that there seems to be a
# mild mistake in the CleanerML file because walk.all is expected to
# be used with directories instead of with files. Do not translate
# search="walk.all" and path="%s"
_('search="walk.all" used with regular file path="%s"'),
expanded,
)
def get_walk_files(top):
"""Delete files inside a directory but not any directories"""
for expanded in glob.iglob(top):
yield from FileUtilities.children_in_directory(expanded, False)
def get_top(top):
"""Delete directory contents and the directory itself"""
yield from get_walk_all(top)
if os.path.exists(top):
yield top
if 'deep' == self.search:
return
search_functions = {
'file': get_file,
'glob': glob.iglob,
'walk.all': get_walk_all,
'walk.files': get_walk_files,
'walk.top': get_top
}
if self.search not in search_functions:
raise RuntimeError(f"Invalid search='{self.search}'")
func = search_functions[self.search]
cache = self.__class__.cache
for input_path in self.paths:
if self.search == 'glob' and not has_glob(input_path):
# TRANSLATORS: This is a lint-style warning that the CleanerML file
# specified a search for glob, but the path specified didn't have any
# wildcard patterns. Therefore, maybe the developer either missed
# the wildcard or should search using path="file" which does not
# expect or support wildcards in the path.
logger.debug(_('path="%s" is not a glob pattern'), input_path)
# use cache
if self.search in self.CACHEABLE_SEARCHERS and cache[0] == self.search and cache[1] == input_path:
# logger.debug(_('using cached walk for path %s'), input_path)
for x in cache[2]:
yield x
return
# if self.search in self.CACHEABLE_SEARCHERS:
# logger.debug('not using cache because it has (%s,%s) and we want (%s,%s)',
# cache[0], cache[1], self.search, input_path)
self.__class__.cache = ('cleared by', input_path, tuple())
# build new cache
# logger.debug('%s walking %s', id(self), input_path)
if self.search in self.CACHEABLE_SEARCHERS:
cache = self.__class__.cache = (self.search, input_path, [])
for path in func(input_path):
cache[2].append(path)
yield path
else:
for path in func(input_path):
yield path
def get_commands(self):
raise NotImplementedError('not implemented')
#
# Action providers
#
class AptAutoclean(ActionProvider):
"""Action to run 'apt-get autoclean'"""
action_key = 'apt.autoclean'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking executable allows auto-hide to work for non-APT systems
assert os.name == 'posix'
if FileUtilities.exe_exists('apt-get'):
# pylint: disable=possibly-used-before-assignment
yield Command.Function(None,
Unix.apt_autoclean,
'apt-get autoclean')
class AptAutoremove(ActionProvider):
"""Action to run 'apt-get autoremove'"""
action_key = 'apt.autoremove'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking executable allows auto-hide to work for non-APT systems
if FileUtilities.exe_exists('apt-get'):
yield Command.Function(None,
Unix.apt_autoremove,
'apt-get autoremove')
class AptClean(ActionProvider):
"""Action to run 'apt-get clean'"""
action_key = 'apt.clean'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking executable allows auto-hide to work for non-APT systems
if FileUtilities.exe_exists('apt-get'):
yield Command.Function(None,
Unix.apt_clean,
'apt-get clean')
class ChromeAutofill(FileActionProvider):
"""Action to clean 'autofill' table in Google Chrome/Chromium"""
action_key = 'chrome.autofill'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_autofill,
_('Clean file'))
class ChromeDatabases(FileActionProvider):
"""Action to clean Databases.db in Google Chrome/Chromium"""
action_key = 'chrome.databases_db'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_databases_db,
_('Clean file'))
class ChromeFavicons(FileActionProvider):
"""Action to clean 'Favicons' file in Google Chrome/Chromium"""
action_key = 'chrome.favicons'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_favicons,
_('Clean file'))
class ChromeHistory(FileActionProvider):
"""Action to clean 'History' file in Google Chrome/Chromium"""
action_key = 'chrome.history'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_history,
_('Clean file'))
class ChromeKeywords(FileActionProvider):
"""Action to clean 'keywords' table in Google Chrome/Chromium"""
action_key = 'chrome.keywords'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_keywords,
_('Clean file'))
class Delete(FileActionProvider):
"""Action to delete files"""
action_key = 'delete'
def get_commands(self):
for path in self.get_paths():
yield Command.Delete(path)
class Ini(FileActionProvider):
"""Action to clean .ini configuration files"""
action_key = 'ini'
def __init__(self, action_element, path_vars=None):
FileActionProvider.__init__(self, action_element, path_vars)
self.section = action_element.getAttribute('section')
self.parameter = action_element.getAttribute('parameter')
if self.parameter == "":
self.parameter = None
def get_commands(self):
for path in self.get_paths():
yield Command.Ini(path, self.section, self.parameter)
class Journald(ActionProvider):
"""Action to run 'journalctl --vacuum-time=1'"""
action_key = 'journald.clean'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
if FileUtilities.exe_exists('journalctl'):
yield Command.Function(None, Unix.journald_clean, 'journalctl --vacuum-time=1')
class Json(FileActionProvider):
"""Action to clean JSON configuration files"""
action_key = 'json'
def __init__(self, action_element, path_vars=None):
FileActionProvider.__init__(self, action_element, path_vars)
self.address = action_element.getAttribute('address')
def get_commands(self):
for path in self.get_paths():
yield Command.Json(path, self.address)
class MozillaUrlHistory(FileActionProvider):
"""Action to clean Mozilla (Firefox) URL history in places.sqlite"""
action_key = 'mozilla.url.history'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(path,
Special.delete_mozilla_url_history,
_('Clean file'))
class MozillaFavicons(FileActionProvider):
"""Action to clean Mozilla (Firefox) URL history in places.sqlite"""
action_key = 'mozilla.favicons'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(path,
Special.delete_mozilla_favicons,
_('Clean file'))
class OfficeRegistryModifications(FileActionProvider):
"""Action to delete LibreOffice history"""
action_key = 'office_registrymodifications'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_office_registrymodifications,
_('Clean'))
class Process(ActionProvider):
"""Action to run a process"""
action_key = 'process'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
self.cmd = os.path.expandvars(action_element.getAttribute('cmd'))
# by default, wait
self.wait = True
wait = action_element.getAttribute('wait')
if wait and wait.lower()[0] in ('f', 'n'):
# false or no
self.wait = False
def get_commands(self):
def run_process():
try:
args = General.shell_split(self.cmd)
(rc, stdout, stderr) = General.run_external(args, wait=self.wait)
except Exception as e:
raise RuntimeError(
f'Exception in external command\nCommand: {args}\nError: {str(e)}') from e
if self.wait and 0 != rc:
logger.warning('Command: %s\nReturn code: %d\nStdout: %s\nStderr: %s\n',
args, rc, stdout, stderr)
return 0
yield Command.Function(path=None, func=run_process,
label=_("Run external command: %s") % self.cmd)
class Shred(FileActionProvider):
"""Action to shred files (override preference)"""
action_key = 'shred'
def get_commands(self):
for path in self.get_paths():
yield Command.Shred(path)
class SqliteVacuum(FileActionProvider):
"""Action to vacuum SQLite databases"""
action_key = 'sqlite.vacuum'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
FileUtilities.vacuum_sqlite3,
# TRANSLATORS: Vacuum is a verb. The term is jargon
# from the SQLite database. Microsoft Access uses
# the term 'Compact Database' (which you may translate
# instead). Another synonym is 'defragment.'
_('Vacuum'))
class Truncate(FileActionProvider):
"""Action to truncate files"""
action_key = 'truncate'
def get_commands(self):
for path in self.get_paths():
yield Command.Truncate(path)
class WinShellChangeNotify(ActionProvider):
"""Action to clean the Windows Registry"""
action_key = 'win.shell.change.notify'
def get_commands(self):
assert os.name == 'nt'
yield Command.Function(
None,
# pylint: disable=possibly-used-before-assignment
Windows.shell_change_notify,
_('Refresh Windows shell'))
class Winreg(ActionProvider):
"""Action to clean the Windows Registry"""
action_key = 'winreg'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
self.keyname = action_element.getAttribute('path')
self.name = action_element.getAttribute('name')
def get_commands(self):
yield Command.Winreg(self.keyname, self.name)
class YumCleanAll(ActionProvider):
"""Action to run 'yum clean all'"""
action_key = 'yum.clean_all'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking allows auto-hide to work for non-APT systems
if not FileUtilities.exe_exists('yum'):
return
yield Command.Function(
None,
Unix.yum_clean,
'yum clean all')
class DnfCleanAll(ActionProvider):
"""Action to run 'dnf clean all'"""
action_key = 'dnf.clean_all'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking allows auto-hide to work for non-APT systems
if not FileUtilities.exe_exists('dnf'):
return
yield Command.Function(
None,
Unix.dnf_clean,
'dnf clean all')
class DnfAutoremove(ActionProvider):
"""Action to run 'dnf autoremove'"""
action_key = 'dnf.autoremove'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking allows auto-hide to work for non-APT systems
if not FileUtilities.exe_exists('dnf'):
return
yield Command.Function(
None,
Unix.dnf_autoremove,
'dnf autoremove')
class PacmanCache(ActionProvider):
"""Action to run `paccache -rk0'"""
action_key = 'pacman.cache'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
yield Command.Function(
None,
Unix.pacman_cache,
'paccache -rk0')
class SnapDisabled(ActionProvider):
"""Action to remove disabled snaps"""
action_key = 'snap.disabled'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
yield Command.Function(
None,
Unix.snap_disabled_clean,
'snap remove disabled',
Unix.snap_disabled_preview)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/CLI.py 0000664 0001750 0001750 00000026470 15075303713 013714 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
Command line interface
"""
from bleachbit.Cleaner import backends, create_simple_cleaner, register_cleaners
from bleachbit import APP_VERSION
from bleachbit import SystemInformation, Options, Worker
from bleachbit.Language import get_text as _
from bleachbit.Log import set_root_log_level
import logging
import optparse
import os
import sys
logger = logging.getLogger(__name__)
class CliCallback:
"""Command line's callback passed to Worker"""
def __init__(self, quiet=False):
self.quiet = quiet
def append_text(self, msg, _tag=None):
"""Write text to the terminal"""
if not self.quiet:
print(msg.strip('\n'))
def update_progress_bar(self, status):
"""Not used"""
def update_total_size(self, size):
"""Not used"""
def update_item_size(self, op, opid, size):
"""Not used"""
def worker_done(self, worker, really_delete):
"""Not used"""
def cleaners_list():
"""Yield each cleaner-option pair"""
list(register_cleaners())
for key in sorted(backends):
c_id = backends[key].get_id()
for (o_id, _o_name) in backends[key].get_options():
yield "%s.%s" % (c_id, o_id)
def list_cleaners():
"""Display available cleaners"""
for cleaner in cleaners_list():
print(cleaner)
def preview_or_clean(operations, really_clean, quiet=False):
"""Preview deletes and other changes"""
cb = CliCallback(quiet)
worker = Worker.Worker(cb, really_clean, operations).run()
try:
while next(worker):
pass
except StopIteration:
pass
except Exception:
logger.exception('Failed to clean')
def args_to_operations_list(preset, all_but_warning):
"""For --preset and --all-but-warning return list of operations as list
Example return: ['google_chrome.cache', 'system.tmp']
"""
args = []
if not backends:
list(register_cleaners())
assert len(backends) > 1
for key in sorted(backends):
c_id = backends[key].get_id()
for (o_id, _o_name) in backends[key].get_options():
# restore presets from the GUI
if preset and Options.options.get_tree(c_id, o_id):
args.append('.'.join([c_id, o_id]))
elif all_but_warning and not backends[c_id].get_warning(o_id):
args.append('.'.join([c_id, o_id]))
return args
def args_to_operations(args, preset, all_but_warning):
"""Read arguments and return list of operations as dictionary"""
list(register_cleaners())
operations = {}
if not args:
args = []
args = set(args + args_to_operations_list(preset, all_but_warning))
for arg in args:
if 2 != len(arg.split('.')):
logger.warning(_("not a valid cleaner: %s"), arg)
continue
(cleaner_id, option_id) = arg.split('.')
# enable all options (for example, firefox.*)
if '*' == option_id:
if cleaner_id in operations:
del operations[cleaner_id]
operations[cleaner_id] = [
option_id2
for (option_id2, _o_name) in backends[cleaner_id].get_options()
]
continue
# add the specified option
if cleaner_id not in operations:
operations[cleaner_id] = []
if option_id not in operations[cleaner_id]:
operations[cleaner_id].append(option_id)
for (k, v) in operations.items():
operations[k] = sorted(v)
return operations
def process_cmd_line():
"""Parse the command line and execute given commands."""
# TRANSLATORS: This is the command line usage. Don't translate
# %prog, but do translate options, cleaner, and option.
# Don't translate and add "usage:" - it gets added by Python.
# More information about the command line is here
# https://www.bleachbit.org/documentation/command-line
usage = _("usage: %prog [options] cleaner.option1 cleaner.option2")
parser = optparse.OptionParser(usage)
parser.add_option("-l", "--list-cleaners", action="store_true",
help=_("list cleaners"))
parser.add_option("-p", "--preview", action="store_true",
help=_("preview files to be deleted and other changes"))
parser.add_option("-c", "--clean", action="store_true",
# TRANSLATORS: predefined cleaners are for applications, such as Firefox and Flash.
# This is different than cleaning an arbitrary file, such as a
# spreadsheet on the desktop.
help=_("run cleaners to delete files and make other permanent changes"))
parser.add_option("-s", "--shred", action="store_true",
help=_("shred specific files or folders"))
parser.add_option("-w", "--wipe-free-space", action="store_true",
help=_("wipe free space in the given paths"))
parser.add_option('-o', '--overwrite', action='store_true',
help=_('overwrite files to hide contents'))
parser.add_option("--gui", action="store_true",
help=_("launch the graphical interface"))
parser.add_option("--preset", action="store_true",
help=_("use options set in the graphical interface"))
parser.add_option("--all-but-warning", action="store_true",
help=_("enable all options that do not have a warning"))
parser.add_option(
'--debug', help=_("set log level to verbose"), action="store_true")
parser.add_option('--debug-log', help=_("log debug messages to file"))
parser.add_option("--sysinfo", action="store_true",
help=_("show system information"))
parser.add_option("-v", "--version", action="store_true",
help=_("output version information and exit"))
if 'nt' == os.name:
uac_help = _("do not prompt for administrator privileges")
else:
uac_help = optparse.SUPPRESS_HELP
parser.add_option("--no-uac", action="store_true", help=uac_help)
parser.add_option('--pot', action='store_true',
help=optparse.SUPPRESS_HELP)
if 'nt' == os.name:
parser.add_option("--update-winapp2", action="store_true",
help=_("update winapp2.ini, if a new version is available"))
# added for testing py2exe build
# https://github.com/bleachbit/bleachbit/commit/befe244efee9b2d4859c6b6c31f8bedfd4d85aad#diff-b578cd35e15095f69822ebe497bf8691da1b587d6cc5f5ec252ff4f186dbed56
parser.add_option('--exit', action='store_true',
help=optparse.SUPPRESS_HELP)
# some workaround for context menu added here
# https://github.com/bleachbit/bleachbit/commit/b09625925149c98a6c79e278c35d5995e7526993
def expand_context_menu_option(_option, _opt, _value, parser):
setattr(parser.values, 'gui', True)
setattr(parser.values, 'exit', True)
parser.add_option("--context-menu", action="callback", callback=expand_context_menu_option,
help=optparse.SUPPRESS_HELP)
(options, args) = parser.parse_args()
cmd_list = (options.list_cleaners, options.wipe_free_space,
options.preview, options.clean)
cmd_count = sum(x is True for x in cmd_list)
if cmd_count > 1:
logger.error(
_('Specify only one of these commands: --list-cleaners, --wipe-free-space, --preview, --clean'))
sys.exit(1)
did_something = False
if options.debug:
# set in __init__ so it takes effect earlier
pass
elif options.preset:
# but if --preset is given, check if GUI option sets debug
if Options.options.get('debug'):
set_root_log_level(Options.options.get('debug'))
logger.debug("Debugging is enabled in GUI settings.")
if options.debug_log:
# File handler is already set up in Log.py init_log() function
# Just add system information to the log
logger.info(SystemInformation.get_system_information())
if options.version:
print("""
BleachBit version %s
Copyright (C) 2008-2025 Andrew Ziem. All rights reserved.
License GPLv3+: GNU GPL version 3 or later .
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.""" % APP_VERSION)
sys.exit(0)
if 'nt' == os.name and options.update_winapp2:
from bleachbit import Update
logger.info(_("Checking online for updates to winapp2.ini"))
Update.check_updates(False, True,
lambda x: sys.stdout.write("%s\n" % x),
lambda: None)
# updates can be combined with --list-cleaners, --preview, --clean
did_something = True
if options.list_cleaners:
list_cleaners()
sys.exit(0)
if options.pot:
from bleachbit.CleanerML import create_pot
create_pot()
sys.exit(0)
if options.wipe_free_space:
if len(args) < 1:
logger.error(_("No directories given for --wipe-free-space"))
sys.exit(1)
logger.info(_("Wiping free space can take a long time."))
for wipe_path in args:
logger.info('Wiping free space in path: %s', wipe_path)
import bleachbit.FileUtilities
for _ret in bleachbit.FileUtilities.wipe_path(wipe_path):
pass
sys.exit(0)
if options.preview or options.clean:
operations = args_to_operations(
args, options.preset, options.all_but_warning)
if not operations:
logger.error(_("No work to do. Specify options."))
sys.exit(1)
if options.overwrite:
if not options.clean or options.shred:
logger.warning(
_("--overwrite is intended only for use with --clean"))
Options.options.set('shred', True, commit=False)
if options.clean or options.preview:
preview_or_clean(operations, options.clean)
sys.exit(0)
if options.gui:
import bleachbit.GUI
app = bleachbit.GUI.Bleachbit(
uac=not options.no_uac, shred_paths=args, auto_exit=options.exit)
sys.exit(app.run())
if options.shred:
# delete arbitrary files without GUI
# create a temporary cleaner object
backends['_gui'] = create_simple_cleaner(args)
operations = {'_gui': ['files']}
preview_or_clean(operations, True)
sys.exit(0)
if options.sysinfo:
print(SystemInformation.get_system_information())
sys.exit(0)
if not did_something:
parser.print_help()
if __name__ == '__main__':
process_cmd_line()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/Chaff.py 0000664 0001750 0001750 00000021431 15075303713 014304 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 bz2
from datetime import datetime
import email.generator
from email.mime.text import MIMEText
import json
import logging
import os
import random
import tempfile
from bleachbit import options_dir
from . import markovify
logger = logging.getLogger(__name__)
# These were typos in the original emails, not OCR errors:
# abdinh@state.gov
# mhcaleja@state.gov
RECIPIENTS = [
'abedinh@state.gov',
'adlerce@state.gov',
'baerdb@state.gov',
'baldersonkm@state.gov',
'balderstonkm@state.gov',
'bam@mikulski.senate.gov',
'bealeca@state.gov',
'benjamin_moncrief@lemieux.senate.gov',
'blaker2@state.gov',
'brimmere@state.gov',
'burnswj@state.gov',
'butzgych2@state.gov',
'campbellkm@state.gov',
'carsonj@state.gov',
'cholletdh@state.gov',
'cindy.buhl@mail.house.gov',
'colemancl@state.gov',
'crowleypj@state.gov',
'danieljj@state.gov',
'david_garten@lautenberg.senate.gov',
'dewanll@state.gov',
'feltmanjd@state.gov',
'fuchsmh@state.gov',
'goldbergps@state.gov',
'goldenjr@state.gov',
'gonzalezjs@state.gov',
'gordonph@state.gov',
'hanleymr@state.gov',
'hdr22@clintonemail.com',
'hillcr@state.gov',
'holbrookerc@state.gov',
'hormatsrd@state.gov',
'hr15@att.blackberry.net',
'hr15@mycingular.blackberry.net',
'hrod17@clintonemail.com',
'huma@clintonemail.com',
'hyded@state.gov',
'info@mailva.evite.com',
'jilotylc@state.gov',
'jonespw2@state.gov',
'kellyc@state.gov',
'klevorickcb@state.gov',
'kohhh@state.gov',
'laszczychj@state.gov',
'lewjj@state.gov',
'macmanusje@state.gov',
'marshallcp@state.gov',
'mchaleja@state.gov',
'millscd@state.gov',
'muscatinel@state.gov',
'nidestr@state.gov',
'nulandvj@state.gov',
'oterom2@state.gov',
'posnermh@state.gov',
'reinesp@state.gov',
'reinespi@state.gov',
'ricese@state.gov',
'rodriguezme@state.gov',
'rooneym@state.gov',
's_specialassistants@state.gov',
'schwerindb@state.gov',
'shannonta@state.gov',
'shapiroa@state.gov',
'shermanwr@state.gov',
'slaughtera@state.gov',
'steinbergjb@state.gov',
'sterntd@state.gov',
'sullivanjj@state.gov',
'tauschereo@state.gov',
'tillemannts@state.gov',
'toivnf@state.gov',
'tommy_ross@reid.senate.gov',
'valenzuelaaa@state.gov',
'valmorolj@state.gov',
'vermarr@state.gov',
'verveerms@state.gov',
'woodardew@state.gov']
DEFAULT_SUBJECT_LENGTH = 64
DEFAULT_NUMBER_OF_SENTENCES_CLINTON = 50
DEFAULT_NUMBER_OF_SENTENCES_2600 = 50
MODEL_BASENAMES = (
'2600_model.json.bz2',
'clinton_content_model.json.bz2',
'clinton_subject_model.json.bz2')
URL_TEMPLATES = (
'https://sourceforge.net/projects/bleachbit/files/chaff/%s/download',
'https://download.bleachbit.org/chaff/%s')
DEFAULT_MODELS_DIR = options_dir
def _load_model(model_path):
_open = open
if model_path.endswith('.bz2'):
_open = bz2.open
with _open(model_path, 'rt', encoding='utf-8') as model_file:
return markovify.Text.from_dict(json.load(model_file))
def load_subject_model(model_path):
return _load_model(model_path)
def load_content_model(model_path):
return _load_model(model_path)
def load_2600_model(model_path):
return _load_model(model_path)
def _get_random_recipient():
return random.choice(RECIPIENTS)
def _get_random_datetime(min_year=2011, max_year=2012):
date = datetime.strptime('{} {}'.format(random.randint(
1, 365), random.randint(min_year, max_year)), '%j %Y')
# Saturday, September 15, 2012 2:20 PM
return date.strftime('%A, %B %d, %Y %I:%M %p')
def _get_random_content(content_model, number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_CLINTON):
content = []
for _i in range(number_of_sentences):
content.append(content_model.make_sentence())
content.append(random.choice([' ', ' ', '\n\n']))
try:
return MIMEText(''.join(content), _charset='iso-8859-1')
except UnicodeEncodeError:
return _get_random_content(content_model, number_of_sentences=number_of_sentences)
def _generate_email(subject_model, content_model, number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_CLINTON, subject_length=DEFAULT_SUBJECT_LENGTH):
message = _get_random_content(
content_model, number_of_sentences=number_of_sentences)
message['Subject'] = subject_model.make_short_sentence(subject_length)
message['To'] = _get_random_recipient()
message['From'] = _get_random_recipient()
message['Sent'] = _get_random_datetime()
return message
def download_models(models_dir=DEFAULT_MODELS_DIR,
on_error=None):
"""Download models
Calls on_error(primary_message, secondary_message) in case of error
Returns success as boolean value
"""
from bleachbit.Network import download_url_to_fn
for basename in (MODEL_BASENAMES):
fn = os.path.join(models_dir, basename)
if os.path.exists(fn):
logger.debug('File %s already exists', fn)
continue
this_file_success = False
for url_template in URL_TEMPLATES:
url = url_template % basename
if download_url_to_fn(url, fn, on_error=on_error):
this_file_success = True
break
if not this_file_success:
return False
return True
def generate_emails(number_of_emails,
email_output_dir,
models_dir=DEFAULT_MODELS_DIR,
number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_CLINTON,
on_progress=None,
*kwargs):
logger.debug('Loading two email models')
subject_model_path = os.path.join(
models_dir, 'clinton_subject_model.json.bz2')
content_model_path = os.path.join(
models_dir, 'clinton_content_model.json.bz2')
subject_model = load_subject_model(subject_model_path)
content_model = load_content_model(content_model_path)
logger.debug('Generating {:,} emails'.format(number_of_emails))
generated_file_names = []
for i in range(1, number_of_emails + 1):
with tempfile.NamedTemporaryFile(mode='w+', prefix='outlook-', suffix='.eml', dir=email_output_dir, delete=False) as email_output_file:
email_generator = email.generator.Generator(email_output_file)
msg = _generate_email(
subject_model, content_model, number_of_sentences=number_of_sentences)
email_generator.write(msg.as_string())
generated_file_names.append(email_output_file.name)
if on_progress:
on_progress(1.0 * i / number_of_emails)
return generated_file_names
def _generate_2600_file(model, number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_2600):
content = []
for _i in range(number_of_sentences):
content.append(model.make_sentence())
# The space is repeated to make paragraphs longer.
content.append(random.choice([' ', ' ', '\n\n']))
return ''.join(content)
def generate_2600(file_count,
output_dir,
model_dir=DEFAULT_MODELS_DIR,
on_progress=None):
logger.debug('Loading 2600 model')
model_path = os.path.join(model_dir, '2600_model.json.bz2')
model = _load_model(model_path)
logger.debug(f'Generating {file_count:,} files')
generated_file_names = []
for i in range(1, file_count + 1):
with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', prefix='2600-', suffix='.txt', dir=output_dir, delete=False) as output_file:
txt = _generate_2600_file(model)
output_file.write(txt)
generated_file_names.append(output_file.name)
if on_progress:
on_progress(1.0 * i / file_count)
return generated_file_names
def have_models():
"""Check whether the models exist in the default location.
Used to check whether download is needed."""
for basename in (MODEL_BASENAMES):
fn = os.path.join(DEFAULT_MODELS_DIR, basename)
if not os.path.exists(fn):
return False
return True
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/Cleaner.py 0000775 0001750 0001750 00000077647 15075303713 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 .
"""
Perform (or assist with) cleaning operations.
"""
import glob
import logging
import os.path
import re
import sys
import tempfile
import warnings
from bleachbit.Language import get_text as _
from bleachbit.FileUtilities import children_in_directory
from bleachbit.Options import options
from bleachbit import Action, CleanerML, Command, FileUtilities, Memory, Special
if 'posix' == os.name:
from bleachbit import Unix
# Suppress GTK warning messages while running in CLI #34
warnings.simplefilter("ignore", Warning)
try:
from bleachbit.GuiBasic import Gtk, Gdk
HAVE_GTK = Gdk.get_default_root_window() is not None
except (ImportError, RuntimeError, ValueError):
# ImportError happens when GTK is not installed.
# RuntimeError can happen when X is not available (e.g., cron, ssh).
# ValueError seen on BleachBit 3.0 with GTK 3 (GitHub issue 685)
HAVE_GTK = False
elif 'nt' == os.name:
from bleachbit import Windows
from bleachbit.GuiBasic import Gtk, Gdk
HAVE_GTK = True
else:
raise RuntimeError(f"Unknown OS '{os.name}'")
# a module-level variable for holding cleaners
backends = {}
class Cleaner:
"""Base class for a cleaner"""
def __init__(self):
self.actions = []
self.id = None
self.description = None
self.name = None
self.options = {}
self.running = []
self.warnings = {}
self.regexes_compiled = []
def add_action(self, option_id, action):
"""Register 'action' (instance of class Action) to be executed
for ''option_id'. The actions must implement list_files and
other_cleanup()"""
self.actions.append((option_id, action))
def add_option(self, option_id, name, description):
"""Register option (such as 'cache')"""
self.options[option_id] = (name, description)
def add_running(self, detection_type, pathname, same_user=False):
"""Add a way to detect this program is currently running"""
self.running.append((detection_type, pathname, same_user))
def auto_hide(self):
"""Return boolean whether it is OK to automatically hide this
cleaner"""
for (option_id, __name) in self.get_options():
try:
for cmd in self.get_commands(option_id):
for _dummy in cmd.execute(False):
return False
for _ds in self.get_deep_scan(option_id):
return False
except Exception:
logger = logging.getLogger(__name__)
logger.exception('exception in auto_hide(), cleaner=%s, option=%s',
self.name, option_id)
return True
def get_commands(self, option_id):
"""Get list of Command instances for option 'option_id'"""
for action in self.actions:
if option_id == action[0]:
yield from action[1].get_commands()
if option_id not in self.options:
raise RuntimeError(f"Unknown option '{option_id}'")
def get_deep_scan(self, option_id):
"""Get dictionary used to build a deep scan"""
for action in self.actions:
if option_id == action[0]:
try:
yield from action[1].get_deep_scan()
except StopIteration:
return
if option_id not in self.options:
raise RuntimeError(f"Unknown option '{option_id}'")
def get_description(self):
"""Brief description of the cleaner"""
return self.description
def get_id(self):
"""Return the unique name of this cleaner"""
return self.id
def get_name(self):
"""Return the human name of this cleaner"""
return self.name
def get_option_descriptions(self):
"""Yield the names and descriptions of each option in a 2-tuple"""
if self.options:
for key in sorted(self.options.keys()):
yield (self.options[key][0], self.options[key][1])
def get_options(self):
"""Return user-configurable options in 2-tuple (id, name)"""
if self.options:
for key in sorted(self.options.keys()):
yield (key, self.options[key][0])
def get_warning(self, option_id):
"""Return a warning as string."""
if option_id in self.warnings:
return self.warnings[option_id]
return None
def is_process_running(self):
"""Return whether the process is currently running"""
logger = logging.getLogger(__name__)
for (test, pathname, same_user) in self.running:
if 'exe' == test:
if _is_process_running(pathname, same_user):
logger.debug("process '%s' is running", pathname)
return True
elif 'pathname' == test:
expanded = os.path.expanduser(os.path.expandvars(pathname))
for globbed in glob.iglob(expanded):
if os.path.exists(globbed):
logger.debug(
"file '%s' exists indicating '%s' is running", globbed, self.name)
return True
else:
raise RuntimeError(f"Unknown running-detection test '{test}'")
return False
def is_usable(self):
"""Return whether the cleaner is usable (has actions)"""
return len(self.actions) > 0
def set_warning(self, option_id, description):
"""Set a warning to be displayed when option is selected interactively"""
self.warnings[option_id] = description
class OpenOfficeOrg(Cleaner):
"""Delete OpenOffice.org cache"""
def __init__(self):
Cleaner.__init__(self)
self.options = {}
self.add_option('cache', _('Cache'), _('Delete the cache'))
self.add_option('recent_documents', _('Most recently used'), _(
"Delete the list of recently used documents"))
self.id = 'openofficeorg'
self.name = 'OpenOffice.org'
self.description = _("Office suite")
# reference: http://katana.oooninja.com/w/editions_of_openoffice.org
if 'posix' == os.name:
self.prefixes = ["~/.ooo-2.0", "~/.openoffice.org2",
"~/.openoffice.org2.0", "~/.openoffice.org/3",
"~/.ooo-dev3"]
if 'nt' == os.name:
self.prefixes = [
"$APPDATA\\OpenOffice.org\\3", "$APPDATA\\OpenOffice.org2"]
def get_commands(self, option_id):
# paths for which to run expand_glob_join
egj = []
if 'recent_documents' == option_id:
egj.append(
"user/registry/data/org/openoffice/Office/Histories.xcu")
egj.append(
"user/registry/cache/org.openoffice.Office.Histories.dat")
if 'recent_documents' == option_id and not 'cache' == option_id:
egj.append("user/registry/cache/org.openoffice.Office.Common.dat")
for egj_ in egj:
for prefix in self.prefixes:
for path in FileUtilities.expand_glob_join(prefix, egj_):
if 'nt' == os.name:
path = os.path.normpath(path)
if os.path.lexists(path):
yield Command.Delete(path)
if 'cache' == option_id:
dirs = []
for prefix in self.prefixes:
dirs += FileUtilities.expand_glob_join(
prefix, "user/registry/cache/")
for dirname in dirs:
if 'nt' == os.name:
dirname = os.path.normpath(dirname)
for filename in children_in_directory(dirname, False):
yield Command.Delete(filename)
if 'recent_documents' == option_id:
for prefix in self.prefixes:
for path in FileUtilities.expand_glob_join(prefix,
"user/registry/data/org/openoffice/Office/Common.xcu"):
if os.path.lexists(path):
yield Command.Function(path,
Special.delete_ooo_history,
_('Delete the usage history'))
# ~/.openoffice.org/3/user/registrymodifications.xcu
# Apache OpenOffice.org 3.4.1 from openoffice.org on Ubuntu 13.04
# %AppData%\OpenOffice.org\3\user\registrymodifications.xcu
# Apache OpenOffice.org 3.4.1 from openoffice.org on Windows XP
for path in FileUtilities.expand_glob_join(prefix,
"user/registrymodifications.xcu"):
if os.path.lexists(path):
yield Command.Function(path,
Special.delete_office_registrymodifications,
_('Delete the usage history'))
class System(Cleaner):
"""Clean the system in general"""
def __init__(self):
Cleaner.__init__(self)
#
# options for Linux and BSD
#
if 'posix' == os.name:
# TRANSLATORS: desktop entries are .desktop files in Linux that
# make up the application menu (the menu that shows BleachBit,
# Firefox, and others. The .desktop files also associate file
# types, so clicking on an .html file in Nautilus brings up
# Firefox.
# More information:
# http://standards.freedesktop.org/menu-spec/latest/index.html#introduction
self.add_option('desktop_entry', _('Broken desktop files'), _(
'Delete broken application menu entries and file associations'))
self.add_option('cache', _('Cache'), _('Delete the cache'))
# TRANSLATORS: Localizations are files supporting specific
# languages, so applications appear in Spanish, etc.
self.add_option('localizations', _('Localizations'), _(
'Delete files for unwanted languages'))
self.set_warning(
'localizations', _("Configure this option in the preferences."))
# TRANSLATORS: 'Rotated logs' refers to old system log files.
# Linux systems often have a scheduled job to rotate the logs
# which means compress all except the newest log and then delete
# the oldest log. You could translate this 'old logs.'
self.add_option(
'rotated_logs', _('Rotated logs'), _('Delete old system logs'))
self.add_option('recent_documents', _('Recent documents list'), _(
'Delete the list of recently used documents'))
self.add_option('trash', _('Trash'), _('Empty the trash'))
#
# options just for Linux
#
if sys.platform == 'linux':
self.add_option('memory', _('Memory'),
# TRANSLATORS: 'free' means 'unallocated'
_('Wipe the swap and free memory'))
self.set_warning(
'memory', _('This option is experimental and may cause system problems.'))
#
# options just for Microsoft Windows
#
if 'nt' == os.name:
self.add_option('logs', _('Logs'), _('Delete the logs'))
self.add_option(
'memory_dump', _('Memory dump'), _('Delete the file'))
self.add_option('muicache', 'MUICache', _('Delete the cache'))
# TRANSLATORS: Prefetch is Microsoft Windows jargon.
self.add_option('prefetch', _('Prefetch'), _('Delete the cache'))
self.add_option(
'recycle_bin', _('Recycle bin'), _('Empty the recycle bin'))
# TRANSLATORS: 'Update' is a noun, and 'Update uninstallers' is an option to delete
# the uninstallers for software updates.
self.add_option('updates', _('Update uninstallers'), _(
'Delete uninstallers for Microsoft updates including hotfixes, service packs, and Internet Explorer updates'))
#
# options for GTK+
#
if HAVE_GTK:
self.add_option('clipboard', _('Clipboard'), _(
'The desktop environment\'s clipboard used for copy and paste operations'))
#
# options common to all platforms
#
# TRANSLATORS: "Custom" is an option allowing the user to specify which
# files and folders will be erased.
self.add_option('custom', _('Custom'), _(
'Delete user-specified files and folders'))
# TRANSLATORS: 'free' means 'unallocated'
self.add_option('free_disk_space', _('Free disk space'),
# TRANSLATORS: 'free' means 'unallocated'
_('Overwrite free disk space to hide deleted files'))
self.set_warning('free_disk_space', _('This option is very slow.'))
self.add_option(
'tmp', _('Temporary files'), _('Delete the temporary files'))
self.description = _("The system in general")
self.id = 'system'
self.name = _("System")
def get_commands(self, option_id):
# cache
if 'posix' == os.name and 'cache' == option_id:
dirname = os.path.expanduser("~/.cache/")
for filename in children_in_directory(dirname, True):
if not self.whitelisted(filename):
yield Command.Delete(filename)
# custom
if 'custom' == option_id:
for (c_type, c_path) in options.get_custom_paths():
if 'file' == c_type:
if os.path.lexists(c_path):
yield Command.Delete(c_path)
elif 'folder' == c_type:
if os.path.lexists(c_path):
for path in children_in_directory(c_path, True):
yield Command.Delete(path)
yield Command.Delete(c_path)
else:
raise RuntimeError(
f'custom folder has invalid type {c_type}')
# menu
menu_dirs = ['~/.local/share/applications',
'~/.config/autostart',
'~/.gnome/apps/',
'~/.gnome2/panel2.d/default/launchers',
'~/.gnome2/vfolders/applications/',
'~/.kde/share/apps/RecentDocuments/',
'~/.kde/share/mimelnk',
'~/.kde/share/mimelnk/application/ram.desktop',
'~/.kde2/share/mimelnk/application/',
'~/.kde2/share/applnk']
if 'posix' == os.name and 'desktop_entry' == option_id:
for path in menu_dirs:
dirname = os.path.expanduser(path)
for filename in children_in_directory(dirname, False):
# pylint: disable=possibly-used-before-assignment
if filename.endswith('.desktop') and Unix.is_broken_xdg_desktop(filename):
yield Command.Delete(filename)
# unwanted locales
if 'posix' == os.name and 'localizations' == option_id:
for path in Unix.locales.localization_paths(locales_to_keep=options.get_languages()):
if os.path.isdir(path):
for f in FileUtilities.children_in_directory(path, True):
yield Command.Delete(f)
yield Command.Delete(path)
# Windows logs
if 'nt' == os.name and 'logs' == option_id:
paths = (
'$ALLUSERSPROFILE\\Application Data\\Microsoft\\Dr Watson\\*.log',
'$ALLUSERSPROFILE\\Application Data\\Microsoft\\Dr Watson\\user.dmp',
'$LocalAppData\\Microsoft\\Windows\\WER\\ReportArchive\\*\\*',
'$LocalAppData\\Microsoft\\Windows\\WER\\ReportQueue\\*\\*',
'$programdata\\Microsoft\\Windows\\WER\\ReportArchive\\*\\*',
'$programdata\\Microsoft\\Windows\\WER\\ReportQueue\\*\\*',
'$localappdata\\Microsoft\\Internet Explorer\\brndlog.bak',
'$localappdata\\Microsoft\\Internet Explorer\\brndlog.txt',
'$windir\\*.log',
'$windir\\imsins.BAK',
'$windir\\OEWABLog.txt',
'$windir\\SchedLgU.txt',
'$windir\\ntbtlog.txt',
'$windir\\setuplog.txt',
'$windir\\REGLOCS.OLD',
'$windir\\Debug\\*.log',
'$windir\\Debug\\Setup\\UpdSh.log',
'$windir\\Debug\\UserMode\\*.log',
'$windir\\Debug\\UserMode\\ChkAcc.bak',
'$windir\\Debug\\UserMode\\userenv.bak',
'$windir\\Microsoft.NET\\Framework\\*\\*.log',
'$windir\\pchealth\\helpctr\\Logs\\hcupdate.log',
'$windir\\security\\logs\\*.log',
'$windir\\security\\logs\\*.old',
'$windir\\SoftwareDistribution\\*.log',
'$windir\\SoftwareDistribution\\DataStore\\Logs\\*',
'$windir\\system32\\TZLog.log',
'$windir\\system32\\config\\systemprofile\\Application Data\\Microsoft\\Internet Explorer\\brndlog.bak',
'$windir\\system32\\config\\systemprofile\\Application Data\\Microsoft\\Internet Explorer\\brndlog.txt',
'$windir\\system32\\LogFiles\\AIT\\AitEventLog.etl.???',
'$windir\\system32\\LogFiles\\Firewall\\pfirewall.log*',
'$windir\\system32\\LogFiles\\Scm\\SCM.EVM*',
'$windir\\system32\\LogFiles\\WMI\\Terminal*.etl',
'$windir\\system32\\LogFiles\\WMI\\RTBackup\\EtwRT.*etl',
'$windir\\system32\\wbem\\Logs\\*.lo_',
'$windir\\system32\\wbem\\Logs\\*.log', )
for path in paths:
expanded = os.path.expandvars(path)
for globbed in glob.iglob(expanded):
yield Command.Delete(globbed)
# memory
if sys.platform == 'linux' and 'memory' == option_id:
yield Command.Function(None, Memory.wipe_memory, _('Memory'))
# memory dump
# how to manually create this file
# http://www.pctools.com/guides/registry/detail/856/
if 'nt' == os.name and 'memory_dump' == option_id:
fname = os.path.expandvars('$windir\\memory.dmp')
if os.path.exists(fname):
yield Command.Delete(fname)
for fname in glob.iglob(os.path.expandvars('$windir\\Minidump\\*.dmp')):
yield Command.Delete(fname)
# most recently used documents list
if 'posix' == os.name and 'recent_documents' == option_id:
ru_fn = os.path.expanduser("~/.recently-used")
if os.path.lexists(ru_fn):
yield Command.Delete(ru_fn)
# GNOME 2.26 (as seen on Ubuntu 9.04) will retain the list
# in memory if it is simply deleted, so it must be shredded
# (or at least truncated).
#
# GNOME 2.28.1 (Ubuntu 9.10) and 2.30 (10.04) do not re-read
# the file after truncation, but do re-read it after
# shredding.
#
# https://bugzilla.gnome.org/show_bug.cgi?id=591404
def gtk_purge_items():
"""Purge GTK items"""
Gtk.RecentManager().get_default().purge_items()
yield 0
xbel_pathnames = [
'~/.recently-used.xbel',
'~/.local/share/recently-used.xbel*',
'~/snap/*/*/.local/share/recently-used.xbel']
for path1 in xbel_pathnames:
for path2 in glob.iglob(os.path.expanduser(path1)):
if os.path.lexists(path2):
yield Command.Shred(path2)
if HAVE_GTK:
# Use the Function to skip when in preview mode
yield Command.Function(None, gtk_purge_items, _('Recent documents list'))
if 'posix' == os.name and 'rotated_logs' == option_id:
for path in Unix.rotated_logs():
yield Command.Delete(path)
# temporary files
if 'posix' == os.name and 'tmp' == option_id:
dirnames = ['/tmp', '/var/tmp']
for dirname in dirnames:
for path in children_in_directory(dirname, True):
is_open = FileUtilities.openfiles.is_open(path)
ok = not is_open and os.path.isfile(path) and \
not os.path.islink(path) and \
FileUtilities.ego_owner(path) and \
not self.whitelisted(path)
if ok:
yield Command.Delete(path)
# temporary files
if 'nt' == os.name and 'tmp' == option_id:
dirnames = [os.path.expandvars(
r'%temp%'), os.path.expandvars("%windir%\\temp\\")]
# whitelist the folder %TEMP%\Low but not its contents
# https://bugs.launchpad.net/bleachbit/+bug/1421726
for dirname in dirnames:
low = os.path.join(dirname, 'low').lower()
for filename in children_in_directory(dirname, True):
if not low == filename.lower():
yield Command.Delete(filename)
# trash
if 'posix' == os.name and 'trash' == option_id:
dirname = os.path.expanduser("~/.Trash")
for filename in children_in_directory(dirname, False):
yield Command.Delete(filename)
# fixme http://www.ramendik.ru/docs/trashspec.html
# http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html
# ~/.local/share/Trash
# * GNOME 2.22, Fedora 9
# * KDE 4.1.3, Ubuntu 8.10
dirname = os.path.expanduser("~/.local/share/Trash/files")
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
dirname = os.path.expanduser("~/.local/share/Trash/info")
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
dirname = os.path.expanduser("~/.local/share/Trash/expunged")
# desrt@irc.gimpnet.org tells me that the trash
# backend puts files in here temporary, but in some situations
# the files are stuck.
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
# clipboard
if HAVE_GTK and 'clipboard' == option_id:
def clear_clipboard():
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(' ', 1)
clipboard.clear()
return 0
yield Command.Function(None, clear_clipboard, _('Clipboard'))
# overwrite free space
shred_drives = options.get_list('shred_drives')
if 'free_disk_space' == option_id and shred_drives:
for pathname in shred_drives:
# TRANSLATORS: 'Free' means 'unallocated.'
# %s expands to a path such as C:\ or /tmp/
display = _("Overwrite free disk space %s") % pathname
def wipe_path_func(path=pathname):
# Yield control to GTK idle because this process
# is very slow. Also display progress.
yield from FileUtilities.wipe_path(path, idle=True)
yield 0
yield Command.Function(None, wipe_path_func, display)
# MUICache
if 'nt' == os.name and 'muicache' == option_id:
keys = (
'HKCU\\Software\\Microsoft\\Windows\\ShellNoRoam\\MUICache',
'HKCU\\Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\Shell\\MuiCache')
for key in keys:
yield Command.Winreg(key, None)
# prefetch
if 'nt' == os.name and 'prefetch' == option_id:
for path in glob.iglob(os.path.expandvars('$windir\\Prefetch\\*.pf')):
yield Command.Delete(path)
# recycle bin
if 'nt' == os.name and 'recycle_bin' == option_id:
# This method allows shredding
recycled_any = False
# pylint: disable=possibly-used-before-assignment
for path in Windows.get_recycle_bin():
recycled_any = True
yield Command.Delete(path)
# Windows 10 refreshes the recycle bin icon when the user
# opens the recycle bin folder.
# This is a hack to refresh the icon.
def empty_recycle_bin_func():
tmpdir = tempfile.mkdtemp()
Windows.move_to_recycle_bin(tmpdir)
try:
Windows.empty_recycle_bin(None, True)
except Exception:
logging.getLogger(__name__).info(
'error in empty_recycle_bin()', exc_info=True)
yield 0
# Using the Function Command prevents emptying the recycle bin
# when in preview mode.
if recycled_any:
yield Command.Function(None, empty_recycle_bin_func, _('Empty the recycle bin'))
# Windows Updates
if 'nt' == os.name and 'updates' == option_id:
for wu in Windows.delete_updates():
yield wu
def init_whitelist(self):
"""Initialize the whitelist only once for performance"""
regexes = [
'^/tmp/.X0-lock$',
'^/tmp/.truecrypt_aux_mnt.*/(control|volume)$',
'^/tmp/.vbox-[^/]+-ipc/lock$',
'^/tmp/.wine-[0-9]+/server-.*/lock$',
'^/tmp/fsa/', # fsarchiver
'^/tmp/gconfd-[^/]+/lock/ior$',
'^/tmp/kde-',
'^/tmp/kdesudo-',
'^/tmp/ksocket-',
'^/tmp/orbit-[^/]+/bonobo-activation-register[a-z0-9-]*.lock$',
'^/tmp/orbit-[^/]+/bonobo-activation-server-[a-z0-9-]*ior$',
'^/tmp/pulse-[^/]+/pid$',
'^/tmp/xauth',
'^/var/tmp/kdecache-',
'^' + os.path.expanduser('~/.cache/wallpaper/'),
# Flatpak mount point
'^' + os.path.expanduser('~/.cache/doc($|/)'),
# Clean Firefox cache from Firefox cleaner (LP#1295826)
'^' + os.path.expanduser('~/.cache/mozilla/'),
# Clean Google Chrome cache from Google Chrome cleaner (LP#656104)
'^' + os.path.expanduser('~/.cache/google-chrome/'),
'^' + os.path.expanduser('~/.cache/gnome-control-center/'),
# Clean Evolution cache from Evolution cleaner (GitHub #249)
'^' + os.path.expanduser('~/.cache/evolution/'),
# iBus Pinyin
# https://bugs.launchpad.net/bleachbit/+bug/1538919
'^' + os.path.expanduser('~/.cache/ibus/'),
# Linux Bluetooth daemon obexd directory is typically empty, so be careful
# not to delete the empty directory.
'^' + os.path.expanduser('~/.cache/obexd($|/)'),
# KDE/Plasma cache files
# https://github.com/bleachbit/bleachbit/issues/1853
'^' + os.path.expanduser('~/.cache/kwin($|/)'), # folder
# folder
'^' + os.path.expanduser('~/.cache/mesa_shader_cache($|/)'),
'^' + os.path.expanduser('~/.cache/plasmashell($|/)'), # folder
'^' + os.path.expanduser('~/.cache/icon-cache.kcache$'), # file
# file
r'^' + os.path.expanduser(r'~/.cache/plasma_theme_.*\.kcache$'),
'^' + os.path.expanduser('~/.cache/drkonqi($|/)'), # folder
# folder
'^' + os.path.expanduser('~/.cache/mesa_shader_cache_db($|/)'),
# folder
'^' + os.path.expanduser('~/.cache/qtshadercache-[^/]+($|/)'),
# file
'^' + os.path.expanduser('~/.cache/plasma_theme_default.kcache$')]
for regex in regexes:
self.regexes_compiled.append(re.compile(regex))
def whitelisted(self, pathname):
"""Return boolean whether file is whitelisted"""
if os.name == 'nt':
# Whitelist is specific to POSIX
return False
if not self.regexes_compiled:
self.init_whitelist()
for regex in self.regexes_compiled:
if regex.match(pathname) is not None:
return True
return False
def _is_process_running(exename, require_same_user):
if 'posix' == os.name:
return Unix.is_process_running(exename, require_same_user)
elif 'nt' == os.name:
return Windows.is_process_running(exename, require_same_user)
raise NotImplementedError('_is_process_running: Unsupported platform')
def register_cleaners(cb_progress=lambda x: None, cb_done=lambda: None):
"""Register all known cleaners: system, CleanerML, and Winapp2"""
# pylint: disable=global-variable-not-assigned
global backends
# wipe out any registrations
# Because this is a global variable, cannot use backends = {}
backends.clear()
# initialize "hard coded" (non-CleanerML) backends
backends["openofficeorg"] = OpenOfficeOrg()
backends["system"] = System()
# register CleanerML cleaners
cb_progress(_('Loading native cleaners.'))
yield from CleanerML.load_cleaners(cb_progress)
# register Winapp2.ini cleaners
if 'nt' == os.name:
cb_progress(_('Importing cleaners from Winapp2.ini.'))
# pylint: disable=import-outside-toplevel
from bleachbit import Winapp
yield from Winapp.load_cleaners(cb_progress)
cb_done()
yield False # end the iteration
def create_simple_cleaner(paths):
"""Shred arbitrary files (used in CLI and GUI)"""
cleaner = Cleaner()
cleaner.add_option(option_id='files', name='', description='')
cleaner.name = _("System") # shows up in progress bar
class CustomFileAction(Action.ActionProvider):
"""Custom file action"""
action_key = '__customfileaction'
def get_commands(self):
for path in paths:
if not isinstance(path, (str)):
raise RuntimeError(
f'expected path as string but got {str(path)}')
if not os.path.isabs(path):
path = os.path.abspath(path)
if os.path.isdir(path):
for child in children_in_directory(path, True):
yield Command.Shred(child)
yield Command.Shred(path)
provider = CustomFileAction(None)
cleaner.add_action('files', provider)
return cleaner
def create_wipe_cleaner(path):
"""Wipe free disk space of arbitrary paths (used in GUI)"""
cleaner = Cleaner()
cleaner.add_option(
option_id='free_disk_space', name='', description='')
cleaner.name = ''
# create a temporary cleaner object
display = _("Overwrite free disk space %s") % path
def wipe_path_func():
yield from FileUtilities.wipe_path(path, idle=True)
yield 0
class CustomWipeAction(Action.ActionProvider):
action_key = '__customwipeaction'
def get_commands(self):
yield Command.Function(None, wipe_path_func, display)
provider = CustomWipeAction(None)
cleaner.add_action('free_disk_space', provider)
return cleaner
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/CleanerML.py 0000775 0001750 0001750 00000032540 15075303713 015105 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 .
"""
Create cleaners from CleanerML (markup language)
"""
# standard library
import logging
import os
import stat
import sys
import xml.dom.minidom
# local import
import bleachbit
from bleachbit.Action import ActionProvider
from bleachbit.FileUtilities import expand_glob_join, listdir
from bleachbit.General import boolstr_to_bool, getText
from bleachbit.Language import get_text as _
from bleachbit import Cleaner
logger = logging.getLogger(__name__)
def default_vars():
"""Return default multi-value variables"""
ret = {}
if not os.name == 'nt':
return ret
# Expand ProgramFiles to also be ProgramW6432, etc.
wowvars = (('ProgramFiles', 'ProgramW6432'),
('CommonProgramFiles', 'CommonProgramW6432'))
for v1, v2 in wowvars:
# Remove None, if variable is not found.
# Make list unique.
mylist = list({x for x in (os.getenv(v1), os.getenv(v2)) if x})
ret[v1] = mylist
return ret
class CleanerML:
"""Create a cleaner from CleanerML"""
def __init__(self, pathname, xlate_cb=None):
"""Create cleaner from XML in pathname.
If xlate_cb is set, use it as a callback for each
translate-able string.
"""
self.action = None
self.cleaner = Cleaner.Cleaner()
self.option_id = None
self.option_name = None
self.option_description = None
self.option_warning = None
self.vars = default_vars()
self.xlate_cb = xlate_cb
if self.xlate_cb is None:
self.xlate_mode = False
self.xlate_cb = lambda x, y=None: None # do nothing
else:
self.xlate_mode = True
try:
dom = xml.dom.minidom.parse(pathname)
except xml.parsers.expat.ExpatError as e:
logger.error(
"Error parsing CleanerML file %s with error %s", pathname, e)
return
self.handle_cleaner(dom.getElementsByTagName('cleaner')[0])
def get_cleaner(self):
"""Return the created cleaner"""
return self.cleaner
def os_match(self, 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 or if in .pot-creation-mode, return true.
if len(os_str) == 0 or self.xlate_mode:
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 handle_cleaner(self, cleaner):
""" element"""
self.cleaner.id = cleaner.getAttribute('id')
if not self.os_match(cleaner.getAttribute('os')):
return
self.handle_cleaner_label(cleaner.getElementsByTagName('label')[0])
description = cleaner.getElementsByTagName('description')
if description and description[0].parentNode == cleaner:
self.handle_cleaner_description(description[0])
for var in cleaner.getElementsByTagName('var'):
self.handle_cleaner_var(var)
for option in cleaner.getElementsByTagName('option'):
try:
self.handle_cleaner_option(option)
except Exception:
exc_msg = _(
"Error in handle_cleaner_option() for cleaner id = {cleaner_id}, option XML={option_xml}")
logger.exception(exc_msg.format(
cleaner_id=self.cleaner.id, option_xml=option.toxml()))
self.handle_cleaner_running(cleaner.getElementsByTagName('running'))
self.handle_localizations(
cleaner.getElementsByTagName('localizations'))
def handle_cleaner_label(self, label):
""" element under """
self.cleaner.name = _(getText(label.childNodes))
translate = label.getAttribute('translate')
if translate and boolstr_to_bool(translate):
self.xlate_cb(self.cleaner.name)
def handle_cleaner_description(self, description):
""" element under """
self.cleaner.description = _(getText(description.childNodes))
translators = description.getAttribute('translators')
self.xlate_cb(self.cleaner.description, translators)
def handle_cleaner_running(self, running_elements):
""" element under """
# example: opera
for running in running_elements:
if not self.os_match(running.getAttribute('os')):
continue
detection_type = running.getAttribute('type')
value = getText(running.childNodes)
same_user = running.getAttribute('same_user') or False
self.cleaner.add_running(detection_type, value, same_user)
def handle_cleaner_option(self, option):
""" element"""
self.option_id = option.getAttribute('id')
self.option_description = None
self.option_name = None
self.handle_cleaner_option_label(
option.getElementsByTagName('label')[0])
description = option.getElementsByTagName('description')
self.handle_cleaner_option_description(description[0])
warning = option.getElementsByTagName('warning')
if warning:
self.handle_cleaner_option_warning(warning[0])
if self.option_warning:
self.cleaner.set_warning(self.option_id, self.option_warning)
for action in option.getElementsByTagName('action'):
self.handle_cleaner_option_action(action)
self.cleaner.add_option(
self.option_id, self.option_name, self.option_description)
def handle_cleaner_option_label(self, label):
""" element under """
self.option_name = _(getText(label.childNodes))
translate = label.getAttribute('translate')
translators = label.getAttribute('translators')
if not translate or boolstr_to_bool(translate):
self.xlate_cb(self.option_name, translators)
def handle_cleaner_option_description(self, description):
""" element under """
self.option_description = _(getText(description.childNodes))
translators = description.getAttribute('translators')
self.xlate_cb(self.option_description, translators)
def handle_cleaner_option_warning(self, warning):
""" element under """
self.option_warning = _(getText(warning.childNodes))
self.xlate_cb(self.option_warning)
def handle_cleaner_option_action(self, action_node):
""" element under """
if not self.os_match(action_node.getAttribute('os')):
return
command = action_node.getAttribute('command')
provider = None
for actionplugin in ActionProvider.plugins:
if actionplugin.action_key == command:
provider = actionplugin(action_node, self.vars)
if provider is None:
raise RuntimeError(f"Invalid command '{command}'")
self.cleaner.add_action(self.option_id, provider)
def handle_localizations(self, localization_nodes):
""" element under """
if not 'posix' == os.name:
return
# pylint: disable=import-outside-toplevel
from bleachbit import Unix
for localization_node in localization_nodes:
for child_node in localization_node.childNodes:
Unix.locales.add_xml(child_node)
# Add a dummy action so the file isn't reported as unusable
self.cleaner.add_action('localization', ActionProvider(None))
def handle_cleaner_var(self, var):
"""Handle one element under .
Example:
~/.config/f*
~/.config/foo
%AppData\foo
"""
var_name = var.getAttribute('name')
for value_element in var.getElementsByTagName('value'):
if not self.os_match(value_element.getAttribute('os')):
continue
value_str = getText(value_element.childNodes)
is_glob = value_element.getAttribute('search') == 'glob'
if is_glob:
value_list = expand_glob_join(value_str, '')
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: When BleachBit detects the file permissions are
# insecure, it will not load the cleaner as if it did not exist.
logger.warning(
_("Ignoring cleaner because it is world writable: %s"), 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:
logger.exception(_("Error reading cleaner: %s"), pathname)
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=1760921547.0
bleachbit-5.0.2/bleachbit/Command.py 0000775 0001750 0001750 00000027340 15075303713 014663 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.
"""
from bleachbit.Language import get_text as _
from bleachbit import FileUtilities
import logging
import os
import types
import warnings
if 'nt' == os.name:
import bleachbit.Windows
else:
from bleachbit.General import WindowsError
logger = logging.getLogger(__name__)
def whitelist(path):
"""Return information that this file was whitelisted"""
ret = {
# TRANSLATORS: This is the label in the log indicating was
# skipped because it matches the whitelist
'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 'Command to %s %s' % \
('shred' if self.shred else 'delete', self.path)
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
# TRANSLATORS: This is the label in the log indicating will be
# deleted (for previews) or was actually deleted
'label': _('Delete'),
'n_deleted': 1,
'n_special': 0,
'path': self.path,
'size': FileUtilities.getsize(self.path)}
if really_delete:
try:
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 e.winerror not in (5, 32):
raise
bleachbit.Windows.delete_locked_file(self.path)
if self.shred:
warnings.warn(
_('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: The file will be deleted when the
# system reboots
ret['label'] = _('Mark for deletion')
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 'Function: %s: %s' % (self.label, self.path)
return 'Function: %s' % (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 whitelist(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(f'Preview function failed: {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('Attempting to run file function %s on directory %s' %
(self.func.__name__, self.path))
# Function takes a path. We check the size.
oldsize = FileUtilities.getsize(self.path)
from sqlite3 import DatabaseError
try:
self.func(self.path)
except 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:
from errno import ENOENT
if e.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 'Command to clean .ini path=%s, section=%s, parameter=%s ' % \
(self.path, self.section, self.parameter)
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
# TRANSLATORS: Parts of this file will be deleted
'label': _('Clean file'),
'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 'Command to clean JSON file, path=%s, address=%s ' % \
(self.path, self.address)
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
'label': _('Clean file'),
'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 'Command to shred %s' % self.path
class Truncate(Delete):
"""Truncate a single file"""
def __str__(self):
return 'Command to truncate %s' % self.path
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(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):
"""Create the Windows registry cleaner"""
self.keyname = keyname
self.valuename = valuename
def __str__(self):
return 'Command to clean registry, key=%s, value=%s ' % (self.keyname, 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 = '%s<%s>' % (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)
_str = self.keyname
if not ret:
# Nothing to delete or nothing was deleted. This return
# makes the auto-hide feature work nicely.
return
ret = {
'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=1760921547.0
bleachbit-5.0.2/bleachbit/DeepScan.py 0000775 0001750 0001750 00000007702 15075303713 014767 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 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))
import time
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=1760921547.0
bleachbit-5.0.2/bleachbit/DesktopMenuOptions.py 0000664 0001750 0001750 00000003017 15075303713 017107 0 ustar 00z z from bleachbit.Options import options
import os
from pathlib import Path
def install_kde_service_menu_file():
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=1760921547.0
bleachbit-5.0.2/bleachbit/FileUtilities.py 0000775 0001750 0001750 00000114127 15075303713 016060 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 .
"""
File-related utilities
"""
# standard imports
import atexit
import contextlib
import ctypes
import errno
import glob
import json
import locale
import logging
import os
import os.path
import random
import re
import sqlite3
import stat
import string
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
import warnings
from pathlib import Path
# local imports
import bleachbit
from bleachbit.Language import get_text as _
logger = logging.getLogger(__name__)
if 'nt' == os.name:
# pylint: disable=import-error, no-name-in-module
from pywintypes import error as pywinerror
import win32file
# pylint: disable=import-error
from win32com.shell.shell import IsUserAnAdmin
# 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
try:
# FIXME: replace scandir.walk() with os.scandir()
# Preserve behavior added in 25e694.
# Test that os.walk() behaves the same on Windows.
from scandir import walk
if 'nt' == os.name:
import scandir
class _Win32DirEntryPython(scandir.Win32DirEntryPython):
def is_symlink(self):
"""internal use"""
return super(_Win32DirEntryPython, self).is_symlink() or \
bleachbit.Windows.is_junction(self.path)
scandir.scandir = scandir.scandir_python
scandir.DirEntry = scandir.Win32DirEntryPython = _Win32DirEntryPython
except ImportError:
# Since Python 3.5, os.walk() calls os.scandir().
from os import walk
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 __random_string(length):
"""Return random alphanumeric characters of given length"""
return ''.join(random.choice(string.ascii_letters + '0123456789_.-')
for i in range(length))
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"""
if isinstance(top, tuple):
for top_ in top:
yield from children_in_directory(top_, list_directories)
return
for (dirpath, dirnames, filenames) in walk(top, topdown=False):
if list_directories:
for dirname in dirnames:
yield os.path.join(dirpath, dirname)
for filename in filenames:
yield os.path.join(dirpath, filename)
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()
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 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
"""
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
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)
elif os.path.isdir(path):
delpath = path
if do_shred:
if not is_dir_empty(path):
# Avoid renaming non-empty directory like
# https://github.com/bleachbit/bleachbit/issues/783
logger.info(_("Directory is not empty: %s"), path)
return
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(_("Directory is not empty: %s"), path)
elif errno.EBUSY == e.errno:
if os.name == 'posix' and os.path.ismount(path):
logger.info(_("Skipping mount point: %s"), path)
else:
logger.info(_("Device or resource is busy: %s"), path)
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(_("Directory is not empty: %s"), path)
else:
raise
elif os.path.isfile(path):
# wipe contents
if do_shred:
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, exc_info=True)
# wipe name
os.remove(wipe_name(path))
else:
# unlink
os.remove(path)
elif os.path.islink(path):
os.remove(path)
else:
logger.info(_("Special file type cannot be deleted: %s"), path)
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"""
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)
return mystat.f_bfree * 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)
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(
_("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 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 pywinerror as e:
if 5 == e.winerror:
# 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
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 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 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 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"
"""
def wipe_write():
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
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."""
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)
# 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]
logger.debug(_(f"Wiping path {pathname} with file system type {fstype}"))
if not os.path.isdir(pathname):
logger.error(
_("Path to wipe must be an existing directory: %s"), pathname)
return
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
logger.debug(_('Wrote {files:,} files and {bytes:,} bytes in {seconds:,} seconds at {rate:.2f} MB/s').format(
files=len(files), bytes=total_bytes, seconds=int(elapsed_sec), rate=rate_mbs))
# how much free space is left (should be near zero)
if 'posix' == os.name:
# pylint: disable=no-member
stats = os.statvfs(pathname)
logger.debug(_("{bytes:,} bytes and {inodes:,} inodes available to non-super-user").format(
bytes=stats.f_bsize * stats.f_bavail, inodes=stats.f_favail))
logger.debug(_("{bytes:,} bytes and {inodes:,} inodes available to super-user").format(
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(
_("Handled 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)
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=1760921547.0
bleachbit-5.0.2/bleachbit/GUI.py 0000664 0001750 0001750 00000173772 15075303713 013741 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 glob
import logging
import os
import sys
import threading
import time
# third party import
import gi
gi.require_version('Gtk', '3.0') # Keep this above `import Gtk`.
from gi.repository import Gtk, Gdk, GObject, GLib, Gio
APP_INDICATOR_FOUND = True
if sys.platform == 'linux':
try:
# Ubuntu: sudo apt install gir1.2-ayatanaappindicator3-0.1
gi.require_version('AyatanaAppIndicator3', '0.1') # throws ValueError
from gi.repository import AyatanaAppIndicator3 as AppIndicator
except (ValueError, ImportError):
try:
from gi.repository import AppIndicator3 as AppIndicator
except ImportError:
try:
from gi.repository import AppIndicator
except ImportError:
APP_INDICATOR_FOUND = False
# local
import bleachbit
from bleachbit import APP_NAME, appicon_path, portable_mode, windows10_theme_path
from bleachbit import Cleaner, FileUtilities, GuiBasic
from bleachbit.Cleaner import backends, register_cleaners
from bleachbit.GuiPreferences import PreferencesDialog
from bleachbit.Language import get_text as _
from bleachbit.Log import set_root_log_level
from bleachbit.Options import options
if os.name == 'nt':
from bleachbit import Windows
# Now that the configuration is loaded, honor the debug preference there.
set_root_log_level(options.get('debug'))
logger = logging.getLogger(__name__)
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)
def threaded(func):
"""Decoration to create a threaded function"""
def wrapper(*args):
thread = threading.Thread(target=func, args=args)
thread.start()
return wrapper
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):
notify = Notify.Notification.new('BleachBit', msg, 'bleachbit')
notify.set_hint("desktop-entry", GLib.Variant('s', 'bleachbit'))
try:
notify.show()
except gi.repository.GLib.GError as e:
logger.debug('Notify.Notification.show() failed: %s', e)
return
notify.set_timeout(10000)
def notify_plyer(msg):
"""Show a pop-up notification.
Linux distributions do not include plyer, so this is just 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 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)
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)
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')
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 ''
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()
builder = Gtk.Builder()
# set_translation_domain() seems to have no effect.
# builder.set_translation_domain('bleachbit')
builder.add_from_file(bleachbit.app_menu_filename)
menu = builder.get_object('app-menu')
self.set_app_menu(menu)
# 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,
'wipeFreeSpace': self.cb_wipe_free_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
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"""
paths = GuiBasic.browse_folder(self._window,
_("Choose folder to shred"),
multiple=True,
stock_button=_('_Delete'))
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
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)
else:
logger.warning(_('No paths found in clipboard.'))
if shred_paths:
GUI.shred_paths(self._window, shred_paths)
else:
logger.warning(_('No paths found in clipboard.'))
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_free_space(self, action, param):
"""callback to wipe free space in arbitrary folder"""
path = GuiBasic.browse_folder(self._window,
_("Choose a folder"),
multiple=False, stock_button=_('_OK'))
if not path:
# user cancelled
return
backends['_gui'] = Cleaner.create_wipe_cleaner(path)
# execute
operations = {'_gui': ['free_disk_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"""
pref = self.get_preferences_dialog()
pref.run()
# In case the user changed the log level...
GUI.update_log_level(self._window)
def get_about_dialog(self):
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):
dialog.set_license(
_("GNU General Public License version 3 or later.\nSee https://www.gnu.org/licenses/gpl-3.0.txt"))
# 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:
bleachbit.Options.init_configuration()
self._window.destroy()
def get_system_information_dialog(self):
"""Show system information dialog"""
dialog = Gtk.Dialog(_("System information"), self._window)
dialog.set_default_size(600, 400)
txtbuffer = Gtk.TextBuffer()
from bleachbit import SystemInformation
txt = SystemInformation.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.vbox.pack_start(swindow, True, True, 0)
dialog.add_buttons(Gtk.STOCK_COPY, 100,
Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
return (dialog, txt)
def system_information_dialog(self, _action, _param):
dialog, txt = self.get_system_information_dialog()
dialog.show_all()
while True:
rc = dialog.run()
if rc != 100:
break
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(txt, -1)
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')
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)
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
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
self.view.set_headers_visible(False)
# listen for right click (context menu)
self.view.connect("button_press_event", context_menu_event)
# first column
self.renderer0 = Gtk.CellRendererText()
self.column0 = Gtk.TreeViewColumn(_("Name"), self.renderer0, text=0)
self.view.append_column(self.column0)
self.view.set_search_column(0)
# second column
self.renderer1 = Gtk.CellRendererToggle()
self.renderer1.set_property('activatable', True)
self.renderer1.connect('toggled', self.col1_toggled_cb, model, parent)
self.column1 = Gtk.TreeViewColumn(_("Active"), self.renderer1)
self.column1.add_attribute(self.renderer1, "active", 1)
self.view.append_column(self.column1)
# third column
self.renderer2 = Gtk.CellRendererText()
self.renderer2.set_alignment(1.0, 0.0)
# TRANSLATORS: Size is the label for the column that shows how
# much space an option would clean or did clean
self.column2 = Gtk.TreeViewColumn(_("Size"), self.renderer2, text=3)
self.column2.set_alignment(1.0)
self.view.append_column(self.column2)
# 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)
# TRANSLATORS: %(cleaner) may be Firefox, System, etc.
# %(option) may be cache, logs, cookies, etc.
# %(warning) may be 'This option is really slow'
msg = _("Warning regarding %(cleaner)s - %(option)s:\n\n%(warning)s") % \
{'cleaner': model[parent][0],
'option': model[path][0],
'warning': warning}
if warning:
resp = GuiBasic.message_dialog(parent_window,
msg,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.OK_CANCEL,
_('Confirm'))
if Gtk.ResponseType.OK != resp:
# user cancelled, so don't toggle option
return
model[path][1] = value
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)
return
class GUI(Gtk.ApplicationWindow):
"""The main application GUI"""
_style_provider = None
_style_provider_regular = None
_style_provider_dark = None
def __init__(self, auto_exit, *args, **kwargs):
super(GUI, self).__init__(*args, **kwargs)
self._show_splash_screen()
self._auto_exit = auto_exit
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
self.set_windows10_theme()
Gtk.Settings.get_default().set_property(
'gtk-application-prefer-dark-theme', options.get('dark_mode'))
if options.is_corrupt():
logger.error(
_('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
self._set_appindicator()
def _set_appindicator(self):
"""Setup the app indicator"""
if not (sys.platform == 'linux' and APP_INDICATOR_FOUND):
return
APPINDICATOR_ID = 'BLEACHBIT'
icon_dir = os.path.dirname(appicon_path)
menu_icon_path = os.path.join(icon_dir, 'bleachbit-indicator.svg')
if not os.path.exists(menu_icon_path):
if os.path.exists(appicon_path):
menu_icon_path = appicon_path
else:
menu_icon_path = 'user-trash'
indicator_category = AppIndicator.IndicatorCategory.SYSTEM_SERVICES
self.indicator = AppIndicator.Indicator.new(
APPINDICATOR_ID, menu_icon_path, indicator_category)
self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
self.indicator.set_menu(self.build_appindicator_menu())
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 = Gdk.Screen.get_default()
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, commit=False)
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 _show_splash_screen(self):
"""Show the splash screen on Windows because startup may be slow"""
if os.name != 'nt':
return
font_conf_file = Windows.get_font_conf_file()
if not os.path.exists(font_conf_file):
logger.error('No fonts.conf file {}'.format(font_conf_file))
return
has_cache = Windows.has_fontconfig_cache(font_conf_file)
if not has_cache:
Windows.splash_thread.start()
def _confirm_delete(self, mention_preview, shred_settings=False):
if options.get("delete_confirmation"):
return GuiBasic.delete_confirmation_dialog(self, mention_preview, shred_settings=shred_settings)
return True
def destroy(self):
"""Prevent textbuffer usage during UI destruction"""
self.textbuffer = None
super(GUI, self).destroy()
def build_appindicator_menu(self):
"""Build the app indicator menu"""
menu = Gtk.Menu()
item_clean = Gtk.MenuItem(label=_("Clean"))
item_clean.connect('activate', self.run_operations)
item_quit = Gtk.MenuItem(label=_("Quit"))
item_quit.connect('activate', self.on_quit)
menu.append(item_clean)
menu.append(item_quit)
menu.show_all()
return menu
def get_preferences_dialog(self):
return PreferencesDialog(
self,
self.cb_refresh_operations,
self.set_windows10_theme)
def shred_paths(self, paths, shred_settings=False):
"""Shred file or folders
When shredding_settings=True:
If user confirms to delete, then returns True. If user aborts, returns
False.
"""
# create a temporary cleaner object
backends['_gui'] = Cleaner.create_simple_cleaner(paths)
# preview and confirm
operations = {'_gui': ['files']}
self.preview_or_run_operations(False, operations)
if self._confirm_delete(False, shred_settings):
# delete
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)
# user aborted
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()
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 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
GuiBasic.message_dialog(self,
_("You must select an operation"),
Gtk.MessageType.ERROR,
Gtk.ButtonsType.OK,
_('Error'))
return
try:
self.set_sensitive(False)
self.textbuffer.set_text("")
self.progressbar.show()
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)
self.progressbar.set_text(_("Done."))
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
elapsed = (time.time() - self.start_time)
logger.debug('elapsed time: %d seconds', elapsed)
if elapsed < 10 or self.is_active():
return
notify(_("Done."))
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()
# Check for online updates.
if not self._auto_exit and \
bleachbit.online_update_notification_enabled and \
options.get("check_online_updates") and \
not hasattr(self, 'checked_for_updates'):
self.checked_for_updates = True
self.check_online_updates()
# Show information for first start.
# (The first start flag is set also for each new version.)
if options.get("first_start") and not self._auto_exit:
if os.name == 'posix':
self.append_text(
_('Access the application menu by clicking the hamburger icon on the title bar.'))
pref = self.get_preferences_dialog()
pref.run()
elif os.name == 'nt':
self.append_text(
_('Access the application menu by clicking the logo on the title bar.'))
options.set('first_start', False)
# Show notice about admin privileges.
if os.name == 'posix' and os.path.expanduser('~') == '/root':
self.append_text(
_('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.') + '\n')
if os.name == 'nt' and options.get('shred'):
from win32com.shell.shell import IsUserAnAdmin
if not IsUserAnAdmin():
self.append_text(
_('Run BleachBit with administrator privileges to improve the accuracy of overwriting the contents of files.'))
self.append_text('\n')
if 'windowsapps' in sys.executable.lower():
self.append_text(
_('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.') + '\n', 'error')
# remove from idle loop (see GObject.idle_add)
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
# 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 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())
# TRANSLATORS: this is the context menu
preview_item = Gtk.MenuItem(label=_("Preview"))
preview_item.connect('activate', self.cb_run_option,
False, cleaner_id, option_id)
menu.append(preview_item)
# TRANSLATORS: this is the context menu
clean_item = Gtk.MenuItem(label=_("Clean"))
clean_item.connect('activate', self.cb_run_option,
True, cleaner_id, option_id)
menu.append(clean_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 widget,
context, x, y, time: 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_tooltip_text(
_("Preview files in the selected operations (without deleting any files)"))
# TRANSLATORS: This is the preview button on the main window. It
# previews changes.
self.preview_button.set_label(_('Preview'))
# Clean button
# TRANSLATORS: This is the clean button on the main window.
# It makes permanent changes: usually deleting files, sometimes
# altering them.
self.run_button.set_label(_('Clean'))
self.run_button.set_tooltip_text(
_("Clean files in the selected operations"))
# Stop button
self.stop_button.set_label(_('Abort'))
self.stop_button.set_tooltip_text(
_('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
self.update_button.set_label(_('Update'))
self.update_button.set_tooltip_text(
_('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.7; }
100% { opacity: 1; }
}
.update-available {
background: @theme_selected_bg_color;
color: @theme_selected_fg_color;
animation: update-pulse 2s ease-in-out;
animation-delay: 2s;
animation-iteration-count: 1;
}
""")
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
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()
builder.add_from_file(bleachbit.app_menu_filename)
menu_button.set_menu_model(builder.get_object('app-menu'))
menu_button.add(image)
hbar.pack_end(menu_button)
# 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 {}+{}: {}".format(
(x, y), (width, height), str(g)))
self.move(g.x, g.y)
return True
# save window position and size
options.set("window_x", x, commit=False)
options.set("window_y", y, commit=False)
options.set("window_width", width, commit=False)
options.set("window_height", height, commit=False)
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, commit=False)
maximized = event.new_window_state & Gdk.WindowState.MAXIMIZED != 0
options.set("window_maximized", maximized, commit=False)
if 'nt' == os.name:
logger.debug(
f'window state = {event.new_window_state}, full screen = {fullscreen}, maximized = {maximized}')
return False
def on_delete_event(self, widget, event):
# commit options to disk
options.commit()
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 {}, prior window geometry = {}+{}".format(
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(
_("Press F11 to exit fullscreen mode.") + '\n')
elif options.get("window_maximized"):
self.maximize()
def set_windows10_theme(self):
"""Toggle the Windows 10 theme"""
if not 'nt' == os.name:
return
if not self._style_provider_regular:
self._style_provider_regular = Gtk.CssProvider()
self._style_provider_regular.load_from_path(
os.path.join(windows10_theme_path, 'gtk.css'))
if not self._style_provider_dark:
self._style_provider_dark = Gtk.CssProvider()
self._style_provider_dark.load_from_path(
os.path.join(windows10_theme_path, 'gtk-dark.css'))
screen = Gdk.Display.get_default_screen(Gdk.Display.get_default())
if self._style_provider is not None:
Gtk.StyleContext.remove_provider_for_screen(
screen, self._style_provider)
if options.get("win10_theme"):
if options.get("dark_mode"):
self._style_provider = self._style_provider_dark
else:
self._style_provider = self._style_provider_regular
Gtk.StyleContext.add_provider_for_screen(
screen, self._style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
else:
self._style_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)
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')
style_operation.set_property('foreground', '#b00000')
tt.add(style_operation)
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()
@threaded
def check_online_updates(self):
"""Check for software updates in background"""
from bleachbit import Update
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:
logger.exception(_("Error when checking for updates: "))
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=1760921547.0
bleachbit-5.0.2/bleachbit/General.py 0000775 0001750 0001750 00000026042 15075303713 014660 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""
General code
"""
import 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("Invalid boolean: '%s'" % 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
"""
if 'posix' != os.name:
raise RuntimeError('get_real_username() requires POSIX')
try:
return os.getenv('SUDO_USER') or os.getlogin()
except OSError:
return getpass.getuser()
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 and 'root' != login:
# pwd does not exist on Windows, so global unconditional import
# would cause a ModuleNotFoundError.
import pwd # pylint: disable=import-outside-toplevel
return pwd.getpwnam(login).pw_uid
# 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 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.
"""
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
with open(os.devnull, 'w', encoding=bleachbit.stdout_encoding) as devnull:
os.dup2(devnull.fileno(), sys.stdout.fileno())
os.dup2(devnull.fileno(), sys.stderr.fileno())
with open(os.devnull, 'r', encoding=bleachbit.stdout_encoding) as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
# 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=1760921547.0
bleachbit-5.0.2/bleachbit/GuiBasic.py 0000775 0001750 0001750 00000016777 15075303713 015007 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
# third party
try:
import gi
except ModuleNotFoundError as e:
print('*' * 60)
print('Please install PyGObject')
print('https://pygobject.readthedocs.io/en/latest/getting_started.html')
print('*' * 60)
raise e
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk # keep after gi.require_version()
# local import
from bleachbit.Language import get_text as _
if os.name == 'nt':
from bleachbit import Windows
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"), 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"), Gtk.ResponseType.CANCEL,
_("_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"), Gtk.ResponseType.CANCEL,
_("_Delete"), 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."""
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:
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:
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:
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)
dialog.get_content_area().pack_start(vbox, False, True, 0)
dialog.get_content_area().set_spacing(10)
dialog.add_button(_('_Delete'), Gtk.ResponseType.ACCEPT)
dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
dialog.show_all()
ret = dialog.run()
dialog.destroy()
return ret == Gtk.ResponseType.ACCEPT
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:
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: %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,
_('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=1760921547.0
bleachbit-5.0.2/bleachbit/GuiChaff.py 0000775 0001750 0001750 00000017656 15075303713 014772 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 .
"""
GUI for making chaff
"""
from bleachbit.Language import get_text as _
from gi.repository import Gtk, GLib
import logging
import os
logger = logging.getLogger(__name__)
def make_files_thread(file_count, inspiration, output_folder, delete_when_finished, on_progress):
if inspiration == 0:
from bleachbit.Chaff import generate_2600
generated_file_names = generate_2600(
file_count, output_folder, on_progress=on_progress)
elif inspiration == 1:
from bleachbit.Chaff import generate_emails
generated_file_names = generate_emails(
file_count, output_folder, on_progress=on_progress)
else:
raise ValueError(f'Invalid inspiration {inspiration}')
if delete_when_finished:
on_progress(0, msg=_('Deleting files'))
for i in range(0, file_count):
os.unlink(generated_file_names[i])
on_progress(1.0 * (i + 1) / file_count)
on_progress(1.0, is_done=True)
class ChaffDialog(Gtk.Dialog):
"""Present the dialog to make chaff"""
def __init__(self, parent):
self._make_dialog(parent)
def _make_dialog(self, parent):
"""Make the main dialog"""
# TRANSLATORS: BleachBit creates digital chaff like that is like the
# physical chaff 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(5)
box = self.get_content_area()
label = Gtk.Label(
label=_("Make randomly-generated messages inspired by documents."))
box.add(label)
inspiration_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# TRANSLATORS: Inspiration is a choice of documents from which random
# text will be generated.
inspiration_box.add(Gtk.Label(label=_("Inspiration")))
self.inspiration_combo = Gtk.ComboBoxText()
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
inspiration_box.add(self.inspiration_combo)
box.add(inspiration_box)
spin_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
spin_box.add(Gtk.Label(label=_("Number of files")))
adjustment = Gtk.Adjustment(
value=100, lower=1, upper=99999, step_increment=1, page_increment=1000, page_size=0)
self.file_count = Gtk.SpinButton(adjustment=adjustment)
spin_box.add(self.file_count)
box.add(spin_box)
folder_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
folder_box.add(Gtk.Label(label=_("Select destination folder")))
# 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)
import tempfile
self.choose_folder_button.set_filename(tempfile.gettempdir())
folder_box.add(self.choose_folder_button)
box.add(folder_box)
delete_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
delete_box.add(Gtk.Label(label=_("When finished")))
self.when_finished_combo = Gtk.ComboBoxText()
self.combo_options = (
_('Delete without shredding'), _('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
delete_box.add(self.when_finished_combo)
box.add(delete_box)
self.progressbar = Gtk.ProgressBar()
box.add(self.progressbar)
self.progressbar.hide()
self.make_button = Gtk.Button(label=_("Make files"))
self.make_button.connect('clicked', self.on_make_files)
box.add(self.make_button)
def download_models_gui(self):
"""Download models and return whether successful as boolean"""
def on_download_error(msg, msg2):
dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR,
Gtk.ButtonsType.CANCEL, msg)
dialog.format_secondary_text(msg2)
dialog.run()
dialog.destroy()
import bleachbit.Chaff
return bleachbit.Chaff.download_models(on_error=on_download_error)
def download_models_dialog(self):
"""Download models"""
dialog = Gtk.MessageDialog(parent=self, flags=0, message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.OK_CANCEL, text=_("Download data needed for chaff generator?"))
response = dialog.run()
ret = None
if response == Gtk.ResponseType.OK:
# User wants to download
ret = self.download_models_gui() # True if successful
elif response == Gtk.ResponseType.CANCEL:
ret = False
dialog.destroy()
return ret
def on_make_files(self, widget):
"""Callback for make files button"""
file_count = self.file_count.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:
dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR,
Gtk.ButtonsType.CANCEL, _("Select destination folder"))
dialog.run()
dialog.destroy()
return
from bleachbit.Chaff import have_models
if not have_models():
if not self.download_models_dialog():
return
def _on_progress(fraction, msg, is_done):
"""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.make_button.set_sensitive(True)
def on_progress(fraction, msg=None, is_done=False):
"""Callback for progress bar"""
# Use idle_add() because threads cannot make GDK calls.
GLib.idle_add(_on_progress, fraction, msg, is_done)
msg = _('Generating files')
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)
import threading
args = (file_count, inspiration, output_dir,
delete_when_finished, on_progress)
self.thread = threading.Thread(target=make_files_thread, args=args)
self.thread.start()
def run(self):
"""Run the dialog"""
self.show_all()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/GuiPreferences.py 0000664 0001750 0001750 00000060347 15075303713 016214 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
"""
from bleachbit import online_update_notification_enabled
from bleachbit.Options import options
from bleachbit import GuiBasic
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 gi.repository import Gtk
import logging
import os
logger = logging.getLogger(__name__)
LOCATIONS_WHITELIST = 1
LOCATIONS_CUSTOM = 2
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
self.dialog = Gtk.Dialog(title=_("Preferences"),
transient_for=parent,
modal=True,
destroy_with_parent=True)
self.dialog.set_default_size(300, 200)
notebook = Gtk.Notebook()
notebook.append_page(self.__general_page(),
Gtk.Label(label=_("General")))
notebook.append_page(self.__locations_page(
LOCATIONS_CUSTOM), Gtk.Label(label=_("Custom")))
notebook.append_page(self.__drives_page(),
Gtk.Label(label=_("Drives")))
if 'posix' == os.name:
notebook.append_page(self.__languages_page(),
Gtk.Label(label=_("Languages")))
notebook.append_page(self.__locations_page(
LOCATIONS_WHITELIST), Gtk.Label(label=_("Whitelist")))
# pack_start parameters: child, expand (reserve space), fill (actually fill it), padding
self.dialog.get_content_area().pack_start(notebook, True, True, 0)
self.dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
self.refresh_operations = False
def __del__(self):
"""Destructor called when the dialog is closing"""
if self.refresh_operations:
# refresh the list of cleaners
self.cb_refresh_operations()
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:
if 'nt' == os.name and options.get('win10_theme'):
self.cb_set_windows10_theme()
Gtk.Settings.get_default().set_property(
'gtk-application-prefer-dark-theme', options.get('dark_mode'))
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()
def __create_update_widgets(self, vbox):
"""Create and configure update-related checkboxes."""
if not online_update_notification_enabled:
return
cb_updates = Gtk.CheckButton.new_with_label(
label=_("Check periodically for software updates via the Internet"))
cb_updates.set_active(options.get('check_online_updates'))
cb_updates.connect(
'toggled', self.__toggle_callback, 'check_online_updates')
cb_updates.set_tooltip_text(
_("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."))
vbox.pack_start(cb_updates, False, True, 0)
updates_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
updates_box.set_border_width(10)
self.cb_beta = Gtk.CheckButton.new_with_label(
label=_("Check for new beta releases"))
self.cb_beta.set_active(options.get('check_beta'))
self.cb_beta.set_sensitive(options.get('check_online_updates'))
self.cb_beta.connect(
'toggled', self.__toggle_callback, 'check_beta')
updates_box.pack_start(self.cb_beta, False, True, 0)
if 'nt' == os.name:
self.cb_winapp2 = Gtk.CheckButton.new_with_label(
label=_("Download and update cleaners from community (winapp2.ini)"))
self.cb_winapp2.set_active(options.get('update_winapp2'))
self.cb_winapp2.set_sensitive(
options.get('check_online_updates'))
self.cb_winapp2.connect(
'toggled', self.__toggle_callback, 'update_winapp2')
updates_box.pack_start(self.cb_winapp2, False, True, 0)
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)
self.cb_auto_lang = Gtk.CheckButton(
label=_("Auto-detect language"))
is_auto_detect = options.get("auto_detect_lang")
self.cb_auto_lang.set_active(is_auto_detect)
self.cb_auto_lang.set_tooltip_text(
_("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)
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:
logger.error("Failed to get list of supported languages")
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
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
def __create_general_checkboxes(self, vbox):
"""Create and configure general checkboxes."""
# 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.
cb_auto_hide = Gtk.CheckButton.new_with_label(
label=_("Hide irrelevant cleaners"))
cb_auto_hide.set_active(options.get('auto_hide'))
cb_auto_hide.connect('toggled', self.__toggle_callback, 'auto_hide')
vbox.pack_start(cb_auto_hide, False, True, 0)
# 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.'
cb_shred = Gtk.CheckButton(
label=_("Overwrite contents of files to prevent recovery"))
cb_shred.set_active(options.get('shred'))
cb_shred.connect('toggled', self.__toggle_callback, 'shred')
cb_shred.set_tooltip_text(
_("Overwriting is ineffective on some file systems and with certain BleachBit operations. Overwriting is significantly slower."))
vbox.pack_start(cb_shred, False, True, 0)
# Close the application after cleaning is complete.
cb_exit = Gtk.CheckButton.new_with_label(
label=_("Exit after cleaning"))
cb_exit.set_active(options.get('exit_done'))
cb_exit.connect('toggled', self.__toggle_callback, 'exit_done')
vbox.pack_start(cb_exit, False, True, 0)
# Disable delete confirmation message.
cb_popup = Gtk.CheckButton(label=_("Confirm before delete"))
cb_popup.set_active(options.get('delete_confirmation'))
cb_popup.connect(
'toggled', self.__toggle_callback, 'delete_confirmation')
vbox.pack_start(cb_popup, False, True, 0)
# Use base 1000 over 1024?
cb_units_iec = Gtk.CheckButton(
label=_("Use IEC sizes (1 KiB = 1024 bytes) instead of SI (1 kB = 1000 bytes)"))
cb_units_iec.set_active(options.get("units_iec"))
cb_units_iec.connect('toggled', self.__toggle_callback, 'units_iec')
vbox.pack_start(cb_units_iec, False, True, 0)
def __general_page(self):
"""Return a widget containing the general page"""
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.__create_update_widgets(vbox)
self.__create_general_checkboxes(vbox)
# Remember window geometry (position and size)
self.cb_geom = Gtk.CheckButton(label=_("Remember window geometry"))
self.cb_geom.set_active(options.get("remember_geometry"))
self.cb_geom.connect(
'toggled', self.__toggle_callback, 'remember_geometry')
vbox.pack_start(self.cb_geom, False, True, 0)
# Debug logging
cb_debug = Gtk.CheckButton(label=_("Show debug messages"))
cb_debug.set_active(options.get("debug"))
cb_debug.connect('toggled', self.__toggle_callback, 'debug')
vbox.pack_start(cb_debug, False, True, 0)
# KDE context menu shred option
if 'nt' != os.name:
cb_kde_shred_menu_option = Gtk.CheckButton(
label=_("Add the shred context menu to KDE Plasma"))
cb_kde_shred_menu_option.set_active(
options.get("kde_shred_menu_option"))
cb_kde_shred_menu_option.connect(
'toggled', self.__toggle_callback, 'kde_shred_menu_option')
vbox.pack_start(cb_kde_shred_menu_option, False, True, 0)
# Dark theme
self.cb_dark_mode = Gtk.CheckButton(label=_("Dark mode"))
self.cb_dark_mode.set_active(options.get("dark_mode"))
self.cb_dark_mode.connect(
'toggled', self.__toggle_callback, 'dark_mode')
vbox.pack_start(self.cb_dark_mode, False, True, 0)
if 'nt' == os.name:
# Windows 10 theme
cb_win10_theme = Gtk.CheckButton(label=_("Windows 10 theme"))
cb_win10_theme.set_active(options.get("win10_theme"))
cb_win10_theme.connect(
'toggled', self.__toggle_callback, 'win10_theme')
vbox.pack_start(cb_win10_theme, False, True, 0)
self.__create_language_widgets(vbox)
return vbox
def __drives_page(self):
"""Return widget containing the drives page"""
def add_drive_cb(button):
"""Callback for adding a drive"""
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)
# TRANSLATORS: 'free' means 'unallocated'
notice = Gtk.Label(label=_(
"Choose a writable folder for each drive for which to overwrite free space."))
notice.set_line_wrap(True)
vbox.pack_start(notice, 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 preserve_toggled_cb(cell, path, liststore):
"""Callback for toggling the 'preserve' 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)
notice = Gtk.Label(
label=_("All languages will be deleted except those checked."))
vbox.pack_start(notice, False, False, 0)
# 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', preserve_toggled_cb, liststore)
self.column0 = Gtk.TreeViewColumn(
_("Preserve"), self.renderer0, active=0)
treeview.append_column(self.column0)
self.renderer1 = Gtk.CellRendererText()
self.column1 = Gtk.TreeViewColumn(_("Code"), self.renderer1, text=1)
treeview.append_column(self.column1)
self.renderer2 = Gtk.CellRendererText()
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)
vbox.pack_start(swindow, False, True, 0)
return vbox
def _check_path_exists(self, pathname, page_type):
"""Check if a path exists in either whitelist 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]:
msg = _("This path already exists in the whitelist.")
GuiBasic.message_dialog(self.dialog,
msg,
Gtk.MessageType.ERROR,
Gtk.ButtonsType.OK,
_('Error'))
return True
# Check in custom
for path in custom_paths:
if pathname == path[1]:
msg = _("This path already exists in the custom list.")
GuiBasic.message_dialog(self.dialog,
msg,
Gtk.MessageType.ERROR,
Gtk.ButtonsType.OK,
_('Error'))
return True
return False
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, page_type):
return
type_str = _('File') if path_type == 'file' else _('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)
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)
def __locations_page(self, page_type):
"""Return a widget containing a list of files and folders"""
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# load data
if LOCATIONS_WHITELIST == page_type:
pathnames = options.get_whitelist_paths()
elif LOCATIONS_CUSTOM == page_type:
pathnames = options.get_custom_paths()
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 LOCATIONS_WHITELIST == page_type:
# TRANSLATORS: "Paths" is used generically to refer to both files
# and folders
notice = Gtk.Label(
label=_("These paths will not be deleted or modified."))
elif LOCATIONS_CUSTOM == page_type:
notice = Gtk.Label(
label=_("These locations can be selected for deletion."))
vbox.pack_start(notice, False, False, 0)
# create treeview
treeview = Gtk.TreeView.new_with_model(liststore)
# create column views
self.renderer0 = Gtk.CellRendererText()
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, False, True, 0)
# buttons that modify the list
def add_file_cb(button):
"""Callback for adding a file"""
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"""
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):
"""Run the dialog"""
self.dialog.show_all()
self.dialog.run()
self.dialog.destroy()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/Language.py 0000664 0001750 0001750 00000032606 15075303713 015026 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 local-gen to generate it.', 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=1760921547.0
bleachbit-5.0.2/bleachbit/Log.py 0000664 0001750 0001750 00000011206 15075303713 014015 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=1760921547.0
bleachbit-5.0.2/bleachbit/Memory.py 0000775 0001750 0001750 00000025516 15075303713 014560 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
"""
from bleachbit import FileUtilities
from bleachbit import General
from bleachbit.Language import get_text as _
import logging
import os
import re
import subprocess
import sys
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("Unexpected output:\nargs='%(args)s'\nstdout='%(stdout)s'\nstderr='%(stderr)s'"
% {'args': str(args), 'stdout': stdout, 'stderr': 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"""
# In Linux 2.6.36 the system changed from oom_adj to oom_score_adj
path = '/proc/%d/oom_score_adj' % os.getpid()
if os.path.exists(path):
with open(path, 'w') as f:
f.write('1000')
else:
path = '/proc/%d/oomadj' % os.getpid()
if os.path.exists(path):
with open(path, 'w') as f:
f.write('15')
# 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:
logger.debug(
_("Dropping privileges of process ID {pid} to user ID {uid}.").format(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)
logger.debug(_("Found UUID for swap file {device} is {uuid}.").format(
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_long, c_ulonglong
from ctypes.wintypes import Structure, sizeof, windll, byref
class MEMORYSTATUSEX(Structure):
_fields_ = [
('dwLength', c_long),
('dwMemoryLoad', c_long),
('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(
'swap device %s is larger (%d) than expected (%d)' %
(device, actual_size_bytes, safety_limit_bytes))
uuid = get_swap_uuid(device)
# wipe
FileUtilities.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"""
# 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.
logger.debug(_("The function wipe_memory() with process ID {pid} is waiting for child process ID {cid}.").format(
pid=os.getpid(), cid=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=1760921547.0
bleachbit-5.0.2/bleachbit/Network.py 0000664 0001750 0001750 00000016211 15075303713 014726 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 sys
import platform
from collections.abc import Callable
from urllib3.util.retry import Retry
# 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 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)
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):
"""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
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')
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)
headers = {'User-Agent': get_user_agent()}
# 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)
retries = Retry(total=max_retries, backoff_factor=backoff_factor,
status_forcelist=status_forcelist, redirect=5)
with requests.Session() as session:
session.mount(
'http://', requests.adapters.HTTPAdapter(max_retries=retries))
session.mount(
'https://', requests.adapters.HTTPAdapter(max_retries=retries))
response = session.get(url, headers=headers,
timeout=timeout, verify=True)
return response
def get_gtk_version():
"""Return the version of GTK
If GTK is not available, returns None.
"""
try:
# pylint: disable=import-outside-toplevel
import gi
except ModuleNotFoundError:
return None
gi.require_version('Gtk', '3.0')
# pylint: disable=import-outside-toplevel, import-error
from gi.repository import Gtk
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"""
__platform = platform.system() # 'Linux', 'Windows', etc.
# On Windows, version is like '10.0.22631'.
__os = platform.uname().version
if sys.platform == 'linux':
# pylint: disable=import-outside-toplevel
from bleachbit.Unix import get_distribution_name_version
__os = get_distribution_name_version()
elif sys.platform[:6] == 'netbsd':
__sys = platform.system()
mach = platform.machine()
rel = platform.release()
__os = __sys + '/' + mach + ' ' + rel
__locale = get_active_language_code()
gtk_ver_raw = get_gtk_version()
if gtk_ver_raw:
gtk_ver = f'; GTK {gtk_ver_raw}'
else:
gtk_ver = ''
agent = f"BleachBit/{APP_VERSION} ({__platform}; {__os}; {__locale}{gtk_ver})"
return agent
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/Options.py 0000664 0001750 0001750 00000035146 15075303713 014740 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 errno
import logging
import os
import re
# 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__)
boolean_keys = ['auto_hide',
'auto_detect_lang',
'check_beta',
'check_online_updates',
'dark_mode',
'debug',
'delete_confirmation',
'exit_done',
'first_start',
'kde_shred_menu_option',
'remember_geometry',
'shred',
'units_iec',
'window_maximized',
'window_fullscreen']
if 'nt' == os.name:
boolean_keys.append('update_winapp2')
boolean_keys.append('win10_theme')
int_keys = ['window_x', 'window_y', 'window_width', 'window_height', ]
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 ':' == pathname[1]:
# ConfigParser treats colons in a special way
pathname = pathname[0] + pathname[2:]
return pathname
def init_configuration():
"""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):
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.restore()
def __flush(self):
"""Write information to disk"""
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
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 __set_default(self, key, value):
"""Set the default value"""
if not self.config.has_option('bleachbit', key):
self.set(key, value)
def has_option(self, option, section='bleachbit'):
"""Check if option is set"""
return self.config.has_option(section, option)
def get(self, option, section='bleachbit'):
"""Retrieve a general option"""
if not 'nt' == os.name and 'update_winapp2' == option:
return False
if section == 'bleachit' 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
if section == 'hashpath' and option[1] == ':':
option = option[0] + option[2:]
if option in boolean_keys:
return self.config.getboolean(section, option)
elif option in int_keys:
return self.config.getint(section, option)
return self.config.get(section, option)
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 = "list/%s" % option
if not self.config.has_section(section):
return None
values = [
self.config.get(section, option)
for option in sorted(self.config.options(section))
]
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 option in sorted(self.config.options(section)):
pos = option.find('_')
if -1 == pos:
continue
myoptions.append(option[0:pos])
values = []
for option in set(myoptions):
p_type = self.config.get(section, option + '_type')
p_path = self.config.get(section, option + '_path')
values.append((p_type, p_path))
return values
def get_whitelist_paths(self):
"""Return the 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_tree(self, parent, child):
"""Retrieve an option for the tree view. The child may be None."""
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 restore(self):
"""Restore saved options from disk"""
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."))
# set defaults
self.__set_default("auto_hide", True)
self.__set_default("auto_detect_lang", True)
self.__set_default("check_beta", False)
self.__set_default("check_online_updates", True)
self.__set_default("dark_mode", True)
self.__set_default("debug", False)
self.__set_default("delete_confirmation", True)
self.__set_default("exit_done", False)
self.__set_default("kde_shred_menu_option", False)
self.__set_default("remember_geometry", True)
self.__set_default("shred", False)
self.__set_default("units_iec", False)
self.__set_default("window_fullscreen", False)
self.__set_default("window_maximized", False)
if 'nt' == os.name:
self.__set_default("update_winapp2", False)
self.__set_default("win10_theme", False)
# BleachBit upgrade or first start ever
if not self.config.has_option('bleachbit', 'version') or \
self.get('version') != bleachbit.APP_VERSION:
self.set('first_start', True)
# set version
self.set("version", bleachbit.APP_VERSION)
def set(self, key, value, section='bleachbit', commit=True):
"""Set a general option"""
self.config.set(section, key, str(value))
if commit:
self.__flush()
def commit(self):
self.__flush()
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"""
section = "list/%s" % key
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.__flush()
def set_whitelist_paths(self, values):
"""Save the whitelist"""
section = "whitelist/paths"
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.__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"
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.__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.__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.__flush()
def toggle(self, key):
"""Toggle a boolean key"""
self.set(key, not self.get(key))
options = Options()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/RecognizeCleanerML.py 0000775 0001750 0001750 00000013607 15075303713 016756 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
"""
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
import hashlib
import logging
import os
import sys
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.GuiBasic import Gtk
from gi.repository import 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?"""
with open(pathname) as f:
body = f.read()
new_hash = hashdigest(self.salt + 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=1760921547.0
bleachbit-5.0.2/bleachbit/Revision.py 0000664 0001750 0001750 00000000025 15075303713 015067 0 ustar 00z z revision = "efb5cfb"
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/Special.py 0000775 0001750 0001750 00000046507 15075303713 014673 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.path
import sqlite3
import xml.dom.minidom
from urllib.parse import 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_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:
with contextlib.closing(sqlite3.connect(f'file:{pathname}?mode=ro', 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 __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)"""
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.
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=1760921547.0
bleachbit-5.0.2/bleachbit/SystemInformation.py 0000775 0001750 0001750 00000013170 15075303713 016773 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 sys
# local
import bleachbit
from bleachbit.General import get_executable
logger = logging.getLogger(__name__)
def get_gtk_info():
"""Get dictionary of information about GTK"""
info = {}
try:
# pylint: disable=import-outside-toplevel
import gi
except ImportError:
logger.debug('import gi failed')
return info
info['gi.version'] = gi.__version__
try:
gi.require_version('Gtk', '3.0')
except ValueError:
logger.debug(
'gi.require_version failed: GTK 3.0 not found or not available')
return info
# pylint: disable=import-outside-toplevel
try:
from gi.repository import Gtk
except (ImportError, ValueError):
logger.debug('import Gtk failed: GTK 3.0 not found or not available')
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_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."""
from collections import OrderedDict
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())
import sqlite3
info['SQLite version'] = sqlite3.sqlite_version
# 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=1760921547.0
bleachbit-5.0.2/bleachbit/Unix.py 0000775 0001750 0001750 00000102074 15075303713 014226 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 gi.repository 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
"""
whitelists = [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'):
whitelist_match = False
for whitelist in whitelists:
if whitelist.search(path) or bleachbit.FileUtilities.whitelisted(path):
whitelist_match = True
break
if whitelist_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']
run_cleaner_cmd('yum', args, '^unused regex$', invalid)
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']
run_cleaner_cmd('dnf', args, '^unused regex$', invalid)
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 has_gui():
"""Return True if the GUI is available"""
assert os.name == 'posix'
if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'):
return False
try:
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
return True
except ImportError:
return 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'
result = General.run_external(['xhost'], clean_env=False)
xhost_returned_error = result[0] == 1
return xhost_returned_error
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=1760921547.0
bleachbit-5.0.2/bleachbit/Update.py 0000775 0001750 0001750 00000011602 15075303713 014521 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
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 gi.repository 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)
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=1760921547.0
bleachbit-5.0.2/bleachbit/Winapp.py 0000775 0001750 0001750 00000043106 15075303713 014541 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 .
"""
Import Winapp2.ini files
"""
import logging
import os
import glob
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 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('%{}%'.format(sub_orig), flags=re.IGNORECASE)
if pattern.match(pathname):
expand2 = pattern.sub('%{}%'.format(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"""
import fnmatch
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=lambda x: None):
"""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 = lambda: 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
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\|*.*
"""
parts = excludekey.split('|')
parts[0] = parts[0].upper()
if parts[0] == 'REG':
raise NotImplementedError('REG not supported in ExcludeKey')
# 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 = '(%s)' % '|'.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 '(%s)' % '|'.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
excludekeys = [
self.excludekey_to_nwholeregex(self.parser.get(section, option))
for option in self.parser.options(section)
if re.match(self.re_excludekey, option)
]
# 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, excludekeys)
elif option.startswith('regkey'):
self.handle_regkey(lid, section, option)
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:
import fnmatch
regex = ' regex="^%s$" ' % 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 = '(%s)' % '|'.join(excludekeys)
else:
# just one
exclude_str = excludekeys[0]
excludekeysxml = 'nwholeregex="%s"' % xml_escape(exclude_str)
action_str = ' ' % \
(search, xml_escape(path), regex, excludekeysxml)
yield Delete(parseString(action_str).childNodes[0])
if removeself:
search = 'file'
if dirname.find('*') > -1:
search = 'glob'
action_str = ' ' % \
(search, xml_escape(dirname))
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/
dirname = '{}{}'.format(dirname, os.path.sep) if os.path.splitdrive(dirname)[
0] == dirname else dirname
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):
"""Parse a RegKey# option"""
elements = self.parser.get(
ini_section, ini_option).strip().split('|')
path = xml_escape(elements[0])
name = ""
if len(elements) == 2:
name = 'name="%s"' % xml_escape(elements[1])
action_str = ' ' % (path, name)
provider = Winreg(parseString(action_str).childNodes[0])
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=lambda x: None):
"""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=1760921547.0
bleachbit-5.0.2/bleachbit/Windows.py 0000775 0001750 0001750 00000107303 15075303713 014735 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.
"""
import bleachbit
from bleachbit import Command, FileUtilities, General
from bleachbit.Language import get_text as _
import ctypes
import errno
import glob
import logging
import os
import shutil
import sys
import xml.dom.minidom
from decimal import Decimal
from threading import Thread, Event
if 'win32' == sys.platform:
import winreg
import pywintypes
import win32api
import win32con
import win32file
import win32gui
import win32process
import win32security
import win32service
import win32serviceutil
from ctypes import windll, byref
from win32com.shell import shell, shellcon
psapi = windll.psapi
kernel = windll.kernel32
logger = logging.getLogger(__name__)
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):
"""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
(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)
]
for child_key in child_keys:
delete_registry_key(child_key, True)
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
Yields commands
"""
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*'))
dirs += [os.path.expandvars(r'%windir%\SoftwareDistribution')]
dirs += [os.path.expandvars(r'%windir%\SoftwareDistribution.old')]
dirs += [os.path.expandvars(r'%windir%\SoftwareDistribution.bak')]
dirs += [os.path.expandvars(r'%windir%\ie7updates')]
dirs += [os.path.expandvars(r'%windir%\ie8updates')]
# see https://github.com/bleachbit/bleachbit/issues/1215 about catroot2
# dirs += [os.path.expandvars(r'%windir%\system32\catroot2')]
dirs += [os.path.expandvars(r'%systemdrive%\windows.old')]
dirs += [os.path.expandvars(r'%systemdrive%\$windows.~bt')]
dirs += [os.path.expandvars(r'%systemdrive%\$windows.~ws')]
if not dirs:
# if nothing to delete, then also do not restart service
return
# 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 not is_service_running(service):
continue
restart_services.append(service)
label = _(f"stop Windows service {service}")
yield Command.Function(None, make_run_service(service, False), label)
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)
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.
"""
assert isinstance(service, str)
assert isinstance(start, bool)
if start:
action = win32serviceutil.StartService
ignore_code = 1056 # already running
ignore_msgs = ('already',)
verb = 'start'
desired = win32service.SERVICE_RUNNING
state_txt = 'RUNNING'
else:
action = win32serviceutil.StopServiceWithDeps
ignore_code = 1062 # not active
ignore_msgs = ('not active', 'not been started', 'is not started')
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) == ignore_code 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 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
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"""
import ctypes
from ctypes import wintypes
from uuid import UUID
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 os.path.isdir(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 link
On Python 2.7 the function os.path.islink() always returns False,
so this is needed
"""
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attr = windll.kernel32.GetFileAttributesW(path)
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 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):
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
def start(self):
Thread.start(self)
self._splash_screen_started.wait()
logger.debug('SplashThread started')
def run(self):
self._splash_screen_handle = self._target()
self._splash_screen_started.set()
# Dispatch messages
win32gui.PumpMessages()
def join(self, *args):
import win32con
import win32gui
win32gui.PostMessage(self._splash_screen_handle,
win32con.WM_CLOSE, 0, 0)
Thread.join(self, *args)
def _show_splash_screen(self):
# 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.WHITE_BRUSH)
wndClass.lpszClassName = className
# register window class
wndClassAtom = None
try:
wndClassAtom = win32gui.RegisterClass(wndClass)
except Exception as e:
raise e
displayWidth = win32api.GetSystemMetrics(0)
displayHeigh = win32api.GetSystemMetrics(1)
self._splash_screen_height = 100
self._splash_screen_width = displayWidth // 4
windowPosX = (displayWidth - self._splash_screen_width) // 2
windowPosY = (displayHeigh - self._splash_screen_height) // 2
hWindow = win32gui.CreateWindow(
wndClassAtom, # it seems message dispatching only works with the atom, not the class name
'Bleachbit splash screen',
win32con.WS_POPUPWINDOW |
win32con.WS_VISIBLE,
windowPosX,
windowPosY,
self._splash_screen_width,
self._splash_screen_height,
0,
0,
hInstance,
None)
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):
# 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):
if message == win32con.WM_PAINT:
hDC, paintStruct = win32gui.BeginPaint(hWnd)
folder_with_ico_file = 'share' if hasattr(
sys, 'frozen') else 'windows'
filename = os.path.join(os.path.dirname(
sys.argv[0]), folder_with_ico_file, 'bleachbit.ico')
flags = win32con.LR_LOADFROMFILE
hIcon = win32gui.LoadImage(
0, filename, win32con.IMAGE_ICON,
0, 0, flags)
# Default icon size seems to be 32 pixels so we center the icon vertically.
default_icon_size = 32
icon_top_margin = self._splash_screen_height - \
2 * (default_icon_size + 2)
win32gui.DrawIcon(hDC, 0, icon_top_margin, hIcon)
# win32gui.DrawIconEx(hDC, 0, 0, hIcon, 64, 64, 0, 0, win32con.DI_NORMAL)
rect = win32gui.GetClientRect(hWnd)
textmetrics = win32gui.GetTextMetrics(hDC)
text_left_margin = 2 * default_icon_size
text_rect = (text_left_margin,
(rect[3] - textmetrics['Height']) // 2, rect[2], rect[3])
win32gui.DrawText(
hDC,
_("BleachBit is starting...\n"),
-1,
text_rect,
win32con.DT_WORDBREAK)
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=1760921547.0
bleachbit-5.0.2/bleachbit/WindowsWipe.py 0000664 0001750 0001750 00000137643 15075303713 015571 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.
# We include a couple of zero ints for 64-bit alignment.
input_struct = struct.pack('IIqqII', int(file_handle), 0, 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(file_handle, 0, 0, file_size & 0xFFFF, file_size >> 16)
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
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
cluster_dest = lcn_start + new_vcn
if new_lcn_start != cluster_dest:
logger.debug("Move %d clusters to %d",
cluster_count, cluster_dest)
try:
move_file(volume_handle, file_handle, new_vcn,
cluster_dest, cluster_count)
except:
# 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 !!")
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
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=1760921547.0
bleachbit-5.0.2/bleachbit/Worker.py 0000775 0001750 0001750 00000035474 15075303713 014565 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
"""
from bleachbit import DeepScan, FileUtilities
from bleachbit.Cleaner import backends
from bleachbit.Language import get_text as _, nget_text as ngettext
import logging
import math
import sys
import os
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.
# Do not show traceback.
logger.error(_("File not found: %s"), e.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 'free_disk_space' == option_id:
# TRANSLATORS: 'free' means 'unallocated'
msg = _("Please wait. Wiping free disk space.")
self.ui.append_text(
_('Wiping free disk space erases remnants of files that were deleted without shredding. It does not free up space.'))
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 free 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. Free disk space"""
self.deepscans = {}
# prioritize
self.delayed_ops = []
for operation in self.operations:
delayables = ['free_disk_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 'free_disk_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=1760921547.0
bleachbit-5.0.2/bleachbit/__init__.py 0000664 0001750 0001750 00000017071 15075303713 015041 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 .
"""
Code that is commonly shared throughout BleachBit
"""
import os
import re
import sys
import getpass
from bleachbit import Log
from configparser import RawConfigParser, NoOptionError # used in other files
APP_VERSION = "5.0.2"
APP_NAME = "BleachBit"
APP_URL = "https://www.bleachbit.org"
APP_COPYRIGHT = "Copyright (C) 2008-2025 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') and sys.frozen == 'windows_exe':
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:
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')
# 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
# menu
# This path works when running from source (cross platform) or when
# installed on Windows.
app_menu_filename = os.path.join(bleachbit_exe_path, 'data', 'app-menu.ui')
if not os.path.exists(app_menu_filename) and system_cleaners_dir:
# This path works when installed on Linux.
app_menu_filename = os.path.abspath(
os.path.join(system_cleaners_dir, '../app-menu.ui'))
if not os.path.exists(app_menu_filename):
logger.error('unknown location for app-menu.ui')
# 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 = "%s/help/%s" \
% (base_url, APP_VERSION)
update_check_url = "%s/update/%s" % (base_url, 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 = {
'HOME': os.path.expanduser('~'),
'PATH': '/usr/bin:/bin:/usr/sbin:/sbin',
'USER': getpass.getuser(),
'XDG_CACHE_HOME': os.path.expanduser('~/.cache'),
'XDG_CONFIG_HOME': os.path.expanduser('~/.config'),
'XDG_DATA_HOME': os.path.expanduser('~/.local/share')
}
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=1760921547.7055607
bleachbit-5.0.2/bleachbit/markovify/ 0000775 0001750 0001750 00000000000 15075303714 014732 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit/markovify/__init__.py 0000664 0001750 0001750 00000000302 15075303713 017035 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=1760921547.0
bleachbit-5.0.2/bleachbit/markovify/chain.py 0000664 0001750 0001750 00000012047 15075303713 016371 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=1760921547.0
bleachbit-5.0.2/bleachbit/markovify/splitters.py 0000664 0001750 0001750 00000004256 15075303713 017343 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=1760921547.0
bleachbit-5.0.2/bleachbit/markovify/text.py 0000664 0001750 0001750 00000021014 15075303713 016265 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:
# not used in BleachBit
pass
else:
if not chain:
# not used in BleachBit
pass
self.chain = chain or Chain(parsed, state_size)
def to_dict(self):
"""
Returns the underlying data as a Python dict.
"""
# not used in BleachBit
pass
def to_json(self):
"""
Returns the underlying data as a JSON string.
"""
# not used in BleachBit
pass
@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):
# not used in BleachBit
pass
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.
"""
# not used in BleachBit
pass
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`
"""
# not used in BleachBit
pass
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).
"""
# not used in BleachBit
pass
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=1760921547.0
bleachbit-5.0.2/bleachbit/markovify/utils.py 0000664 0001750 0001750 00000004031 15075303713 016441 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 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit-indicator.svg 0000664 0001750 0001750 00000004026 15075303713 015417 0 ustar 00z z
image/svg+xml
trash-solid
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1760921547.7353806
bleachbit-5.0.2/bleachbit.egg-info/ 0000775 0001750 0001750 00000000000 15075303714 014415 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit.egg-info/PKG-INFO 0000644 0001750 0001750 00000001455 15075303713 015514 0 ustar 00z z Metadata-Version: 2.2
Name: bleachbit
Version: 5.0.2
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: 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=1760921547.0
bleachbit-5.0.2/bleachbit.egg-info/SOURCES.txt 0000664 0001750 0001750 00000011657 15075303713 016312 0 ustar 00z z COPYING
MANIFEST.in
Makefile
README.md
bleachbit-indicator.svg
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/DeepScan.py
bleachbit/DesktopMenuOptions.py
bleachbit/FileUtilities.py
bleachbit/GUI.py
bleachbit/General.py
bleachbit/GuiBasic.py
bleachbit/GuiChaff.py
bleachbit/GuiPreferences.py
bleachbit/Language.py
bleachbit/Log.py
bleachbit/Memory.py
bleachbit/Network.py
bleachbit/Options.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/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/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/zoom.xml
data/app-menu.ui
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
po/Makefile
po/af.po
po/ar.po
po/ast.po
po/be.po
po/bg.po
po/bn.po
po/bs.po
po/ca.po
po/cs.po
po/da.po
po/de.po
po/el.po
po/en_AU.po
po/en_CA.po
po/en_GB.po
po/eo.po
po/es.po
po/et.po
po/eu.po
po/fa.po
po/fi.po
po/fr.po
po/ga.po
po/gl.po
po/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/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
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/TestDeepScan.py
tests/TestExternalCommand.py
tests/TestFileUtilities.py
tests/TestGUI.py
tests/TestGeneral.py
tests/TestGuiChaff.py
tests/TestInit.py
tests/TestLanguage.py
tests/TestMakefile.py
tests/TestMemory.py
tests/TestNetwork.py
tests/TestNsisUtilities.py
tests/TestOptions.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/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=1760921547.0
bleachbit-5.0.2/bleachbit.egg-info/dependency_links.txt 0000664 0001750 0001750 00000000001 15075303713 020462 0 ustar 00z z
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit.egg-info/top_level.txt 0000664 0001750 0001750 00000000012 15075303713 017137 0 ustar 00z z bleachbit
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1760921547.0
bleachbit-5.0.2/bleachbit.png 0000664 0001750 0001750 00000102770 15075303713 013437 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}}