diff --git a/autotests/openurljobtest.cpp b/autotests/openurljobtest.cpp index 8ba8455c..47f7ad60 100644 --- a/autotests/openurljobtest.cpp +++ b/autotests/openurljobtest.cpp @@ -1,454 +1,532 @@ /* 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 #include QTEST_GUILESS_MAIN(OpenUrlJobTest) extern KSERVICE_EXPORT int ksycoca_ms_between_checks; namespace KIO { KIOGUI_EXPORT void setDefaultUntrustedProgramHandler(KIO::UntrustedProgramHandlerInterface *iface); +KIOGUI_EXPORT void setDefaultOpenUrlJobHandler(KIO::OpenUrlJobHandlerInterface *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; +class TestOpenUrlJobHandler : public KIO::OpenUrlJobHandlerInterface +{ +public: + void promptUserForApplication(KIO::OpenUrlJob *job, const QUrl &url, const QString &mimeType) override + { + Q_UNUSED(job); + m_urls << url; + m_mimeTypes << mimeType; + if (m_chosenService) { + Q_EMIT serviceSelected(m_chosenService); + } else { + Q_EMIT canceled(); + } + } + QList m_urls; + QStringList m_mimeTypes; + KService::Ptr m_chosenService; +}; +static TestOpenUrlJobHandler s_openUrlJobHandler; + 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 QString fakeService = QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation) + QLatin1Char('/') + s_tempServiceName; writeApplicationDesktopFile(fakeService); fakeService = QFileInfo(fakeService).canonicalFilePath(); 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() { for (const QString &file : m_filesToRemove) { QFile::remove(file); }; } 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 loadUrlActionRestrictions(const KConfigGroup &cg); 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(); loadUrlActionRestrictions(cg); 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(); loadUrlActionRestrictions(cg); } 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(), "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(), "Could not connect to host 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(job->error(), KJob::KilledJobError); QCOMPARE(foundMime, "image/jpeg"); } +void OpenUrlJobTest::onlyOpenWithDialog_data() +{ + QTest::addColumn("knownMimeType"); + QTest::addColumn("handlerRetVal"); + + QTest::newRow("false_knownMimeType") << true << false; + QTest::newRow("true_knownMimeType") << true << true; + + QTest::newRow("false_unknownMimeType") << false << false; + QTest::newRow("true_unknownMimeType") << false << true; +} + +void OpenUrlJobTest::onlyOpenWithDialog() +{ +#ifdef Q_OS_UNIX + QFETCH(bool, knownMimeType); + QFETCH(bool, handlerRetVal); + + // Given a local text file + QTemporaryDir tempDir; + const QString srcDir = tempDir.path(); + const QString srcFile = srcDir + QLatin1String("/file.txt"); + createSrcFile(srcFile); + + const QString mimeType = knownMimeType ? "text/plain" : ""; + KIO::OpenUrlJob *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(srcFile), mimeType, this); + job->setShowOpenWithDialog(true); + + KService::Ptr service = KService::serviceByDesktopName(QString(s_tempServiceName).remove(".desktop")); + QVERIFY(service); + s_openUrlJobHandler.m_urls.clear(); + s_openUrlJobHandler.m_mimeTypes.clear(); + s_openUrlJobHandler.m_chosenService = handlerRetVal ? service : KService::Ptr{}; + KIO::setDefaultOpenUrlJobHandler(&s_openUrlJobHandler); + + const bool success = job->exec(); + + // Then --- it depends on what the user says via the handler + + QCOMPARE(s_openUrlJobHandler.m_urls.count(), 1); + QCOMPARE(s_openUrlJobHandler.m_mimeTypes.count(), 1); + QCOMPARE(s_openUrlJobHandler.m_mimeTypes.at(0), "text/plain"); + if (handlerRetVal) { + QVERIFY2(success, qPrintable(job->errorString())); + // If the user chose a service, it should be executed (it writes to "dest") + const QString dest = m_tempDir.path() + "/dest"; + QTRY_VERIFY2(QFile::exists(dest), qPrintable(dest)); + QCOMPARE(readFile(dest), srcFile); + } else { + QVERIFY(!success); + QCOMPARE(job->error(), KIO::ERR_USER_CANCELED); + } +#else + QSKIP("Test skipped on Windows because the code ends up in QDesktopServices::openUrl") +#endif +} + 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/autotests/openurljobtest.h b/autotests/openurljobtest.h index d6d0a632..9cbfa7b5 100644 --- a/autotests/openurljobtest.h +++ b/autotests/openurljobtest.h @@ -1,67 +1,69 @@ /* 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(); + void onlyOpenWithDialog_data(); + void onlyOpenWithDialog(); private: void writeApplicationDesktopFile(const QString &filePath); QStringList m_filesToRemove; QTemporaryDir m_tempDir; }; #endif /* OPENURLJOBTEST_H */ diff --git a/src/gui/openurljob.cpp b/src/gui/openurljob.cpp index c660519e..20cf4755 100644 --- a/src/gui/openurljob.cpp +++ b/src/gui/openurljob.cpp @@ -1,647 +1,658 @@ /* 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) { q->setCapabilities(KJob::Killable); } 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 * const q; QString m_suggestedFileName; QByteArray m_startupId; QString m_mimeTypeName; KService::Ptr m_preferredService; bool m_deleteTemporaryFile = false; bool m_runExecutables = false; bool m_externalBrowserEnabled = true; bool m_followRedirections = true; + bool m_showOpenWithDialog = false; 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::setDeleteTemporaryFile(bool b) { d->m_deleteTemporaryFile = b; } 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; } +void KIO::OpenUrlJob::setShowOpenWithDialog(bool b) +{ + d->m_showOpenWithDialog = 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.toDisplayString(); 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; } } 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(); } bool KIO::OpenUrlJob::doKill() { return true; } 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::statFile() { 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(KIO::buildErrorString(errCode, job->errorText())); } q->emitResult(); return; } 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; } if (entry.isDir()) { 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_deleteTemporaryFile ? KIO::ApplicationLauncherJob::DeleteTemporaryFiles : KIO::ApplicationLauncherJob::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_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) const QMimeType mime = fixupMimeType(m_mimeTypeName, m_suggestedFileName.isEmpty() ? m_url.fileName() : m_suggestedFileName); const QString mimeName = mime.name(); if (mime.isValid() && mimeName != m_mimeTypeName) { m_mimeTypeName = mimeName; } 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 // 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) { return QFileInfo(fileName).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 an 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())); 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; } + if (m_showOpenWithDialog) { + showOpenWithDialog(); + 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; const 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 const int errCode = job->error(); if (errCode) { setError(errCode); setErrorText(KIO::buildErrorString(errCode, job->errorText())); } emitResult(); } diff --git a/src/gui/openurljob.h b/src/gui/openurljob.h index 599212de..165385d4 100644 --- a/src/gui/openurljob.h +++ b/src/gui/openurljob.h @@ -1,140 +1,155 @@ /* 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 an 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 an 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 by default jobs auto-delete themselves after emitting result. */ ~OpenUrlJob() override; /** * Specifies that the URL passed to the application will be deleted when it exits (if the URL is a local file) + * The default is false. */ void setDeleteTemporaryFile(bool b); /** * Sets the file name to use in the case of downloading the file to a tempfile, * in order to give it 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. + * Unlike KF5's KRun, this setting is false 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 let the external browser handle the URL 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); + /** + * Sets whether the job should right away show the "open with" dialog, + * without checking for executables, or for associated applications. + * + * Compared to using KOpenWithDialog directly, this takes care of determining + * the mimetype first (if not passed to the constructor), and it allows for + * a different implementation on Windows. + * + * The default is false. + * + * @param b whether to only show the "open with" dialog + */ + void setShowOpenWithDialog(bool b); + /** * Starts the job. * You must call this, after having called all the needed setters. * This is a GUI job, never use exec(), it would block user interaction. */ void start() override; Q_SIGNALS: /** * Emitted when the mimeType is 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); protected: bool doKill() override; private: void slotResult(KJob *job) override; friend class OpenUrlJobPrivate; QScopedPointer d; }; } // namespace KIO #endif // OPENURLJOB_H