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:
2026-08-26 17:19:30 +12:00
parent a7d204f241
commit b0c95b9e78
31 changed files with 1673 additions and 72 deletions
@@ -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;
};
+93
View File
@@ -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;
}
+44
View File
@@ -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;
};
+109
View File
@@ -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));
}
+42
View File
@@ -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;
};
+44
View File
@@ -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()
+413
View File
@@ -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;
}
+31
View File
@@ -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);
+18
View File
@@ -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.