mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-21 22:24:07 +01:00
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:
parent
168f346c81
commit
69c983e0d9
18 changed files with 741 additions and 61 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -438,6 +438,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
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QVariant>
|
||||
|
||||
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<std::pair<QString, QString>>{
|
||||
{"firefox", "-private-window"}, {"librewolf", "-private-window"},
|
||||
{"waterfox", "-private-window"}, {"icecat", "-private-window"},
|
||||
{"chrome", "-incognito"}, {"vivaldi", "-incognito"},
|
||||
{"opera", "-newprivatetab"}, {"opera\\\\launcher", "--private"},
|
||||
{"opera", "-newprivatetab"}, {"opera\\launcher", "--private"},
|
||||
{"iexplore", "-private"}, {"msedge", "-inprivate"},
|
||||
{"firefox-esr", "-private-window"}, {"chromium", "-incognito"},
|
||||
};
|
||||
|
||||
// transform into regex and replacement string
|
||||
std::vector<std::pair<QRegularExpression, QString>> replacers;
|
||||
// compare case-insensitively
|
||||
auto lowercasedBrowserExecutable = browserExecutable.toLower();
|
||||
|
||||
#ifdef Q_OS_WINDOWS
|
||||
if (lowercasedBrowserExecutable.endsWith(".exe"))
|
||||
{
|
||||
lowercasedBrowserExecutable.chop(4);
|
||||
}
|
||||
#endif
|
||||
|
||||
for (const auto &switch_ : switches)
|
||||
{
|
||||
replacers.emplace_back(
|
||||
QRegularExpression("(" + switch_.first + "\\.exe\"?).*",
|
||||
QRegularExpression::CaseInsensitiveOption),
|
||||
"\\1 " + switch_.second);
|
||||
}
|
||||
|
||||
// try to find matching regex and apply it
|
||||
for (const auto &replacement : replacers)
|
||||
if (lowercasedBrowserExecutable.endsWith(switch_.first))
|
||||
{
|
||||
if (replacement.first.match(command).hasMatch())
|
||||
{
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
118
src/util/XDGDesktopFile.cpp
Normal file
118
src/util/XDGDesktopFile.cpp
Normal 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
|
49
src/util/XDGDesktopFile.hpp
Normal file
49
src/util/XDGDesktopFile.hpp
Normal 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
77
src/util/XDGDirectory.cpp
Normal 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
21
src/util/XDGDirectory.hpp
Normal 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
259
src/util/XDGHelper.cpp
Normal 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
22
src/util/XDGHelper.hpp
Normal 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
|
|
@ -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})
|
||||
|
|
32
tests/resources/001-mimeapps.list
Normal file
32
tests/resources/001-mimeapps.list
Normal 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;
|
5
tests/resources/test-resources.qrc
Normal file
5
tests/resources/test-resources.qrc
Normal file
|
@ -0,0 +1,5 @@
|
|||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>001-mimeapps.list</file>
|
||||
</qresource>
|
||||
</RCC>
|
19
tests/src/XDGDesktopFile.cpp
Normal file
19
tests/src/XDGDesktopFile.cpp
Normal 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
62
tests/src/XDGHelper.cpp
Normal 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() << "'";
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue