329 lines
13 KiB
QML
329 lines
13 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|