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();
}
}
}