Add a Storage page
Nothing showed what was using the drive, and removable media was handled by a tray helper with no surface in Settings at all. One scroll rather than tabs: space above, the device below. 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 has 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, 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 overstates this machine by 8 GB. Unmount and eject refuse anything not on a removable drive, because the UI is what asks and a UI can be wrong. The cheap read -- layout, usage, health -- runs when the page opens, at around 90ms. Measuring what is filling the drive means walking every file, so it happens on request and says so rather than showing an empty list that reads as "nothing here". Partitioning and formatting are deliberately absent. A settings pane is the wrong place to put erasing a disk two clicks deep; the page opens GNOME Disks for that. Adding the page found a fourth hard-coded page list in ShellState. A page missing from it does not error -- openSettings() falls back to "home", so the launcher opens the wrong page and logs nothing. A registry contract now holds the three lists together. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -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 {} }
|
||||
|
||||
@@ -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}" }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Executable
+372
@@ -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:]))
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+148
@@ -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")"
|
||||
+60
@@ -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")"
|
||||
Reference in New Issue
Block a user