diff --git a/CHANGES b/CHANGES index c84f245b0..d41d4b8fa 100644 --- a/CHANGES +++ b/CHANGES @@ -18,12 +18,25 @@ This release contains contributions from (alphabetically by first name): number of jobs. (Thanks to Bill Auger) - Preliminary work has been added to post the installation log to a pastebin for bug reporting. (Thanks to Bill Auger) + - Support for translated human-readable strings in Calamares + config files has been added. This is used only in the *packagechooser* + module (see below) but will expand to those modules that need + user-visible strings from the configuration file (existing + solutions need either gettext or Qt support). + - Esperanto is now available when Qt version 5.12.2 or later is used. ## Modules ## - *fstab* A new configuration key *efiMountOptions* has been added, to allow setting filesystem options specifically for the EFI partition. (Thanks to apt-ghetto) + - *packagechooser* is a new module for low-density package choices, + e.g. for selecting a default desktop environment, or adding some + proprietary drivers, or chosing browsers of office suites. It presents + **one** collection of items -- at most ten or so, because of the UI -- + and the user can select zero or more of them. The behavior is + configurable, and package information can be set through the Calamares + configuration file or by reading AppData files for the packages. #426 # 3.2.11 (2019-07-06) # diff --git a/io.calamares.calamares.appdata.xml b/io.calamares.calamares.appdata.xml new file mode 100644 index 000000000..355f485c2 --- /dev/null +++ b/io.calamares.calamares.appdata.xml @@ -0,0 +1,29 @@ + + + io.calamares.calamares.desktop + CC0-1.0 + GPL-3.0+ + Calamares + Calamares + Calamares + Calamares + Calamares Linux Installer + Calamares Linux Installer + Linux Installatieprogramma Calamares + +

Calamares is an installer program for Linux distributions.

+

Calamares is een installatieprogramma voor Linux distributies.

