From 5a0643357fa4acb19a0e6bc7ce429357c7b07138 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 24 Aug 2026 17:18:14 -0400 Subject: [PATCH] Make Applications a real app manager, and clean up storage without the racket Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- README.md | 2 +- .../modules/settings/ApplicationsPage.qml | 604 ++++++++++++++---- .../modules/settings/FileTypePicker.qml | 139 ++++ .../modules/settings/InstalledAppRow.qml | 402 ++++++++++++ .../modules/settings/SettingsChip.qml | 64 ++ .../modules/settings/SnapshotsPage.qml | 312 ++++++--- .../modules/settings/StorageBreakdownBar.qml | 153 +++++ .../modules/settings/StoragePage.qml | 215 ++++++- config/dot/quickshell/modules/settings/qmldir | 4 + .../quickshell/scripts/panama-applications | 583 +++++++++++++++++ .../quickshell/scripts/panama-default-apps | 192 +++++- config/dot/quickshell/scripts/panama-disks | 325 +++++++++- .../dot/quickshell/scripts/panama-snapshots | 12 +- config/dot/quickshell/services/AppLibrary.qml | 328 ++++++++++ .../dot/quickshell/services/DefaultApps.qml | 84 +++ config/dot/quickshell/services/Disks.qml | 117 ++++ .../quickshell/services/SettingsSearch.qml | 13 + config/dot/quickshell/services/Snapshots.qml | 35 +- .../vicinae/scripts/settings-applications | 2 +- .../vicinae/scripts/settings-connectivity | 2 +- .../share/vicinae/scripts/settings-snapshots | 2 +- .../share/vicinae/scripts/settings-storage | 2 +- ...26-08-23-settings-redesign-test-backlog.md | 179 ++++++ ...applications-storage-snapshots-redesign.md | 141 ++++ tests/quickshell/app-library-contract | 539 ++++++++++++++++ .../quickshell/applications-settings-contract | 247 ++++++- tests/quickshell/default-apps-contract | 80 +++ tests/quickshell/disks-contract | 370 ++++++++++- tests/quickshell/snapshots-contract | 190 +++++- 29 files changed, 5024 insertions(+), 314 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/FileTypePicker.qml create mode 100644 config/dot/quickshell/modules/settings/InstalledAppRow.qml create mode 100644 config/dot/quickshell/modules/settings/SettingsChip.qml create mode 100644 config/dot/quickshell/modules/settings/StorageBreakdownBar.qml create mode 100755 config/dot/quickshell/scripts/panama-applications create mode 100644 config/dot/quickshell/services/AppLibrary.qml create mode 100644 docs/superpowers/specs/2026-08-24-applications-storage-snapshots-redesign.md create mode 100755 tests/quickshell/app-library-contract diff --git a/README.md b/README.md index a32ef62..eb0c154 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index 4864586..1848706 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -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 + delegate: OptionPickerRow { + id: roleRow 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) + + readonly property var choices: root.choicesForRole(roleRow.modelData) width: parent.width - - SettingRow { - 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) - - 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 = ""; - } - } - } - } + 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(); + } } } } diff --git a/config/dot/quickshell/modules/settings/FileTypePicker.qml b/config/dot/quickshell/modules/settings/FileTypePicker.qml new file mode 100644 index 0000000..698d28f --- /dev/null +++ b/config/dot/quickshell/modules/settings/FileTypePicker.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/InstalledAppRow.qml b/config/dot/quickshell/modules/settings/InstalledAppRow.qml new file mode 100644 index 0000000..4c559eb --- /dev/null +++ b/config/dot/quickshell/modules/settings/InstalledAppRow.qml @@ -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 } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsChip.qml b/config/dot/quickshell/modules/settings/SettingsChip.qml new file mode 100644 index 0000000..c4e35f4 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsChip.qml @@ -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() + } +} diff --git a/config/dot/quickshell/modules/settings/SnapshotsPage.qml b/config/dot/quickshell/modules/settings/SnapshotsPage.qml index ae0a8b4..ba198d1 100644 --- a/config/dot/quickshell/modules/settings/SnapshotsPage.qml +++ b/config/dot/quickshell/modules/settings/SnapshotsPage.qml @@ -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,119 +321,128 @@ 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. + + 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 === "" + ? "Close this point in time" + : "…/" + Snapshots.browsingPath + detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now" + action: Snapshots.browsingPath === "" ? "Close" : "Back" + enabled: !Snapshots.browsing + onTriggered: Snapshots.browseUp() + } + + TextRow { + width: parent.width + visible: Snapshots.browsing + label: "Reading the snapshot…" + detail: "Listing a folder from a point in time" + value: "" + divider: false + } + + Repeater { + model: Snapshots.browsing ? [] : Snapshots.browseEntries + + delegate: SettingRow { + id: entryRow + + required property var modelData + required property int index + + readonly property string entryPath: Snapshots.browsingPath === "" + ? String(entryRow.modelData.name) + : Snapshots.browsingPath + "/" + String(entryRow.modelData.name) + readonly property bool confirming: root.confirmingRestore === entryRow.entryPath - Column { width: parent.width - visible: volumeCard.open && root.browsingOpen - && Snapshots.browsingConfig === volumeCard.configName + icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}" + label: String(entryRow.modelData.name ?? "") + detail: entryRow.modelData.directory + ? "Folder" + : Snapshots.formatBytes(entryRow.modelData.bytes ?? 0) + controlWidth: 230 + divider: entryRow.index < Snapshots.browseEntries.length - 1 - ActionRow { - width: parent.width - label: Snapshots.browsingPath === "" - ? "Snapshot #" + Snapshots.browsingSnapshot - : "…/" + Snapshots.browsingPath - detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now" - action: "Back" - enabled: !Snapshots.browsing - onTriggered: Snapshots.browseUp() - } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 - TextRow { - width: parent.width - visible: Snapshots.browsing - label: "Reading the snapshot…" - detail: "Listing a folder from a point in time" - value: "" - } + SettingsButton { + visible: entryRow.modelData.directory === true + text: "Open" + enabled: !Snapshots.browsing + onClicked: Snapshots.browse(Snapshots.browsingConfig, + Snapshots.browsingSnapshot, + entryRow.entryPath) + } - Repeater { - model: Snapshots.browsing ? [] : Snapshots.browseEntries + SettingsButton { + text: entryRow.confirming ? "Cancel" : "Restore" + enabled: !Snapshots.busy + onClicked: root.confirmingRestore = + entryRow.confirming ? "" : entryRow.entryPath + } - delegate: SettingRow { - id: entryRow - - required property var modelData - required property int index - - readonly property string entryPath: Snapshots.browsingPath === "" - ? String(entryRow.modelData.name) - : Snapshots.browsingPath + "/" + String(entryRow.modelData.name) - readonly property bool confirming: root.confirmingRestore === entryRow.entryPath - - width: parent.width - icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}" - label: String(entryRow.modelData.name ?? "") - detail: entryRow.modelData.directory - ? "Folder" - : Snapshots.formatBytes(entryRow.modelData.bytes ?? 0) - controlWidth: 230 - divider: entryRow.index < Snapshots.browseEntries.length - 1 - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 8 - - SettingsButton { - visible: entryRow.modelData.directory === true - text: "Open" - enabled: !Snapshots.browsing - onClicked: Snapshots.browse(Snapshots.browsingConfig, - Snapshots.browsingSnapshot, - entryRow.entryPath) - } - - SettingsButton { - text: entryRow.confirming ? "Cancel" : "Restore" - enabled: !Snapshots.busy - onClicked: root.confirmingRestore = - entryRow.confirming ? "" : entryRow.entryPath - } - - SettingsButton { - visible: entryRow.confirming - text: "Put it back" - tone: "danger" - enabled: !Snapshots.busy - onClicked: { - root.confirmingRestore = ""; - Snapshots.restore(Snapshots.browsingConfig, - Snapshots.browsingSnapshot, - entryRow.entryPath); - } - } + SettingsButton { + visible: entryRow.confirming + text: "Put it back" + tone: "danger" + enabled: !Snapshots.busy + onClicked: { + root.confirmingRestore = ""; + Snapshots.restore(Snapshots.browsingConfig, + Snapshots.browsingSnapshot, + entryRow.entryPath); } } } - - TextRow { - width: parent.width - visible: !Snapshots.browsing && Snapshots.browseTruncated - label: "Only the first entries are shown" - detail: "This folder holds more than this list can usefully show" - value: "" - divider: false - } - - TextRow { - width: parent.width - visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0 - label: "Nothing here" - detail: "This folder was empty at that point in time" - value: "" - divider: false - } } } + + TextRow { + width: parent.width + visible: !Snapshots.browsing && Snapshots.browseTruncated + label: "Only the first entries are shown" + detail: "This folder holds more than this list can usefully show" + value: "" + divider: false + } + + TextRow { + width: parent.width + visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0 + label: "Nothing here" + detail: "This folder was empty at that point in time" + value: "" + divider: false + } } // ── What it costs ──────────────────────────────────────────────────────── diff --git a/config/dot/quickshell/modules/settings/StorageBreakdownBar.qml b/config/dot/quickshell/modules/settings/StorageBreakdownBar.qml new file mode 100644 index 0000000..7065d77 --- /dev/null +++ b/config/dot/quickshell/modules/settings/StorageBreakdownBar.qml @@ -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 + } + } + } + } + +} diff --git a/config/dot/quickshell/modules/settings/StoragePage.qml b/config/dot/quickshell/modules/settings/StoragePage.qml index a024fba..52418f7 100644 --- a/config/dot/quickshell/modules/settings/StoragePage.qml +++ b/config/dot/quickshell/modules/settings/StoragePage.qml @@ -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) diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index ab9dd70..e530f38 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/scripts/panama-applications b/config/dot/quickshell/scripts/panama-applications new file mode 100755 index 0000000..78a86a7 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-applications @@ -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 ` for a flatpak and `rpm -q ` +# 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:])) diff --git a/config/dot/quickshell/scripts/panama-default-apps b/config/dot/quickshell/scripts/panama-default-apps index 3e37a69..247bbd7 100755 --- a/config/dot/quickshell/scripts/panama-default-apps +++ b/config/dot/quickshell/scripts/panama-default-apps @@ -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 comes first; the xml:lang ones follow. + found = re.search(r"([^<]*)", 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) diff --git a/config/dot/quickshell/scripts/panama-disks b/config/dot/quickshell/scripts/panama-disks index 7e5621d..c64eb03 100755 --- a/config/dot/quickshell/scripts/panama-disks +++ b/config/dot/quickshell/scripts/panama-disks @@ -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 /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 diff --git a/config/dot/quickshell/scripts/panama-snapshots b/config/dot/quickshell/scripts/panama-snapshots index 92652b9..504061b 100755 --- a/config/dot/quickshell/scripts/panama-snapshots +++ b/config/dot/quickshell/scripts/panama-snapshots @@ -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: diff --git a/config/dot/quickshell/services/AppLibrary.qml b/config/dot/quickshell/services/AppLibrary.qml new file mode 100644 index 0000000..a23e517 --- /dev/null +++ b/config/dot/quickshell/services/AppLibrary.qml @@ -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 .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(); + } + } +} diff --git a/config/dot/quickshell/services/DefaultApps.qml b/config/dot/quickshell/services/DefaultApps.qml index d5b12c5..22df9db 100644 --- a/config/dot/quickshell/services/DefaultApps.qml +++ b/config/dot/quickshell/services/DefaultApps.qml @@ -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; diff --git a/config/dot/quickshell/services/Disks.qml b/config/dot/quickshell/services/Disks.qml index cc87969..f7431ba 100644 --- a/config/dot/quickshell/services/Disks.qml +++ b/config/dot/quickshell/services/Disks.qml @@ -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 { diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index baa5de4..d7585a9 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/services/Snapshots.qml b/config/dot/quickshell/services/Snapshots.qml index da0aa3f..8109c42 100644 --- a/config/dot/quickshell/services/Snapshots.qml +++ b/config/dot/quickshell/services/Snapshots.qml @@ -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 { diff --git a/config/local/share/vicinae/scripts/settings-applications b/config/local/share/vicinae/scripts/settings-applications index 4498a0c..346cc24 100755 --- a/config/local/share/vicinae/scripts/settings-applications +++ b/config/local/share/vicinae/scripts/settings-applications @@ -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 diff --git a/config/local/share/vicinae/scripts/settings-connectivity b/config/local/share/vicinae/scripts/settings-connectivity index 3d39046..552d5df 100755 --- a/config/local/share/vicinae/scripts/settings-connectivity +++ b/config/local/share/vicinae/scripts/settings-connectivity @@ -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 diff --git a/config/local/share/vicinae/scripts/settings-snapshots b/config/local/share/vicinae/scripts/settings-snapshots index 2ac94c2..44112f5 100755 --- a/config/local/share/vicinae/scripts/settings-snapshots +++ b/config/local/share/vicinae/scripts/settings-snapshots @@ -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 diff --git a/config/local/share/vicinae/scripts/settings-storage b/config/local/share/vicinae/scripts/settings-storage index 1787de7..b06c115 100755 --- a/config/local/share/vicinae/scripts/settings-storage +++ b/config/local/share/vicinae/scripts/settings-storage @@ -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 diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index 12fa730..352628a 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-24-applications-storage-snapshots-redesign.md b/docs/superpowers/specs/2026-08-24-applications-storage-snapshots-redesign.md new file mode 100644 index 0000000..c8dfc63 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-applications-storage-snapshots-redesign.md @@ -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 ` → `{ 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 ` → `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: | Label`, bare dnf names, indented continuations), + each entry `{ id, label, kind: flatpak|dnf, installed }` (installed via flatpak info / rpm -q). +- `install ` → flatpak: `flatpak install --noninteractive flathub `; + dnf: `pkexec dnf install -y `. 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 ` → matching MIME types with their + current handler + candidate apps; `set-type ` → `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 ` 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. diff --git a/tests/quickshell/app-library-contract b/tests/quickshell/app-library-contract new file mode 100755 index 0000000..6a061b9 --- /dev/null +++ b/tests/quickshell/app-library-contract @@ -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" <>"$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" <>"$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" <>"$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" <>"$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 + # `targetlabel` 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' diff --git a/tests/quickshell/applications-settings-contract b/tests/quickshell/applications-settings-contract index 735fd22..301041f 100755 --- a/tests/quickshell/applications-settings-contract +++ b/tests/quickshell/applications-settings-contract @@ -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' -fi +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' diff --git a/tests/quickshell/default-apps-contract b/tests/quickshell/default-apps-contract index b602df9..9e2bf9b 100755 --- a/tests/quickshell/default-apps-contract +++ b/tests/quickshell/default-apps-contract @@ -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' diff --git a/tests/quickshell/disks-contract b/tests/quickshell/disks-contract index 2c9624a..5c47da1 100755 --- a/tests/quickshell/disks-contract +++ b/tests/quickshell/disks-contract @@ -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" <>"$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")" diff --git a/tests/quickshell/snapshots-contract b/tests/quickshell/snapshots-contract index 79ef437..ed98091 100755 --- a/tests/quickshell/snapshots-contract +++ b/tests/quickshell/snapshots-contract @@ -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" <>"$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")"