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:
@@ -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")"
|
||||
|
||||
Reference in New Issue
Block a user