pragma Singleton // What is installed, what Panama offers to install, and what a flatpak may do. // // DesktopEntries knows every application that put a launcher on this machine // and nothing about where it came from. flatpak knows its own applications and // nothing about the rest. Neither answers "what is installed", which is the // question the Applications page asks, so this joins them: a desktop entry // whose id matches an installed flatpak id is that flatpak, because flatpak // exports its launcher as .desktop and always has. // // Everything else is a system package. That is stated rather than acted on -- // there is no dnf removal here or in the helper. A settings page that // uninstalls system packages is one mis-click from removing the compositor it // is drawn by, and dnf will take half the desktop with it. The page names the // command instead. // // Permissions are cached per application id. `flatpak info --show-permissions` // is a process launch, the page shows them per expanded row, and re-reading // them on every repaint would launch one per frame. // // Nothing here loads on its own. `flatpak list` and the catalog cost a process // each and only the Applications page wants them, so the page calls refresh() // and refreshCatalog() when it opens rather than every shell start paying for a // page nobody opened. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root // The seam: contracts point this at a stub that answers from fixtures, so // "install refuses an id that is not in the catalog" can be proven without // installing anything. readonly property string helperPath: Quickshell.env("PANAMA_APPLICATIONS_HELPER") || Quickshell.shellDir + "/scripts/panama-applications" // [{ id, name, size, sizeBytes, origin }] property var flatpaks: [] // [{ name, label, entries: [{ id, ref, label, kind, installed }] }] property var categories: [] property var permissionCache: ({}) property bool flatpaksLoaded: false property bool catalogLoaded: false property string lastError: "" // Which row is mid-change, so the page can disable exactly that one rather // than greying out the whole card. property string busyEntryId: "" // Guards read the Process objects directly rather than this binding; a // binding is stale inside the handler that changes it. See DefaultApps.qml. readonly property bool busy: flatpakQuery.running || catalogQuery.running || mutation.running readonly property var flatpakIndex: { const index = {}; for (const entry of root.flatpaks) index[String(entry.id ?? "").toLowerCase()] = entry; return index; } // [{ entryId, name, icon, kind, flatpakId, size, sizeBytes, entry }] // // `entry` is the DesktopEntry itself when there is one, so the page can // launch it, read its categories, or match it against a notification rule // without looking it up a second time. readonly property var apps: { const out = []; const claimed = {}; for (const entry of DesktopEntries.applications.values) { if (entry.noDisplay) continue; const entryId = root.desktopId(entry); const key = entryId.replace(/\.desktop$/, "").toLowerCase(); const flatpak = root.flatpakIndex[key] ?? null; if (flatpak) claimed[key] = true; out.push({ entryId: entryId, name: String(entry.name || entry.genericName || entryId), icon: String(entry.icon ?? ""), kind: flatpak ? "flatpak" : "system", flatpakId: flatpak ? String(flatpak.id) : "", size: flatpak ? String(flatpak.size ?? "") : "", sizeBytes: flatpak ? Number(flatpak.sizeBytes ?? 0) : 0, entry: entry }); } // A flatpak with no launcher is still installed and still takes space. // Dropping it would make the list disagree with `flatpak list`, and the // uninstall row is the only place someone can get rid of it. for (const flatpak of root.flatpaks) { const key = String(flatpak.id ?? "").toLowerCase(); if (claimed[key]) continue; out.push({ entryId: String(flatpak.id) + ".desktop", name: String(flatpak.name ?? flatpak.id), icon: "", kind: "flatpak", flatpakId: String(flatpak.id), size: String(flatpak.size ?? ""), sizeBytes: Number(flatpak.sizeBytes ?? 0), entry: null }); } out.sort((left, right) => left.name.localeCompare(right.name)); return out; } readonly property int flatpakCount: root.flatpaks.length function desktopId(entry: var): string { const entryId = String(entry?.id ?? ""); return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop"; } // Name and id both, because someone searching for "obs" and someone // searching for "com.obsproject" are looking for the same row. function matches(app: var, query: string): bool { const needle = String(query ?? "").trim().toLowerCase(); if (needle === "") return true; return (String(app.name) + " " + String(app.entryId) + " " + String(app.flatpakId)) .toLowerCase().indexOf(needle) >= 0; } function entriesFor(category: string): var { for (const entry of root.categories) { if (String(entry.name) === String(category)) return entry.entries ?? []; } return []; } function categoryLabel(category: string): string { for (const entry of root.categories) { if (String(entry.name) === String(category)) return String(entry.label ?? entry.name); } return String(category); } // Cached, and null while the answer is on its way. Reading this inside a // binding is safe: the read of permissionCache is what makes the binding // re-evaluate when the answer lands. function permissionsFor(flatpakId: string): var { const key = String(flatpakId ?? ""); if (key === "") return null; const cached = root.permissionCache[key]; if (cached !== undefined) return cached; root.requestPermissions(key); return null; } function requestPermissions(flatpakId: string): void { if (permissionQuery.pending.indexOf(flatpakId) >= 0) return; permissionQuery.pending = permissionQuery.pending.concat([flatpakId]); permissionQuery.pump(); } function refresh(): void { if (flatpakQuery.running) return; flatpakQuery.command = [root.helperPath, "flatpaks"]; flatpakQuery.running = true; } function refreshCatalog(): void { if (catalogQuery.running) return; catalogQuery.command = [root.helperPath, "catalog"]; catalogQuery.running = true; } // Only ids the catalog already listed. The helper refuses off-catalog ids // too -- this is the near guard, not the only one, because the page is a // caller that can be wrong. function install(category: string, entryId: string): void { if (mutation.running) return; const known = root.entriesFor(category).some(entry => String(entry.id) === String(entryId)); if (!known) { root.lastError = "That application is not in the catalog."; return; } root.lastError = ""; root.busyEntryId = String(entryId); mutation.mode = "catalog"; mutation.command = [root.helperPath, "install", String(category), String(entryId)]; mutation.running = true; } function uninstall(flatpakId: string): void { if (mutation.running) return; const known = root.flatpaks.some(entry => String(entry.id) === String(flatpakId)); if (!known) { root.lastError = "That application is not installed."; return; } root.lastError = ""; root.busyEntryId = String(flatpakId); mutation.mode = "flatpaks"; mutation.command = [root.helperPath, "uninstall", String(flatpakId)]; mutation.running = true; } function absorbFlatpaks(text: string): void { try { const parsed = JSON.parse(text); root.flatpaks = Array.isArray(parsed) ? parsed : []; root.flatpaksLoaded = true; } catch (error) { root.lastError = "The list of installed applications could not be read."; console.warn("AppLibrary: could not parse flatpaks output:", error); } } function absorbCatalog(text: string): void { try { const parsed = JSON.parse(text); root.categories = Array.isArray(parsed.categories) ? parsed.categories : []; root.catalogLoaded = true; } catch (error) { root.lastError = "The application catalog could not be read."; console.warn("AppLibrary: could not parse catalog output:", error); } } Process { id: flatpakQuery stdout: StdioCollector { onStreamFinished: root.absorbFlatpaks(this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } } Process { id: catalogQuery stdout: StdioCollector { onStreamFinished: root.absorbCatalog(this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } } Process { id: mutation // Which fresh state the helper answers with, so the reply lands in the // right property instead of being parsed twice and guessed at. property string mode: "flatpaks" stdout: StdioCollector { onStreamFinished: { if (mutation.mode === "catalog") root.absorbCatalog(this.text); else root.absorbFlatpaks(this.text); } } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } onExited: { root.busyEntryId = ""; // Uninstalling changes what the catalog says is installed, and // installing changes what is installed. Both sides are re-read // rather than assumed. if (mutation.mode === "catalog") root.refresh(); else root.refreshCatalog(); } } Process { id: permissionQuery property var pending: [] property string current: "" function pump(): void { if (permissionQuery.running || permissionQuery.pending.length === 0) return; permissionQuery.current = String(permissionQuery.pending[0]); permissionQuery.command = [root.helperPath, "permissions", permissionQuery.current]; permissionQuery.running = true; } stdout: StdioCollector { onStreamFinished: { const key = permissionQuery.current; let value = { summary: [], raw: {} }; try { const parsed = JSON.parse(this.text); value = { summary: Array.isArray(parsed.summary) ? parsed.summary : [], raw: parsed.raw ?? ({}) }; } catch (error) { // Cached as an honest empty answer rather than left absent, // or every repaint asks again for something that failed. value = { summary: ["Permissions could not be read"], raw: {} }; } const next = Object.assign({}, root.permissionCache); next[key] = value; root.permissionCache = next; } } onExited: (exitCode, exitStatus) => { if (exitCode !== 0 && root.permissionCache[permissionQuery.current] === undefined) { const next = Object.assign({}, root.permissionCache); next[permissionQuery.current] = { summary: ["Permissions could not be read"], raw: {} }; root.permissionCache = next; } permissionQuery.pending = permissionQuery.pending.filter( id => String(id) !== permissionQuery.current); permissionQuery.current = ""; permissionQuery.pump(); } } }