pragma Singleton // Freedesktop default handlers and session autostart entries. // // The helper owns parsing and atomic desktop-file writes. This singleton keeps // the QML side typed and reactive, and every external command crosses Process // as an argument array. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root property var handlers: ({}) property var autostartEntries: [] 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 // hands back its cached value until the change notification that feeds it // has been delivered. Inside a process's own onExited handler that has not // happened yet, so `busy` still reads true there -- which silently turned // the refresh after every successful write into a no-op. The write landed // and the settings page never noticed, which looks exactly like a settings // page that cannot change anything. Guards below read the Process objects // directly, where the value is current. readonly property bool busy: snapshotProcess.running || mutationProcess.running readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps" // Derived from the snapshot, never restated. This was a hardcoded list of // seven, and when the helper and the page grew documents, text and archives // it stayed at seven -- so choosing a PDF viewer set lastError and did // nothing, which reads exactly like a settings page that does not work. // // The snapshot already reports one handler per role the helper supports, so // that IS the list. An empty one means no snapshot has landed yet; the // helper validates the role itself and reports a failure, so there is // nothing for this guard to add before then. readonly property var supportedRoles: Object.keys(root.handlers ?? ({})) Process { id: snapshotProcess stdout: StdioCollector { onStreamFinished: root.applySnapshot(this.text) } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) root.lastError = "Default applications could not be read. Try refreshing." } } 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); root.handlers = payload.handlers ?? ({}); root.autostartEntries = payload.autostartEntries ?? []; root.luaAutostartEntries = payload.luaAutostartEntries ?? []; root.lastError = ""; } catch (error) { root.lastError = "Default applications returned an unreadable response." } } function refresh(): void { // Only a snapshot already in flight is a reason not to start another. // A mutation finishing is the single best reason TO refresh. if (snapshotProcess.running) return; root.lastError = ""; snapshotProcess.exec([root.helper, "snapshot"]); } function knownDesktopId(desktopId: string): bool { if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId)) return false; const entries = DesktopEntries.applications.values; return entries.some(entry => { const entryId = String(entry.id ?? ""); return entryId === desktopId || entryId + ".desktop" === desktopId; }); } function setDefault(role: string, desktopId: string): void { if (mutationProcess.running) return; const roleIsKnown = root.supportedRoles.length === 0 || root.supportedRoles.includes(role); if (!roleIsKnown || !root.knownDesktopId(desktopId)) { root.lastError = "Choose an application from the available list." return; } root.lastError = ""; mutationProcess.exec([root.helper, "set-default", role, desktopId]); } function setAutostart(desktopId: string, enabled: bool): void { if (mutationProcess.running) return; const known = root.autostartEntries.some(entry => entry.id === desktopId); if (!known) { root.lastError = "That user autostart entry is no longer available." return; } root.lastError = ""; mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); } // Deletes the entry rather than hiding it. Disabling writes Hidden=true and // is reversible; this is not, so the page confirms before calling it. function removeAutostart(desktopId: string): void { if (mutationProcess.running) return; const known = root.autostartEntries.some(entry => entry.id === desktopId); if (!known) { root.lastError = "That user autostart entry is no longer available." return; } root.lastError = ""; 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; if (!root.knownDesktopId(desktopId)) { root.lastError = "Choose an installed application."; return; } root.lastError = ""; mutationProcess.exec([root.helper, "add-autostart", desktopId]); } Component.onCompleted: root.refresh() }