235 lines
9.0 KiB
QML
235 lines
9.0 KiB
QML
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()
|
|
}
|