diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,11 @@ # Dependencies set(REQUIRED_QT_VERSION 5.10.0) +find_package(Qt5 ${REQUIRED_QT_VERSION} CONFIG COMPONENTS Gui) +set_package_properties(Qt5Gui PROPERTIES DESCRIPTION "Qt 5: Gui component" + TYPE OPTIONAL + PURPOSE "Qt5::Gui is needed to build the AppImage extractor") + find_package(Qt5 ${REQUIRED_QT_VERSION} REQUIRED NO_MODULE COMPONENTS Xml) find_package(KF5 ${KF5_DEP_VERSION} COMPONENTS Archive) @@ -45,6 +50,11 @@ URL "https://download.kde.org/stable/frameworks/" TYPE REQUIRED PURPOSE "Needed for the formatting of properties for display purposes") +find_package(KF5 ${KF5_DEP_VERSION} COMPONENTS Config) +set_package_properties(KF5Config PROPERTIES DESCRIPTION "KDE Frameworks 5: Config Framework" + URL "https://download.kde.org/stable/frameworks/" TYPE OPTIONAL + PURPOSE "Config is needed to build the AppImage extractor") + find_package(KF5 ${KF5_DEP_VERSION} REQUIRED COMPONENTS I18n) find_package(Poppler 0.12.1 COMPONENTS Qt5) @@ -97,6 +107,13 @@ # URL "https://projects.kde.org/projects/kde/kdegraphics/kdegraphics-mobipocket" # TYPE OPTIONAL PURPOSE "Support for mobi metadata") +find_package(libappimage 0.1.10 CONFIG) +set_package_properties(libappimage PROPERTIES DESCRIPTION "Core library of the AppImage project" + URL "https://github.com/AppImage/libappimage" + TYPE OPTIONAL + PURPOSE "Needed to build the AppImage extractor" + ) + add_definitions(-DTRANSLATION_DOMAIN=\"kfilemetadata5\") add_subdirectory(src) diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -176,6 +176,22 @@ LINK_LIBRARIES Qt5::Test KF5::FileMetaData ) +# +# AppImage +# +if(libappimage_FOUND AND KF5Config_FOUND AND Qt5Gui_FOUND) + ecm_add_test(appimageextractortest.cpp ../src/extractors/appimageextractor.cpp + TEST_NAME "appimageextractortest" + LINK_LIBRARIES + Qt5::Test + KF5::FileMetaData + KF5::ConfigCore + Qt5::Xml + Qt5::Gui + libappimage + ) +endif() + ################ # Writer tests # ################ diff --git a/autotests/appimageextractortest.h b/autotests/appimageextractortest.h new file mode 100644 --- /dev/null +++ b/autotests/appimageextractortest.h @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2019 Friedrich W. H. Kossebau + * + * 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.1 of the License, or (at your option) 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +#ifndef APPIMAGEEXTRACTORTEST_H +#define APPIMAGEEXTRACTORTEST_H + +#include + +class AppImageExtractorTest : public QObject +{ + Q_OBJECT +private: + QString testFilePath(const QString& fileName) const; + +private Q_SLOTS: + void test(); +}; + +#endif diff --git a/autotests/appimageextractortest.cpp b/autotests/appimageextractortest.cpp new file mode 100644 --- /dev/null +++ b/autotests/appimageextractortest.cpp @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2019 Friedrich W. H. Kossebau + * + * 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.1 of the License, or (at your option) 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +#include "appimageextractortest.h" + +// Qt +#include +#include + +#include "simpleextractionresult.h" +#include "indexerextractortestsconfig.h" +#include "extractors/appimageextractor.h" + +using namespace KFileMetaData; + +QString AppImageExtractorTest::testFilePath(const QString& fileName) const +{ + return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH) + QLatin1Char('/') + fileName; +} + +/* +The test.AppImage got created by: + +wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage +chmod +x linuxdeploy-x86_64.AppImage +./linuxdeploy-x86_64.AppImage --appdir AppDir --output appimage --executable=/usr/bin/echo --desktop-file=appimagetest.desktop --icon-file=/usr/share/icons/oxygen/base/16x16/apps/kde.png +mkdir AppDir/usr/share/metainfo/ +cp org.kde.kfilemetadata.appimagetest.appdata.xml AppDir/usr/share/metainfo/ +./linuxdeploy-x86_64.AppImage --appdir AppDir --output appimage +mv Test_Desktop_Name-x86_64.AppImage test.AppImage + +To edit the appdata.xml & desktop file, run ./test.AppImage --appimage-extract to get the files. +Use linuxdeploy to create the appimage then again. +*/ + +void AppImageExtractorTest::test() +{ + AppImageExtractor plugin{this}; + + SimpleExtractionResult result(testFilePath(QStringLiteral("test.AppImage")), QStringLiteral("application/vnd.appimage")); + plugin.extract(&result); + + QCOMPARE(result.types().size(), 0); + + QCOMPARE(result.properties().value(Property::Author), QVariant(QStringLiteral("Konqi"))); + QCOMPARE(result.properties().value(Property::Title), QVariant(QStringLiteral("Test Desktop Name"))); + QCOMPARE(result.properties().value(Property::Comment), QVariant(QStringLiteral("Test Desktop Comment"))); + QCOMPARE(result.properties().value(Property::Description), QVariant(QStringLiteral("Test description line 1.\nTest description line 2."))); + QCOMPARE(result.properties().value(Property::License), QVariant(QStringLiteral("GPL-2.0"))); + +} + +QTEST_GUILESS_MAIN(AppImageExtractorTest) diff --git a/autotests/samplefiles/test.AppImage b/autotests/samplefiles/test.AppImage new file mode 100755 index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 GIT binary patch literal 0 Hc$@ + + 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.1 of the License, or (at your option) 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef APPIMAGEEXTRACTOR_H +#define APPIMAGEEXTRACTOR_H + +#include "extractorplugin.h" + +namespace KFileMetaData +{ + +class AppImageExtractor : public ExtractorPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.kde.kf5.kfilemetadata.ExtractorPlugin") + Q_INTERFACES(KFileMetaData::ExtractorPlugin) + +public: + explicit AppImageExtractor(QObject* parent = nullptr); + +public: + void extract(ExtractionResult* result) override; + QStringList mimetypes() const override; +}; + +} + +#endif diff --git a/src/extractors/appimageextractor.cpp b/src/extractors/appimageextractor.cpp new file mode 100644 --- /dev/null +++ b/src/extractors/appimageextractor.cpp @@ -0,0 +1,302 @@ +/* + Copyright (C) 2019 Friedrich W. H. Kossebau + + 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.1 of the License, or (at your option) 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "appimageextractor.h" + +// KF +#include +// Qt +#include +#include +#include +#include +#include +// libappimage +#include + +using namespace KFileMetaData; + + +namespace { +namespace AttributeNames { +QString xml_lang() { return QStringLiteral("xml:lang"); } +} +} + + +// helper class to extract the interesting data from the appdata file +// prefers localized strings over unlocalized, using system locale +class AppDataParser +{ +public: + AppDataParser(const char* appImageFilePath, const QString& appdataFilePath); + +public: + QString summary() const { return !m_localized.summary.isEmpty() ? m_localized.summary : m_unlocalized.summary; } + QString description() const { return !m_localized.description.isEmpty() ? m_localized.description : m_unlocalized.description; } + QString developerName() const { return !m_localized.developerName.isEmpty() ? m_localized.developerName : m_unlocalized.developerName; } + QString projectLicense() const { return m_projectLicense; } + +private: + void extractDescription(const QDomElement& e, const QString& localeName); + +private: + struct Data { + QString summary; + QString description; + QString developerName; + }; + Data m_localized; + Data m_unlocalized; + QString m_projectLicense; +}; + + +AppDataParser::AppDataParser(const char* appImageFilePath, const QString& appdataFilePath) +{ + if (appdataFilePath.isEmpty()) { + return; + } + + unsigned long size = 0L; + char* buf = nullptr; + bool ok = appimage_read_file_into_buffer_following_symlinks(appImageFilePath, + qUtf8Printable(appdataFilePath), + &buf, + &size); + + QScopedPointer cleanup(buf); + + if (!ok) { + return; + } + + QDomDocument domDocument; + if (!domDocument.setContent(QByteArray::fromRawData(buf, size))) { + return; + } + + QDomElement docElem = domDocument.documentElement(); + if (docElem.tagName() != QLatin1String("component")) { + return; + } + + const auto localeName = QLocale::system().bcp47Name(); + + QDomElement ec = docElem.firstChildElement(); + while (!ec.isNull()) { + const auto tagName = ec.tagName(); + const auto hasLangAttribute = ec.hasAttribute(AttributeNames::xml_lang()); + const auto matchingLocale = hasLangAttribute && (ec.attribute(AttributeNames::xml_lang()) == localeName); + if (matchingLocale || !hasLangAttribute) { + if (tagName == QLatin1String("summary")) { + Data& data = hasLangAttribute ? m_localized : m_unlocalized; + data.summary = ec.text(); + } else if (tagName == QLatin1String("description")) { + extractDescription(ec, localeName); + } else if (tagName == QLatin1String("developer_name")) { + Data& data = hasLangAttribute ? m_localized : m_unlocalized; + data.developerName = ec.text(); + } else if (tagName == QLatin1String("project_license")) { + m_projectLicense = ec.text(); + } + } + ec = ec.nextSiblingElement(); + } +} + +using DesriptionDomFilter = std::function; + +void stripDescriptionTextElements(QDomElement& element, const DesriptionDomFilter& stripFilter) +{ + auto childElement = element.firstChildElement(); + while (!childElement.isNull()) { + auto nextChildElement = childElement.nextSiblingElement(); + + const auto tagName = childElement.tagName(); + const bool isElementToFilter = (tagName == QLatin1String("p")) || (tagName == QLatin1String("li")); + if (isElementToFilter && stripFilter(childElement)) { + element.removeChild(childElement); + } else { + stripDescriptionTextElements(childElement, stripFilter); + } + + childElement = nextChildElement; + } +} + +void AppDataParser::extractDescription(const QDomElement& e, const QString& localeName) +{ + // create fake html from it and let QTextDocument transform it to plain text for us + QDomDocument descriptionDocument; + auto htmlElement = descriptionDocument.createElement(QStringLiteral("html")); + descriptionDocument.appendChild(htmlElement); + + // first localized... + auto clonedE = descriptionDocument.importNode(e, true).toElement(); + clonedE.setTagName(QStringLiteral("body")); + stripDescriptionTextElements(clonedE, [localeName](const QDomElement& e) { + return !e.hasAttribute(AttributeNames::xml_lang()) || + e.attribute(AttributeNames::xml_lang()) != localeName; + }); + htmlElement.appendChild(clonedE); + + QTextDocument textDocument; + textDocument.setHtml(descriptionDocument.toString(-1)); + + m_localized.description = textDocument.toPlainText().trimmed(); + + if (!m_localized.description.isEmpty()) { + // localized will be preferred, no need to calculate unlocalized one + return; + } + + // then unlocalized if still needed + htmlElement.removeChild(clonedE); // reuse descriptionDocument + clonedE = descriptionDocument.importNode(e, true).toElement(); + clonedE.setTagName(QStringLiteral("body")); + stripDescriptionTextElements(clonedE, [](const QDomElement& e) { + return e.hasAttribute(AttributeNames::xml_lang()); + }); + htmlElement.appendChild(clonedE); + + textDocument.setHtml(descriptionDocument.toString(-1)); + + m_unlocalized.description = textDocument.toPlainText().trimmed(); +} + + +// helper class to extract the interesting data from the desktop file +class DesktopFileParser +{ +public: + DesktopFileParser(const char* appImageFilePath, const QString& desktopFilePath); + +public: + QString name; + QString comment; +}; + + +DesktopFileParser::DesktopFileParser(const char* appImageFilePath, const QString& desktopFilePath) +{ + if (desktopFilePath.isEmpty()) { + return; + } + + unsigned long size = 0L; + char* buf = nullptr; + bool ok = appimage_read_file_into_buffer_following_symlinks(appImageFilePath, + qUtf8Printable(desktopFilePath), + &buf, + &size); + + QScopedPointer cleanup(buf); + + if (!ok) { + return; + } + + // create real file, KDesktopFile needs that + QTemporaryFile tmpDesktopFile; + tmpDesktopFile.open(); + tmpDesktopFile.write(buf, size); + tmpDesktopFile.close(); + + KDesktopFile desktopFile(tmpDesktopFile.fileName()); + name = desktopFile.readName(); + comment = desktopFile.readComment(); +} + + +AppImageExtractor::AppImageExtractor(QObject* parent) + : ExtractorPlugin(parent) +{ +} + +QStringList AppImageExtractor::mimetypes() const +{ + return QStringList{ + QStringLiteral("application/x-iso9660-appimage"), + QStringLiteral("application/vnd.appimage"), + }; +} + +void KFileMetaData::AppImageExtractor::extract(ExtractionResult* result) +{ + const auto appImageFilePath = result->inputUrl().toUtf8(); + const auto appImageType = appimage_get_type(appImageFilePath.constData(), false); + // not a valid appimage file? + if (appImageType <= 0) { + return; + } + + // find desktop file and appdata file + // need to scan ourselves, given there are no fixed names in the spec yet defined + // and we just can try as the other appimage tools to simply use the first file of the type found + char** filePaths = appimage_list_files(appImageFilePath.constData()); + if (!filePaths) { + return; + } + + QString desktopFilePath; + QString appdataFilePath; + for (int i = 0; filePaths[i] != nullptr; ++i) { + const auto filePath = QString::fromUtf8(filePaths[i]); + + if (filePath.startsWith(QLatin1String("usr/share/metainfo/")) && + filePath.endsWith(QLatin1String(".appdata.xml"))) { + appdataFilePath = filePath; + if (!desktopFilePath.isEmpty()) { + break; + } + } + + if (filePath.endsWith(QLatin1String(".desktop")) && !filePath.contains(QLatin1Char('/'))) { + desktopFilePath = filePath; + if (!appdataFilePath.isEmpty()) { + break; + } + } + } + + appimage_string_list_free(filePaths); + + // extract data from both files... + const AppDataParser appData(appImageFilePath.constData(), appdataFilePath); + + const DesktopFileParser desktopFileData(appImageFilePath.constData(), desktopFilePath); + + // ... and insert into the result + result->add(Property::Title, desktopFileData.name); + + if (!desktopFileData.comment.isEmpty()) { + result->add(Property::Comment, desktopFileData.comment); + } else if (!appData.summary().isEmpty()) { + result->add(Property::Comment, appData.summary()); + } + if (!appData.description().isEmpty()) { + result->add(Property::Description, appData.description()); + } + if (!appData.projectLicense().isEmpty()) { + result->add(Property::License, appData.projectLicense()); + } + if (!appData.developerName().isEmpty()) { + result->add(Property::Author, appData.developerName()); + } +}