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.
416 lines
12 KiB
C++
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;
|
|
}
|