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:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
+368 -2
View File
@@ -37,6 +37,22 @@ grep -q 'function refresh(): void' "$service" || fail 'Disks has no refresh'
grep -q 'function scan(): void' "$service" || fail 'Disks has no folder scan'
grep -qE 'command\s*:\s*"' "$service" && fail 'Process command must be an argument array'
# The cleanup path, service side. `clean` takes ONE id and refuses one it has
# not been shown, so a mis-wired button cannot free something the user never
# looked at -- and the helper refuses it again, because a service is not a
# security boundary.
grep -q 'function clean(identifier: string): void' "$service" \
|| fail 'Disks cannot clean one named thing'
grep -q 'root.cleanables.some(item' "$service" \
|| fail 'the service passes an id straight through without checking it is one it offered'
grep -qE 'function measureBreakdown|function measureCleanables' "$service" \
|| fail 'the breakdown and the cleanup list are not measured on demand'
# Both walks are asked for, never done on open: they cost the same as the
# folder scan the page already refuses to start by itself.
grep -qE 'Component\.onCompleted:.*Disks\.(scan|measureBreakdown|measureCleanables)' "$page" \
&& fail 'the page starts an expensive walk the moment it opens'
# The expensive read must not run on open; that is the entire reason it is a
# separate command.
grep -q 'Component.onCompleted: Disks.refresh()' "$page" \
@@ -143,6 +159,356 @@ grep -Fxq 'unmount -b /dev/sdb1' "$PANAMA_DISKS_CALL_LOG" \
unset PANAMA_DISKS_LSBLK PANAMA_DISKS_CALL_LOG
printf 'disks contract: PASS (%d drives, %d filesystems)\n' \
# ── The breakdown adds up, and the leftover says so ──────────────────────────
#
# A stacked bar is a claim about arithmetic. Three of its four segments are
# measured (the home scan targets, the flatpak sizes, ~/.cache) and the fourth
# is whatever is left of the used space -- system files, package caches, logs,
# everything nobody itemized. That last segment is the honest one only while it
# is computed as the remainder and captioned as the remainder. Two ways it lies:
#
# * measured parts that overlap or overshoot, so the segments sum past the
# used space and the remainder goes negative (drawn as zero, silently);
# * a remainder captioned "System", which invites the user to believe the
# desktop is using 400 GB when most of it is their own unscanned files.
#
# SAFETY: this half runs the helper under `env -i` with HOME and XDG_CACHE_HOME
# inside the scratch tree and the block device tree read from the fixture, so
# every path it measures is one this file created. The first assertion below is
# the proof: the numbers it returns have to be the fixture's numbers, or the
# contract stops before anything else runs.
# No absolute home anywhere: the measurements have to follow HOME, which is the
# only reason pointing it at a fixture works.
grep -n '"/home/' "$helper" \
&& fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture'
for verb in breakdown cleanables clean; do
grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb command"
done
# The applications segment is measured by walking the flatpak install roots, and
# deciding which of them counts needs "is this the same filesystem as home".
# st_dev is the obvious test and the wrong one: btrfs gives every subvolume its
# own device number, so / and /home compare as different filesystems on this
# machine and the system-wide flatpak installation drops out of the bar. The
# question is which block device is behind the path.
grep -q 'def same_filesystem' "$helper" \
|| fail 'nothing decides whether a flatpak root is on the same filesystem as home'
grep -qE 'findmnt.*SOURCE|"SOURCE"' "$helper" \
|| fail 'the filesystem behind a path is not read from findmnt, so btrfs subvolumes will compare as separate drives'
# One dnf invocation in the whole helper, and it drops downloads. This is the
# only place in Panama's settings surface that runs dnf at all.
python3 - "$helper" <<'PY' || fail 'panama-disks runs dnf for something other than dropping downloads'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
commands = []
for node in ast.walk(tree):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = [element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if "dnf" in literals or "yum" in literals or "rpm" in literals:
commands.append(literals)
if len(commands) != 1:
print(f"expected exactly one package-manager command, found {commands}", file=sys.stderr)
raise SystemExit(1)
command = commands[0]
if command[:4] != ["pkexec", "dnf", "clean", "packages"]:
print(f"the one dnf command is {command}", file=sys.stderr)
raise SystemExit(1)
PY
fixture_home="$work/home"
fixture_cache="$fixture_home/.cache"
mkdir -p "$fixture_cache/one" "$fixture_cache/two" "$work/outside"
# A distinctive size: 12 files of 111111 bytes. The point is that this cannot be
# confused with the real ~/.cache, which is orders of magnitude larger.
for index in $(seq 1 12); do
head -c 111111 /dev/zero >"$fixture_cache/one/file-$index"
done
printf 'this file is not inside the cache and must survive\n' >"$work/outside/precious"
ln -s "$work/outside" "$fixture_cache/escape-hatch"
mkdir -p "$work/bin2"
: >"$work/calls2"
for stubbed in gio flatpak pkexec dnf podman; do
cat >"$work/bin2/$stubbed" <<STUB
#!/usr/bin/env bash
printf '$stubbed %s\n' "\$*" >>"$work/calls2"
exit 0
STUB
done
chmod +x "$work/bin2"/*
runh() {
env -i \
PATH="$work/bin2:/usr/bin:/bin" \
HOME="$fixture_home" \
XDG_CACHE_HOME="$fixture_cache" \
XDG_CONFIG_HOME="$work/xdg-config" \
XDG_DATA_HOME="$work/xdg-data" \
PANAMA_DISKS_LSBLK="$work/tree.json" \
PANAMA_DISKS_CALL_LOG="$work/calls2" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
calls2() { cat "$work/calls2"; }
breakdown="$(runh breakdown 2>/dev/null)" || fail 'breakdown failed against the fixture'
jq -e '(.segments | type == "object") and has("usedBytes") and has("totalBytes")
and has("complete") and has("exceedsUsed")' <<<"$breakdown" >/dev/null \
|| fail "breakdown is missing its segments or the numbers they are drawn against: $breakdown"
jq -e '[.segments.home, .segments.applications, .segments.caches, .segments.system, .segments.free]
| map(type == "number" and . >= 0) | all' <<<"$breakdown" >/dev/null \
|| fail "a breakdown segment is missing, negative, or not a number: $breakdown"
# The proof that this ran against the fixture rather than against the folders
# somebody is using: the home it measured is the one this file created, and the
# cache segment is the 12 x 111111 bytes written into it a moment ago. Nothing
# below runs until both are true.
[[ "$(jq -r '.path' <<<"$breakdown")" == "$fixture_home" ]] \
|| fail "breakdown measured $(jq -r '.path' <<<"$breakdown"), not the fixture home; refusing to go on"
jq -e '.segments.caches > 1200000 and .segments.caches < 1500000' <<<"$breakdown" >/dev/null \
|| fail "the cache segment is not the fixture's cache: $(jq -r .segments.caches <<<"$breakdown")"
# The arithmetic. Three segments are measured and the fourth is what is left of
# the used space; they have to add up to exactly the used space, or the bar is
# drawn against a total nobody has.
jq -e '(.segments.home + .segments.applications + .segments.caches + .segments.system) == .usedBytes' \
<<<"$breakdown" >/dev/null \
|| fail "the segments do not add up to the used space: $breakdown"
jq -e '.segments.free == .freeBytes and (.usedBytes + .freeBytes) <= .totalBytes' <<<"$breakdown" >/dev/null \
|| fail "the free segment and the filesystem disagree: $breakdown"
# The two ways the measurement can be wrong are reported rather than absorbed
# into the remainder: a walk that ran out of time, and parts that overlap.
jq -e '.exceedsUsed == false' <<<"$breakdown" >/dev/null \
|| fail "the fixture's measured parts overshot its used space, so this run proves nothing: $breakdown"
grep -Fq 'Disks.breakdown.complete === false' "$page" \
|| fail 'the page does not say when the walk ran out of time, so floors are drawn as totals'
grep -Fq 'Disks.breakdown.exceedsUsed === true' "$page" \
|| fail 'the page does not say when the measured parts overlap, so a clamped remainder looks measured'
# The remainder is named for what it is. "System" alone would blame the desktop
# for the user's own unscanned files.
page_text="$(grep -vE '^\s*//' "$page")"
grep -Fq 'System & everything else' <<<"$page_text" \
|| fail 'the remainder segment is captioned as if it were all system files'
# ── Every cleanable is itemized, sized, and inert until asked for ────────────
cleanables="$(runh cleanables 2>/dev/null)" || fail 'cleanables failed against the fixture'
jq -e 'type == "array" and length > 0' <<<"$cleanables" >/dev/null \
|| fail "cleanables reported nothing at all: $cleanables"
jq -e '[.[] | has("id") and has("label") and has("detail") and has("bytes") and has("privileged")] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable is missing its id, label, honest detail, size, or privilege flag: $cleanables"
jq -e '[.[] | (.bytes | type == "number") and .bytes >= 0 and (.label | length > 0) and (.detail | length > 0)] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable has no size or no explanation of what it costs to remove: $cleanables"
unknown_ids="$(jq -r '[.[].id] - ["cache", "trash", "flatpak-unused", "dnf-cache"] | join(", ")' \
<<<"$cleanables")"
[[ -z "$unknown_ids" ]] \
|| fail "a cleanable id nothing else knows about: $unknown_ids"
# Nothing arrives pre-selected. A cleanup list that ships with boxes ticked is
# the racket this card exists not to be: the user should have to say yes to each
# thing, individually, having seen what it costs.
jq -e '[.[] | (has("selected") or has("checked") or has("default")) | not] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable carries a pre-selected state: $cleanables"
jq -e '[.[] | select(.id == "dnf-cache") | .privileged] | all and length > 0' <<<"$cleanables" >/dev/null \
|| fail 'the package cache is not marked as needing a password, so the page cannot warn about it'
jq -e '[.[] | select(.id == "cache" or .id == "trash") | .privileged | not] | all' <<<"$cleanables" >/dev/null \
|| fail 'clearing your own cache or trash is marked as privileged, which would ask for a password it does not need'
# The unused-runtime size is not measured here: it is asked of the applications
# helper, which is the thing that removes them. Two ideas of "unused" would show
# one number and free another.
jq -e '[.[] | select(.id == "flatpak-unused") | .detail | test("flatpak decides")] | all and length > 0' \
<<<"$cleanables" >/dev/null \
|| fail 'the unused-runtime row does not say that flatpak has the final word on the list'
grep -q 'PANAMA_APPLICATIONS_HELPER' "$helper" \
|| fail 'the flatpak-unused row cannot be pointed at the applications helper, so the two cannot be kept in step'
cat >"$work/bin2/apps-helper" <<'STUB'
#!/usr/bin/env bash
printf 'apps-helper %s\n' "$*" >>"$PANAMA_DISKS_CALL_LOG"
case "$1" in
unused-runtimes) printf '[{"id":"org.example.Old","sizeBytes":777000},{"id":"org.example.Older","sizeBytes":3000}]\n' ;;
*) printf '[]\n' ;;
esac
exit 0
STUB
chmod +x "$work/bin2/apps-helper"
: >"$work/calls2"
borrowed="$(env -i \
PATH="$work/bin2:/usr/bin:/bin" \
HOME="$fixture_home" \
XDG_CACHE_HOME="$fixture_cache" \
PANAMA_DISKS_LSBLK="$work/tree.json" \
PANAMA_DISKS_CALL_LOG="$work/calls2" \
PANAMA_APPLICATIONS_HELPER="$work/bin2/apps-helper" \
LANG=C LC_ALL=C \
"$helper" cleanables 2>/dev/null)"
grep -Fq 'apps-helper unused-runtimes' "$work/calls2" \
|| fail "the unused-runtime size was computed here rather than asked of the applications helper: $(calls2)"
[[ "$(jq -r '.[] | select(.id == "flatpak-unused") | .bytes' <<<"$borrowed")" == "780000" ]] \
|| fail "the row does not report what the applications helper said was unused: $borrowed"
cache_bytes="$(jq -r '.[] | select(.id == "cache") | .bytes' <<<"$cleanables")"
[[ "$cache_bytes" -gt 1200000 && "$cache_bytes" -lt 1500000 ]] \
|| fail "the cache cleanable does not describe the fixture cache, so it is measuring something else: $cache_bytes"
# ── Nothing runs without its own id ──────────────────────────────────────────
#
# Checked from the log rather than the exit code: a refusal that happens after
# the command ran is not a refusal.
for bad in '' 'all' '*' '../../' 'cache trash' 'CACHE' 'dnf-cache; reboot'; do
: >"$work/calls2"
runh clean "$bad" >/dev/null 2>&1 \
&& fail "clean accepted an id that is not a cleanable: ${bad@Q}"
[[ ! -s "$work/calls2" ]] \
|| fail "a refused clean still ran something: ${bad@Q}: $(calls2)"
done
: >"$work/calls2"
runh clean >/dev/null 2>&1 && fail 'clean with no id at all was accepted'
[[ ! -s "$work/calls2" ]] || fail "clean with no id still ran something: $(calls2)"
grep -qE '"clean-all"|"clean_everything"|--all' "$helper" \
&& fail 'the helper offers a way to clean everything at once, which nobody asked for item by item'
# ── Each cleanable does its own one thing ────────────────────────────────────
: >"$work/calls2"
runh clean trash >/dev/null 2>&1
grep -Eq '^gio trash .*--empty|^gio trash --empty' "$work/calls2" \
|| fail "emptying the trash does not go through gio, which is the only thing that knows where it is: $(calls2)"
grep -qE '^(rm|find) ' "$work/calls2" \
&& fail "the trash was emptied with rm rather than gio: $(calls2)"
: >"$work/calls2"
runh clean flatpak-unused >/dev/null 2>&1
grep -Eq '^flatpak uninstall .*--unused' "$work/calls2" \
|| fail "clearing unused runtimes does not reach flatpak: $(calls2)"
grep -Eq '^flatpak uninstall .*--noninteractive' "$work/calls2" \
|| fail "clearing unused runtimes would stop for a prompt nobody can answer: $(calls2)"
: >"$work/calls2"
runh clean dnf-cache >/dev/null 2>&1
grep -Fq 'pkexec dnf clean packages' "$work/calls2" \
|| fail "clearing the package cache is not the polkit-wrapped drop of downloaded rpms: $(calls2)"
grep -Fq 'dnf clean all' "$work/calls2" \
&& fail "the package METADATA was dropped too, which frees little and slows the next install: $(calls2)"
grep -qE '^dnf ' "$work/calls2" \
&& fail "the helper ran dnf directly instead of going through pkexec: $(calls2)"
grep -qE 'remove|erase|autoremove' "$work/calls2" \
&& fail "clearing the package cache removes packages: $(calls2)"
# ── Clearing the cache stays inside the cache ────────────────────────────────
#
# The dangerous one. ~/.cache collects symlinks -- Steam, Electron applications
# and language toolchains all put them there -- and an rm that follows one
# deletes whatever it points at. The fixture plants exactly that: a link out of
# the cache to a file that must survive.
#
# Running this is safe because of the two assertions above: the helper reported
# the fixture's byte count, so the directory it is about to empty is the one
# this file created.
: >"$work/calls2"
runh clean cache >/dev/null 2>&1 || fail 'clearing the cache failed against the fixture'
[[ -f "$work/outside/precious" ]] \
|| fail 'clearing the cache followed a symlink out of it and deleted a file elsewhere'
[[ -d "$work/outside" ]] \
|| fail 'clearing the cache deleted a directory outside the cache'
remaining="$(find "$fixture_cache" -type f | wc -l)"
[[ "$remaining" == "0" ]] \
|| fail "clearing the cache left $remaining file(s) behind, so it did not do what it said"
[[ -d "$fixture_cache" ]] \
|| fail 'clearing the cache removed the cache directory itself, which applications expect to exist'
# ── The cleanup card does not sell anything ──────────────────────────────────
#
# Every "clean my PC" product on earth manufactures urgency, and the difference
# between this card and those is entirely a matter of copy. Pinned as an
# absence, in the card itself, because that is where the pressure would go.
python3 - "$page" <<'PY' || fail 'the cleanup card uses the language of a cleaner racket'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join(line for line in source.splitlines() if not line.strip().startswith("//"))
def block_at(start: int) -> str:
depth = 0
for index in range(text.find("{", start), len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return text[start:index + 1]
return ""
# The tightest card whose own title is the cleanup one: an outer card that
# merely contains it would drag the whole page's copy into the check.
cards = [block for block in (block_at(match.start())
for match in re.finditer(r"SettingsCard \{", text))
if re.search(r"title:[^\n]*Clean up", block)]
if not cards:
print("there is no cleanup card on the Storage page", file=sys.stderr)
raise SystemExit(1)
card = min(cards, key=len)
# Only what the user reads. QML is full of exclamation marks and none of them
# are shouting at anybody.
copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower()
PRESSURE = [
"running out", "running low", "act now", "recommended", "we recommend",
"urgent", "boost", "speed up", "optimize", "optimise", "reclaim now",
"free up now", "clean now", "junk", "safe to remove", "you should",
"needs attention", "!",
]
found = [phrase for phrase in PRESSURE if phrase in copy]
if found:
print(f"the cleanup card says: {found}", file=sys.stderr)
raise SystemExit(1)
PY
# Nor does it decide for the user: every row is its own two-stage confirm, and
# there is no button that clears the lot.
grep -qiE '"(Clean everything|Clean all|Free up space|Optimize)"' "$page" \
&& fail 'the cleanup card offers a single button that clears everything'
grep -Fq 'nothing to do' <<<"$page_text" \
|| fail 'a cleanable with nothing in it does not say so, so the row invites a pointless confirm'
# The breakdown bar is a component, and a component the page draws has to be
# registered or the page does not load at all -- which reads as an empty tab
# rather than as a missing line in a qmldir.
grep -q '^StorageBreakdownBar 1\.0 StorageBreakdownBar\.qml$' \
"$repo_dir/config/dot/quickshell/modules/settings/qmldir" \
|| fail 'the breakdown bar is not registered in the Settings module'
# ── One affordance for container space, not two ──────────────────────────────
#
# The Storage page used to offer "Unused container images" as a row that opened
# a terminal running `podman system df`, directly above a Containers card that
# reclaims the same space properly. Two buttons for one job, one of which is a
# terminal window.
grep -Fq 'Unused container images' "$page" \
&& fail 'the duplicate container-images row is back, above the card that already does this'
grep -Fq 'kitty' "$page" \
&& fail 'the Storage page opens a terminal, which is not a settings page doing its job'
printf 'disks contract: PASS (%d drives, %d filesystems, breakdown adds up, %d cleanable(s))\n' \
"$(jq '.drives | length' <<<"$snapshot")" \
"$(jq '.filesystems | length' <<<"$snapshot")"
"$(jq '.filesystems | length' <<<"$snapshot")" \
"$(jq 'length' <<<"$cleanables")"