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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
@@ -0,0 +1,328 @@
pragma Singleton
// What is installed, what Panama offers to install, and what a flatpak may do.
//
// DesktopEntries knows every application that put a launcher on this machine
// and nothing about where it came from. flatpak knows its own applications and
// nothing about the rest. Neither answers "what is installed", which is the
// question the Applications page asks, so this joins them: a desktop entry
// whose id matches an installed flatpak id is that flatpak, because flatpak
// exports its launcher as <app-id>.desktop and always has.
//
// Everything else is a system package. That is stated rather than acted on --
// there is no dnf removal here or in the helper. A settings page that
// uninstalls system packages is one mis-click from removing the compositor it
// is drawn by, and dnf will take half the desktop with it. The page names the
// command instead.
//
// Permissions are cached per application id. `flatpak info --show-permissions`
// is a process launch, the page shows them per expanded row, and re-reading
// them on every repaint would launch one per frame.
//
// Nothing here loads on its own. `flatpak list` and the catalog cost a process
// each and only the Applications page wants them, so the page calls refresh()
// and refreshCatalog() when it opens rather than every shell start paying for a
// page nobody opened.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// The seam: contracts point this at a stub that answers from fixtures, so
// "install refuses an id that is not in the catalog" can be proven without
// installing anything.
readonly property string helperPath: Quickshell.env("PANAMA_APPLICATIONS_HELPER")
|| Quickshell.shellDir + "/scripts/panama-applications"
// [{ id, name, size, sizeBytes, origin }]
property var flatpaks: []
// [{ name, label, entries: [{ id, ref, label, kind, installed }] }]
property var categories: []
property var permissionCache: ({})
property bool flatpaksLoaded: false
property bool catalogLoaded: false
property string lastError: ""
// Which row is mid-change, so the page can disable exactly that one rather
// than greying out the whole card.
property string busyEntryId: ""
// Guards read the Process objects directly rather than this binding; a
// binding is stale inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: flatpakQuery.running || catalogQuery.running || mutation.running
readonly property var flatpakIndex: {
const index = {};
for (const entry of root.flatpaks)
index[String(entry.id ?? "").toLowerCase()] = entry;
return index;
}
// [{ entryId, name, icon, kind, flatpakId, size, sizeBytes, entry }]
//
// `entry` is the DesktopEntry itself when there is one, so the page can
// launch it, read its categories, or match it against a notification rule
// without looking it up a second time.
readonly property var apps: {
const out = [];
const claimed = {};
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
const entryId = root.desktopId(entry);
const key = entryId.replace(/\.desktop$/, "").toLowerCase();
const flatpak = root.flatpakIndex[key] ?? null;
if (flatpak)
claimed[key] = true;
out.push({
entryId: entryId,
name: String(entry.name || entry.genericName || entryId),
icon: String(entry.icon ?? ""),
kind: flatpak ? "flatpak" : "system",
flatpakId: flatpak ? String(flatpak.id) : "",
size: flatpak ? String(flatpak.size ?? "") : "",
sizeBytes: flatpak ? Number(flatpak.sizeBytes ?? 0) : 0,
entry: entry
});
}
// A flatpak with no launcher is still installed and still takes space.
// Dropping it would make the list disagree with `flatpak list`, and the
// uninstall row is the only place someone can get rid of it.
for (const flatpak of root.flatpaks) {
const key = String(flatpak.id ?? "").toLowerCase();
if (claimed[key])
continue;
out.push({
entryId: String(flatpak.id) + ".desktop",
name: String(flatpak.name ?? flatpak.id),
icon: "",
kind: "flatpak",
flatpakId: String(flatpak.id),
size: String(flatpak.size ?? ""),
sizeBytes: Number(flatpak.sizeBytes ?? 0),
entry: null
});
}
out.sort((left, right) => left.name.localeCompare(right.name));
return out;
}
readonly property int flatpakCount: root.flatpaks.length
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
// Name and id both, because someone searching for "obs" and someone
// searching for "com.obsproject" are looking for the same row.
function matches(app: var, query: string): bool {
const needle = String(query ?? "").trim().toLowerCase();
if (needle === "")
return true;
return (String(app.name) + " " + String(app.entryId) + " " + String(app.flatpakId))
.toLowerCase().indexOf(needle) >= 0;
}
function entriesFor(category: string): var {
for (const entry of root.categories) {
if (String(entry.name) === String(category))
return entry.entries ?? [];
}
return [];
}
function categoryLabel(category: string): string {
for (const entry of root.categories) {
if (String(entry.name) === String(category))
return String(entry.label ?? entry.name);
}
return String(category);
}
// Cached, and null while the answer is on its way. Reading this inside a
// binding is safe: the read of permissionCache is what makes the binding
// re-evaluate when the answer lands.
function permissionsFor(flatpakId: string): var {
const key = String(flatpakId ?? "");
if (key === "")
return null;
const cached = root.permissionCache[key];
if (cached !== undefined)
return cached;
root.requestPermissions(key);
return null;
}
function requestPermissions(flatpakId: string): void {
if (permissionQuery.pending.indexOf(flatpakId) >= 0)
return;
permissionQuery.pending = permissionQuery.pending.concat([flatpakId]);
permissionQuery.pump();
}
function refresh(): void {
if (flatpakQuery.running)
return;
flatpakQuery.command = [root.helperPath, "flatpaks"];
flatpakQuery.running = true;
}
function refreshCatalog(): void {
if (catalogQuery.running)
return;
catalogQuery.command = [root.helperPath, "catalog"];
catalogQuery.running = true;
}
// Only ids the catalog already listed. The helper refuses off-catalog ids
// too -- this is the near guard, not the only one, because the page is a
// caller that can be wrong.
function install(category: string, entryId: string): void {
if (mutation.running)
return;
const known = root.entriesFor(category).some(entry => String(entry.id) === String(entryId));
if (!known) {
root.lastError = "That application is not in the catalog.";
return;
}
root.lastError = "";
root.busyEntryId = String(entryId);
mutation.mode = "catalog";
mutation.command = [root.helperPath, "install", String(category), String(entryId)];
mutation.running = true;
}
function uninstall(flatpakId: string): void {
if (mutation.running)
return;
const known = root.flatpaks.some(entry => String(entry.id) === String(flatpakId));
if (!known) {
root.lastError = "That application is not installed.";
return;
}
root.lastError = "";
root.busyEntryId = String(flatpakId);
mutation.mode = "flatpaks";
mutation.command = [root.helperPath, "uninstall", String(flatpakId)];
mutation.running = true;
}
function absorbFlatpaks(text: string): void {
try {
const parsed = JSON.parse(text);
root.flatpaks = Array.isArray(parsed) ? parsed : [];
root.flatpaksLoaded = true;
} catch (error) {
root.lastError = "The list of installed applications could not be read.";
console.warn("AppLibrary: could not parse flatpaks output:", error);
}
}
function absorbCatalog(text: string): void {
try {
const parsed = JSON.parse(text);
root.categories = Array.isArray(parsed.categories) ? parsed.categories : [];
root.catalogLoaded = true;
} catch (error) {
root.lastError = "The application catalog could not be read.";
console.warn("AppLibrary: could not parse catalog output:", error);
}
}
Process {
id: flatpakQuery
stdout: StdioCollector { onStreamFinished: root.absorbFlatpaks(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: catalogQuery
stdout: StdioCollector { onStreamFinished: root.absorbCatalog(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
// Which fresh state the helper answers with, so the reply lands in the
// right property instead of being parsed twice and guessed at.
property string mode: "flatpaks"
stdout: StdioCollector {
onStreamFinished: {
if (mutation.mode === "catalog")
root.absorbCatalog(this.text);
else
root.absorbFlatpaks(this.text);
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.busyEntryId = "";
// Uninstalling changes what the catalog says is installed, and
// installing changes what is installed. Both sides are re-read
// rather than assumed.
if (mutation.mode === "catalog")
root.refresh();
else
root.refreshCatalog();
}
}
Process {
id: permissionQuery
property var pending: []
property string current: ""
function pump(): void {
if (permissionQuery.running || permissionQuery.pending.length === 0)
return;
permissionQuery.current = String(permissionQuery.pending[0]);
permissionQuery.command = [root.helperPath, "permissions", permissionQuery.current];
permissionQuery.running = true;
}
stdout: StdioCollector {
onStreamFinished: {
const key = permissionQuery.current;
let value = { summary: [], raw: {} };
try {
const parsed = JSON.parse(this.text);
value = {
summary: Array.isArray(parsed.summary) ? parsed.summary : [],
raw: parsed.raw ?? ({})
};
} catch (error) {
// Cached as an honest empty answer rather than left absent,
// or every repaint asks again for something that failed.
value = { summary: ["Permissions could not be read"], raw: {} };
}
const next = Object.assign({}, root.permissionCache);
next[key] = value;
root.permissionCache = next;
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 && root.permissionCache[permissionQuery.current] === undefined) {
const next = Object.assign({}, root.permissionCache);
next[permissionQuery.current] = { summary: ["Permissions could not be read"], raw: {} };
root.permissionCache = next;
}
permissionQuery.pending = permissionQuery.pending.filter(
id => String(id) !== permissionQuery.current);
permissionQuery.current = "";
permissionQuery.pump();
}
}
}
@@ -18,6 +18,16 @@ Singleton {
property var luaAutostartEntries: []
property string lastError: ""
// One file type at a time: the escape hatch for when a role's family is too
// broad, which is a real case -- SVG belongs in an editor while every other
// image belongs in a viewer. Kept separate from the role state because it is
// a search, not a setting: it is whatever was last asked for and nothing is
// remembered between visits.
property var typeMatches: []
property string typeQuery: ""
property bool typeSearchTruncated: false
readonly property bool searchingTypes: typeSearch.running
// For the UI, which wants one answer to "is anything happening".
//
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
@@ -56,15 +66,48 @@ Singleton {
Process {
id: mutationProcess
// A set-type write changes the answer the open search is showing, so
// the search is re-run rather than left displaying the old handler.
property string repeatQuery: ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
mutationProcess.repeatQuery = ""
root.lastError = "That application setting could not be changed."
return;
}
if (mutationProcess.repeatQuery !== "") {
const query = mutationProcess.repeatQuery;
mutationProcess.repeatQuery = "";
root.searchTypes(query);
}
root.refresh();
}
}
Process {
id: typeSearch
stdout: StdioCollector {
onStreamFinished: {
try {
const payload = JSON.parse(this.text);
root.typeMatches = Array.isArray(payload.types) ? payload.types : [];
root.typeSearchTruncated = payload.truncated === true;
} catch (error) {
root.typeMatches = [];
root.lastError = "File types returned an unreadable response."
}
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.typeMatches = [];
root.lastError = "File types could not be searched."
}
}
}
function applySnapshot(text: string): void {
try {
const payload = JSON.parse(text);
@@ -135,6 +178,47 @@ Singleton {
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
}
// Searching is over the type name and its file extensions -- "svg", "heic",
// ".mkv". Matching the human descriptions would mean reading the whole
// shared-mime-info database, some thousands of small files, per keystroke.
function searchTypes(query: string): void {
const trimmed = String(query ?? "").trim();
root.typeQuery = trimmed;
if (trimmed.length < 2) {
root.typeMatches = [];
root.typeSearchTruncated = false;
return;
}
if (typeSearch.running)
return;
typeSearch.exec([root.helper, "search-types", trimmed]);
}
function clearTypeSearch(): void {
root.typeQuery = "";
root.typeMatches = [];
root.typeSearchTruncated = false;
}
// One type, one application: the override that leaves the rest of the
// family where it is. The helper validates the type against what this
// system knows and the application against what is installed.
function setType(mime: string, desktopId: string): void {
if (mutationProcess.running)
return;
if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test(mime)) {
root.lastError = "Choose a file type from the list."
return;
}
if (!root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
root.lastError = "";
mutationProcess.repeatQuery = root.typeQuery;
mutationProcess.exec([root.helper, "set-type", mime, desktopId]);
}
function addAutostart(desktopId: string): void {
if (mutationProcess.running)
return;
+117
View File
@@ -40,6 +40,32 @@ Singleton {
// are different answers.
property bool foldersMeasured: false
// What the used space is made of, and what could be freed.
//
// { segments: { home, applications, caches, system, free }, usedBytes,
// totalBytes, freeBytes, complete, exceedsUsed }
//
// `system` is the remainder -- used bytes minus the three measured segments
// -- and the page calls it "System & everything else" for that reason. It
// is not a measurement of the system; it is everything the walk did not
// reach. The segments never sum past used, and when a measurement would
// overshoot, `exceedsUsed` says so instead of a number being scaled to make
// the bar look tidy.
property var breakdown: null
property bool breakdownMeasured: false
property bool measuringBreakdown: false
// [{ id, label, detail, bytes, privileged }]
//
// Nothing here is selected, ordered by urgency, or acted on. Each row is
// freed only by its own id being passed to clean(), which is what keeps a
// mis-wired button from emptying four things at once.
property var cleanables: []
property bool cleanablesMeasured: false
property bool measuringCleanables: false
// Which row is mid-clean, so the page can disable that one row.
property string cleaningId: ""
readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null
// The filesystem the user means when they ask how full the machine is.
@@ -110,6 +136,49 @@ Singleton {
folderScan.running = true;
}
// The same walk `scan` does, so it costs the same and is asked for on
// demand rather than when the page opens.
function measureBreakdown(): void {
if (root.measuringBreakdown)
return;
root.measuringBreakdown = true;
breakdownScan.running = true;
}
function measureCleanables(): void {
if (root.measuringCleanables)
return;
root.measuringCleanables = true;
cleanableScan.running = true;
}
// Exactly one, named. An id this service has not been told about is
// refused here and refused again by the helper.
function clean(identifier: string): void {
if (cleaner.running || root.measuringCleanables)
return;
const known = root.cleanables.some(item => String(item.id) === String(identifier));
if (!known) {
root.lastError = "There is nothing by that name to clean up.";
return;
}
root.lastError = "";
root.cleaningId = String(identifier);
cleaner.command = [root.helperPath, "clean", String(identifier)];
cleaner.running = true;
}
function absorbCleanables(text: string): void {
try {
const parsed = JSON.parse(text);
root.cleanables = Array.isArray(parsed) ? parsed : [];
root.cleanablesMeasured = true;
} catch (error) {
root.lastError = "Could not measure what could be cleaned up.";
console.warn("Disks: could not parse cleanables output:", error);
}
}
function unmount(devicePath: string): void {
root.runMedia(["unmount", devicePath]);
}
@@ -171,6 +240,54 @@ Singleton {
onExited: root.scanning = false
}
Process {
id: breakdownScan
command: [root.helperPath, "breakdown"]
stdout: StdioCollector {
onStreamFinished: {
try {
root.breakdown = JSON.parse(this.text);
root.breakdownMeasured = true;
} catch (error) {
root.lastError = "Could not measure what is using the drive.";
console.warn("Disks: could not parse breakdown output:", error);
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.measuringBreakdown = false
}
Process {
id: cleanableScan
command: [root.helperPath, "cleanables"]
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.measuringCleanables = false
}
Process {
id: cleaner
// The helper answers with the fresh list, so the sizes on screen are
// what is there now rather than what was there before the clean.
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.cleaningId = "";
// Freeing space changes the drive's usage and its breakdown, and
// both are on screen while this happens.
root.refresh();
if (root.breakdownMeasured)
root.measureBreakdown();
}
}
Process {
id: media
stderr: StdioCollector {
@@ -92,6 +92,16 @@ Singleton {
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
// The Applications tab manages applications now, rather than only
// pointing file types at them, so the things people come looking for —
// what is installed, removing one, what it is allowed to reach — are
// findable by their own names.
{ label: "Installed applications", detail: "Everything installed here, where it came from, and how much room it takes", page: "applications" },
{ label: "Uninstall an application", detail: "Remove a Flatpak application, or see the command for a system package", page: "applications" },
{ label: "Application permissions", detail: "What a Flatpak application may reach: files, camera, microphone, network", page: "applications" },
{ label: "Install applications", detail: "Browse the catalog Panama curates, by category", page: "applications" },
{ label: "Autostart", detail: "Which applications start with your session", page: "applications" },
{ label: "File associations", detail: "Which application opens each kind of file", page: "applications" },
{ label: "User account", detail: "Your name, picture, and password", page: "users" },
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },
{ label: "Change password", detail: "Set a new password for signing in", page: "users" },
@@ -133,11 +143,14 @@ Singleton {
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
{ label: "File history", detail: "Earlier versions of your files", page: "snapshots" },
{ label: "Undo a change", detail: "Put back a file as it was at an earlier point", page: "snapshots" },
{ label: "Snapshot retention", detail: "How many hourly, daily, and weekly snapshots to keep", page: "snapshots" },
{ label: "Free space", detail: "How full each drive and filesystem is", page: "storage" },
{ label: "Disk usage", detail: "What is using the space on this machine", page: "storage" },
{ label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" },
{ label: "Removable drives", detail: "Unmount a USB drive or memory card safely", page: "storage" },
{ label: "Encryption", detail: "Whether the filesystem is encrypted", page: "storage" },
{ label: "Clean up storage", detail: "Caches, trash, and unused runtimes, each itemized and sized before you remove it", page: "storage" },
{ label: "Application caches", detail: "What applications have left in your cache folder, and clearing it", page: "storage" },
{ label: "Output volume", detail: "Choose the output device and its level", page: "sound" },
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
+34 -1
View File
@@ -44,6 +44,26 @@ Singleton {
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: query.running || mutation.running
// How many snapshots a horizon may keep. The page offers a dropdown rather
// than a number field, and this is what fills it; the helper refuses
// anything above it too. 999 hourly snapshots is not a retention policy,
// it is a typo that fills a drive.
//
// The monthly and yearly limits are not editable here and are never
// written, so a configuration with longer horizons keeps them.
readonly property int retentionMax: 50
readonly property var retentionChoices: {
const values = [];
for (let index = 0; index <= root.retentionMax; index += 1)
values.push(index);
return values;
}
// The card the browser lives in is not the card it was opened from: the
// browse state below is independent of any config row's expanded state, so
// opening a snapshot from a collapsed row still shows the files.
readonly property bool browserOpen: root.browsingConfig !== ""
function labelFor(config: var): string {
const subvolume = String(config?.subvolume ?? "");
if (subvolume === "/")
@@ -128,8 +148,21 @@ Singleton {
root.run(["delete", config, String(number)]);
}
// Three horizons, each a whole number the dropdown offered. Checked here as
// well as in the helper because the caller is a page that can be wrong, and
// a keep count that arrives as "5.5" or "-1" would be written straight into
// snapper's config file.
function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]);
const values = [hourly, daily, weekly];
for (const value of values) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > root.retentionMax) {
root.lastError = "Keep counts go from 0 to " + root.retentionMax + ".";
return;
}
}
root.run(["set-retention", config,
String(values[0]), String(values[1]), String(values[2])]);
}
function setTimeline(config: string, enabled: bool): void {