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