Make Applications a real app manager, and clean up storage without the racket

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
170 of them, under `tests/`. Run the lot, or a subset by pattern:
171 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
@@ -1,22 +1,56 @@
// Applications and session startup.
// Applications: what is installed, what opens your files, and what starts with
// your session.
//
// This page used to answer only the middle question. That made it the one place
// in Settings you could not do the obvious thing -- see what is on the machine
// and remove something -- so the list came first and everything else arranged
// itself around it.
//
// Two rules the page keeps to:
//
// It never pretends to own a system package. A Flatpak can be inspected and
// removed here because that is a per-user operation with per-user blast
// radius. `dnf remove` is not: removing the wrong package takes the desktop
// with it, and a settings pane should not put that one press away. The row
// says so and prints the command, which is more useful than a button that
// refuses.
//
// It never links somewhere with nothing on it. The "Elsewhere in Settings"
// chips appear per application, and only where that application already has a
// rule on the page being linked to.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
objectName: "applications"
title: "Applications"
lede: "Choose what opens your files and links, and what starts with your session."
lede: "What is installed, what opens your files, and what starts with your session."
property string expandedRole: ""
property bool addingAutostart: false
// Which installed application is unfolded. One at a time: the body is tall
// enough that two open rows would push the third off the screen.
property string expandedApp: ""
// Uninstalling deletes an application, so it never happens on a first
// press. Holds the Flatpak id that is one press away from being removed.
property string pendingUninstall: ""
// Which entry has been asked to be removed. Removal deletes a file, so
// it never happens on a first press.
property string confirmingAutostartRemoval: ""
property bool addingAutostart: false
property bool showingFileTypes: false
// Empty means "whichever category the catalog lists first", so the card is
// never blank before anything has been chosen.
property string catalogCategory: ""
readonly property var applications: DesktopEntries.applications.values
// Each role governs a whole family of types, not one representative: setting
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
@@ -90,6 +124,154 @@ SettingsPage {
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
// ── The installed list ───────────────────────────────────────────────────
// How many rows the card draws before it stops and says how many are left.
// A machine with 243 applications is a scroll, not a list; the search field
// is what actually finds one.
readonly property int installedLimit: 10
readonly property var installedApps: (AppLibrary.apps ?? []).filter(
app => AppLibrary.matches(app, installedSearch.text))
readonly property var expandedAppRecord:
(AppLibrary.apps ?? []).find(app => String(app.entryId ?? "") === root.expandedApp) ?? null
// Read for the open row only. The cache is read directly so this binding
// has a dependency on it -- that is what turns "Reading…" into the summary
// when the answer lands -- and permissionsFor() is what asks for an answer
// that is not there yet.
readonly property var expandedPermissions: {
const cache = AppLibrary.permissionCache;
const app = root.expandedAppRecord;
if (!app || String(app.kind ?? "") !== "flatpak")
return null;
const key = String(app.flatpakId ?? "");
const info = cache[key] ?? AppLibrary.permissionsFor(key);
return info && Array.isArray(info.summary) ? info : null;
}
// Flatseal is the tool that edits these; offering it when it is not
// installed would be a button that does nothing.
readonly property bool flatsealAvailable: root.applications.some(
entry => String(entry.id ?? "").replace(/\.desktop$/, "") === "com.github.tchx84.Flatseal")
function autostartId(app: var): string {
const id = String(app?.entryId ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
function autostartEntry(desktopId: string): var {
return DefaultApps.autostartEntries.find(entry => String(entry.id) === desktopId) ?? null;
}
function autostartOn(app: var): bool {
const entry = root.autostartEntry(root.autostartId(app));
return entry !== null && entry.enabled !== false;
}
// Turning it on for an application that has never had an entry writes one;
// there is no separate "add" step from here, because from the row's point
// of view there is one switch and it means one thing.
function setAppAutostart(app: var, value: bool): void {
const desktopId = root.autostartId(app);
if (root.autostartEntry(desktopId) === null) {
if (value)
DefaultApps.addAutostart(desktopId);
return;
}
DefaultApps.setAutostart(desktopId, value);
}
// ── Where else this application already has a rule ───────────────────────
function appIdentifiers(app: var): var {
const out = [];
const entryId = String(app?.entryId ?? "").replace(/\.desktop$/, "");
const flatpakId = String(app?.flatpakId ?? "");
const name = String(app?.name ?? "");
for (const value of [entryId, flatpakId, name]) {
if (value !== "")
out.push(value.toLowerCase());
}
return out;
}
function hasNotificationRule(app: var): bool {
const identifiers = root.appIdentifiers(app);
return (Notifs.applications ?? []).some(
known => identifiers.indexOf(String(known.id ?? "").toLowerCase()) >= 0);
}
// A stream, not a stored rule: the Sound page's per-application rows come
// from what is playing right now, so the chip is honest only while there is
// something there to change.
function hasSoundRule(app: var): bool {
const identifiers = root.appIdentifiers(app);
return (AudioDevices.applications ?? []).some(stream =>
identifiers.indexOf(String(stream.key ?? "").toLowerCase()) >= 0
|| identifiers.indexOf(String(stream.label ?? "").toLowerCase()) >= 0);
}
// The portal's permission store records answers under the application's own
// id, which is the same id this list carries -- so the match is exact and
// costs nothing.
function hasPrivacyRule(app: var): bool {
const identifiers = root.appIdentifiers(app);
return (Permissions.devices ?? []).some(device =>
(device.applications ?? []).some(
recorded => identifiers.indexOf(String(recorded.app ?? "").toLowerCase()) >= 0));
}
// ── The catalog ──────────────────────────────────────────────────────────
readonly property var catalogCategories: AppLibrary.categories ?? []
// A category arrives either as a plain name or as an object carrying its
// entries with it, so both are read rather than one being assumed.
function categoryId(category: var): string {
return String(category?.id ?? category?.name ?? category ?? "");
}
function categoryLabel(category: var): string {
const label = String(category?.label ?? category?.name ?? category?.id ?? category ?? "");
const count = Number(category?.count ?? (category?.entries ?? []).length ?? 0);
return count > 0 ? label + " · " + count : label;
}
readonly property string activeCategory: {
if (root.catalogCategory !== "")
return root.catalogCategory;
const categories = root.catalogCategories;
return categories.length > 0 ? root.categoryId(categories[0]) : "";
}
readonly property var catalogEntries: {
// Reading the category list is what makes this re-run once the catalog
// has been parsed; entriesFor() alone is a function call and would not.
const categories = root.catalogCategories;
if (categories.length === 0 || root.activeCategory === "")
return [];
return AppLibrary.entriesFor(root.activeCategory) ?? [];
}
// ── The launcher's own chord, read from the keymap ───────────────────────
// Hardcoding "Super+Space" survived two rebinds of the launcher and told
// the wrong story both times. The keymap is already loaded; this reads it.
readonly property var launcherChords: (Keybinds.binds ?? [])
.filter(bind => String(bind.description ?? "") === "Launcher")
.map(bind => String(bind.chord ?? ""))
.filter(chord => chord !== "")
// AppLibrary loads nothing on its own: `flatpak list` and the catalog cost
// a process each and only this page wants them, so a shell start where
// nobody opens Applications pays for neither.
Component.onCompleted: {
AppLibrary.refresh();
AppLibrary.refreshCatalog();
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
@@ -98,143 +280,248 @@ SettingsPage {
divider: false
}
TextRow {
visible: String(AppLibrary.lastError ?? "") !== ""
label: "The application list needs attention"
detail: String(AppLibrary.lastError ?? "")
value: ""
divider: false
}
// ── What is installed ────────────────────────────────────────────────────
SettingsCard {
title: "Installed applications"
subtitle: !AppLibrary.flatpaksLoaded
? "Reading the applications installed on this machine…"
: (AppLibrary.apps ?? []).length + " installed · "
+ AppLibrary.flatpakCount + " through Flatpak"
SearchField {
id: installedSearch
width: parent.width
placeholder: "Search installed applications"
}
Item { width: 1; height: 8 }
Repeater {
model: root.installedApps.slice(0, root.installedLimit)
delegate: InstalledAppRow {
id: installedRow
required property var modelData
required property int index
readonly property bool open: root.expandedApp === String(installedRow.modelData.entryId ?? "")
width: parent.width
app: installedRow.modelData
expanded: installedRow.open
// A write in flight, not a read: the catalog refresh this page
// starts on open must not grey out every row for a second.
busy: AppLibrary.busyEntryId !== "" || DefaultApps.busy
divider: installedRow.index
< Math.min(root.installedApps.length, root.installedLimit) - 1
autostart: root.autostartOn(installedRow.modelData)
flatsealAvailable: root.flatsealAvailable
permissionsKnown: installedRow.open && root.expandedPermissions !== null
permissionSummary: installedRow.open && root.expandedPermissions
? root.expandedPermissions.summary
: []
hasNotificationRule: root.hasNotificationRule(installedRow.modelData)
hasSoundRule: root.hasSoundRule(installedRow.modelData)
hasPrivacyRule: root.hasPrivacyRule(installedRow.modelData)
confirmingUninstall: root.pendingUninstall !== ""
&& root.pendingUninstall === String(installedRow.modelData.flatpakId ?? "")
onActivated: {
root.pendingUninstall = "";
root.expandedApp = installedRow.open
? ""
: String(installedRow.modelData.entryId ?? "");
}
onAutostartToggled: value => root.setAppAutostart(installedRow.modelData, value)
onJumped: page => ShellState.openSettings(page)
onFlatsealRequested: Quickshell.execDetached(
["flatpak", "run", "com.github.tchx84.Flatseal"])
onUninstallArmed: root.pendingUninstall = String(installedRow.modelData.flatpakId ?? "")
onUninstallCancelled: root.pendingUninstall = ""
onUninstallConfirmed: {
const flatpakId = String(installedRow.modelData.flatpakId ?? "");
root.pendingUninstall = "";
root.expandedApp = "";
AppLibrary.uninstall(flatpakId);
}
}
}
TextRow {
visible: root.installedApps.length > root.installedLimit
label: (root.installedApps.length - root.installedLimit) + " more"
detail: "Search finds any of them by name or by application id."
value: ""
divider: false
}
TextRow {
visible: (AppLibrary.apps ?? []).length > 0 && root.installedApps.length === 0
label: "Nothing matches that"
detail: "Both the application's name and its id are searched."
value: ""
divider: false
}
}
// ── What could be installed ──────────────────────────────────────────────
SettingsCard {
title: "Browse the catalog"
subtitle: "Panama's curated applications — the same list `panama apps` offers, installed the same way."
Flow {
width: parent.width
spacing: 7
Repeater {
model: root.catalogCategories
delegate: SettingsChip {
required property var modelData
text: root.categoryLabel(modelData)
active: root.categoryId(modelData) === root.activeCategory
onClicked: root.catalogCategory = root.categoryId(modelData)
}
}
}
Item { width: 1; height: 10 }
Repeater {
model: root.catalogEntries
delegate: SettingRow {
id: catalogRow
required property var modelData
required property int index
readonly property bool installed: catalogRow.modelData.installed === true
readonly property bool flatpak: String(catalogRow.modelData.kind ?? "") === "flatpak"
// `id` is the catalog line verbatim, "flatpak:" prefix and all,
// because that is what install matches. `ref` is the same thing
// with the prefix taken off, which is what a person reads.
readonly property string ref: String(catalogRow.modelData.ref ?? catalogRow.modelData.id ?? "")
width: parent.width
label: String(catalogRow.modelData.label ?? "") !== ""
? String(catalogRow.modelData.label)
: catalogRow.ref
detail: catalogRow.flatpak
? catalogRow.ref + " · Flatpak, from Flathub"
: catalogRow.ref + " · system package, so installing asks for your password"
value: catalogRow.installed ? "Installed" : ""
controlWidth: 110
divider: catalogRow.index < root.catalogEntries.length - 1
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !catalogRow.installed
// Only the row being installed says so, rather than the
// whole card greying out around one press.
text: AppLibrary.busyEntryId === String(catalogRow.modelData.id ?? "")
? "Installing…"
: "Install"
tone: "accent"
enabled: !AppLibrary.busy
onClicked: AppLibrary.install(root.activeCategory,
String(catalogRow.modelData.id ?? ""))
}
}
}
TextRow {
visible: root.catalogCategories.length === 0
label: AppLibrary.catalogLoaded
? "The catalog is empty"
: "Reading the catalog…"
detail: "It is parsed from setup/packages/extras, the same files the command line uses."
value: ""
divider: false
}
}
// ── What opens what ──────────────────────────────────────────────────────
SettingsCard {
title: "Default applications"
subtitle: "Open a row to choose from applications that advertise the matching role."
subtitle: "Each role sets its whole family of types together, so one picture never opens somewhere different from the next."
Repeater {
model: root.roles
delegate: Column {
id: roleBlock
required property var modelData
required property int index
readonly property var choices: root.choicesForRole(roleBlock.modelData)
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
width: parent.width
SettingRow {
delegate: OptionPickerRow {
id: roleRow
readonly property bool open: root.expandedRole === roleBlock.modelData.key
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
controlWidth: 210
// Drawn rather than left to SettingRow's plain value text, so
// the row carries the same chevron a PickerRow does. These
// open a chooser but looked completely inert without it.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: roleBlock.choices.length > 0
text: roleRow.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
? ""
: roleBlock.modelData.key;
}
}
Column {
width: parent.width
visible: root.expandedRole === roleBlock.modelData.key
Repeater {
model: roleBlock.choices
delegate: SettingRow {
id: candidateRow
required property var modelData
required property int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
readonly property var choices: root.choicesForRole(roleRow.modelData)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
}
width: parent.width
label: roleRow.modelData.label
detail: roleRow.modelData.detail
options: roleRow.choices.map(entry => ({
value: root.desktopId(entry),
label: root.displayName(entry),
detail: String(entry.genericName || entry.comment || root.desktopId(entry))
}))
current: root.currentHandler(roleRow.modelData.key)
enabled: roleRow.choices.length > 0 && !DefaultApps.busy
value: DefaultApps.busy ? "Loading…" : (
roleRow.currentOption
? String(roleRow.currentOption.label)
: (root.currentHandler(roleRow.modelData.key) || "Not set")
)
onPicked: value => DefaultApps.setDefault(roleRow.modelData.key, String(value))
}
}
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" }
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
value: "Super+Space"
}
// The escape hatch from the paragraph above: one type, on its own.
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
label: "One file type"
detail: root.showingFileTypes
? "Search the type database and give a single type its own application"
: "Override a single type when a role's family is too broad"
action: root.showingFileTypes ? "Close" : "Choose"
divider: root.showingFileTypes
onTriggered: {
root.showingFileTypes = !root.showingFileTypes;
if (!root.showingFileTypes)
DefaultApps.clearTypeSearch();
}
}
FileTypePicker {
visible: root.showingFileTypes
width: parent.width
matches: DefaultApps.typeMatches ?? []
busy: DefaultApps.searchingTypes
truncated: DefaultApps.typeSearchTruncated
onQueried: query => DefaultApps.searchTypes(query)
onChosen: (mime, desktopId) => DefaultApps.setType(mime, desktopId)
}
}
// ── What starts with the session ─────────────────────────────────────────
SettingsCard {
title: "User autostart"
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
title: "Autostart"
subtitle: "Entries live in your user configuration. The switch in an application's own row above writes the same files."
ActionRow {
label: "Add an application"
@@ -262,7 +549,6 @@ SettingsPage {
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
@@ -281,7 +567,6 @@ SettingsPage {
detail: autostartRow.confirming
? "Removing deletes this entry. Turning it off instead is reversible."
: autostartRow.modelData.id
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
controlWidth: 210
// A switch, not the words "Enabled"/"Disabled". The row always
@@ -320,11 +605,19 @@ SettingsPage {
}
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "These are started from the Hyprland configuration. They are read-only here."
// The compositor's own autostart, described rather than offered. These
// are started from config/dot/hypr/autostart.lua, which is where they
// are changed -- a switch here would edit a file the compositor reads
// once at launch and give no sign that nothing had happened.
TextRow {
label: "Compositor autostart"
detail: "Started from the Hyprland configuration, and read-only here."
value: DefaultApps.luaAutostartEntries.length > 0
? DefaultApps.luaAutostartEntries.length
+ (DefaultApps.luaAutostartEntries.length === 1 ? " entry" : " entries")
: ""
}
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -351,16 +644,63 @@ SettingsPage {
}
}
// ── Searching from the launcher ──────────────────────────────────────────
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" }
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: root.launcherChords.length > 1
? root.launcherChords.join(", ") + " all open it, because Super+A and Super+R"
+ " were GNOME's app grid and run dialog"
: "Rebindable from the keyboard page, like every other launcher chord"
// The keymap is the source of truth; the literal is what shows
// while it is still being read.
value: root.launcherChords.length > 0 ? root.launcherChords[0] : "Super + Space"
}
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
detail: "Re-read desktop entries, defaults, user autostart files, and the installed list"
action: DefaultApps.busy || AppLibrary.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy && !AppLibrary.busy
divider: false
onTriggered: DefaultApps.refresh()
onTriggered: {
DefaultApps.refresh();
AppLibrary.refresh();
AppLibrary.refreshCatalog();
}
}
}
}
@@ -0,0 +1,139 @@
// Overriding one file type, when the role above it covers too much.
//
// The role rows set a whole family together on purpose -- a file manager must
// not open one image in a viewer and its neighbour in an editor. That is right
// almost always and wrong exactly once: the one type somebody wants somewhere
// else. This is that escape hatch, and it is deliberately a search rather than
// a list, because the MIME database has thousands of entries and none of them
// is worth scrolling past.
//
// Nothing is written here. The page owns the service call, so a type set from
// this row goes down the same validated path as a role does.
import QtQuick
import qs.config
import qs.modules.clipboard
Column {
id: root
// [{ mime, label, extensions, handler, handlerName, candidates: [{ id, name }] }]
property var matches: []
property bool busy: false
property bool truncated: false
// The helper answers nothing shorter than this, so the row says so rather
// than looking broken while somebody types the first letter.
readonly property int minimumQuery: 2
// Emitted after the typing settles, so a query is one search rather than
// one per keystroke.
signal queried(string query)
signal chosen(string mime, string desktopId)
readonly property string query: search.text.trim()
function typeLabel(match: var): string {
const label = String(match?.label ?? "").trim();
return label !== "" ? label : String(match?.mime ?? "");
}
function typeDetail(match: var): string {
const parts = [String(match?.mime ?? "")];
const extensions = match?.extensions ?? [];
if (extensions.length > 0)
parts.push(extensions.join(" "));
const handler = String(match?.handlerName ?? match?.handler ?? "").trim();
parts.push(handler === "" ? "nothing opens it yet" : "opens with " + handler);
return parts.join(" · ");
}
function optionsFor(match: var): var {
return (match?.candidates ?? []).map(candidate => ({
value: String(candidate.id ?? ""),
label: String(candidate.name ?? candidate.id ?? ""),
detail: String(candidate.id ?? "")
}));
}
width: parent ? parent.width : 620
spacing: 0
SearchField {
id: search
width: parent.width
placeholder: "png, pdf, video/… — the type or its extension"
onTextChanged: settle.restart()
}
Timer {
id: settle
interval: 250
onTriggered: root.queried(search.text.trim())
}
Item { width: 1; height: 8 }
TextRow {
width: parent.width
visible: root.query.length < root.minimumQuery
label: "Type what you are looking for"
detail: "The type and its file extensions are both searched, so \"svg\" and"
+ " \"image/svg+xml\" land on the same row. Two letters is the shortest"
+ " search the type database answers."
value: ""
divider: false
}
TextRow {
width: parent.width
visible: root.busy && root.query.length >= root.minimumQuery
label: "Searching the type database…"
detail: "Reading which applications advertise each type"
value: ""
divider: false
}
Repeater {
model: root.busy ? [] : root.matches
delegate: OptionPickerRow {
id: typeRow
required property var modelData
required property int index
width: parent.width
label: root.typeLabel(typeRow.modelData)
detail: root.typeDetail(typeRow.modelData)
options: root.optionsFor(typeRow.modelData)
current: String(typeRow.modelData.handler ?? "")
enabled: typeRow.options.length > 0
divider: typeRow.index < root.matches.length - 1
value: typeRow.currentOption
? String(typeRow.currentOption.label)
: (typeRow.options.length === 0 ? "Nothing offers it" : "Not set")
onPicked: value => root.chosen(String(typeRow.modelData.mime ?? ""), String(value))
}
}
TextRow {
width: parent.width
visible: !root.busy && root.truncated
label: "Only the closest matches are shown"
detail: "Narrow the search to reach the rest."
value: ""
divider: false
}
TextRow {
width: parent.width
visible: !root.busy && root.query.length >= root.minimumQuery
&& root.matches.length === 0
label: "No file type matches that"
detail: "Only types this system knows about can be given a handler."
value: ""
divider: false
}
}
@@ -0,0 +1,402 @@
// One installed application: a line you can read, unfolding in place into
// everything this desktop can honestly say about it.
//
// Built like NotificationAppRow rather than out of SettingRow, for the same
// reason -- the head carries the application's own icon and a badge saying
// where it came from, and neither fits a row whose trailing slot is one fixed
// width. What is different here is that the body's contents depend on where the
// application came from: a Flatpak can be inspected and removed, a system
// package can only be described.
//
// Nothing in here writes. Every control leaves through a signal so the page
// owns the service calls, which is what keeps the uninstall confirmation a
// single piece of page state rather than something each row remembers for
// itself.
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.config
Column {
id: root
// { entryId, name, icon, kind: "flatpak"|"system", flatpakId, size }
required property var app
property bool expanded: false
property bool divider: true
property bool busy: false
// Whether ~/.config/autostart currently starts this application.
property bool autostart: false
// The helper's curated permission summary, as a list of human strings.
// Empty while it is still being read, which the row says rather than
// showing a blank line.
property var permissionSummary: []
property bool permissionsKnown: false
property bool flatsealAvailable: false
// Jump chips appear only where a rule for this application actually
// exists somewhere else. A chip that lands on a page with nothing about
// this application on it is worse than no chip.
property bool hasNotificationRule: false
property bool hasSoundRule: false
property bool hasPrivacyRule: false
// Set by the page while this row's uninstall is one press from happening.
property bool confirmingUninstall: false
signal activated
signal autostartToggled(bool value)
signal jumped(string page)
signal flatsealRequested
signal uninstallArmed
signal uninstallCancelled
signal uninstallConfirmed
readonly property string appName: String(root.app?.name ?? "")
readonly property string entryId: String(root.app?.entryId ?? "")
readonly property string flatpakId: String(root.app?.flatpakId ?? "")
readonly property bool flatpak: String(root.app?.kind ?? "") === "flatpak"
// The dnf package name, for the refusal row's exact command. The desktop
// entry id is the closest thing to it this desktop can know without asking
// rpm about every application on the machine, so the row says "the package
// that provides" rather than claiming the name is the package.
readonly property string systemName: {
const id = root.entryId.replace(/\.desktop$/, "");
const short = id.split(".").pop();
return short !== "" ? short : id;
}
// `sizeBytes` when the helper could turn flatpak's answer into a number and
// `size` -- flatpak's own "412.5 MB" -- when it could not. Whichever
// arrived is shown as it arrived, rather than one being coerced into the
// other and rounded to nothing.
readonly property string subtitle: {
if (!root.flatpak)
return "Installed by the system package manager";
const bytes = Number(root.app?.sizeBytes ?? 0);
if (Number.isFinite(bytes) && bytes > 0)
return root.flatpakId + " · " + root.formatBytes(bytes);
const text = String(root.app?.size ?? "").trim();
return text === "" || text === "0" ? root.flatpakId : root.flatpakId + " · " + text;
}
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;
}
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
}
readonly property string iconSource: {
const name = String(root.app?.icon ?? "");
return name === "" ? "" : Quickshell.iconPath(name, true);
}
// A stable colour per application, so the tile keeps its identity as the
// list re-sorts or the search narrows it.
readonly property color tileColor: {
const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan,
Theme.green, Theme.orange, Theme.pink];
let hash = 0;
for (let index = 0; index < root.entryId.length; index++)
hash = (hash * 31 + root.entryId.charCodeAt(index)) % 9973;
return palette[hash % palette.length];
}
width: parent ? parent.width : 620
spacing: 0
Item {
id: head
width: parent.width
implicitHeight: Math.max(56, copy.implicitHeight + 20)
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 9
z: -1
visible: headHover.hovered
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Rectangle {
id: tile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 8
color: root.iconSource === "" ? root.tileColor : "transparent"
border.width: 0
Text {
anchors.centerIn: parent
visible: root.iconSource === ""
text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?"
color: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Bold
}
IconImage {
anchors.centerIn: parent
implicitSize: 24
asynchronous: true
visible: root.iconSource !== ""
source: root.iconSource
}
}
Column {
id: copy
anchors.left: tile.right
anchors.leftMargin: 12
anchors.right: chevron.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
width: parent.width
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, parent.width - badge.width - 8))
text: root.appName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
// Where it came from, said once and in the same place on every
// row -- the difference decides what the body can offer.
Rectangle {
id: badge
anchors.verticalCenter: parent.verticalCenter
width: badgeText.implicitWidth + 14
height: 17
radius: Theme.pillRadius
color: root.flatpak
? Theme.alpha(Theme.cyan, 0.09)
: Theme.alpha(Theme.fgMuted, 0.12)
border.width: 1
border.color: root.flatpak
? Theme.alpha(Theme.cyan, 0.28)
: Theme.alpha(Theme.fgMuted, 0.3)
Text {
id: badgeText
anchors.centerIn: parent
text: root.flatpak ? "FLATPAK" : "SYSTEM"
color: root.flatpak ? Theme.cyan : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
}
}
}
Text {
width: parent.width
text: root.subtitle
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
anchors.left: copy.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider && !root.expanded
color: Theme.alpha(Theme.fg, 0.065)
}
HoverHandler {
id: headHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.activated()
}
}
// Indented under the head, so what is inside reads as belonging to the
// application above it rather than to the card.
Column {
id: body
x: 42
width: Math.max(0, parent.width - 42)
visible: root.expanded
// ── What it can reach ────────────────────────────────────────────────
SettingRow {
width: parent.width
visible: root.flatpak
label: "Permissions"
detail: !root.permissionsKnown
? "Reading what this application is allowed to reach…"
: (root.permissionSummary.length > 0
? root.permissionSummary.join(" · ")
: "Nothing beyond its own sandbox")
controlWidth: root.flatsealAvailable ? 130 : 0
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.flatsealAvailable
text: "Open Flatseal"
onClicked: root.flatsealRequested()
}
}
// The honest refusal, with the command that does the thing this page
// will not. Removing a system package can take the desktop with it, so
// it happens where the consequences are visible.
TextRow {
width: parent.width
visible: !root.flatpak
label: "Managed by dnf"
detail: "Settings does not remove system packages — removing one can take the desktop"
+ " with it. To remove the package that provides this: sudo dnf remove "
+ root.systemName
value: ""
}
// ── What it does at sign-in ──────────────────────────────────────────
SettingRow {
width: parent.width
label: "Start with the session"
detail: root.autostart
? "An entry in ~/.config/autostart starts this when you sign in"
: "Nothing starts this automatically"
controlWidth: 48
divider: root.flatpak || root.hasNotificationRule
|| root.hasSoundRule || root.hasPrivacyRule
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.autostart
onToggled: value => root.autostartToggled(value)
}
}
// ── Where its other settings live ────────────────────────────────────
SettingRow {
width: parent.width
visible: root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule
label: "Elsewhere in Settings"
detail: "This application already has a rule on these pages"
controlWidth: 270
divider: root.flatpak
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasNotificationRule
text: "Notifications"
onClicked: root.jumped("notifications")
}
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasSoundRule
text: "Sound"
onClicked: root.jumped("sound")
}
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasPrivacyRule
text: "Privacy"
onClicked: root.jumped("privacy")
}
}
}
// ── Removing it ──────────────────────────────────────────────────────
SettingRow {
width: parent.width
visible: root.flatpak
label: "Uninstall"
detail: root.confirmingUninstall
? "This removes the application. Data it kept under ~/.var/app stays behind."
: "Removes the application, and its runtime if nothing else needs it"
controlWidth: 210
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.confirmingUninstall
text: "Uninstall it"
tone: "danger"
enabled: !root.busy
onClicked: root.uninstallConfirmed()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.confirmingUninstall ? "Keep" : "Uninstall…"
enabled: !root.busy
onClicked: root.confirmingUninstall
? root.uninstallCancelled()
: root.uninstallArmed()
}
}
}
Item { width: 1; height: 6 }
}
}
@@ -0,0 +1,64 @@
// A pill-shaped button, for the places a row's trailing slot needs several
// small targets rather than one.
//
// Two of them exist on the Applications page and they want the same shape: the
// catalog's category filter, where one chip is active and the rest are not, and
// the per-application "elsewhere in Settings" links, where none is. `active`
// covers the first case; leaving it false gives the second.
//
// SettingsButton stays the control for a row's primary action -- it is taller,
// squarer, and reads as a button. A chip reads as a tag you can press.
import QtQuick
import qs.config
Rectangle {
id: root
property string text: ""
property bool active: false
signal clicked
implicitWidth: caption.implicitWidth + 22
implicitHeight: 26
radius: Theme.pillRadius
opacity: root.enabled ? 1 : 0.45
color: root.active
? Theme.alpha(Theme.accent, 0.14)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.12 : 0.055)
border.width: root.activeFocus || root.active ? 2 : 1
border.color: root.activeFocus
? Theme.accentSecondary
: (root.active ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.1))
activeFocusOnTab: root.enabled
Accessible.role: Accessible.Button
Accessible.name: root.text
Accessible.focusable: root.enabled
Accessible.focused: root.activeFocus
Accessible.onPressAction: root.clicked()
Keys.onReturnPressed: root.clicked()
Keys.onSpacePressed: root.clicked()
Text {
id: caption
anchors.centerIn: parent
text: root.text
color: root.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.clicked()
}
}
@@ -8,9 +8,16 @@
// Inside a volume, the timeline is the Time Machine view: points in time,
// newest first, each one openable as a folder tree you can take a file out of.
//
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
// it -- so it would report success and change nothing after a reboot.
// The browser is its own card, not a section inside a volume card. It used to
// live inside, behind `volumeCard.open` -- so opening a point in time from a
// collapsed card set the browsing state, drew nothing, and hid the timeline as
// well. Whether a volume's older points are showing has nothing to do with
// whether you are looking inside one of them.
//
// Going back to a previous state of the whole machine is deliberately absent.
// snapper's version of it changes the btrfs default subvolume, and this
// system's fstab pins subvol= explicitly, which overrides it -- so it would
// report success and change nothing after a reboot.
import Quickshell
import QtQuick
@@ -34,7 +41,38 @@ SettingsPage {
property string confirmingDelete: ""
property string confirmingRestore: ""
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
// The service's own answer, kept under the page's name so every `visible:`
// below reads as "because something is being browsed" and never as
// "because a volume card happens to be expanded".
readonly property bool browsingOpen: Snapshots.browserOpen
readonly property var browsingConfigRecord: Snapshots.configs.find(
config => String(config.name ?? "") === Snapshots.browsingConfig) ?? null
// How many of each horizon the timeline keeps. A subset of the 0-50 the
// service accepts, because a dropdown of fifty-one numbers is a list, not a
// choice -- and whatever the configuration is actually set to is added to
// it, so a snapper default of 24 hourly is a row you can see rather than a
// value the picker cannot display.
readonly property var retentionCounts: [0, 1, 2, 3, 5, 7, 10, 14, 21, 30, 50]
function retentionOptions(unit: string, current: int): var {
const counts = root.retentionCounts.slice();
if (counts.indexOf(current) < 0 && current >= 0 && current <= Snapshots.retentionMax)
counts.push(current);
counts.sort((left, right) => left - right);
return counts.map(count => ({
value: count,
label: count === 0 ? "None kept" : count + " " + unit,
detail: count === 0
? "Nothing is kept on this horizon"
: ""
}));
}
function retentionLimit(config: var, horizon: string): int {
return Number((config?.limits ?? ({}))[horizon] ?? 0);
}
Component.onCompleted: Snapshots.refresh()
@@ -96,6 +134,8 @@ SettingsPage {
readonly property string configName: String(volumeCard.modelData.name ?? "")
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
readonly property bool open: root.openConfig === volumeCard.configName
readonly property bool editable: !Snapshots.busy
&& volumeCard.modelData.readable === true
readonly property int shownCount: volumeCard.open
? volumeCard.snapshots.length
: Math.min(root.previewCount, volumeCard.snapshots.length)
@@ -109,21 +149,75 @@ SettingsPage {
? Snapshots.describe(volumeCard.modelData)
: "Nothing is being taken for this volume"
checked: volumeCard.modelData.timelineEnabled === true
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
enabled: volumeCard.editable
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
}
// The three horizons, editable. This was a read-only summary of
// numbers only snapper's config file could change, sitting one
// line above a service function nothing ever called.
TextRow {
label: "Keep"
detail: "Older points are removed automatically once these counts are exceeded"
value: Snapshots.retentionSummary(volumeCard.modelData)
}
OptionPickerRow {
id: hourlyRow
width: parent.width
label: "Hourly"
detail: "Points taken on the hour, and the first to be removed"
options: root.retentionOptions(
"hourly", root.retentionLimit(volumeCard.modelData, "hourly"))
current: root.retentionLimit(volumeCard.modelData, "hourly")
enabled: volumeCard.editable
onPicked: value => Snapshots.setRetention(
volumeCard.configName,
Number(value),
root.retentionLimit(volumeCard.modelData, "daily"),
root.retentionLimit(volumeCard.modelData, "weekly"))
}
OptionPickerRow {
id: dailyRow
width: parent.width
label: "Daily"
detail: "One point kept per day, for this many days"
options: root.retentionOptions(
"daily", root.retentionLimit(volumeCard.modelData, "daily"))
current: root.retentionLimit(volumeCard.modelData, "daily")
enabled: volumeCard.editable
onPicked: value => Snapshots.setRetention(
volumeCard.configName,
root.retentionLimit(volumeCard.modelData, "hourly"),
Number(value),
root.retentionLimit(volumeCard.modelData, "weekly"))
}
OptionPickerRow {
id: weeklyRow
width: parent.width
label: "Weekly"
detail: "One point kept per week, for this many weeks — the longest reach back"
options: root.retentionOptions(
"weekly", root.retentionLimit(volumeCard.modelData, "weekly"))
current: root.retentionLimit(volumeCard.modelData, "weekly")
enabled: volumeCard.editable
onPicked: value => Snapshots.setRetention(
volumeCard.configName,
root.retentionLimit(volumeCard.modelData, "hourly"),
root.retentionLimit(volumeCard.modelData, "daily"),
Number(value))
}
ActionRow {
label: "Take one now"
detail: "Kept until you remove it, unlike the automatic ones"
action: "Take snapshot"
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
enabled: volumeCard.editable
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
}
@@ -139,7 +233,6 @@ SettingsPage {
Column {
width: parent.width
visible: !root.browsingOpen
Repeater {
model: volumeCard.open
@@ -154,6 +247,9 @@ SettingsPage {
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
readonly property bool confirming: root.confirmingDelete === pointRow.token
readonly property bool browsingThis: root.browsingOpen
&& Snapshots.browsingConfig === volumeCard.configName
&& Snapshots.browsingSnapshot === Number(pointRow.modelData.number)
width: parent.width
// A kept snapshot is one the timeline will not remove,
@@ -164,6 +260,7 @@ SettingsPage {
detail: String(pointRow.modelData.description ?? "")
+ " · #" + pointRow.modelData.number
+ (pointRow.modelData.kept ? " · kept" : "")
+ (pointRow.browsingThis ? " · open below" : "")
controlWidth: 250
divider: pointRow.index < volumeCard.shownCount - 1
@@ -173,7 +270,7 @@ SettingsPage {
spacing: 8
SettingsButton {
text: "Open"
text: pointRow.browsingThis ? "Open again" : "Open"
enabled: !Snapshots.browsing
onClicked: Snapshots.browse(volumeCard.configName,
Number(pointRow.modelData.number), "")
@@ -224,28 +321,38 @@ SettingsPage {
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE"
text: volumeCard.open ? "Show fewer " : "Show all "
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
// ── Inside one point in time ─────────────────────────────────────
// ── Inside one point in time ─────────────────────────────────────────────
//
// Its own card, rendered on the browsing state alone. Nothing about which
// volume card happens to be expanded can hide it.
Column {
width: parent.width
visible: volumeCard.open && root.browsingOpen
&& Snapshots.browsingConfig === volumeCard.configName
SettingsCard {
visible: root.browsingOpen
title: "Browsing " + (root.browsingConfigRecord
? Snapshots.labelFor(root.browsingConfigRecord)
: Snapshots.browsingConfig)
+ " · #" + Snapshots.browsingSnapshot
subtitle: Snapshots.browsingPath === ""
? "The top of that point in time."
: "Inside " + Snapshots.browsingPath + "."
ActionRow {
width: parent.width
label: Snapshots.browsingPath === ""
? "Snapshot #" + Snapshots.browsingSnapshot
? "Close this point in time"
: "…/" + Snapshots.browsingPath
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
action: "Back"
action: Snapshots.browsingPath === "" ? "Close" : "Back"
enabled: !Snapshots.browsing
onTriggered: Snapshots.browseUp()
}
@@ -256,6 +363,7 @@ SettingsPage {
label: "Reading the snapshot…"
detail: "Listing a folder from a point in time"
value: ""
divider: false
}
Repeater {
@@ -336,8 +444,6 @@ SettingsPage {
divider: false
}
}
}
}
// ── What it costs ────────────────────────────────────────────────────────
@@ -0,0 +1,153 @@
// What is on the disk, drawn as the disk.
//
// One bar whose segments add up to the whole filesystem, because the question
// people bring to this page is "what is taking my space" and the only answer
// that cannot mislead is one where the parts sum to the total. Four of the five
// segments are measured; the fifth is what is left over, and it is labelled as
// exactly that rather than being called "System" and quietly absorbing every
// measurement error in the other four.
//
// The legend carries the numbers. A bar this wide can hold a 2% segment but it
// cannot label one, and a segment nobody can name is decoration.
import QtQuick
import qs.config
import qs.services
Column {
id: root
readonly property var breakdown: Disks.breakdown ?? null
readonly property var segments: {
const source = root.breakdown?.segments ?? null;
if (!source)
return [];
return [
{
key: "home",
label: "Home",
detail: "Your files, not counting caches",
bytes: Number(source.home ?? 0),
color: Theme.accent
},
{
key: "applications",
label: "Applications",
detail: "Flatpak applications and the runtimes they share",
bytes: Number(source.applications ?? 0),
color: Theme.teal
},
{
key: "caches",
label: "Caches",
detail: "~/.cache, rebuilt as applications run",
bytes: Number(source.caches ?? 0),
color: Theme.warn
},
{
key: "system",
label: "System & everything else",
detail: "Whatever the measured segments do not account for",
bytes: Number(source.system ?? 0),
color: Theme.magenta
},
{
key: "free",
label: "Free",
detail: "Reported by the filesystem",
bytes: Number(source.free ?? 0),
color: Theme.alpha(Theme.fg, 0.1)
}
];
}
readonly property real total: root.segments.reduce(
(sum, segment) => sum + Math.max(0, segment.bytes), 0)
width: parent ? parent.width : 620
spacing: 10
Text {
width: parent.width
visible: root.total <= 0
text: Disks.measuringBreakdown
? "Measuring what is on this disk…"
: (Disks.lastError !== "" ? Disks.lastError : "Not measured yet.")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
visible: root.total > 0
height: 26
radius: 8
clip: true
color: Theme.alpha(Theme.fg, 0.06)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
Row {
anchors.fill: parent
anchors.margins: 1
spacing: 0
Repeater {
model: root.segments
delegate: Rectangle {
required property var modelData
// A measured segment always gets a sliver, so "1 GB of
// caches" is visible as a fact rather than rounded away.
width: root.total > 0 && modelData.bytes > 0
? Math.max(parent.width * (modelData.bytes / root.total), 2)
: 0
height: parent.height
color: modelData.color
border.width: 0
}
}
}
}
Flow {
width: parent.width
visible: root.total > 0
spacing: 14
Repeater {
model: root.segments
delegate: Row {
required property var modelData
spacing: 6
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 9
height: 9
radius: 3
color: modelData.key === "free"
? Theme.alpha(Theme.fg, 0.25)
: modelData.color
border.width: 0
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.label + " " + Disks.formatBytes(modelData.bytes)
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
@@ -9,6 +9,13 @@
// 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".
//
// The cleanup card has rules, and they are the whole reason it can exist at
// all. Every row names what it is and what is lost by clearing it. Every row
// carries its size. Nothing is selected in advance, nothing is recommended,
// nothing counts down, and a row with nothing in it says so and offers no
// button. A "clean up your PC" panel that nags is a racket; this one is a list
// of facts with a button next to each.
//
// Partitioning and formatting are deliberately absent; GNOME Disks is one row
// away for that.
@@ -22,19 +29,25 @@ SettingsPage {
objectName: "storage"
title: "Storage"
lede: "How much space is left, what is using it, and whether the drive is healthy."
lede: root.rootFs
? Disks.formatBytes(root.rootFs.usedBytes) + " used of "
+ Disks.formatBytes(root.rootFs.sizeBytes) + " — "
+ Disks.formatBytes(root.rootFs.availBytes) + " free."
: "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;
}
// Clearing something deletes it, so it never happens on a first press.
// Holds the id of the cleanable that is one press away from running.
property string pendingClean: ""
// Folder bars are drawn against the DISK, not against the largest folder.
// Against the largest, the top row is always full and the bar says nothing
// except which row is biggest -- which the sizes beside them already say.
// Against the disk, the bar means the same thing as the free-space bar
// above it, and a 212 GB folder on a 2 TB disk looks like what it is.
readonly property real diskBytes: Number(root.rootFs?.sizeBytes ?? 0)
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
@@ -51,12 +64,74 @@ SettingsPage {
// ── Space ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Free space"
title: "What is on this disk"
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."
// Measuring the segments walks the same folders the Folders card walks,
// so it costs the same and is asked for the same way.
ActionRow {
visible: !Disks.breakdownMeasured || Disks.measuringBreakdown
label: Disks.measuringBreakdown ? "Measuring…" : "Measure what is on this disk"
detail: Disks.measuringBreakdown
? "Walking your home folder, the flatpak installations, and the cache."
: "Three of the four segments are measured by walking folders, which takes a minute."
action: "Measure"
enabled: !Disks.measuringBreakdown
divider: false
onTriggered: Disks.measureBreakdown()
}
StorageBreakdownBar {
visible: Disks.breakdownMeasured
width: parent.width
}
// The caption belongs to the bar's arithmetic, so it lives with the
// card rather than inside the component: home, applications and caches
// are measured, and the fourth segment is what is left of the used
// space once those three are subtracted. Calling that "System" would
// blame the desktop for the user's own unscanned files.
Text {
width: parent.width
visible: Disks.breakdownMeasured
topPadding: 12
text: "Home, applications and caches are measured. \"System & everything else\" is"
+ " the remainder — the packages this machine runs on, plus anything those"
+ " three measurements did not reach."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// What the walk could not finish, said rather than absorbed into the
// remainder without comment.
TextRow {
visible: Disks.breakdownMeasured && Disks.breakdown
&& Disks.breakdown.complete === false
label: "The walk ran out of time"
detail: "Home, applications and caches are floors rather than totals, so the remainder is larger than it should be."
value: ""
divider: false
}
TextRow {
visible: Disks.breakdownMeasured && Disks.breakdown
&& Disks.breakdown.exceedsUsed === true
label: "The measured parts overlap"
detail: "They add up to more than the used space, which means something was counted twice. The remainder is shown as zero rather than as a negative number."
value: ""
divider: false
}
}
SettingsCard {
title: "Free space"
subtitle: "The number the rest of this page is about."
Column {
width: parent.width
spacing: 10
@@ -118,9 +193,9 @@ SettingsPage {
}
SettingsCard {
title: "What is using it"
title: "Folders"
subtitle: Disks.foldersMeasured
? "Measured by walking each folder."
? "Measured by walking each folder. Bars are drawn against the whole disk, so they mean the same thing as the free-space bar above."
: "Measuring means walking every file, so it is not done automatically."
ActionRow {
@@ -160,12 +235,9 @@ SettingsPage {
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)
width: root.diskBytes > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.diskBytes), 2)
: 0
height: parent.height
radius: parent.radius
@@ -182,26 +254,98 @@ SettingsPage {
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
divider: false
}
}
// ── Reclaiming space, without the racket ─────────────────────────────────
SettingsCard {
title: "Clean up, honestly"
subtitle: "Each row says what it is and what clearing it costs. Nothing here is chosen for you, and nothing on this page needs doing."
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"
visible: !Disks.cleanablesMeasured || Disks.measuringCleanables
label: Disks.measuringCleanables ? "Measuring…" : "Measure what could be cleared"
detail: "Measuring only counts bytes. Every row below is cleared by its own button, one at a time."
action: "Measure"
enabled: !Disks.measuringCleanables
divider: Disks.cleanablesMeasured
onTriggered: Disks.measureCleanables()
}
Repeater {
model: Disks.cleanables ?? []
delegate: SettingRow {
id: cleanRow
required property var modelData
required property int index
readonly property string cleanId: String(cleanRow.modelData.id ?? "")
readonly property real bytes: Number(cleanRow.modelData.bytes ?? 0)
readonly property bool empty: cleanRow.bytes <= 0
readonly property bool privileged: cleanRow.modelData.privileged === true
readonly property bool confirming: root.pendingClean !== ""
&& root.pendingClean === cleanRow.cleanId
width: parent.width
label: String(cleanRow.modelData.label ?? "")
detail: cleanRow.empty
? String(cleanRow.modelData.detail ?? "") + " · empty, nothing to do"
: (cleanRow.confirming
? "This deletes it now. Nothing here is recoverable from the trash afterwards."
: String(cleanRow.modelData.detail ?? "")
+ (cleanRow.privileged ? " · the system will ask for your password" : ""))
value: cleanRow.empty ? Disks.formatBytes(0) : ""
controlWidth: cleanRow.empty ? 90 : 210
divider: cleanRow.index < (Disks.cleanables ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: cleanRow.bytes > 0
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: Disks.formatBytes(cleanRow.bytes)
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: cleanRow.confirming
text: "Clear it"
tone: "danger"
enabled: Disks.cleaningId === ""
onClicked: {
root.pendingClean = "";
Disks.clean(cleanRow.cleanId);
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: cleanRow.confirming ? "Keep" : "Clear…"
enabled: Disks.cleaningId === ""
onClicked: root.pendingClean =
cleanRow.confirming ? "" : cleanRow.cleanId
}
}
}
}
TextRow {
visible: Disks.cleanablesMeasured && (Disks.cleanables ?? []).length === 0
label: "Nothing itemized"
detail: "Caches, the trash, unused Flatpak runtimes and the package download cache were all measured and none of them holds anything."
value: ""
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"])
}
}
@@ -281,6 +425,11 @@ SettingsPage {
// Named here because this is the page somebody opens when space is short,
// and container images are usually the largest thing nobody remembers
// having. Only shown when there is actually something to reclaim.
//
// This is the ONLY container affordance on the page. There used to be a
// second one above that opened a terminal running `podman system df`, which
// meant two rows about the same gigabytes leading two different places --
// and the terminal one could not actually reclaim anything.
SettingsCard {
visible: Containers.available && Containers.reclaimable > 0
title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable)
@@ -63,6 +63,10 @@ WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
InstalledAppRow 1.0 InstalledAppRow.qml
FileTypePicker 1.0 FileTypePicker.qml
SettingsChip 1.0 SettingsChip.qml
StorageBreakdownBar 1.0 StorageBreakdownBar.qml
DockPinsStrip 1.0 DockPinsStrip.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
+583
View File
@@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""What is installed, what Panama offers to install, and what a flatpak may do.
Three boundaries, deliberately separate:
flatpaks / permissions read-only questions about what is already here.
catalog the same optional-application catalog `panama apps`
and ./install read, parsed by the same rules.
install / uninstall mutations, each one validated against the catalog or
against what flatpak reports as installed.
Deliberately absent: removing dnf packages. A settings page that uninstalls
system packages is one mis-click away from removing the compositor it is drawn
by, and dnf's dependency resolution will happily take half the desktop with it.
The Applications page says "installed by the system package manager" and names
the command instead. There is no dnf removal path anywhere in this file, and the
contract checks for one.
The catalog is a closed surface: `install` refuses any entry that is not in
setup/packages/extras, so this helper can never become a way to install an
arbitrary package by passing a different string.
panama-applications flatpaks
panama-applications permissions APP_ID
panama-applications uninstall APP_ID
panama-applications unused-runtimes
panama-applications clean-unused
panama-applications catalog
panama-applications install CATEGORY ENTRY_ID
"""
from __future__ import annotations
import configparser
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
# Flathub ids, dnf package names, and category file names. Everything that
# reaches a command line is matched against one of these first.
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$")
PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
CATEGORY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
# flatpak prints sizes for people, not for programs: there is no machine-readable
# column for the installed size in any released flatpak. The display string is
# kept as-is for the page, and the parsed number is only ever used for the
# storage breakdown, which says out loud that it is flatpak's own rounded figure.
SIZE_UNITS = {
"b": 1, "byte": 1, "bytes": 1,
"kb": 10**3, "mb": 10**6, "gb": 10**9, "tb": 10**12,
"kib": 1024, "mib": 1024**2, "gib": 1024**3, "tib": 1024**4,
}
SIZE = re.compile(r"^\s*([0-9]+(?:[.,][0-9]+)?)\s*([A-Za-z]+)\s*$")
# The remote every catalog flatpak comes from. install-packages adds it during
# setup; naming it here keeps a machine with several remotes from resolving an
# id against whichever one happens to be first.
FLATHUB = "flathub"
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except subprocess.TimeoutExpired as error:
raise BoundaryError(f"{command[0]} did not answer in time.") from error
except OSError as error:
raise BoundaryError(f"{command[0]} is not available.") from error
def require_flatpak() -> None:
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed on this machine.")
def parse_size(text: str) -> int | None:
"""flatpak's own size string as bytes, or nothing when it cannot be read.
Nothing is not zero. A size this cannot parse is reported as unmeasured so
the storage breakdown can say so, rather than quietly shrinking the
applications segment and inflating the remainder.
"""
match = SIZE.match(text or "")
if not match:
return None
number, unit = match.groups()
factor = SIZE_UNITS.get(unit.lower())
if factor is None:
return None
try:
return int(float(number.replace(",", ".")) * factor)
except ValueError:
return None
def flatpak_rows(scope: list[str], columns: list[str]) -> list[list[str]]:
"""Tab-separated `flatpak list` output as rows, or nothing when it fails."""
result = run(["flatpak", "list", *scope, "--columns=" + ",".join(columns)])
if result.returncode != 0:
return []
rows = []
for line in result.stdout.splitlines():
if not line.strip():
continue
fields = line.split("\t")
# A column flatpak could not fill comes back empty rather than missing,
# but a future flatpak that drops one should not throw an IndexError
# across the whole page.
fields += [""] * (len(columns) - len(fields))
rows.append(fields[:len(columns)])
return rows
def flatpaks() -> list[dict]:
require_flatpak()
entries = []
for app_id, name, size, origin in flatpak_rows(
["--app"], ["application", "name", "size", "origin"]):
entries.append({
"id": app_id,
"name": name or app_id,
"size": size,
"sizeBytes": parse_size(size),
"origin": origin,
})
entries.sort(key=lambda entry: (entry["name"].casefold(), entry["id"]))
return entries
# ── Permissions ──────────────────────────────────────────────────────────────
#
# A flatpak's sandbox is described by a handful of keys whose values are
# semicolon-separated tokens. The buckets below are the questions people
# actually ask -- can it see my camera, can it read my files -- and everything
# that does not land in a bucket still gets a line. A permission summary that
# silently omits what it did not recognise is worse than no summary: it reads as
# "this app asks for nothing else".
CONTEXT_KEYS = ("shared", "sockets", "devices", "filesystems", "features", "persistent")
HOST_FILESYSTEMS = {"host", "host-os", "host-etc", "/"}
HOME_FILESYSTEMS = {"home", "~", "~/"}
DEVICE_LABELS = {
"all": "every device",
"dri": "graphics acceleration",
"kvm": "virtual machines",
"shm": "shared memory",
"input": "input devices",
"usb": "USB devices",
}
SOCKET_LABELS = {
"wayland": "Wayland",
"x11": "X11",
"fallback-x11": "X11 when Wayland is unavailable",
"session-bus": "the whole session bus",
"system-bus": "the whole system bus",
"ssh-auth": "your SSH agent",
"gpg-agent": "your GPG agent",
"cups": "printing",
"pcsc": "smart cards",
"inherit-wayland-socket": "an inherited Wayland socket",
}
def permission_sections(text: str) -> dict[str, dict[str, str]]:
parser = configparser.RawConfigParser(strict=False)
# Keys are lower-case already in the [Context] section, but bus names are
# not, and lower-casing org.gnome.Software would make the raw payload lie.
parser.optionxform = str
try:
parser.read_string(text)
except configparser.Error as error:
raise BoundaryError("That application's permissions could not be read.") from error
return {section: dict(parser.items(section)) for section in parser.sections()}
def tokens(value: str) -> list[str]:
return [item.strip() for item in (value or "").split(";") if item.strip()]
def base_path(entry: str) -> str:
"""`xdg-download:create` names the path `xdg-download`."""
return entry.split(":", 1)[0]
def join_some(items: list[str], limit: int = 4) -> str:
if len(items) <= limit:
return ", ".join(items)
return ", ".join(items[:limit]) + f" and {len(items) - limit} more"
def permission_summary(sections: dict[str, dict[str, str]]) -> list[str]:
context = sections.get("Context", {})
shared = tokens(context.get("shared", ""))
sockets = tokens(context.get("sockets", ""))
devices = tokens(context.get("devices", ""))
filesystems = tokens(context.get("filesystems", ""))
summary: list[str] = []
host = [item for item in filesystems if base_path(item) in HOST_FILESYSTEMS]
home = [item for item in filesystems if base_path(item) in HOME_FILESYSTEMS]
# Camera. Flatpak has no camera token: a webcam is reached through
# `devices=all`, so that is what this reports, and it says why.
if "all" in devices:
summary.append("Camera — full device access reaches webcams")
# Microphone. Likewise, audio is one permission in both directions.
if "pulseaudio" in sockets:
summary.append("Microphone — audio access records as well as plays")
if host:
summary.append("Full file system access")
if home:
summary.append("Home folder")
if "network" in shared:
summary.append("Network")
if devices:
summary.append("Devices: " + join_some(
[DEVICE_LABELS.get(item, item) for item in devices]))
# Everything the buckets did not claim, said plainly rather than dropped.
rest_shared = [item for item in shared if item != "network"]
if rest_shared:
summary.append("Also shares: " + join_some(rest_shared))
rest_sockets = [item for item in sockets if item != "pulseaudio"]
if rest_sockets:
# Sorted by the words shown rather than by flatpak's order, so
# "X11 when Wayland is unavailable" follows "X11" instead of leading it.
summary.append("Talks to " + join_some(
sorted(SOCKET_LABELS.get(item, item) for item in rest_sockets)))
rest_files = [item for item in filesystems if item not in host and item not in home]
if rest_files:
summary.append("Other locations: " + join_some(rest_files))
features = tokens(context.get("features", ""))
if features:
summary.append("Sandbox features: " + join_some(features))
persistent = tokens(context.get("persistent", ""))
if persistent:
summary.append("Keeps files in " + join_some(persistent))
for key, value in context.items():
if key in CONTEXT_KEYS:
continue
items = tokens(value)
summary.append(f"Also requests {key}: " + (join_some(items) if items else str(value)))
for name, entries in sections.items():
if name == "Context" or not entries:
continue
count = len(entries)
thing = "setting" if count == 1 else "settings"
summary.append(f"{name}: {count} {thing}")
if not summary:
summary.append("Nothing beyond the sandbox defaults")
return summary
def permissions(app_id: str) -> dict:
require_flatpak()
if not APP_ID.fullmatch(app_id or ""):
raise BoundaryError("That is not an application id.")
result = run(["flatpak", "info", "--show-permissions", app_id])
if result.returncode != 0:
raise BoundaryError("That application is not installed.")
sections = permission_sections(result.stdout)
return {"id": app_id, "summary": permission_summary(sections), "raw": sections}
# ── Mutations ────────────────────────────────────────────────────────────────
def installed_app_ids() -> set[str]:
return {row[0] for row in flatpak_rows(["--app"], ["application"]) if row[0]}
def uninstall(app_id: str) -> list[dict]:
require_flatpak()
if not APP_ID.fullmatch(app_id or ""):
raise BoundaryError("That is not an application id.")
# --app so a mistyped id can never resolve to a runtime, and the id is
# checked against what is actually installed rather than trusting the page.
if app_id not in installed_app_ids():
raise BoundaryError("That application is not installed.")
result = run(["flatpak", "uninstall", "--app", "--noninteractive", app_id], timeout=300)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "That application could not be removed."))
return flatpaks()
def unused_runtimes() -> list[dict]:
"""Runtimes and extensions no installed application asks for.
This is an estimate, and the page says so: the removal itself is
`flatpak uninstall --unused`, which recomputes the list with flatpak's own
dependency graph. Showing a size before asking needs a number now, and
flatpak offers no way to ask what --unused would do without doing it.
"""
require_flatpak()
apps = flatpak_rows(["--app"], ["application", "runtime"])
runtimes = flatpak_rows(["--runtime"], ["application", "ref", "branch", "size", "runtime"])
# Seeded with the apps, so an application's own extensions -- OBS's plugins
# are installed as runtimes named com.obsproject.Studio.Plugin.* -- count as
# used rather than as sixteen orphans.
used: set[str] = {row[0] for row in apps if row[0]}
used |= {row[1].split("/")[0] for row in apps if row[1]}
used_refs: set[str] = {row[1] for row in apps if row[1]}
# An extension of something used is used, and a runtime's own runtime is
# used. Repeated until nothing new appears, because the chain can be two or
# three long (app -> Platform -> Platform.Locale).
changed = True
while changed:
changed = False
for app_id, ref, _branch, _size, runtime in runtimes:
if not app_id or app_id in used:
continue
parent = next((name for name in used
if app_id.startswith(name + ".")), None)
if parent is not None or ref in used_refs:
used.add(app_id)
if runtime:
used.add(runtime.split("/")[0])
used_refs.add(runtime)
changed = True
entries = []
for app_id, ref, branch, size, _runtime in runtimes:
if not app_id or app_id in used:
continue
entries.append({
"id": app_id,
"ref": ref,
"branch": branch,
"size": size,
"sizeBytes": parse_size(size),
})
entries.sort(key=lambda entry: entry["sizeBytes"] or 0, reverse=True)
return entries
def clean_unused() -> list[dict]:
require_flatpak()
result = run(["flatpak", "uninstall", "--unused", "--noninteractive"], timeout=600)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The unused runtimes could not be removed."))
return flatpaks()
# ── The catalog ──────────────────────────────────────────────────────────────
#
# The rules below are setup/lib/extras-catalog's, restated in Python because a
# QML page cannot source a bash library. They are pinned by a contract that
# reads both, so a change to one that is not made to the other fails loudly
# rather than producing a menu that installs something else.
#
# * everything from the first `#` onward is stripped, inline comments included
# * blank and whitespace-only lines are skipped, after stripping
# * a line beginning with whitespace continues the entry above it and is
# installed with it, never listed on its own
# * `target | label`, split on the first `|`, both sides trimmed
# * with no label, a `flatpak:` id becomes its last dotted component and a dnf
# package keeps its own name
# * installed means `flatpak info <id>` for a flatpak and `rpm -q <name>`
# otherwise -- here, membership in one listing of each rather than a process
# per entry, which is the same question asked cheaply
def extras_directory() -> Path:
override = os.environ.get("PANAMA_EXTRAS_DIR")
if override:
return Path(override)
return Path(__file__).resolve().parents[4] / "setup" / "packages" / "extras"
def category_files() -> list[Path]:
directory = extras_directory()
if not directory.is_dir():
raise BoundaryError("The application catalog is not available.")
return sorted(path for path in directory.iterdir()
if path.is_file() and CATEGORY_NAME.fullmatch(path.name))
def category_file(name: str) -> Path:
if not CATEGORY_NAME.fullmatch(name or ""):
raise BoundaryError("That is not an application category.")
for path in category_files():
if path.name == name:
return path
raise BoundaryError("That is not an application category.")
def catalog_lines(path: Path) -> list[str]:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as error:
raise BoundaryError("The application catalog could not be read.") from error
return [line.split("#", 1)[0] for line in text.splitlines()]
def split_entry(line: str) -> tuple[str, str]:
target, _, label = line.partition("|")
target = target.strip()
label = label.strip()
if not label:
label = target[len("flatpak:"):] if target.startswith("flatpak:") else target
if target.startswith("flatpak:"):
label = label.rsplit(".", 1)[-1]
return target, label
def catalog_entries(path: Path) -> list[tuple[str, str]]:
entries = []
for line in catalog_lines(path):
if not line.strip() or line[:1].isspace():
continue
target, label = split_entry(line)
if target:
entries.append((target, label))
return entries
def catalog_targets(path: Path, wanted: str) -> list[str]:
"""The entry itself, then the indented lines beneath it."""
targets: list[str] = []
seen = False
for line in catalog_lines(path):
if not line.strip():
continue
if line[:1].isspace():
if seen:
targets.append(line.split("|", 1)[0].strip())
continue
target, _ = split_entry(line)
if target == wanted:
seen = True
targets.append(target)
elif seen:
break
return [target for target in targets if target]
def installed_sets() -> tuple[set[str], set[str]]:
"""Every installed flatpak ref name, and every installed rpm name.
One listing each rather than `flatpak info` and `rpm -q` per entry: the
catalog runs to a hundred entries and the page opens with it.
"""
flatpak_ids: set[str] = set()
if shutil.which("flatpak"):
flatpak_ids = {row[0] for row in flatpak_rows([], ["application"]) if row[0]}
packages: set[str] = set()
if shutil.which("rpm"):
result = run(["rpm", "-qa", "--qf", "%{NAME}\\n"], timeout=60)
if result.returncode == 0:
packages = {line.strip() for line in result.stdout.splitlines() if line.strip()}
return flatpak_ids, packages
def entry_payload(target: str, label: str,
flatpak_ids: set[str], packages: set[str]) -> dict:
is_flatpak = target.startswith("flatpak:")
ref = target[len("flatpak:"):] if is_flatpak else target
return {
# The id is the catalog line's target, verbatim, because that is what
# `install` matches and what extras-catalog's own lookup takes.
"id": target,
"ref": ref,
"label": label,
"kind": "flatpak" if is_flatpak else "dnf",
"installed": ref in (flatpak_ids if is_flatpak else packages),
}
def catalog() -> dict:
flatpak_ids, packages = installed_sets()
categories = []
for path in category_files():
entries = [entry_payload(target, label, flatpak_ids, packages)
for target, label in catalog_entries(path)]
categories.append({
"name": path.name,
# "gpu-compute" is a file name; "GPU compute" is a heading. The
# page needs the second and the helper needs the first.
"label": path.name.replace("-", " ").capitalize(),
"entries": entries,
})
return {"categories": categories}
def install(category: str, entry_id: str) -> dict:
"""Install one catalog entry, and nothing that is not a catalog entry."""
path = category_file(category)
known = {target for target, _ in catalog_entries(path)}
if entry_id not in known:
raise BoundaryError("That application is not in the catalog.")
flatpak_targets: list[str] = []
dnf_targets: list[str] = []
for target in catalog_targets(path, entry_id):
if target.startswith("flatpak:"):
identifier = target[len("flatpak:"):]
if not APP_ID.fullmatch(identifier):
raise BoundaryError("That catalog entry names something unusable.")
flatpak_targets.append(identifier)
else:
if not PACKAGE_NAME.fullmatch(target):
raise BoundaryError("That catalog entry names something unusable.")
dnf_targets.append(target)
if not flatpak_targets and not dnf_targets:
raise BoundaryError("That catalog entry installs nothing.")
if flatpak_targets:
require_flatpak()
result = run(["flatpak", "install", "--noninteractive", FLATHUB, *flatpak_targets],
timeout=1800)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "That application could not be installed."))
if dnf_targets:
if not shutil.which("dnf"):
raise BoundaryError("dnf is not available on this machine.")
# pkexec rather than sudo: the desktop already runs a polkit agent, and
# a settings page has no terminal to type a password into.
result = run(["pkexec", "dnf", "install", "-y", *dnf_targets], timeout=1800)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "That package could not be installed."))
return catalog()
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
text = (result.stderr or result.stdout or "").strip().splitlines()
if not text:
return fallback
last = text[-1].strip()
lowered = last.lower()
if "not authorized" in lowered or "dismissed" in lowered:
return "That change was not authorized."
return last[:200] or fallback
def emit(payload) -> None:
print(json.dumps(payload, separators=(",", ":")))
def main(arguments: list[str]) -> int:
try:
if arguments == ["flatpaks"]:
emit(flatpaks())
elif arguments == ["catalog"]:
emit(catalog())
elif arguments == ["unused-runtimes"]:
emit(unused_runtimes())
elif arguments == ["clean-unused"]:
emit(clean_unused())
elif len(arguments) == 2 and arguments[0] == "permissions":
emit(permissions(arguments[1]))
elif len(arguments) == 2 and arguments[0] == "uninstall":
emit(uninstall(arguments[1]))
elif len(arguments) == 3 and arguments[0] == "install":
emit(install(arguments[1], arguments[2]))
else:
raise BoundaryError(
"Usage: panama-applications flatpaks | permissions APP_ID | "
"uninstall APP_ID | unused-runtimes | clean-unused | catalog | "
"install CATEGORY ENTRY_ID")
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -57,6 +57,15 @@ ROLE_TARGETS = {
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
MIME_TYPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}"
r"/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$")
# A single-type override is a scalpel, not a browser: someone looking for "the
# thing that opens .heic" wants a short answer, and a list of six hundred types
# is not one. Anything past this is reported as truncated so the page can say
# "narrow the search" rather than pretending these are all of them.
TYPE_SEARCH_LIMIT = 20
TYPE_CANDIDATE_LIMIT = 12
class BoundaryError(RuntimeError):
@@ -224,6 +233,182 @@ def set_default(role: str, desktop_id: str) -> None:
run(["xdg-mime", "default", desktop_id, setting])
# ── One file type at a time ──────────────────────────────────────────────────
#
# The roles above govern families, which is right nearly always and wrong
# exactly when a family is too broad: SVG belongs in an editor while the rest of
# the images belong in a viewer, and setting the whole "Images" role to the
# editor is not what anyone wanted. These two verbs are the escape hatch.
#
# Searching is over the type name and its file extensions, and says so on the
# page. Matching human descriptions would mean reading the whole shared-mime-info
# database -- some thousands of small XML files -- to answer a keystroke.
def mime_globs() -> dict[str, list[str]]:
"""Extension patterns per type, from shared-mime-info's globs2.
Absent on a machine without shared-mime-info, which is not fatal: the search
falls back to matching the type name, and every type still resolves.
"""
globs: dict[str, list[str]] = {}
for root in xdg_data_roots():
path = root / "mime" / "globs2"
if not path.is_file():
continue
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for line in lines:
if line.startswith("#"):
continue
parts = line.split(":")
# weight:type:glob, with optional trailing flags.
if len(parts) < 3:
continue
mime, pattern = parts[1], parts[2]
if not MIME_TYPE.fullmatch(mime):
continue
patterns = globs.setdefault(mime, [])
if pattern not in patterns:
patterns.append(pattern)
return globs
def mime_registrations() -> dict[str, list[str]]:
"""Which installed applications declare which types.
Read from the desktop files themselves rather than mimeinfo.cache, because
the cache is regenerated by update-desktop-database and is stale on exactly
the machine where an application was just installed.
"""
registrations: dict[str, list[str]] = {}
for desktop_id, path in discovered_desktop_files().items():
try:
values = parse_desktop_entry(path)
except BoundaryError:
continue
if values.get("NoDisplay", "false").lower() == "true":
continue
for mime in values.get("MimeType", "").split(";"):
mime = mime.strip()
if not MIME_TYPE.fullmatch(mime):
continue
registered = registrations.setdefault(mime, [])
if desktop_id not in registered:
registered.append(desktop_id)
return registrations
def mime_label(mime: str) -> str:
"""shared-mime-info's own description, or "" when it has none.
Read only for the handful of types a search actually returns.
"""
media, _, subtype = mime.partition("/")
if not media or not subtype:
return ""
for root in xdg_data_roots():
path = root / "mime" / media / f"{subtype}.xml"
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
# The untranslated <comment> comes first; the xml:lang ones follow.
found = re.search(r"<comment>([^<]*)</comment>", text)
if found:
return found.group(1).strip()
return ""
def application_names() -> dict[str, str]:
names: dict[str, str] = {}
for desktop_id, path in discovered_desktop_files().items():
try:
values = parse_desktop_entry(path)
except BoundaryError:
continue
names[desktop_id] = values.get("Name", desktop_id[:-len(".desktop")])
return names
def current_handler(mime: str) -> str:
completed = subprocess.run(["xdg-mime", "query", "default", mime],
check=False, capture_output=True, text=True)
if completed.returncode != 0:
return ""
output = completed.stdout.strip().splitlines()
return output[0] if output else ""
def search_types(query: str) -> dict[str, object]:
needle = (query or "").strip().lower().lstrip(".")
if len(needle) < 2:
return {"query": query, "types": [], "truncated": False}
globs = mime_globs()
registrations = mime_registrations()
names = application_names()
known = sorted(set(globs) | set(registrations))
def score(mime: str) -> tuple[int, str]:
patterns = globs.get(mime, [])
extensions = [pattern[2:].lower() for pattern in patterns
if pattern.startswith("*.")]
if needle in extensions:
return (0, mime)
if mime.lower() == needle or mime.lower().split("/")[-1] == needle:
return (1, mime)
return (2, mime)
matched = []
for mime in known:
patterns = globs.get(mime, [])
haystack = " ".join([mime.lower(),
*(pattern.lower() for pattern in patterns)])
if needle in haystack:
matched.append(mime)
matched.sort(key=score)
truncated = len(matched) > TYPE_SEARCH_LIMIT
types = []
for mime in matched[:TYPE_SEARCH_LIMIT]:
handler = current_handler(mime)
candidates = list(registrations.get(mime, []))
# The current handler belongs in the list even when it never declared
# the type -- an override put it there, and a picker that cannot show
# the answer it is displaying is a picker nobody trusts.
if handler and handler not in candidates:
candidates.insert(0, handler)
candidates = candidates[:TYPE_CANDIDATE_LIMIT]
types.append({
"mime": mime,
"label": mime_label(mime),
"extensions": [pattern[1:] for pattern in globs.get(mime, [])
if pattern.startswith("*.")][:6],
"handler": handler,
"handlerName": names.get(handler, ""),
"candidates": [{"id": desktop_id, "name": names.get(desktop_id, desktop_id)}
for desktop_id in candidates],
})
return {"query": query, "types": types, "truncated": truncated}
def set_type(mime: str, desktop_id: str) -> None:
"""Point one type at one application, leaving its family alone."""
if not MIME_TYPE.fullmatch(mime or ""):
raise BoundaryError("That is not a file type.")
globs = mime_globs()
registrations = mime_registrations()
if mime not in globs and mime not in registrations:
raise BoundaryError("This system does not know that file type.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
run(["xdg-mime", "default", desktop_id, mime])
# What this desktop opens a file with, when nobody has said otherwise.
#
# Applications register themselves for every type they can technically read, so
@@ -442,10 +627,15 @@ def main(arguments: list[str]) -> int:
remove_autostart(arguments[1])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
elif len(arguments) == 2 and arguments[0] == "search-types":
print(json.dumps(search_types(arguments[1]), separators=(",", ":")))
elif len(arguments) == 3 and arguments[0] == "set-type":
set_type(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID | "
"remove-autostart DESKTOP_ID | search-types QUERY | set-type MIME DESKTOP_ID"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
+324 -1
View File
@@ -10,11 +10,22 @@ Two boundaries, deliberately separate:
means walking it, and this machine has a 1.2 TiB Steam library.
The page asks for this on demand and remembers the answer.
breakdown what the used space is made of, as four segments and a remainder.
Same walk as `scan`, so it costs the same and is asked for on
demand.
cleanables what could be freed, itemized and sized. Reading only.
clean ID frees exactly one of them, named explicitly.
unmount PATH / eject PATH removable media only, by explicit request.
Deliberately absent: partitioning and formatting. A settings pane is the wrong
place to hand someone a way to erase a disk in two clicks; GNOME Disks is one
button away on the page for that.
Also deliberately absent: anything that runs on its own. Nothing here is
pre-selected, nothing is measured in order to nag about it, and `clean` refuses
every id it was not handed. A storage page that cleans things you did not ask it
to clean is a cleaner, and cleaners are how people lose files.
"""
from __future__ import annotations
@@ -47,6 +58,22 @@ SCAN_TARGETS = [
("Trash", "~/.local/share/Trash"),
]
# The cache folder is its own segment in the breakdown and its own cleanable, so
# it is named once here rather than spelled out in three places.
CACHE_TARGET = "~/.cache"
TRASH_TARGET = "~/.local/share/Trash"
# Where flatpak keeps what it installed. Measured rather than summed from
# `flatpak list --columns=size`: those are per-ref installed sizes, and ostree
# hard-links every object shared between refs, so adding them up on this machine
# reports about twice what the drive actually holds.
FLATPAK_ROOTS = ["/var/lib/flatpak", "~/.local/share/flatpak"]
# dnf keeps downloaded rpms under <repo>/packages and its metadata beside them.
# Only the packages are offered: dropping the metadata costs a re-download on
# the next install and frees comparatively little.
DNF_CACHE_ROOTS = ["/var/cache/libdnf5", "/var/cache/dnf"]
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
@@ -329,6 +356,295 @@ def scan() -> dict:
}
# ── What the used space is made of ───────────────────────────────────────────
#
# Four measured segments and one remainder. The remainder is what is left of the
# filesystem's used bytes after the four are subtracted, and it is labelled
# "System & everything else" on the page for exactly that reason: it is not a
# measurement of the system, it is everything this did not measure.
#
# The arithmetic rule, which a contract pins: the measured segments never sum to
# more than the filesystem reports as used, and nothing is scaled to make a bar
# look tidy. When a measurement does overshoot -- possible when /home lives on a
# different filesystem from the flatpak installation -- the remainder is zero and
# `exceedsUsed` says so rather than inventing a number.
def measure_all(paths: list[str], budget: float) -> tuple[int, float, bool]:
"""Bytes across several paths, and whether every one of them was measured."""
total = 0
complete = True
for target in paths:
if budget <= 1.0:
return total, budget, False
size, budget = measure(Path(os.path.expanduser(target)), budget)
if size is None:
# A path that does not exist contributes nothing and is not a gap;
# one that timed out is, and the budget is gone either way.
if Path(os.path.expanduser(target)).is_dir():
complete = False
continue
total += size
return total, budget, complete
def backing_device(path: str) -> str:
"""The block device behind a path, with any btrfs subvolume stripped.
st_dev is not the question being asked. btrfs hands every subvolume its own
device number, so / and /home compare as different filesystems by that test
even though they are one pool with one free-space total -- which is the
exact confusion the filesystems list upstairs already exists to avoid. The
first version of the breakdown left the system-wide flatpak installation out
of the applications segment for that reason, and reported 4 kB of apps on a
machine with twenty gigabytes of them.
"""
if not shutil.which("findmnt"):
try:
return str(os.stat(path).st_dev)
except OSError:
return ""
try:
source = run(["findmnt", "-n", "-o", "SOURCE", "--target", path], timeout=10.0)
except BoundaryError:
return ""
return source.strip().split("[", 1)[0]
def same_filesystem(first: str, second: str) -> bool:
left = backing_device(first)
return left != "" and left == backing_device(second)
def breakdown() -> dict:
home = os.path.expanduser("~")
try:
usage = shutil.disk_usage(home)
except OSError as error:
raise BoundaryError("The filesystem holding your home folder could not be read.") from error
remaining = float(SCAN_TIMEOUT_SECONDS)
caches, remaining, caches_complete = measure_all([CACHE_TARGET], remaining)
home_targets = [target for _, target in SCAN_TARGETS if target != CACHE_TARGET]
home_bytes, remaining, home_complete = measure_all(home_targets, remaining)
# Only the installations that live on the same filesystem as home, because
# adding bytes from another drive into this drive's bar is a lie about this
# drive.
flatpak_paths = [target for target in FLATPAK_ROOTS
if Path(os.path.expanduser(target)).is_dir()
and same_filesystem(os.path.expanduser(target), home)]
applications, remaining, applications_complete = measure_all(flatpak_paths, remaining)
used = int(usage.used)
accounted = home_bytes + applications + caches
system = max(0, used - accounted)
return {
"segments": {
"home": home_bytes,
"applications": applications,
"caches": caches,
"system": system,
"free": int(usage.free),
},
"totalBytes": int(usage.total),
"usedBytes": used,
"freeBytes": int(usage.free),
# The measured segments are floors when this is false: something took
# longer than the budget and was left out rather than guessed at.
"complete": home_complete and caches_complete and applications_complete,
"exceedsUsed": accounted > used,
"path": home,
}
# ── Cleaning up, honestly ────────────────────────────────────────────────────
#
# Every row is itemized, sized in real bytes, and inert until its own id is
# passed to `clean`. There is no "clean everything" verb and there is no
# recommendation: the page shows what each one costs you -- caches are rebuilt,
# first launches get slower -- and lets it be somebody's decision.
def applications_helper() -> str:
return os.environ.get("PANAMA_APPLICATIONS_HELPER") or str(
Path(__file__).resolve().parent / "panama-applications")
def unused_runtime_bytes() -> int:
"""What the flatpak helper reports as unused, in bytes.
Asked of the applications helper rather than reimplemented, so the number
shown here and the thing `clean` removes can never come from two different
ideas of "unused".
"""
if not shutil.which("flatpak"):
return 0
try:
raw = run([applications_helper(), "unused-runtimes"], timeout=60.0)
entries = json.loads(raw)
except (BoundaryError, json.JSONDecodeError):
return 0
return sum(int(entry.get("sizeBytes") or 0)
for entry in entries if isinstance(entry, dict))
def dnf_package_cache_paths() -> list[str]:
paths = []
for root in DNF_CACHE_ROOTS:
directory = Path(root)
if not directory.is_dir():
continue
try:
paths.extend(str(child / "packages") for child in directory.iterdir()
if (child / "packages").is_dir())
except OSError:
continue
return paths
def cleanables() -> list[dict]:
remaining = float(SCAN_TIMEOUT_SECONDS)
cache_bytes, remaining, _ = measure_all([CACHE_TARGET], remaining)
trash_bytes, remaining, _ = measure_all([TRASH_TARGET], remaining)
dnf_bytes, remaining, _ = measure_all(dnf_package_cache_paths(), remaining)
return [
{
"id": "cache",
"label": "Application caches",
"detail": "~/.cache · rebuilt as apps run · first launches get slower once",
"bytes": cache_bytes,
"privileged": False,
},
{
"id": "trash",
"label": "Trash",
"detail": "Files you deleted · emptying is permanent",
"bytes": trash_bytes,
"privileged": False,
},
{
"id": "flatpak-unused",
"label": "Unused Flatpak runtimes",
"detail": "Runtimes no installed app asks for · flatpak decides the final list",
"bytes": unused_runtime_bytes(),
"privileged": False,
},
{
"id": "dnf-cache",
"label": "Package download cache",
"detail": "Downloaded packages · the system will ask for your password",
"bytes": dnf_bytes,
"privileged": True,
},
]
def guarded_cache_directory() -> Path:
"""~/.cache, or a refusal.
This function is the whole reason the cache row is safe to press. It refuses
a symlinked ~/.cache and refuses anything that resolves outside the home
directory, so XDG_CACHE_HOME pointing somewhere alarming, or a ~/.cache
someone linked to /, cannot turn one click into a deleted system.
"""
home = Path(os.path.expanduser("~")).resolve(strict=False)
target = Path(os.path.expanduser(CACHE_TARGET))
if target.is_symlink():
raise BoundaryError("The cache folder is a link, so it will not be emptied.")
if not target.is_dir():
raise BoundaryError("There is no cache folder to empty.")
resolved = target.resolve(strict=True)
if resolved == home or home not in resolved.parents:
raise BoundaryError("The cache folder is not inside your home folder.")
return resolved
def empty_cache() -> None:
"""Delete what is inside ~/.cache, never following a link out of it.
A cache file an application still has open cannot be removed, and that is
the normal case rather than a failure -- so a partial pass succeeds, and the
freshly measured size the caller gets back is what says how much is left.
Only a pass that removed nothing at all is reported as a failure.
"""
directory = guarded_cache_directory()
removed = 0
failures = 0
with os.scandir(directory) as entries:
for entry in entries:
try:
# is_symlink first: a symlinked directory must be unlinked, not
# walked, or this deletes whatever it points at.
if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
os.unlink(entry.path)
else:
# rmtree lstats as it goes and refuses to descend a symlink.
shutil.rmtree(entry.path, ignore_errors=False)
removed += 1
except OSError:
failures += 1
if failures and not removed:
raise BoundaryError("The cache is in use and nothing could be removed.")
def empty_trash() -> None:
if not shutil.which("gio"):
raise BoundaryError("gio is not available, so the trash cannot be emptied.")
# gio rather than removing ~/.local/share/Trash by hand: the trash is a
# freedesktop structure with per-file metadata and mount-point trash
# directories elsewhere, and gio empties all of it correctly.
run(["gio", "trash", "--empty"], timeout=300.0)
def clean_flatpak_unused() -> None:
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed on this machine.")
run([applications_helper(), "clean-unused"], timeout=600.0)
def clean_dnf_cache() -> None:
if not shutil.which("dnf"):
raise BoundaryError("dnf is not available on this machine.")
# `clean packages` and never `clean all`: this drops the downloaded rpms,
# which is what was measured and what takes the space. Dropping the metadata
# as well would free little and make the next install slow for no reason.
#
# This is the only dnf invocation in Panama's settings surface, and it
# removes downloads. Nothing here removes an installed package.
try:
run(["pkexec", "dnf", "clean", "packages"], timeout=300.0)
except BoundaryError as error:
detail = str(error).lower()
if "dismissed" in detail or "not authorized" in detail:
raise BoundaryError("That change was not authorized.") from error
raise
CLEANERS = {
"cache": empty_cache,
"trash": empty_trash,
"flatpak-unused": clean_flatpak_unused,
"dnf-cache": clean_dnf_cache,
}
def clean(identifier: str) -> list[dict]:
"""Free exactly one named thing, and refuse everything else.
One id per call, no list, no "all". The caller has to name what it wants
removed, which is what keeps a mis-wired button from emptying four things.
"""
cleaner = CLEANERS.get(identifier or "")
if cleaner is None:
raise BoundaryError("There is nothing by that name to clean up.")
cleaner()
return cleanables()
def removable_device(path: str) -> dict:
"""Resolve a device path, refusing anything that is not removable.
@@ -354,6 +670,12 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":")))
elif arguments == ["scan"]:
print(json.dumps(scan(), separators=(",", ":")))
elif arguments == ["breakdown"]:
print(json.dumps(breakdown(), separators=(",", ":")))
elif arguments == ["cleanables"]:
print(json.dumps(cleanables(), separators=(",", ":")))
elif len(arguments) == 2 and arguments[0] == "clean":
print(json.dumps(clean(arguments[1]), separators=(",", ":")))
elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"):
removable_device(arguments[1])
action = "unmount" if arguments[0] == "unmount" else "power-off"
@@ -361,7 +683,8 @@ def main(arguments: list[str]) -> int:
run(["udisksctl", action, flag, arguments[1]], timeout=30.0)
else:
raise BoundaryError(
"Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE")
"Usage: panama-disks snapshot | scan | breakdown | cleanables | "
"clean ID | unmount DEVICE | eject DEVICE")
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
+11 -1
View File
@@ -308,11 +308,21 @@ def delete(config: str, number: str) -> None:
raise BoundaryError(_refusal(result, "That snapshot could not be removed."))
# The three horizons the page can edit, and the largest number it will accept
# for any of them. 999 hourly snapshots is not a retention policy, it is a typo
# that fills a drive; the page offers a dropdown and this is its ceiling. The
# monthly and yearly limits are left exactly as snapper has them -- nothing here
# writes them, so a config with longer horizons keeps them.
RETENTION_LIMIT = 50
def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None:
values = []
for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)):
if not str(value).isdigit() or int(value) > 999:
if not str(value).isdigit():
raise BoundaryError("Keep counts must be whole numbers.")
if int(value) > RETENTION_LIMIT:
raise BoundaryError(f"Keep counts go up to {RETENTION_LIMIT}.")
values.append(f"TIMELINE_LIMIT_{label}={int(value)}")
result = run(["snapper", "-c", require_config(config), "set-config", *values])
if result.returncode != 0:
@@ -0,0 +1,328 @@
pragma Singleton
// What is installed, what Panama offers to install, and what a flatpak may do.
//
// DesktopEntries knows every application that put a launcher on this machine
// and nothing about where it came from. flatpak knows its own applications and
// nothing about the rest. Neither answers "what is installed", which is the
// question the Applications page asks, so this joins them: a desktop entry
// whose id matches an installed flatpak id is that flatpak, because flatpak
// exports its launcher as <app-id>.desktop and always has.
//
// Everything else is a system package. That is stated rather than acted on --
// there is no dnf removal here or in the helper. A settings page that
// uninstalls system packages is one mis-click from removing the compositor it
// is drawn by, and dnf will take half the desktop with it. The page names the
// command instead.
//
// Permissions are cached per application id. `flatpak info --show-permissions`
// is a process launch, the page shows them per expanded row, and re-reading
// them on every repaint would launch one per frame.
//
// Nothing here loads on its own. `flatpak list` and the catalog cost a process
// each and only the Applications page wants them, so the page calls refresh()
// and refreshCatalog() when it opens rather than every shell start paying for a
// page nobody opened.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// The seam: contracts point this at a stub that answers from fixtures, so
// "install refuses an id that is not in the catalog" can be proven without
// installing anything.
readonly property string helperPath: Quickshell.env("PANAMA_APPLICATIONS_HELPER")
|| Quickshell.shellDir + "/scripts/panama-applications"
// [{ id, name, size, sizeBytes, origin }]
property var flatpaks: []
// [{ name, label, entries: [{ id, ref, label, kind, installed }] }]
property var categories: []
property var permissionCache: ({})
property bool flatpaksLoaded: false
property bool catalogLoaded: false
property string lastError: ""
// Which row is mid-change, so the page can disable exactly that one rather
// than greying out the whole card.
property string busyEntryId: ""
// Guards read the Process objects directly rather than this binding; a
// binding is stale inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: flatpakQuery.running || catalogQuery.running || mutation.running
readonly property var flatpakIndex: {
const index = {};
for (const entry of root.flatpaks)
index[String(entry.id ?? "").toLowerCase()] = entry;
return index;
}
// [{ entryId, name, icon, kind, flatpakId, size, sizeBytes, entry }]
//
// `entry` is the DesktopEntry itself when there is one, so the page can
// launch it, read its categories, or match it against a notification rule
// without looking it up a second time.
readonly property var apps: {
const out = [];
const claimed = {};
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
const entryId = root.desktopId(entry);
const key = entryId.replace(/\.desktop$/, "").toLowerCase();
const flatpak = root.flatpakIndex[key] ?? null;
if (flatpak)
claimed[key] = true;
out.push({
entryId: entryId,
name: String(entry.name || entry.genericName || entryId),
icon: String(entry.icon ?? ""),
kind: flatpak ? "flatpak" : "system",
flatpakId: flatpak ? String(flatpak.id) : "",
size: flatpak ? String(flatpak.size ?? "") : "",
sizeBytes: flatpak ? Number(flatpak.sizeBytes ?? 0) : 0,
entry: entry
});
}
// A flatpak with no launcher is still installed and still takes space.
// Dropping it would make the list disagree with `flatpak list`, and the
// uninstall row is the only place someone can get rid of it.
for (const flatpak of root.flatpaks) {
const key = String(flatpak.id ?? "").toLowerCase();
if (claimed[key])
continue;
out.push({
entryId: String(flatpak.id) + ".desktop",
name: String(flatpak.name ?? flatpak.id),
icon: "",
kind: "flatpak",
flatpakId: String(flatpak.id),
size: String(flatpak.size ?? ""),
sizeBytes: Number(flatpak.sizeBytes ?? 0),
entry: null
});
}
out.sort((left, right) => left.name.localeCompare(right.name));
return out;
}
readonly property int flatpakCount: root.flatpaks.length
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
// Name and id both, because someone searching for "obs" and someone
// searching for "com.obsproject" are looking for the same row.
function matches(app: var, query: string): bool {
const needle = String(query ?? "").trim().toLowerCase();
if (needle === "")
return true;
return (String(app.name) + " " + String(app.entryId) + " " + String(app.flatpakId))
.toLowerCase().indexOf(needle) >= 0;
}
function entriesFor(category: string): var {
for (const entry of root.categories) {
if (String(entry.name) === String(category))
return entry.entries ?? [];
}
return [];
}
function categoryLabel(category: string): string {
for (const entry of root.categories) {
if (String(entry.name) === String(category))
return String(entry.label ?? entry.name);
}
return String(category);
}
// Cached, and null while the answer is on its way. Reading this inside a
// binding is safe: the read of permissionCache is what makes the binding
// re-evaluate when the answer lands.
function permissionsFor(flatpakId: string): var {
const key = String(flatpakId ?? "");
if (key === "")
return null;
const cached = root.permissionCache[key];
if (cached !== undefined)
return cached;
root.requestPermissions(key);
return null;
}
function requestPermissions(flatpakId: string): void {
if (permissionQuery.pending.indexOf(flatpakId) >= 0)
return;
permissionQuery.pending = permissionQuery.pending.concat([flatpakId]);
permissionQuery.pump();
}
function refresh(): void {
if (flatpakQuery.running)
return;
flatpakQuery.command = [root.helperPath, "flatpaks"];
flatpakQuery.running = true;
}
function refreshCatalog(): void {
if (catalogQuery.running)
return;
catalogQuery.command = [root.helperPath, "catalog"];
catalogQuery.running = true;
}
// Only ids the catalog already listed. The helper refuses off-catalog ids
// too -- this is the near guard, not the only one, because the page is a
// caller that can be wrong.
function install(category: string, entryId: string): void {
if (mutation.running)
return;
const known = root.entriesFor(category).some(entry => String(entry.id) === String(entryId));
if (!known) {
root.lastError = "That application is not in the catalog.";
return;
}
root.lastError = "";
root.busyEntryId = String(entryId);
mutation.mode = "catalog";
mutation.command = [root.helperPath, "install", String(category), String(entryId)];
mutation.running = true;
}
function uninstall(flatpakId: string): void {
if (mutation.running)
return;
const known = root.flatpaks.some(entry => String(entry.id) === String(flatpakId));
if (!known) {
root.lastError = "That application is not installed.";
return;
}
root.lastError = "";
root.busyEntryId = String(flatpakId);
mutation.mode = "flatpaks";
mutation.command = [root.helperPath, "uninstall", String(flatpakId)];
mutation.running = true;
}
function absorbFlatpaks(text: string): void {
try {
const parsed = JSON.parse(text);
root.flatpaks = Array.isArray(parsed) ? parsed : [];
root.flatpaksLoaded = true;
} catch (error) {
root.lastError = "The list of installed applications could not be read.";
console.warn("AppLibrary: could not parse flatpaks output:", error);
}
}
function absorbCatalog(text: string): void {
try {
const parsed = JSON.parse(text);
root.categories = Array.isArray(parsed.categories) ? parsed.categories : [];
root.catalogLoaded = true;
} catch (error) {
root.lastError = "The application catalog could not be read.";
console.warn("AppLibrary: could not parse catalog output:", error);
}
}
Process {
id: flatpakQuery
stdout: StdioCollector { onStreamFinished: root.absorbFlatpaks(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: catalogQuery
stdout: StdioCollector { onStreamFinished: root.absorbCatalog(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
// Which fresh state the helper answers with, so the reply lands in the
// right property instead of being parsed twice and guessed at.
property string mode: "flatpaks"
stdout: StdioCollector {
onStreamFinished: {
if (mutation.mode === "catalog")
root.absorbCatalog(this.text);
else
root.absorbFlatpaks(this.text);
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.busyEntryId = "";
// Uninstalling changes what the catalog says is installed, and
// installing changes what is installed. Both sides are re-read
// rather than assumed.
if (mutation.mode === "catalog")
root.refresh();
else
root.refreshCatalog();
}
}
Process {
id: permissionQuery
property var pending: []
property string current: ""
function pump(): void {
if (permissionQuery.running || permissionQuery.pending.length === 0)
return;
permissionQuery.current = String(permissionQuery.pending[0]);
permissionQuery.command = [root.helperPath, "permissions", permissionQuery.current];
permissionQuery.running = true;
}
stdout: StdioCollector {
onStreamFinished: {
const key = permissionQuery.current;
let value = { summary: [], raw: {} };
try {
const parsed = JSON.parse(this.text);
value = {
summary: Array.isArray(parsed.summary) ? parsed.summary : [],
raw: parsed.raw ?? ({})
};
} catch (error) {
// Cached as an honest empty answer rather than left absent,
// or every repaint asks again for something that failed.
value = { summary: ["Permissions could not be read"], raw: {} };
}
const next = Object.assign({}, root.permissionCache);
next[key] = value;
root.permissionCache = next;
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 && root.permissionCache[permissionQuery.current] === undefined) {
const next = Object.assign({}, root.permissionCache);
next[permissionQuery.current] = { summary: ["Permissions could not be read"], raw: {} };
root.permissionCache = next;
}
permissionQuery.pending = permissionQuery.pending.filter(
id => String(id) !== permissionQuery.current);
permissionQuery.current = "";
permissionQuery.pump();
}
}
}
@@ -18,6 +18,16 @@ Singleton {
property var luaAutostartEntries: []
property string lastError: ""
// One file type at a time: the escape hatch for when a role's family is too
// broad, which is a real case -- SVG belongs in an editor while every other
// image belongs in a viewer. Kept separate from the role state because it is
// a search, not a setting: it is whatever was last asked for and nothing is
// remembered between visits.
property var typeMatches: []
property string typeQuery: ""
property bool typeSearchTruncated: false
readonly property bool searchingTypes: typeSearch.running
// For the UI, which wants one answer to "is anything happening".
//
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
@@ -56,15 +66,48 @@ Singleton {
Process {
id: mutationProcess
// A set-type write changes the answer the open search is showing, so
// the search is re-run rather than left displaying the old handler.
property string repeatQuery: ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
mutationProcess.repeatQuery = ""
root.lastError = "That application setting could not be changed."
return;
}
if (mutationProcess.repeatQuery !== "") {
const query = mutationProcess.repeatQuery;
mutationProcess.repeatQuery = "";
root.searchTypes(query);
}
root.refresh();
}
}
Process {
id: typeSearch
stdout: StdioCollector {
onStreamFinished: {
try {
const payload = JSON.parse(this.text);
root.typeMatches = Array.isArray(payload.types) ? payload.types : [];
root.typeSearchTruncated = payload.truncated === true;
} catch (error) {
root.typeMatches = [];
root.lastError = "File types returned an unreadable response."
}
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.typeMatches = [];
root.lastError = "File types could not be searched."
}
}
}
function applySnapshot(text: string): void {
try {
const payload = JSON.parse(text);
@@ -135,6 +178,47 @@ Singleton {
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
}
// Searching is over the type name and its file extensions -- "svg", "heic",
// ".mkv". Matching the human descriptions would mean reading the whole
// shared-mime-info database, some thousands of small files, per keystroke.
function searchTypes(query: string): void {
const trimmed = String(query ?? "").trim();
root.typeQuery = trimmed;
if (trimmed.length < 2) {
root.typeMatches = [];
root.typeSearchTruncated = false;
return;
}
if (typeSearch.running)
return;
typeSearch.exec([root.helper, "search-types", trimmed]);
}
function clearTypeSearch(): void {
root.typeQuery = "";
root.typeMatches = [];
root.typeSearchTruncated = false;
}
// One type, one application: the override that leaves the rest of the
// family where it is. The helper validates the type against what this
// system knows and the application against what is installed.
function setType(mime: string, desktopId: string): void {
if (mutationProcess.running)
return;
if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test(mime)) {
root.lastError = "Choose a file type from the list."
return;
}
if (!root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
root.lastError = "";
mutationProcess.repeatQuery = root.typeQuery;
mutationProcess.exec([root.helper, "set-type", mime, desktopId]);
}
function addAutostart(desktopId: string): void {
if (mutationProcess.running)
return;
+117
View File
@@ -40,6 +40,32 @@ Singleton {
// are different answers.
property bool foldersMeasured: false
// What the used space is made of, and what could be freed.
//
// { segments: { home, applications, caches, system, free }, usedBytes,
// totalBytes, freeBytes, complete, exceedsUsed }
//
// `system` is the remainder -- used bytes minus the three measured segments
// -- and the page calls it "System & everything else" for that reason. It
// is not a measurement of the system; it is everything the walk did not
// reach. The segments never sum past used, and when a measurement would
// overshoot, `exceedsUsed` says so instead of a number being scaled to make
// the bar look tidy.
property var breakdown: null
property bool breakdownMeasured: false
property bool measuringBreakdown: false
// [{ id, label, detail, bytes, privileged }]
//
// Nothing here is selected, ordered by urgency, or acted on. Each row is
// freed only by its own id being passed to clean(), which is what keeps a
// mis-wired button from emptying four things at once.
property var cleanables: []
property bool cleanablesMeasured: false
property bool measuringCleanables: false
// Which row is mid-clean, so the page can disable that one row.
property string cleaningId: ""
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.
@@ -110,6 +136,49 @@ Singleton {
folderScan.running = true;
}
// The same walk `scan` does, so it costs the same and is asked for on
// demand rather than when the page opens.
function measureBreakdown(): void {
if (root.measuringBreakdown)
return;
root.measuringBreakdown = true;
breakdownScan.running = true;
}
function measureCleanables(): void {
if (root.measuringCleanables)
return;
root.measuringCleanables = true;
cleanableScan.running = true;
}
// Exactly one, named. An id this service has not been told about is
// refused here and refused again by the helper.
function clean(identifier: string): void {
if (cleaner.running || root.measuringCleanables)
return;
const known = root.cleanables.some(item => String(item.id) === String(identifier));
if (!known) {
root.lastError = "There is nothing by that name to clean up.";
return;
}
root.lastError = "";
root.cleaningId = String(identifier);
cleaner.command = [root.helperPath, "clean", String(identifier)];
cleaner.running = true;
}
function absorbCleanables(text: string): void {
try {
const parsed = JSON.parse(text);
root.cleanables = Array.isArray(parsed) ? parsed : [];
root.cleanablesMeasured = true;
} catch (error) {
root.lastError = "Could not measure what could be cleaned up.";
console.warn("Disks: could not parse cleanables output:", error);
}
}
function unmount(devicePath: string): void {
root.runMedia(["unmount", devicePath]);
}
@@ -171,6 +240,54 @@ Singleton {
onExited: root.scanning = false
}
Process {
id: breakdownScan
command: [root.helperPath, "breakdown"]
stdout: StdioCollector {
onStreamFinished: {
try {
root.breakdown = JSON.parse(this.text);
root.breakdownMeasured = true;
} catch (error) {
root.lastError = "Could not measure what is using the drive.";
console.warn("Disks: could not parse breakdown output:", error);
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.measuringBreakdown = false
}
Process {
id: cleanableScan
command: [root.helperPath, "cleanables"]
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.measuringCleanables = false
}
Process {
id: cleaner
// The helper answers with the fresh list, so the sizes on screen are
// what is there now rather than what was there before the clean.
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.cleaningId = "";
// Freeing space changes the drive's usage and its breakdown, and
// both are on screen while this happens.
root.refresh();
if (root.breakdownMeasured)
root.measureBreakdown();
}
}
Process {
id: media
stderr: StdioCollector {
@@ -92,6 +92,16 @@ Singleton {
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
// The Applications tab manages applications now, rather than only
// pointing file types at them, so the things people come looking for —
// what is installed, removing one, what it is allowed to reach — are
// findable by their own names.
{ label: "Installed applications", detail: "Everything installed here, where it came from, and how much room it takes", page: "applications" },
{ label: "Uninstall an application", detail: "Remove a Flatpak application, or see the command for a system package", page: "applications" },
{ label: "Application permissions", detail: "What a Flatpak application may reach: files, camera, microphone, network", page: "applications" },
{ label: "Install applications", detail: "Browse the catalog Panama curates, by category", page: "applications" },
{ label: "Autostart", detail: "Which applications start with your session", page: "applications" },
{ label: "File associations", detail: "Which application opens each kind of file", page: "applications" },
{ label: "User account", detail: "Your name, picture, and password", page: "users" },
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },
{ label: "Change password", detail: "Set a new password for signing in", page: "users" },
@@ -133,11 +143,14 @@ Singleton {
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
{ label: "File history", detail: "Earlier versions of your files", page: "snapshots" },
{ label: "Undo a change", detail: "Put back a file as it was at an earlier point", page: "snapshots" },
{ label: "Snapshot retention", detail: "How many hourly, daily, and weekly snapshots to keep", page: "snapshots" },
{ 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: "Clean up storage", detail: "Caches, trash, and unused runtimes, each itemized and sized before you remove it", page: "storage" },
{ label: "Application caches", detail: "What applications have left in your cache folder, and clearing it", 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" },
+34 -1
View File
@@ -44,6 +44,26 @@ Singleton {
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: query.running || mutation.running
// How many snapshots a horizon may keep. The page offers a dropdown rather
// than a number field, and this is what fills it; the helper refuses
// anything above it too. 999 hourly snapshots is not a retention policy,
// it is a typo that fills a drive.
//
// The monthly and yearly limits are not editable here and are never
// written, so a configuration with longer horizons keeps them.
readonly property int retentionMax: 50
readonly property var retentionChoices: {
const values = [];
for (let index = 0; index <= root.retentionMax; index += 1)
values.push(index);
return values;
}
// The card the browser lives in is not the card it was opened from: the
// browse state below is independent of any config row's expanded state, so
// opening a snapshot from a collapsed row still shows the files.
readonly property bool browserOpen: root.browsingConfig !== ""
function labelFor(config: var): string {
const subvolume = String(config?.subvolume ?? "");
if (subvolume === "/")
@@ -128,8 +148,21 @@ Singleton {
root.run(["delete", config, String(number)]);
}
// Three horizons, each a whole number the dropdown offered. Checked here as
// well as in the helper because the caller is a page that can be wrong, and
// a keep count that arrives as "5.5" or "-1" would be written straight into
// snapper's config file.
function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]);
const values = [hourly, daily, weekly];
for (const value of values) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > root.retentionMax) {
root.lastError = "Keep counts go from 0 to " + root.retentionMax + ".";
return;
}
}
root.run(["set-retention", config,
String(values[0]), String(values[1]), String(values[2])]);
}
function setTimeline(config: string, enabled: bool): void {
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Applications in Settings.
# @vicinae.keywords ["settings", "web search engine", "default applications"]
# @vicinae.keywords ["settings", "web search engine", "default applications", "installed applications", "uninstall an application", "application permissions", "install applications", "autostart", "file associations"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page applications
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Connections in Settings.
# @vicinae.keywords ["settings", "wi-fi", "bluetooth", "printers"]
# @vicinae.keywords ["settings", "wi-fi", "bluetooth", "vpn", "import a vpn", "hotspot", "airplane mode", "network proxy", "ip address", "forget a wi-fi network", "enterprise wi-fi"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page connectivity
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Snapshots in Settings.
# @vicinae.keywords ["settings", "snapshots", "restore a file", "backups", "file history", "undo a change"]
# @vicinae.keywords ["settings", "snapshots", "restore a file", "backups", "file history", "undo a change", "snapshot retention"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page snapshots
@@ -5,6 +5,6 @@
# @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"]
# @vicinae.keywords ["settings", "free space", "disk usage", "drive health", "removable drives", "encryption", "clean up storage", "application caches"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page storage
@@ -719,3 +719,182 @@ counted the same way `panama test` collects the suite.
`panama-settings-commands` to pick up. Verified by reading the diff — every
new setting here is system state (NetworkManager, firewalld, CUPS, gsettings),
not a Panama preference.
## Phase 10 (Applications, Storage & Snapshots) — append below
Spec: `2026-08-24-applications-storage-snapshots-redesign.md`. Applications
became an app manager (installed list with search, Flatpak uninstall, permission
summaries, per-app autostart, jump chips, the `panama apps` catalog rendered
natively, role dropdowns plus a single-type override). Storage grew a
proportional breakdown bar and an itemized "Clean up, honestly" card, and lost
the duplicate container-images row that opened a terminal. Snapshots made
retention editable and moved the file browser into its own card, which fixes
"browse from a collapsed volume card does nothing".
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase rather than against the spec's
pinned shapes.
### New contracts (1)
`quickshell/app-library-contract`. The README count line moves **170 → 171**;
`setup/readme-contract` was run and passes ("171 contracts, as documented").
### Run and passing
These four are hermetic or source-only, so they were run rather than deferred:
- **`quickshell/app-library-contract` — RUN END TO END, PASS.** Safe to run
because it never reaches the machine: `env -i`, a stub directory first on
`PATH`, recording stubs for flatpak/rpm/pkexec and screaming stubs for
dnf/yum/sudo/rpm-ostree/gio, `HOME` and every XDG directory inside the scratch
tree, and a fixture catalog through `PANAMA_EXTRAS_DIR`. It asserts, before
running the helper at all, that the helper names no binary by absolute path
and that all six commands resolve inside the stub directory. What it pins:
- **Catalog agreement.** `setup/lib/extras-catalog`'s bash functions and the
helper's Python parser are both run over the same fixture files and their
answers compared entry by entry — id, label, kind — rather than either being
read. The fixture exercises the format's corners: a labelled entry, an
unlabelled Flathub id (label derived from the last dotted component), a bare
dnf name, a `|`-labelled dnf name, comments, blank lines, and two indented
continuation lines that must fold into the entry above.
- **Ids are the catalog line verbatim.** `flatpak:org.example.Bravo`, not the
stripped ref: the bare ref is in the refusal list, because handing that to
`install` would be refused for every Flathub entry in the real catalog and
the failure would read as "installing is broken".
- **Nothing off-catalog installs**, and the empty command log is what proves
the refusal came first. Off-catalog ids, an unknown category, a path
escaping the catalog directory, a continuation line, and an entry that
exists in the *other* category are all refused with nothing run.
- **Extensions ride with their entry and nothing else does** (installing Echo
reaches for Echo and its two plugins, and no other `org.example.*`).
- **Removal is flatpak-only**, pinned three ways: an AST walk that fails if
any command list in the helper pairs dnf/yum/rpm-ostree with a removal verb
or invokes rpm for anything but a query; a second AST walk over `uninstall`
itself; and a runtime check that no argument — including a dnf package name
— produces a package-manager call.
- **Permission buckets from data**: `filesystems=host` → "Full file system
access" and `filesystems=home` → "Home folder" are checked against separate
fixtures so the two cannot be conflated; `devices=all` → Camera,
`sockets=pulseaudio` → Microphone, `shared=network` → Network; an
unrecognized key must still appear in the output; and a sandboxed
application must summarize strictly shorter than a permissive one, so the
summary cannot be a constant.
- **`quickshell/applications-settings-contract` — RUN END TO END, PASS**
(source-only). The bun-extracted `roles` / `matchesRole` half survives B's
rebuild verbatim and gained four fixtures, including the two shapes that once
made Archives match nothing: categories arriving as a QML list, and as a
comma-separated string.
- **`quickshell/default-apps-contract` — RUN END TO END, PASS.** Already
fixture-driven (its own XDG tree, stub `xdg-mime`/`xdg-settings`); the new
half adds a fixture `mime/globs2` and two desktop entries that declare
`MimeType`, then pins `search-types` shape, that candidates are only installed
applications, that a one-character query is not a search, that `set-type`
writes exactly one type, and that an unknown type, an unknown application, and
a type this system does not have are each refused with nothing reaching
xdg-mime.
- **`setup/readme-contract`, `quickshell/search-routing-contract`,
`quickshell/settings-ownership-contract` — RUN, PASS** after the nine new
search entries.
### Extended contracts, new halves run in isolation (3)
`disks-contract` and `snapshots-contract` each keep a first half that reads the
live machine (`panama-disks snapshot`, `panama-snapshots snapshot`), so neither
was run end to end. The **new** halves were extracted into scratch scripts and
run on their own, and both pass:
- **`quickshell/disks-contract`** (new half run, PASS). It is hermetic: `env -i`,
`HOME` and `XDG_CACHE_HOME` inside the scratch tree, the block tree from
`PANAMA_DISKS_LSBLK`, and recording stubs for gio/flatpak/pkexec/dnf/podman.
- **Two proofs run before anything is cleaned**: `breakdown.path` must be the
fixture home, and the cache segment must be the 12 × 111111 bytes this
contract wrote a moment earlier. Only then is `clean cache` allowed to run —
which is what makes it safe to assert that the symlink planted inside the
fixture cache was unlinked rather than followed, and that the file it points
at outside the cache survived.
- **The arithmetic**: home + applications + caches + system == `usedBytes`
exactly, `free` == `freeBytes`, every segment non-negative, and
`exceedsUsed` false on the fixture. The two ways the measurement can be
wrong (`complete === false`, `exceedsUsed === true`) are pinned as things
the page says out loud.
- **The anti-racket stance**, pinned as an absence in the cleanup card's own
string literals: no "running out", "recommended", "act now", "junk", "safe
to remove", "you should", no exclamation marks, and no single button that
clears everything. The extractor takes the *tightest* card whose own title
is the cleanup one, and scans only double-quoted literals — QML is full of
`!` and none of it is shouting.
- **Each cleanable does its own one thing**: `gio trash --empty` (never rm),
`flatpak uninstall --unused --noninteractive`, and
`pkexec dnf clean packages` — never `clean all`, never a removal. An AST
walk pins that this is the *only* package-manager command in the whole
helper.
- **The unused-runtime size is borrowed, not recomputed**: with
`PANAMA_APPLICATIONS_HELPER` pointed at a recording stub, the row must
report what that stub said (780000) and the stub must have been asked. Two
ideas of "unused" would show one number and free another.
- `backing_device` reading findmnt's SOURCE rather than `st_dev` is pinned
with the reason: btrfs gives every subvolume its own device number, and the
first version of the breakdown lost the system-wide flatpak installation to
exactly that.
- **`quickshell/snapshots-contract`** (new half run, PASS). Retention is
exercised against a recording `snapper` stub under `env -i`, after asserting
snapper resolves inside the stub directory — the only way to run a write verb
without changing how this machine keeps its snapshots.
- Refusals here are read from the JSON `error` field, not an exit code: this
helper answers a refusal with fresh state and an `error` in it. Whether a
refusal happened *first* is read from the absence of a `set-config` in the
log, because the helper reads the configuration list on its way back out
either way.
- Ceiling pinned at **50 in both places** — the helper's `RETENTION_LIMIT` and
the service's `retentionMax` — because a dropdown offering a value the
helper refuses fails after the user has already chosen.
- The browser-card fix is pinned structurally: a brace-aware scan (strings and
comments skipped) walks the QML ancestry of `Snapshots.browseEntries` and
fails if the enclosing card is a delegate of the per-volume Repeater. This
was checked against the OLD page first, where it correctly fails, so it is
not a check that passes on anything.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — nine entries added, all routing to leaves that
exist in `SettingsRoutes`: **Installed applications**, **Uninstall an
application**, **Application permissions**, **Install applications**,
**Autostart**, **File associations** → `applications`; **Clean up storage**,
**Application caches** → `storage`; **Snapshot retention** → `snapshots`.
Checked by evaluating the array: 154 entries, no duplicate labels, every
`page` a real leaf, and none of `settings-search-contract`'s 21 ranked queries
or 9 leaf-routing queries matches a new entry, so no pinned top result moves.
(The first draft of the Snapshot retention detail said "each volume keeps",
which put it in the result set for the pinned `volume` query; reworded.)
- No settings docs regenerated: this phase adds no schema keys. Every new
setting is system state (flatpak, dnf, snapper, xdg-mime), not a Panama
preference — verified by reading the diff.
### Still open before the run
- **`disks-contract` and `snapshots-contract` have not been run end to end.**
Their first halves read the live machine, so they want the same quiet moment
the other system contracts do. Nothing in the new halves depends on that
order.
- **`settings-search-contract` has not been run**: it starts a Quickshell
harness. The nine new entries were checked statically as described above, but
the schema-label sweep and the ranked queries need the live harness.
- **`settings-pages-contract`, `settings-docs-contract`, `settings-jump-contract`
and `settings-buttons-contract` were not run against the three rebuilt pages.**
ApplicationsPage grew four new components (InstalledAppRow, FileTypePicker,
SettingsChip, StorageBreakdownBar); the qmldir registration of the first three
is pinned by `applications-settings-contract`, but nothing here has loaded the
QML.
- **`app-library-contract` pins the fixture seam `PANAMA_EXTRAS_DIR` and the
permission bucket wording** ("Camera", "Microphone", "Full file system
access", "Home folder", "Network", "Devices"). Both match the landed helper;
changing either is meant to be a deliberate act that updates this contract,
and will read as a surprise the first time somebody tries.
- Run order for this phase: the hermetic and source-only ones first
(`app-library-contract`, `applications-settings-contract`,
`default-apps-contract`, `search-routing-contract`,
`settings-ownership-contract`, `setup/readme-contract`), then the read-only
system ones (`disks-contract`, `snapshots-contract`, `containers-contract`),
then the harness ones (`settings-search-contract`, `health-ui-contract`), and
`settings-pages-contract` last, as before.
@@ -0,0 +1,141 @@
# Applications, Storage & Snapshots redesign
Approved mock: `home-mocks/applications.html` (scratchpad, :8642). Spec wins over mock on
conflict. Scope: the Applications *tab* (Gaming and Screen Intelligence untouched) plus the
System category's Storage and Snapshots tabs, folded into this phase by decision.
## Goals
1. **Applications becomes an app manager**: searchable installed list (DesktopEntries +
flatpak metadata), flatpak uninstall, honest system-package rows, permission summaries,
autostart toggle per app, jump chips to the app's rules elsewhere.
2. **The panama apps catalog goes native**: category chips → entries with installed state,
installing through the same dnf/flatpak paths (polkit prompts). Same files as the CLI.
3. **Defaults modernized**: role dropdowns replace the hand-rolled accordion; a single-file-type
override ("One file type") for when a role's family is too broad.
4. **Storage**: proportional breakdown bar with honest captions; folder bars share ONE scale;
"Clean up, honestly" — itemized, sized, click-each, nothing pre-selected, nothing nags
(the anti-racket rules are contract-pinned); the duplicate container affordances merge.
5. **Snapshots**: retention becomes editable (wire the caller-less `Snapshots.setRetention`);
the file browser becomes its own card that opens regardless of source-card state (fixes the
collapsed-card bug); rollback stays absent (contract).
Non-goals: dnf package removal from Settings (honest refusal row instead), full flatpak
permission *editing* (summary + Open Flatseal when installed), per-app storage classification
beyond the flatpak sizes, portal-grant editing, snapshot space measurement (quotas honesty
stays).
## New helper: `scripts/panama-applications` (pinned verbs)
House discipline (validated inputs, JSON out, mutations return fresh state, bounded timeouts):
- `flatpaks``[{ id, name, size, origin }]` (from `flatpak list` machine-readable columns).
- `permissions <app-id>``{ summary: [human strings], raw: {...} }` from `flatpak info
--show-permissions` — curated buckets: Camera, Microphone, Full file system access, Home
folder, Network, Devices; unknown keys summarized honestly, never dropped silently.
- `uninstall <app-id>` → `flatpak uninstall --noninteractive` (app only; a second verb
`unused-runtimes` lists what `--unused` would remove, and `clean-unused` removes them —
the Storage cleanup row uses these).
- `catalog` → categories + entries from `setup/packages/extras/*` (same parser rules as
`setup/lib/extras-catalog`: `flatpak:<id> | Label`, bare dnf names, indented continuations),
each entry `{ id, label, kind: flatpak|dnf, installed }` (installed via flatpak info / rpm -q).
- `install <category> <entry-id>` → flatpak: `flatpak install --noninteractive flathub <id>`;
dnf: `pkexec dnf install -y <pkg>`. Refuses ids not present in the catalog files (no
arbitrary package installation through this surface — contract-pinned).
## Extended helpers
- `scripts/panama-default-apps`: `search-types <query>` → matching MIME types with their
current handler + candidate apps; `set-type <mime> <desktop-id>` → `xdg-mime default`
(validated against installed desktop entries). Role behavior untouched.
- `scripts/panama-disks`: `breakdown` → segments `{ home, applications, caches, system, free }`
in bytes — home = the scan targets under ~ minus caches, applications = flatpak app+runtime
sizes (user-readable sums), caches = ~/.cache, system = used the others ("System &
everything else" caption — honest arithmetic remainder), free from the fs. `cleanables` →
`[{ id, label, detail, bytes, privileged }]` for: cache (~/.cache), trash, flatpak-unused,
dnf-cache (privileged). `clean <id>` executes exactly one, refusing unknown ids; trash via
`gio trash --empty`; cache via a guarded rm of ~/.cache/* (never follows symlinks out).
- `scripts/panama-snapshots`: retention already supported (`set-retention`); add
`set-retention` argument validation for the three horizons if not already split
(hourly/daily/weekly numbers 0-50).
## Services (A)
- **`services/AppLibrary.qml`** (new): merges `DesktopEntries.applications` with the flatpak
metadata (id-matched via the entry's flatpak export), exposes `apps: [{ entryId, name, icon,
kind: flatpak|system, flatpakId, size }]`, `permissionsFor(id)` (cached),
`uninstall(flatpakId)`, catalog state (`categories`, `entriesFor(cat)`, `install(...)`),
`busy/lastError`, seam `PANAMA_APPLICATIONS_HELPER`.
- **`services/DefaultApps.qml`**: `searchTypes(query)`, `setType(mime, desktopId)` wrappers.
- **`services/Disks.qml`**: `breakdown`, `cleanables`, `clean(id)` wrappers; cleanables
refresh after any clean.
- **`services/Snapshots.qml`**: nothing new needed beyond confirming `setRetention`'s shape
matches three-horizon editing; adjust if it takes a single string.
## UI (B)
**ApplicationsPage.qml** rebuilt: Installed applications card (search field filters name +
id; rows: letter/icon tile, name + source badge, subtitle id·size or "Installed by the system
package manager"; expanded: Permissions row (+ Open Flatseal when installed), Start with the
session (autostart toggle — creates/toggles the autostart entry for that app), "Elsewhere in
Settings" jump chips (shown only where a rule exists: Notifs.appRule customized/present →
notifications; app in AudioDevices applications → sound; PrivacyPage relevance is static —
link when the app id appears in its rules if cheaply knowable, else omit), Uninstall (flatpak,
two-stage danger) or the honest dnf refusal row with the exact command). Browse-the-catalog
card (category chips, entry rows, Install with polkit caption for dnf entries, INSTALLED
badge). Default applications card: role dropdowns via OptionPickerRow-style (candidates from
the existing role matching; keep all 10 roles + family-count details), then the "One file
type" row expanding to a search field over `searchTypes` with per-type app pickers. Autostart
card: existing rows restyled with the standardized confirm; compositor autostart row kept
read-only. The Search card stays but the hardcoded "Super+Space" is replaced by a live
`Keybinds` lookup (launcher description match, literal fallback).
**StoragePage.qml**: breakdown stack bar + legend at top (honest captions; "System &
everything else" for the remainder); Folders card bars share one scale (the disk, or the
largest — pick the DISK so it's comparable to the free-space bar, with a note); "Clean up,
honestly" card from `cleanables` (row per item: label, honest cost detail, size, Clear… with
two-stage confirm; privileged rows carry the password caption; zero-byte rows render inert
with "nothing to do"); the "Unused container images" kitty-terminal row is REMOVED (the
Containers card below already routes properly — one affordance, not two). Drive/Filesystems/
Removable/Swap cards kept.
**SnapshotsPage.qml**: per-config Keep row becomes three dropdowns (hourly/daily/weekly →
setRetention); the browser moves OUT of the volume card into its own card rendered whenever
browsing state is set (opening from a collapsed card now works — this is the bug fix);
timeline/preview/expander kept; Space card kept with its honesty.
## Search & docs (C)
New entries: Installed applications, Uninstall an application, Application permissions,
Install applications (catalog), Autostart, File associations → applications; Clean up storage,
Application caches → storage; Snapshot retention → snapshots. Docs regen only if schema
changes (none expected — verify).
## Contracts (C — write, never run)
- `applications-settings-contract`: reconcile with the rebuilt page (role labels, card
titles, AutostartAppPicker, the bun matcher extraction).
- NEW `app-library-contract`: fixture-driven (stub flatpak/rpm): catalog parser agreement
with `setup/lib/extras-catalog` rules, install refuses off-catalog ids, uninstall argv
shape, permissions summary buckets, dnf never invoked for removal anywhere in the helper.
- `disks-contract`: extend — breakdown arithmetic honesty (segments sum ≤ used, remainder
labeled), cleanup rules: every cleanable itemized with bytes, nothing auto-selected, no
cleanable executes without its id being explicitly passed, cache rm is guarded, trash via
gio. Pin the anti-racket copy stance (no urgency language).
- `snapshots-contract`: extend — setRetention wired from the page, browser renders
independent of the volume card's open state, rollback still absent.
- `default-apps-contract` family rules untouched; new needles for set-type validation.
- Backlog: Phase 10. README count line if count changes.
## Agent ownership (parallel)
- **A**: `scripts/panama-applications` (new), `scripts/panama-default-apps`,
`scripts/panama-disks`, `scripts/panama-snapshots` (only if retention args need splitting),
`services/AppLibrary.qml` (new), `services/DefaultApps.qml`, `services/Disks.qml`,
`services/Snapshots.qml`.
- **B**: `modules/settings/ApplicationsPage.qml`, `StoragePage.qml`, `SnapshotsPage.qml`,
new components + qmldir.
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
Hard rules for everyone: no live mutations (no flatpak install/uninstall, no dnf, no rm, no
gio trash, no snapper writes, no xdg-mime writes) — read-only probes and stub fixtures only;
no test runs; valid QML/Python at every save. B programs against the pinned APIs.
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env bash
# The Applications page can now install and remove software, which makes
# `panama-applications` the most dangerous helper in the shell. Three things
# have to hold, and each of them is a way a settings page could damage a machine
# rather than manage it:
#
# 1. The catalog is the ONLY thing installable. A page that took an id from
# anywhere else would be a general-purpose package installer wearing a
# settings icon, and `pkexec dnf install -y $anything` is as bad as that
# sounds. So an id the catalog does not contain is refused before any
# command runs.
# 2. The catalog the page reads is the SAME catalog `panama apps` and the
# installer read. Two parsers over one file format drift, and the drift is
# invisible: the page simply offers a slightly different list, or installs
# a slightly different target. This contract runs both parsers over the
# same files and compares the answers rather than reading either one.
# 3. Removal is flatpak and nothing else. Removing a dnf package from a
# settings page can take the desktop, the compositor, or the kernel with
# it -- Settings says so honestly and prints the command instead. That is
# only true while no removal path exists at all, so it is checked as an
# absence in the source, not as a refusal at runtime: a refusal can be
# bypassed by a later caller; a path that does not exist cannot.
#
# SAFETY. This runs the real helper against a fake machine, so it must be
# impossible for it to reach the real one. Verified before anything runs:
#
# 1. every binary the helper names is resolved through PATH (asserted
# statically -- an absolute /usr/bin/flatpak would walk straight past the
# stubs);
# 2. PATH's first entry is the stub directory, and flatpak, rpm, dnf, pkexec
# and gio each resolve there;
# 3. the helper is run under `env -i` with HOME and every XDG directory inside
# the scratch tree, so anything it writes lands there;
# 4. dnf, sudo and rpm-ostree are stubs that record and FAIL, so a real
# package transaction is not merely unlikely, it exits non-zero and is
# visible in the log.
#
# The catalog itself is a fixture, not `setup/packages/extras`: the point is to
# exercise the format's corners (a labelled entry, an unlabelled one, a dnf
# name, comments, blank lines, and indented continuations) rather than whatever
# the shipped catalog happens to contain this week.
#
# Set PANAMA_APPLICATIONS_STATIC_ONLY=1 to run only the source-reading half,
# which touches nothing at all.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-applications"
service="$repo_dir/config/dot/quickshell/services/AppLibrary.qml"
catalog_lib="$repo_dir/setup/lib/extras-catalog"
fail() {
printf 'app library contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$catalog_lib"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-applications is not executable'
# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked first because the dynamic half's safety rests on it.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z0-9-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
# ── Static: there is no removal path for system packages ─────────────────────
#
# The page's honest refusal row is a promise that Settings cannot do this. The
# promise is kept by the code not existing. `rpm` is allowed, but only to ask a
# question: -q and nothing else.
python3 - "$helper" <<'PY' || fail 'the helper can remove a system package'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
REMOVAL = {"remove", "erase", "autoremove", "-e", "--erase", "history", "rollback"}
findings = []
for node in ast.walk(tree):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = [element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
words = set(literals)
if {"dnf", "rpm-ostree", "yum"} & words:
offending = words & REMOVAL
if offending:
findings.append(f"line {node.lineno}: dnf command with {sorted(offending)}")
if "rpm" in words:
# A query is a question. Anything else is a transaction.
if not ({"-q", "-qa", "--query"} & words) or (words & REMOVAL):
findings.append(f"line {node.lineno}: rpm invoked for something other than a query: {literals}")
if findings:
print("; ".join(findings), file=sys.stderr)
raise SystemExit(1)
PY
# Even in a string that never becomes a command list. There is no reason for
# these words to be in this file, and a helper that builds its argv from a
# format string would slip past the check above.
forbidden="$(grep -nE '(dnf|rpm-ostree|yum)[^\n]{0,40}(remove|erase|autoremove)|rpm[^\n]{0,20} -e' "$helper")"
[[ -z "$forbidden" ]] \
|| fail "the helper spells out a package removal: $forbidden"
# ── Static: uninstall is flatpak, alone ──────────────────────────────────────
python3 - "$helper" <<'PY' || fail 'uninstall does not build a flatpak-only command'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name in ("uninstall", "uninstall_app")),
None)
if target is None:
print("no uninstall function", file=sys.stderr)
raise SystemExit(1)
OTHER = {"dnf", "rpm", "yum", "pkexec", "sudo", "rpm-ostree", "sh", "bash"}
commands = []
for node in ast.walk(target):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = [element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if literals and literals[0] in OTHER:
print(f"line {node.lineno}: uninstall reaches for {literals[0]}", file=sys.stderr)
raise SystemExit(1)
if literals and literals[0] == "flatpak":
commands.append(literals)
if not commands:
print("uninstall never builds a flatpak command", file=sys.stderr)
raise SystemExit(1)
if not any("uninstall" in command and "--noninteractive" in command for command in commands):
print(f"uninstall is not a non-interactive flatpak uninstall: {commands}", file=sys.stderr)
raise SystemExit(1)
PY
# ── Static: the service's shape, and its seam ────────────────────────────────
grep -q 'pragma Singleton' "$service" || fail 'AppLibrary is not a singleton'
grep -q 'PANAMA_APPLICATIONS_HELPER' "$service" \
|| fail 'the service has no helper-path seam, so nothing can point it at a stub'
grep -qE 'command\s*:\s*"' "$service" \
&& fail 'Process command must be an argument array, or an application id becomes shell'
for needle in 'property string lastError' 'function permissionsFor' 'function uninstall' \
'function install' 'function entriesFor'; do
grep -q "$needle" "$service" || fail "the service is missing: $needle"
done
if [[ "${PANAMA_APPLICATIONS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'app library contract: PASS (static)\n'
exit 0
fi
command -v jq >/dev/null 2>&1 || { printf 'app library contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'app library contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ─────────────────────────────────────────────────────────
work="$(mktemp -d /tmp/panama-app-library.XXXXXX)"
stub_dir="$work/bin"
state_dir="$work/state"
home_dir="$work/home"
extras_dir="$work/extras"
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$extras_dir" \
"$work/config" "$work/data" "$work/cache" "$work/run"
: >"$state_dir/argv"
trap 'rm -rf "$work"' EXIT
# The catalog, written to exercise the format rather than to be realistic.
# Every line here is a rule in setup/lib/extras-catalog's header comment.
cat >"$extras_dir/demo" <<'CATALOG'
# A comment, and the blank line under it, are not entries.
flatpak:org.example.Alpha | Alpha Editor
flatpak:org.example.Bravo
charlie-tool
delta-tool | Delta
flatpak:org.example.Echo | Echo Studio
flatpak:org.example.Echo.Plugin.One
flatpak:org.example.Echo.Plugin.Two
CATALOG
cat >"$extras_dir/more" <<'CATALOG'
zulu-tool
flatpak:org.example.Zulu | Zulu
CATALOG
# flatpak, recorded rather than performed. Which ids it claims are installed is
# the fixture's business, not the helper's: `installed` in the catalog has to
# come from asking, and this is what answers.
cat >"$stub_dir/flatpak" <<STUB
#!/usr/bin/env bash
printf 'flatpak %s\n' "\$*" >>"$state_dir/argv"
joined="\$*"
permissions() {
case "\$1" in
org.example.Permissive)
printf '[Context]\n'
printf 'shared=network;ipc;\n'
printf 'sockets=x11;wayland;pulseaudio;\n'
printf 'devices=all;\n'
printf 'filesystems=host;\n'
printf 'unrecognized-capability=yes;\n'
printf '[Session Bus Policy]\n'
printf 'org.freedesktop.Flatpak=talk\n' ;;
org.example.Homey)
printf '[Context]\n'
printf 'sockets=wayland;\n'
printf 'filesystems=home;\n' ;;
*)
printf '[Context]\n'
printf 'sockets=wayland;\n' ;;
esac
}
case "\$joined" in
*"--show-permissions"*)
for argument in "\$@"; do
case "\$argument" in
-*) ;;
info) ;;
*) permissions "\$argument"; exit 0 ;;
esac
done
exit 0 ;;
info*)
# Only these four are on the fake machine.
case "\$joined" in
*org.example.Alpha*|*org.example.Permissive*|*org.example.Homey*|*org.example.Sandboxed*)
exit 0 ;;
esac
printf 'error: %s not installed\n' "\$2" >&2
exit 1 ;;
list*)
printf 'org.example.Alpha\tAlpha Editor\t412.5 MB\tflathub\n'
printf 'org.example.Permissive\tPermissive\t88.1 MB\tflathub\n'
printf 'org.example.Homey\tHomey\t12.0 MB\tflathub\n'
printf 'org.example.Sandboxed\tSandboxed\t4.2 MB\tflathub\n'
exit 0 ;;
esac
exit 0
STUB
# rpm answers questions and nothing else. Anything but a query is a failure the
# log will show. One package on this fake machine is installed: charlie-tool,
# which the catalog offers, so `installed` has something true to report.
cat >"$stub_dir/rpm" <<STUB
#!/usr/bin/env bash
printf 'rpm %s\n' "\$*" >>"$state_dir/argv"
case "\$1" in
-qa|--query|-q)
printf 'charlie-tool\nbash\nkernel\n'
exit 0 ;;
esac
printf 'app library contract: rpm was asked to do something other than query\n' >&2
exit 1
STUB
# pkexec records the privileged command it was handed and runs NOTHING.
cat >"$stub_dir/pkexec" <<STUB
#!/usr/bin/env bash
printf 'pkexec %s\n' "\$*" >>"$state_dir/argv"
exit 0
STUB
# Every other way out is closed rather than left open.
for blocked in dnf yum sudo rpm-ostree gio flatpak-builder; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
printf 'app library contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
HOME="$home_dir" \
XDG_CONFIG_HOME="$work/config" \
XDG_DATA_HOME="$work/data" \
XDG_CACHE_HOME="$work/cache" \
XDG_RUNTIME_DIR="$work/run" \
PANAMA_EXTRAS_DIR="$extras_dir" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in flatpak rpm dnf pkexec sudo gio; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
done
# A refusal is a non-zero exit and a sentence on stderr, which is how every
# helper in this repo says no.
refused() { ! runh "$@" >/dev/null 2>&1; }
log() { cat "$state_dir/argv"; }
no_package_transaction() {
local where="$1"
grep -qE '^(dnf|yum|sudo|rpm-ostree) ' "$state_dir/argv" \
&& fail "$where reached a package manager directly: $(log)"
grep -qE '^rpm .*(-e|--erase|remove)' "$state_dir/argv" \
&& fail "$where asked rpm to remove something: $(log)"
return 0
}
# ── The catalog is read by ONE set of rules ──────────────────────────────────
#
# Both parsers, the same files, compared line for line. Reading either one to
# build the expectation would pass just as happily when both are wrong.
source "$catalog_lib"
catalog="$(runh catalog 2>/dev/null)" || fail 'catalog failed against the fixture'
jq -e '(.categories | type == "array") and (.categories | length) == 2' <<<"$catalog" >/dev/null \
|| fail "catalog did not report the two fixture categories: $catalog"
expected_categories="$(catalog_categories "$extras_dir" | sort | tr '\n' ' ')"
actual_categories="$(jq -r '.categories[] | if type == "object" then .name else . end' <<<"$catalog" \
| sort | tr '\n' ' ')"
[[ "$expected_categories" == "$actual_categories" ]] \
|| fail "the two parsers disagree about the categories: '$expected_categories' vs '$actual_categories'"
for category in demo more; do
# `target<TAB>label` from the shell library, turned into the id/label/kind
# triple the page is given. The mapping is the claim: the id is the catalog
# line's target VERBATIM -- `flatpak:` prefix and all, because that is the
# string `install` matches against -- and the prefix becomes the kind.
expected="$(catalog_entries "$extras_dir/$category" | while IFS=$'\t' read -r target label; do
if [[ "$target" == flatpak:* ]]; then
printf '%s\t%s\tflatpak\n' "$target" "$label"
else
printf '%s\t%s\tdnf\n' "$target" "$label"
fi
done)"
actual="$(jq -r --arg category "$category" '
(.entries[$category] // (.categories[] | select(.name == $category) | .entries))[]
| [.id, .label, .kind] | @tsv' <<<"$catalog" 2>/dev/null)"
[[ -n "$actual" ]] || fail "the catalog reports no entries for '$category': $catalog"
if [[ "$expected" != "$actual" ]]; then
printf 'from setup/lib/extras-catalog:\n%s\nfrom panama-applications:\n%s\n' \
"$expected" "$actual" >&2
fail "the two catalog parsers disagree about '$category'"
fi
done
# The indented lines belong to the entry above them. Listing them separately
# would put fifteen OBS plugins in the menu as if they were applications.
jq -e '[.. | objects | select(has("id")) | .id | select(contains(".Plugin."))] | length == 0' \
<<<"$catalog" >/dev/null \
|| fail 'an indented continuation line is offered as an entry of its own'
# `installed` is asked, not assumed: only what the stubs admit to is marked.
installed="$(jq -r '[.. | objects | select(has("id") and has("installed")) | select(.installed) | .id]
| sort | join(",")' <<<"$catalog")"
[[ "$installed" == "charlie-tool,flatpak:org.example.Alpha" ]] \
|| fail "installed state does not match what flatpak and rpm were willing to confirm: '$installed'"
# ── Nothing outside the catalog can be installed ─────────────────────────────
#
# The refusal must happen before any command runs, which the empty log is what
# proves. Checking only the exit code would pass with the guard deleted, since
# the stub flatpak would fail on a nonsense id anyway.
for bad in 'org.evil.Payload' 'charlie-tool; reboot' '--unused' '' '../../etc/passwd' \
'flatpak:org.example.Echo.Plugin.One' 'org.example.Bravo' 'flatpak:org.evil.Payload'; do
: >"$state_dir/argv"
refused install demo "$bad" \
|| fail "an id the catalog does not offer was accepted for install: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a refused install still ran something: ${bad@Q}: $(log)"
done
for bad_category in 'nonexistent' '../extras' '' 'demo/../more'; do
: >"$state_dir/argv"
refused install "$bad_category" flatpak:org.example.Bravo \
|| fail "an unknown catalog category was accepted: ${bad_category@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a refused category still ran something: ${bad_category@Q}: $(log)"
done
# An entry that exists, but in the other category, is still off-catalog here.
: >"$state_dir/argv"
refused install demo zulu-tool \
|| fail 'an entry from another category was installed as if it belonged to this one'
[[ ! -s "$state_dir/argv" ]] || fail "a cross-category install still ran something: $(log)"
# ── What an accepted install actually runs ───────────────────────────────────
: >"$state_dir/argv"
runh install demo flatpak:org.example.Bravo >/dev/null 2>&1
grep -Eq 'flatpak install .*--noninteractive.*flathub .*org\.example\.Bravo' "$state_dir/argv" \
|| fail "installing a Flathub entry did not reach flatpak as expected: $(log)"
no_package_transaction 'installing a flatpak'
# The extensions ride along with the entry that owns them, and nothing else
# does: an install that quietly pulled a neighbouring entry would make the
# catalog's grouping a lie.
: >"$state_dir/argv"
runh install demo flatpak:org.example.Echo >/dev/null 2>&1
grep -Fq 'org.example.Echo' "$state_dir/argv" \
|| fail "installing an entry with extensions did not install the entry: $(log)"
strays="$(grep -oE 'org\.example\.[A-Za-z.]+' "$state_dir/argv" \
| grep -vE '^org\.example\.Echo(\.Plugin\.(One|Two))?$' | sort -u | tr '\n' ' ')"
[[ -z "$strays" ]] || fail "installing one entry reached for another: $strays"
# A dnf entry goes through polkit, and dnf is never run directly.
: >"$state_dir/argv"
runh install more zulu-tool >/dev/null 2>&1
grep -Eq '^pkexec .*dnf .*install .*zulu-tool' "$state_dir/argv" \
|| fail "installing a package entry did not go through pkexec: $(log)"
grep -qE '^dnf ' "$state_dir/argv" \
&& fail "the helper ran dnf directly instead of asking polkit first: $(log)"
# ── Removal is flatpak, and only for a flatpak ───────────────────────────────
: >"$state_dir/argv"
runh uninstall org.example.Alpha >/dev/null 2>&1
grep -Eq 'flatpak uninstall .*--noninteractive.*org\.example\.Alpha' "$state_dir/argv" \
|| fail "uninstall did not reach flatpak with the expected arguments: $(log)"
no_package_transaction 'uninstalling an application'
# A dnf package name is not an application id, whatever the caller believes.
: >"$state_dir/argv"
runh uninstall charlie-tool >/dev/null 2>&1
no_package_transaction 'uninstalling a system package name'
for bad in '--unused' '-y' '' 'org.example.Alpha; reboot' '../../org.example.Alpha' \
"$(printf 'a%.0s' {1..300})"; do
: >"$state_dir/argv"
refused uninstall "$bad" \
|| fail "uninstall accepted a malformed application id: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a malformed application id reached flatpak before being refused: ${bad@Q}: $(log)"
done
# ── Unused runtimes are listed without being removed ─────────────────────────
#
# The Storage page's cleanup row shows a size before anything happens, so the
# listing verb has to be a question. A `flatpak uninstall --unused` here would
# remove gigabytes at the moment the page merely rendered.
: >"$state_dir/argv"
runh unused-runtimes >/dev/null 2>&1
while read -r line; do
[[ "$line" == flatpak* ]] || continue
case "$line" in
*--dry-run*|*list*|*info*) ;;
*uninstall*) fail "listing unused runtimes actually removed them: $line" ;;
esac
done <"$state_dir/argv"
: >"$state_dir/argv"
runh clean-unused >/dev/null 2>&1
grep -Eq 'flatpak uninstall .*--unused' "$state_dir/argv" \
|| fail "clean-unused does not remove unused runtimes: $(log)"
grep -Eq 'flatpak uninstall .*--noninteractive' "$state_dir/argv" \
|| fail "clean-unused would stop for a prompt nobody can answer: $(log)"
# ── Permissions are summarized, and nothing is dropped ───────────────────────
#
# The buckets are the point: "filesystems=host" and "filesystems=home" are one
# character apart in the metadata and worlds apart in what they mean, so the
# summary has to tell them apart rather than reporting "file access".
permissive="$(runh permissions org.example.Permissive 2>/dev/null)" \
|| fail 'permissions failed for an installed application'
jq -e '(.summary | type == "array") and has("raw")' <<<"$permissive" >/dev/null \
|| fail "permissions is missing its summary or its raw metadata: $permissive"
summary_of() { jq -r '.summary | join(" | ")' <<<"$1"; }
permissive_summary="$(summary_of "$permissive")"
for bucket in 'Full file system access' 'Network' 'Devices' 'Camera' 'Microphone'; do
grep -Fq "$bucket" <<<"$permissive_summary" \
|| fail "a permission the application really has is missing from the summary ($bucket): $permissive_summary"
done
# Unknown keys are summarized honestly rather than dropped: a Flatpak feature
# added next year must not silently become "this application asks for nothing".
grep -Fq 'unrecognized-capability' <<<"$permissive" \
|| fail 'a permission key the helper does not recognize disappeared entirely'
homey="$(runh permissions org.example.Homey 2>/dev/null)" \
|| fail 'permissions failed for a home-folder application'
homey_summary="$(summary_of "$homey")"
grep -Fq 'Home folder' <<<"$homey_summary" \
|| fail "an application with home access is not described as having it: $homey_summary"
grep -Fq 'Full file system access' <<<"$homey_summary" \
&& fail "home access is being reported as access to the whole filesystem: $homey_summary"
# And the summary is derived, not decorative: a sandboxed application says less.
sandboxed="$(runh permissions org.example.Sandboxed 2>/dev/null)"
sandboxed_summary="$(summary_of "$sandboxed")"
for bucket in 'Full file system access' 'Network' 'Camera'; do
grep -Fq "$bucket" <<<"$sandboxed_summary" \
&& fail "a sandboxed application is credited with $bucket: $sandboxed_summary"
done
[[ "$(jq '.summary | length' <<<"$permissive")" -gt "$(jq '.summary | length' <<<"$sandboxed")" ]] \
|| fail 'the permissive and the sandboxed application summarize the same, so nothing is being read'
for bad in '' '--show-permissions' 'org.example.Alpha; reboot' '../escape'; do
: >"$state_dir/argv"
refused permissions "$bad" \
|| fail "permissions accepted a malformed application id: ${bad@Q}"
done
# ── The installed list carries what the rows draw ────────────────────────────
flatpaks="$(runh flatpaks 2>/dev/null)" || fail 'flatpaks failed against the stub'
jq -e 'type == "array" and length == 4' <<<"$flatpaks" >/dev/null \
|| fail "flatpaks did not report the four installed applications: $flatpaks"
jq -e '[.[] | has("id") and has("name") and has("size") and has("origin")] | all' \
<<<"$flatpaks" >/dev/null || fail "an installed application is missing part of its shape: $flatpaks"
jq -e '[.[] | (.id | length > 0) and (.name | length > 0) and (.origin | length > 0)] | all' \
<<<"$flatpaks" >/dev/null || fail "an installed application has an empty field: $flatpaks"
jq -e '[.[].id] | index("org.example.Alpha") != null' <<<"$flatpaks" >/dev/null \
|| fail "the machine-readable listing was not parsed into ids: $flatpaks"
# The size arrives as flatpak's own words ("412.5 MB") and as bytes beside them.
# The bytes may be null -- a flatpak that cannot say is better than a number
# invented for the sake of having one -- but never a string the page would then
# have to parse a second time.
jq -e '[.[].sizeBytes | type] | all(. == "number" or . == "null")' <<<"$flatpaks" >/dev/null \
|| fail "an installed size is neither a number of bytes nor honestly absent: $flatpaks"
jq -e '[.[] | select(.size == "412.5 MB") | .sizeBytes] | first > 400000000' <<<"$flatpaks" >/dev/null \
|| fail "flatpak's own size was not parsed into bytes: $flatpaks"
refused bogus-verb || fail 'an unknown command was accepted'
# ── Nothing anywhere ran a package transaction ───────────────────────────────
: >"$state_dir/argv"
runh catalog >/dev/null 2>&1
runh flatpaks >/dev/null 2>&1
no_package_transaction 'reading the catalog and the installed list'
printf 'app library contract: PASS (catalog agreement, install refusals, flatpak-only removal, permission buckets)\n'
+212 -33
View File
@@ -1,5 +1,22 @@
#!/usr/bin/env bash
# The Applications page manages applications now, rather than only pointing file
# types at them. What is pinned here is the part that is easy to get subtly
# wrong and impossible to notice:
#
# * the role matcher, which decides which applications a role may be set to.
# It is extracted from the page and run against fixtures, because every bug
# it has ever had was silent -- a role that offered nothing but the
# application it already had, and nobody could tell whether that was the
# matcher or the machine;
# * removal being honest: Flatpak applications come off from here, system
# packages do not, and the row says the command instead of pretending;
# * the things a page can quietly lose in a rebuild -- the escape hatch for a
# single file type, the read-only compositor autostart, the launcher chord
# that is read rather than hardcoded.
#
# Reading only: nothing here runs the page or changes a setting.
set -euo pipefail
fail() {
@@ -8,45 +25,174 @@ fail() {
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml"
settings="$project_root/config/dot/quickshell/modules/settings"
page="$settings/ApplicationsPage.qml"
row="$settings/InstalledAppRow.qml"
picker="$settings/AutostartAppPicker.qml"
types="$settings/FileTypePicker.qml"
qmldir="$settings/qmldir"
[[ -f "$page" ]] || fail 'Applications page is missing'
for path in "$page" "$row" "$picker" "$types" "$qmldir"; do
[[ -f "$path" ]] || fail "missing $path"
done
assert_contains() {
rg -F --quiet "$1" "$page" || fail "page is missing: $1"
}
assert_row_contains() {
rg -F --quiet "$1" "$row" || fail "the installed-application row is missing: $1"
}
assert_contains 'SettingsPage {'
assert_contains 'objectName: "applications"'
assert_contains 'DesktopEntries.applications.values'
assert_contains 'AppLibrary'
assert_contains 'DefaultApps'
assert_contains 'SettingsCard {'
assert_contains 'SettingRow {'
assert_contains 'activatable:'
assert_contains 'ActionRow {'
assert_contains 'TextRow {'
for label in Browser Mail Files Terminal Music Images Video; do
assert_contains "label: \"$label\""
# ── The cards, and what each one is for ──────────────────────────────────────
for title in 'Installed applications' 'Browse the catalog' 'Default applications' \
'Autostart' 'Search'; do
rg -F --quiet "title: \"$title\"" "$page" || fail "the page has no \"$title\" card"
done
assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"'
# Every role still has a row, and the role list is still ten long: a family that
# quietly disappears takes its file types with it.
for label in Browser Mail Files Terminal Images Music Video Documents Text Archives; do
assert_contains "label: \"$label\""
done
[[ "$(rg --count 'key: "' "$page")" == "10" ]] \
|| fail 'the ten default-application roles are no longer ten'
# The roles are pickers now, not an accordion of buttons.
assert_contains 'OptionPickerRow {'
assert_contains 'onPicked: value => DefaultApps.setDefault('
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'currentEntry'
assert_contains 'choices.push(currentEntry)'
# The escape hatch for one type, and the service call behind it.
assert_contains 'label: "One file type"'
assert_contains 'FileTypePicker {'
assert_contains 'DefaultApps.searchTypes(query)'
assert_contains 'DefaultApps.setType(mime, desktopId)'
rg -Fq 'signal queried(string query)' "$types" \
|| fail 'the file-type picker cannot ask for a search'
rg -Fq 'signal chosen(string mime, string desktopId)' "$types" \
|| fail 'the file-type picker cannot report which application was chosen for a type'
# ── The installed list ───────────────────────────────────────────────────────
assert_contains 'InstalledAppRow {'
assert_contains 'AppLibrary.matches(app, installedSearch.text)'
assert_row_contains 'signal uninstallArmed'
assert_row_contains 'signal uninstallConfirmed'
assert_row_contains 'root.confirmingUninstall'
assert_row_contains 'label: "Permissions"'
assert_row_contains 'label: "Start with the session"'
assert_row_contains 'label: "Elsewhere in Settings"'
# Removing a system package is refused, by name, with the command that would do
# it. This is the whole reason the page can be trusted with an Uninstall button
# at all: it does one thing, and says plainly what it will not do.
assert_row_contains 'label: "Managed by dnf"'
assert_row_contains 'sudo dnf remove '
rg -v '^\s*//' "$page" | rg -q 'dnf remove' \
&& fail 'the page offers a package removal outside the honest refusal row'
python3 - "$page" "$row" <<'PY' || fail 'the page can run a package manager'
import re
import sys
for path in sys.argv[1:]:
text = open(path, encoding="utf-8").read()
# Every argument list handed to a process. A dnf or rpm in one of these is a
# settings page removing packages, whatever the button says.
for match in re.finditer(r"exec\w*\(\s*(\[[^\]]*\])", text):
argv = match.group(1)
if re.search(r'"(dnf|rpm|pkexec|sudo|yum|rpm-ostree)"', argv):
print(f"{path}: {argv.strip()[:120]}", file=sys.stderr)
raise SystemExit(1)
PY
# Flatseal is offered only when it is installed: a button that does nothing is
# worse than no button.
assert_contains 'flatsealAvailable'
assert_contains 'com.github.tchx84.Flatseal'
# The jump chips are shown where a rule exists, not everywhere.
assert_row_contains 'hasNotificationRule'
assert_row_contains 'hasSoundRule'
assert_contains 'Notifs.applications'
assert_contains 'AudioDevices.applications'
# ── The catalog ──────────────────────────────────────────────────────────────
#
# The id passed to install is the catalog line verbatim. Handing over the
# human-readable `ref` instead would be refused by the helper for every Flathub
# entry in the catalog -- the failure would look like "installing is broken".
assert_contains 'AppLibrary.install(root.activeCategory,'
rg -q 'AppLibrary\.install\([^)]*\.ref' "$page" \
&& fail 'the catalog installs by the display ref rather than by the catalog id'
assert_contains 'AppLibrary.entriesFor('
assert_contains 'system package, so installing asks for your password'
assert_contains 'value: catalogRow.installed ? "Installed" : ""'
# ── Autostart ────────────────────────────────────────────────────────────────
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories'
assert_contains 'genericName'
assert_contains '.sort('
assert_contains 'currentEntry'
assert_contains 'label: "Compositor autostart"'
assert_contains 'read-only'
assert_contains 'choices.push(currentEntry)'
assert_contains 'label: "Application settings need attention"'
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
# The compositor's entries are described, never toggled: the file they live in
# is read once at launch, so a switch here would silently do nothing.
python3 - "$page" <<'PY' || fail 'a compositor autostart entry is offered as something to change'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for match in re.finditer(r"model: DefaultApps\.luaAutostartEntries", text):
tail = text[match.start():match.start() + 1200]
if "SettingsToggle" in tail or "setAutostart" in tail:
print(tail[:200], file=sys.stderr)
raise SystemExit(1)
PY
# ── The launcher chord is read, not remembered ───────────────────────────────
#
# "Super+Space" was hardcoded here through two rebinds of the launcher and told
# the wrong story both times.
assert_contains 'Keybinds.binds'
assert_contains 'root.launcherChords'
python3 - "$page" <<'PY' || fail 'the launcher chord is stated rather than read from the keymap'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join("" if line.strip().startswith("//") else line
for line in source.splitlines())
# The literal may survive as the fallback shown while the keymap is still being
# read, but not as the value itself.
for match in re.finditer(r'"Super ?\+ ?Space"', text):
line = text[:match.start()].rsplit("\n", 1)[-1] + text[match.start():].split("\n", 1)[0]
if "launcherChords" not in line:
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
PY
# ── The role matcher, run rather than read ───────────────────────────────────
#
# Extracted from the page and executed against fixtures. The interesting cases
# are the near misses: a media centre is not a music player, a document scanner
# is not an image viewer, and the categories arrive as a QML list rather than a
# JavaScript array -- which is the exact shape that once made Archives match
# nothing at all.
PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.PAGE_PATH).text();
const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/);
@@ -58,7 +204,14 @@ if (!rolesSource || !matcherSource) {
const roles = Function(`return (${rolesSource[1]})`)();
const matchesRole = Function("entry", "role", matcherSource[1]);
const role = key => roles.find(candidate => candidate.key === key);
const role = key => {
const found = roles.find(candidate => candidate.key === key);
if (!found) {
console.error(`applications settings contract: no "${key}" role`);
process.exit(1);
}
return found;
};
const fixtures = [
{
name: "AudioVideo does not imply music",
@@ -101,6 +254,30 @@ const fixtures = [
entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" },
role: "images",
expected: true
},
{
name: "an archive manager can be the archives handler",
entry: { name: "File Roller", genericName: "Archive Manager", comment: "Open archives", categories: "Utility;Archiving;" },
role: "archives",
expected: true
},
{
name: "categories that arrive as a list are read as categories",
entry: { name: "Ark", genericName: "Ark", comment: "", categories: ["Utility", "Archiving"] },
role: "archives",
expected: true
},
{
name: "a comma-separated category string is still a list of categories",
entry: { name: "Ark", genericName: "Ark", comment: "", categories: "Utility,Archiving" },
role: "archives",
expected: true
},
{
name: "a text editor is not a terminal",
entry: { name: "Neovim", genericName: "Text Editor", comment: "Edit text", categories: "Utility;TextEditor;" },
role: "terminal",
expected: false
}
];
@@ -113,27 +290,29 @@ for (const fixture of fixtures) {
}
'
if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page snapshots or performs a one-time desktop-entry lookup'
# ── House rules ──────────────────────────────────────────────────────────────
#
# The page may ask its services to load when it opens -- AppLibrary reads
# nothing until something wants it -- but it may not snapshot desktop entries or
# look one up by hand: both produce a list that stops tracking what is
# installed.
if rg --quiet 'DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page performs a one-time desktop-entry lookup instead of tracking the live list'
fi
if rg -F --quiet 'label: "Could not apply the change"' "$page"; then
fail 'error heading incorrectly describes read failures as apply failures'
fi
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then
fail 'page introduces a color literal instead of the shared visual system'
for path in "$page" "$row" "$types"; do
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$path"; then
fail "$(basename "$path") introduces a color literal instead of the shared visual system"
fi
done
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable'
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|| fail 'autostart picker is not registered in the Settings module'
# Every component the page draws is registered, or the page does not load at
# all -- and a QML page that fails to load looks like an empty tab.
for component in AutostartAppPicker InstalledAppRow FileTypePicker SettingsChip OptionPickerRow; do
rg -q "^$component 1\.0 $component\.qml$" "$qmldir" \
|| fail "$component is not registered in the Settings module"
done
printf 'applications settings contract: PASS\n'
+80
View File
@@ -313,4 +313,84 @@ rg --quiet 'image/png' "$call_log" \
rm -f "$config_home/mimeapps.list"
# ── One type on its own ──────────────────────────────────────────────────────
#
# The role rows set a whole family together, which is right almost always and
# wrong for the person whose .heic files should open somewhere other than the
# rest of their pictures. `set-type` is that escape hatch, and it is the one
# verb here that writes a type nobody curated -- so what it accepts is the
# question.
#
# Two guards, and they do different jobs: the type has to be one this system
# knows (a typo becomes a handler entry for a MIME type that will never exist),
# and the application has to be one that is installed (the same rule the role
# rows already keep).
assert_service_contains 'function searchTypes(query: string): void'
assert_service_contains 'function setType(mime: string, desktopId: string): void'
# The type database, as this fake machine has it.
mkdir -p "$data_home/mime"
cat >"$data_home/mime/globs2" <<'GLOBS'
# weight:type:glob
50:image/png:*.png
50:image/jpeg:*.jpg
50:image/heic:*.heic
50:text/markdown:*.md
50:application/pdf:*.pdf
GLOBS
# Two applications that declare what they open, so "candidates" has something
# true to report rather than passing on an empty list.
printf 'MimeType=image/png;image/jpeg;image/heic;\n' >>"$data_home/applications/org.gnome.Loupe.desktop"
printf 'MimeType=text/markdown;text/plain;\n' >>"$data_home/applications/panama-nvim.desktop"
: >"$call_log"
search="$($helper search-types heic)" || fail 'search-types failed'
jq -e '(.types | type == "array") and has("truncated")' <<<"$search" >/dev/null \
|| fail "search-types does not report a list of types and whether it was cut short: $search"
jq -e '[.types[] | has("mime") and has("handler") and has("candidates")] | all' <<<"$search" >/dev/null \
|| fail "a search result is missing its type, its current handler, or the applications that could open it: $search"
jq -e '[.types[].mime] | index("image/heic") != null' <<<"$search" >/dev/null \
|| fail "searching an extension did not find the type it belongs to: $search"
jq -e '[.types[] | select(.mime == "image/heic") | .candidates[].id]
| index("org.gnome.Loupe.desktop") != null' <<<"$search" >/dev/null \
|| fail "an application that declares the type is not offered for it: $search"
# Candidates are installed applications, never a name read out of a registry.
discovered="$(cd "$data_home/applications" && ls)"
while read -r candidate; do
[[ -n "$candidate" ]] || continue
rg -Fxq "$candidate" <<<"$discovered" \
|| fail "search-types offers '$candidate', which is not installed on this machine"
done < <(jq -r '[.types[].candidates[].id] | unique[]' <<<"$search")
# A one-character query is not a search: it would return the whole database.
jq -e '(.types | length) == 0' <<<"$($helper search-types a)" >/dev/null \
|| fail 'a single character was treated as a search of the whole type database'
: >"$call_log"
$helper set-type image/heic org.gnome.Loupe.desktop
assert_call $'default\norg.gnome.Loupe.desktop\nimage/heic'
# One type, and only that type: the whole point of the escape hatch is that it
# leaves the rest of the family where it was.
[[ "$(rg --count '^default$' "$call_log")" == "1" ]] \
|| fail 'setting one file type wrote more than one'
: >"$call_log"
if $helper set-type image/heic org.example.Missing.desktop >/dev/null 2>&1; then
fail 'set-type accepted an application that is not installed'
fi
if $helper set-type image/heic ../escape.desktop >/dev/null 2>&1; then
fail 'set-type accepted an unsafe desktop id'
fi
for bad_type in 'image' 'image/' '/png' 'image/png;rm -rf /' '' '../../etc/passwd' \
'image/does-not-exist'; do
if $helper set-type "$bad_type" org.gnome.Loupe.desktop >/dev/null 2>&1; then
fail "set-type accepted a type this system does not have: ${bad_type@Q}"
fi
done
[[ ! -s "$call_log" ]] || fail "a refused set-type still reached xdg-mime: $(cat "$call_log")"
printf 'default apps contract: PASS\n'
+368 -2
View File
@@ -37,6 +37,22 @@ grep -q 'function refresh(): void' "$service" || fail 'Disks has no refresh'
grep -q 'function scan(): void' "$service" || fail 'Disks has no folder scan'
grep -qE 'command\s*:\s*"' "$service" && fail 'Process command must be an argument array'
# The cleanup path, service side. `clean` takes ONE id and refuses one it has
# not been shown, so a mis-wired button cannot free something the user never
# looked at -- and the helper refuses it again, because a service is not a
# security boundary.
grep -q 'function clean(identifier: string): void' "$service" \
|| fail 'Disks cannot clean one named thing'
grep -q 'root.cleanables.some(item' "$service" \
|| fail 'the service passes an id straight through without checking it is one it offered'
grep -qE 'function measureBreakdown|function measureCleanables' "$service" \
|| fail 'the breakdown and the cleanup list are not measured on demand'
# Both walks are asked for, never done on open: they cost the same as the
# folder scan the page already refuses to start by itself.
grep -qE 'Component\.onCompleted:.*Disks\.(scan|measureBreakdown|measureCleanables)' "$page" \
&& fail 'the page starts an expensive walk the moment it opens'
# The expensive read must not run on open; that is the entire reason it is a
# separate command.
grep -q 'Component.onCompleted: Disks.refresh()' "$page" \
@@ -143,6 +159,356 @@ grep -Fxq 'unmount -b /dev/sdb1' "$PANAMA_DISKS_CALL_LOG" \
unset PANAMA_DISKS_LSBLK PANAMA_DISKS_CALL_LOG
printf 'disks contract: PASS (%d drives, %d filesystems)\n' \
# ── The breakdown adds up, and the leftover says so ──────────────────────────
#
# A stacked bar is a claim about arithmetic. Three of its four segments are
# measured (the home scan targets, the flatpak sizes, ~/.cache) and the fourth
# is whatever is left of the used space -- system files, package caches, logs,
# everything nobody itemized. That last segment is the honest one only while it
# is computed as the remainder and captioned as the remainder. Two ways it lies:
#
# * measured parts that overlap or overshoot, so the segments sum past the
# used space and the remainder goes negative (drawn as zero, silently);
# * a remainder captioned "System", which invites the user to believe the
# desktop is using 400 GB when most of it is their own unscanned files.
#
# SAFETY: this half runs the helper under `env -i` with HOME and XDG_CACHE_HOME
# inside the scratch tree and the block device tree read from the fixture, so
# every path it measures is one this file created. The first assertion below is
# the proof: the numbers it returns have to be the fixture's numbers, or the
# contract stops before anything else runs.
# No absolute home anywhere: the measurements have to follow HOME, which is the
# only reason pointing it at a fixture works.
grep -n '"/home/' "$helper" \
&& fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture'
for verb in breakdown cleanables clean; do
grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb command"
done
# The applications segment is measured by walking the flatpak install roots, and
# deciding which of them counts needs "is this the same filesystem as home".
# st_dev is the obvious test and the wrong one: btrfs gives every subvolume its
# own device number, so / and /home compare as different filesystems on this
# machine and the system-wide flatpak installation drops out of the bar. The
# question is which block device is behind the path.
grep -q 'def same_filesystem' "$helper" \
|| fail 'nothing decides whether a flatpak root is on the same filesystem as home'
grep -qE 'findmnt.*SOURCE|"SOURCE"' "$helper" \
|| fail 'the filesystem behind a path is not read from findmnt, so btrfs subvolumes will compare as separate drives'
# One dnf invocation in the whole helper, and it drops downloads. This is the
# only place in Panama's settings surface that runs dnf at all.
python3 - "$helper" <<'PY' || fail 'panama-disks runs dnf for something other than dropping downloads'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
commands = []
for node in ast.walk(tree):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = [element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if "dnf" in literals or "yum" in literals or "rpm" in literals:
commands.append(literals)
if len(commands) != 1:
print(f"expected exactly one package-manager command, found {commands}", file=sys.stderr)
raise SystemExit(1)
command = commands[0]
if command[:4] != ["pkexec", "dnf", "clean", "packages"]:
print(f"the one dnf command is {command}", file=sys.stderr)
raise SystemExit(1)
PY
fixture_home="$work/home"
fixture_cache="$fixture_home/.cache"
mkdir -p "$fixture_cache/one" "$fixture_cache/two" "$work/outside"
# A distinctive size: 12 files of 111111 bytes. The point is that this cannot be
# confused with the real ~/.cache, which is orders of magnitude larger.
for index in $(seq 1 12); do
head -c 111111 /dev/zero >"$fixture_cache/one/file-$index"
done
printf 'this file is not inside the cache and must survive\n' >"$work/outside/precious"
ln -s "$work/outside" "$fixture_cache/escape-hatch"
mkdir -p "$work/bin2"
: >"$work/calls2"
for stubbed in gio flatpak pkexec dnf podman; do
cat >"$work/bin2/$stubbed" <<STUB
#!/usr/bin/env bash
printf '$stubbed %s\n' "\$*" >>"$work/calls2"
exit 0
STUB
done
chmod +x "$work/bin2"/*
runh() {
env -i \
PATH="$work/bin2:/usr/bin:/bin" \
HOME="$fixture_home" \
XDG_CACHE_HOME="$fixture_cache" \
XDG_CONFIG_HOME="$work/xdg-config" \
XDG_DATA_HOME="$work/xdg-data" \
PANAMA_DISKS_LSBLK="$work/tree.json" \
PANAMA_DISKS_CALL_LOG="$work/calls2" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
calls2() { cat "$work/calls2"; }
breakdown="$(runh breakdown 2>/dev/null)" || fail 'breakdown failed against the fixture'
jq -e '(.segments | type == "object") and has("usedBytes") and has("totalBytes")
and has("complete") and has("exceedsUsed")' <<<"$breakdown" >/dev/null \
|| fail "breakdown is missing its segments or the numbers they are drawn against: $breakdown"
jq -e '[.segments.home, .segments.applications, .segments.caches, .segments.system, .segments.free]
| map(type == "number" and . >= 0) | all' <<<"$breakdown" >/dev/null \
|| fail "a breakdown segment is missing, negative, or not a number: $breakdown"
# The proof that this ran against the fixture rather than against the folders
# somebody is using: the home it measured is the one this file created, and the
# cache segment is the 12 x 111111 bytes written into it a moment ago. Nothing
# below runs until both are true.
[[ "$(jq -r '.path' <<<"$breakdown")" == "$fixture_home" ]] \
|| fail "breakdown measured $(jq -r '.path' <<<"$breakdown"), not the fixture home; refusing to go on"
jq -e '.segments.caches > 1200000 and .segments.caches < 1500000' <<<"$breakdown" >/dev/null \
|| fail "the cache segment is not the fixture's cache: $(jq -r .segments.caches <<<"$breakdown")"
# The arithmetic. Three segments are measured and the fourth is what is left of
# the used space; they have to add up to exactly the used space, or the bar is
# drawn against a total nobody has.
jq -e '(.segments.home + .segments.applications + .segments.caches + .segments.system) == .usedBytes' \
<<<"$breakdown" >/dev/null \
|| fail "the segments do not add up to the used space: $breakdown"
jq -e '.segments.free == .freeBytes and (.usedBytes + .freeBytes) <= .totalBytes' <<<"$breakdown" >/dev/null \
|| fail "the free segment and the filesystem disagree: $breakdown"
# The two ways the measurement can be wrong are reported rather than absorbed
# into the remainder: a walk that ran out of time, and parts that overlap.
jq -e '.exceedsUsed == false' <<<"$breakdown" >/dev/null \
|| fail "the fixture's measured parts overshot its used space, so this run proves nothing: $breakdown"
grep -Fq 'Disks.breakdown.complete === false' "$page" \
|| fail 'the page does not say when the walk ran out of time, so floors are drawn as totals'
grep -Fq 'Disks.breakdown.exceedsUsed === true' "$page" \
|| fail 'the page does not say when the measured parts overlap, so a clamped remainder looks measured'
# The remainder is named for what it is. "System" alone would blame the desktop
# for the user's own unscanned files.
page_text="$(grep -vE '^\s*//' "$page")"
grep -Fq 'System & everything else' <<<"$page_text" \
|| fail 'the remainder segment is captioned as if it were all system files'
# ── Every cleanable is itemized, sized, and inert until asked for ────────────
cleanables="$(runh cleanables 2>/dev/null)" || fail 'cleanables failed against the fixture'
jq -e 'type == "array" and length > 0' <<<"$cleanables" >/dev/null \
|| fail "cleanables reported nothing at all: $cleanables"
jq -e '[.[] | has("id") and has("label") and has("detail") and has("bytes") and has("privileged")] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable is missing its id, label, honest detail, size, or privilege flag: $cleanables"
jq -e '[.[] | (.bytes | type == "number") and .bytes >= 0 and (.label | length > 0) and (.detail | length > 0)] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable has no size or no explanation of what it costs to remove: $cleanables"
unknown_ids="$(jq -r '[.[].id] - ["cache", "trash", "flatpak-unused", "dnf-cache"] | join(", ")' \
<<<"$cleanables")"
[[ -z "$unknown_ids" ]] \
|| fail "a cleanable id nothing else knows about: $unknown_ids"
# Nothing arrives pre-selected. A cleanup list that ships with boxes ticked is
# the racket this card exists not to be: the user should have to say yes to each
# thing, individually, having seen what it costs.
jq -e '[.[] | (has("selected") or has("checked") or has("default")) | not] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable carries a pre-selected state: $cleanables"
jq -e '[.[] | select(.id == "dnf-cache") | .privileged] | all and length > 0' <<<"$cleanables" >/dev/null \
|| fail 'the package cache is not marked as needing a password, so the page cannot warn about it'
jq -e '[.[] | select(.id == "cache" or .id == "trash") | .privileged | not] | all' <<<"$cleanables" >/dev/null \
|| fail 'clearing your own cache or trash is marked as privileged, which would ask for a password it does not need'
# The unused-runtime size is not measured here: it is asked of the applications
# helper, which is the thing that removes them. Two ideas of "unused" would show
# one number and free another.
jq -e '[.[] | select(.id == "flatpak-unused") | .detail | test("flatpak decides")] | all and length > 0' \
<<<"$cleanables" >/dev/null \
|| fail 'the unused-runtime row does not say that flatpak has the final word on the list'
grep -q 'PANAMA_APPLICATIONS_HELPER' "$helper" \
|| fail 'the flatpak-unused row cannot be pointed at the applications helper, so the two cannot be kept in step'
cat >"$work/bin2/apps-helper" <<'STUB'
#!/usr/bin/env bash
printf 'apps-helper %s\n' "$*" >>"$PANAMA_DISKS_CALL_LOG"
case "$1" in
unused-runtimes) printf '[{"id":"org.example.Old","sizeBytes":777000},{"id":"org.example.Older","sizeBytes":3000}]\n' ;;
*) printf '[]\n' ;;
esac
exit 0
STUB
chmod +x "$work/bin2/apps-helper"
: >"$work/calls2"
borrowed="$(env -i \
PATH="$work/bin2:/usr/bin:/bin" \
HOME="$fixture_home" \
XDG_CACHE_HOME="$fixture_cache" \
PANAMA_DISKS_LSBLK="$work/tree.json" \
PANAMA_DISKS_CALL_LOG="$work/calls2" \
PANAMA_APPLICATIONS_HELPER="$work/bin2/apps-helper" \
LANG=C LC_ALL=C \
"$helper" cleanables 2>/dev/null)"
grep -Fq 'apps-helper unused-runtimes' "$work/calls2" \
|| fail "the unused-runtime size was computed here rather than asked of the applications helper: $(calls2)"
[[ "$(jq -r '.[] | select(.id == "flatpak-unused") | .bytes' <<<"$borrowed")" == "780000" ]] \
|| fail "the row does not report what the applications helper said was unused: $borrowed"
cache_bytes="$(jq -r '.[] | select(.id == "cache") | .bytes' <<<"$cleanables")"
[[ "$cache_bytes" -gt 1200000 && "$cache_bytes" -lt 1500000 ]] \
|| fail "the cache cleanable does not describe the fixture cache, so it is measuring something else: $cache_bytes"
# ── Nothing runs without its own id ──────────────────────────────────────────
#
# Checked from the log rather than the exit code: a refusal that happens after
# the command ran is not a refusal.
for bad in '' 'all' '*' '../../' 'cache trash' 'CACHE' 'dnf-cache; reboot'; do
: >"$work/calls2"
runh clean "$bad" >/dev/null 2>&1 \
&& fail "clean accepted an id that is not a cleanable: ${bad@Q}"
[[ ! -s "$work/calls2" ]] \
|| fail "a refused clean still ran something: ${bad@Q}: $(calls2)"
done
: >"$work/calls2"
runh clean >/dev/null 2>&1 && fail 'clean with no id at all was accepted'
[[ ! -s "$work/calls2" ]] || fail "clean with no id still ran something: $(calls2)"
grep -qE '"clean-all"|"clean_everything"|--all' "$helper" \
&& fail 'the helper offers a way to clean everything at once, which nobody asked for item by item'
# ── Each cleanable does its own one thing ────────────────────────────────────
: >"$work/calls2"
runh clean trash >/dev/null 2>&1
grep -Eq '^gio trash .*--empty|^gio trash --empty' "$work/calls2" \
|| fail "emptying the trash does not go through gio, which is the only thing that knows where it is: $(calls2)"
grep -qE '^(rm|find) ' "$work/calls2" \
&& fail "the trash was emptied with rm rather than gio: $(calls2)"
: >"$work/calls2"
runh clean flatpak-unused >/dev/null 2>&1
grep -Eq '^flatpak uninstall .*--unused' "$work/calls2" \
|| fail "clearing unused runtimes does not reach flatpak: $(calls2)"
grep -Eq '^flatpak uninstall .*--noninteractive' "$work/calls2" \
|| fail "clearing unused runtimes would stop for a prompt nobody can answer: $(calls2)"
: >"$work/calls2"
runh clean dnf-cache >/dev/null 2>&1
grep -Fq 'pkexec dnf clean packages' "$work/calls2" \
|| fail "clearing the package cache is not the polkit-wrapped drop of downloaded rpms: $(calls2)"
grep -Fq 'dnf clean all' "$work/calls2" \
&& fail "the package METADATA was dropped too, which frees little and slows the next install: $(calls2)"
grep -qE '^dnf ' "$work/calls2" \
&& fail "the helper ran dnf directly instead of going through pkexec: $(calls2)"
grep -qE 'remove|erase|autoremove' "$work/calls2" \
&& fail "clearing the package cache removes packages: $(calls2)"
# ── Clearing the cache stays inside the cache ────────────────────────────────
#
# The dangerous one. ~/.cache collects symlinks -- Steam, Electron applications
# and language toolchains all put them there -- and an rm that follows one
# deletes whatever it points at. The fixture plants exactly that: a link out of
# the cache to a file that must survive.
#
# Running this is safe because of the two assertions above: the helper reported
# the fixture's byte count, so the directory it is about to empty is the one
# this file created.
: >"$work/calls2"
runh clean cache >/dev/null 2>&1 || fail 'clearing the cache failed against the fixture'
[[ -f "$work/outside/precious" ]] \
|| fail 'clearing the cache followed a symlink out of it and deleted a file elsewhere'
[[ -d "$work/outside" ]] \
|| fail 'clearing the cache deleted a directory outside the cache'
remaining="$(find "$fixture_cache" -type f | wc -l)"
[[ "$remaining" == "0" ]] \
|| fail "clearing the cache left $remaining file(s) behind, so it did not do what it said"
[[ -d "$fixture_cache" ]] \
|| fail 'clearing the cache removed the cache directory itself, which applications expect to exist'
# ── The cleanup card does not sell anything ──────────────────────────────────
#
# Every "clean my PC" product on earth manufactures urgency, and the difference
# between this card and those is entirely a matter of copy. Pinned as an
# absence, in the card itself, because that is where the pressure would go.
python3 - "$page" <<'PY' || fail 'the cleanup card uses the language of a cleaner racket'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join(line for line in source.splitlines() if not line.strip().startswith("//"))
def block_at(start: int) -> str:
depth = 0
for index in range(text.find("{", start), len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return text[start:index + 1]
return ""
# The tightest card whose own title is the cleanup one: an outer card that
# merely contains it would drag the whole page's copy into the check.
cards = [block for block in (block_at(match.start())
for match in re.finditer(r"SettingsCard \{", text))
if re.search(r"title:[^\n]*Clean up", block)]
if not cards:
print("there is no cleanup card on the Storage page", file=sys.stderr)
raise SystemExit(1)
card = min(cards, key=len)
# Only what the user reads. QML is full of exclamation marks and none of them
# are shouting at anybody.
copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower()
PRESSURE = [
"running out", "running low", "act now", "recommended", "we recommend",
"urgent", "boost", "speed up", "optimize", "optimise", "reclaim now",
"free up now", "clean now", "junk", "safe to remove", "you should",
"needs attention", "!",
]
found = [phrase for phrase in PRESSURE if phrase in copy]
if found:
print(f"the cleanup card says: {found}", file=sys.stderr)
raise SystemExit(1)
PY
# Nor does it decide for the user: every row is its own two-stage confirm, and
# there is no button that clears the lot.
grep -qiE '"(Clean everything|Clean all|Free up space|Optimize)"' "$page" \
&& fail 'the cleanup card offers a single button that clears everything'
grep -Fq 'nothing to do' <<<"$page_text" \
|| fail 'a cleanable with nothing in it does not say so, so the row invites a pointless confirm'
# The breakdown bar is a component, and a component the page draws has to be
# registered or the page does not load at all -- which reads as an empty tab
# rather than as a missing line in a qmldir.
grep -q '^StorageBreakdownBar 1\.0 StorageBreakdownBar\.qml$' \
"$repo_dir/config/dot/quickshell/modules/settings/qmldir" \
|| fail 'the breakdown bar is not registered in the Settings module'
# ── One affordance for container space, not two ──────────────────────────────
#
# The Storage page used to offer "Unused container images" as a row that opened
# a terminal running `podman system df`, directly above a Containers card that
# reclaims the same space properly. Two buttons for one job, one of which is a
# terminal window.
grep -Fq 'Unused container images' "$page" \
&& fail 'the duplicate container-images row is back, above the card that already does this'
grep -Fq 'kitty' "$page" \
&& fail 'the Storage page opens a terminal, which is not a settings page doing its job'
printf 'disks contract: PASS (%d drives, %d filesystems, breakdown adds up, %d cleanable(s))\n' \
"$(jq '.drives | length' <<<"$snapshot")" \
"$(jq '.filesystems | length' <<<"$snapshot")"
"$(jq '.filesystems | length' <<<"$snapshot")" \
"$(jq 'length' <<<"$cleanables")"
+189 -1
View File
@@ -107,6 +107,194 @@ grep -q 'keeping whatever is there now' "$page" \
grep -q 'Not measured' "$page" \
|| fail 'the page reports a per-snapshot size it cannot actually measure'
printf 'snapshots contract: PASS (%d volume(s), %d snapshot(s), no rollback)\n' \
# ── 5. The browser opens from a card that is closed ─────────────────────────
#
# The bug this pins: the file browser used to be drawn INSIDE the volume card's
# expanded body, so "Browse this snapshot" from a collapsed card set the
# browsing state and rendered nothing. The user pressed a button and the page
# did not move.
#
# Nesting is the whole failure, so nesting is what is checked: the browser has
# to be a card of its own, not a delegate inside the Repeater that draws one
# card per volume, and its visibility may not mention the volume card's open
# state.
python3 - "$page" <<'PY' || fail 'the snapshot browser cannot open from a collapsed volume card'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
# Comment lines go first, so a `//` aside about the browser cannot be mistaken
# for the browser.
text = "\n".join("" if line.strip().startswith("//") else line
for line in source.splitlines())
target = text.find("Snapshots.browseEntries")
if target < 0:
print("the page never lists the entries of the snapshot it is browsing", file=sys.stderr)
raise SystemExit(1)
# The chain of QML types enclosing one position. Braces also appear inside
# strings ("\u{F0413}") and comments, so the scan has to know the difference or
# the nesting it reports is fiction.
def enclosing(text: str, position: int) -> list[str]:
ancestors: list[str] = []
index = 0
length = len(text)
while index < length:
if index >= position:
return [name for name in ancestors if name]
char = text[index]
if char == "/" and text.startswith("//", index):
index = text.find("\n", index)
if index < 0:
break
continue
if char == "/" and text.startswith("/*", index):
end = text.find("*/", index + 2)
if end < 0:
break
index = end + 2
continue
if char in "\"'`":
index += 1
while index < length:
if text[index] == "\\":
index += 2
continue
if text[index] == char:
index += 1
break
index += 1
continue
if char == "{":
head = text[max(0, index - 80):index].rstrip()
match = re.search(r"([A-Z][A-Za-z0-9_.]*)\s*$", head)
ancestors.append(match.group(1) if match else "")
elif char == "}" and ancestors:
ancestors.pop()
index += 1
return [name for name in ancestors if name]
ancestors = enclosing(text, target)
if "SettingsCard" not in ancestors:
print(f"the browser is not inside a card at all ({ancestors})", file=sys.stderr)
raise SystemExit(1)
# The listing is a Repeater of its own, which is fine. What matters is what
# encloses the CARD: a Repeater above it is the per-volume one, and that is the
# bug -- the browser only exists while that volume's card is drawn expanded.
outside = ancestors[:len(ancestors) - 1 - ancestors[::-1].index("SettingsCard")]
if "Repeater" in outside:
print(f"the browser card is a delegate of the per-volume Repeater ({ancestors})", file=sys.stderr)
raise SystemExit(1)
PY
# And the visibility that gates it is about browsing, not about a card being
# open. Written out because `volumeCard.open` was exactly the expression that
# made the button do nothing.
grep -qE 'visible:.*volumeCard\.open.*browsing' <<<"$page_code" \
&& fail 'the browser still renders only while the volume card it came from is expanded'
grep -qE 'visible:.*(browsingOpen|Snapshots\.browsingConfig)' <<<"$page_code" \
|| fail 'nothing on the page is shown because a snapshot is being browsed'
# ── 6. Retention is editable, and the page is what edits it ─────────────────
#
# `Snapshots.setRetention` existed with no caller for three phases: the Keep row
# printed "24 hourly, 7 daily, 4 weekly" and there was no way to change any of
# them. A service function nobody calls is not a feature.
grep -Fq 'Snapshots.setRetention(' <<<"$page_code" \
|| fail 'the page never calls setRetention, so the keep counts are still read-only'
for horizon in Hourly Daily Weekly; do
grep -Fq "\"$horizon\"" <<<"$page_code" \
|| fail "the page has no $horizon control, so that horizon cannot be edited"
done
grep -q 'function setRetention(config: string, hourly: int, daily: int, weekly: int): void' "$service" \
|| fail 'the service does not take the three horizons separately'
# The helper validates them. Run against a snapper that records and does
# nothing: this is the only way to exercise a write verb without changing how
# this machine keeps its snapshots.
work="$(mktemp -d /tmp/panama-snapshots-contract.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/bin"
cat >"$work/bin/snapper" <<STUB
#!/usr/bin/env bash
printf 'snapper %s\n' "\$*" >>"$work/calls"
exit 0
STUB
chmod +x "$work/bin/snapper"
: >"$work/calls"
resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" bash -c 'command -v snapper')"
[[ "$resolved" == "$work/bin/snapper" ]] \
|| fail "snapper resolves to '$resolved', not the stub; refusing to run a write verb against the real one"
runh() {
env -i PATH="$work/bin:/usr/bin:/bin" HOME="$work" LANG=C LC_ALL=C "$helper" "$@"
}
# This helper answers a refusal the way the page reads one: fresh state with an
# `error` in it, not an exit code. So a refusal is checked from that field, and
# from the absence of a write in the log -- the helper reads the configuration
# list on its way back out either way, and counting "did anything run" would
# mistake that read for the write it refused to do.
retention_error() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }
wrote() { grep -c 'set-config' "$work/calls"; }
: >"$work/calls"
[[ -z "$(retention_error set-retention home 24 7 4)" ]] \
|| fail 'setting the keep counts failed against the stub'
grep -Eq 'snapper -c home set-config .*TIMELINE_LIMIT_HOURLY=24' "$work/calls" \
|| fail "the hourly count did not reach snapper: $(cat "$work/calls")"
grep -Eq 'TIMELINE_LIMIT_DAILY=7' "$work/calls" \
|| fail "the daily count did not reach snapper: $(cat "$work/calls")"
grep -Eq 'TIMELINE_LIMIT_WEEKLY=4' "$work/calls" \
|| fail "the weekly count did not reach snapper: $(cat "$work/calls")"
# One command, not three: a partial write would leave the three horizons
# disagreeing with what the page shows.
[[ "$(wrote)" == "1" ]] \
|| fail "the three horizons were written separately: $(cat "$work/calls")"
# The horizons have a ceiling, and it is the same number in the helper and in
# the service. A dropdown that offers a value the helper refuses is a dropdown
# that fails after the user has chosen.
: >"$work/calls"
[[ -z "$(retention_error set-retention home 50 50 50)" ]] \
|| fail 'the highest keep count the page offers was refused by the helper'
: >"$work/calls"
[[ -n "$(retention_error set-retention home 51 7 4)" ]] \
|| fail 'a keep count above the ceiling was accepted'
[[ "$(wrote)" == "0" ]] \
|| fail "a keep count above the ceiling still reached snapper: $(cat "$work/calls")"
grep -q 'retentionMax' "$service" \
|| fail 'the service does not publish the ceiling, so the page has to keep a second copy of it'
[[ "$(grep -oE 'retentionMax[^0-9]*[0-9]+' "$service" | grep -oE '[0-9]+' | head -1)" == "50" ]] \
|| fail 'the service and the helper disagree about how high the keep counts go'
# Each horizon is checked before anything is written. Checking that nothing was
# written is what says the refusal came first: snapper would refuse most of
# these too, so "did it error" alone would pass with the validation deleted.
for bad in 'abc' '-1' '5.5' '' '1e3' '99999' '7; reboot'; do
for position in 1 2 3; do
case "$position" in
1) arguments=("$bad" 7 4) ;;
2) arguments=(24 "$bad" 4) ;;
3) arguments=(24 7 "$bad") ;;
esac
: >"$work/calls"
[[ -n "$(retention_error set-retention home "${arguments[@]}")" ]] \
|| fail "an impossible keep count was accepted in position $position: ${bad@Q}"
[[ "$(wrote)" == "0" ]] \
|| fail "a refused keep count still reached snapper: ${bad@Q}: $(cat "$work/calls")"
done
done
: >"$work/calls"
[[ -n "$(retention_error set-retention '../../etc' 24 7 4)" ]] \
|| fail 'a configuration name that is not one was accepted'
[[ "$(wrote)" == "0" ]] || fail "a refused configuration still reached snapper: $(cat "$work/calls")"
printf 'snapshots contract: PASS (%d volume(s), %d snapshot(s), no rollback, retention editable)\n' \
"$(jq '.configs | length' <<<"$snapshot")" \
"$(jq '[.configs[].snapshots[]?] | length' <<<"$snapshot")"