Add desktop app service and VM dev session tooling
Introduces a new `core/applications` QML/C++ module (`Nebula.Applications`) to discover `.desktop` entries, expose filterable application models, and launch installed apps safely from the shell. The desktop launcher now uses real installed apps with themed icon loading and fallback glyphs, adds output-aware surface sizing via `OutputTracker`, and wires in image/icon providers. It also adds VMware-focused Sway/shell wrapper scripts, updates Sway config/session env defaults, and refreshes README docs to document the new development flow and Desktop 0.1 behavior.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
#include "ApplicationFilterModel.hpp"
|
||||
#include "ApplicationModel.hpp"
|
||||
|
||||
ApplicationFilterModel::ApplicationFilterModel(QObject *parent)
|
||||
: QSortFilterProxyModel(parent)
|
||||
{
|
||||
setDynamicSortFilter(true);
|
||||
setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||||
|
||||
connect(this, &QAbstractItemModel::modelReset, this, &ApplicationFilterModel::countChanged);
|
||||
connect(this, &QAbstractItemModel::rowsInserted, this, &ApplicationFilterModel::countChanged);
|
||||
connect(this, &QAbstractItemModel::rowsRemoved, this, &ApplicationFilterModel::countChanged);
|
||||
connect(this, &QAbstractItemModel::layoutChanged, this, &ApplicationFilterModel::countChanged);
|
||||
}
|
||||
|
||||
QString ApplicationFilterModel::filter() const
|
||||
{
|
||||
return m_filter;
|
||||
}
|
||||
|
||||
void ApplicationFilterModel::setFilter(const QString &filter)
|
||||
{
|
||||
const QString trimmed = filter.trimmed();
|
||||
if (m_filter == trimmed)
|
||||
return;
|
||||
|
||||
m_filter = trimmed;
|
||||
invalidateFilter();
|
||||
emit filterChanged();
|
||||
emit countChanged();
|
||||
}
|
||||
|
||||
int ApplicationFilterModel::count() const
|
||||
{
|
||||
return rowCount();
|
||||
}
|
||||
|
||||
bool ApplicationFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
|
||||
{
|
||||
if (m_filter.isEmpty())
|
||||
return true;
|
||||
|
||||
const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
|
||||
if (!index.isValid())
|
||||
return false;
|
||||
|
||||
const auto matches = [this, &index](int role) {
|
||||
return index.data(role).toString().contains(m_filter, Qt::CaseInsensitive);
|
||||
};
|
||||
|
||||
if (matches(ApplicationModel::NameRole)
|
||||
|| matches(ApplicationModel::GenericNameRole)
|
||||
|| matches(ApplicationModel::CommentRole)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const QStringList categories = index.data(ApplicationModel::CategoriesRole).toStringList();
|
||||
for (const QString &category : categories) {
|
||||
if (category.contains(m_filter, Qt::CaseInsensitive))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <QSortFilterProxyModel>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
class ApplicationFilterModel : public QSortFilterProxyModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Obtained from ApplicationService")
|
||||
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
explicit ApplicationFilterModel(QObject *parent = nullptr);
|
||||
|
||||
QString filter() const;
|
||||
void setFilter(const QString &filter);
|
||||
int count() const;
|
||||
|
||||
signals:
|
||||
void filterChanged();
|
||||
void countChanged();
|
||||
|
||||
protected:
|
||||
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
|
||||
|
||||
private:
|
||||
QString m_filter;
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "ApplicationModel.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
ApplicationModel::ApplicationModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
}
|
||||
|
||||
int ApplicationModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
return m_entries.size();
|
||||
}
|
||||
|
||||
int ApplicationModel::count() const
|
||||
{
|
||||
return m_entries.size();
|
||||
}
|
||||
|
||||
QVariant ApplicationModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size())
|
||||
return {};
|
||||
|
||||
const DesktopEntry &entry = m_entries.at(index.row());
|
||||
switch (role) {
|
||||
case DesktopIdRole:
|
||||
case Qt::UserRole:
|
||||
return entry.desktopId;
|
||||
case NameRole:
|
||||
case Qt::DisplayRole:
|
||||
return entry.name;
|
||||
case GenericNameRole:
|
||||
return entry.genericName;
|
||||
case CommentRole:
|
||||
return entry.comment;
|
||||
case IconNameRole:
|
||||
return entry.icon;
|
||||
case CategoriesRole:
|
||||
return entry.categories;
|
||||
case TerminalRole:
|
||||
return entry.terminal;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> ApplicationModel::roleNames() const
|
||||
{
|
||||
return {
|
||||
{DesktopIdRole, QByteArrayLiteral("desktopId")},
|
||||
{NameRole, QByteArrayLiteral("name")},
|
||||
{GenericNameRole, QByteArrayLiteral("genericName")},
|
||||
{CommentRole, QByteArrayLiteral("comment")},
|
||||
{IconNameRole, QByteArrayLiteral("iconName")},
|
||||
{CategoriesRole, QByteArrayLiteral("categories")},
|
||||
{TerminalRole, QByteArrayLiteral("terminal")},
|
||||
};
|
||||
}
|
||||
|
||||
void ApplicationModel::setEntries(QVector<DesktopEntry> entries)
|
||||
{
|
||||
std::sort(entries.begin(), entries.end(), [](const DesktopEntry &left, const DesktopEntry &right) {
|
||||
const int order = QString::localeAwareCompare(left.name, right.name);
|
||||
if (order != 0)
|
||||
return order < 0;
|
||||
return left.desktopId < right.desktopId;
|
||||
});
|
||||
|
||||
beginResetModel();
|
||||
m_entries = std::move(entries);
|
||||
endResetModel();
|
||||
emit countChanged();
|
||||
}
|
||||
|
||||
const DesktopEntry *ApplicationModel::entryAt(int row) const
|
||||
{
|
||||
if (row < 0 || row >= m_entries.size())
|
||||
return nullptr;
|
||||
return &m_entries.at(row);
|
||||
}
|
||||
|
||||
const DesktopEntry *ApplicationModel::entryById(const QString &desktopId) const
|
||||
{
|
||||
for (const DesktopEntry &entry : m_entries) {
|
||||
if (entry.desktopId == desktopId)
|
||||
return &entry;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include "DesktopEntry.hpp"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QVector>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
class ApplicationModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Obtained from ApplicationService")
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
DesktopIdRole = Qt::UserRole + 1,
|
||||
NameRole,
|
||||
GenericNameRole,
|
||||
CommentRole,
|
||||
IconNameRole,
|
||||
CategoriesRole,
|
||||
TerminalRole
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit ApplicationModel(QObject *parent = nullptr);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
int count() const;
|
||||
void setEntries(QVector<DesktopEntry> entries);
|
||||
const DesktopEntry *entryAt(int row) const;
|
||||
const DesktopEntry *entryById(const QString &desktopId) const;
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
QVector<DesktopEntry> m_entries;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
#include "ApplicationService.hpp"
|
||||
|
||||
#include "DesktopEntry.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFileInfo>
|
||||
#include <QSet>
|
||||
#include <QVector>
|
||||
#include <utility>
|
||||
|
||||
ApplicationService::ApplicationService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_model(new ApplicationModel(this))
|
||||
, m_filtered(new ApplicationFilterModel(this))
|
||||
{
|
||||
m_filtered->setSourceModel(m_model);
|
||||
m_filtered->setSortRole(ApplicationModel::NameRole);
|
||||
m_filtered->sort(0, Qt::AscendingOrder);
|
||||
|
||||
connect(m_model, &ApplicationModel::countChanged, this, &ApplicationService::countChanged);
|
||||
connect(m_filtered, &ApplicationFilterModel::filterChanged, this, &ApplicationService::filterChanged);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
ApplicationService *ApplicationService::create(QQmlEngine *engine, QJSEngine *)
|
||||
{
|
||||
auto *service = new ApplicationService(engine);
|
||||
QQmlEngine::setObjectOwnership(service, QQmlEngine::CppOwnership);
|
||||
return service;
|
||||
}
|
||||
|
||||
ApplicationModel *ApplicationService::model() const
|
||||
{
|
||||
return m_model;
|
||||
}
|
||||
|
||||
ApplicationFilterModel *ApplicationService::filteredModel() const
|
||||
{
|
||||
return m_filtered;
|
||||
}
|
||||
|
||||
QString ApplicationService::filter() const
|
||||
{
|
||||
return m_filtered->filter();
|
||||
}
|
||||
|
||||
void ApplicationService::setFilter(const QString &filter)
|
||||
{
|
||||
m_filtered->setFilter(filter);
|
||||
}
|
||||
|
||||
int ApplicationService::count() const
|
||||
{
|
||||
return m_model->count();
|
||||
}
|
||||
|
||||
bool ApplicationService::launch(const QString &desktopId) const
|
||||
{
|
||||
const DesktopEntry *entry = m_model->entryById(desktopId);
|
||||
if (!entry) {
|
||||
qWarning("nebula: unknown application %s", qPrintable(desktopId));
|
||||
return false;
|
||||
}
|
||||
return entry->launch();
|
||||
}
|
||||
|
||||
void ApplicationService::refresh()
|
||||
{
|
||||
QVector<DesktopEntry> entries;
|
||||
QSet<QString> seenIds;
|
||||
|
||||
const QStringList directories = applicationDirectories();
|
||||
qInfo("nebula: scanning %d application director%s",
|
||||
directories.size(),
|
||||
directories.size() == 1 ? "y" : "ies");
|
||||
|
||||
for (const QString &directory : directories) {
|
||||
QDir dir(directory);
|
||||
if (!dir.exists())
|
||||
continue;
|
||||
|
||||
QDirIterator iterator(directory, {QStringLiteral("*.desktop")}, QDir::Files, QDirIterator::Subdirectories);
|
||||
while (iterator.hasNext()) {
|
||||
const QString filePath = iterator.next();
|
||||
QString relative = QDir(directory).relativeFilePath(filePath);
|
||||
relative.replace(QLatin1Char('/'), QLatin1Char('-'));
|
||||
const QString desktopId = relative;
|
||||
|
||||
if (seenIds.contains(desktopId))
|
||||
continue;
|
||||
|
||||
seenIds.insert(desktopId);
|
||||
DesktopEntry entry = parseDesktopEntryFile(filePath, desktopId);
|
||||
if (entry.name.isEmpty())
|
||||
entry.name = QFileInfo(filePath).completeBaseName();
|
||||
if (!entry.isLauncherVisible())
|
||||
continue;
|
||||
|
||||
entries.append(std::move(entry));
|
||||
}
|
||||
}
|
||||
|
||||
qInfo("nebula: discovered %d launcher application%s",
|
||||
entries.size(),
|
||||
entries.size() == 1 ? "" : "s");
|
||||
m_model->setEntries(std::move(entries));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include "ApplicationFilterModel.hpp"
|
||||
#include "ApplicationModel.hpp"
|
||||
|
||||
#include <QJSEngine>
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
class ApplicationService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
Q_PROPERTY(ApplicationModel *model READ model CONSTANT)
|
||||
Q_PROPERTY(ApplicationFilterModel *filteredModel READ filteredModel CONSTANT)
|
||||
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
explicit ApplicationService(QObject *parent = nullptr);
|
||||
static ApplicationService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||
|
||||
ApplicationModel *model() const;
|
||||
ApplicationFilterModel *filteredModel() const;
|
||||
|
||||
QString filter() const;
|
||||
void setFilter(const QString &filter);
|
||||
int count() const;
|
||||
|
||||
Q_INVOKABLE bool launch(const QString &desktopId) const;
|
||||
Q_INVOKABLE void refresh();
|
||||
|
||||
signals:
|
||||
void filterChanged();
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
ApplicationModel *m_model = nullptr;
|
||||
ApplicationFilterModel *m_filtered = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
if(NOT TARGET Qt6::Qml)
|
||||
project(NebulaApplications LANGUAGES CXX)
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml)
|
||||
qt_standard_project_setup(REQUIRES 6.4)
|
||||
endif()
|
||||
|
||||
qt_add_library(NebulaApplications STATIC)
|
||||
|
||||
qt_add_qml_module(NebulaApplications
|
||||
URI Nebula.Applications
|
||||
VERSION 1.0
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
SOURCES
|
||||
DesktopEntry.hpp
|
||||
DesktopEntry.cpp
|
||||
ApplicationModel.hpp
|
||||
ApplicationModel.cpp
|
||||
ApplicationFilterModel.hpp
|
||||
ApplicationFilterModel.cpp
|
||||
ApplicationService.hpp
|
||||
ApplicationService.cpp
|
||||
)
|
||||
|
||||
target_include_directories(NebulaApplications
|
||||
PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
)
|
||||
|
||||
target_link_libraries(NebulaApplications
|
||||
PUBLIC
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Qml
|
||||
)
|
||||
|
||||
set_target_properties(NebulaApplications PROPERTIES
|
||||
AUTOMOC ON
|
||||
)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(NebulaApplications PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
@@ -0,0 +1,413 @@
|
||||
#include "DesktopEntry.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QLocale>
|
||||
#include <QMap>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
QString unquote(QString value)
|
||||
{
|
||||
value = value.trimmed();
|
||||
if (value.size() >= 2) {
|
||||
const QChar first = value.front();
|
||||
const QChar last = value.back();
|
||||
if ((first == QLatin1Char('"') && last == QLatin1Char('"'))
|
||||
|| (first == QLatin1Char('\'') && last == QLatin1Char('\''))) {
|
||||
value = value.mid(1, value.size() - 2);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool parseBool(const QString &value)
|
||||
{
|
||||
const QString lowered = value.trimmed().toLower();
|
||||
return lowered == QLatin1String("true") || lowered == QLatin1String("1");
|
||||
}
|
||||
|
||||
struct LocalizedValue
|
||||
{
|
||||
QString fallback;
|
||||
QMap<QString, QString> locales;
|
||||
};
|
||||
|
||||
void storeLocalized(LocalizedValue *target, const QString &key, const QString &prefix, const QString &value)
|
||||
{
|
||||
if (key == prefix) {
|
||||
target->fallback = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const QString start = prefix + QLatin1Char('[');
|
||||
if (!key.startsWith(start) || !key.endsWith(QLatin1Char(']')))
|
||||
return;
|
||||
|
||||
const QString locale = key.mid(start.size(), key.size() - start.size() - 1);
|
||||
if (!locale.isEmpty())
|
||||
target->locales.insert(locale, value);
|
||||
}
|
||||
|
||||
QString resolveLocalized(const LocalizedValue &value, const QLocale &locale)
|
||||
{
|
||||
const QString name = locale.name(); // e.g. en_US
|
||||
const QString language = name.section(QLatin1Char('_'), 0, 0);
|
||||
|
||||
auto match = [&value](const QString &tag) -> QString {
|
||||
if (tag.isEmpty())
|
||||
return {};
|
||||
return value.locales.value(tag);
|
||||
};
|
||||
|
||||
if (QString found = match(name); !found.isEmpty())
|
||||
return found;
|
||||
|
||||
if (QString found = match(language); !found.isEmpty())
|
||||
return found;
|
||||
|
||||
const int underscore = name.indexOf(QLatin1Char('_'));
|
||||
if (underscore > 0) {
|
||||
if (QString found = match(name.left(underscore)); !found.isEmpty())
|
||||
return found;
|
||||
}
|
||||
|
||||
return value.fallback;
|
||||
}
|
||||
|
||||
QString findTerminalEmulator()
|
||||
{
|
||||
const QStringList candidates = {
|
||||
QStringLiteral("foot"),
|
||||
QStringLiteral("x-terminal-emulator"),
|
||||
QStringLiteral("kitty"),
|
||||
QStringLiteral("alacritty"),
|
||||
QStringLiteral("kgx"),
|
||||
QStringLiteral("gnome-terminal"),
|
||||
QStringLiteral("konsole"),
|
||||
QStringLiteral("xterm"),
|
||||
};
|
||||
|
||||
for (const QString &name : candidates) {
|
||||
const QString path = QStandardPaths::findExecutable(name);
|
||||
if (!path.isEmpty())
|
||||
return path;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool executableAvailable(const QString &command)
|
||||
{
|
||||
if (command.isEmpty())
|
||||
return false;
|
||||
|
||||
const QFileInfo info(command);
|
||||
if (info.isAbsolute())
|
||||
return info.isExecutable();
|
||||
|
||||
return !QStandardPaths::findExecutable(command).isEmpty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList splitDesktopList(const QString &value)
|
||||
{
|
||||
const QStringList raw = value.split(QLatin1Char(';'), Qt::SkipEmptyParts);
|
||||
QStringList cleaned;
|
||||
cleaned.reserve(raw.size());
|
||||
for (const QString &item : raw) {
|
||||
const QString trimmed = item.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
cleaned.append(trimmed);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
QStringList currentDesktopIdentifiers()
|
||||
{
|
||||
const QByteArray raw = qgetenv("XDG_CURRENT_DESKTOP");
|
||||
if (raw.isEmpty())
|
||||
return {QStringLiteral("NebulaOS")};
|
||||
|
||||
QStringList ids;
|
||||
for (const QByteArray &part : raw.split(':')) {
|
||||
const QString id = QString::fromLocal8Bit(part).trimmed();
|
||||
if (!id.isEmpty())
|
||||
ids.append(id);
|
||||
}
|
||||
if (ids.isEmpty())
|
||||
ids.append(QStringLiteral("NebulaOS"));
|
||||
return ids;
|
||||
}
|
||||
|
||||
QStringList applicationDirectories()
|
||||
{
|
||||
QStringList dirs;
|
||||
|
||||
QString dataHome = QString::fromLocal8Bit(qgetenv("XDG_DATA_HOME"));
|
||||
if (dataHome.isEmpty())
|
||||
dataHome = QDir::homePath() + QStringLiteral("/.local/share");
|
||||
dirs.append(QDir::cleanPath(dataHome + QStringLiteral("/applications")));
|
||||
|
||||
QString dataDirs = QString::fromLocal8Bit(qgetenv("XDG_DATA_DIRS"));
|
||||
if (dataDirs.isEmpty())
|
||||
dataDirs = QStringLiteral("/usr/local/share:/usr/share");
|
||||
|
||||
for (const QString &root : dataDirs.split(QLatin1Char(':'), Qt::SkipEmptyParts)) {
|
||||
const QString trimmed = root.trimmed();
|
||||
if (trimmed.isEmpty())
|
||||
continue;
|
||||
dirs.append(QDir::cleanPath(trimmed + QStringLiteral("/applications")));
|
||||
}
|
||||
|
||||
dirs.removeDuplicates();
|
||||
return dirs;
|
||||
}
|
||||
|
||||
QString resolveIconName(const QString &icon)
|
||||
{
|
||||
if (icon.isEmpty())
|
||||
return {};
|
||||
|
||||
const QFileInfo info(icon);
|
||||
if (info.isAbsolute())
|
||||
return icon;
|
||||
|
||||
QString name = icon;
|
||||
if (name.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
|
||||
|| name.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)
|
||||
|| name.endsWith(QLatin1String(".xpm"), Qt::CaseInsensitive)) {
|
||||
name = QFileInfo(name).completeBaseName();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
QStringList expandExec(const QString &exec, const DesktopEntry &entry)
|
||||
{
|
||||
QString expanded;
|
||||
expanded.reserve(exec.size());
|
||||
|
||||
for (int i = 0; i < exec.size(); ++i) {
|
||||
const QChar ch = exec.at(i);
|
||||
if (ch != QLatin1Char('%') || i + 1 >= exec.size()) {
|
||||
expanded.append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
const QChar code = exec.at(++i);
|
||||
auto trimTrailingSpace = [&expanded]() {
|
||||
if (expanded.endsWith(QLatin1Char(' ')))
|
||||
expanded.chop(1);
|
||||
};
|
||||
|
||||
switch (code.toLatin1()) {
|
||||
case '%':
|
||||
expanded.append(QLatin1Char('%'));
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
case 'u':
|
||||
case 'U':
|
||||
case 'd':
|
||||
case 'D':
|
||||
case 'n':
|
||||
case 'N':
|
||||
case 'v':
|
||||
case 'm':
|
||||
// File/URL/deprecated codes are omitted when launching without arguments.
|
||||
trimTrailingSpace();
|
||||
break;
|
||||
case 'c':
|
||||
expanded.append(QLatin1Char('"'));
|
||||
expanded.append(entry.name);
|
||||
expanded.append(QLatin1Char('"'));
|
||||
break;
|
||||
case 'k':
|
||||
expanded.append(QLatin1Char('"'));
|
||||
expanded.append(entry.filePath);
|
||||
expanded.append(QLatin1Char('"'));
|
||||
break;
|
||||
case 'i':
|
||||
if (!entry.icon.isEmpty()) {
|
||||
expanded.append(QStringLiteral("--icon \""));
|
||||
expanded.append(entry.icon);
|
||||
expanded.append(QLatin1Char('"'));
|
||||
} else {
|
||||
trimTrailingSpace();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
trimTrailingSpace();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const QStringList parts = QProcess::splitCommand(expanded.trimmed());
|
||||
return parts;
|
||||
}
|
||||
|
||||
bool DesktopEntry::isLauncherVisible() const
|
||||
{
|
||||
if (hidden || noDisplay)
|
||||
return false;
|
||||
if (exec.trimmed().isEmpty())
|
||||
return false;
|
||||
if (!tryExec.trimmed().isEmpty() && !executableAvailable(tryExec.trimmed()))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DesktopEntry::launch() const
|
||||
{
|
||||
QStringList args = expandExec(exec, *this);
|
||||
if (args.isEmpty()) {
|
||||
qWarning("nebula: cannot launch %s: empty Exec", qPrintable(desktopId));
|
||||
return false;
|
||||
}
|
||||
|
||||
QString program = args.takeFirst();
|
||||
if (program.isEmpty()) {
|
||||
qWarning("nebula: cannot launch %s: missing program", qPrintable(desktopId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (terminal) {
|
||||
const QString term = findTerminalEmulator();
|
||||
if (term.isEmpty()) {
|
||||
qWarning("nebula: cannot launch %s: Terminal=true but no terminal emulator was found",
|
||||
qPrintable(desktopId));
|
||||
return false;
|
||||
}
|
||||
QStringList wrapped;
|
||||
wrapped.append(QStringLiteral("-e"));
|
||||
wrapped.append(program);
|
||||
wrapped.append(args);
|
||||
args = std::move(wrapped);
|
||||
program = term;
|
||||
}
|
||||
|
||||
const QString cwd = workingDirectory.isEmpty() ? QDir::homePath() : workingDirectory;
|
||||
qint64 pid = 0;
|
||||
if (!QProcess::startDetached(program, args, cwd, &pid)) {
|
||||
qWarning("nebula: failed to launch %s (%s)", qPrintable(desktopId), qPrintable(program));
|
||||
return false;
|
||||
}
|
||||
|
||||
qInfo("nebula: launched %s (%s) pid %lld", qPrintable(desktopId), qPrintable(program), static_cast<long long>(pid));
|
||||
return true;
|
||||
}
|
||||
|
||||
DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &desktopId)
|
||||
{
|
||||
DesktopEntry entry;
|
||||
entry.filePath = filePath;
|
||||
entry.desktopId = desktopId;
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return entry;
|
||||
|
||||
LocalizedValue names;
|
||||
LocalizedValue genericNames;
|
||||
LocalizedValue comments;
|
||||
QString type = QStringLiteral("Application");
|
||||
QStringList onlyShowIn;
|
||||
QStringList notShowIn;
|
||||
bool inDesktopEntry = false;
|
||||
|
||||
while (!file.atEnd()) {
|
||||
QString line = QString::fromUtf8(file.readLine());
|
||||
if (!line.isEmpty() && line.back() == QLatin1Char('\n'))
|
||||
line.chop(1);
|
||||
if (!line.isEmpty() && line.back() == QLatin1Char('\r'))
|
||||
line.chop(1);
|
||||
|
||||
const QString trimmed = line.trimmed();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#')))
|
||||
continue;
|
||||
|
||||
if (trimmed.startsWith(QLatin1Char('[')) && trimmed.endsWith(QLatin1Char(']'))) {
|
||||
inDesktopEntry = (trimmed == QLatin1String("[Desktop Entry]"));
|
||||
continue;
|
||||
}
|
||||
if (!inDesktopEntry)
|
||||
continue;
|
||||
|
||||
const int equals = trimmed.indexOf(QLatin1Char('='));
|
||||
if (equals <= 0)
|
||||
continue;
|
||||
|
||||
const QString key = trimmed.left(equals).trimmed();
|
||||
const QString value = unquote(trimmed.mid(equals + 1));
|
||||
|
||||
if (key == QLatin1String("Type"))
|
||||
type = value;
|
||||
else if (key == QLatin1String("Exec"))
|
||||
entry.exec = value;
|
||||
else if (key == QLatin1String("TryExec"))
|
||||
entry.tryExec = value;
|
||||
else if (key == QLatin1String("Icon"))
|
||||
entry.icon = value;
|
||||
else if (key == QLatin1String("Path"))
|
||||
entry.workingDirectory = value;
|
||||
else if (key == QLatin1String("Categories"))
|
||||
entry.categories = splitDesktopList(value);
|
||||
else if (key == QLatin1String("Terminal"))
|
||||
entry.terminal = parseBool(value);
|
||||
else if (key == QLatin1String("Hidden"))
|
||||
entry.hidden = parseBool(value);
|
||||
else if (key == QLatin1String("NoDisplay"))
|
||||
entry.noDisplay = parseBool(value);
|
||||
else if (key == QLatin1String("OnlyShowIn"))
|
||||
onlyShowIn = splitDesktopList(value);
|
||||
else if (key == QLatin1String("NotShowIn"))
|
||||
notShowIn = splitDesktopList(value);
|
||||
else {
|
||||
storeLocalized(&names, key, QStringLiteral("Name"), value);
|
||||
storeLocalized(&genericNames, key, QStringLiteral("GenericName"), value);
|
||||
storeLocalized(&comments, key, QStringLiteral("Comment"), value);
|
||||
}
|
||||
}
|
||||
|
||||
const QLocale locale = QLocale::system();
|
||||
entry.name = resolveLocalized(names, locale);
|
||||
entry.genericName = resolveLocalized(genericNames, locale);
|
||||
entry.comment = resolveLocalized(comments, locale);
|
||||
entry.icon = resolveIconName(entry.icon);
|
||||
|
||||
if (entry.desktopId.isEmpty())
|
||||
entry.desktopId = QFileInfo(filePath).fileName();
|
||||
|
||||
if (type != QLatin1String("Application")) {
|
||||
entry.hidden = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
const QStringList desktops = currentDesktopIdentifiers();
|
||||
if (!onlyShowIn.isEmpty()) {
|
||||
bool matched = false;
|
||||
for (const QString &desktop : desktops) {
|
||||
if (onlyShowIn.contains(desktop)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched)
|
||||
entry.noDisplay = true;
|
||||
}
|
||||
for (const QString &desktop : desktops) {
|
||||
if (notShowIn.contains(desktop)) {
|
||||
entry.noDisplay = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
struct DesktopEntry
|
||||
{
|
||||
QString desktopId;
|
||||
QString filePath;
|
||||
QString name;
|
||||
QString genericName;
|
||||
QString comment;
|
||||
QString icon;
|
||||
QString exec;
|
||||
QString tryExec;
|
||||
QString workingDirectory;
|
||||
QStringList categories;
|
||||
bool terminal = false;
|
||||
bool hidden = false;
|
||||
bool noDisplay = false;
|
||||
|
||||
bool isLauncherVisible() const;
|
||||
bool launch() const;
|
||||
};
|
||||
|
||||
DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &desktopId);
|
||||
QStringList applicationDirectories();
|
||||
QStringList currentDesktopIdentifiers();
|
||||
QStringList splitDesktopList(const QString &value);
|
||||
QStringList expandExec(const QString &exec, const DesktopEntry &entry);
|
||||
QString resolveIconName(const QString &icon);
|
||||
@@ -0,0 +1,18 @@
|
||||
# Nebula Applications
|
||||
|
||||
Shared C++ service for discovering and launching installed Linux applications from `.desktop` entries.
|
||||
|
||||
Desktop and the future Bigscreen shell should both use this library. It has no Desktop-specific QML.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Scans XDG application directories (`XDG_DATA_HOME`, `XDG_DATA_DIRS`, with the usual `~/.local/share` and `/usr/share` defaults).
|
||||
- Hides entries with `Hidden=true` or `NoDisplay=true`, and respects `OnlyShowIn` / `NotShowIn` / `TryExec`.
|
||||
- Exposes a `QAbstractListModel` to QML (`desktopId`, `name`, `genericName`, `comment`, `iconName`, `categories`, `terminal`).
|
||||
- Launches with `QProcess::startDetached` and `QProcess::splitCommand`. Does not use a shell.
|
||||
- Strips or substitutes common Exec field codes when launching without files/URLs.
|
||||
|
||||
QML module URI: `Nebula.Applications`.
|
||||
|
||||
See `shells/desktop/README.md` for launcher behaviour and session integration.
|
||||
|
||||
Reference in New Issue
Block a user