+
+ https://calamares.io + https://https://github.com/calamares/calamares/issues/ + https://github.com/calamares/calamares/wiki + + + Calamares Welcome + https://calamares.io/images/cal_640.png + + + + calamares + +
diff --git a/src/libcalamares/locale/Tests.cpp b/src/libcalamares/locale/Tests.cpp index 664390511..fa84cd2fb 100644 --- a/src/libcalamares/locale/Tests.cpp +++ b/src/libcalamares/locale/Tests.cpp @@ -96,6 +96,10 @@ someLanguages() } +/** @brief Check consistency of test data + * Check that all the languages used in testing, are actually enabled + * in Calamares translations. + */ void LocaleTests::testTranslatableLanguages() { @@ -108,12 +112,19 @@ LocaleTests::testTranslatableLanguages() } } +/** @brief Test strings with no translations + */ void LocaleTests::testTranslatableConfig1() { + CalamaresUtils::Locale::TranslatedString ts0; + QVERIFY( ts0.isEmpty() ); + QCOMPARE( ts0.count(), 1 ); // the empty string + QCOMPARE( QLocale().name(), "C" ); // Otherwise plain get() is dubious CalamaresUtils::Locale::TranslatedString ts1( "Hello" ); QCOMPARE( ts1.count(), 1 ); + QVERIFY( !ts1.isEmpty() ); QCOMPARE( ts1.get(), "Hello" ); QCOMPARE( ts1.get( QLocale( "nl" ) ), "Hello" ); @@ -122,11 +133,14 @@ LocaleTests::testTranslatableConfig1() map.insert( "description", "description (no language)" ); CalamaresUtils::Locale::TranslatedString ts2( map, "description" ); QCOMPARE( ts2.count(), 1 ); + QVERIFY( !ts2.isEmpty() ); QCOMPARE( ts2.get(), "description (no language)" ); QCOMPARE( ts2.get( QLocale( "nl" ) ), "description (no language)" ); } +/** @bref Test strings with translations. + */ void LocaleTests::testTranslatableConfig2() { @@ -143,11 +157,22 @@ LocaleTests::testTranslatableConfig2() } } + // If there's no untranslated string in the map, it is considered empty + CalamaresUtils::Locale::TranslatedString ts0( map, "description" ); + QVERIFY( ts0.isEmpty() ); // Because no untranslated string + QCOMPARE( ts0.count(), + someLanguages().count() + 1 ); // But there are entries for the translations, plus an empty string + + // expand the map with untranslated entries + map.insert( QString( "description" ), "description (no language)" ); + map.insert( QString( "name" ), "name (no language)" ); + CalamaresUtils::Locale::TranslatedString ts1( map, "description" ); // The +1 is because "" is always also inserted QCOMPARE( ts1.count(), someLanguages().count() + 1 ); + QVERIFY( !ts1.isEmpty() ); - QCOMPARE( ts1.get(), "description" ); // it wasn't set + QCOMPARE( ts1.get(), "description (no language)" ); // it wasn't set QCOMPARE( ts1.get( QLocale( "nl" ) ), "description (language nl)" ); for ( const auto& language : someLanguages() ) { @@ -167,4 +192,10 @@ LocaleTests::testTranslatableConfig2() CalamaresUtils::Locale::TranslatedString ts2( map, "name" ); // We skipped dutch this time QCOMPARE( ts2.count(), someLanguages().count() ); + QVERIFY( !ts2.isEmpty() ); + + // This key doesn't exist + CalamaresUtils::Locale::TranslatedString ts3( map, "front" ); + QVERIFY( ts3.isEmpty() ); + QCOMPARE( ts3.count(), 1 ); // The empty string } diff --git a/src/libcalamares/locale/TranslatableConfiguration.cpp b/src/libcalamares/locale/TranslatableConfiguration.cpp index b3b5259c9..7493c836c 100644 --- a/src/libcalamares/locale/TranslatableConfiguration.cpp +++ b/src/libcalamares/locale/TranslatableConfiguration.cpp @@ -38,10 +38,6 @@ TranslatedString::TranslatedString( const QVariantMap& map, const QString& key ) { // Get the un-decorated value for the key QString value = CalamaresUtils::getString( map, key ); - if ( value.isEmpty() ) - { - value = key; - } m_strings[ QString() ] = value; for ( auto it = map.constKeyValueBegin(); it != map.constKeyValueEnd(); ++it ) diff --git a/src/libcalamares/locale/TranslatableConfiguration.h b/src/libcalamares/locale/TranslatableConfiguration.h index b2f598069..a055cbfbd 100644 --- a/src/libcalamares/locale/TranslatableConfiguration.h +++ b/src/libcalamares/locale/TranslatableConfiguration.h @@ -46,9 +46,23 @@ public: TranslatedString( const QString& string ); /// @brief Empty string TranslatedString() - : TranslatedString( QString() ) {} + : TranslatedString( QString() ) + { + } + /** @brief How many strings (translations) are there? + * + * This is always at least 1 (for the untranslated string), + * but may be more than 1 even when isEmpty() is true -- + * if there is no untranslated version, for instance. + */ int count() const { return m_strings.count(); } + /** @brief Consider this string empty? + * + * Only the state of the untranslated string is considered, + * so count() may be more than 1 even while the string is empty. + */ + bool isEmpty() const { return m_strings[ QString() ].isEmpty(); } /// @brief Gets the string in the current locale QString get() const; diff --git a/src/modules/packagechooser/CMakeLists.txt b/src/modules/packagechooser/CMakeLists.txt index 4663ccce7..70a86a3bb 100644 --- a/src/modules/packagechooser/CMakeLists.txt +++ b/src/modules/packagechooser/CMakeLists.txt @@ -1,4 +1,15 @@ find_package( Qt5 COMPONENTS Core Gui Widgets REQUIRED ) +set( _extra_libraries "" ) + +### OPTIONAL AppData XML support in PackageModel +# +# +find_package(Qt5 COMPONENTS Xml) +if ( Qt5Xml_FOUND ) + add_definitions( -DHAVE_XML ) + list( APPEND _extra_libraries Qt5::Xml ) +endif() + calamares_add_plugin( packagechooser TYPE viewmodule @@ -13,6 +24,7 @@ calamares_add_plugin( packagechooser page_package.ui LINK_PRIVATE_LIBRARIES calamaresui + ${_extra_libraries} SHARED_LIB ) @@ -23,8 +35,11 @@ if( ECM_FOUND AND BUILD_TESTING ) packagechoosertest LINK_LIBRARIES ${CALAMARES_LIBRARIES} + calamares_viewmodule_packagechooser Qt5::Core Qt5::Test + Qt5::Gui + ${_extra_libraries} ) calamares_automoc( packagechoosertest) endif() diff --git a/src/modules/packagechooser/PackageChooserViewStep.cpp b/src/modules/packagechooser/PackageChooserViewStep.cpp index 4476eb9e6..6df785a06 100644 --- a/src/modules/packagechooser/PackageChooserViewStep.cpp +++ b/src/modules/packagechooser/PackageChooserViewStep.cpp @@ -232,31 +232,14 @@ PackageChooserViewStep::fillModel( const QVariantList& items ) continue; } - QString id = CalamaresUtils::getString( item_map, "id" ); - QString package = CalamaresUtils::getString( item_map, "package" ); - QString name = CalamaresUtils::getString( item_map, "name" ); - QString description = CalamaresUtils::getString( item_map, "description" ); - QString screenshot = CalamaresUtils::getString( item_map, "screenshot" ); - - if ( name.isEmpty() && id.isEmpty() ) + if ( item_map.contains( "appdata" ) ) { - name = tr( "No product" ); + m_model->addPackage( PackageItem::fromAppData( item_map ) ); } - else if ( name.isEmpty() ) + else { - cWarning() << "PackageChooser item" << id << "has an empty name."; - continue; + m_model->addPackage( PackageItem( item_map ) ); } - if ( description.isEmpty() ) - { - description = tr( "No description provided." ); - } - if ( screenshot.isEmpty() ) - { - screenshot = QStringLiteral( ":/images/no-selection.png" ); - } - - m_model->addPackage( PackageItem { id, package, name, description, screenshot } ); } } diff --git a/src/modules/packagechooser/PackageModel.cpp b/src/modules/packagechooser/PackageModel.cpp index f133f4fbd..59c6973ba 100644 --- a/src/modules/packagechooser/PackageModel.cpp +++ b/src/modules/packagechooser/PackageModel.cpp @@ -19,6 +19,13 @@ #include "PackageModel.h" #include "utils/Logger.h" +#include "utils/Variant.h" + +#ifdef HAVE_XML +#include +#include +#include +#endif const NamedEnumTable< PackageChooserMode >& roleNames() @@ -40,13 +47,6 @@ roleNames() return names; } -PackageItem -PackageItem::fromAppStream( const QString& filename ) -{ - // TODO: implement this - return PackageItem {}; -} - PackageItem::PackageItem() {} PackageItem::PackageItem( const QString& a_id, @@ -73,6 +73,239 @@ PackageItem::PackageItem( const QString& a_id, { } +PackageItem::PackageItem::PackageItem( const QVariantMap& item_map ) + : id( CalamaresUtils::getString( item_map, "id" ) ) + , package( CalamaresUtils::getString( item_map, "package" ) ) + , name( CalamaresUtils::Locale::TranslatedString( item_map, "name" ) ) + , description( CalamaresUtils::Locale::TranslatedString( item_map, "description" ) ) + , screenshot( CalamaresUtils::getString( item_map, "screenshot" ) ) +{ + if ( name.isEmpty() && id.isEmpty() ) + { + name = QObject::tr( "No product" ); + } + else if ( name.isEmpty() ) + { + cWarning() << "PackageChooser item" << id << "has an empty name."; + } + if ( description.isEmpty() ) + { + description = QObject::tr( "No description provided." ); + } +} + +#ifdef HAVE_XML +/** @brief try to load the given @p fileName XML document + * + * Returns a QDomDocument, which will be valid iff the file can + * be read and contains valid XML data. + */ +static inline QDomDocument +loadAppData( const QString& fileName ) +{ + QFile file( fileName ); + if ( !file.open( QIODevice::ReadOnly ) ) + { + return QDomDocument(); + } + QDomDocument doc( "AppData" ); + if ( !doc.setContent( &file ) ) + { + file.close(); + return QDomDocument(); + } + file.close(); + return doc; +} + +/** @brief gets the text of child element @p tagName + */ +static inline QString +getChildText( const QDomNode& n, const QString& tagName ) +{ + QDomElement e = n.firstChildElement( tagName ); + return e.isNull() ? QString() : e.text(); +} + +/** @brief Gets a suitable screenshot path + * + * The element contains zero or more + * elements, which can have a *type* associated with them. + * Scan the screenshot elements, return the path + * for the one labeled with type=default or, if there is no + * default, the first element. + */ +static inline QString +getScreenshotPath( const QDomNode& n ) +{ + QDomElement shotsNode = n.firstChildElement( "screenshots" ); + if ( shotsNode.isNull() ) + { + return QString(); + } + + const QDomNodeList shotList = shotsNode.childNodes(); + int firstScreenshot = -1; // Use which screenshot node? + for ( int i = 0; i < shotList.count(); ++i ) + { + if ( !shotList.at( i ).isElement() ) + { + continue; + } + QDomElement e = shotList.at( i ).toElement(); + if ( e.tagName() != "screenshot" ) + { + continue; + } + // If none has the "type=default" attribute, use the first one + if ( firstScreenshot < 0 ) + { + firstScreenshot = i; + } + // But type=default takes precedence. + if ( e.hasAttribute( "type" ) && e.attribute( "type" ) == "default" ) + { + firstScreenshot = i; + break; + } + } + + if ( firstScreenshot >= 0 ) + { + return shotList.at( firstScreenshot ).firstChildElement( "image" ).text(); + } + + return QString(); +} + +/** @brief Returns language of the given element @p e + * + * Transforms the attribute value for xml:lang to something + * suitable for TranslatedString (e.g. [lang]). + */ +static inline QString +getLanguage( const QDomElement& e ) +{ + QString language = e.attribute( "xml:lang" ); + if ( !language.isEmpty() ) + { + language.replace( '-', '_' ); + language.prepend( '[' ); + language.append( ']' ); + } + return language; +} + +/** @brief Scan the list of @p children for @p tagname elements and add them to the map + * + * Uses @p mapname instead of @p tagname for the entries in map @p m + * to allow renaming from XML to map keys (in particular for + * TranslatedString). Also transforms xml:lang attributes to suitable + * key-decorations on @p mapname. + */ +static inline void +fillMap( QVariantMap& m, const QDomNodeList& children, const QString& tagname, const QString& mapname ) +{ + for ( int i = 0; i < children.count(); ++i ) + { + if ( !children.at( i ).isElement() ) + { + continue; + } + + QDomElement e = children.at( i ).toElement(); + if ( e.tagName() != tagname ) + { + continue; + } + + m[ mapname + getLanguage( e ) ] = e.text(); + } +} + +/** @brief gets the and elements +* +* Builds up a map of the elements (which may have a *lang* +* attribute to indicate translations and paragraphs of the +* element (also with lang). Uses the +* elements to supplement the description if no description +* is available for a given language. +* +* Returns a map with keys suitable for use by TranslatedString. +*/ +static inline QVariantMap +getNameAndSummary( const QDomNode& n ) +{ + QVariantMap m; + + const QDomNodeList children = n.childNodes(); + fillMap( m, children, "name", "name" ); + fillMap( m, children, "summary", "description" ); + + const QDomElement description = n.firstChildElement( "description" ); + if ( !description.isNull() ) + { + fillMap( m, description.childNodes(), "p", "description" ); + } + + return m; +} +#endif + +PackageItem +PackageItem::fromAppData( const QVariantMap& item_map ) +{ +#ifdef HAVE_XML + QString fileName = CalamaresUtils::getString( item_map, "appdata" ); + if ( fileName.isEmpty() ) + { + cWarning() << "Can't load AppData without a suitable key."; + return PackageItem(); + } + cDebug() << "Loading AppData XML from" << fileName; + + QDomDocument doc = loadAppData( fileName ); + if ( doc.isNull() ) + { + return PackageItem(); + } + + QDomElement componentNode = doc.documentElement(); + if ( !componentNode.isNull() && componentNode.tagName() == "component" ) + { + // An "id" entry in the Calamares config overrides ID in the AppData + QString id = CalamaresUtils::getString( item_map, "id" ); + if ( id.isEmpty() ) + { + id = getChildText( componentNode, "id" ); + } + if ( id.isEmpty() ) + { + return PackageItem(); + } + + // A "screenshot" entry in the Calamares config overrides AppData + QString screenshotPath = CalamaresUtils::getString( item_map, "screenshot" ); + if ( screenshotPath.isEmpty() ) + { + screenshotPath = getScreenshotPath( componentNode ); + } + + QVariantMap map = getNameAndSummary( componentNode ); + map.insert( "id", id ); + map.insert( "screenshot", screenshotPath ); + + return PackageItem( map ); + } + + return PackageItem(); +#else + cWarning() << "Loading AppData XML is not supported."; + + return PackageItem(); +#endif +} + PackageListModel::PackageListModel( QObject* parent ) : QAbstractListModel( parent ) @@ -90,10 +323,14 @@ PackageListModel::~PackageListModel() {} void PackageListModel::addPackage( PackageItem&& p ) { - int c = m_packages.count(); - beginInsertRows( QModelIndex(), c, c ); - m_packages.append( p ); - endInsertRows(); + // Only add valid packages + if ( p.isValid() ) + { + int c = m_packages.count(); + beginInsertRows( QModelIndex(), c, c ); + m_packages.append( p ); + endInsertRows(); + } } int diff --git a/src/modules/packagechooser/PackageModel.h b/src/modules/packagechooser/PackageModel.h index 68e19a25d..869e124f0 100644 --- a/src/modules/packagechooser/PackageModel.h +++ b/src/modules/packagechooser/PackageModel.h @@ -40,11 +40,10 @@ const NamedEnumTable< PackageChooserMode >& roleNames(); struct PackageItem { QString id; - // TODO: may need more than one + // FIXME: unused QString package; CalamaresUtils::Locale::TranslatedString name; CalamaresUtils::Locale::TranslatedString description; - // TODO: may be more than one QPixmap screenshot; /// @brief Create blank PackageItem @@ -56,14 +55,44 @@ struct PackageItem */ PackageItem( const QString& id, const QString& package, const QString& name, const QString& description ); + /** @brief Creates a PackageItem from given strings. + * + * Set all the text members and load the screenshot from the given + * @p screenshotPath, which may be a QRC path (:/path/in/qrc) or + * a filesystem path, whatever QPixmap understands. + */ PackageItem( const QString& id, const QString& package, const QString& name, const QString& description, const QString& screenshotPath ); - // TODO: implement this - PackageItem fromAppStream( const QString& filename ); + /** @brief Creates a PackageItem from a QVariantMap + * + * This is intended for use when loading PackageItems from a + * configuration map. It will look up the various keys in the map + * and handle translation strings as well. + */ + PackageItem( const QVariantMap& map ); + + /** @brief Is this item valid? + * + * A valid item has an untranslated name available. + */ + bool isValid() const { return !name.isEmpty(); } + + /** @brief Loads an AppData XML file and returns a PackageItem + * + * The @p map must have a key *appdata*. That is used as the + * primary source of information, but keys *id* and *screenshotPath* + * may be used to override parts of the AppData -- so that the + * ID is under the control of Calamares, and the screenshot can be + * forced to a local path available on the installation medium. + * + * Requires XML support in libcalamares, if not present will + * return invalid PackageItems. + */ + static PackageItem fromAppData( const QVariantMap& map ); }; using PackageList = QVector< PackageItem >; @@ -75,6 +104,10 @@ public: PackageListModel( QObject* parent ); virtual ~PackageListModel() override; + /** @brief Add a package @p to the model + * + * Only valid packages are added -- that is, they must have a name. + */ void addPackage( PackageItem&& p ); int rowCount( const QModelIndex& index ) const override; diff --git a/src/modules/packagechooser/Tests.cpp b/src/modules/packagechooser/Tests.cpp index c016f1808..3e7961b92 100644 --- a/src/modules/packagechooser/Tests.cpp +++ b/src/modules/packagechooser/Tests.cpp @@ -18,9 +18,13 @@ #include "Tests.h" +#include "PackageModel.h" + +#include "utils/Logger.h" + #include -QTEST_GUILESS_MAIN( PackageChooserTests ) +QTEST_MAIN( PackageChooserTests ) PackageChooserTests::PackageChooserTests() {} @@ -29,6 +33,7 @@ PackageChooserTests::~PackageChooserTests() {} void PackageChooserTests::initTestCase() { + Logger::setupLogLevel( Logger::LOGDEBUG ); } void @@ -36,3 +41,37 @@ PackageChooserTests::testBogus() { QVERIFY( true ); } + +void +PackageChooserTests::testAppData() +{ + // Path from the build-dir + QString appdataName( "../io.calamares.calamares.appdata.xml" ); + QVERIFY( QFile::exists( appdataName ) ); + + QVariantMap m; + m.insert( "appdata", appdataName ); + + PackageItem p1 = PackageItem::fromAppData( m ); +#ifdef HAVE_XML + QVERIFY( p1.isValid() ); + QCOMPARE( p1.id, "io.calamares.calamares.desktop" ); + QCOMPARE( p1.name.get(), "Calamares" ); + // The entry has precedence + QCOMPARE( p1.description.get(), "Calamares is an installer program for Linux distributions." ); + // .. but en_GB doesn't have an entry in description, so uses + QCOMPARE( p1.description.get( QLocale( "en_GB" ) ), "Calamares Linux Installer" ); + QCOMPARE( p1.description.get( QLocale( "nl" ) ), "Calamares is een installatieprogramma voor Linux distributies." ); + QVERIFY( p1.screenshot.isNull() ); + + m.insert( "id", "calamares" ); + m.insert( "screenshot", ":/images/calamares.png" ); + PackageItem p2= PackageItem::fromAppData( m ); + QVERIFY( p2.isValid() ); + QCOMPARE( p2.id, "calamares" ); + QCOMPARE( p2.description.get( QLocale( "nl" ) ), "Calamares is een installatieprogramma voor Linux distributies." ); + QVERIFY( !p2.screenshot.isNull() ); +#else + QVERIFY( !p1.isValid() ); +#endif +} diff --git a/src/modules/packagechooser/Tests.h b/src/modules/packagechooser/Tests.h index bc257f5a5..62efe92cc 100644 --- a/src/modules/packagechooser/Tests.h +++ b/src/modules/packagechooser/Tests.h @@ -31,6 +31,7 @@ public: private Q_SLOTS: void initTestCase(); void testBogus(); + void testAppData(); }; #endif diff --git a/src/modules/packagechooser/images/calamares.png b/src/modules/packagechooser/images/calamares.png new file mode 100644 index 000000000..452e4450c Binary files /dev/null and b/src/modules/packagechooser/images/calamares.png differ diff --git a/src/modules/packagechooser/packagechooser.conf b/src/modules/packagechooser/packagechooser.conf index 7d60b50a4..e9b2d9329 100644 --- a/src/modules/packagechooser/packagechooser.conf +++ b/src/modules/packagechooser/packagechooser.conf @@ -24,8 +24,11 @@ mode: required # pretty short list to avoid overwhelming the UI. This is a list # of objects, and the items are displayed in list order. # -# Each item has an id, which is used in setting # the value of -# *packagechooser_*). The following fields +# Either provide the data for an item in the list (using the keys +# below), or use existing AppData XML files as a source for the data. +# +# For data provided by the list: the item has an id, which is used in +# setting the value of *packagechooser_*). The following fields # are mandatory: # # - *id* ID for the product. The ID "" is special, and is used for @@ -33,13 +36,30 @@ mode: required # selecting none. # - *package* Package name for the product. While mandatory, this is # not actually used anywhere. -# - *name* Human-readable, but untranslated, name of the product. -# - *description* Human-readable, but untranslated, description. +# - *name* Human-readable name of the product. To provide translations, +# add a *[lang]* decoration as part of the key name, e.g. `name[nl]` +# for Dutch. The list of usable languages can be found in +# `CMakeLists.txt` or as part of the debug output of Calamares. +# - *description* Human-readable description. These can be translated +# as well. # - *screenshot* Path to a single screenshot of the product. May be # a filesystem path or a QRC path (e.g. ":/images/no-selection.png"). # # Use the empty string "" as ID / key for the "no selection" item if # you want to customize the display of that item as well. +# +# For data provided by AppData XML: the item has an *appdata* +# key which points to an AppData XML file in the local filesystem. +# This file is parsed to provide the id (from AppData id), name +# (from AppData name), description (from AppData description paragraphs +# or the summary entries), and a screenshot (the defautl screenshot +# from AppData). No package is set (but that is unused anyway). +# +# AppData may contain IDs that are not useful inside Calamares, +# and the screenshot URL may be remote -- a remote URL will not +# be loaded and the screenshot will be missing. An item with *appdata* +# **may** specify an ID or screenshot path, as above. This will override +# the settings from AppData. items: - id: "" package: "" @@ -58,5 +78,7 @@ items: name: GNOME description: GNU Networked Object Modeling Environment Desktop screenshot: ":/images/gnome.png" - + - id: calamares + appdata: ../io.calamares.calamares.appdata.xml + screenshot: ":/images/calamares.png" diff --git a/src/modules/packagechooser/packagechooser.qrc b/src/modules/packagechooser/packagechooser.qrc index 9212c2f93..8f211c1bb 100644 --- a/src/modules/packagechooser/packagechooser.qrc +++ b/src/modules/packagechooser/packagechooser.qrc @@ -3,5 +3,6 @@ images/no-selection.png images/kde.png images/gnome.png + images/calamares.png