From 075265b7c8e4107d7cbba3a896c87e1729c4aae3 Mon Sep 17 00:00:00 2001 From: Sam Heybey Date: Sun, 6 Aug 2023 09:57:01 -0400 Subject: [PATCH] Add support for opening links in incognito mode on Linux & BSD (#4745) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 3 + src/CMakeLists.txt | 6 + src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 1 + src/util/IncognitoBrowser.cpp | 105 ++++++------ src/util/WindowsHelper.cpp | 8 +- src/util/WindowsHelper.hpp | 2 +- src/util/XDGDesktopFile.cpp | 118 +++++++++++++ src/util/XDGDesktopFile.hpp | 49 ++++++ src/util/XDGDirectory.cpp | 77 +++++++++ src/util/XDGDirectory.hpp | 21 +++ src/util/XDGHelper.cpp | 259 +++++++++++++++++++++++++++++ src/util/XDGHelper.hpp | 22 +++ tests/CMakeLists.txt | 8 + tests/resources/001-mimeapps.list | 32 ++++ tests/resources/test-resources.qrc | 5 + tests/src/XDGDesktopFile.cpp | 19 +++ tests/src/XDGHelper.cpp | 62 +++++++ 18 files changed, 739 insertions(+), 59 deletions(-) create mode 100644 src/util/XDGDesktopFile.cpp create mode 100644 src/util/XDGDesktopFile.hpp create mode 100644 src/util/XDGDirectory.cpp create mode 100644 src/util/XDGDirectory.hpp create mode 100644 src/util/XDGHelper.cpp create mode 100644 src/util/XDGHelper.hpp create mode 100644 tests/resources/001-mimeapps.list create mode 100644 tests/resources/test-resources.qrc create mode 100644 tests/src/XDGDesktopFile.cpp create mode 100644 tests/src/XDGHelper.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f68e3d6c1..7d01d2d3187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - Minor: All channels opened in browser tabs are synced when using the extension for quicker switching between tabs. (#4741) - Minor: Show channel point redemptions without messages in usercard. (#4557) - Minor: Allow for customizing the behavior of `Right Click`ing of usernames. (#4622, #4751) +- Minor: Added support for opening incognito links in firefox-esr and chromium. (#4745) +- Minor: Added support for opening incognito links under Linux/BSD using XDG. (#4745) - Bugfix: Increased amount of blocked users loaded from 100 to 1,000. (#4721) - Bugfix: Fixed generation of crashdumps by the browser-extension process when the browser was closed. (#4667) - Bugfix: Fix spacing issue with mentions inside RTL text. (#4677) @@ -55,6 +57,7 @@ - Dev: Added the ability to use an alternate linker using the `-DUSE_ALTERNATE_LINKER=...` CMake parameter. (#4711) - Dev: The Windows installer is now built in CI. (#4408) - Dev: Removed `getApp` and `getSettings` calls from message rendering. (#4535) +- Dev: Get the default browser executable instead of the entire command line when opening incognito links. (#4745) ## 2.4.4 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 59640127e00..87ce15d62ef 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -457,6 +457,12 @@ set(SOURCE_FILES util/TypeName.hpp util/WindowsHelper.cpp util/WindowsHelper.hpp + util/XDGDesktopFile.cpp + util/XDGDesktopFile.hpp + util/XDGDirectory.cpp + util/XDGDirectory.hpp + util/XDGHelper.cpp + util/XDGHelper.hpp util/serialize/Container.hpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 163debfd5b0..e05ce240dcb 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -55,3 +55,4 @@ Q_LOGGING_CATEGORY(chatterinoWebsocket, "chatterino.websocket", logThreshold); Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold); Q_LOGGING_CATEGORY(chatterinoWindowmanager, "chatterino.windowmanager", logThreshold); +Q_LOGGING_CATEGORY(chatterinoXDG, "chatterino.xdg", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 4531ce89e1f..bd6ba982273 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -42,3 +42,4 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget); Q_DECLARE_LOGGING_CATEGORY(chatterinoWindowmanager); +Q_DECLARE_LOGGING_CATEGORY(chatterinoXDG); diff --git a/src/util/IncognitoBrowser.cpp b/src/util/IncognitoBrowser.cpp index 074f07c1b9e..93ae2983bb4 100644 --- a/src/util/IncognitoBrowser.cpp +++ b/src/util/IncognitoBrowser.cpp @@ -1,88 +1,93 @@ #include "util/IncognitoBrowser.hpp" #ifdef USEWINSDK # include "util/WindowsHelper.hpp" +#elif defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) +# include "util/XDGHelper.hpp" #endif #include -#include #include namespace { using namespace chatterino; -#ifdef USEWINSDK -QString injectPrivateSwitch(QString command) +QString getPrivateSwitch(const QString &browserExecutable) { // list of command line switches to turn on private browsing in browsers static auto switches = std::vector>{ - {"firefox", "-private-window"}, {"librewolf", "-private-window"}, - {"waterfox", "-private-window"}, {"icecat", "-private-window"}, - {"chrome", "-incognito"}, {"vivaldi", "-incognito"}, - {"opera", "-newprivatetab"}, {"opera\\\\launcher", "--private"}, - {"iexplore", "-private"}, {"msedge", "-inprivate"}, + {"firefox", "-private-window"}, {"librewolf", "-private-window"}, + {"waterfox", "-private-window"}, {"icecat", "-private-window"}, + {"chrome", "-incognito"}, {"vivaldi", "-incognito"}, + {"opera", "-newprivatetab"}, {"opera\\launcher", "--private"}, + {"iexplore", "-private"}, {"msedge", "-inprivate"}, + {"firefox-esr", "-private-window"}, {"chromium", "-incognito"}, }; - // transform into regex and replacement string - std::vector> replacers; - for (const auto &switch_ : switches) + // compare case-insensitively + auto lowercasedBrowserExecutable = browserExecutable.toLower(); + +#ifdef Q_OS_WINDOWS + if (lowercasedBrowserExecutable.endsWith(".exe")) { - replacers.emplace_back( - QRegularExpression("(" + switch_.first + "\\.exe\"?).*", - QRegularExpression::CaseInsensitiveOption), - "\\1 " + switch_.second); + lowercasedBrowserExecutable.chop(4); } +#endif - // try to find matching regex and apply it - for (const auto &replacement : replacers) + for (const auto &switch_ : switches) { - if (replacement.first.match(command).hasMatch()) + if (lowercasedBrowserExecutable.endsWith(switch_.first)) { - command.replace(replacement.first, replacement.second); - return command; + return switch_.second; } } // couldn't match any browser -> unknown browser - return QString(); + return {}; } -QString getCommand() +QString getDefaultBrowserExecutable() { +#ifdef USEWINSDK // get default browser start command, by protocol if possible, falling back to extension if not QString command = - getAssociatedCommand(AssociationQueryType::Protocol, L"http"); + getAssociatedExecutable(AssociationQueryType::Protocol, L"http"); if (command.isNull()) { // failed to fetch default browser by protocol, try by file extension instead - command = - getAssociatedCommand(AssociationQueryType::FileExtension, L".html"); + command = getAssociatedExecutable(AssociationQueryType::FileExtension, + L".html"); } if (command.isNull()) { // also try the equivalent .htm extension - command = - getAssociatedCommand(AssociationQueryType::FileExtension, L".htm"); - } - - if (command.isNull()) - { - // failed to find browser command - return QString(); - } - - // inject switch to enable private browsing - command = injectPrivateSwitch(command); - if (command.isNull()) - { - return QString(); + command = getAssociatedExecutable(AssociationQueryType::FileExtension, + L".htm"); } return command; -} +#elif defined(Q_OS_UNIX) && !defined(Q_OS_DARWIN) + static QString defaultBrowser = []() -> QString { + auto desktopFile = getDefaultBrowserDesktopFile(); + if (desktopFile.has_value()) + { + auto entry = desktopFile->getEntries("Desktop Entry"); + auto exec = entry.find("Exec"); + if (exec != entry.end()) + { + return parseDesktopExecProgram(exec->second.trimmed()); + } + } + return {}; + }(); + + return defaultBrowser; +#else + return {}; #endif +} } // namespace @@ -90,23 +95,15 @@ namespace chatterino { bool supportsIncognitoLinks() { -#ifdef USEWINSDK - return !getCommand().isNull(); -#else - return false; -#endif + auto browserExe = getDefaultBrowserExecutable(); + return !browserExe.isNull() && !getPrivateSwitch(browserExe).isNull(); } bool openLinkIncognito(const QString &link) { -#ifdef USEWINSDK - auto command = getCommand(); - - // TODO: split command into program path and incognito argument - return QProcess::startDetached(command, {link}); -#else - return false; -#endif + auto browserExe = getDefaultBrowserExecutable(); + return QProcess::startDetached(browserExe, + {getPrivateSwitch(browserExe), link}); } } // namespace chatterino diff --git a/src/util/WindowsHelper.cpp b/src/util/WindowsHelper.cpp index d46d29158ad..73a4d591d7a 100644 --- a/src/util/WindowsHelper.cpp +++ b/src/util/WindowsHelper.cpp @@ -88,7 +88,7 @@ void setRegisteredForStartup(bool isRegistered) } } -QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) +QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query) { static HINSTANCE shlwapi = LoadLibrary(L"shlwapi"); if (shlwapi == nullptr) @@ -122,7 +122,7 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) } DWORD resultSize = 0; - if (FAILED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, + if (FAILED(assocQueryString(flags, ASSOCSTR_EXECUTABLE, query, nullptr, nullptr, &resultSize))) { return QString(); @@ -137,8 +137,8 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) QString result; auto buf = new wchar_t[resultSize]; - if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, buf, - &resultSize))) + if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_EXECUTABLE, query, nullptr, + buf, &resultSize))) { // QString::fromWCharArray expects the length in characters *not // including* the null terminator, but AssocQueryStringW calculates diff --git a/src/util/WindowsHelper.hpp b/src/util/WindowsHelper.hpp index 0cf2cbd2d06..ff569265fd7 100644 --- a/src/util/WindowsHelper.hpp +++ b/src/util/WindowsHelper.hpp @@ -16,7 +16,7 @@ void flushClipboard(); bool isRegisteredForStartup(); void setRegisteredForStartup(bool isRegistered); -QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query); +QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query); } // namespace chatterino diff --git a/src/util/XDGDesktopFile.cpp b/src/util/XDGDesktopFile.cpp new file mode 100644 index 00000000000..886a921dc45 --- /dev/null +++ b/src/util/XDGDesktopFile.cpp @@ -0,0 +1,118 @@ +#include "util/XDGDesktopFile.hpp" + +#include "util/XDGDirectory.hpp" + +#include +#include + +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +namespace chatterino { + +XDGDesktopFile::XDGDesktopFile(const QString &filename) +{ + QFile file(filename); + if (!file.open(QIODevice::ReadOnly)) + { + return; + } + this->valid = true; + + std::optional> entries; + + while (!file.atEnd()) + { + auto lineBytes = file.readLine().trimmed(); + + // Ignore comments & empty lines + if (lineBytes.startsWith('#') || lineBytes.size() == 0) + { + continue; + } + + auto line = QString::fromUtf8(lineBytes); + + if (line.startsWith('[')) + { + // group header + auto end = line.indexOf(']', 1); + if (end == -1 || end == 1) + { + // malformed header - either empty or no closing bracket + continue; + } + auto groupName = line.mid(1, end - 1); + + // it is against spec for the group name to already exist, but the + // parsing behavior for that case is not specified. operator[] will + // result in duplicate groups being merged, which makes the most + // sense for a read-only parser + entries = this->groups[groupName]; + + continue; + } + + // group entry + if (!entries.has_value()) + { + // no group header yet, entry before a group header is against spec + // and should be ignored + continue; + } + + auto delimiter = line.indexOf('='); + if (delimiter == -1) + { + // line is not a group header or a key value pair, ignore it + continue; + } + + auto key = QStringView(line).left(delimiter).trimmed().toString(); + // QStringView.mid() does not do bounds checking before qt 5.15, so + // we have to do it ourselves + auto valueStart = delimiter + 1; + QString value; + if (valueStart < line.size()) + { + value = QStringView(line).mid(valueStart).trimmed().toString(); + } + + // existing keys are against spec, so we can overwrite them with + // wild abandon + entries->get().emplace(key, value); + } +} + +XDGEntries XDGDesktopFile::getEntries(const QString &groupHeader) const +{ + auto group = this->groups.find(groupHeader); + if (group != this->groups.end()) + { + return group->second; + } + + return {}; +} + +std::optional XDGDesktopFile::findDesktopFile( + const QString &desktopFileID) +{ + for (const auto &dataDir : getXDGDirectories(XDGDirectoryType::Data)) + { + auto fileName = + QDir::cleanPath(dataDir + QDir::separator() + "applications" + + QDir::separator() + desktopFileID); + XDGDesktopFile desktopFile(fileName); + if (desktopFile.isValid()) + { + return desktopFile; + } + } + return {}; +} + +} // namespace chatterino + +#endif diff --git a/src/util/XDGDesktopFile.hpp b/src/util/XDGDesktopFile.hpp new file mode 100644 index 00000000000..d61705c804c --- /dev/null +++ b/src/util/XDGDesktopFile.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "util/QStringHash.hpp" + +#include +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +namespace chatterino { + +// See https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#group-header +using XDGEntries = std::unordered_map; + +class XDGDesktopFile +{ +public: + // Read the file at `filename` as an XDG desktop file, parsing its groups & their entries + // + // Use the `isValid` function to check if the file was read properly + explicit XDGDesktopFile(const QString &filename); + + /// Returns a map of entries for the given group header + XDGEntries getEntries(const QString &groupHeader) const; + + /// isValid returns true if the file exists and is readable + bool isValid() const + { + return valid; + } + + /// Find the first desktop file based on the given desktop file ID + /// + /// This will look through all Data XDG directories + /// + /// Can return std::nullopt if no desktop file was found for the given desktop file ID + /// + /// References: https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id + static std::optional findDesktopFile( + const QString &desktopFileID); + +private: + bool valid{}; + std::unordered_map groups; +}; + +} // namespace chatterino + +#endif diff --git a/src/util/XDGDirectory.cpp b/src/util/XDGDirectory.cpp new file mode 100644 index 00000000000..3bfef95b5ec --- /dev/null +++ b/src/util/XDGDirectory.cpp @@ -0,0 +1,77 @@ +#include "util/XDGDirectory.hpp" + +#include "util/CombinePath.hpp" +#include "util/Qt.hpp" + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +QStringList getXDGDirectories(XDGDirectoryType directory) +{ + // User XDG directory environment variables with defaults + // Defaults fetched from https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 2023-08-05 + static std::unordered_map> + userDirectories = { + { + XDGDirectoryType::Config, + { + "XDG_CONFIG_HOME", + combinePath(QDir::homePath(), ".config/"), + }, + }, + { + XDGDirectoryType::Data, + { + "XDG_DATA_HOME", + combinePath(QDir::homePath(), ".local/share/"), + }, + }, + }; + + // Base (or system) XDG directory environment variables with defaults + // Defaults fetched from https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 2023-08-05 + static std::unordered_map> + baseDirectories = { + { + XDGDirectoryType::Config, + { + "XDG_CONFIG_DIRS", + {"/etc/xdg"}, + }, + }, + { + XDGDirectoryType::Data, + { + "XDG_DATA_DIRS", + {"/usr/local/share/", "/usr/share/"}, + }, + }, + }; + + QStringList paths; + + const auto &[userEnvVar, userDefaultValue] = userDirectories.at(directory); + auto userEnvPath = qEnvironmentVariable(userEnvVar, userDefaultValue); + paths.push_back(userEnvPath); + + const auto &[baseEnvVar, baseDefaultValue] = baseDirectories.at(directory); + auto baseEnvPaths = + qEnvironmentVariable(baseEnvVar).split(':', Qt::SkipEmptyParts); + if (baseEnvPaths.isEmpty()) + { + paths.append(baseDefaultValue); + } + else + { + paths.append(baseEnvPaths); + } + + return paths; +} + +#endif + +} // namespace chatterino diff --git a/src/util/XDGDirectory.hpp b/src/util/XDGDirectory.hpp new file mode 100644 index 00000000000..9a18ea25f95 --- /dev/null +++ b/src/util/XDGDirectory.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +enum class XDGDirectoryType { + Config, + Data, +}; + +/// getXDGDirectories returns a list of directories given a directory type +/// +/// This will attempt to read the relevant environment variable (e.g. XDG_CONFIG_HOME and XDG_CONFIG_DIRS) and merge them, with sane defaults +QStringList getXDGDirectories(XDGDirectoryType directory); + +#endif + +} // namespace chatterino diff --git a/src/util/XDGHelper.cpp b/src/util/XDGHelper.cpp new file mode 100644 index 00000000000..588c4616648 --- /dev/null +++ b/src/util/XDGHelper.cpp @@ -0,0 +1,259 @@ +#include "util/XDGHelper.hpp" + +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "util/CombinePath.hpp" +#include "util/Qt.hpp" +#include "util/XDGDesktopFile.hpp" +#include "util/XDGDirectory.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +using namespace chatterino::literals; + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoXDG; + +using namespace chatterino; + +const auto HTTPS_MIMETYPE = u"x-scheme-handler/https"_s; + +/// Read the given mimeapps file and try to find an association for the HTTPS_MIMETYPE +/// +/// If the mimeapps file is invalid (i.e. wasn't read), return nullopt +/// If the file is valid, look for the default Desktop File ID handler for the HTTPS_MIMETYPE +/// If no default Desktop File ID handler is found, populate `associations` +/// and `denyList` with Desktop File IDs from "Added Associations" and "Removed Associations" respectively +std::optional processMimeAppsList( + const QString &mimeappsPath, QStringList &associations, + std::unordered_set &denyList) +{ + XDGDesktopFile mimeappsFile(mimeappsPath); + if (!mimeappsFile.isValid()) + { + return {}; + } + + // get the list of Desktop File IDs for the given mimetype under the "Default + // Applications" group in the mimeapps.list file + auto defaultGroup = mimeappsFile.getEntries("Default Applications"); + auto defaultApps = defaultGroup.find(HTTPS_MIMETYPE); + if (defaultApps != defaultGroup.cend()) + { + // for each desktop ID in the list: + auto desktopIds = defaultApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + auto desktopId = entry.trimmed(); + + // if a valid desktop file is found, verify that it is associated + // with the type. being in the default list gives it an implicit + // association, so just check that it's not in the denylist + if (!denyList.contains(desktopId)) + { + auto desktopFile = XDGDesktopFile::findDesktopFile(desktopId); + // if a valid association is found, we have found the default + // application + if (desktopFile.has_value()) + { + return desktopFile; + } + } + } + } + + // no definitive default application found. process added and removed + // associations, then return empty + + // load any removed associations into the denylist + auto removedGroup = mimeappsFile.getEntries("Removed Associations"); + auto removedApps = removedGroup.find(HTTPS_MIMETYPE); + if (removedApps != removedGroup.end()) + { + auto desktopIds = removedApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + denyList.insert(entry.trimmed()); + } + } + + // append any created associations to the associations list + auto addedGroup = mimeappsFile.getEntries("Added Associations"); + auto addedApps = addedGroup.find(HTTPS_MIMETYPE); + if (addedApps != addedGroup.end()) + { + auto desktopIds = addedApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + associations.push_back(entry.trimmed()); + } + } + + return {}; +} + +std::optional searchMimeAppsListsInDirectory( + const QString &directory, QStringList &associations, + std::unordered_set &denyList) +{ + static auto desktopNames = qEnvironmentVariable("XDG_CURRENT_DESKTOP") + .split(':', Qt::SkipEmptyParts); + static const QString desktopFilename = QStringLiteral("%1-mimeapps.list"); + static const QString nonDesktopFilename = QStringLiteral("mimeapps.list"); + + // try desktop specific mimeapps.list files first + for (const auto &desktopName : desktopNames) + { + auto fileName = + combinePath(directory, desktopFilename.arg(desktopName)); + auto defaultApp = processMimeAppsList(fileName, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // try the generic mimeapps.list + auto fileName = combinePath(directory, nonDesktopFilename); + auto defaultApp = processMimeAppsList(fileName, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + + // no definitive default application found + return {}; +} + +} // namespace + +namespace chatterino { + +/// Try to figure out the most reasonably default web browser to use +/// +/// If the `xdg-settings` program is available, use that +/// If not, read through all possible mimapps files in the order specified here: https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-1.0.1.html#file +/// If no mimeapps file has a default, try to use the Added Associations in those files +std::optional getDefaultBrowserDesktopFile() +{ + // no xdg-utils, find it manually by searching mimeapps.list files + QStringList associations; + std::unordered_set denyList; + + // config dirs first + for (const auto &configDir : getXDGDirectories(XDGDirectoryType::Config)) + { + auto defaultApp = + searchMimeAppsListsInDirectory(configDir, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // data dirs for backwards compatibility + for (const auto &dataDir : getXDGDirectories(XDGDirectoryType::Data)) + { + auto appsDir = combinePath(dataDir, "applications"); + auto defaultApp = + searchMimeAppsListsInDirectory(appsDir, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // no mimeapps.list has an explicit default, use the most preferred added + // association that exists. We could search here for one we support... + if (!associations.empty()) + { + for (const auto &desktopId : associations) + { + auto desktopFile = XDGDesktopFile::findDesktopFile(desktopId); + if (desktopFile.has_value()) + { + return desktopFile; + } + } + } + + // use xdg-settings if installed + QProcess xdgSettings; + xdgSettings.start("xdg-settings", {"get", "default-web-browser"}, + QIODevice::ReadOnly); + xdgSettings.waitForFinished(1000); + if (xdgSettings.exitStatus() == QProcess::ExitStatus::NormalExit && + xdgSettings.error() == QProcess::UnknownError && + xdgSettings.exitCode() == 0) + { + return XDGDesktopFile::findDesktopFile( + xdgSettings.readAllStandardOutput().trimmed()); + } + + return {}; +} + +QString parseDesktopExecProgram(const QString &execKey) +{ + static const QRegularExpression unescapeReservedCharacters( + R"(\\(["`$\\]))"); + + QString program = execKey; + + // string values in desktop files escape all backslashes. This is an + // independent escaping scheme that must be processed first + program.replace(u"\\\\"_s, u"\\"_s); + + if (!program.startsWith('"')) + { + // not quoted, trim after the first space (if any) + auto end = program.indexOf(' '); + if (end != -1) + { + program = program.left(end); + } + } + else + { + // quoted + auto endQuote = program.indexOf('"', 1); + if (endQuote == -1) + { + // No end quote found, the returned program might be malformed + program = program.mid(1); + qCWarning(LOG).noquote().nospace() + << "Malformed desktop entry key " << program << ", originally " + << execKey << ", you might run into issues"; + } + else + { + // End quote found + program = program.mid(1, endQuote - 1); + } + } + + // program now contains the first token of the command line. + // this is either the program name with an absolute path, or just the program name + // denoting it's a relative path. Either will be handled by QProcess cleanly + // now, there is a second escaping scheme specific to the + // exec key that must be applied. + program.replace(unescapeReservedCharacters, "\\1"); + + return program; +} + +} // namespace chatterino + +#endif diff --git a/src/util/XDGHelper.hpp b/src/util/XDGHelper.hpp new file mode 100644 index 00000000000..c862af936c7 --- /dev/null +++ b/src/util/XDGHelper.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "util/XDGDesktopFile.hpp" + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +std::optional getDefaultBrowserDesktopFile(); + +/// Parses the given `execKey` and returns the resulting program name, ignoring all arguments +/// +/// Parsing is done in accordance to https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html +/// +/// Note: We do *NOT* support field codes +QString parseDesktopExecProgram(const QString &execKey); + +#endif + +} // namespace chatterino diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0a684b92b0f..a760c6d8713 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,6 +4,7 @@ option(CHATTERINO_TEST_USE_PUBLIC_HTTPBIN "Use public httpbin for testing networ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp + ${CMAKE_CURRENT_LIST_DIR}/resources/test-resources.qrc ${CMAKE_CURRENT_LIST_DIR}/src/ChannelChatters.cpp ${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.cpp @@ -31,6 +32,8 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp ${CMAKE_CURRENT_LIST_DIR}/src/InputCompletion.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Literals.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/XDGDesktopFile.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/XDGHelper.cpp # Add your new file above this line! ) @@ -62,4 +65,9 @@ if(CHATTERINO_TEST_USE_PUBLIC_HTTPBIN) target_compile_definitions(${PROJECT_NAME} PRIVATE CHATTERINO_TEST_USE_PUBLIC_HTTPBIN) endif() +set_target_properties(${PROJECT_NAME} + PROPERTIES + AUTORCC ON + ) + gtest_discover_tests(${PROJECT_NAME}) diff --git a/tests/resources/001-mimeapps.list b/tests/resources/001-mimeapps.list new file mode 100644 index 00000000000..63cc19e3b68 --- /dev/null +++ b/tests/resources/001-mimeapps.list @@ -0,0 +1,32 @@ +thisshould=beignored +# this is a comment + +[test] +foo=bar +# cool comment 😂 +zoo=zar + +[Default Applications] +lol= +x-scheme-handler/http=firefox.desktop +x-scheme-handler/https=firefox.desktop +x-scheme-handler/chrome=firefox.desktop +text/html=firefox.desktop +application/x-extension-htm=firefox.desktop +application/x-extension-html=firefox.desktop +application/x-extension-shtml=firefox.desktop +application/xhtml+xml=firefox.desktop +application/x-extension-xhtml=firefox.desktop +application/x-extension-xht=firefox.desktop + +[Added Associations] +x-scheme-handler/http=firefox.desktop; +x-scheme-handler/https=firefox.desktop; +x-scheme-handler/chrome=firefox.desktop; +text/html=firefox.desktop; +application/x-extension-htm=firefox.desktop; +application/x-extension-html=firefox.desktop; +application/x-extension-shtml=firefox.desktop; +application/xhtml+xml=firefox.desktop; +application/x-extension-xhtml=firefox.desktop; +application/x-extension-xht=firefox.desktop; diff --git a/tests/resources/test-resources.qrc b/tests/resources/test-resources.qrc new file mode 100644 index 00000000000..defe634b3be --- /dev/null +++ b/tests/resources/test-resources.qrc @@ -0,0 +1,5 @@ + + + 001-mimeapps.list + + diff --git a/tests/src/XDGDesktopFile.cpp b/tests/src/XDGDesktopFile.cpp new file mode 100644 index 00000000000..171ac9f22e5 --- /dev/null +++ b/tests/src/XDGDesktopFile.cpp @@ -0,0 +1,19 @@ +#include "util/XDGDesktopFile.hpp" + +#include +#include + +using namespace chatterino; + +TEST(XDGDesktopFile, String) +{ + auto desktopFile = XDGDesktopFile(":/001-mimeapps.list"); + auto entries = desktopFile.getEntries("Default Applications"); + + ASSERT_EQ(entries["thisshould"], ""); + + ASSERT_EQ(entries["lol"], ""); + ASSERT_EQ(entries["x-scheme-handler/http"], QString("firefox.desktop")); + + ASSERT_EQ(desktopFile.getEntries("test").size(), 2); +} diff --git a/tests/src/XDGHelper.cpp b/tests/src/XDGHelper.cpp new file mode 100644 index 00000000000..e142697b6f1 --- /dev/null +++ b/tests/src/XDGHelper.cpp @@ -0,0 +1,62 @@ +#include "util/XDGHelper.hpp" + +#include +#include + +using namespace chatterino; + +TEST(XDGHelper, ParseDesktopExecProgram) +{ + struct TestCase { + QString input; + QString expectedOutput; + }; + + std::vector testCases{ + { + // Sanity check: Ensure simple Exec lines aren't made messed with + "firefox", + "firefox", + }, + { + // Simple trim after the first space + "/usr/lib/firefox/firefox %u", + "/usr/lib/firefox/firefox", + }, + { + // Simple unquote + "\"/usr/lib/firefox/firefox\"", + "/usr/lib/firefox/firefox", + }, + { + // Unquote + trim + "\"/usr/lib/firefox/firefox\" %u", + "/usr/lib/firefox/firefox", + }, + { + // Test malformed exec key (only one quote) + "\"/usr/lib/firefox/firefox", + "/usr/lib/firefox/firefox", + }, + { + // Quoted executable name with space + "\"/usr/bin/my cool browser\"", + "/usr/bin/my cool browser", + }, + { + // Executable name with reserved character + "/usr/bin/\\$hadowwizardmoneybrowser", + "/usr/bin/$hadowwizardmoneybrowser", + }, + }; + + for (const auto &test : testCases) + { + auto output = parseDesktopExecProgram(test.input); + + EXPECT_EQ(output, test.expectedOutput) + << "Input '" << test.input.toStdString() << "' failed. Expected '" + << test.expectedOutput.toStdString() << "' but got '" + << output.toStdString() << "'"; + } +}