Files
Nebula-OS/core/applications/DesktopEntry.cpp
T
andrew 2f59d3c115 Add Nebula.Windows with Sway IPC backend
Introduces a new `core/windows` module (`Nebula.Windows`) with `WindowService`, window/workspace models, and a Sway IPC backend for live window tracking and control (focus, close, move/resize, snap, maximize/restore, minimize, workspace switch). The desktop shell now links this module, uses compositor focus for top-bar active app text, and adds an Open Windows section in the launcher with focus/close actions.

Also updates application metadata handling to parse `StartupWMClass` and map window `app_id`/class to friendly names and icons, and revises Sway config/docs for the new floating-first Desktop 0.2 behavior with temporary compositor-provided decorations.
2026-08-26 18:20:56 +12:00

416 lines
12 KiB
C++

#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("StartupWMClass"))
entry.startupWmClass = 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;
}