#!/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 = ( '\n' '\n' '\n' ) BOOKMARK = re.compile(rb" 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:]))