Add support for opening links in incognito mode on Linux & BSD (#4745)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Sam Heybey 2023-08-06 09:57:01 -04:00 committed by GitHub
parent 168f346c81
commit 69c983e0d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 741 additions and 61 deletions

View file

@ -23,6 +23,8 @@
- Minor: All channels opened in browser tabs are synced when using the extension for quicker switching between tabs. (#4741) - 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: Show channel point redemptions without messages in usercard. (#4557)
- Minor: Allow for customizing the behavior of `Right Click`ing of usernames. (#4622, #4751) - 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: 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: 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) - 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: 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: The Windows installer is now built in CI. (#4408)
- Dev: Removed `getApp` and `getSettings` calls from message rendering. (#4535) - 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 ## 2.4.4

View file

@ -438,6 +438,12 @@ set(SOURCE_FILES
util/TypeName.hpp util/TypeName.hpp
util/WindowsHelper.cpp util/WindowsHelper.cpp
util/WindowsHelper.hpp 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 util/serialize/Container.hpp

View file

@ -55,3 +55,4 @@ Q_LOGGING_CATEGORY(chatterinoWebsocket, "chatterino.websocket", logThreshold);
Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold); Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold);
Q_LOGGING_CATEGORY(chatterinoWindowmanager, "chatterino.windowmanager", Q_LOGGING_CATEGORY(chatterinoWindowmanager, "chatterino.windowmanager",
logThreshold); logThreshold);
Q_LOGGING_CATEGORY(chatterinoXDG, "chatterino.xdg", logThreshold);

View file

@ -42,3 +42,4 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate);
Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket);
Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget); Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget);
Q_DECLARE_LOGGING_CATEGORY(chatterinoWindowmanager); Q_DECLARE_LOGGING_CATEGORY(chatterinoWindowmanager);
Q_DECLARE_LOGGING_CATEGORY(chatterinoXDG);

View file

@ -1,88 +1,93 @@
#include "util/IncognitoBrowser.hpp" #include "util/IncognitoBrowser.hpp"
#ifdef USEWINSDK #ifdef USEWINSDK
# include "util/WindowsHelper.hpp" # include "util/WindowsHelper.hpp"
#elif defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN)
# include "util/XDGHelper.hpp"
#endif #endif
#include <QProcess> #include <QProcess>
#include <QRegularExpression>
#include <QVariant> #include <QVariant>
namespace { namespace {
using namespace chatterino; using namespace chatterino;
#ifdef USEWINSDK QString getPrivateSwitch(const QString &browserExecutable)
QString injectPrivateSwitch(QString command)
{ {
// list of command line switches to turn on private browsing in browsers // list of command line switches to turn on private browsing in browsers
static auto switches = std::vector<std::pair<QString, QString>>{ static auto switches = std::vector<std::pair<QString, QString>>{
{"firefox", "-private-window"}, {"librewolf", "-private-window"}, {"firefox", "-private-window"}, {"librewolf", "-private-window"},
{"waterfox", "-private-window"}, {"icecat", "-private-window"}, {"waterfox", "-private-window"}, {"icecat", "-private-window"},
{"chrome", "-incognito"}, {"vivaldi", "-incognito"}, {"chrome", "-incognito"}, {"vivaldi", "-incognito"},
{"opera", "-newprivatetab"}, {"opera\\\\launcher", "--private"}, {"opera", "-newprivatetab"}, {"opera\\launcher", "--private"},
{"iexplore", "-private"}, {"msedge", "-inprivate"}, {"iexplore", "-private"}, {"msedge", "-inprivate"},
{"firefox-esr", "-private-window"}, {"chromium", "-incognito"},
}; };
// transform into regex and replacement string // compare case-insensitively
std::vector<std::pair<QRegularExpression, QString>> replacers; auto lowercasedBrowserExecutable = browserExecutable.toLower();
#ifdef Q_OS_WINDOWS
if (lowercasedBrowserExecutable.endsWith(".exe"))
{
lowercasedBrowserExecutable.chop(4);
}
#endif
for (const auto &switch_ : switches) for (const auto &switch_ : switches)
{ {
replacers.emplace_back( if (lowercasedBrowserExecutable.endsWith(switch_.first))
QRegularExpression("(" + switch_.first + "\\.exe\"?).*",
QRegularExpression::CaseInsensitiveOption),
"\\1 " + switch_.second);
}
// try to find matching regex and apply it
for (const auto &replacement : replacers)
{
if (replacement.first.match(command).hasMatch())
{ {
command.replace(replacement.first, replacement.second); return switch_.second;
return command;
} }
} }
// couldn't match any browser -> unknown browser // 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 // get default browser start command, by protocol if possible, falling back to extension if not
QString command = QString command =
getAssociatedCommand(AssociationQueryType::Protocol, L"http"); getAssociatedExecutable(AssociationQueryType::Protocol, L"http");
if (command.isNull()) if (command.isNull())
{ {
// failed to fetch default browser by protocol, try by file extension instead // failed to fetch default browser by protocol, try by file extension instead
command = command = getAssociatedExecutable(AssociationQueryType::FileExtension,
getAssociatedCommand(AssociationQueryType::FileExtension, L".html"); L".html");
} }
if (command.isNull()) if (command.isNull())
{ {
// also try the equivalent .htm extension // also try the equivalent .htm extension
command = command = getAssociatedExecutable(AssociationQueryType::FileExtension,
getAssociatedCommand(AssociationQueryType::FileExtension, L".htm"); 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();
} }
return command; 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 #endif
}
} // namespace } // namespace
@ -90,23 +95,15 @@ namespace chatterino {
bool supportsIncognitoLinks() bool supportsIncognitoLinks()
{ {
#ifdef USEWINSDK auto browserExe = getDefaultBrowserExecutable();
return !getCommand().isNull(); return !browserExe.isNull() && !getPrivateSwitch(browserExe).isNull();
#else
return false;
#endif
} }
bool openLinkIncognito(const QString &link) bool openLinkIncognito(const QString &link)
{ {
#ifdef USEWINSDK auto browserExe = getDefaultBrowserExecutable();
auto command = getCommand(); return QProcess::startDetached(browserExe,
{getPrivateSwitch(browserExe), link});
// TODO: split command into program path and incognito argument
return QProcess::startDetached(command, {link});
#else
return false;
#endif
} }
} // namespace chatterino } // namespace chatterino

View file

@ -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"); static HINSTANCE shlwapi = LoadLibrary(L"shlwapi");
if (shlwapi == nullptr) if (shlwapi == nullptr)
@ -122,7 +122,7 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query)
} }
DWORD resultSize = 0; DWORD resultSize = 0;
if (FAILED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, if (FAILED(assocQueryString(flags, ASSOCSTR_EXECUTABLE, query, nullptr,
nullptr, &resultSize))) nullptr, &resultSize)))
{ {
return QString(); return QString();
@ -137,8 +137,8 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query)
QString result; QString result;
auto buf = new wchar_t[resultSize]; auto buf = new wchar_t[resultSize];
if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, buf, if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_EXECUTABLE, query, nullptr,
&resultSize))) buf, &resultSize)))
{ {
// QString::fromWCharArray expects the length in characters *not // QString::fromWCharArray expects the length in characters *not
// including* the null terminator, but AssocQueryStringW calculates // including* the null terminator, but AssocQueryStringW calculates

View file

@ -16,7 +16,7 @@ void flushClipboard();
bool isRegisteredForStartup(); bool isRegisteredForStartup();
void setRegisteredForStartup(bool isRegistered); void setRegisteredForStartup(bool isRegistered);
QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query); QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query);
} // namespace chatterino } // namespace chatterino

