diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -73,6 +73,7 @@ ecm_add_tests( favicontest.cpp applicationlauncherjobtest.cpp + openurljobtest.cpp commandlauncherjobtest.cpp NAME_PREFIX "kiogui-" LINK_LIBRARIES KF5::KIOCore KF5::KIOGui Qt5::Test diff --git a/autotests/krununittest.cpp b/autotests/krununittest.cpp --- a/autotests/krununittest.cpp +++ b/autotests/krununittest.cpp @@ -348,7 +348,7 @@ QTest::qWait(100); // let auto-deletion proceed. } -void KRunUnitTest::testMimeTypeDoesNotExist() +void KRunUnitTest::testMimeTypeDoesNotExist() // ported to OpenUrlJobTest::nonExistingFile() { KRunImpl *krun = new KRunImpl(QUrl::fromLocalFile(QStringLiteral("/does/not/exist"))); QSignalSpy spyError(krun, SIGNAL(error())); diff --git a/autotests/openurljobtest.h b/autotests/openurljobtest.h new file mode 100644 --- /dev/null +++ b/autotests/openurljobtest.h @@ -0,0 +1,67 @@ +/* + This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#ifndef OPENURLJOBTEST_H +#define OPENURLJOBTEST_H + +#include +#include +#include + +class OpenUrlJobTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + void init(); + + void startProcess_data(); + void startProcess(); + + void noServiceNoHandler(); + void invalidUrl(); + void refuseRunningNativeExecutables(); + void refuseRunningRemoteNativeExecutables(); + void notAuthorized(); + void runScript_data(); + void runScript(); + void runNativeExecutable_data(); + void runNativeExecutable(); + void launchExternalBrowser_data(); + void launchExternalBrowser(); + void nonExistingFile(); + + void httpUrlWithKIO(); + void ftpUrlWithKIO(); + + void takeOverAfterMimeTypeFound(); + +private: + void writeApplicationDesktopFile(const QString &filePath); + + QStringList m_filesToRemove; + QTemporaryDir m_tempDir; +}; + +#endif /* OPENURLJOBTEST_H */ + diff --git a/autotests/openurljobtest.cpp b/autotests/openurljobtest.cpp new file mode 100644 --- /dev/null +++ b/autotests/openurljobtest.cpp @@ -0,0 +1,452 @@ +/* + This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "openurljobtest.h" +#include "openurljob.h" +#include +#include + +#include "kiotesthelper.h" // createTestFile etc. + +#include +#include +#include + +#ifdef Q_OS_UNIX +#include // kill +#endif + +#include +#include +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(OpenUrlJobTest) + +extern KSERVICE_EXPORT int ksycoca_ms_between_checks; + +namespace KIO { +KIOGUI_EXPORT void setDefaultUntrustedProgramHandler(KIO::UntrustedProgramHandlerInterface *iface); +} +class TestUntrustedProgramHandler : public KIO::UntrustedProgramHandlerInterface +{ +public: + void showUntrustedProgramWarning(KJob *job, const QString &programName) override { + Q_UNUSED(job) + m_calls << programName; + Q_EMIT result(m_retVal); + } + + void setRetVal(bool b) { m_retVal = b; } + + QStringList m_calls; + bool m_retVal = false; +}; + +static TestUntrustedProgramHandler s_handler; + +static const char s_tempServiceName[] = "openurljobtest_service.desktop"; + +void OpenUrlJobTest::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + + // Ensure no leftovers from other tests + QDir(QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation)).removeRecursively(); + // (including a mimeapps.list file) + // Don't remove ConfigLocation completely, it's useful when enabling debug output with kdebugsettings --test-mode + const QString mimeApps = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + QLatin1String("/mimeapps.list"); + QFile::remove(mimeApps); + + ksycoca_ms_between_checks = 0; // need it to check the ksycoca mtime + const QString fakeService = QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation) + QLatin1Char('/') + s_tempServiceName; + writeApplicationDesktopFile(fakeService); + m_filesToRemove.append(fakeService); + + // Ensure our service is the preferred one + KConfig mimeAppsCfg(mimeApps); + KConfigGroup grp = mimeAppsCfg.group("Default Applications"); + grp.writeEntry("text/plain", s_tempServiceName); + grp.writeEntry("text/html", s_tempServiceName); + grp.writeEntry("application/x-shellscript", s_tempServiceName); + grp.sync(); + + for (const char *mimeType : {"text/plain", "application/x-shellscript"}) { + KService::Ptr preferredTextEditor = KApplicationTrader::preferredService(QString::fromLatin1(mimeType)); + QVERIFY(preferredTextEditor); + QCOMPARE(preferredTextEditor->entryPath(), fakeService); + } + + // As used for preferredService + QVERIFY(KService::serviceByDesktopName("openurljobtest_service")); + + ksycoca_ms_between_checks = 5000; // all done, speed up again +} + +void OpenUrlJobTest::cleanupTestCase() +{ + std::for_each(m_filesToRemove.begin(), m_filesToRemove.end(), [](const QString & f) { + QFile::remove(f); + }); +} + +void OpenUrlJobTest::init() +{ + QFile::remove(m_tempDir.path() + "/dest"); +} + +static void createSrcFile(const QString &path) +{ + QFile srcFile(path); + QVERIFY2(srcFile.open(QFile::WriteOnly), qPrintable(srcFile.errorString())); + srcFile.write("Hello world\n"); +} + +static QString readFile(const QString &path) +{ + QFile file(path); + file.open(QIODevice::ReadOnly); + return QString::fromLocal8Bit(file.readAll()).trimmed(); +} + +void OpenUrlJobTest::startProcess_data() +{ + QTest::addColumn("mimeType"); + QTest::addColumn("fileName"); + + // Known mimetype + QTest::newRow("text_file") << "text/plain" << "srcfile.txt"; + QTest::newRow("directory_file") << "application/x-desktop" << ".directory"; + QTest::newRow("desktop_file_link") << "application/x-desktop" << "srcfile.txt"; + QTest::newRow("desktop_file_link_preferred_service") << "application/x-desktop" << "srcfile.html"; + QTest::newRow("non_executable_script_running_not_allowed") << "application/x-shellscript" << "srcfile.sh"; + QTest::newRow("executable_script_running_not_allowed") << "application/x-shellscript" << "srcfile.sh"; + + // Require mimetype determination + QTest::newRow("text_file_no_mimetype") << QString() << "srcfile.txt"; + QTest::newRow("directory_file_no_mimetype") << QString() << ".directory"; +} + +void OpenUrlJobTest::startProcess() +{ + QFETCH(QString, mimeType); + QFETCH(QString, fileName); + + // Given a file to open + QTemporaryDir tempDir; + const QString srcDir = tempDir.path(); + const QString srcFile = srcDir + QLatin1Char('/') + fileName; + createSrcFile(srcFile); + QVERIFY(QFile::exists(srcFile)); + const bool isLink = QByteArray(QTest::currentDataTag()).startsWith("desktop_file_link"); + QUrl url = QUrl::fromLocalFile(srcFile); + if (isLink) { + const QString desktopFilePath = srcDir + QLatin1String("/link.desktop"); + KDesktopFile linkDesktopFile(desktopFilePath); + linkDesktopFile.desktopGroup().writeEntry("Type", "Link"); + linkDesktopFile.desktopGroup().writeEntry("URL", url); + const bool linkHasPreferredService = QByteArray(QTest::currentDataTag()) == "desktop_file_link_preferred_service"; + if (linkHasPreferredService) { + linkDesktopFile.desktopGroup().writeEntry("X-KDE-LastOpenedWith", "openurljobtest_service"); + } + url = QUrl::fromLocalFile(desktopFilePath); + } + if (QByteArray(QTest::currentDataTag()).startsWith("executable")) { + QFile file(srcFile); + QVERIFY(file.setPermissions(QFile::ExeUser | file.permissions())); + } + + // When running a OpenUrlJob + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(url, mimeType, this); + QVERIFY2(job->exec(), qPrintable(job->errorString())); + + // Then the service should be executed (which writes to "dest") + const QString dest = m_tempDir.path() + "/dest"; + QTRY_VERIFY2(QFile::exists(dest), qPrintable(dest)); + QCOMPARE(readFile(dest), srcFile); +} + +void OpenUrlJobTest::noServiceNoHandler() +{ + QTemporaryFile tempFile; + QVERIFY(tempFile.open()); + const QUrl url = QUrl::fromLocalFile(tempFile.fileName()); + const QString mimeType = QStringLiteral("application/x-zerosize"); + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(url, mimeType, this); + // This is going to try QDesktopServices::openUrl which will fail because we are no QGuiApplication, good. + QTest::ignoreMessage(QtWarningMsg, "QDesktopServices::openUrl: Application is not a GUI application"); + QVERIFY(!job->exec()); + QCOMPARE(job->error(), KJob::UserDefinedError); + QCOMPARE(job->errorString(), QStringLiteral("Failed to open the file.")); +} + +void OpenUrlJobTest::invalidUrl() +{ + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl(":/"), QStringLiteral("text/plain"), this); + QVERIFY(!job->exec()); + QCOMPARE(job->error(), KIO::ERR_MALFORMED_URL); + QCOMPARE(job->errorString(), QStringLiteral("Malformed URL\nRelative URL's path component contains ':' before any '/'; source was \":/\"; path = \":/\"")); + + QUrl u; + u.setPath(QStringLiteral("/pathonly")); + KIO::OpenUrlJob *job2 = new KIO::OpenUrlJob(u, QStringLiteral("text/plain"), this); + QVERIFY(!job2->exec()); + QCOMPARE(job2->error(), KIO::ERR_MALFORMED_URL); + QCOMPARE(job2->errorString(), QStringLiteral("Malformed URL\n/pathonly")); +} + +void OpenUrlJobTest::refuseRunningNativeExecutables() +{ + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(QCoreApplication::applicationFilePath()), QStringLiteral("application/x-executable"), this); + QVERIFY(!job->exec()); + QCOMPARE(job->error(), KJob::UserDefinedError); + QVERIFY2(job->errorString().contains("For safety it will not be started"), qPrintable(job->errorString())); +} + +void OpenUrlJobTest::refuseRunningRemoteNativeExecutables() +{ + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl("protocol://host/path/exe"), QStringLiteral("application/x-executable"), this); + job->setRunExecutables(true); // even with this enabled, an error will occur + QVERIFY(!job->exec()); + QCOMPARE(job->error(), KJob::UserDefinedError); + QVERIFY2(job->errorString().contains("For safety it will not be started"), qPrintable(job->errorString())); +} + +KCONFIGCORE_EXPORT void reloadUrlActionRestrictions(); + +void OpenUrlJobTest::notAuthorized() +{ + KConfigGroup cg(KSharedConfig::openConfig(), "KDE URL Restrictions"); + cg.writeEntry("rule_count", 1); + cg.writeEntry("rule_1", QStringList{"open", {}, {}, {}, "file", "", "", "false"}); + cg.sync(); + reloadUrlActionRestrictions(); + + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl("file:///"), QStringLiteral("text/plain"), this); + QVERIFY(!job->exec()); + QCOMPARE(job->error(), KIO::ERR_ACCESS_DENIED); + QCOMPARE(job->errorString(), QStringLiteral("Access denied to file:///.")); + + cg.deleteEntry("rule_1"); + cg.deleteEntry("rule_count"); + cg.sync(); + reloadUrlActionRestrictions(); +} + +void OpenUrlJobTest::runScript_data() +{ + QTest::addColumn("mimeType"); + + QTest::newRow("shellscript") << "application/x-shellscript"; + QTest::newRow("native") << "application/x-executable"; +} + +void OpenUrlJobTest::runScript() +{ +#ifdef Q_OS_UNIX + QFETCH(QString, mimeType); + + // Given an executable shell script that copies "src" to "dest" + QTemporaryDir tempDir; + const QString dir = tempDir.path(); + createSrcFile(dir + QLatin1String("/src")); + const QString scriptFile = dir + QLatin1String("/script.sh"); + QFile file(scriptFile); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("#!/bin/sh\ncp src dest"); + file.close(); + QVERIFY(file.setPermissions(QFile::ExeUser | file.permissions())); + + // When using OpenUrlJob to run the script + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(scriptFile), mimeType, this); + job->setRunExecutables(true); // startProcess tests the case where this isn't set + + // Then it works :-) + QVERIFY2(job->exec(), qPrintable(job->errorString())); + QTRY_VERIFY(QFileInfo::exists(dir + QLatin1String("/dest"))); // TRY because CommandLineLauncherJob finishes immediately +#endif +} + +void OpenUrlJobTest::runNativeExecutable_data() +{ + QTest::addColumn("withHandler"); + QTest::addColumn("handlerRetVal"); + + QTest::newRow("no_handler") << false << false; + QTest::newRow("handler_false") << true << false; + QTest::newRow("handler_true") << true << true; +} + +void OpenUrlJobTest::runNativeExecutable() +{ + QFETCH(bool, withHandler); + QFETCH(bool, handlerRetVal); + +#ifdef Q_OS_UNIX + // Given an executable shell script that copies "src" to "dest" (we'll cheat with the mimetype to treat it like a native binary) + QTemporaryDir tempDir; + const QString dir = tempDir.path(); + createSrcFile(dir + QLatin1String("/src")); + const QString scriptFile = dir + QLatin1String("/script.sh"); + QFile file(scriptFile); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("#!/bin/sh\ncp src dest"); + file.close(); + // Note that it's missing executable permissions + + s_handler.m_calls.clear(); + s_handler.setRetVal(handlerRetVal); + KIO::setDefaultUntrustedProgramHandler(withHandler ? &s_handler : nullptr); + + // When using OpenUrlJob to run the executable + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(scriptFile), QStringLiteral("application/x-executable"), this); + job->setRunExecutables(true); // startProcess tests the case where this isn't set + const bool success = job->exec(); + + // Then --- it depends on what the user says via the handler + if (!withHandler) { + QVERIFY(!success); + QCOMPARE(job->error(), KJob::UserDefinedError); + QCOMPARE(job->errorString(), QStringLiteral("The program \"%1\" needs to have executable permission before it can be launched.").arg(scriptFile)); + } else { + if (handlerRetVal) { + QVERIFY(success); + QTRY_VERIFY(QFileInfo::exists(dir + QLatin1String("/dest"))); // TRY because CommandLineLauncherJob finishes immediately + } else { + QVERIFY(!success); + QCOMPARE(job->error(), KIO::ERR_USER_CANCELED); + } + } +#endif +} + +void OpenUrlJobTest::launchExternalBrowser_data() +{ + QTest::addColumn("useBrowserApp"); + QTest::addColumn("useSchemeHandler"); + + QTest::newRow("browserapp") << true << false; + QTest::newRow("scheme_handler") << false << true; +} + +void OpenUrlJobTest::launchExternalBrowser() +{ +#ifdef Q_OS_UNIX + QFETCH(bool, useBrowserApp); + QFETCH(bool, useSchemeHandler); + + QTemporaryDir tempDir; + const QString dir = tempDir.path(); + createSrcFile(dir + QLatin1String("/src")); + const QString scriptFile = dir + QLatin1String("/browser.sh"); + QFile file(scriptFile); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("#!/bin/sh\necho $1 > `dirname $0`/destbrowser"); + file.close(); + QVERIFY(file.setPermissions(QFile::ExeUser | file.permissions())); + + QUrl remoteImage("http://example.org/image.jpg"); + if (useBrowserApp) { + KConfigGroup(KSharedConfig::openConfig(), "General").writeEntry("BrowserApplication", QString(QLatin1Char('!') + scriptFile)); + } else if (useSchemeHandler) { + remoteImage.setScheme("scheme"); + } + + // When using OpenUrlJob to run the script + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(remoteImage, this); + + // Then it works :-) + QVERIFY2(job->exec(), qPrintable(job->errorString())); + QString dest; + if (useBrowserApp) { + dest = dir + QLatin1String("/destbrowser"); + } else if (useSchemeHandler) { + dest = m_tempDir.path() + QLatin1String("/dest"); // see the .desktop file in writeApplicationDesktopFile + } + QTRY_VERIFY(QFileInfo::exists(dest)); // TRY because CommandLineLauncherJob finishes immediately + QCOMPARE(readFile(dest), remoteImage.toString()); + + // Restore settings + KConfigGroup(KSharedConfig::openConfig(), "General").deleteEntry("BrowserApplication"); +#endif +} + +void OpenUrlJobTest::nonExistingFile() +{ + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(QStringLiteral("/does/not/exist")), this); + QVERIFY(!job->exec()); + QCOMPARE(job->error(), KIO::ERR_DOES_NOT_EXIST); + QCOMPARE(job->errorString(), "Unable to run the command specified. The file or folder \"/does/not/exist\" does not exist."); +} + +void OpenUrlJobTest::httpUrlWithKIO() +{ + // This tests the scanFileWithGet() code path + const QUrl url(QStringLiteral("http://www.google.com/")); + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(url, this); + job->setFollowRedirections(false); + QVERIFY2(job->exec(), qPrintable(job->errorString())); + + // Then the service should be executed (which writes to "dest") + const QString dest = m_tempDir.path() + "/dest"; + QTRY_VERIFY2(QFile::exists(dest), qPrintable(dest)); + QCOMPARE(readFile(dest), url.toString()); +} + +void OpenUrlJobTest::ftpUrlWithKIO() +{ + // This is just to test the statFile() code at least a bit + const QUrl url(QStringLiteral("ftp://localhost:2")); // unlikely that anything is running on that port + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(url, this); + QVERIFY(!job->exec()); + QCOMPARE(job->errorString(), "localhost: Connection refused"); +} + +void OpenUrlJobTest::takeOverAfterMimeTypeFound() +{ + // Given a local image file + QTemporaryDir tempDir; + const QString srcDir = tempDir.path(); + const QString srcFile = srcDir + QLatin1String("/image.jpg"); + createSrcFile(srcFile); + + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(srcFile), this); + QString foundMime = QStringLiteral("NONE"); + connect(job, &KIO::OpenUrlJob::mimetypeFound, this, [&](const QString &mimeType) { + foundMime = mimeType; + job->kill(); + }); + QVERIFY(job->exec()); + QCOMPARE(foundMime, "image/jpeg"); +} + +void OpenUrlJobTest::writeApplicationDesktopFile(const QString &filePath) +{ + KDesktopFile file(filePath); + KConfigGroup group = file.desktopGroup(); + group.writeEntry("Name", "KRunUnittestService"); + group.writeEntry("MimeType", "text/plain;application/x-shellscript;x-scheme-handler/scheme"); + group.writeEntry("Type", "Application"); + group.writeEntry("Exec", QByteArray("echo %u > " + QFile::encodeName(m_tempDir.path()) + "/dest")); // not using %d because of remote urls + QVERIFY(file.sync()); +} diff --git a/src/core/kurlauthorized.h b/src/core/kurlauthorized.h --- a/src/core/kurlauthorized.h +++ b/src/core/kurlauthorized.h @@ -131,7 +131,8 @@ * @see authorizeUrlAction() * @since 5.0 */ -KIOCORE_EXPORT void allowUrlAction(const QString &action, const QUrl &baseUrl, const QUrl &destUrl); +KIOCORE_EXPORT void allowUrlAction(const QString &action, const QUrl &baseUrl, const QUrl &destUrl); + } #endif diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -1,9 +1,11 @@ configure_file(config-kiogui.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-kiogui.h) set(kiogui_SRCS - faviconrequestjob.cpp applicationlauncherjob.cpp commandlauncherjob.cpp + faviconrequestjob.cpp + openurljob.cpp + openurljobhandlerinterface.cpp kprocessrunner.cpp ) @@ -46,9 +48,10 @@ # Headers prefixed with KIO/ ecm_generate_headers(KIOGui_CamelCase_HEADERS HEADER_NAMES - FavIconRequestJob ApplicationLauncherJob CommandLauncherJob + FavIconRequestJob + OpenUrlJob PREFIX KIO REQUIRED_HEADERS KIO_namespaced_gui_HEADERS diff --git a/src/gui/applicationlauncherjob.h b/src/gui/applicationlauncherjob.h --- a/src/gui/applicationlauncherjob.h +++ b/src/gui/applicationlauncherjob.h @@ -129,6 +129,7 @@ /** * Starts the job. * You must call this, after having done all the setters. + * This is (potentially) a GUI job, never use exec(), it would block user interaction. */ void start() override; diff --git a/src/gui/applicationlauncherjob.cpp b/src/gui/applicationlauncherjob.cpp --- a/src/gui/applicationlauncherjob.cpp +++ b/src/gui/applicationlauncherjob.cpp @@ -30,10 +30,13 @@ #include #include +// KF6 TODO: Remove static KIO::UntrustedProgramHandlerInterface *s_untrustedProgramHandler = nullptr; namespace KIO { // Hidden API because in KF6 we'll just check if the job's uiDelegate implements UntrustedProgramHandlerInterface. KIOGUI_EXPORT void setDefaultUntrustedProgramHandler(KIO::UntrustedProgramHandlerInterface *iface) { s_untrustedProgramHandler = iface; } +// For OpenUrlJob +KIO::UntrustedProgramHandlerInterface *defaultUntrustedProgramHandler() { return s_untrustedProgramHandler; } } class KIO::ApplicationLauncherJobPrivate diff --git a/src/gui/openurljob.h b/src/gui/openurljob.h new file mode 100644 --- /dev/null +++ b/src/gui/openurljob.h @@ -0,0 +1,138 @@ +/* + This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#ifndef KIO_OPENURLJOB_H +#define KIO_OPENURLJOB_H + +#include "kiogui_export.h" +#include "applicationlauncherjob.h" +#include +#include + +class QUrl; + +namespace KIO { + +class OpenUrlJobPrivate; + +/** + * @class OpenUrlJob openurljob.h + * + * @brief OpenUrlJob finds out the right way to "open" a URL. + * This includes finding out its mimetype, and then the associated application, + * or running desktop files, executables, etc. + * It also honours the "use this webbrowser for all http(s) URLs" setting. + * @since 5.71 + */ +class KIOGUI_EXPORT OpenUrlJob : public KCompositeJob +{ + Q_OBJECT +public: + /** + * @brief Creates a OpenUrlJob in order to open a URL. + * @param url the URL of the file/directory to open + */ + explicit OpenUrlJob(const QUrl &url, QObject *parent = nullptr); + + /** + * @brief Creates a OpenUrlJob for the case where the mimeType is already known + * @param url the URL of the file/directory to open + * @param mimeType the type of file/directory. See QMimeType. + */ + explicit OpenUrlJob(const QUrl &url, const QString &mimeType, QObject *parent = nullptr); + + /** + * Destructor + * + * Note that jobs auto-delete themselves after emitting result. + */ + ~OpenUrlJob() override; + + /** + * Specifies various flags. + * @param runFlags the flags to be set. For instance, whether the URLs are temporary files that should be deleted after execution. + */ + void setRunFlags(ApplicationLauncherJob::RunFlags runFlags); + + /** + * Sets the file name to use in the case of downloading the file to a tempfile + * in order to give to a non-URL-aware application. + * Some apps rely on the extension to determine the mimetype of the file. + * Usually the file name comes from the URL, but in the case of the + * HTTP Content-Disposition header, we need to override the file name. + * @param suggestedFileName the file name + */ + void setSuggestedFileName(const QString &suggestedFileName); + + /** + * Sets the startup notification id of the application launch. + * @param startupId startup notification id, if any (otherwise ""). + */ + void setStartupId(const QByteArray &startupId); + + /** + * Set this to true if this class should allow the user to run executables. + * Unlike KF5's KRun, this setting is OFF by default here for security reasons. + * File managers can enable this, but e.g. web browsers, mail clients etc. shouldn't. + */ + void setRunExecutables(bool allow); + + /** + * Sets whether the external webbrowser setting should be honoured. + * This is enabled by default. + * This should only be disabled in webbrowser applications. + * @param b whether to enable the external browser or not. + */ + void setEnableExternalBrowser(bool b); + + /** + * Sets whether the job should follow URL redirections. + * This is enabled by default. + * @param b whether to follow redirections or not. + */ + void setFollowRedirections(bool b); + + /** + * Starts the job. + * You must call this, after having done all the setters. + * This is a GUI job, never use exec(), it would block user interaction. + */ + void start() override; + +Q_SIGNALS: + /** + * Emitted when the mimeType was determined. + * This can be used for special cases like webbrowsers + * who want to embed the URL in some cases, rather than starting a different + * application. In that case they can kill the job. + */ + void mimetypeFound(const QString &mimeType); + +private: + void slotResult(KJob *job) override; + + friend class OpenUrlJobPrivate; + QScopedPointer d; +}; + +} // namespace KIO + +#endif // OPENURLJOB_H diff --git a/src/gui/openurljob.cpp b/src/gui/openurljob.cpp new file mode 100644 --- /dev/null +++ b/src/gui/openurljob.cpp @@ -0,0 +1,674 @@ +/* + This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "openurljob.h" +#include "openurljobhandlerinterface.h" +#include "global.h" +#include "job.h" // for buildErrorString +#include "commandlauncherjob.h" +#include "desktopexecparser.h" +#include "untrustedprogramhandlerinterface.h" +#include "kiogui_debug.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +static KIO::OpenUrlJobHandlerInterface *s_openUrlJobHandler = nullptr; +namespace KIO { +// Hidden API because in KF6 we'll just check if the job's uiDelegate implements OpenUrlJobHandlerInterface. +KIOGUI_EXPORT void setDefaultOpenUrlJobHandler(KIO::OpenUrlJobHandlerInterface *iface) { s_openUrlJobHandler = iface; } +} + +class KIO::OpenUrlJobPrivate +{ +public: + explicit OpenUrlJobPrivate(const QUrl &url, OpenUrlJob *qq) + : m_url(url), q(qq) {} + + void emitAccessDenied(); + void runUrlWithMimeType(); + QString externalBrowser() const; + bool runExternalBrowser(const QString &exe); + void useSchemeHandler(); + void determineLocalMimeType(); + void statFile(); + void scanFileWithGet(); + + QUrl m_url; + KIO::OpenUrlJob *q; + KIO::ApplicationLauncherJob::RunFlags m_runFlags; + QString m_suggestedFileName; + QByteArray m_startupId; + QString m_mimeTypeName; + KService::Ptr m_preferredService; + bool m_runExecutables = false; + bool m_externalBrowserEnabled = true; + bool m_followRedirections = true; + +private: + void executeCommand(); + bool handleExecutables(const QMimeType &mimeType); + void runLink(const QString &filePath, const QString &urlStr, const QString &optionalServiceName); + void showOpenWithDialog(); + void startService(const KService::Ptr &service); +}; + +KIO::OpenUrlJob::OpenUrlJob(const QUrl &url, QObject *parent) + : KCompositeJob(parent), d(new OpenUrlJobPrivate(url, this)) +{ +} + +KIO::OpenUrlJob::OpenUrlJob(const QUrl &url, const QString &mimeType, QObject *parent) + : KCompositeJob(parent), d(new OpenUrlJobPrivate(url, this)) +{ + d->m_mimeTypeName = mimeType; +} + +KIO::OpenUrlJob::~OpenUrlJob() +{ +} + +void KIO::OpenUrlJob::setRunFlags(KIO::ApplicationLauncherJob::RunFlags runFlags) +{ + d->m_runFlags = runFlags; +} + +void KIO::OpenUrlJob::setSuggestedFileName(const QString &suggestedFileName) +{ + d->m_suggestedFileName = suggestedFileName; +} + +void KIO::OpenUrlJob::setStartupId(const QByteArray &startupId) +{ + d->m_startupId = startupId; +} + +void KIO::OpenUrlJob::setRunExecutables(bool allow) +{ + d->m_runExecutables = allow; +} + +void KIO::OpenUrlJob::setEnableExternalBrowser(bool b) +{ + d->m_externalBrowserEnabled = b; +} + +void KIO::OpenUrlJob::setFollowRedirections(bool b) +{ + d->m_followRedirections = b; +} + +static bool checkNeedPortalSupport() +{ + return !QStandardPaths::locate(QStandardPaths::RuntimeLocation, + QLatin1String("flatpak-info")).isEmpty() || + qEnvironmentVariableIsSet("SNAP"); +} + +void KIO::OpenUrlJob::start() +{ + if (!d->m_url.isValid() || d->m_url.scheme().isEmpty()) { + const QString error = !d->m_url.isValid() ? d->m_url.errorString() : d->m_url.toString(); + setError(KIO::ERR_MALFORMED_URL); + setErrorText(i18n("Malformed URL\n%1", error)); + emitResult(); + return; + } + if (!KUrlAuthorized::authorizeUrlAction(QStringLiteral("open"), QUrl(), d->m_url)) { + d->emitAccessDenied(); + return; + } + if (d->m_externalBrowserEnabled && checkNeedPortalSupport()) { + // Use the function from QDesktopServices as it handles portals correctly + // Note that it falls back to "normal way" if the portal service isn't running. + if (!QDesktopServices::openUrl(d->m_url)) { + // Is this an actual error, or USER_CANCELED? + setError(KJob::UserDefinedError); + setErrorText(i18n("Failed to open %1", d->m_url.toDisplayString())); + } + emitResult(); + return; + } + + // If we know the mimetype, proceed + if (!d->m_mimeTypeName.isEmpty()) { + d->runUrlWithMimeType(); + return; + } + + if (d->m_externalBrowserEnabled && d->m_url.scheme().startsWith(QLatin1String("http"))) { + const QString externalBrowser = d->externalBrowser(); + if (!externalBrowser.isEmpty() && d->runExternalBrowser(externalBrowser)) { + return; + } + } else if (d->m_url.isLocalFile() && + (d->m_url.host().isEmpty() || + (d->m_url.host() == QLatin1String("localhost")) || + (d->m_url.host().compare(QHostInfo::localHostName(), Qt::CaseInsensitive) == 0))) { + d->determineLocalMimeType(); + return; + } + if (KIO::DesktopExecParser::hasSchemeHandler(d->m_url)) { + d->useSchemeHandler(); + return; + } + + if (!KProtocolManager::supportsListing(d->m_url)) { + // No support for listing => it can't be a directory (example: http) + d->scanFileWithGet(); + return; + } + + // It may be a directory or a file, let's use stat to find out + d->statFile(); +} + +QString KIO::OpenUrlJobPrivate::externalBrowser() const +{ + if (!m_externalBrowserEnabled) { + return QString(); + } + + const QString browserApp = KConfigGroup(KSharedConfig::openConfig(), "General").readEntry("BrowserApplication"); + if (!browserApp.isEmpty()) { + return browserApp; + } + + // If a default browser isn't set in kdeglobals, fall back to mimeapps.list + KSharedConfig::Ptr profile = KSharedConfig::openConfig(QStringLiteral("mimeapps.list"), KConfig::NoGlobals, QStandardPaths::GenericConfigLocation); + const KConfigGroup defaultApps(profile, "Default Applications"); + + QString externalBrowser = defaultApps.readEntry("x-scheme-handler/https"); + if (externalBrowser.isEmpty()) { + externalBrowser = defaultApps.readEntry("x-scheme-handler/http"); + } + return externalBrowser; +} + +bool KIO::OpenUrlJobPrivate::runExternalBrowser(const QString &exec) +{ + if (exec.startsWith(QLatin1Char('!'))) { + // Literal command + const QString command = exec.midRef(1) + QLatin1String(" %u"); + KService::Ptr service(new KService(QString(), command, QString())); + startService(service); + return true; + } else { + // Name of desktop file + KService::Ptr service = KService::serviceByStorageId(exec); + if (service) { + startService(service); + return true; + } + } + return false; +} + +void KIO::OpenUrlJobPrivate::useSchemeHandler() +{ + // look for an application associated with x-scheme-handler/ + const KService::Ptr service = KApplicationTrader::preferredService(QLatin1String("x-scheme-handler/") + m_url.scheme()); + if (service) { + startService(service); + return; + } + // fallback, look for associated helper protocol + Q_ASSERT(KProtocolInfo::isHelperProtocol(m_url.scheme())); + const auto exec = KProtocolInfo::exec(m_url.scheme()); + if (exec.isEmpty()) { + // use default mimetype opener for file + m_mimeTypeName = KProtocolManager::defaultMimetype(m_url); + runUrlWithMimeType(); + } else { + KService::Ptr service(new KService(QString(), exec, QString())); + startService(service); + } +} + +void KIO::OpenUrlJobPrivate::determineLocalMimeType() +{ + const QString localPath = m_url.toLocalFile(); + if (!QFile::exists(localPath)) { + q->setError(KIO::ERR_DOES_NOT_EXIST); + q->setErrorText(i18n("Unable to run the command specified. " + "The file or folder \"%1\" does not exist.", + localPath)); + q->emitResult(); + return; + } + + QMimeDatabase db; + QMimeType mime = db.mimeTypeForUrl(m_url); + //qDebug() << "MIME TYPE is " << mime.name(); + if (mime.isDefault() && !QFileInfo(localPath).isReadable()) { + // Unknown mimetype because the file is unreadable, no point in showing an open-with dialog (#261002) + q->setErrorText(localPath); + q->setError(KIO::ERR_ACCESS_DENIED); + q->emitResult(); + } else { + m_mimeTypeName = mime.name(); + runUrlWithMimeType(); + } +} + +void KIO::OpenUrlJobPrivate::statFile() +{ + Q_ASSERT(!m_url.isLocalFile()); + Q_ASSERT(m_mimeTypeName.isEmpty()); + + KIO::StatJob *job = KIO::statDetails(m_url, KIO::StatJob::SourceSide, KIO::StatBasic, KIO::HideProgressInfo); + job->setUiDelegate(nullptr); + QObject::connect(job, &KJob::result, + q, [=]() { + const int errCode = job->error(); + if (errCode) { + // ERR_NO_CONTENT is not an error, but an indication no further + // actions needs to be taken. + if (errCode != KIO::ERR_NO_CONTENT) { + q->setError(errCode); + q->setErrorText(job->errorText()); + } + q->emitResult(); + } else { + if (m_followRedirections) { // Update our URL in case of a redirection + m_url = job->url(); + } + + const KIO::UDSEntry entry = job->statResult(); + + const QString localPath = entry.stringValue(KIO::UDSEntry::UDS_LOCAL_PATH); + if (!localPath.isEmpty()) { + m_url = QUrl::fromLocalFile(localPath); + } + + // mimetype already known? (e.g. print:/manager) + m_mimeTypeName = entry.stringValue(KIO::UDSEntry::UDS_MIME_TYPE); + if (!m_mimeTypeName.isEmpty()) { + runUrlWithMimeType(); + return; + } + + const mode_t mode = entry.numberValue(KIO::UDSEntry::UDS_FILE_TYPE); + if ((mode & QT_STAT_MASK) == QT_STAT_DIR) { // it's a dir + m_mimeTypeName = QStringLiteral("inode/directory"); + runUrlWithMimeType(); + } else { // it's a file + // Start the timer. Once we get the timer event this + // protocol server is back in the pool and we can reuse it. + // This gives better performance than starting a new slave + QTimer::singleShot(0, q, [this] { scanFileWithGet(); }); + } + } + }); +} + +void KIO::OpenUrlJobPrivate::startService(const KService::Ptr &service) +{ + KIO::ApplicationLauncherJob *job = new KIO::ApplicationLauncherJob(service, q); + job->setUrls({m_url}); + job->setRunFlags(m_runFlags); + job->setSuggestedFileName(m_suggestedFileName); + job->setStartupId(m_startupId); + job->setUiDelegate(q->uiDelegate()); + q->addSubjob(job); + job->start(); +} + +static QMimeType fixupMimeType(const QString &mimeType, const QString &fileName) +{ + QMimeDatabase db; + QMimeType mime = db.mimeTypeForName(mimeType); + if ((!mime.isValid() || mime.isDefault()) && !fileName.isEmpty()) { + mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchExtension); + } + return mime; +} + +void KIO::OpenUrlJobPrivate::scanFileWithGet() +{ + Q_ASSERT(!m_url.isLocalFile()); + Q_ASSERT(m_mimeTypeName.isEmpty()); + + // First, let's check for well-known extensions + // Not over HTTP and not when there is a query in the URL, in any case. + if (!m_url.hasQuery() && !m_url.scheme().startsWith(QLatin1String("http"))) { + QMimeDatabase db; + QMimeType mime = db.mimeTypeForUrl(m_url); + if (!mime.isDefault()) { + //qDebug() << "Scanfile: MIME TYPE is " << mime.name(); + m_mimeTypeName = mime.name(); + runUrlWithMimeType(); + return; + } + } + + // No mimetype found, and the URL is not local (or fast mode not allowed). + // We need to apply the 'KIO' method, i.e. either asking the server or + // getting some data out of the file, to know what mimetype it is. + + if (!KProtocolManager::supportsReading(m_url)) { + qCWarning(KIO_GUI) << "#### NO SUPPORT FOR READING!"; + q->setError(KIO::ERR_CANNOT_READ); + q->setErrorText(m_url.toDisplayString()); + q->emitResult(); + return; + } + //qDebug() << this << "Scanning file" << url; + + KIO::TransferJob *job = KIO::get(m_url, KIO::NoReload /*reload*/, KIO::HideProgressInfo); + job->setUiDelegate(nullptr); + QObject::connect(job, &KJob::result, + q, [=]() { + const int errCode = job->error(); + if (errCode) { + // ERR_NO_CONTENT is not an error, but an indication no further + // actions needs to be taken. + if (errCode != KIO::ERR_NO_CONTENT) { + q->setError(errCode); + q->setErrorText(job->errorText()); + } + q->emitResult(); + } + // if the job succeeded, we certainly hope it emitted mimetype()... + }); + QObject::connect(job, QOverload::of(&KIO::TransferJob::mimetype), + q, [=](KIO::Job *, const QString &mimetype) { + if (m_followRedirections) { // Update our URL in case of a redirection + m_url = job->url(); + } + if (mimetype.isEmpty()) { + qCWarning(KIO_GUI) << "get() didn't emit a mimetype! Probably a kioslave bug, please check the implementation of" << m_url.scheme(); + } + m_mimeTypeName = mimetype; + + // If the current mime-type is the default mime-type, then attempt to + // determine the "real" mimetype from the file name (bug #279675) + QMimeType mime = fixupMimeType(m_mimeTypeName, m_suggestedFileName.isEmpty() ? m_url.fileName() : m_suggestedFileName); + if (mime.isValid() && mime.name() != m_mimeTypeName) { + m_mimeTypeName = mime.name(); + } + + if (m_suggestedFileName.isEmpty()) { + m_suggestedFileName = job->queryMetaData(QStringLiteral("content-disposition-filename")); + } + + job->putOnHold(); + KIO::Scheduler::publishSlaveOnHold(); + runUrlWithMimeType(); + }); +} + +void KIO::OpenUrlJobPrivate::runLink(const QString &filePath, const QString &urlStr, const QString &optionalServiceName) +{ + if (urlStr.isEmpty()) { + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("The desktop entry file\n%1\nis of type Link but has no URL=... entry.", filePath)); + q->emitResult(); + return; + } + + m_url = QUrl::fromUserInput(urlStr); + m_mimeTypeName.clear(); + + // X-KDE-LastOpenedWith holds the service desktop entry name that + // was should be preferred for opening this URL if possible. + // This is used by the Recent Documents menu for instance. + if (!optionalServiceName.isEmpty()) { + m_preferredService = KService::serviceByDesktopName(optionalServiceName); + } + + // Restart from scratch with the target of the link + q->start(); +} + +void KIO::OpenUrlJobPrivate::emitAccessDenied() +{ + q->setError(KIO::ERR_ACCESS_DENIED); + q->setErrorText(KIO::buildErrorString(KIO::ERR_ACCESS_DENIED, m_url.toDisplayString())); + q->emitResult(); +} + +// was: KRun::isExecutable. Feel free to make public if needed. +static bool isExecutableMime(const QMimeType &mimeType) +{ + return (mimeType.inherits(QLatin1String("application/x-desktop")) || + mimeType.inherits(QLatin1String("application/x-executable")) || + /* See https://bugs.freedesktop.org/show_bug.cgi?id=97226 */ + mimeType.inherits(QLatin1String("application/x-sharedlib")) || + mimeType.inherits(QLatin1String("application/x-ms-dos-executable")) || + mimeType.inherits(QLatin1String("application/x-shellscript"))); +} + +// Helper function that returns whether a file has the execute bit set or not. +static bool hasExecuteBit(const QString &fileName) +{ + QFileInfo file(fileName); + return file.isExecutable(); +} + +namespace KIO { +extern KIO::UntrustedProgramHandlerInterface *defaultUntrustedProgramHandler(); +} + +// Return true if handled in any way (success or error) +// Return false if the caller should proceed +bool KIO::OpenUrlJobPrivate::handleExecutables(const QMimeType &mimeType) +{ + if (!KAuthorized::authorize(QStringLiteral("shell_access"))) { + emitAccessDenied(); + return true; // handled + } + + // Check whether file is executable script +#ifdef Q_OS_WIN + const bool isNativeBinary = !mimeType.inherits(QStringLiteral("text/plain")); +#else + const bool isNativeBinary = !mimeType.inherits(QStringLiteral("text/plain")) && !mimeType.inherits(QStringLiteral("application/x-ms-dos-executable")); +#endif + + if (!m_url.isLocalFile() || !m_runExecutables) { + if (isNativeBinary) { + // Show warning for executables that aren't scripts + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("The file %1 is an executable program. " + "For safety it will not be started.", m_url.toDisplayString().toHtmlEscaped())); + q->emitResult(); + return true; // handled + } + // Let scripts be open as text files, if remote, or no exec allowed + return false; + } + + const QString localPath = m_url.toLocalFile(); + + // For executables that aren't scripts and without execute bit, + // show prompt asking user if he wants to run the program. + if (!hasExecuteBit(localPath)) { + if (!isNativeBinary) { + // Don't try to run scripts/exes without execute bit, instead + // open them with default application + return false; + } + KIO::UntrustedProgramHandlerInterface *untrustedProgramHandler = defaultUntrustedProgramHandler(); + if (!untrustedProgramHandler) { + // No way to ask the user to make it executable + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("The program \"%1\" needs to have executable permission before it can be launched.", localPath)); + q->emitResult(); + return true; + } + QObject::connect(untrustedProgramHandler, &KIO::UntrustedProgramHandlerInterface::result, q, [=](bool result) { + if (result) { + QString errorString; + if (untrustedProgramHandler->setExecuteBit(localPath, errorString)) { + executeCommand(); + } else { + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("Unable to make file \"%1\" executable.\n%2.", localPath, errorString)); + q->emitResult(); + } + } else { + q->setError(KIO::ERR_USER_CANCELED); + q->emitResult(); + } + }); + untrustedProgramHandler->showUntrustedProgramWarning(q, m_url.fileName()); + return true; + } + + // Local executable with execute bit, proceed + executeCommand(); + return true; // handled +} + +void KIO::OpenUrlJobPrivate::executeCommand() +{ + // Execute the URL as a command. This is how we start scripts and executables + KIO::CommandLauncherJob *job = new KIO::CommandLauncherJob(m_url.toLocalFile()); + job->setUiDelegate(q->uiDelegate()); + job->setStartupId(m_startupId); + job->setWorkingDirectory(m_url.adjusted(QUrl::RemoveFilename).toLocalFile()); + q->addSubjob(job); + job->start(); + + // TODO implement deleting the file if tempFile==true + // CommandLauncherJob doesn't support that, unlike ApplicationLauncherJob + // We'd have to do it in KProcessRunner. +} + +void KIO::OpenUrlJobPrivate::runUrlWithMimeType() +{ + // Tell the app, in case it wants us to stop here + Q_EMIT q->mimetypeFound(m_mimeTypeName); + if (q->error() == KJob::KilledJobError) { + q->emitResult(); + return; + } + + // Support for preferred service setting, see setPreferredService + if (m_preferredService && m_preferredService->hasMimeType(m_mimeTypeName)) { + startService(m_preferredService); + return; + } + + // Local desktop file + if (m_url.isLocalFile() && m_mimeTypeName == QLatin1String("application/x-desktop")) { + if (m_url.fileName() == QLatin1String(".directory")) { + // We cannot execute a .directory file. Open with a text editor instead. + m_mimeTypeName = QStringLiteral("text/plain"); + } else { + const QString filePath = m_url.toLocalFile(); + KDesktopFile cfg(filePath); + KConfigGroup cfgGroup = cfg.desktopGroup(); + if (!cfgGroup.hasKey("Type")) { + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("The desktop entry file %1 has no Type=... entry.", filePath)); + q->emitResult(); + return; + } + if ((cfg.hasApplicationType() + || cfg.readType() == QLatin1String("Service"))// for kio_settings + && !cfgGroup.readEntry("Exec").isEmpty() + && m_runExecutables) { + KService::Ptr service(new KService(filePath)); + startService(service); + return; + } else if (cfg.hasLinkType()) { + runLink(filePath, cfg.readUrl(), cfg.desktopGroup().readEntry("X-KDE-LastOpenedWith")); + return; + } + } + } + + // Scripts and executables + QMimeDatabase db; + QMimeType mimeType = db.mimeTypeForName(m_mimeTypeName); + if (isExecutableMime(mimeType)) { + if (handleExecutables(mimeType)) { + return; + } + } + + // General case: look up associated application + KService::Ptr service = KApplicationTrader::preferredService(m_mimeTypeName); + if (service) { + startService(service); + } else { + showOpenWithDialog(); + } +} + +void KIO::OpenUrlJobPrivate::showOpenWithDialog() +{ + if (!KAuthorized::authorizeAction(QStringLiteral("openwith"))) { + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("You are not authorized to select an application to open this file.")); + q->emitResult(); + return; + } + + if (!s_openUrlJobHandler || QOperatingSystemVersion::currentType() == QOperatingSystemVersion::Windows) { + // As KDE on windows doesn't know about the windows default applications offers will be empty in nearly all cases. + // So we use QDesktopServices::openUrl to let windows decide how to open the file. + // It's also our fallback if there's no handler to show an open-with dialog. + if (!QDesktopServices::openUrl(m_url)) { + q->setError(KJob::UserDefinedError); + q->setErrorText(i18n("Failed to open the file.")); + } + q->emitResult(); + return; + } + + QObject::connect(s_openUrlJobHandler, &KIO::OpenUrlJobHandlerInterface::canceled, q, [this]() { + q->setError(KIO::ERR_USER_CANCELED); + q->emitResult(); + }); + + QObject::connect(s_openUrlJobHandler, &KIO::OpenUrlJobHandlerInterface::serviceSelected, q, [this](const KService::Ptr &service) { + startService(service); + }); + + s_openUrlJobHandler->promptUserForApplication(q, m_url, m_mimeTypeName); +} + +void KIO::OpenUrlJob::slotResult(KJob *job) +{ + // This is only used for the final application/launcher job, so we're done when it's done + KCompositeJob::slotResult(job); // forward errors + if (!job->error()) { + emitResult(); + } +} diff --git a/src/gui/openurljobhandlerinterface.h b/src/gui/openurljobhandlerinterface.h new file mode 100644 --- /dev/null +++ b/src/gui/openurljobhandlerinterface.h @@ -0,0 +1,90 @@ +/* This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#ifndef OPENURLJOBHANDLERINTERFACE_H +#define OPENURLJOBHANDLERINTERFACE_H + +#include +#include +#include +class QString; + +namespace KIO { +class OpenUrlJob; + +/** + * @brief The OpenUrlJobHandlerInterface class allows OpenUrlJob to + * prompt the user about which application to use to open URLs that do not + * have an associated application (the "Open With" dialog). + * + * This extension mechanism for jobs is similar to KIO::JobUiDelegateExtension + * and UntrustedProgramHandlerInterface. + * + * @since 5.71 + */ +class KIOGUI_EXPORT OpenUrlJobHandlerInterface : public QObject +{ + Q_OBJECT +protected: + /** + * Constructor + */ + OpenUrlJobHandlerInterface(); + + /** + * Destructor + */ + virtual ~OpenUrlJobHandlerInterface(); + +public: + /** + * Show the "Open With" dialog. + * @param job the job calling this. Useful to get all is properties + * @param url the URL to open + * @param mimeType the mimeType of the URL + * + * Implementations of this method must emit either serviceSelected or canceled. + * + * The default implementation in this base class simply emits cancelled(). + * Any application using KIO::JobUiDelegate (KIOWidgets) will benefit from an + * automatically registered subclass which implements this method using KOpenWithDialog. + */ + virtual void promptUserForApplication(KIO::OpenUrlJob *job, const QUrl &url, const QString &mimeType); + +Q_SIGNALS: + /** + * Emitted by promptUserForApplication() once the user chose an application. + * @param service the application chosen by the user + */ + void serviceSelected(const KService::Ptr &service); + + /** + * Emitted by promptUserForApplication() if the user canceled the application selection dialog. + */ + void canceled(); + +private: + class Private; + Private *const d; +}; + +} + +#endif // OPENURLJOBHANDLERINTERFACE_H diff --git a/src/gui/openurljobhandlerinterface.cpp b/src/gui/openurljobhandlerinterface.cpp new file mode 100644 --- /dev/null +++ b/src/gui/openurljobhandlerinterface.cpp @@ -0,0 +1,44 @@ +/* This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "openurljobhandlerinterface.h" + +#include +#include +#include "kiocoredebug.h" + +using namespace KIO; + +OpenUrlJobHandlerInterface::OpenUrlJobHandlerInterface() + : d(nullptr) +{ +} + +OpenUrlJobHandlerInterface::~OpenUrlJobHandlerInterface() +{ +} + +void OpenUrlJobHandlerInterface::promptUserForApplication(OpenUrlJob *job, const QUrl &url, const QString &mimeType) +{ + Q_UNUSED(job) + Q_UNUSED(url) + Q_UNUSED(mimeType) + Q_EMIT canceled(); +} diff --git a/src/widgets/CMakeLists.txt b/src/widgets/CMakeLists.txt --- a/src/widgets/CMakeLists.txt +++ b/src/widgets/CMakeLists.txt @@ -59,6 +59,7 @@ kurifiltersearchprovideractions.cpp renamefiledialog.cpp widgetsuntrustedprogramhandler.cpp + widgetsopenurljobhandler.cpp ) if (WIN32) list(APPEND kiowidgets_SRCS diff --git a/src/widgets/jobuidelegate.cpp b/src/widgets/jobuidelegate.cpp --- a/src/widgets/jobuidelegate.cpp +++ b/src/widgets/jobuidelegate.cpp @@ -25,6 +25,7 @@ #include "kio_widgets_debug.h" #include "kiogui_export.h" #include "widgetsuntrustedprogramhandler.h" +#include "widgetsopenurljobhandler.h" #include #include @@ -52,14 +53,17 @@ namespace KIO { KIOGUI_EXPORT void setDefaultUntrustedProgramHandler(KIO::UntrustedProgramHandlerInterface *iface); +KIOGUI_EXPORT void setDefaultOpenUrlJobHandler(KIO::OpenUrlJobHandlerInterface *iface); } KIO::JobUiDelegate::JobUiDelegate() : d(new Private()) { // KF6 TODO: remove, inherit from WidgetsUntrustedProgramHandler instead static WidgetsUntrustedProgramHandler s_handler; KIO::setDefaultUntrustedProgramHandler(&s_handler); + static WidgetsOpenUrlJobHandler s_openUrlHandler; + KIO::setDefaultOpenUrlJobHandler(&s_openUrlHandler); } KIO::JobUiDelegate::~JobUiDelegate() diff --git a/src/widgets/kdesktopfileactions.cpp b/src/widgets/kdesktopfileactions.cpp --- a/src/widgets/kdesktopfileactions.cpp +++ b/src/widgets/kdesktopfileactions.cpp @@ -44,12 +44,13 @@ static bool runLink(const QUrl &_url, const KDesktopFile &cfg, const QByteArray &asn); - +// TODO KF6 remove, use OpenUrlJob instead bool KDesktopFileActions::run(const QUrl &u, bool _is_local) { return runWithStartup(u, _is_local, QByteArray()); } +// TODO KF6 remove, use OpenUrlJob instead bool KDesktopFileActions::runWithStartup(const QUrl &u, bool _is_local, const QByteArray &asn) { // It might be a security problem to run external untrusted desktop diff --git a/src/widgets/kopenwithdialog.h b/src/widgets/kopenwithdialog.h --- a/src/widgets/kopenwithdialog.h +++ b/src/widgets/kopenwithdialog.h @@ -80,6 +80,21 @@ KOpenWithDialog(const QString &mimeType, const QString &value, QWidget *parent = nullptr); + /** + * Create a dialog that asks for a application for opening a given + * URL (or more than one), when we already know the mimetype of the URL(s). + * + * @param urls is the URLs that should be opened + * @param mimeType the MIME type of the URL + * @param text appears as a label on top of the entry box. + * @param value is the initial value of the line + * @param parent parent widget + * @since 5.71 + */ + KOpenWithDialog(const QList &urls, const QString &mimeType, + const QString &text, const QString &value, + QWidget *parent = nullptr); + /** * Create a dialog to select an application * Note that this dialog doesn't apply to URLs. diff --git a/src/widgets/kopenwithdialog.cpp b/src/widgets/kopenwithdialog.cpp --- a/src/widgets/kopenwithdialog.cpp +++ b/src/widgets/kopenwithdialog.cpp @@ -514,7 +514,9 @@ /** * Determine mime type from URLs */ - void setMimeType(const QList &_urls); + void setMimeTypeFromUrls(const QList &_urls); + + void setMimeType(const QString &mimeType); void addToMimeAppsList(const QString &serviceId); @@ -576,12 +578,19 @@ { text = i18n("Choose the name of the program with which to open the selected files."); } - d->setMimeType(_urls); + d->setMimeTypeFromUrls(_urls); d->init(text, QString()); } KOpenWithDialog::KOpenWithDialog(const QList &_urls, const QString &_text, const QString &_value, QWidget *parent) + : KOpenWithDialog(_urls, QString(), _text, _value, parent) +{ +} + +KOpenWithDialog::KOpenWithDialog(const QList &_urls, const QString &mimeType, + const QString &_text, const QString &_value, + QWidget *parent) : QDialog(parent), d(new KOpenWithDialogPrivate(this)) { setObjectName(QStringLiteral("openwith")); @@ -597,7 +606,11 @@ } } setWindowTitle(i18n("Choose Application")); - d->setMimeType(_urls); + if (mimeType.isEmpty()) { + d->setMimeTypeFromUrls(_urls); + } else { + d->setMimeType(mimeType); + } d->init(text, _value); } @@ -611,13 +624,8 @@ QString text = i18n("Select the program for the file type: %1. " "If the program is not listed, enter the name or click " "the browse button.", mimeType); - d->qMimeType = mimeType; - QMimeDatabase db; - d->qMimeTypeComment = db.mimeTypeForName(mimeType).comment(); + d->setMimeType(mimeType); d->init(text, value); - if (d->remember) { - d->remember->hide(); - } } KOpenWithDialog::KOpenWithDialog(QWidget *parent) @@ -633,7 +641,7 @@ d->init(text, QString()); } -void KOpenWithDialogPrivate::setMimeType(const QList &_urls) +void KOpenWithDialogPrivate::setMimeTypeFromUrls(const QList &_urls) { if (_urls.count() == 1) { QMimeDatabase db; @@ -649,6 +657,13 @@ } } +void KOpenWithDialogPrivate::setMimeType(const QString &mimeType) +{ + qMimeType = mimeType; + QMimeDatabase db; + qMimeTypeComment = db.mimeTypeForName(mimeType).comment(); +} + void KOpenWithDialogPrivate::init(const QString &_text, const QString &_value) { bool bReadOnly = !KAuthorized::authorize(QStringLiteral("shell_access")); diff --git a/src/widgets/krun.cpp b/src/widgets/krun.cpp --- a/src/widgets/krun.cpp +++ b/src/widgets/krun.cpp @@ -70,7 +70,9 @@ #include #include +#include #include +#include KRunPrivate::KRunPrivate(KRun *parent) : q(parent), @@ -176,101 +178,17 @@ // This is called by foundMimeType, since it knows the mimetype of the URL bool KRun::runUrl(const QUrl &u, const QString &_mimetype, QWidget *window, RunFlags flags, const QString &suggestedFileName, const QByteArray &asn) { - const QMimeDatabase db; const bool runExecutables = flags.testFlag(KRun::RunExecutables); const bool tempFile = flags.testFlag(KRun::DeleteTemporaryFiles); - bool noRun = false; - bool noAuth = false; - if (_mimetype == QLatin1String("application/x-desktop")) { - if (u.isLocalFile() && runExecutables) { - return KDesktopFileActions::runWithStartup(u, true, asn); - } - } else if (isExecutable(_mimetype)) { - // Check whether file is executable script - const QMimeType mime = db.mimeTypeForName(_mimetype); -#ifdef Q_OS_WIN - bool isNativeBinary = !mime.inherits(QStringLiteral("text/plain")); -#else - bool isNativeBinary = !mime.inherits(QStringLiteral("text/plain")) && !mime.inherits(QStringLiteral("application/x-ms-dos-executable")); -#endif - // Only run local files - if (u.isLocalFile() && runExecutables) { - if (KAuthorized::authorize(QStringLiteral("shell_access"))) { - - bool canRun = true; - bool isFileExecutable = hasExecuteBit(u.toLocalFile()); - - // For executables that aren't scripts and without execute bit, - // show prompt asking user if he wants to run the program. - if (!isFileExecutable && isNativeBinary) { - canRun = false; - KIO::WidgetsUntrustedProgramHandler handler; - if (handler.execUntrustedProgramWarning(window, u.fileName())) { - QString errorString; - if (!handler.setExecuteBit(u.toLocalFile(), errorString)) { - KMessageBox::sorry( - window, - i18n("Unable to make file %1 executable.\n%2.", - u.toLocalFile(), errorString) - ); - } else { - canRun = true; - } - } - } else if (!isFileExecutable && !isNativeBinary) { - // Don't try to run scripts/exes without execute bit, instead - // open them with default application - canRun = false; - } - - if (canRun) { - qDebug() << "Execute the URL as a command"; - return (KRun::runCommand(KShell::quoteArg(u.toLocalFile()), QString(), QString(), - window, asn, u.adjusted(QUrl::RemoveFilename).toLocalFile())); // just execute the url as a command - // ## TODO implement deleting the file if tempFile==true - } - - } else { - // Show no permission warning - noAuth = true; - } - } else if (isNativeBinary) { - // Show warning for executables that aren't scripts - noRun = true; - } - } - - if (noRun) { - KMessageBox::sorry(window, - i18n("The file %1 is an executable program. " - "For safety it will not be started.", u.toDisplayString().toHtmlEscaped())); - return false; - } - if (noAuth) { - KMessageBox::error(window, - i18n("You do not have permission to run %1.", u.toDisplayString().toHtmlEscaped())); - return false; - } - - QList lst; - lst.append(u); - KService::Ptr offer = KMimeTypeTrader::self()->preferredService(_mimetype); - - if (!offer) { -#ifdef Q_OS_WIN - // As KDE on windows doesn't know about the windows default applications offers will be empty in nearly all cases. - // So we use QDesktopServices::openUrl to let windows decide how to open the file - return QDesktopServices::openUrl(u); -#else - // Open-with dialog - // TODO : pass the mimetype as a parameter, to show it (comment field) in the dialog ! - // Hmm, in fact KOpenWithDialog::setServiceType already guesses the mimetype from the first URL of the list... - return displayOpenWithDialog(lst, window, tempFile, suggestedFileName, asn); -#endif - } - - return KRun::runApplication(*offer, lst, window, flags, suggestedFileName, asn); + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(u, _mimetype); + job->setSuggestedFileName(suggestedFileName); + job->setStartupId(asn); + job->setRunFlags(tempFile ? KIO::ApplicationLauncherJob::DeleteTemporaryFiles : KIO::ApplicationLauncherJob::RunFlags()); + job->setUiDelegate(new KIO::JobUiDelegate(KJobUiDelegate::AutoHandlingEnabled, window)); + job->setRunExecutables(runExecutables); + job->start(); + return true; } bool KRun::displayOpenWithDialog(const QList &lst, QWidget *window, bool tempFiles, @@ -289,6 +207,9 @@ suggestedFileName, asn); } #endif + + // TODO : pass the mimetype as a parameter, to show it (comment field) in the dialog ! + // Hmm, in fact KOpenWithDialog::setServiceType already guesses the mimetype from the first URL of the list... KOpenWithDialog dialog(lst, QString(), QString(), window); dialog.setWindowModality(Qt::WindowModal); if (dialog.exec()) { @@ -911,7 +832,7 @@ // Resolve .desktop files from media:/, remote:/, applications:/ etc. QMimeType mime = db.mimeTypeForName(type); if (!mime.isValid()) { - qCWarning(KIO_WIDGETS) << "Unknown mimetype " << type; + qCWarning(KIO_WIDGETS) << "Unknown mimetype" << type; } else if (mime.inherits(QStringLiteral("application/x-desktop")) && !d->m_localPath.isEmpty()) { d->m_strURL = QUrl::fromLocalFile(d->m_localPath); } diff --git a/src/widgets/widgetsopenurljobhandler.h b/src/widgets/widgetsopenurljobhandler.h new file mode 100644 --- /dev/null +++ b/src/widgets/widgetsopenurljobhandler.h @@ -0,0 +1,51 @@ +/* This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#ifndef WIDGETSOPENURLJOBHANDLER_H +#define WIDGETSOPENURLJOBHANDLER_H + +#include "openurljobhandlerinterface.h" + +class QDialog; +class QWidget; + +namespace KIO { + +// TODO KF6: Make KIO::JobUiDelegate inherit from WidgetsOpenUrlJobHandler +// (or even merge the two classes) +// so that setDelegate(new KIO::JobUiDelegate) provides both dialog boxes on error +// and the open with dialog. + +class WidgetsOpenUrlJobHandler : public OpenUrlJobHandlerInterface +{ +public: + WidgetsOpenUrlJobHandler(); + ~WidgetsOpenUrlJobHandler() override; + + void promptUserForApplication(KIO::OpenUrlJob *job, const QUrl &url, const QString &mimeType) override; + +private: + class Private; + Private *const d; +}; + +} + +#endif // WIDGETSOPENURLJOBHANDLER_H diff --git a/src/widgets/widgetsopenurljobhandler.cpp b/src/widgets/widgetsopenurljobhandler.cpp new file mode 100644 --- /dev/null +++ b/src/widgets/widgetsopenurljobhandler.cpp @@ -0,0 +1,57 @@ +/* This file is part of the KDE libraries + Copyright (c) 2020 David Faure + + This library is free software; you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; either version 2 of the License or ( at + your option ) version 3 or, at the discretion of KDE e.V. ( which shall + act as a proxy as in section 14 of the GPLv3 ), any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "kopenwithdialog.h" +#include "openurljob.h" +#include "widgetsopenurljobhandler.h" + +#include +#include +#include + +#include + +KIO::WidgetsOpenUrlJobHandler::WidgetsOpenUrlJobHandler() + : KIO::OpenUrlJobHandlerInterface(), d(nullptr) +{ +} + +KIO::WidgetsOpenUrlJobHandler::~WidgetsOpenUrlJobHandler() +{ +} + +void KIO::WidgetsOpenUrlJobHandler::promptUserForApplication(KIO::OpenUrlJob *job, const QUrl &url, const QString &mimeType) +{ + QWidget *parentWidget = job ? KJobWidgets::window(job) : qApp->activeWindow(); + + KOpenWithDialog *dialog = new KOpenWithDialog({url}, mimeType, QString(), QString(), parentWidget); + dialog->setAttribute(Qt::WA_DeleteOnClose); + connect(dialog, &QDialog::accepted, this, [=]() { + KService::Ptr service = dialog->service(); + if (!service) { + service = KService::Ptr(new KService(QString() /*name*/, dialog->text(), QString() /*icon*/)); + } + Q_EMIT serviceSelected(service); + }); + connect(dialog, &QDialog::rejected, this, [this]() { + Q_EMIT canceled(); + }); + dialog->show(); +}