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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user