diff --git a/CMakeLists.txt b/CMakeLists.txt index cdcacdf..d43610c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ else() endif() # ------------------------------------------- +add_subdirectory(applications) add_subdirectory(accounts) add_subdirectory(bluez) add_subdirectory(mpris) diff --git a/applications/CMakeLists.txt b/applications/CMakeLists.txt new file mode 100644 index 0000000..c4e1bc4 --- /dev/null +++ b/applications/CMakeLists.txt @@ -0,0 +1,21 @@ +set(CUTEFISH_APPLICATIONS_SRCS + applicationlauncher.cpp + applicationlauncher.h + applicationregistry.cpp + applicationregistry.h + desktopentry.cpp + desktopentry.h +) + +add_library(cutefishapplications STATIC ${CUTEFISH_APPLICATIONS_SRCS}) +set_target_properties(cutefishapplications PROPERTIES POSITION_INDEPENDENT_CODE ON) +set_target_properties(cutefishapplications PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) + +target_include_directories(cutefishapplications PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(cutefishapplications + PUBLIC + Qt6::Core + Qt6::DBus + Qt6::Gui + Qt6::Qml +) diff --git a/applications/applicationlauncher.cpp b/applications/applicationlauncher.cpp new file mode 100644 index 0000000..3865783 --- /dev/null +++ b/applications/applicationlauncher.cpp @@ -0,0 +1,26 @@ +#include "applicationlauncher.h" + +#include +#include +#include + +bool ApplicationLauncher::startDetached(const QStringList &command, + const QString &workingDirectory) +{ + if (command.isEmpty() || command.first().isEmpty()) + return false; + + const QString program = command.first(); + const QStringList arguments = command.mid(1); + + // Keep using the session launch service when it is available. It applies + // the desktop session's environment and startup handling consistently. + QDBusInterface session("com.cutefish.Session", "/Session", + "com.cutefish.Session", QDBusConnection::sessionBus()); + if (session.isValid()) { + session.asyncCall("launch", program, arguments); + return true; + } + + return QProcess::startDetached(program, arguments, workingDirectory); +} diff --git a/applications/applicationlauncher.h b/applications/applicationlauncher.h new file mode 100644 index 0000000..f2a2051 --- /dev/null +++ b/applications/applicationlauncher.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +class ApplicationLauncher +{ +public: + static bool startDetached(const QStringList &command, + const QString &workingDirectory = QString()); +}; diff --git a/applications/applicationregistry.cpp b/applications/applicationregistry.cpp new file mode 100644 index 0000000..2bdbf0e --- /dev/null +++ b/applications/applicationregistry.cpp @@ -0,0 +1,454 @@ +#include "applicationregistry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +class DesktopEntryScanner : public QRunnable +{ +public: + explicit DesktopEntryScanner(ApplicationRegistry *registry) + : m_registry(registry) + { + setAutoDelete(true); + } + + void run() override + { + QList results; + QSet selectedIds; + + for (const QString &rootPath : ApplicationRegistry::desktopPaths()) { + QDir root(rootPath); + if (!root.exists()) + continue; + + QStringList files; + QDirIterator iterator(rootPath, {QStringLiteral("*.desktop")}, + QDir::Files | QDir::Readable, + QDirIterator::Subdirectories); + while (iterator.hasNext()) + files.append(iterator.next()); + std::sort(files.begin(), files.end()); + + for (const QString &path : files) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + continue; + + QString id = root.relativeFilePath(path); + id.replace(QLatin1Char('/'), QLatin1Char('-')); + if (id.endsWith(QStringLiteral(".desktop"))) + id.chop(QStringLiteral(".desktop").size()); + + if (selectedIds.contains(id)) + continue; + + DesktopEntryData data; + if (DesktopEntry::parse(id, path, file.readAll(), &data)) { + selectedIds.insert(id); + results.append(std::move(data)); + } + } + } + + QPointer registry = m_registry; + QMetaObject::invokeMethod(m_registry, [registry, results]() { + if (registry) + registry->applyScan(results); + }, Qt::QueuedConnection); + } + +private: + ApplicationRegistry *m_registry; +}; + +QString normalized(const QString &value) +{ + return value.trimmed().toLower(); +} + +bool desktopMatches(const DesktopEntryData &entry, const QByteArray &desktop) +{ + const QStringList currentDesktops = QString::fromLocal8Bit(desktop) + .split(QLatin1Char(':'), Qt::SkipEmptyParts); + auto containsDesktop = [¤tDesktops](const QStringList &desktops) { + for (const QString &desktop : desktops) { + for (const QString ¤t : currentDesktops) { + if (desktop.compare(current, Qt::CaseInsensitive) == 0) + return true; + } + } + return false; + }; + + if (!entry.onlyShowIn.isEmpty() && !containsDesktop(entry.onlyShowIn)) + return false; + if (containsDesktop(entry.notShowIn)) + return false; + return true; +} + +} // namespace + +DesktopEntryModel::DesktopEntryModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int DesktopEntryModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_entries.size(); +} + +QVariant DesktopEntryModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) + return {}; + + DesktopEntry *entry = m_entries.at(index.row()); + switch (role) { + case IdRole: return entry->id(); + case NameRole: return entry->name(); + case GenericNameRole: return entry->genericName(); + case CommentRole: return entry->comment(); + case IconRole: return entry->icon(); + case PathRole: return entry->path(); + case ExecRole: return entry->exec(); + case CommandRole: return entry->command(); + case StartupWMClassRole: return entry->startupWMClass(); + case CategoriesRole: return entry->categories(); + case KeywordsRole: return entry->keywords(); + case MimeTypesRole: return entry->mimeTypes(); + case TerminalRole: return entry->terminal(); + case EntryRole: return QVariant::fromValue(entry); + default: return {}; + } +} + +QHash DesktopEntryModel::roleNames() const +{ + return { + {IdRole, "id"}, + {NameRole, "name"}, + {GenericNameRole, "genericName"}, + {CommentRole, "comment"}, + {IconRole, "icon"}, + {PathRole, "path"}, + {ExecRole, "exec"}, + {CommandRole, "command"}, + {StartupWMClassRole, "startupWMClass"}, + {CategoriesRole, "categories"}, + {KeywordsRole, "keywords"}, + {MimeTypesRole, "mimeTypes"}, + {TerminalRole, "terminal"}, + {EntryRole, "entry"} + }; +} + +void DesktopEntryModel::setEntries(const QList &entries) +{ + for (DesktopEntry *entry : std::as_const(m_entries)) + disconnect(entry, nullptr, this, nullptr); + + beginResetModel(); + m_entries = entries; + endResetModel(); + + for (DesktopEntry *entry : std::as_const(m_entries)) { + connect(entry, &DesktopEntry::changed, this, [this, entry]() { + const int row = m_entries.indexOf(entry); + if (row >= 0) + emit dataChanged(index(row), index(row)); + }); + } +} + +QList DesktopEntryModel::entries() const +{ + return m_entries; +} + +ApplicationRegistry *ApplicationRegistry::instance() +{ + static ApplicationRegistry *registry = new ApplicationRegistry; + return registry; +} + +ApplicationRegistry::ApplicationRegistry(QObject *parent) + : QObject(parent) + , m_model(new DesktopEntryModel(this)) + , m_watcher(new QFileSystemWatcher(this)) +{ + m_debounceTimer.setSingleShot(true); + m_debounceTimer.setInterval(100); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, + this, &ApplicationRegistry::scheduleScan); + connect(m_watcher, &QFileSystemWatcher::fileChanged, + this, &ApplicationRegistry::scheduleScan); + connect(&m_debounceTimer, &QTimer::timeout, + this, &ApplicationRegistry::startScan); + + monitorPaths(); + startScan(); +} + +QAbstractItemModel *ApplicationRegistry::applications() const +{ + return m_model; +} + +bool ApplicationRegistry::loading() const +{ + return m_loading; +} + +DesktopEntry *ApplicationRegistry::byId(const QString &id) const +{ + if (DesktopEntry *entry = m_entriesById.value(id)) + return entry; + + const QString lower = normalized(id); + for (auto it = m_entriesById.cbegin(); it != m_entriesById.cend(); ++it) { + if (normalized(it.key()) == lower) + return it.value(); + } + return nullptr; +} + +DesktopEntry *ApplicationRegistry::byPath(const QString &path) const +{ + if (path.isEmpty()) + return nullptr; + return m_entriesByPath.value(QFileInfo(path).absoluteFilePath()); +} + +DesktopEntry *ApplicationRegistry::resolveWindow(const QString &appId, + const QString &windowClass, + const QString &executablePath, + const QString &executableName) const +{ + const QString wantedAppId = normalized(appId); + const QString wantedClass = normalized(windowClass); + const QString wantedExecutable = normalized(executablePath); + const QString wantedName = normalized(executableName); + DesktopEntry *best = nullptr; + int bestScore = 0; + + for (DesktopEntry *entry : applicationEntries()) { + const QString id = normalized(entry->id()); + const QString startupClass = normalized(entry->startupWMClass()); + const QString program = normalized(entry->command().value(0)); + const QString programName = normalized(QFileInfo(entry->command().value(0)).fileName()); + const QString icon = normalized(entry->icon()); + const QString name = normalized(entry->name()); + const QString fileName = normalized(QFileInfo(entry->path()).completeBaseName()); + int score = 0; + + if (!wantedAppId.isEmpty() && id == wantedAppId) + score = qMax(score, 100); + if (!wantedClass.isEmpty() && id == wantedClass) + score = qMax(score, 90); + if (!wantedClass.isEmpty() && startupClass == wantedClass) + score = qMax(score, 95); + if (!wantedAppId.isEmpty() && startupClass == wantedAppId) + score = qMax(score, 95); + if (!wantedExecutable.isEmpty() && program == wantedExecutable) + score = qMax(score, 85); + if (!wantedName.isEmpty() && programName == wantedName) + score = qMax(score, 80); + if (!wantedExecutable.isEmpty() && fileName == wantedExecutable) + score = qMax(score, 75); + if (!wantedName.isEmpty() && fileName == wantedName) + score = qMax(score, 75); + if (!wantedExecutable.isEmpty() && icon == wantedExecutable) + score = qMax(score, 75); + if (!wantedName.isEmpty() && icon == wantedName) + score = qMax(score, 75); + if (!wantedAppId.isEmpty() && !startupClass.isEmpty() + && startupClass.startsWith(wantedAppId)) + score = qMax(score, 70); + if (!wantedClass.isEmpty() && !startupClass.isEmpty() + && startupClass.startsWith(wantedClass)) + score = qMax(score, 70); + if (!wantedClass.isEmpty() && id.startsWith(wantedClass)) + score = qMax(score, 60); + if (!wantedClass.isEmpty() && fileName.startsWith(wantedClass)) + score = qMax(score, 60); + if (!wantedClass.isEmpty() && program.startsWith(wantedClass)) + score = qMax(score, 55); + if (!wantedClass.isEmpty() && icon.startsWith(wantedClass)) + score = qMax(score, 55); + if (!wantedClass.isEmpty() && name.startsWith(wantedClass)) + score = qMax(score, 50); + if (!wantedExecutable.isEmpty() && !program.isEmpty() + && wantedExecutable.contains(program)) + score = qMax(score, 45); + + if (score > bestScore) { + best = entry; + bestScore = score; + } + } + + return best; +} + +QList ApplicationRegistry::entries() const +{ + return m_entries; +} + +QList ApplicationRegistry::applicationEntries() const +{ + return m_model->entries(); +} + +QStringList ApplicationRegistry::desktopPaths() +{ + QString dataHome = qEnvironmentVariable("XDG_DATA_HOME"); + if (dataHome.isEmpty()) + dataHome = QDir::homePath() + QStringLiteral("/.local/share"); + + QString dataDirs = qEnvironmentVariable("XDG_DATA_DIRS"); + if (dataDirs.isEmpty()) + dataDirs = QStringLiteral("/usr/local/share:/usr/share"); + + QStringList paths; + paths.append(QDir::cleanPath(dataHome + QStringLiteral("/applications"))); + for (const QString &dir : dataDirs.split(QLatin1Char(':'), Qt::SkipEmptyParts)) + paths.append(QDir::cleanPath(dir + QStringLiteral("/applications"))); + + paths.removeDuplicates(); + return paths; +} + +void ApplicationRegistry::scheduleScan() +{ + m_debounceTimer.start(); +} + +void ApplicationRegistry::startScan() +{ + if (m_scanInProgress) { + m_scanQueued = true; + return; + } + + m_scanInProgress = true; + if (!m_loading) { + m_loading = true; + emit loadingChanged(); + } + QThreadPool::globalInstance()->start(new DesktopEntryScanner(this)); +} + +void ApplicationRegistry::applyScan(const QList &results) +{ + const bool wasLoading = m_loading; + m_loading = false; + m_scanInProgress = false; + + QHash oldEntries = m_entriesById; + QHash newEntries; + QHash newEntriesByPath; + QList allEntries; + QList visibleEntries; + const QByteArray currentDesktop = qgetenv("XDG_CURRENT_DESKTOP"); + + for (const DesktopEntryData &data : results) { + DesktopEntry *entry = oldEntries.take(data.id); + if (!entry) + entry = new DesktopEntry(data.id, this); + entry->update(data); + + newEntries.insert(data.id, entry); + newEntriesByPath.insert(QFileInfo(data.path).absoluteFilePath(), entry); + allEntries.append(entry); + + if (!data.hidden && !data.noDisplay && desktopMatches(data, currentDesktop)) + visibleEntries.append(entry); + } + + std::sort(visibleEntries.begin(), visibleEntries.end(), [](DesktopEntry *a, DesktopEntry *b) { + const int nameCompare = QString::localeAwareCompare(a->name(), b->name()); + return nameCompare == 0 ? a->id() < b->id() : nameCompare < 0; + }); + + m_entriesById = newEntries; + m_entriesByPath = newEntriesByPath; + m_entries = allEntries; + m_model->setEntries(visibleEntries); + monitorPaths(); + + for (DesktopEntry *entry : std::as_const(oldEntries)) + entry->deleteLater(); + + if (wasLoading) + emit loadingChanged(); + emit applicationsChanged(); + + if (m_scanQueued) { + m_scanQueued = false; + startScan(); + } +} + +void ApplicationRegistry::monitorPaths() +{ + for (const QString &rootPath : desktopPaths()) { + QDir root(rootPath); + if (!root.exists()) { + QString parentPath = rootPath; + while (!QDir(parentPath).exists()) { + const QString parent = QFileInfo(parentPath).absolutePath(); + if (parent == parentPath) + break; + parentPath = parent; + } + if (QDir(parentPath).exists()) + m_watcher->addPath(parentPath); + continue; + } + + m_watcher->addPath(rootPath); + QDirIterator iterator(rootPath, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (iterator.hasNext()) + m_watcher->addPath(iterator.next()); + + QDirIterator files(rootPath, {QStringLiteral("*.desktop")}, + QDir::Files | QDir::Readable, + QDirIterator::Subdirectories); + while (files.hasNext()) + m_watcher->addPath(files.next()); + } +} + +void registerApplicationsQmlTypes() +{ + static bool registered = false; + if (registered) + return; + registered = true; + + qmlRegisterSingletonType( + "Cutefish.Applications", 1, 0, "DesktopEntries", + [](QQmlEngine *, QJSEngine *) -> QObject * { + return ApplicationRegistry::instance(); + }); + qmlRegisterUncreatableType( + "Cutefish.Applications", 1, 0, "DesktopEntry", + QStringLiteral("DesktopEntry objects are provided by DesktopEntries")); +} diff --git a/applications/applicationregistry.h b/applications/applicationregistry.h new file mode 100644 index 0000000..ba0c938 --- /dev/null +++ b/applications/applicationregistry.h @@ -0,0 +1,95 @@ +#pragma once + +#include "desktopentry.h" + +#include +#include +#include +#include +#include + +class DesktopEntryModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + IdRole = Qt::UserRole + 1, + NameRole, + GenericNameRole, + CommentRole, + IconRole, + PathRole, + ExecRole, + CommandRole, + StartupWMClassRole, + CategoriesRole, + KeywordsRole, + MimeTypesRole, + TerminalRole, + EntryRole + }; + Q_ENUM(Roles) + + explicit DesktopEntryModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void setEntries(const QList &entries); + QList entries() const; + +private: + QList m_entries; +}; + +class ApplicationRegistry : public QObject +{ + Q_OBJECT + Q_PROPERTY(QAbstractItemModel *applications READ applications CONSTANT) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + +public: + static ApplicationRegistry *instance(); + + explicit ApplicationRegistry(QObject *parent = nullptr); + + QAbstractItemModel *applications() const; + bool loading() const; + + Q_INVOKABLE DesktopEntry *byId(const QString &id) const; + Q_INVOKABLE DesktopEntry *byPath(const QString &path) const; + + DesktopEntry *resolveWindow(const QString &appId, + const QString &windowClass, + const QString &executablePath, + const QString &executableName) const; + QList applicationEntries() const; + QList entries() const; + static QStringList desktopPaths(); + +signals: + void applicationsChanged(); + void loadingChanged(); + +public slots: + void scheduleScan(); + void startScan(); + void applyScan(const QList &results); + +private: + void monitorPaths(); + + DesktopEntryModel *m_model; + QFileSystemWatcher *m_watcher; + QTimer m_debounceTimer; + QHash m_entriesById; + QHash m_entriesByPath; + QList m_entries; + bool m_loading = false; + bool m_scanInProgress = false; + bool m_scanQueued = false; +}; + +void registerApplicationsQmlTypes(); diff --git a/applications/desktopentry.cpp b/applications/desktopentry.cpp new file mode 100644 index 0000000..c83704d --- /dev/null +++ b/applications/desktopentry.cpp @@ -0,0 +1,364 @@ +#include "desktopentry.h" + +#include "applicationlauncher.h" + +#include +#include +#include +#include +#include + +namespace { + +QStringList splitList(const QString &value) +{ + QStringList result; + for (const QString &item : value.split(QLatin1Char(';'), Qt::SkipEmptyParts)) + result.append(item.trimmed()); + return result; +} + +QString unescapeValue(QString value) +{ + value.replace(QStringLiteral("\\n"), QStringLiteral("\n")); + value.replace(QStringLiteral("\\t"), QStringLiteral("\t")); + value.replace(QStringLiteral("\\r"), QStringLiteral("\r")); + value.replace(QStringLiteral("\\s"), QStringLiteral(" ")); + value.replace(QStringLiteral("\\;"), QStringLiteral(";")); + value.replace(QStringLiteral("\\\\"), QStringLiteral("\\")); + return value; +} + +bool parseBool(const QString &value) +{ + return value.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0; +} + +QString localizedValue(const QMap &values, + const QMap &localized, + const QString &key) +{ + const QLocale locale = QLocale::system(); + const QStringList candidates = { + locale.name(), + locale.bcp47Name(), + locale.name().section(QLatin1Char('_'), 0, 0) + }; + + for (const QString &candidate : candidates) { + if (candidate.isEmpty()) + continue; + const auto it = localized.constFind(key + QLatin1Char('[') + candidate + + QLatin1Char(']')); + if (it != localized.constEnd()) + return *it; + } + + return values.value(key); +} + +QStringList tokenizeExec(const QString &exec) +{ + QStringList result; + QString token; + bool quoted = false; + QChar quote; + + for (int i = 0; i < exec.size(); ++i) { + const QChar ch = exec.at(i); + + if (ch == QLatin1Char('\\') && i + 1 < exec.size()) { + token.append(exec.at(++i)); + continue; + } + + if ((ch == QLatin1Char('"') || ch == QLatin1Char('\''))) { + if (!quoted) { + quoted = true; + quote = ch; + } else if (quote == ch) { + quoted = false; + } else { + token.append(ch); + } + continue; + } + + if (!quoted && ch.isSpace()) { + if (!token.isEmpty()) { + result.append(token); + token.clear(); + } + continue; + } + + token.append(ch); + } + + if (!token.isEmpty()) + result.append(token); + + return result; +} + +QString substituteToken(const QString &token, const DesktopEntry *entry, + const QStringList &arguments) +{ + QString result; + + for (int i = 0; i < token.size(); ++i) { + if (token.at(i) != QLatin1Char('%') || i + 1 >= token.size()) { + result.append(token.at(i)); + continue; + } + + const QChar field = token.at(++i); + if (field == QLatin1Char('%')) { + result.append(QLatin1Char('%')); + } else if (field == QLatin1Char('c')) { + result.append(entry->name()); + } else if (field == QLatin1Char('k')) { + result.append(entry->path()); + } else if (field == QLatin1Char('i')) { + // %i is handled as two arguments by commandForArguments(). + } else if (field == QLatin1Char('f') || field == QLatin1Char('u')) { + if (!arguments.isEmpty()) + result.append(arguments.first()); + } else if (field == QLatin1Char('F') || field == QLatin1Char('U')) { + if (!arguments.isEmpty()) + result.append(arguments.first()); + } + } + + return result; +} + +} // namespace + +DesktopEntry::DesktopEntry(const QString &id, QObject *parent) + : QObject(parent) + , m_id(id) +{ +} + +QString DesktopEntry::id() const { return m_id; } +QString DesktopEntry::path() const { return m_path; } +QString DesktopEntry::name() const { return m_name; } +QString DesktopEntry::genericName() const { return m_genericName; } +QString DesktopEntry::comment() const { return m_comment; } +QString DesktopEntry::icon() const { return m_icon; } +QString DesktopEntry::exec() const { return m_exec; } +QStringList DesktopEntry::command() const { return m_command; } +QString DesktopEntry::workingDirectory() const { return m_workingDirectory; } +QString DesktopEntry::startupWMClass() const { return m_startupWMClass; } +QStringList DesktopEntry::categories() const { return m_categories; } +QStringList DesktopEntry::keywords() const { return m_keywords; } +QStringList DesktopEntry::mimeTypes() const { return m_mimeTypes; } +bool DesktopEntry::terminal() const { return m_terminal; } +bool DesktopEntry::noDisplay() const { return m_noDisplay; } +bool DesktopEntry::hidden() const { return m_hidden; } + +QStringList DesktopEntry::commandForArguments(const QStringList &arguments) const +{ + QStringList result; + const QStringList tokens = tokenizeExec(m_exec); + + for (const QString &token : tokens) { + if (token == QStringLiteral("%F") || token == QStringLiteral("%U")) { + result.append(arguments); + continue; + } + + if (token == QStringLiteral("%f") || token == QStringLiteral("%u")) { + if (!arguments.isEmpty()) + result.append(arguments.first()); + continue; + } + + if (token == QStringLiteral("%i")) { + if (!m_icon.isEmpty()) + result << QStringLiteral("--icon") << m_icon; + continue; + } + + const QString substituted = substituteToken(token, this, arguments); + if (!substituted.isEmpty()) + result.append(substituted); + } + + return result; +} + +bool DesktopEntry::launch(const QStringList &arguments) const +{ + return ApplicationLauncher::startDetached(commandForArguments(arguments), + m_workingDirectory); +} + +QStringList DesktopEntry::parseExec(const QString &exec) +{ + // This intentionally returns a command without field-code arguments. The + // complete expansion is available through commandForArguments(). + DesktopEntry entry(QStringLiteral("temporary")); + entry.m_exec = exec; + return entry.commandForArguments(QStringList()); +} + +bool DesktopEntry::parse(const QString &id, const QString &path, + const QByteArray &contents, DesktopEntryData *result) +{ + if (!result) + return false; + + DesktopEntryData data; + data.id = id; + data.path = path; + + QString group; + QMap values; + QMap localized; + QMap> actionValues; + + auto finishGroup = [&]() { + if (group == QStringLiteral("Desktop Entry")) { + data.type = values.value(QStringLiteral("Type")); + data.name = localizedValue(values, localized, QStringLiteral("Name")); + data.genericName = localizedValue(values, localized, QStringLiteral("GenericName")); + data.comment = localizedValue(values, localized, QStringLiteral("Comment")); + data.icon = values.value(QStringLiteral("Icon")); + data.exec = values.value(QStringLiteral("Exec")); + data.workingDirectory = values.value(QStringLiteral("Path")); + data.startupWMClass = values.value(QStringLiteral("StartupWMClass")); + data.categories = splitList(values.value(QStringLiteral("Categories"))); + data.keywords = splitList(values.value(QStringLiteral("Keywords"))); + data.mimeTypes = splitList(values.value(QStringLiteral("MimeType"))); + data.onlyShowIn = splitList(values.value(QStringLiteral("OnlyShowIn"))); + data.notShowIn = splitList(values.value(QStringLiteral("NotShowIn"))); + data.terminal = parseBool(values.value(QStringLiteral("Terminal"))); + data.noDisplay = parseBool(values.value(QStringLiteral("NoDisplay"))); + data.hidden = parseBool(values.value(QStringLiteral("Hidden"))); + } else if (group.startsWith(QStringLiteral("Desktop Action "))) { + const QString actionId = group.mid(QStringLiteral("Desktop Action ").size()); + const auto action = actionValues.value(actionId); + if (!action.isEmpty()) { + DesktopActionData item; + item.id = actionId; + item.name = action.value(QStringLiteral("Name")); + item.icon = action.value(QStringLiteral("Icon")); + item.exec = action.value(QStringLiteral("Exec")); + data.actions.append(item); + } + } + + values.clear(); + localized.clear(); + }; + + const QStringList lines = QString::fromUtf8(contents).split(QLatin1Char('\n')); + for (QString line : lines) { + line.remove(QLatin1Char('\r')); + line = line.trimmed(); + if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) + continue; + + if (line.startsWith(QLatin1Char('[')) && line.endsWith(QLatin1Char(']'))) { + finishGroup(); + group = line.mid(1, line.size() - 2); + continue; + } + + const int equals = line.indexOf(QLatin1Char('=')); + if (equals <= 0) + continue; + + QString key = line.left(equals).trimmed(); + const QString value = unescapeValue(line.mid(equals + 1).trimmed()); + if (group.startsWith(QStringLiteral("Desktop Action "))) { + actionValues[group.mid(QStringLiteral("Desktop Action ").size())][key] = value; + } else if (group == QStringLiteral("Desktop Entry")) { + const int localeStart = key.indexOf(QLatin1Char('[')); + if (localeStart > 0 && key.endsWith(QLatin1Char(']'))) { + localized.insert(key, value); + key = key.left(localeStart); + } else { + values.insert(key, value); + } + } + } + finishGroup(); + + if (data.type != QStringLiteral("Application") || data.name.isEmpty()) + return false; + + *result = data; + return true; +} + +void DesktopEntry::update(const DesktopEntryData &data) +{ + if (m_path != data.path) { + m_path = data.path; + emit pathChanged(); + } + if (m_name != data.name) { + m_name = data.name; + emit nameChanged(); + } + if (m_genericName != data.genericName) { + m_genericName = data.genericName; + emit genericNameChanged(); + } + if (m_comment != data.comment) { + m_comment = data.comment; + emit commentChanged(); + } + if (m_icon != data.icon) { + m_icon = data.icon; + emit iconChanged(); + } + const bool hasExecChanged = m_exec != data.exec; + if (hasExecChanged) { + m_exec = data.exec; + emit execChanged(); + } + if (m_workingDirectory != data.workingDirectory) { + m_workingDirectory = data.workingDirectory; + emit workingDirectoryChanged(); + } + if (m_startupWMClass != data.startupWMClass) { + m_startupWMClass = data.startupWMClass; + emit startupWMClassChanged(); + } + if (m_categories != data.categories) { + m_categories = data.categories; + emit categoriesChanged(); + } + if (m_keywords != data.keywords) { + m_keywords = data.keywords; + emit keywordsChanged(); + } + if (m_mimeTypes != data.mimeTypes) { + m_mimeTypes = data.mimeTypes; + emit mimeTypesChanged(); + } + if (m_terminal != data.terminal) { + m_terminal = data.terminal; + emit terminalChanged(); + } + if (m_noDisplay != data.noDisplay) { + m_noDisplay = data.noDisplay; + emit noDisplayChanged(); + } + if (m_hidden != data.hidden) { + m_hidden = data.hidden; + emit hiddenChanged(); + } + + const QStringList command = commandForArguments(QStringList()); + if (m_command != command) { + m_command = command; + emit commandChanged(); + } + + emit changed(); +} diff --git a/applications/desktopentry.h b/applications/desktopentry.h new file mode 100644 index 0000000..2389e9c --- /dev/null +++ b/applications/desktopentry.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include + +struct DesktopActionData +{ + QString id; + QString name; + QString icon; + QString exec; +}; + +struct DesktopEntryData +{ + QString id; + QString path; + QString type; + QString name; + QString genericName; + QString comment; + QString icon; + QString exec; + QString workingDirectory; + QString startupWMClass; + QStringList categories; + QStringList keywords; + QStringList mimeTypes; + QStringList onlyShowIn; + QStringList notShowIn; + QList actions; + bool terminal = false; + bool noDisplay = false; + bool hidden = false; +}; + +Q_DECLARE_METATYPE(DesktopEntryData) +Q_DECLARE_METATYPE(QList) + +class DesktopEntry : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString path READ path NOTIFY pathChanged) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString genericName READ genericName NOTIFY genericNameChanged) + Q_PROPERTY(QString comment READ comment NOTIFY commentChanged) + Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) + Q_PROPERTY(QString exec READ exec NOTIFY execChanged) + Q_PROPERTY(QStringList command READ command NOTIFY commandChanged) + Q_PROPERTY(QString workingDirectory READ workingDirectory NOTIFY workingDirectoryChanged) + Q_PROPERTY(QString startupWMClass READ startupWMClass NOTIFY startupWMClassChanged) + Q_PROPERTY(QStringList categories READ categories NOTIFY categoriesChanged) + Q_PROPERTY(QStringList keywords READ keywords NOTIFY keywordsChanged) + Q_PROPERTY(QStringList mimeTypes READ mimeTypes NOTIFY mimeTypesChanged) + Q_PROPERTY(bool terminal READ terminal NOTIFY terminalChanged) + Q_PROPERTY(bool noDisplay READ noDisplay NOTIFY noDisplayChanged) + Q_PROPERTY(bool hidden READ hidden NOTIFY hiddenChanged) + +public: + explicit DesktopEntry(const QString &id, QObject *parent = nullptr); + + QString id() const; + QString path() const; + QString name() const; + QString genericName() const; + QString comment() const; + QString icon() const; + QString exec() const; + QStringList command() const; + QString workingDirectory() const; + QString startupWMClass() const; + QStringList categories() const; + QStringList keywords() const; + QStringList mimeTypes() const; + bool terminal() const; + bool noDisplay() const; + bool hidden() const; + + Q_INVOKABLE bool launch(const QStringList &arguments = QStringList()) const; + + QStringList commandForArguments(const QStringList &arguments) const; + + static bool parse(const QString &id, const QString &path, + const QByteArray &contents, DesktopEntryData *result); + static QStringList parseExec(const QString &exec); + + void update(const DesktopEntryData &data); + +signals: + void pathChanged(); + void nameChanged(); + void genericNameChanged(); + void commentChanged(); + void iconChanged(); + void execChanged(); + void commandChanged(); + void workingDirectoryChanged(); + void startupWMClassChanged(); + void categoriesChanged(); + void keywordsChanged(); + void mimeTypesChanged(); + void terminalChanged(); + void noDisplayChanged(); + void hiddenChanged(); + void changed(); + +private: + QString m_id; + QString m_path; + QString m_name; + QString m_genericName; + QString m_comment; + QString m_icon; + QString m_exec; + QStringList m_command; + QString m_workingDirectory; + QString m_startupWMClass; + QStringList m_categories; + QStringList m_keywords; + QStringList m_mimeTypes; + bool m_terminal = false; + bool m_noDisplay = false; + bool m_hidden = false; +}; diff --git a/screen/qt5/screen.cpp b/screen/qt5/screen.cpp index 8db3f7a..c0b29dc 100644 --- a/screen/qt5/screen.cpp +++ b/screen/qt5/screen.cpp @@ -12,13 +12,30 @@ #include #include +static bool isDynamicDisplay(const KScreen::OutputPtr &output) +{ + if (!output) { + return false; + } + + const QString identity = QStringLiteral("%1 %2 %3") + .arg(output->name(), output->vendor(), output->model()); + return identity.contains(QStringLiteral("virtual"), Qt::CaseInsensitive) + || identity.contains(QStringLiteral("qemu"), Qt::CaseInsensitive) + || identity.contains(QStringLiteral("spice"), Qt::CaseInsensitive) + || identity.contains(QStringLiteral("virtio"), Qt::CaseInsensitive); +} + static QString displayOutputKey(const KScreen::OutputPtr &output) { if (!output) { return QString(); } - QString key = output->hashMd5(); + // Virtual outputs can change their EDID/mode list whenever the host + // resizes the guest window. Their hash is therefore not a stable + // identity; use the compositor's output name instead. + QString key = isDynamicDisplay(output) ? output->name() : output->hashMd5(); if (key.isEmpty()) { key = output->name(); }