diff --git a/src/core/desktopentry.cpp b/src/core/desktopentry.cpp index 637f758f..22da225b 100644 --- a/src/core/desktopentry.cpp +++ b/src/core/desktopentry.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -123,13 +124,25 @@ ParsedDesktopEntryData DesktopEntry::parseText(const QString& id, const QString& else if (key == "StartupWMClass") data.startupClass = value; else if (key == "NoDisplay") data.noDisplay = value == "true"; else if (key == "Hidden") data.hidden = value == "true"; + else if (key == "OnlyShowIn") data.onlyShowIn = value.split(u';', Qt::SkipEmptyParts); + else if (key == "NotShowIn") data.notShowIn = value.split(u';', Qt::SkipEmptyParts); else if (key == "Comment") data.comment = value; else if (key == "Icon") data.icon = value; else if (key == "Exec") { data.execString = value; data.command = DesktopEntry::parseExecString(value); - } else if (key == "Path") data.workingDirectory = value; - else if (key == "Terminal") data.terminal = value == "true"; + } else if (key == "TryExec") data.tryExec = value; + else if (key == "Path") data.workingDirectory = value; + else if (key == "Terminal") data.runInTerminal = value == "true"; + else if (key == "X-TerminalArgExec" || key == "TerminalArgExec") + data.terminal.execArg = value; + else if (key == "X-TerminalArgAppId" || key == "TerminalArgAppId") + data.terminal.appIdArg = value; + else if (key == "X-TerminalArgTitle" || key == "TerminalArgTitle") + data.terminal.titleArg = value; + else if (key == "X-TerminalArgDir" || key == "TerminalArgDir") data.terminal.dirArg = value; + else if (key == "X-TerminalArgHold" || key == "TerminalArgHold") + data.terminal.holdArg = value; else if (key == "Categories") data.categories = value.split(u';', Qt::SkipEmptyParts); else if (key == "Keywords") data.keywords = value.split(u';', Qt::SkipEmptyParts); else if (key == "Actions") actionOrder = value.split(u';', Qt::SkipEmptyParts); @@ -213,16 +226,27 @@ void DesktopEntry::updateState(const ParsedDesktopEntryData& newState) { this->bGenericName = newState.genericName; this->bStartupClass = newState.startupClass; this->bNoDisplay = newState.noDisplay; + this->bOnlyShowIn = newState.onlyShowIn; + this->bNotShowIn = newState.notShowIn; this->bComment = newState.comment; this->bIcon = newState.icon; this->bExecString = newState.execString; this->bCommand = newState.command; this->bWorkingDirectory = newState.workingDirectory; - this->bRunInTerminal = newState.terminal; + this->bRunInTerminal = newState.runInTerminal; this->bCategories = newState.categories; this->bKeywords = newState.keywords; Qt::endPropertyUpdateGroup(); + this->tryExec = newState.tryExec; + this->terminal = { + .execArg = newState.terminal.execArg, + .appIdArg = newState.terminal.appIdArg, + .titleArg = newState.terminal.titleArg, + .dirArg = newState.terminal.dirArg, + .holdArg = newState.terminal.holdArg, + }; + this->state = newState; this->updateActions(newState.actions); } @@ -258,7 +282,15 @@ void DesktopEntry::updateActions(const QVector& newActions) { } void DesktopEntry::execute() const { - DesktopEntry::doExec(this->bCommand.value(), this->bWorkingDirectory.value()); + DesktopEntry::doExec( + this->bCommand.value(), + this->bWorkingDirectory.value(), + { + .enabled = this->bRunInTerminal.value(), + .appId = this->bStartupClass.value().isEmpty() ? this->mId : this->bStartupClass.value(), + .title = this->bName.value(), + } + ); } bool DesktopEntry::isValid() const { return !this->bName.value().isEmpty(); } @@ -335,15 +367,86 @@ QVector DesktopEntry::parseExecString(const QString& execString) { return arguments; } -void DesktopEntry::doExec(const QList& execString, const QString& workingDirectory) { +void DesktopEntry::doExec( + const QList& execString, + const QString& workingDirectory, + const DoExecTerminal& terminal +) { + auto command = execString; + + if (terminal.enabled) { + auto* manager = DesktopEntryManager::instance(); + auto found = false; + + for (const auto& term: manager->resolvedTerminals) { + if (!term.tryExec.isEmpty() && QStandardPaths::findExecutable(term.tryExec).isEmpty()) { + qCWarning(logDesktopEntry) << "Terminal" << term.command.first() << "TryExec" + << term.tryExec << "not found in PATH (skipping)"; + continue; + } + + if (term.command.isEmpty() || QStandardPaths::findExecutable(term.command.first()).isEmpty()) + { + qCWarning(logDesktopEntry) + << "Terminal executable" << (term.command.isEmpty() ? "(empty)" : term.command.first()) + << "not found in PATH (skipping)"; + continue; + } + + command = QList(); + command.append(term.command); + + auto appendTermArg = [&command](const QString& arg, const QString& value) { + if (arg.isEmpty() || value.isEmpty()) return; + if (arg.endsWith('=')) { + command.append(arg + value); + } else { + command.append(arg); + command.append(value); + } + }; + + appendTermArg(term.appIdArg, terminal.appId); + appendTermArg(term.titleArg, terminal.title); + appendTermArg(term.dirArg, workingDirectory); + + // Do not append the exec argumnet (the "-e" in ghostty -e bash) if it is empty. + // + // If we don't add a check & the exec argument doesn't exist, + // arguments would look like ["termemulator", "", "hx", "a.cpp", "a.hpp"], + // which is not desired. + if (!term.execArg.isEmpty()) command.append(term.execArg); + command.append( + execString + ); // This is a special(ly stupid) overload in QList. Think of it as Rust's `Vec::extend`. + + qCDebug(logDesktopEntry) << "Using terminal emulator:" << term.command.first(); + found = true; + break; + } + + if (!found) { + qCWarning(logDesktopEntry) << "No terminal emulator found; running without terminal."; + } + } + qs::io::process::ProcessContext ctx; - ctx.setCommand(execString); + ctx.setCommand(command); ctx.setWorkingDirectory(workingDirectory); QuickshellGlobal::execDetached(ctx); } void DesktopAction::execute() const { - DesktopEntry::doExec(this->bCommand.value(), this->entry->bWorkingDirectory.value()); + auto* e = this->entry; + DesktopEntry::doExec( + this->bCommand.value(), + e->bWorkingDirectory.value(), + { + .enabled = e->bRunInTerminal.value(), + .appId = e->bStartupClass.value().isEmpty() ? e->mId : e->bStartupClass.value(), + .title = e->bName.value(), + } + ); } DesktopEntryScanner::DesktopEntryScanner(DesktopEntryManager* manager): manager(manager) { @@ -514,6 +617,7 @@ void DesktopEntryManager::onScanCompleted(const QList& s auto oldEntries = this->desktopEntries; auto newEntries = QHash(); auto newLowercaseEntries = QHash(); + auto desktopNames = qEnvironmentVariable("XDG_CURRENT_DESKTOP").split(':', Qt::SkipEmptyParts); for (const auto& data: scanResults) { auto lowerId = data.id.toLower(); @@ -578,11 +682,225 @@ void DesktopEntryManager::onScanCompleted(const QList& s this->lowercaseDesktopEntries = newLowercaseEntries; auto newApplications = QVector(); - for (auto* entry: this->desktopEntries.values()) - if (!entry->bNoDisplay) newApplications.append(entry); + for (auto* entry: this->desktopEntries.values()) { + if (entry->bNoDisplay) continue; + + const auto& onlyShowIn = entry->bOnlyShowIn.value(); + const auto& notShowIn = entry->bNotShowIn.value(); + if (onlyShowIn.has_value() && notShowIn.has_value()) { + qCWarning(logDesktopEntry) << "Desktop entry" << entry->mId + << "defines both OnlyShowIn and NotShowIn (skipping display)"; + continue; + } + if (onlyShowIn.has_value() && !std::ranges::any_of(desktopNames, [&](const QString& name) { + return onlyShowIn->contains(name); + })) + continue; + if (notShowIn.has_value() && std::ranges::any_of(desktopNames, [&](const QString& name) { + return notShowIn->contains(name); + })) + continue; + + newApplications.append(entry); + } this->mApplications.diffUpdate(newApplications); + // Resolve terminal emulators via xdg-terminal-exec strict mode algorithm. + { + this->resolvedTerminals.clear(); + + struct ConfigEntry { + QString id; + QString action; + bool exclude = false; + bool protect = false; + }; + + auto configEntries = QVector(); + auto excludedIds = QSet(); + auto protectedIds = QSet(); + + // Collect config dirs in priority order. + auto configDirs = QStringList(); + { + auto configHome = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (configHome.isEmpty() && qEnvironmentVariableIsSet("HOME")) + configHome = qEnvironmentVariable("HOME") + "/.config"; + if (!configHome.isEmpty()) configDirs.append(configHome); + + auto configDirsStr = qEnvironmentVariable("XDG_CONFIG_DIRS"); + if (configDirsStr.isEmpty()) configDirsStr = "/etc/xdg"; + for (const auto& dir: configDirsStr.split(':', Qt::SkipEmptyParts)) configDirs.append(dir); + + auto dataDirs = qEnvironmentVariable("XDG_DATA_DIRS"); + if (dataDirs.isEmpty()) dataDirs = "/usr/local/share:/usr/share"; + for (const auto& dir: dataDirs.split(':', Qt::SkipEmptyParts)) + configDirs.append(dir + "/xdg-terminal-exec"); + } + + auto parseConfigFile = [&](const QString& path) { + auto file = QFile(path); + if (!file.open(QFile::ReadOnly | QFile::Text)) return; + + qCDebug(logDesktopEntry) << "Reading terminal config:" << path; + auto content = QString::fromUtf8(file.readAll()); + + for (const auto& line: content.split('\n', Qt::SkipEmptyParts)) { + auto trimmed = line.trimmed(); + if (trimmed.isEmpty() || trimmed.startsWith('#') || trimmed.startsWith('/')) continue; + + ConfigEntry entry; + auto value = trimmed; + + if (value.startsWith('-')) { + entry.exclude = true; + value = value.mid(1); + } else if (value.startsWith('+')) { + entry.protect = true; + value = value.mid(1); + } + + auto colonIdx = value.indexOf(':'); + if (colonIdx != -1) { + entry.action = value.mid(colonIdx + 1); + value = value.left(colonIdx); + } + + if (value.endsWith(".desktop")) value.chop(8); + + entry.id = value; + configEntries.append(entry); + + if (entry.exclude) excludedIds.insert(entry.id); + if (entry.protect) protectedIds.insert(entry.id); + } + }; + + for (const auto& dir: configDirs) { + for (const auto& name: desktopNames) + parseConfigFile(dir + "/" + name.toLower() + "-xdg-terminals.list"); + parseConfigFile(dir + "/xdg-terminals.list"); + } + + // Expand escape sequences in X-TerminalArg* values (\s \n \t \r \\). + auto expandEscapes = [](const QString& value) { + QString result; + result.reserve(value.size()); + auto escape = false; + + for (auto c: value) { + if (escape) { + switch (c.unicode()) { + case 's': result += u' '; break; + case 'n': result += u'\n'; break; + case 't': result += u'\t'; break; + case 'r': result += u'\r'; break; + case '\\': result += u'\\'; break; + default: + qCWarning(logDesktopEntry).noquote() + << "Illegal escape sequence in desktop entry terminal arg:" << value; + result += c; + break; + } + escape = false; + } else if (c == u'\\') { + escape = true; + } else { + result += c; + } + } + + return result; + }; + + auto termWarn = [](const QString& id, const char* reason) { + qCWarning(logDesktopEntry) << "Terminal" << id << reason << "(skipping)"; + }; + auto termDebug = [](const QString& id, const char* reason) { + qCDebug(logDesktopEntry) << "Terminal" << id << reason << "(skipping)"; + }; + + // Scan-time validation: structural checks only, no PATH lookups. + auto isValidTerminal = [](const DesktopEntry* entry, auto&& log) -> bool { + if (!entry->bCategories.value().contains("TerminalEmulator")) { + log(entry->mId, "missing TerminalEmulator category"); + return false; + } + if (entry->bCommand.value().isEmpty()) { + log(entry->mId, "has empty Exec command"); + return false; + } + if (!entry->terminal.execArg.has_value()) { + log(entry->mId, "missing [X-]TerminalArgExec"); + return false; + } + return true; + }; + + auto addResolved = + [this, &expandEscapes](const DesktopEntry* entry, const QVector& command) { + this->resolvedTerminals.append({ + .command = command, + .tryExec = entry->tryExec, + .execArg = expandEscapes(entry->terminal.execArg.value()), + .appIdArg = expandEscapes(entry->terminal.appIdArg), + .titleArg = expandEscapes(entry->terminal.titleArg), + .dirArg = expandEscapes(entry->terminal.dirArg), + .holdArg = expandEscapes(entry->terminal.holdArg), + }); + }; + + auto addedIds = QSet(); + + // Explicit phase: config entries in priority order. + for (const auto& configEntry: configEntries) { + if (configEntry.exclude || configEntry.protect) continue; + + auto* dentry = this->byId(configEntry.id); + if (!dentry) { + qCWarning(logDesktopEntry) + << "Terminal" << configEntry.id + << "not found in desktop entries (instructed from xdg-terminals.list)"; + continue; + } + if (!isValidTerminal(dentry, termWarn)) continue; + + auto command = dentry->bCommand.value(); + if (!configEntry.action.isEmpty()) { + auto actions = dentry->actions(); + auto action = std::ranges::find(actions, configEntry.action, &DesktopAction::mId); + if (action == actions.end()) { + qCWarning(logDesktopEntry) + << "Terminal entry" << configEntry.id << "no action named" << configEntry.action; + continue; + } + command = (*action)->bCommand.value(); + } + + addResolved(dentry, command); + addedIds.insert(dentry->mId); + qCDebug(logDesktopEntry) << "Terminal candidate (explicit):" << dentry->mId; + } + + // Fallback phase: remaining TerminalEmulator entries. + // XXX: Spec says fallback entry order is undefined, but we sort by ID + // for deterministic behavior. + auto fallbackEntries = this->desktopEntries.values(); + std::ranges::sort(fallbackEntries, {}, &DesktopEntry::mId); + for (auto* entry: fallbackEntries) { + if (addedIds.contains(entry->mId)) continue; + if (!isValidTerminal(entry, termDebug)) continue; + if (excludedIds.contains(entry->mId) && !protectedIds.contains(entry->mId)) continue; + + addResolved(entry, entry->bCommand.value()); + qCDebug(logDesktopEntry) << "Terminal candidate (fallback):" << entry->mId; + } + + qCDebug(logDesktopEntry) << "Resolved" << this->resolvedTerminals.size() + << "terminal candidates"; + } + emit this->applicationsChanged(); for (auto* e: oldEntries) e->deleteLater(); diff --git a/src/core/desktopentry.hpp b/src/core/desktopentry.hpp index 0d1eff28..eb4180a5 100644 --- a/src/core/desktopentry.hpp +++ b/src/core/desktopentry.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -33,13 +34,23 @@ struct ParsedDesktopEntryData { QString genericName; QString startupClass; bool noDisplay = false; + std::optional> onlyShowIn; + std::optional> notShowIn; bool hidden = false; QString comment; QString icon; QString execString; QVector command; QString workingDirectory; - bool terminal = false; + bool runInTerminal = false; + QString tryExec; + struct { + std::optional execArg; + QString appIdArg; + QString titleArg; + QString dirArg; + QString holdArg; + } terminal; QVector categories; QVector keywords; QHash entries; @@ -94,9 +105,15 @@ class DesktopEntry: public QObject { static ParsedDesktopEntryData parseText(const QString& id, const QString& text); void updateState(const ParsedDesktopEntryData& newState); - /// Run the application. Currently ignores @@runInTerminal and field codes. + /// Run the application. Currently ignores field codes (%f, %u, %F, %U, etc encoded in @@command). /// - /// This is equivalent to calling @@Quickshell.Quickshell.execDetached() with @@command + /// When @@runInTerminal is true, a suitable terminal emulator resolved via + /// the [xdg-terminal-exec] strict mode algorithm is spawned, with @@command, + /// ID (@@startupClass, or if it is not set, @@id), @@name and @@workingDirectory + /// passed in as arguments. + /// + /// When @@runInTerminal is false, this is equivalent to calling + /// @@Quickshell.Quickshell.execDetached() with @@command /// and @@DesktopEntry.workingDirectory as shown below: /// /// ```qml @@ -105,6 +122,8 @@ class DesktopEntry: public QObject { /// workingDirectory: desktopEntry.workingDirectory, /// }); /// ``` + /// + /// [xdg-terminal-exec]: https://github.com/Vladimir-csp/xdg-terminal-exec Q_INVOKABLE void execute() const; [[nodiscard]] bool isValid() const; @@ -127,9 +146,19 @@ class DesktopEntry: public QObject { } [[nodiscard]] QBindable> bindableKeywords() const { return &this->bKeywords; } - // currently ignores all field codes. + struct DoExecTerminal { + bool enabled; + QString appId; + QString title; + }; + + // currently ignores all field codes (%f, %u, %F, %U, etc). static QVector parseExecString(const QString& execString); - static void doExec(const QList& execString, const QString& workingDirectory); + static void doExec( + const QList& execString, + const QString& workingDirectory, + const DoExecTerminal& terminal = {} + ); signals: void nameChanged(); @@ -144,6 +173,8 @@ class DesktopEntry: public QObject { void runInTerminalChanged(); void categoriesChanged(); void keywordsChanged(); + void onlyShowInChanged(); + void notShowInChanged(); public: QString mId; @@ -153,6 +184,8 @@ class DesktopEntry: public QObject { Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bGenericName, &DesktopEntry::genericNameChanged); Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bStartupClass, &DesktopEntry::startupClassChanged); Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, bool, bNoDisplay, &DesktopEntry::noDisplayChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, std::optional>, bOnlyShowIn, &DesktopEntry::onlyShowInChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, std::optional>, bNotShowIn, &DesktopEntry::notShowInChanged); Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bComment, &DesktopEntry::commentChanged); Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bIcon, &DesktopEntry::iconChanged); Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bExecString, &DesktopEntry::execStringChanged); @@ -163,6 +196,16 @@ class DesktopEntry: public QObject { Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QVector, bKeywords, &DesktopEntry::keywordsChanged); // clang-format on + // TODO: Expose as bindable. + QString tryExec; + struct { + std::optional execArg; + QString appIdArg; + QString titleArg; + QString dirArg; + QString holdArg; + } terminal; + private: void updateActions(const QVector& newActions); @@ -202,10 +245,7 @@ class DesktopAction: public QObject { , entry(entry) , mId(std::move(id)) {} - /// Run the application. Currently ignores @@DesktopEntry.runInTerminal and field codes. - /// - /// This is equivalent to calling @@Quickshell.Quickshell.execDetached() with @@command - /// and @@DesktopEntry.workingDirectory. + /// Run the action. See @@DesktopEntry.execute() for details on terminal handling. Q_INVOKABLE void execute() const; [[nodiscard]] QBindable bindableName() const { return &this->bName; } @@ -232,6 +272,7 @@ class DesktopAction: public QObject { // clang-format on friend class DesktopEntry; + friend class DesktopEntryManager; }; class DesktopEntryManager; @@ -274,13 +315,25 @@ private slots: private: explicit DesktopEntryManager(); + struct ResolvedTerminal { + QVector command; + QString tryExec; + QString execArg; + QString appIdArg; + QString titleArg; + QString dirArg; + QString holdArg; + }; + QHash desktopEntries; QHash lowercaseDesktopEntries; ObjectModel mApplications {this}; + QVector resolvedTerminals; DesktopEntryMonitor* monitor = nullptr; bool scanInProgress = false; bool scanQueued = false; + friend class DesktopEntry; friend class DesktopEntryScanner; }; diff --git a/src/launch/launch.cpp b/src/launch/launch.cpp index 3a9a2a57..f0a5ef93 100644 --- a/src/launch/launch.cpp +++ b/src/launch/launch.cpp @@ -196,13 +196,10 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio // This seems to be controlled by the QPA and qt6ct does not provide it. { QList dataPaths; - - if (qEnvironmentVariableIsSet("XDG_DATA_DIRS")) { - auto var = qEnvironmentVariable("XDG_DATA_DIRS"); - dataPaths = var.split(u':', Qt::SkipEmptyParts); - } else { - dataPaths.push_back("/usr/local/share"); - dataPaths.push_back("/usr/share"); + { + auto dataDirs = qEnvironmentVariable("XDG_DATA_DIRS"); + if (dataDirs.isEmpty()) dataDirs = "/usr/local/share:/usr/share"; + dataPaths = dataDirs.split(u':', Qt::SkipEmptyParts); } auto fallbackPaths = QIcon::fallbackSearchPaths();