270 lines
9.9 KiB
Python
Executable File
270 lines
9.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""What this desktop remembers about what you opened, and how to forget it.
|
|
|
|
Two traces, both of them a normal and useful part of a desktop rather than a
|
|
problem to be alarmed about. This tool measures them and clears them on request.
|
|
It never clears anything on its own, never runs on a schedule, and reports sizes
|
|
without ever suggesting that a number is too big -- the software that tells you
|
|
your machine is dirty is trying to sell you something.
|
|
|
|
traces what each trace currently costs, measured now.
|
|
clear-recents the recent-files list, replaced with an empty but valid
|
|
document. Not deleted: GTK recreates the file the moment
|
|
something opens a file anyway, and an empty valid file takes
|
|
effect in every running application immediately, where a
|
|
missing one is only noticed on the next write.
|
|
clear-thumbnails the contents of the thumbnail cache. The folder stays; only
|
|
what is inside it goes, and nothing is followed out of it.
|
|
|
|
Trash is deliberately not here. It is measured and emptied by panama-disks, and
|
|
one trash implementation is the right number to have.
|
|
|
|
Seams, for tests that must not touch a real home directory:
|
|
|
|
PANAMA_PRIVACY_RECENTS the recent-files document
|
|
PANAMA_PRIVACY_THUMBNAILS the thumbnail cache folder
|
|
|
|
Both still have to resolve to somewhere inside HOME, so a test points HOME at a
|
|
scratch directory rather than pointing these at one.
|
|
|
|
panama-privacy traces
|
|
panama-privacy clear-recents
|
|
panama-privacy clear-thumbnails
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
# Measuring a thumbnail cache means walking it, and a machine that has browsed a
|
|
# large picture library has a lot of it. Bounded so the page cannot hang; a walk
|
|
# that runs out of time reports what it had and says it is a floor.
|
|
MEASURE_TIMEOUT_SECONDS = 20.0
|
|
|
|
# The document GTK keeps the recent-files list in, and the cache every file
|
|
# manager and image viewer on this desktop shares.
|
|
RECENTS_RELATIVE = "recently-used.xbel"
|
|
THUMBNAILS_RELATIVE = "thumbnails"
|
|
|
|
# What an emptied recent-files list looks like. Byte for byte the header GTK
|
|
# writes itself, so the file this leaves behind is one GTK would have written.
|
|
EMPTY_XBEL = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<xbel version="1.0"\n'
|
|
' xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"\n'
|
|
' xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info"\n'
|
|
'>\n'
|
|
'</xbel>\n'
|
|
)
|
|
|
|
BOOKMARK = re.compile(rb"<bookmark\b")
|
|
|
|
|
|
class BoundaryError(RuntimeError):
|
|
"""A user-visible validation or filesystem failure."""
|
|
|
|
|
|
def home() -> Path:
|
|
return Path(os.path.expanduser("~")).resolve(strict=False)
|
|
|
|
|
|
def data_home() -> Path:
|
|
configured = os.environ.get("XDG_DATA_HOME") or ""
|
|
if configured:
|
|
return Path(configured)
|
|
return Path(os.path.expanduser("~")) / ".local" / "share"
|
|
|
|
|
|
def cache_home() -> Path:
|
|
configured = os.environ.get("XDG_CACHE_HOME") or ""
|
|
if configured:
|
|
return Path(configured)
|
|
return Path(os.path.expanduser("~")) / ".cache"
|
|
|
|
|
|
def recents_path() -> Path:
|
|
override = os.environ.get("PANAMA_PRIVACY_RECENTS") or ""
|
|
return Path(override) if override else data_home() / RECENTS_RELATIVE
|
|
|
|
|
|
def thumbnails_path() -> Path:
|
|
override = os.environ.get("PANAMA_PRIVACY_THUMBNAILS") or ""
|
|
return Path(override) if override else cache_home() / THUMBNAILS_RELATIVE
|
|
|
|
|
|
def confined(target: Path, description: str) -> Path:
|
|
"""A path this is allowed to write to, or a refusal.
|
|
|
|
The same guard panama-disks uses on the cache folder, and for the same
|
|
reason: this function is the whole reason the buttons on the page are safe
|
|
to press. A symlink is refused outright rather than followed, and anything
|
|
that resolves to the home directory itself or to somewhere outside it is
|
|
refused -- so an XDG variable pointing somewhere alarming, or a cache folder
|
|
someone linked to /, cannot turn one click into a deleted system.
|
|
"""
|
|
if target.is_symlink():
|
|
raise BoundaryError(f"{description} is a link, so it will not be touched.")
|
|
resolved = target.resolve(strict=False)
|
|
root = home()
|
|
if resolved == root or root not in resolved.parents:
|
|
raise BoundaryError(f"{description} is not inside your home folder.")
|
|
return resolved
|
|
|
|
|
|
def measure(path: Path, budget: float) -> tuple[int, float, bool]:
|
|
"""Bytes used, what is left of the budget, and whether the walk finished."""
|
|
if not path.exists():
|
|
return 0, budget, True
|
|
if budget <= 1.0:
|
|
return 0, budget, False
|
|
started = time.monotonic()
|
|
try:
|
|
completed = subprocess.run(["du", "-sxb", str(path)], check=False,
|
|
capture_output=True, text=True, timeout=budget)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
# A walk that ran out of time has consumed the whole budget by
|
|
# definition; the caller stops rather than starting another.
|
|
return 0, 0.0, False
|
|
left = max(budget - (time.monotonic() - started), 0.0)
|
|
if completed.returncode != 0:
|
|
return 0, left, False
|
|
match = re.match(r"^(\d+)", completed.stdout)
|
|
return (int(match.group(1)) if match else 0), left, match is not None
|
|
|
|
|
|
def recent_entries(path: Path) -> int:
|
|
"""How many files the recent list remembers.
|
|
|
|
Counted by scanning for the opening tag rather than parsing the document:
|
|
the answer wanted is a count, and an XML parser here would build a tree of
|
|
every path the user has opened in order to throw it away again.
|
|
"""
|
|
try:
|
|
with path.open("rb") as document:
|
|
return sum(len(BOOKMARK.findall(chunk))
|
|
for chunk in iter(lambda: document.read(65536), b""))
|
|
except OSError:
|
|
return 0
|
|
|
|
|
|
def traces() -> dict:
|
|
budget = MEASURE_TIMEOUT_SECONDS
|
|
|
|
recents = recents_path()
|
|
recents_present = recents.is_file()
|
|
recents_bytes = recents.stat().st_size if recents_present else 0
|
|
|
|
thumbnails = thumbnails_path()
|
|
thumbnails_present = thumbnails.is_dir()
|
|
thumbnails_bytes, budget, complete = measure(thumbnails, budget)
|
|
|
|
return {
|
|
"recents": {
|
|
"bytes": int(recents_bytes),
|
|
"entries": recent_entries(recents) if recents_present else 0,
|
|
"path": str(recents),
|
|
"present": recents_present,
|
|
},
|
|
"thumbnails": {
|
|
"bytes": int(thumbnails_bytes),
|
|
"path": str(thumbnails),
|
|
"present": thumbnails_present,
|
|
# False when the walk ran out of time, which makes the byte count a
|
|
# floor rather than an answer. The page says so instead of quoting a
|
|
# number it cannot stand behind.
|
|
"measured": complete,
|
|
},
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def clear_recents() -> None:
|
|
"""Replace the recent-files list with an empty one.
|
|
|
|
Never unlinked. GTK holds the path open and recreates the document on its
|
|
next write, so deleting it buys nothing an empty document does not, and an
|
|
empty document is understood by everything reading the list right now.
|
|
"""
|
|
target = recents_path()
|
|
if not target.exists():
|
|
# Nothing remembered is the state this was asked to produce.
|
|
return
|
|
if not target.is_file():
|
|
raise BoundaryError("The recent-files list is not a file.")
|
|
resolved = confined(target, "The recent-files list")
|
|
try:
|
|
resolved.write_text(EMPTY_XBEL, encoding="utf-8")
|
|
except OSError as error:
|
|
raise BoundaryError("The recent-files list could not be emptied.") from error
|
|
|
|
|
|
def clear_thumbnails() -> None:
|
|
"""Delete what is inside the thumbnail cache, never following a link out.
|
|
|
|
A thumbnail an application still has open cannot always be removed, and that
|
|
is the normal case rather than a failure -- so a partial pass succeeds, and
|
|
the freshly measured size the caller gets back says how much is left. Only a
|
|
pass that removed nothing at all is reported as a failure.
|
|
"""
|
|
target = thumbnails_path()
|
|
if not target.exists():
|
|
return
|
|
if not target.is_dir():
|
|
raise BoundaryError("The thumbnail cache is not a folder.")
|
|
directory = confined(target, "The thumbnail cache")
|
|
|
|
removed = 0
|
|
failures = 0
|
|
with os.scandir(directory) as entries:
|
|
for entry in entries:
|
|
try:
|
|
# is_symlink first: a symlinked directory must be unlinked, not
|
|
# walked, or this deletes whatever it points at.
|
|
if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
|
|
os.unlink(entry.path)
|
|
else:
|
|
# rmtree lstats as it goes and refuses to descend a symlink.
|
|
shutil.rmtree(entry.path, ignore_errors=False)
|
|
removed += 1
|
|
except OSError:
|
|
failures += 1
|
|
if failures and not removed:
|
|
raise BoundaryError("The thumbnail cache is in use and nothing could be removed.")
|
|
|
|
|
|
def main(arguments: list[str]) -> int:
|
|
try:
|
|
if arguments == ["traces"]:
|
|
pass
|
|
elif arguments == ["clear-recents"]:
|
|
clear_recents()
|
|
elif arguments == ["clear-thumbnails"]:
|
|
clear_thumbnails()
|
|
else:
|
|
raise BoundaryError(
|
|
"Usage: panama-privacy traces | clear-recents | clear-thumbnails")
|
|
except BoundaryError as error:
|
|
try:
|
|
state = traces()
|
|
except OSError:
|
|
state = {"recents": {"bytes": 0, "entries": 0, "path": "", "present": False},
|
|
"thumbnails": {"bytes": 0, "path": "", "present": False, "measured": False}}
|
|
state["error"] = str(error)
|
|
print(json.dumps(state, separators=(",", ":")))
|
|
return 0
|
|
|
|
print(json.dumps(traces(), separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|