From f655875547b71afdd643e158c62c2b290f7e3ee7 Mon Sep 17 00:00:00 2001 From: outfoxxed Date: Sun, 16 Jun 2024 01:58:24 -0700 Subject: [PATCH] core/desktopentry: add limited desktop entry api --- src/core/CMakeLists.txt | 1 + src/core/desktopentry.cpp | 367 ++++++++++++++++++++++++++++++++++++++ src/core/desktopentry.hpp | 151 ++++++++++++++++ src/core/module.md | 1 + 4 files changed, 520 insertions(+) create mode 100644 src/core/desktopentry.cpp create mode 100644 src/core/desktopentry.hpp diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 24d2e68..b76c7aa 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -28,6 +28,7 @@ qt_add_library(quickshell-core STATIC boundcomponent.cpp model.cpp elapsedtimer.cpp + desktopentry.cpp ) set_source_files_properties(main.cpp PROPERTIES COMPILE_DEFINITIONS GIT_REVISION="${GIT_REVISION}") diff --git a/src/core/desktopentry.cpp b/src/core/desktopentry.cpp new file mode 100644 index 0000000..a5ecef8 --- /dev/null +++ b/src/core/desktopentry.cpp @@ -0,0 +1,367 @@ +#include "desktopentry.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "model.hpp" + +Q_LOGGING_CATEGORY(logDesktopEntry, "quickshell.desktopentry", QtWarningMsg); + +struct Locale { + explicit Locale() = default; + + explicit Locale(const QString& string) { + auto territoryIdx = string.indexOf('_'); + auto codesetIdx = string.indexOf('.'); + auto modifierIdx = string.indexOf('@'); + + auto parseEnd = string.length(); + + if (modifierIdx != -1) { + this->modifier = string.sliced(modifierIdx + 1, parseEnd - modifierIdx - 1); + parseEnd = modifierIdx; + } + + if (codesetIdx != -1) { + parseEnd = codesetIdx; + } + + if (territoryIdx != -1) { + this->territory = string.sliced(territoryIdx + 1, parseEnd - territoryIdx - 1); + parseEnd = territoryIdx; + } + + this->language = string.sliced(0, parseEnd); + } + + [[nodiscard]] bool isValid() const { return !this->language.isEmpty(); } + + [[nodiscard]] int matchScore(const Locale& other) const { + if (this->language != other.language) return 0; + auto territoryMatches = !this->territory.isEmpty() && this->territory == other.territory; + auto modifierMatches = !this->modifier.isEmpty() && this->modifier == other.modifier; + + auto score = 1; + if (territoryMatches) score += 2; + if (modifierMatches) score += 1; + + return score; + } + + static const Locale& system() { + static Locale* locale = nullptr; // NOLINT + + if (locale == nullptr) { + auto lstr = qEnvironmentVariable("LC_MESSAGES"); + if (lstr.isEmpty()) lstr = qEnvironmentVariable("LANG"); + locale = new Locale(lstr); + } + + return *locale; + } + + QString language; + QString territory; + QString modifier; +}; + +QDebug operator<<(QDebug debug, const Locale& locale) { + auto saver = QDebugStateSaver(debug); + debug.nospace() << "Locale(language=" << locale.language << ", territory=" << locale.territory + << ", modifier" << locale.modifier << ')'; + + return debug; +} + +void DesktopEntry::parseEntry(const QString& text) { + const auto& system = Locale::system(); + + auto groupName = QString(); + auto entries = QHash>(); + + auto finishCategory = [&]() { + if (groupName == "Desktop Entry") { + if (entries["Type"].second != "Application") return; + if (entries.contains("Hidden") && entries["Hidden"].second == "true") return; + + for (const auto& [key, pair]: entries.asKeyValueRange()) { + auto& [_, value] = pair; + this->mEntries.insert(key, value); + + if (key == "Name") this->mName = value; + else if (key == "GenericName") this->mGenericName = value; + else if (key == "NoDisplay") this->mNoDisplay = value == "true"; + else if (key == "Comment") this->mComment = value; + else if (key == "Icon") this->mIcon = value; + else if (key == "Exec") this->mExecString = value; + else if (key == "Path") this->mWorkingDirectory = value; + else if (key == "Terminal") this->mTerminal = value == "true"; + else if (key == "Categories") this->mCategories = value.split(u';', Qt::SkipEmptyParts); + else if (key == "Keywords") this->mKeywords = value.split(u';', Qt::SkipEmptyParts); + } + } else if (groupName.startsWith("Desktop Action ")) { + auto actionName = groupName.sliced(16); + auto* action = new DesktopAction(actionName, this); + + for (const auto& [key, pair]: entries.asKeyValueRange()) { + const auto& [_, value] = pair; + action->mEntries.insert(key, value); + + if (key == "Name") action->mName = value; + else if (key == "Icon") action->mIcon = value; + else if (key == "Exec") action->mExecString = value; + } + + this->mActions.insert(actionName, action); + } + + entries.clear(); + }; + + for (auto& line: text.split(u'\n', Qt::SkipEmptyParts)) { + if (line.startsWith(u'#')) continue; + + if (line.startsWith(u'[') && line.endsWith(u']')) { + finishCategory(); + groupName = line.sliced(1, line.length() - 2); + continue; + } + + auto splitIdx = line.indexOf(u'='); + if (splitIdx == -1) { + qCDebug(logDesktopEntry) << "Encountered invalid line in desktop entry (no =)" << line; + continue; + } + + auto key = line.sliced(0, splitIdx); + const auto& value = line.sliced(splitIdx + 1); + + auto localeIdx = key.indexOf('['); + Locale locale; + if (localeIdx != -1 && localeIdx != key.length() - 1) { + locale = Locale(key.sliced(localeIdx + 1, key.length() - localeIdx - 2)); + key = key.sliced(0, localeIdx); + } + + if (entries.contains(key)) { + const auto& old = entries.value(key); + + if (system.matchScore(locale) > system.matchScore(old.first)) { + entries.insert(key, qMakePair(locale, value)); + } + } else { + entries.insert(key, qMakePair(locale, value)); + } + } + + finishCategory(); +} + +void DesktopEntry::execute() const { + DesktopEntry::doExec(this->mExecString, this->mWorkingDirectory); +} + +bool DesktopEntry::isValid() const { return !this->mName.isEmpty(); } +bool DesktopEntry::noDisplay() const { return this->mNoDisplay; } + +QVector DesktopEntry::actions() const { return this->mActions.values(); } + +QVector DesktopEntry::parseExecString(const QString& execString) { + QVector arguments; + QString currentArgument; + auto parsingString = false; + auto escape = 0; + auto percent = false; + + for (auto c: execString) { + if (escape == 0 && c == u'\\') { + escape = 1; + } else if (parsingString) { + if (c == '\\') { + escape++; + if (escape == 4) { + currentArgument += '\\'; + escape = 0; + } + } else if (escape != 0) { + if (escape != 2) { + // Technically this is an illegal state, but the spec has a terrible double escape + // rule in strings for no discernable reason. Assuming someone might understandably + // misunderstand it, treat it as a normal escape and log it. + qCWarning(logDesktopEntry).noquote() + << "Illegal escape sequence in desktop entry exec string:" << execString; + } + + currentArgument += c; + escape = 0; + } else if (c == u'"') { + parsingString = false; + } else { + currentArgument += c; + } + } else if (escape != 0) { + currentArgument += c; + escape = 0; + } else if (percent) { + if (c == '%') { + currentArgument += '%'; + } // else discard + + percent = false; + } else if (c == '%') { + percent = true; + } else if (c == u'"') { + parsingString = true; + } else if (c == u' ') { + if (!currentArgument.isEmpty()) { + arguments.push_back(currentArgument); + currentArgument.clear(); + } + } else { + currentArgument += c; + } + } + + if (!currentArgument.isEmpty()) { + arguments.push_back(currentArgument); + currentArgument.clear(); + } + + return arguments; +} + +void DesktopEntry::doExec(const QString& execString, const QString& workingDirectory) { + auto args = DesktopEntry::parseExecString(execString); + if (args.isEmpty()) { + qCWarning(logDesktopEntry) << "Tried to exec string" << execString << "which parsed as empty."; + return; + } + + auto process = QProcess(); + process.setProgram(args.at(0)); + process.setArguments(args.sliced(1)); + if (!workingDirectory.isEmpty()) process.setWorkingDirectory(workingDirectory); + process.startDetached(); +} + +void DesktopAction::execute() const { + DesktopEntry::doExec(this->mExecString, this->entry->mWorkingDirectory); +} + +DesktopEntryManager::DesktopEntryManager() { + this->scanDesktopEntries(); + this->populateApplications(); +} + +void DesktopEntryManager::scanDesktopEntries() { + QList dataPaths; + + if (qEnvironmentVariableIsSet("XDG_DATA_DIRS")) { + auto var = qEnvironmentVariable("XDG_DATA_DIRS"); + dataPaths = var.split(u':', Qt::SkipEmptyParts); + } else { + dataPaths.push_back("/usr/local/share"); + dataPaths.push_back("/usr/share"); + } + + qCDebug(logDesktopEntry) << "Creating desktop entry scanners"; + + for (auto& path: std::ranges::reverse_view(dataPaths)) { + auto p = QDir(path).filePath("applications"); + auto file = QFileInfo(p); + + if (!file.isDir()) { + qCDebug(logDesktopEntry) << "Not scanning path" << p << "as it is not a directory"; + continue; + } + + qCDebug(logDesktopEntry) << "Scanning path" << p; + this->scanPath(p); + } +} + +void DesktopEntryManager::populateApplications() { + for (auto& entry: this->desktopEntries.values()) { + if (!entry->noDisplay()) this->mApplications.insertObject(entry); + } +} + +void DesktopEntryManager::scanPath(const QDir& dir, const QString& prefix) { + auto entries = dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); + + for (auto& entry: entries) { + if (entry.isDir()) this->scanPath(entry.path(), prefix + dir.dirName() + "-"); + else if (entry.isFile()) { + auto path = entry.filePath(); + if (!path.endsWith(".desktop")) { + qCDebug(logDesktopEntry) << "Skipping file" << path << "as it has no .desktop extension"; + continue; + } + + auto* file = new QFile(path); + + if (!file->open(QFile::ReadOnly)) { + qCDebug(logDesktopEntry) << "Could not open file" << path; + continue; + } + + auto id = prefix + entry.fileName().sliced(0, entry.fileName().length() - 8); + + auto text = QString::fromUtf8(file->readAll()); + auto* dentry = new DesktopEntry(id, this); + dentry->parseEntry(text); + + if (!dentry->isValid()) { + qCDebug(logDesktopEntry) << "Skipping desktop entry" << path; + delete dentry; + continue; + } + + qCDebug(logDesktopEntry) << "Found desktop entry" << id << "at" << path; + + if (desktopEntries.contains(id)) { + qCDebug(logDesktopEntry) << "Replacing old entry for" << id; + delete desktopEntries.value(id); + desktopEntries.remove(id); + } + + desktopEntries.insert(id, dentry); + } + } +} + +DesktopEntryManager* DesktopEntryManager::instance() { + static auto* instance = new DesktopEntryManager(); // NOLINT + return instance; +} + +DesktopEntry* DesktopEntryManager::byId(const QString& id) { + return this->desktopEntries.value(id); +} + +ObjectModel* DesktopEntryManager::applications() { return &this->mApplications; } + +DesktopEntries::DesktopEntries() { DesktopEntryManager::instance(); } + +DesktopEntry* DesktopEntries::byId(const QString& id) { + return DesktopEntryManager::instance()->byId(id); +} + +ObjectModel* DesktopEntries::applications() { + return DesktopEntryManager::instance()->applications(); +} diff --git a/src/core/desktopentry.hpp b/src/core/desktopentry.hpp new file mode 100644 index 0000000..b9399d4 --- /dev/null +++ b/src/core/desktopentry.hpp @@ -0,0 +1,151 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "model.hpp" + +class DesktopAction; + +/// A desktop entry. See [DesktopEntries](../desktopentries) for details. +class DesktopEntry: public QObject { + Q_OBJECT; + Q_PROPERTY(QString id MEMBER mId CONSTANT); + /// Name of the specific application, such as "Firefox". + Q_PROPERTY(QString name MEMBER mName CONSTANT); + /// Short description of the application, such as "Web Browser". May be empty. + Q_PROPERTY(QString genericName MEMBER mGenericName CONSTANT); + /// If true, this application should not be displayed in menus and launchers. + Q_PROPERTY(bool noDisplay MEMBER mNoDisplay CONSTANT); + /// Long description of the application, such as "View websites on the internet". May be empty. + Q_PROPERTY(QString comment MEMBER mComment CONSTANT); + /// Name of the icon associated with this application. May be empty. + Q_PROPERTY(QString icon MEMBER mIcon CONSTANT); + /// The raw `Exec` string from the desktop entry. You probably want `execute()`. + Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + /// The working directory to execute from. + Q_PROPERTY(QString workingDirectory MEMBER mWorkingDirectory CONSTANT); + /// If the application should run in a terminal. + Q_PROPERTY(bool runInTerminal MEMBER mTerminal CONSTANT); + Q_PROPERTY(QVector categories MEMBER mCategories CONSTANT); + Q_PROPERTY(QVector keywords MEMBER mKeywords CONSTANT); + Q_PROPERTY(QVector actions READ actions CONSTANT); + QML_ELEMENT; + QML_UNCREATABLE("DesktopEntry instances must be retrieved from DesktopEntries"); + +public: + explicit DesktopEntry(QString id, QObject* parent): QObject(parent), mId(std::move(id)) {} + + void parseEntry(const QString& text); + + /// Run the application. Currently ignores `runInTerminal` and field codes. + Q_INVOKABLE void execute() const; + + [[nodiscard]] bool isValid() const; + [[nodiscard]] bool noDisplay() const; + [[nodiscard]] QVector actions() const; + + // currently ignores all field codes. + static QVector parseExecString(const QString& execString); + static void doExec(const QString& execString, const QString& workingDirectory); + +private: + QHash mEntries; + QHash mActions; + + QString mId; + QString mName; + QString mGenericName; + bool mNoDisplay = false; + QString mComment; + QString mIcon; + QString mExecString; + QString mWorkingDirectory; + bool mTerminal = false; + QVector mCategories; + QVector mKeywords; + + friend class DesktopAction; +}; + +/// An action of a [DesktopEntry](../desktopentry). +class DesktopAction: public QObject { + Q_OBJECT; + Q_PROPERTY(QString id MEMBER mId CONSTANT); + Q_PROPERTY(QString name MEMBER mName CONSTANT); + Q_PROPERTY(QString icon MEMBER mIcon CONSTANT); + /// The raw `Exec` string from the desktop entry. You probably want `execute()`. + Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + QML_ELEMENT; + QML_UNCREATABLE("DesktopAction instances must be retrieved from a DesktopEntry"); + +public: + explicit DesktopAction(QString id, DesktopEntry* entry) + : QObject(entry) + , entry(entry) + , mId(std::move(id)) {} + + /// Run the application. Currently ignores `runInTerminal` and field codes. + Q_INVOKABLE void execute() const; + +private: + DesktopEntry* entry; + QString mId; + QString mName; + QString mIcon; + QString mExecString; + QHash mEntries; + + friend class DesktopEntry; +}; + +class DesktopEntryManager: public QObject { + Q_OBJECT; + +public: + void scanDesktopEntries(); + + [[nodiscard]] DesktopEntry* byId(const QString& id); + + [[nodiscard]] ObjectModel* applications(); + + static DesktopEntryManager* instance(); + +private: + explicit DesktopEntryManager(); + + void populateApplications(); + void scanPath(const QDir& dir, const QString& prefix = QString()); + + QHash desktopEntries; + ObjectModel mApplications {this}; +}; + +///! Desktop entry index. +/// Index of desktop entries according to the [desktop entry specification]. +/// +/// Primarily useful for looking up icons and metadata from an id, as there is +/// currently no mechanism for usage based sorting of entries and other launcher niceties. +/// +/// [desktop entry specification]: https://specifications.freedesktop.org/desktop-entry-spec/latest/ +class DesktopEntries: public QObject { + Q_OBJECT; + /// All desktop entries of type Application that are not Hidden or NoDisplay. + Q_PROPERTY(ObjectModel* applications READ applications CONSTANT); + QML_ELEMENT; + QML_SINGLETON; + +public: + explicit DesktopEntries(); + + /// Look up a desktop entry by name. Includes NoDisplay entries. May return null. + Q_INVOKABLE [[nodiscard]] static DesktopEntry* byId(const QString& id); + + [[nodiscard]] static ObjectModel* applications(); +}; diff --git a/src/core/module.md b/src/core/module.md index 1321861..315eb25 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -20,5 +20,6 @@ headers = [ "boundcomponent.hpp", "model.hpp", "elapsedtimer.hpp", + "desktopentry.hpp", ] -----