118
src/util/XDGDesktopFile.cpp Normal file
View file

@ -0,0 +1,118 @@
#include "util/XDGDesktopFile.hpp"
#include "util/XDGDirectory.hpp"
#include <QDir>
#include <QFile>
#include <functional>
#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<std::reference_wrapper<XDGEntries>> 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> 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

View file

@ -0,0 +1,49 @@
#pragma once
#include "util/QStringHash.hpp"
#include <optional>
#include <unordered_map>
#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<QString, QString>;
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<XDGDesktopFile> findDesktopFile(
const QString &desktopFileID);
private:
bool valid{};
std::unordered_map<QString, XDGEntries> groups;
};
} // namespace chatterino
#endif

77
src/util/XDGDirectory.cpp Normal file
View file

@ -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<XDGDirectoryType,
std::pair<const char *, QString>>
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<XDGDirectoryType,
std::pair<const char *, QStringList>>
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

21
src/util/XDGDirectory.hpp Normal file
View file

@ -0,0 +1,21 @@
#pragma once
#include <QStringList>
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

259
src/util/XDGHelper.cpp Normal file
View file

@ -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 <QDebug>
#include <QProcess>
#include <QRegularExpression>
#include <QSettings>
#include <QStringLiteral>
#include <QTextCodec>
#include <QtGlobal>
#include <unordered_set>
#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<XDGDesktopFile> processMimeAppsList(
const QString &mimeappsPath, QStringList &associations,
std::unordered_set<QString> &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<XDGDesktopFile> searchMimeAppsListsInDirectory(
const QString &directory, QStringList &associations,
std::unordered_set<QString> &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<XDGDesktopFile> getDefaultBrowserDesktopFile()
{
// no xdg-utils, find it manually by searching mimeapps.list files
QStringList associations;
std::unordered_set<QString> 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

22
src/util/XDGHelper.hpp Normal file
View file

@ -0,0 +1,22 @@
#pragma once
#include "util/XDGDesktopFile.hpp"
#include <QString>
namespace chatterino {
#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN)
std::optional<XDGDesktopFile> 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

View file

@ -4,6 +4,7 @@ option(CHATTERINO_TEST_USE_PUBLIC_HTTPBIN "Use public httpbin for testing networ
set(test_SOURCES set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/main.cpp ${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/ChannelChatters.cpp
${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp ${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp
${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.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/LinkParser.cpp
${CMAKE_CURRENT_LIST_DIR}/src/InputCompletion.cpp ${CMAKE_CURRENT_LIST_DIR}/src/InputCompletion.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Literals.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! # 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) target_compile_definitions(${PROJECT_NAME} PRIVATE CHATTERINO_TEST_USE_PUBLIC_HTTPBIN)
endif() endif()
set_target_properties(${PROJECT_NAME}
PROPERTIES
AUTORCC ON
)
gtest_discover_tests(${PROJECT_NAME}) gtest_discover_tests(${PROJECT_NAME})

View file

@ -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;

View file

@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/">
<file>001-mimeapps.list</file>
</qresource>
</RCC>

View file

@ -0,0 +1,19 @@
#include "util/XDGDesktopFile.hpp"
#include <gtest/gtest.h>
#include <QDebug>
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);
}

62
tests/src/XDGHelper.cpp Normal file
View file

@ -0,0 +1,62 @@
#include "util/XDGHelper.hpp"
#include <gtest/gtest.h>
#include <QDebug>
using namespace chatterino;
TEST(XDGHelper, ParseDesktopExecProgram)
{
struct TestCase {
QString input;
QString expectedOutput;
};
std::vector<TestCase> 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() << "'";
}
}