diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 0a1e57e..cdbecdd 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -120,6 +120,7 @@ Rectangle { case "power": return powerPage; case "datetime": return dateTimePage; case "applications": return applicationsPage; + case "storage": return storagePage; case "services": return healthPage; case "about": return aboutPage; default: return homePage; @@ -158,6 +159,7 @@ Rectangle { Component { id: homePage; HomePage {} } Component { id: applicationsPage; ApplicationsPage {} } + Component { id: storagePage; StoragePage {} } Component { id: accessibilityPage; AccessibilityPage {} } Component { id: powerPage; PowerPage {} } Component { id: dateTimePage; DateTimePage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 20fabdb..c20fc12 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -40,6 +40,7 @@ Rectangle { { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "applications", label: "Applications", icon: "\u{F003B}" }, + { page: "storage", label: "Storage", icon: "\u{F02CA}" }, { page: "services", label: "System Health", icon: "\u{F0493}" }, { page: "about", label: "About", icon: "\u{F02FD}" } ] diff --git a/config/dot/quickshell/modules/settings/StoragePage.qml b/config/dot/quickshell/modules/settings/StoragePage.qml new file mode 100644 index 0000000..5ae0ee3 --- /dev/null +++ b/config/dot/quickshell/modules/settings/StoragePage.qml @@ -0,0 +1,337 @@ +// Storage: what is using the drive, then what the drive is. +// +// The page is one scroll with a rule behind it: everything above the divider +// answers "how much space do I have and what took it", everything below answers +// "what is this device and is it healthy". A card that answers neither does not +// belong here. +// +// Measuring what is using the space is expensive -- a Steam library alone can +// be a terabyte -- so it happens on request rather than on open. The page says +// so plainly instead of showing an empty list that reads as "nothing here". +// +// Partitioning and formatting are deliberately absent; GNOME Disks is one row +// away for that. + +import Quickshell +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + objectName: "storage" + title: "Storage" + lede: "How much space is left, what is using it, and whether the drive is healthy." + + readonly property var rootFs: Disks.rootFilesystem + readonly property var drive: Disks.primaryDrive + + // The measured folders, as a share of the largest one, so the bars compare + // against each other rather than against a total they do not sum to. + readonly property real largestFolder: { + let largest = 0; + for (const folder of Disks.folders) + largest = Math.max(largest, Number(folder.bytes ?? 0)); + return largest; + } + + readonly property var removableDrives: Disks.drives.filter(entry => entry.removable) + + Component.onCompleted: Disks.refresh() + + TextRow { + visible: Disks.lastError !== "" + label: "Storage needs attention" + detail: Disks.lastError + value: "" + divider: false + } + + // ── Space ──────────────────────────────────────────────────────────────── + + SettingsCard { + title: "Free space" + subtitle: root.rootFs && (root.rootFs.mountpoints ?? []).length > 1 + ? "One filesystem is mounted at " + Disks.mountLabel(root.rootFs) + + ", so they share the same space." + : "The filesystem this session runs from." + + Column { + width: parent.width + spacing: 10 + + Row { + width: parent.width + spacing: 12 + + Text { + text: root.rootFs ? Disks.formatBytes(root.rootFs.availBytes) + " free" : "Reading…" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.rootFs !== null + text: root.rootFs + ? "of " + Disks.formatBytes(root.rootFs.sizeBytes) + + " · " + String(root.rootFs.fstype ?? "") + : "" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Rectangle { + width: parent.width + height: 10 + radius: 5 + color: Theme.alpha(Theme.fg, 0.09) + + Rectangle { + width: Math.max(parent.width * Disks.usedFraction(root.rootFs), parent.height) + height: parent.height + radius: parent.radius + // Color is the only warning this bar gives, so it changes + // at the point where free space starts to be a problem + // rather than at a round number. + color: Disks.usedFraction(root.rootFs) > 0.92 + ? Theme.danger + : (Disks.usedFraction(root.rootFs) > 0.8 ? Theme.warn : Theme.accent) + } + } + + Text { + text: root.rootFs + ? Disks.formatBytes(root.rootFs.usedBytes) + " used · " + + Math.round(Disks.usedFraction(root.rootFs) * 100) + "%" + : "" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } + + SettingsCard { + title: "What is using it" + subtitle: Disks.foldersMeasured + ? "Measured by walking each folder." + : "Measuring means walking every file, so it is not done automatically." + + ActionRow { + visible: !Disks.foldersMeasured || Disks.scanning + label: Disks.scanning ? "Measuring…" : "Measure folders" + detail: Disks.scanning + ? "Walking the largest folders. This can take a minute." + : "Walk the usual suspects and report what is biggest." + action: "Measure" + enabled: !Disks.scanning + divider: Disks.foldersMeasured + onTriggered: Disks.scan() + } + + Repeater { + model: Disks.foldersMeasured ? Disks.folders : [] + + delegate: Column { + id: folderBlock + + required property var modelData + required property int index + + width: parent.width + + TextRow { + width: folderBlock.width + label: String(folderBlock.modelData.label ?? "") + detail: String(folderBlock.modelData.path ?? "") + value: Disks.formatBytes(folderBlock.modelData.bytes ?? 0) + divider: false + } + + Rectangle { + width: folderBlock.width + height: 4 + radius: 2 + color: Theme.alpha(Theme.fg, 0.08) + + // Relative to the LARGEST folder, not to the drive. At this + // scale one folder holds almost everything, so bars drawn + // against the total would leave every other row invisible. + Rectangle { + width: root.largestFolder > 0 + ? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.largestFolder), 2) + : 0 + height: parent.height + radius: parent.radius + color: Theme.accent + } + } + + Item { width: 1; height: 10 } + } + } + + TextRow { + visible: Disks.foldersMeasured && Disks.folderScanTruncated + label: "Some folders were not measured" + detail: "The walk ran out of time before reaching them, so this list is incomplete." + value: "" + divider: Disks.containers !== null + } + + ActionRow { + visible: Disks.containers !== null + && Number(Disks.containers?.reclaimableBytes ?? 0) > 0 + label: "Unused container images" + detail: Disks.containers + ? Disks.formatBytes(Disks.containers.reclaimableBytes) + + " of " + Disks.formatBytes(Disks.containers.totalBytes) + + " is not used by any container" + : "" + action: "Show" + divider: false + // Reclaiming is not offered here on purpose: pruning images can + // destroy work that lives outside this desktop, and a settings pane + // should not put that one click deep. This opens a terminal showing + // what would be reclaimed, and leaves the decision there. + onTriggered: Quickshell.execDetached( + ["kitty", "--hold", "-e", "podman", "system", "df"]) + } + } + + // ── The device ─────────────────────────────────────────────────────────── + + SettingsCard { + title: "Drive" + subtitle: root.drive + ? String(root.drive.model) + (root.drive.encrypted ? " · encrypted" : "") + : "Reading…" + + TextRow { + visible: root.drive !== null + label: "Health" + detail: root.drive && root.drive.selfTest !== "" + ? "Last self-test: " + String(root.drive.selfTest) + : "Reported by the drive itself" + value: Disks.healthSummary(root.drive) + } + + TextRow { + visible: root.drive !== null && root.drive.temperatureC !== null + label: "Temperature" + detail: "Measured by the drive controller" + value: root.drive && root.drive.temperatureC !== null + ? String(root.drive.temperatureC) + " °C" : "" + } + + TextRow { + visible: root.drive !== null && root.drive.powerOnHours !== null + label: "Powered on" + detail: "Total hours this drive has been running" + value: root.drive && root.drive.powerOnHours !== null + ? String(root.drive.powerOnHours) + " hours" : "" + } + + TextRow { + visible: root.drive !== null && root.drive.encrypted + label: "Encryption" + detail: "The filesystem is encrypted and unlocked at boot" + value: "LUKS" + } + + ActionRow { + label: "Partitioning and formatting" + detail: "Deliberately not here. Disks is the tool that owns erasing a drive." + action: "Open Disks" + divider: false + onTriggered: Quickshell.execDetached(["gapplication", "launch", "org.gnome.DiskUtility"]) + } + } + + SettingsCard { + title: "Filesystems" + subtitle: "Every mounted filesystem, grouped by the device behind it." + + Repeater { + model: Disks.filesystems + + delegate: TextRow { + id: filesystemRow + + required property var modelData + required property int index + + width: parent.width + label: Disks.mountLabel(filesystemRow.modelData) + detail: String(filesystemRow.modelData.fstype ?? "") + + " on " + String(filesystemRow.modelData.device ?? "") + value: Disks.formatBytes(filesystemRow.modelData.availBytes) + " free of " + + Disks.formatBytes(filesystemRow.modelData.sizeBytes) + divider: filesystemRow.index < Disks.filesystems.length - 1 + } + } + } + + SettingsCard { + title: "Removable drives" + subtitle: "USB drives and memory cards." + + Repeater { + model: root.removableDrives + + delegate: ActionRow { + id: removableRow + + required property var modelData + + width: parent.width + label: String(removableRow.modelData.model ?? removableRow.modelData.name) + detail: Disks.formatBytes(removableRow.modelData.sizeBytes) + + " · " + String(removableRow.modelData.path) + action: removableRow.modelData.ejectable ? "Eject" : "Unmount" + divider: false + onTriggered: removableRow.modelData.ejectable + ? Disks.eject(String(removableRow.modelData.path)) + : Disks.unmount(String(removableRow.modelData.path)) + } + } + + TextRow { + visible: root.removableDrives.length === 0 + label: "None connected" + detail: "Drives you plug in appear here, with a way to unmount them safely." + value: "" + divider: false + } + } + + SettingsCard { + visible: Disks.swap.length > 0 + title: "Swap" + subtitle: "Compressed swap lives in memory, not on the drive." + + Repeater { + model: Disks.swap + + delegate: TextRow { + id: swapRow + + required property var modelData + required property int index + + width: parent.width + label: String(swapRow.modelData.name ?? "") + detail: String(swapRow.modelData.kind) === "zram" + ? "Compressed swap in RAM" : "Swap" + value: Disks.formatBytes(swapRow.modelData.sizeBytes) + divider: swapRow.index < Disks.swap.length - 1 + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 24b664b..4f3cb2d 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -23,6 +23,7 @@ SettingsWindow 1.0 SettingsWindow.qml ShortcutsPage 1.0 ShortcutsPage.qml SoundPage 1.0 SoundPage.qml SettingsPage 1.0 SettingsPage.qml +StoragePage 1.0 StoragePage.qml ToggleRow 1.0 ToggleRow.qml SliderRow 1.0 SliderRow.qml ChoiceRow 1.0 ChoiceRow.qml diff --git a/config/dot/quickshell/scripts/panama-disks b/config/dot/quickshell/scripts/panama-disks new file mode 100755 index 0000000..7e5621d --- /dev/null +++ b/config/dot/quickshell/scripts/panama-disks @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 + +"""Read storage layout, usage, and drive health for Panama's Storage page. + +Two boundaries, deliberately separate: + + snapshot topology, usage, health, removable media. Cheap -- lsblk and a + single udisks call -- so the page can open with it. + scan what is actually filling the drive. Expensive: measuring a folder + means walking it, and this machine has a 1.2 TiB Steam library. + The page asks for this on demand and remembers the answer. + + 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. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time + +# Bounded so a pathological tree cannot hang the page. A folder that cannot be +# measured in this long is reported as unmeasured rather than silently omitted, +# because a missing row reads as "this folder is small". +SCAN_TIMEOUT_SECONDS = 90 + +# Where "what is filling my drive" actually gets answered. Ordered so the most +# likely culprits are measured first; the walk stops when the budget runs out. +SCAN_TARGETS = [ + ("Steam library", "~/.local/share/Steam"), + ("Documents", "~/Documents"), + ("Downloads", "~/Downloads"), + ("Videos", "~/Videos"), + ("Pictures", "~/Pictures"), + ("Music", "~/Music"), + ("Flatpak applications", "~/.var/app"), + ("Caches", "~/.cache"), + ("Trash", "~/.local/share/Trash"), +] + + +class BoundaryError(RuntimeError): + """A user-visible validation or command failure.""" + + +def run(command: list[str], timeout: float = 15.0) -> str: + try: + completed = subprocess.run(command, check=False, capture_output=True, + text=True, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as error: + raise BoundaryError(f"{command[0]} did not answer") from error + if completed.returncode != 0: + raise BoundaryError(completed.stderr.strip() or f"{command[0]} failed") + return completed.stdout + + +def lsblk() -> list[dict]: + """The block device tree. + + PANAMA_DISKS_LSBLK substitutes a recorded tree for the real one. It exists + so the refusal rules below can be tested against a removable drive that is + not plugged in -- the alternative is a test that runs unmount against + whatever is actually mounted, which is not a test anyone should write. + """ + fixture = os.environ.get("PANAMA_DISKS_LSBLK") + if fixture: + try: + return json.loads(Path(fixture).read_text(encoding="utf-8"))["blockdevices"] + except (OSError, json.JSONDecodeError, KeyError) as error: + raise BoundaryError("could not read the recorded block device layout") from error + fields = ("NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINTS,MODEL,SERIAL,RM,HOTPLUG," + "ROTA,FSSIZE,FSUSED,FSAVAIL") + try: + return json.loads(run(["lsblk", "-J", "-b", "-o", fields]))["blockdevices"] + except (json.JSONDecodeError, KeyError) as error: + raise BoundaryError("could not read the block device layout") from error + + +def udisks_objects() -> dict: + """Every udisks object in one call, or nothing if udisks is not running. + + Health is a nice-to-have: a machine without udisks still gets its layout and + usage, which is most of the page. Failing the whole snapshot because a + temperature is unavailable would be the wrong trade. + """ + if not shutil.which("busctl"): + return {} + try: + raw = run(["busctl", "--system", "--json=short", "call", + "org.freedesktop.UDisks2", "/org/freedesktop/UDisks2", + "org.freedesktop.DBus.ObjectManager", "GetManagedObjects"]) + payload = json.loads(raw) + except (BoundaryError, json.JSONDecodeError): + return {} + # busctl wraps the reply as {"type": "...", "data": [ {path: {iface: {prop: {"type","data"}}}} ]} + data = payload.get("data") or [] + return data[0] if data else {} + + +def unwrap(value): + """busctl encodes every value as {"type": t, "data": v}.""" + if isinstance(value, dict) and "data" in value and "type" in value: + return value["data"] + return value + + +def drive_health(objects: dict) -> dict[str, dict]: + """Health keyed by drive serial, which is the only stable join to lsblk.""" + health: dict[str, dict] = {} + for path, interfaces in objects.items(): + if "/drives/" not in path: + continue + drive = {key: unwrap(value) + for key, value in (interfaces.get("org.freedesktop.UDisks2.Drive") or {}).items()} + serial = str(drive.get("Serial") or "") + if not serial: + continue + + entry = { + "ejectable": bool(drive.get("Ejectable")), + "removable": bool(drive.get("Removable")), + "temperatureC": None, + "powerOnHours": None, + "healthy": None, + "warnings": [], + "selfTest": "", + } + + nvme = {key: unwrap(value) for key, value in + (interfaces.get("org.freedesktop.UDisks2.NVMe.Controller") or {}).items()} + ata = {key: unwrap(value) for key, value in + (interfaces.get("org.freedesktop.UDisks2.Drive.Ata") or {}).items()} + + if nvme: + kelvin = nvme.get("SmartTemperature") + if isinstance(kelvin, (int, float)) and kelvin > 0: + entry["temperatureC"] = round(kelvin - 273.15, 1) + hours = nvme.get("SmartPowerOnHours") + if isinstance(hours, (int, float)): + entry["powerOnHours"] = int(hours) + warnings = nvme.get("SmartCriticalWarning") or [] + entry["warnings"] = [str(item) for item in warnings] + entry["healthy"] = not entry["warnings"] + entry["selfTest"] = str(nvme.get("SmartSelftestStatus") or "") + elif ata: + kelvin = ata.get("SmartTemperature") + if isinstance(kelvin, (int, float)) and kelvin > 0: + entry["temperatureC"] = round(kelvin - 273.15, 1) + seconds = ata.get("SmartPowerOnSeconds") + if isinstance(seconds, (int, float)) and seconds > 0: + entry["powerOnHours"] = int(seconds // 3600) + failing = ata.get("SmartFailing") + if isinstance(failing, bool): + entry["healthy"] = not failing + entry["warnings"] = ["failing"] if failing else [] + entry["selfTest"] = str(ata.get("SmartSelftestStatus") or "") + + health[serial] = entry + return health + + +def walk(node: dict, depth: int = 0): + yield node, depth + for child in node.get("children") or []: + yield from walk(child, depth + 1) + + +def mountpoints(node: dict) -> list[str]: + return [point for point in (node.get("mountpoints") or []) if point and point != "[SWAP]"] + + +def snapshot() -> dict: + tree = lsblk() + health = drive_health(udisks_objects()) + + drives = [] + swap = [] + filesystems: dict[str, dict] = {} + + for root in tree: + if root.get("type") != "disk": + continue + + # zram is compressed swap in RAM. It is a block device and it is not + # storage; listing it as a drive would be actively misleading about how + # much of this machine is disk. + if str(root.get("name", "")).startswith("zram"): + swap.append({ + "name": root.get("name"), + "sizeBytes": root.get("size") or 0, + "kind": "zram", + }) + continue + + serial = str(root.get("serial") or "") + drive_health_entry = health.get(serial, {}) + partitions = [] + encrypted = False + + for node, depth in walk(root): + if depth == 0: + continue + if node.get("fstype") == "crypto_LUKS": + encrypted = True + if node.get("type") in ("part", "crypt"): + partitions.append({ + "name": node.get("name"), + "path": node.get("path"), + "sizeBytes": node.get("size") or 0, + "fstype": node.get("fstype") or "", + "type": node.get("type"), + "mountpoints": mountpoints(node), + }) + for point in mountpoints(node): + if node.get("fssize") is None: + continue + # Keyed by device: btrfs subvolumes mounted at / and /home are + # ONE filesystem with one pool of free space. Reporting them as + # two independent bars, which is what df does, doubles the free + # space on screen. + key = str(node.get("path")) + entry = filesystems.setdefault(key, { + "device": node.get("path"), + "fstype": node.get("fstype") or "", + "sizeBytes": int(node.get("fssize") or 0), + "usedBytes": int(node.get("fsused") or 0), + "availBytes": int(node.get("fsavail") or 0), + "mountpoints": [], + "encrypted": node.get("type") == "crypt", + }) + entry["mountpoints"].append(point) + entry["mountpoints"].sort(key=lambda mount: (mount != "/", mount)) + + drives.append({ + "name": root.get("name"), + "path": root.get("path"), + "model": (root.get("model") or "").strip() or str(root.get("name")), + "serial": serial, + "sizeBytes": root.get("size") or 0, + "rotational": bool(root.get("rota")), + "removable": bool(root.get("rm")) or bool(root.get("hotplug")) + or bool(drive_health_entry.get("removable")), + "ejectable": bool(drive_health_entry.get("ejectable")), + "encrypted": encrypted, + "partitions": partitions, + "temperatureC": drive_health_entry.get("temperatureC"), + "powerOnHours": drive_health_entry.get("powerOnHours"), + "healthy": drive_health_entry.get("healthy"), + "warnings": drive_health_entry.get("warnings", []), + "selfTest": drive_health_entry.get("selfTest", ""), + }) + + ordered = sorted(filesystems.values(), + key=lambda entry: (0 if "/" in entry["mountpoints"] else 1, + entry["mountpoints"][0] if entry["mountpoints"] else "")) + return {"drives": drives, "filesystems": ordered, "swap": swap} + + +def measure(path: Path, budget: float) -> tuple[int | None, float]: + """Bytes used by a folder, and what is left of the time budget.""" + if not path.is_dir(): + return None, budget + 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 folder that ran out of time has consumed the whole budget by + # definition; the caller stops rather than starting another walk. + return None, 0.0 + left = max(budget - (time.monotonic() - started), 0.0) + if completed.returncode != 0: + return None, left + match = re.match(r"^(\d+)", completed.stdout) + return (int(match.group(1)) if match else None), left + + +def container_reclaimable() -> dict | None: + """Container images nothing is using. Absent when podman is not installed.""" + if not shutil.which("podman"): + return None + try: + raw = run(["podman", "system", "df", "--format", "json"], timeout=20.0) + entries = json.loads(raw) + except (BoundaryError, json.JSONDecodeError): + return None + for entry in entries if isinstance(entries, list) else []: + if not str(entry.get("Type", "")).lower().startswith("image"): + continue + # Raw* are integers; Size and Reclaimable are display strings like + # "10.8GB" and "5.31GB (49%)". Reading the display strings is how this + # first crashed, so only the raw fields are trusted, and an old podman + # that lacks them reports nothing rather than a wrong number. + total = entry.get("RawSize") + reclaimable = entry.get("RawReclaimable") + if not isinstance(total, int) or not isinstance(reclaimable, int): + return None + return {"totalBytes": total, "reclaimableBytes": reclaimable} + return None + + +def scan() -> dict: + folders = [] + remaining = float(SCAN_TIMEOUT_SECONDS) + truncated = False + for label, target in SCAN_TARGETS: + path = Path(os.path.expanduser(target)) + if remaining <= 1.0: + truncated = True + break + size, remaining = measure(path, remaining) + if size is None: + continue + folders.append({"label": label, "path": str(path), "bytes": size}) + folders.sort(key=lambda item: item["bytes"], reverse=True) + return { + "folders": folders, + "truncated": truncated, + "containers": container_reclaimable(), + } + + +def removable_device(path: str) -> dict: + """Resolve a device path, refusing anything that is not removable. + + Unmounting the root filesystem is not a feature. The check is on the device + rather than the button because the caller is a UI that can be wrong. + """ + if not re.fullmatch(r"/dev/[A-Za-z0-9/_-]+", path or ""): + raise BoundaryError("That is not a device path.") + for root in lsblk(): + for node, _ in walk(root): + if node.get("path") != path: + continue + top = root + if not (bool(top.get("rm")) or bool(top.get("hotplug"))): + raise BoundaryError("That drive is not removable.") + return node + raise BoundaryError("No such device.") + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + elif arguments == ["scan"]: + print(json.dumps(scan(), separators=(",", ":"))) + elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"): + removable_device(arguments[1]) + action = "unmount" if arguments[0] == "unmount" else "power-off" + flag = "-b" if arguments[0] == "unmount" else "-b" + run(["udisksctl", action, flag, arguments[1]], timeout=30.0) + else: + raise BoundaryError( + "Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE") + except BoundaryError as error: + print(str(error), file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/Disks.qml b/config/dot/quickshell/services/Disks.qml new file mode 100644 index 0000000..cc87969 --- /dev/null +++ b/config/dot/quickshell/services/Disks.qml @@ -0,0 +1,183 @@ +pragma Singleton + +// Storage: what is in this machine, how full it is, and what is filling it. +// +// Two reads with very different costs, kept apart on purpose: +// +// refresh() layout, usage, and drive health. Around 90ms -- lsblk plus one +// udisks call -- so the page opens with it and re-reads freely. +// scan() what is using the space. Measuring a folder means walking it, +// and a Steam library alone can be a terabyte, so this happens +// only when asked and the answer is kept until asked again. +// +// Not a stored preference: every value here is the machine's, not the user's. +// +// Deliberately absent: partitioning and formatting. Those stay in GNOME Disks, +// which the page can launch. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-disks" + + property var drives: [] + property var filesystems: [] + property var swap: [] + property bool scanned: false + property string lastError: "" + + // The expensive half. + property var folders: [] + property var containers: null + property bool scanning: false + property bool folderScanTruncated: false + // Empty until a scan has completed, which is what the page shows a prompt + // for rather than an empty list -- "nothing here" and "not measured yet" + // are different answers. + property bool foldersMeasured: false + + readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null + + // The filesystem the user means when they ask how full the machine is. + readonly property var rootFilesystem: { + for (const filesystem of root.filesystems) { + if ((filesystem.mountpoints ?? []).includes("/")) + return filesystem; + } + return root.filesystems.length > 0 ? root.filesystems[0] : null; + } + + // Bytes, in the units people actually read. Binary units with decimal-style + // names would be a lie in the other direction; this matches what df -h and + // the drive's own packaging say. + function formatBytes(bytes: real): string { + if (!(bytes > 0)) + return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let index = 0; + while (value >= 1000 && index < units.length - 1) { + value /= 1000; + index += 1; + } + const decimals = value < 10 && index > 1 ? 1 : 0; + return value.toFixed(decimals) + " " + units[index]; + } + + function usedFraction(filesystem: var): real { + const size = Number(filesystem?.sizeBytes ?? 0); + if (!(size > 0)) + return 0; + return Math.max(0, Math.min(1, Number(filesystem.usedBytes ?? 0) / size)); + } + + // "/ and /home" rather than two rows: btrfs subvolumes share one pool of + // free space, and showing them separately doubles it on screen. + function mountLabel(filesystem: var): string { + const points = filesystem?.mountpoints ?? []; + if (points.length === 0) + return String(filesystem?.device ?? ""); + if (points.length === 1) + return points[0]; + return points.slice(0, -1).join(", ") + " and " + points[points.length - 1]; + } + + function healthSummary(drive: var): string { + if (!drive) + return ""; + if (drive.healthy === false) + return (drive.warnings ?? []).length > 0 + ? "Reporting " + drive.warnings.join(", ") + : "Reporting a failure"; + if (drive.healthy === true) + return "No warnings"; + return "Health not reported"; + } + + function refresh(): void { + if (!query.running) + query.running = true; + } + + function scan(): void { + if (root.scanning) + return; + root.scanning = true; + folderScan.running = true; + } + + function unmount(devicePath: string): void { + root.runMedia(["unmount", devicePath]); + } + + function eject(devicePath: string): void { + root.runMedia(["eject", devicePath]); + } + + function runMedia(arguments: var): void { + if (media.running) + return; + root.lastError = ""; + media.command = [root.helperPath].concat(arguments); + media.running = true; + } + + Process { + id: query + command: [root.helperPath, "snapshot"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.drives = Array.isArray(parsed.drives) ? parsed.drives : []; + root.filesystems = Array.isArray(parsed.filesystems) ? parsed.filesystems : []; + root.swap = Array.isArray(parsed.swap) ? parsed.swap : []; + root.lastError = ""; + } catch (error) { + root.drives = []; + root.filesystems = []; + root.lastError = "Could not read the storage helper's output."; + console.warn("Disks: could not parse helper output:", error); + } + root.scanned = true; + } + } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { + id: folderScan + command: [root.helperPath, "scan"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.folders = Array.isArray(parsed.folders) ? parsed.folders : []; + root.containers = parsed.containers ?? null; + root.folderScanTruncated = parsed.truncated === true; + root.foldersMeasured = true; + } catch (error) { + root.lastError = "Could not measure what is using the drive."; + console.warn("Disks: could not parse scan output:", error); + } + } + } + onExited: root.scanning = false + } + + Process { + id: media + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + // Re-read rather than assuming: a device may refuse to unmount because + // something still has a file open on it. + onExited: root.refresh() + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 995f4e4..89c36be 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -64,6 +64,11 @@ Singleton { { label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" }, { label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" }, { label: "Default applications", detail: "Browser, mail, files", page: "applications" }, + { label: "Free space", detail: "How full each drive and filesystem is", page: "storage" }, + { label: "Disk usage", detail: "What is using the space on this machine", page: "storage" }, + { label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" }, + { label: "Removable drives", detail: "Unmount a USB drive or memory card safely", page: "storage" }, + { label: "Encryption", detail: "Whether the filesystem is encrypted", page: "storage" }, { label: "Output volume", detail: "Choose the output device and its level", page: "sound" }, { label: "Input volume", detail: "Choose the microphone and its level", page: "sound" }, { label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 6a102ed..ce237da 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/local/share/vicinae/scripts/settings-storage.sh b/config/local/share/vicinae/scripts/settings-storage.sh new file mode 100755 index 0000000..1787de7 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-storage.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Storage +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Storage in Settings. +# @vicinae.keywords ["settings", "free space", "disk usage", "drive health", "removable drives", "encryption"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page storage diff --git a/docs/superpowers/plans/2026-08-19-desktop-integration.md b/docs/superpowers/plans/2026-08-19-desktop-integration.md index e68c1f8..52479e4 100644 --- a/docs/superpowers/plans/2026-08-19-desktop-integration.md +++ b/docs/superpowers/plans/2026-08-19-desktop-integration.md @@ -82,6 +82,27 @@ There is no way to see what is using the disk, and removable media is handled by **Done when** the page answers "what is filling my drive" and "is it safe to pull this USB stick out". +**Landed 2026-08-19.** Built as mock A over mock B on one scroll rather than +tabs: every other settings page is a scrolling card stack, and a tab would not be +deep-linkable from the launcher command or from search. + +Three things the page had to get right, each now pinned by a contract, because +each is a way it could quietly lie: + +- `/` and `/home` are one btrfs filesystem sharing one pool of free space. `df` + reports them separately, and a page that copies `df` shows double the free + space that exists. +- zram is a block device and is not storage. Counting it as a drive would + overstate this machine by 8 GB. +- unmount and eject refuse anything not on a removable drive. The UI is what + asks, and a UI can be wrong. + +Adding the page also found a fourth hard-coded page list in `ShellState.qml`. +Missing from it, a page does not error -- `openSettings()` falls back to "home", +so the launcher command opens the wrong page and logs nothing. There is now a +registry contract holding the sidebar, the shell switch, and the allow-list +together. + ## Phase 4 — Launcher-native settings search (small) Settings has a search index. The launcher has script commands. They do not know diff --git a/tests/quickshell/disks-contract.sh b/tests/quickshell/disks-contract.sh new file mode 100755 index 0000000..2c9624a --- /dev/null +++ b/tests/quickshell/disks-contract.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash + +# Storage has to be honest about three things, and each of them is a way the +# page could quietly lie: +# +# 1. btrfs subvolumes mounted at / and /home are ONE filesystem sharing one +# pool of free space. df reports them as two, and a page that copies df +# shows the user twice the free space they have. +# 2. zram is a block device and is not storage. Counting it as a drive +# overstates how much disk this machine has by 8 GB. +# 3. unmount and eject must refuse anything that is not removable. The UI is +# the thing asking, and a UI can be wrong. +# +# The expensive folder scan is NOT exercised here: measuring a terabyte-scale +# library is not something a test should do on the machine someone is using. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-disks" +service="$repo_dir/config/dot/quickshell/services/Disks.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/StoragePage.qml" + +fail() { + printf 'disks contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -x "$helper" ]] || fail 'panama-disks is missing or not executable' +[[ -r "$service" ]] || fail 'the Disks service is missing' +[[ -r "$page" ]] || fail 'StoragePage is missing' + +# ── The service reports failure rather than inventing data ─────────────────── +grep -q 'pragma Singleton' "$service" || fail 'Disks is not a singleton' +grep -q 'property string lastError' "$service" || fail 'Disks cannot report a failure' +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 expensive read must not run on open; that is the entire reason it is a +# separate command. +grep -q 'Component.onCompleted: Disks.refresh()' "$page" \ + || fail 'the page does not read the cheap snapshot when it opens' +grep -q 'Component.onCompleted: Disks.scan()' "$page" \ + && fail 'the page starts the expensive folder walk on open' + +# Partitioning stays out, deliberately and visibly. +grep -qiE 'mkfs|sfdisk|parted|wipefs|dd if=' "$helper" \ + && fail 'the helper can partition or format, which this page deliberately does not do' + +# ── Snapshot shape ─────────────────────────────────────────────────────────── +snapshot="$("$helper" snapshot)" || fail 'snapshot failed' +command -v jq >/dev/null 2>&1 || { printf 'disks contract: SKIP (no jq)\n'; exit 0; } + +jq -e '(.drives | type == "array") and (.filesystems | type == "array") and (.swap | type == "array")' \ + <<<"$snapshot" >/dev/null || fail 'snapshot is missing one of drives, filesystems, or swap' + +# zram is swap, never a drive. +jq -e '[.drives[].name | startswith("zram")] | any | not' <<<"$snapshot" >/dev/null \ + || fail 'zram is reported as a drive, which overstates how much disk this machine has' +jq -e '[.swap[].kind] | index("zram") != null or (. | length == 0)' <<<"$snapshot" >/dev/null \ + || fail 'zram is not reported as swap' + +# One entry per DEVICE, not per mount point. Two entries for one device would +# be the doubled-free-space bug. +duplicates="$(jq -r '[.filesystems[].device] | group_by(.) | map(select(length > 1)) | flatten | join(", ")' <<<"$snapshot")" +[[ -z "$duplicates" ]] \ + || fail "these devices appear more than once, so their free space is counted twice: $duplicates" + +# Every filesystem has to carry the numbers the page draws. +jq -e '[.filesystems[] | (.sizeBytes > 0) and (.mountpoints | length > 0)] | all' <<<"$snapshot" >/dev/null \ + || fail 'a filesystem is missing its size or its mount points' + +# A shared filesystem lists "/" first, because that is what it is called. +jq -e '[.filesystems[] | select(.mountpoints | index("/")) | .mountpoints[0] == "/"] | all' \ + <<<"$snapshot" >/dev/null || fail 'the root filesystem does not lead with "/"' + +# ── Refusals, against a recorded tree ──────────────────────────────────────── +# Never against real hardware. An earlier version of this test ran unmount on +# whatever device happened to hold the root filesystem and passed only because +# the kernel refused -- which tests the kernel, not the helper, and would have +# unmounted /home the moment nothing held it open. +work="$(mktemp -d /tmp/panama-disks.XXXXXX)" +trap 'rm -rf "$work"' EXIT + +cat >"$work/tree.json" <<'FIXTURE' +{"blockdevices":[ + {"name":"nvme0n1","path":"/dev/nvme0n1","size":2000398934016,"type":"disk","fstype":null, + "mountpoints":[],"model":"Fixed Disk","serial":"FIXED1","rm":false,"hotplug":false,"rota":false, + "fssize":null,"fsused":null,"fsavail":null, + "children":[{"name":"nvme0n1p1","path":"/dev/nvme0n1p1","size":2000398934016,"type":"part", + "fstype":"btrfs","mountpoints":["/"],"model":null,"serial":null,"rm":false,"hotplug":false, + "rota":false,"fssize":2000398934016,"fsused":1000000000000,"fsavail":1000398934016}]}, + {"name":"sdb","path":"/dev/sdb","size":32000000000,"type":"disk","fstype":null, + "mountpoints":[],"model":"USB Stick","serial":"USB1","rm":true,"hotplug":true,"rota":false, + "fssize":null,"fsused":null,"fsavail":null, + "children":[{"name":"sdb1","path":"/dev/sdb1","size":32000000000,"type":"part", + "fstype":"vfat","mountpoints":["/run/media/user/USB"],"model":null,"serial":null, + "rm":true,"hotplug":true,"rota":false,"fssize":32000000000,"fsused":1000000000, + "fsavail":31000000000}]} +]} +FIXTURE + +# A fake udisksctl, so a refusal that should never reach the system is visible +# as an empty log rather than inferred from an exit code. +mkdir -p "$work/bin" +cat >"$work/bin/udisksctl" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$PANAMA_DISKS_CALL_LOG" +STUB +chmod +x "$work/bin/udisksctl" + +export PANAMA_DISKS_LSBLK="$work/tree.json" +export PANAMA_DISKS_CALL_LOG="$work/calls" +export PATH="$work/bin:$PATH" +: >"$PANAMA_DISKS_CALL_LOG" + +# The root filesystem is not removable, and no argument may make it so. +"$helper" unmount /dev/nvme0n1p1 >/dev/null 2>&1 \ + && fail 'unmount accepted the root filesystem' +"$helper" eject /dev/nvme0n1 >/dev/null 2>&1 \ + && fail 'eject accepted a fixed drive' +[[ ! -s "$PANAMA_DISKS_CALL_LOG" ]] \ + || fail "a refused device still reached udisksctl: $(cat "$PANAMA_DISKS_CALL_LOG")" + +"$helper" unmount 'not-a-device' >/dev/null 2>&1 \ + && fail 'unmount accepted something that is not a device path' +"$helper" unmount '/dev/../etc/passwd' >/dev/null 2>&1 \ + && fail 'unmount accepted a path escaping /dev' +"$helper" unmount '/dev/sdb99' >/dev/null 2>&1 \ + && fail 'unmount accepted a device that does not exist' +[[ ! -s "$PANAMA_DISKS_CALL_LOG" ]] \ + || fail "a rejected path still reached udisksctl: $(cat "$PANAMA_DISKS_CALL_LOG")" + +# The removable one is allowed, and reaches udisksctl with separate arguments. +"$helper" unmount /dev/sdb1 >/dev/null 2>&1 \ + || fail 'unmount refused a removable partition' +grep -Fxq 'unmount -b /dev/sdb1' "$PANAMA_DISKS_CALL_LOG" \ + || fail "unmount did not reach udisksctl correctly: $(cat "$PANAMA_DISKS_CALL_LOG")" + +"$helper" bogus-command >/dev/null 2>&1 \ + && fail 'an unknown command was accepted' + +unset PANAMA_DISKS_LSBLK PANAMA_DISKS_CALL_LOG + +printf 'disks contract: PASS (%d drives, %d filesystems)\n' \ + "$(jq '.drives | length' <<<"$snapshot")" \ + "$(jq '.filesystems | length' <<<"$snapshot")" diff --git a/tests/quickshell/settings-page-registry-contract.sh b/tests/quickshell/settings-page-registry-contract.sh new file mode 100755 index 0000000..0185135 --- /dev/null +++ b/tests/quickshell/settings-page-registry-contract.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +# A settings page is declared in three places that cannot see each other: +# +# SettingsSidebar.qml the list someone clicks +# SettingsShell.qml the switch that decides which component to build +# ShellState.qml the allow-list that IPC and the launcher go through +# +# Miss one and the failure is silent in the worst way. A page missing from the +# allow-list does not error -- openSettings() falls back to "home", so the +# launcher command opens Settings on the wrong page and logs nothing. That is +# exactly what happened when Storage was added. +# +# Static and read-only; it parses three files. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shell_dir="$repo_dir/config/dot/quickshell" +sidebar="$shell_dir/modules/settings/SettingsSidebar.qml" +shell_file="$shell_dir/modules/settings/SettingsShell.qml" +state="$shell_dir/services/ShellState.qml" + +fail() { + printf 'settings page registry contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$sidebar" "$shell_file" "$state"; do + [[ -r "$path" ]] || fail "missing $path" +done + +listed="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/' | sort -u)" +routed="$(grep -oE 'case "[a-z-]+": return [a-zA-Z]+Page;' "$shell_file" \ + | sed 's/case "\([a-z-]*\)".*/\1/' | sort -u)" +allowed="$(sed -n 's/.*const allowed = \[\(.*\)\];/\1/p' "$state" \ + | tr ',' '\n' | tr -d ' "' | grep -v '^$' | sort -u)" + +[[ -n "$listed" ]] || fail 'no pages found in the sidebar' +[[ -n "$routed" ]] || fail 'no pages found in the shell switch' +[[ -n "$allowed" ]] || fail 'no allow-list found in ShellState' + +# "home" is the fallback: it is routed by `default:` rather than a case, so it +# is legitimately absent from the switch. +routed="$(printf '%s\nhome\n' "$routed" | sort -u)" + +missing_route="$(comm -23 <(printf '%s\n' "$listed") <(printf '%s\n' "$routed") | tr '\n' ' ')" +[[ -z "${missing_route// }" ]] \ + || fail "these pages are in the sidebar but the shell has no case for them, so they render as Home: $missing_route" + +missing_allow="$(comm -23 <(printf '%s\n' "$listed") <(printf '%s\n' "$allowed") | tr '\n' ' ')" +[[ -z "${missing_allow// }" ]] \ + || fail "these pages are in the sidebar but not in ShellState's allow-list, so opening them by IPC silently lands on Home: $missing_allow" + +orphan_allow="$(comm -13 <(printf '%s\n' "$listed") <(printf '%s\n' "$allowed") | tr '\n' ' ')" +[[ -z "${orphan_allow// }" ]] \ + || fail "ShellState allows pages the sidebar does not have: $orphan_allow" + +printf 'settings page registry contract: PASS (%d pages agree across three files)\n' \ + "$(grep -c . <<<"$listed")"