Make Applications a real app manager, and clean up storage without the racket
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -10,11 +10,22 @@ Two boundaries, deliberately separate:
|
||||
means walking it, and this machine has a 1.2 TiB Steam library.
|
||||
The page asks for this on demand and remembers the answer.
|
||||
|
||||
breakdown what the used space is made of, as four segments and a remainder.
|
||||
Same walk as `scan`, so it costs the same and is asked for on
|
||||
demand.
|
||||
cleanables what could be freed, itemized and sized. Reading only.
|
||||
clean ID frees exactly one of them, named explicitly.
|
||||
|
||||
unmount PATH / eject PATH removable media only, by explicit request.
|
||||
|
||||
Deliberately absent: partitioning and formatting. A settings pane is the wrong
|
||||
place to hand someone a way to erase a disk in two clicks; GNOME Disks is one
|
||||
button away on the page for that.
|
||||
|
||||
Also deliberately absent: anything that runs on its own. Nothing here is
|
||||
pre-selected, nothing is measured in order to nag about it, and `clean` refuses
|
||||
every id it was not handed. A storage page that cleans things you did not ask it
|
||||
to clean is a cleaner, and cleaners are how people lose files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,6 +58,22 @@ SCAN_TARGETS = [
|
||||
("Trash", "~/.local/share/Trash"),
|
||||
]
|
||||
|
||||
# The cache folder is its own segment in the breakdown and its own cleanable, so
|
||||
# it is named once here rather than spelled out in three places.
|
||||
CACHE_TARGET = "~/.cache"
|
||||
TRASH_TARGET = "~/.local/share/Trash"
|
||||
|
||||
# Where flatpak keeps what it installed. Measured rather than summed from
|
||||
# `flatpak list --columns=size`: those are per-ref installed sizes, and ostree
|
||||
# hard-links every object shared between refs, so adding them up on this machine
|
||||
# reports about twice what the drive actually holds.
|
||||
FLATPAK_ROOTS = ["/var/lib/flatpak", "~/.local/share/flatpak"]
|
||||
|
||||
# dnf keeps downloaded rpms under <repo>/packages and its metadata beside them.
|
||||
# Only the packages are offered: dropping the metadata costs a re-download on
|
||||
# the next install and frees comparatively little.
|
||||
DNF_CACHE_ROOTS = ["/var/cache/libdnf5", "/var/cache/dnf"]
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or command failure."""
|
||||
@@ -329,6 +356,295 @@ def scan() -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── What the used space is made of ───────────────────────────────────────────
|
||||
#
|
||||
# Four measured segments and one remainder. The remainder is what is left of the
|
||||
# filesystem's used bytes after the four are subtracted, and it is labelled
|
||||
# "System & everything else" on the page for exactly that reason: it is not a
|
||||
# measurement of the system, it is everything this did not measure.
|
||||
#
|
||||
# The arithmetic rule, which a contract pins: the measured segments never sum to
|
||||
# more than the filesystem reports as used, and nothing is scaled to make a bar
|
||||
# look tidy. When a measurement does overshoot -- possible when /home lives on a
|
||||
# different filesystem from the flatpak installation -- the remainder is zero and
|
||||
# `exceedsUsed` says so rather than inventing a number.
|
||||
|
||||
|
||||
def measure_all(paths: list[str], budget: float) -> tuple[int, float, bool]:
|
||||
"""Bytes across several paths, and whether every one of them was measured."""
|
||||
total = 0
|
||||
complete = True
|
||||
for target in paths:
|
||||
if budget <= 1.0:
|
||||
return total, budget, False
|
||||
size, budget = measure(Path(os.path.expanduser(target)), budget)
|
||||
if size is None:
|
||||
# A path that does not exist contributes nothing and is not a gap;
|
||||
# one that timed out is, and the budget is gone either way.
|
||||
if Path(os.path.expanduser(target)).is_dir():
|
||||
complete = False
|
||||
continue
|
||||
total += size
|
||||
return total, budget, complete
|
||||
|
||||
|
||||
def backing_device(path: str) -> str:
|
||||
"""The block device behind a path, with any btrfs subvolume stripped.
|
||||
|
||||
st_dev is not the question being asked. btrfs hands every subvolume its own
|
||||
device number, so / and /home compare as different filesystems by that test
|
||||
even though they are one pool with one free-space total -- which is the
|
||||
exact confusion the filesystems list upstairs already exists to avoid. The
|
||||
first version of the breakdown left the system-wide flatpak installation out
|
||||
of the applications segment for that reason, and reported 4 kB of apps on a
|
||||
machine with twenty gigabytes of them.
|
||||
"""
|
||||
if not shutil.which("findmnt"):
|
||||
try:
|
||||
return str(os.stat(path).st_dev)
|
||||
except OSError:
|
||||
return ""
|
||||
try:
|
||||
source = run(["findmnt", "-n", "-o", "SOURCE", "--target", path], timeout=10.0)
|
||||
except BoundaryError:
|
||||
return ""
|
||||
return source.strip().split("[", 1)[0]
|
||||
|
||||
|
||||
def same_filesystem(first: str, second: str) -> bool:
|
||||
left = backing_device(first)
|
||||
return left != "" and left == backing_device(second)
|
||||
|
||||
|
||||
def breakdown() -> dict:
|
||||
home = os.path.expanduser("~")
|
||||
try:
|
||||
usage = shutil.disk_usage(home)
|
||||
except OSError as error:
|
||||
raise BoundaryError("The filesystem holding your home folder could not be read.") from error
|
||||
|
||||
remaining = float(SCAN_TIMEOUT_SECONDS)
|
||||
caches, remaining, caches_complete = measure_all([CACHE_TARGET], remaining)
|
||||
|
||||
home_targets = [target for _, target in SCAN_TARGETS if target != CACHE_TARGET]
|
||||
home_bytes, remaining, home_complete = measure_all(home_targets, remaining)
|
||||
|
||||
# Only the installations that live on the same filesystem as home, because
|
||||
# adding bytes from another drive into this drive's bar is a lie about this
|
||||
# drive.
|
||||
flatpak_paths = [target for target in FLATPAK_ROOTS
|
||||
if Path(os.path.expanduser(target)).is_dir()
|
||||
and same_filesystem(os.path.expanduser(target), home)]
|
||||
applications, remaining, applications_complete = measure_all(flatpak_paths, remaining)
|
||||
|
||||
used = int(usage.used)
|
||||
accounted = home_bytes + applications + caches
|
||||
system = max(0, used - accounted)
|
||||
|
||||
return {
|
||||
"segments": {
|
||||
"home": home_bytes,
|
||||
"applications": applications,
|
||||
"caches": caches,
|
||||
"system": system,
|
||||
"free": int(usage.free),
|
||||
},
|
||||
"totalBytes": int(usage.total),
|
||||
"usedBytes": used,
|
||||
"freeBytes": int(usage.free),
|
||||
# The measured segments are floors when this is false: something took
|
||||
# longer than the budget and was left out rather than guessed at.
|
||||
"complete": home_complete and caches_complete and applications_complete,
|
||||
"exceedsUsed": accounted > used,
|
||||
"path": home,
|
||||
}
|
||||
|
||||
|
||||
# ── Cleaning up, honestly ────────────────────────────────────────────────────
|
||||
#
|
||||
# Every row is itemized, sized in real bytes, and inert until its own id is
|
||||
# passed to `clean`. There is no "clean everything" verb and there is no
|
||||
# recommendation: the page shows what each one costs you -- caches are rebuilt,
|
||||
# first launches get slower -- and lets it be somebody's decision.
|
||||
|
||||
|
||||
def applications_helper() -> str:
|
||||
return os.environ.get("PANAMA_APPLICATIONS_HELPER") or str(
|
||||
Path(__file__).resolve().parent / "panama-applications")
|
||||
|
||||
|
||||
def unused_runtime_bytes() -> int:
|
||||
"""What the flatpak helper reports as unused, in bytes.
|
||||
|
||||
Asked of the applications helper rather than reimplemented, so the number
|
||||
shown here and the thing `clean` removes can never come from two different
|
||||
ideas of "unused".
|
||||
"""
|
||||
if not shutil.which("flatpak"):
|
||||
return 0
|
||||
try:
|
||||
raw = run([applications_helper(), "unused-runtimes"], timeout=60.0)
|
||||
entries = json.loads(raw)
|
||||
except (BoundaryError, json.JSONDecodeError):
|
||||
return 0
|
||||
return sum(int(entry.get("sizeBytes") or 0)
|
||||
for entry in entries if isinstance(entry, dict))
|
||||
|
||||
|
||||
def dnf_package_cache_paths() -> list[str]:
|
||||
paths = []
|
||||
for root in DNF_CACHE_ROOTS:
|
||||
directory = Path(root)
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
try:
|
||||
paths.extend(str(child / "packages") for child in directory.iterdir()
|
||||
if (child / "packages").is_dir())
|
||||
except OSError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
def cleanables() -> list[dict]:
|
||||
remaining = float(SCAN_TIMEOUT_SECONDS)
|
||||
cache_bytes, remaining, _ = measure_all([CACHE_TARGET], remaining)
|
||||
trash_bytes, remaining, _ = measure_all([TRASH_TARGET], remaining)
|
||||
dnf_bytes, remaining, _ = measure_all(dnf_package_cache_paths(), remaining)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": "cache",
|
||||
"label": "Application caches",
|
||||
"detail": "~/.cache · rebuilt as apps run · first launches get slower once",
|
||||
"bytes": cache_bytes,
|
||||
"privileged": False,
|
||||
},
|
||||
{
|
||||
"id": "trash",
|
||||
"label": "Trash",
|
||||
"detail": "Files you deleted · emptying is permanent",
|
||||
"bytes": trash_bytes,
|
||||
"privileged": False,
|
||||
},
|
||||
{
|
||||
"id": "flatpak-unused",
|
||||
"label": "Unused Flatpak runtimes",
|
||||
"detail": "Runtimes no installed app asks for · flatpak decides the final list",
|
||||
"bytes": unused_runtime_bytes(),
|
||||
"privileged": False,
|
||||
},
|
||||
{
|
||||
"id": "dnf-cache",
|
||||
"label": "Package download cache",
|
||||
"detail": "Downloaded packages · the system will ask for your password",
|
||||
"bytes": dnf_bytes,
|
||||
"privileged": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def guarded_cache_directory() -> Path:
|
||||
"""~/.cache, or a refusal.
|
||||
|
||||
This function is the whole reason the cache row is safe to press. It refuses
|
||||
a symlinked ~/.cache and refuses anything that resolves outside the home
|
||||
directory, so XDG_CACHE_HOME pointing somewhere alarming, or a ~/.cache
|
||||
someone linked to /, cannot turn one click into a deleted system.
|
||||
"""
|
||||
home = Path(os.path.expanduser("~")).resolve(strict=False)
|
||||
target = Path(os.path.expanduser(CACHE_TARGET))
|
||||
if target.is_symlink():
|
||||
raise BoundaryError("The cache folder is a link, so it will not be emptied.")
|
||||
if not target.is_dir():
|
||||
raise BoundaryError("There is no cache folder to empty.")
|
||||
resolved = target.resolve(strict=True)
|
||||
if resolved == home or home not in resolved.parents:
|
||||
raise BoundaryError("The cache folder is not inside your home folder.")
|
||||
return resolved
|
||||
|
||||
|
||||
def empty_cache() -> None:
|
||||
"""Delete what is inside ~/.cache, never following a link out of it.
|
||||
|
||||
A cache file an application still has open cannot 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 is what says how much is left.
|
||||
Only a pass that removed nothing at all is reported as a failure.
|
||||
"""
|
||||
directory = guarded_cache_directory()
|
||||
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 cache is in use and nothing could be removed.")
|
||||
|
||||
|
||||
def empty_trash() -> None:
|
||||
if not shutil.which("gio"):
|
||||
raise BoundaryError("gio is not available, so the trash cannot be emptied.")
|
||||
# gio rather than removing ~/.local/share/Trash by hand: the trash is a
|
||||
# freedesktop structure with per-file metadata and mount-point trash
|
||||
# directories elsewhere, and gio empties all of it correctly.
|
||||
run(["gio", "trash", "--empty"], timeout=300.0)
|
||||
|
||||
|
||||
def clean_flatpak_unused() -> None:
|
||||
if not shutil.which("flatpak"):
|
||||
raise BoundaryError("Flatpak is not installed on this machine.")
|
||||
run([applications_helper(), "clean-unused"], timeout=600.0)
|
||||
|
||||
|
||||
def clean_dnf_cache() -> None:
|
||||
if not shutil.which("dnf"):
|
||||
raise BoundaryError("dnf is not available on this machine.")
|
||||
# `clean packages` and never `clean all`: this drops the downloaded rpms,
|
||||
# which is what was measured and what takes the space. Dropping the metadata
|
||||
# as well would free little and make the next install slow for no reason.
|
||||
#
|
||||
# This is the only dnf invocation in Panama's settings surface, and it
|
||||
# removes downloads. Nothing here removes an installed package.
|
||||
try:
|
||||
run(["pkexec", "dnf", "clean", "packages"], timeout=300.0)
|
||||
except BoundaryError as error:
|
||||
detail = str(error).lower()
|
||||
if "dismissed" in detail or "not authorized" in detail:
|
||||
raise BoundaryError("That change was not authorized.") from error
|
||||
raise
|
||||
|
||||
|
||||
CLEANERS = {
|
||||
"cache": empty_cache,
|
||||
"trash": empty_trash,
|
||||
"flatpak-unused": clean_flatpak_unused,
|
||||
"dnf-cache": clean_dnf_cache,
|
||||
}
|
||||
|
||||
|
||||
def clean(identifier: str) -> list[dict]:
|
||||
"""Free exactly one named thing, and refuse everything else.
|
||||
|
||||
One id per call, no list, no "all". The caller has to name what it wants
|
||||
removed, which is what keeps a mis-wired button from emptying four things.
|
||||
"""
|
||||
cleaner = CLEANERS.get(identifier or "")
|
||||
if cleaner is None:
|
||||
raise BoundaryError("There is nothing by that name to clean up.")
|
||||
cleaner()
|
||||
return cleanables()
|
||||
|
||||
|
||||
def removable_device(path: str) -> dict:
|
||||
"""Resolve a device path, refusing anything that is not removable.
|
||||
|
||||
@@ -354,6 +670,12 @@ def main(arguments: list[str]) -> int:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
elif arguments == ["scan"]:
|
||||
print(json.dumps(scan(), separators=(",", ":")))
|
||||
elif arguments == ["breakdown"]:
|
||||
print(json.dumps(breakdown(), separators=(",", ":")))
|
||||
elif arguments == ["cleanables"]:
|
||||
print(json.dumps(cleanables(), separators=(",", ":")))
|
||||
elif len(arguments) == 2 and arguments[0] == "clean":
|
||||
print(json.dumps(clean(arguments[1]), separators=(",", ":")))
|
||||
elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"):
|
||||
removable_device(arguments[1])
|
||||
action = "unmount" if arguments[0] == "unmount" else "power-off"
|
||||
@@ -361,7 +683,8 @@ def main(arguments: list[str]) -> int:
|
||||
run(["udisksctl", action, flag, arguments[1]], timeout=30.0)
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE")
|
||||
"Usage: panama-disks snapshot | scan | breakdown | cleanables | "
|
||||
"clean ID | unmount DEVICE | eject DEVICE")
|
||||
except BoundaryError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
Reference in New Issue
Block a user