From 2dab1cbb2071dff709b3037a15db4b09d2341064 Mon Sep 17 00:00:00 2001 From: Christian Heimlich Date: Wed, 18 Oct 2023 05:31:14 -0400 Subject: [PATCH] Add default URI scheme handler manipulators to qx-system Allows for registering an application to be the default handler for a given scheme-based protocol: setDefaultProtocolHandler() isDefaultProtocolHandler() removeDefaultProtocolHandler() --- lib/core/CMakeLists.txt | 3 + lib/core/include/qx/core/qx-system.h | 5 +- lib/core/src/qx-system.cpp | 97 +++++++++++++++ lib/core/src/qx-system_p.h | 19 +++ lib/core/src/qx-system_p_linux.cpp | 169 +++++++++++++++++++++++++++ lib/core/src/qx-system_p_win.cpp | 89 ++++++++++++++ 6 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 lib/core/src/qx-system_p.h create mode 100644 lib/core/src/qx-system_p_linux.cpp create mode 100644 lib/core/src/qx-system_p_win.cpp diff --git a/lib/core/CMakeLists.txt b/lib/core/CMakeLists.txt index 0b8cbd08..86fa9721 100644 --- a/lib/core/CMakeLists.txt +++ b/lib/core/CMakeLists.txt @@ -2,6 +2,7 @@ qx_add_component("Core" HEADERS_PRIVATE qx-json_p.h + qx-system_p.h HEADERS_API qx-abstracterror.h qx-algorithm.h @@ -57,6 +58,8 @@ qx_add_component("Core" qx-system.cpp qx-system_linux.cpp qx-system_win.cpp + qx-system_p_win.cpp + qx-system_p_linux.cpp qx-systemerror.cpp qx-systemerror_linux.cpp qx-systemerror_win.cpp diff --git a/lib/core/include/qx/core/qx-system.h b/lib/core/include/qx/core/qx-system.h index 55134d6e..5f8d2a1b 100644 --- a/lib/core/include/qx/core/qx-system.h +++ b/lib/core/include/qx/core/qx-system.h @@ -29,12 +29,15 @@ QX_CORE_EXPORT SystemError forceKillProcess(quint32 processId); QX_CORE_EXPORT bool enforceSingleInstance(QString uniqueAppId); +QX_CORE_EXPORT bool setDefaultProtocolHandler(const QString& scheme, const QString& name, const QString& path = {}, const QStringList& args = {}); +QX_CORE_EXPORT bool isDefaultProtocolHandler(const QString& scheme, const QString& path = {}); +QX_CORE_EXPORT bool removeDefaultProtocolHandler(const QString& scheme, const QString& path = {}); + #ifdef __linux__ // Temporary means to and end, will replace with full parser eventually QX_CORE_EXPORT QSettings::Format xdgSettingsFormat(); QX_CORE_EXPORT QSettings::Format xdgDesktopSettingsFormat(); #endif - } #endif // QX_SYSTEM_H diff --git a/lib/core/src/qx-system.cpp b/lib/core/src/qx-system.cpp index 976e3e63..1d78b9ea 100644 --- a/lib/core/src/qx-system.cpp +++ b/lib/core/src/qx-system.cpp @@ -1,5 +1,10 @@ // Unit Includes #include "qx/core/qx-system.h" +#include "qx-system_p.h" + +// Qt Includes +#include +#include /*! * @file qx-system.h @@ -11,6 +16,13 @@ namespace Qx { +namespace // Anonymous namespace for effectively private (to this cpp) functions +{ + +bool isValidScheme(QStringView scheme) { return !scheme.isEmpty() && !scheme.contains(QChar::Space); }; + +} + //-Namespace Functions------------------------------------------------------------------------------------------------------------- /*! * @fn quint32 processId(QString processName) @@ -123,4 +135,89 @@ bool processIsRunning(quint32 processId) { return processName(processId).isNull( * changed in future revisions once set. */ +/*! + * Sets the application at @a path as the default handler for URI requests of @a scheme for the + * current user. The registration is configured so that when a URL that uses the protocol is followed, + * the program at the given path will be executed with the scheme URL as the last argument. Generally, + * the user is shown a prompt with the friendly name of the application @a name + * when the protocol is used. + * + * @a scheme cannot contain whitespace. If @a path is left empty, it defaults to + * QDir::toNativeSeparators(QCoreApplication::applicationFilePath()). + * + * Returns @c true if the operation was successful; otherwise, returns @c false. + * + * Commonly, applications are designed to handle scheme URLs as a singular argument: + * + * @code + * myapp myscheme://some-data-here + * @endcode + * + * as most operating system facilities that allow a user to select a default protocol handler do not + * for adding additional arguments; however, additional arguments can be provided via @a args, which + * are placed before the scheme URL. + * + * @note On Linux this function relies on FreeDesktop conformance. + * + * @warning The provided arguments are automatically quoted, but not escaped. If the provided arguments + * contain reserved characters, they will need to be escaped manually. + * + * @sa isDefaultProtocolHandler() and removeDefaultProtocolHandler(). + */ +bool setDefaultProtocolHandler(const QString& scheme, const QString& name, const QString& path, const QStringList& args) +{ + if(!isValidScheme(scheme)) + return false; + + QString command = '"' + (!path.isEmpty() ? path : QDir::toNativeSeparators(QCoreApplication::applicationFilePath())) + '"'; + if(!args.isEmpty()) + command += uR"( ")"_s + args.join(uR"(", ")"_s) + '"'; + + return registerUriSchemeHandler(scheme, name, command); +} + +/*! + * Returns @c true if the application at @a path is set as the default handler for URI requests of + * @a scheme for the current user; otherwise, returns @c false. + * + * If @a path is left empty, it defaults to + * QDir::toNativeSeparators(QCoreApplication::applicationFilePath()). + * + * @note On Linux this function relies on FreeDesktop conformance. + * + * @sa setDefaultProtocolHandler() and removeDefaultProtocolHandler(). + */ +bool isDefaultProtocolHandler(const QString& scheme, const QString& path) +{ + if(!isValidScheme(scheme)) + return false; + + return checkUriSchemeHandler(scheme, !path.isEmpty() ? path : QDir::toNativeSeparators(QCoreApplication::applicationFilePath())); +} + +/*! + * Removes the application at @a path as the default handler for UR requests of @a scheme if it is + * currently set as such for the current user. This function can only remove the default on a per-user + * basis, so it can fail if the default is set system-wide on platforms where users cannot + * override defaults with an unset value. + * + * If @a path is left empty, it defaults to + * QDir::toNativeSeparators(QCoreApplication::applicationFilePath()). + * + * Returns @c true if the operation was successful, or the application is not the default; + * otherwise, returns @c false. + * + * @note On Linux this function relies on FreeDesktop conformance and may require a restart + * of the user's desktop session to take effect. + * + * @sa isDefaultProtocolHandler() and setDefaultProtocolHandler(). + */ +bool removeDefaultProtocolHandler(const QString& scheme, const QString& path) +{ + if(!isValidScheme(scheme)) + return false; + + return removeUriSchemeHandler(scheme, !path.isEmpty() ? path : QDir::toNativeSeparators(QCoreApplication::applicationFilePath())); +} + } diff --git a/lib/core/src/qx-system_p.h b/lib/core/src/qx-system_p.h new file mode 100644 index 00000000..c982fb7c --- /dev/null +++ b/lib/core/src/qx-system_p.h @@ -0,0 +1,19 @@ +#ifndef QX_SYSTEM_P_H +#define QX_SYSTEM_P_H + +// Qt Includes +#include + +namespace Qx +{ +/*! @cond */ + +//-Component Private Functions-------------------------------------------------------------------- +bool registerUriSchemeHandler(const QString& scheme, const QString& name, const QString& command); +bool checkUriSchemeHandler(const QString& scheme, const QString& path); +bool removeUriSchemeHandler(const QString& scheme, const QString& path); + +/*! @endcond */ +} + +#endif // QX_SYSTEM_P_H diff --git a/lib/core/src/qx-system_p_linux.cpp b/lib/core/src/qx-system_p_linux.cpp new file mode 100644 index 00000000..f2ed7b30 --- /dev/null +++ b/lib/core/src/qx-system_p_linux.cpp @@ -0,0 +1,169 @@ +// Unit Includes +#include "qx-system_p.h" +#include "qx/core/qx-system.h" + +// Qt Includes +#include +#include +#include +#include + +using namespace Qt::Literals::StringLiterals; + +namespace Qx +{ +/*! @cond */ + +namespace +{ + +bool runXdgMime(QString* output, QStringList args) +{ + QProcess xdgMime; + xdgMime.setProgram(u"xdg-mime"_s); + xdgMime.setArguments(args); + if(!output) + xdgMime.setStandardOutputFile(QProcess::nullDevice()); + xdgMime.setStandardErrorFile(QProcess::nullDevice()); + xdgMime.start(); + + bool success = xdgMime.waitForFinished(3000) && xdgMime.exitStatus() == xdgMime.NormalExit && xdgMime.exitCode() == 0; + if(output) + *output = QString::fromLocal8Bit(xdgMime.readAllStandardOutput()); + + return success; +} + +bool pathIsDefaultHandler(QString* entryName, const QString& scheme, const QString& path) +{ + // Query default MIME handler desktop entry + QString xSchemeHandler = u"x-scheme-handler/"_s + scheme; + QString dEntryFilename; + if(!runXdgMime(&dEntryFilename, {u"query"_s, u"default"_s, xSchemeHandler}) || !dEntryFilename.endsWith(u".desktop"_s)) + return false; // No default or xdg-mime has failed us + + if(entryName) + *entryName = dEntryFilename; + + // Get entry path + QString dEntryPath = QStandardPaths::locate(QStandardPaths::ApplicationsLocation, dEntryFilename); + if(dEntryPath.isEmpty()) + return false; + + // Read desktop entry + QSettings de(dEntryPath, xdgDesktopSettingsFormat()); + if(de.status() != QSettings::NoError) + return false; + + /* Imperfect check since it could just contains a reference to the path, i.e as an + * argument, but unlikely to be an issue and allows checking for the program + * without considering arguments. + */ + QString exec = de.value("Desktop Entry/Exec").toString(); + return exec.contains(path); +} + +void addToValueList(QSettings& set, QStringView key, QStringView v) +{ + QString vl = set.value(key).toString(); + vl += v.toString() + ';'; + set.setValue(key, vl); +} + +void removeFromValueList(QSettings& set, QStringView key, QStringView v) +{ + QString vl = set.value(key).toString(); + if(vl.isEmpty()) + return; + + qsizetype vIdx = vl.indexOf(v); + if(vIdx == -1) + return; + + qsizetype rmCount = v.size(); + qsizetype scIdx = vIdx + v.size(); + if(scIdx < vl.size() && vl.at(scIdx) == ';') + rmCount++; + vl.remove(vIdx, rmCount); + + if(vl.isEmpty()) + set.remove(key); + else + set.setValue(key, vl); +} + +} + +bool registerUriSchemeHandler(const QString& scheme, const QString& name, const QString& command) +{ + // Get desktop entry path + QString userAppsDirPath = QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation); + QString dEntryFilename = scheme + u"-scheme-handler.desktop"_s; + QString dEntryPath = userAppsDirPath + '/' + dEntryFilename; + QString xSchemeHandler = u"x-scheme-handler/"_s + scheme; + + // Create desktop entry + QSettings de(dEntryPath, xdgDesktopSettingsFormat()); + de.beginGroup(u"Desktop Entry"_s); + de.setValue(u"Type"_s, u"Application"_s); + de.setValue(u"Name"_s, name); + de.setValue(u"Exec"_s, command + u" %u"_s); // %u is already passed as single param, no need for quotes + de.setValue(u"StartupNotify"_s, u"false"_s); + de.setValue(u"MimeType"_s, xSchemeHandler); + de.setValue(u"NoDisplay"_s, true); + de.endGroup(); + + de.sync(); + if(de.status() != QSettings::NoError) + return false; + + // Register MIME type + return runXdgMime(nullptr, {u"default"_s, dEntryFilename, xSchemeHandler}); + + // Alternatively "xdg-settings set default-url-scheme-handler *scheme* *.desktop_file*" can be used +} + +bool checkUriSchemeHandler(const QString& scheme, const QString& path) +{ + return pathIsDefaultHandler(nullptr, scheme, path); +} + +bool removeUriSchemeHandler(const QString& scheme, const QString& path) +{ + QString entryName; + if(pathIsDefaultHandler(&entryName, scheme, path)) + return false; + + // Find mimeapps.list + const QString mimeappslist = u"mimeapps.list"_s; + QString mimeappsPath = QStandardPaths::locate(QStandardPaths::ConfigLocation, mimeappslist); + if(mimeappsPath.isEmpty()) // Check deprecated location + mimeappsPath = QStandardPaths::locate(QStandardPaths::ApplicationsLocation, mimeappslist); + if(mimeappsPath.isEmpty()) + return false; + + // Read mimeapps.list + QSettings ma(mimeappsPath, xdgDesktopSettingsFormat()); + if(ma.status() != QSettings::NoError) + return false; + + QString xSchemeHandler = u"x-scheme-handler/"_s + scheme; + + // Remove handler as default if present + removeFromValueList(ma, u"Default Applications/"_s + xSchemeHandler, entryName); + + // Remove handler from added associations if present + removeFromValueList(ma, u"Added Associations/"_s + xSchemeHandler, entryName); + + // Add to removed associations + addToValueList(ma, u"Removed Associations/"_s + xSchemeHandler, entryName); + + // Save and check status + ma.sync(); + return ma.status() == QSettings::NoError; +} + +/*! @endcond */ +} + + diff --git a/lib/core/src/qx-system_p_win.cpp b/lib/core/src/qx-system_p_win.cpp new file mode 100644 index 00000000..75f26e4e --- /dev/null +++ b/lib/core/src/qx-system_p_win.cpp @@ -0,0 +1,89 @@ +// Unit Includes +#include "qx-system_p.h" + +// Qt Includes +#include + +using namespace Qt::Literals::StringLiterals; + +namespace Qx +{ +/*! @cond */ +bool registerUriSchemeHandler(const QString& scheme, const QString& name, const QString& command) +{ + /* Set registry keys + * + * The example registry key root used in the MS documentation is HKEY_CLASSES_ROOT, which is + * a merged view of system-wide and user-specific settings, that defaults to updating the + * system-wide settings when written to, and therefore requires admin priviledges. So instead + * we use HKEY_CURRENT_USER\SOFTWARE\Classes which is just the user-specific section. + */ + QSettings schemeKey(u"HKEY_CURRENT_USER\\SOFTWARE\\Classes\\"_s + scheme, QSettings::NativeFormat); + schemeKey.setValue(u"."_s, name); + schemeKey.setValue("URL Protocol", ""); + schemeKey.setValue(u"shell/open/command/."_s, command + uR"( "%1")"_s); + + // Save and return status + schemeKey.sync(); + return schemeKey.status() == QSettings::NoError; + + /* NOTE: The Microsoft specification recommends adding a DefaultIcon key to these entries + * with an executable based icon path, though I'm not sure if/how that's actually + * used. If that is ever added, we should check if adding an icon to the desktop entry + * of the Linux equivalent has an appreciable effect as well. + */ +} + +bool checkUriSchemeHandler(const QString& scheme, const QString& path) +{ + // Check HKEY_CLASSES_ROOT for merged system/user view since this function only reads + QSettings schemeKey(u"HKEY_CLASSES_ROOT\\"_s + scheme, QSettings::NativeFormat); + if(schemeKey.status() != QSettings::NoError) + return false; // No scheme key means no default + + QString cmdKeyPath = u"shell/open/command"_s; + if(!schemeKey.contains(cmdKeyPath)) + return false; // No command key means no default + + QString launchValue = schemeKey.value(cmdKeyPath).toString(); + + /* Imperfect check since it could just contains a reference to the path, i.e as an + * argument, but unlikely to be an issue and allows checking for the program + * without considering arguments. + */ + return launchValue.contains(path); +} + +bool removeUriSchemeHandler(const QString& scheme, const QString& path) +{ + /* NOTE: This function can only remove handlers registered for the current + * user and not system wide ones. Returning false if the scheme is not found + * under the user specific key can be interpreted also "failed to remove the + * default since it's system wide" should that be the case. + */ + + // Check HKEY_CLASSES_ROOT for merged system/user view since this function only reads + QSettings schemeKey(u"HKEY_CURRENT_USER\\SOFTWARE\\Classes\\"_s + scheme, QSettings::NativeFormat); + return false; // No scheme key means no default + + QString cmdKeyPath = u"shell/open/command"_s; + if(!schemeKey.contains(cmdKeyPath)) + return false; // No command key means no default + + QString launchValue = schemeKey.value(cmdKeyPath).toString(); + if(!launchValue.contains(path)) + return false; // Not the default + + // Delete scheme key + schemeKey.remove(""); + + // Save and return status + schemeKey.sync(); + return schemeKey.status() == QSettings::NoError; +} + + +/*! @endcond */ +} + +