// Copyright 2019 threeSD Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. #include #include #include #include #include #include #include #include #include #include #include "common/logging/log.h" #include "common/scope_exit.h" #include "frontend/import_dialog.h" #include "frontend/import_job.h" #include "ui_import_dialog.h" QString ReadableByteSize(qulonglong size) { static const std::array units = {QT_TR_NOOP("B"), QT_TR_NOOP("KiB"), QT_TR_NOOP("MiB"), QT_TR_NOOP("GiB"), QT_TR_NOOP("TiB"), QT_TR_NOOP("PiB")}; if (size == 0) return QStringLiteral("0"); int digit_groups = std::min(static_cast(std::log10(size) / std::log10(1024)), static_cast(units.size())); return QStringLiteral("%L1 %2") .arg(size / std::pow(1024, digit_groups), 0, 'f', 1) .arg(QObject::tr(units[digit_groups], "ImportDialog")); } // content type, name, icon name static constexpr std::array, 7> ContentTypeMap{{ {Core::ContentType::Application, QT_TR_NOOP("Application"), "app"}, {Core::ContentType::Update, QT_TR_NOOP("Update"), "update"}, {Core::ContentType::DLC, QT_TR_NOOP("DLC"), "dlc"}, {Core::ContentType::Savegame, QT_TR_NOOP("Save Data"), "save_data"}, {Core::ContentType::Extdata, QT_TR_NOOP("Extra Data"), "save_data"}, {Core::ContentType::SystemArchive, QT_TR_NOOP("System Archive"), "system_archive"}, {Core::ContentType::Sysdata, QT_TR_NOOP("System Data"), "system_data"}, }}; static const std::unordered_map EncryptionTypeMap{{ {Core::EncryptionType::None, QT_TR_NOOP("None")}, {Core::EncryptionType::FixedKey, QT_TR_NOOP("FixedKey")}, {Core::EncryptionType::NCCHSecure1, QT_TR_NOOP("Secure1")}, {Core::EncryptionType::NCCHSecure2, QT_TR_NOOP("Secure2")}, {Core::EncryptionType::NCCHSecure3, QT_TR_NOOP("Secure3")}, {Core::EncryptionType::NCCHSecure4, QT_TR_NOOP("Secure4")}, }}; QString GetContentName(const Core::ContentSpecifier& specifier) { return specifier.name.empty() ? QStringLiteral("0x%1").arg(specifier.id, 16, 16, QLatin1Char('0')) : QString::fromStdString(specifier.name); } QString GetContentTypeName(Core::ContentType type) { return QObject::tr(std::get<1>(ContentTypeMap.at(static_cast(type))), "ImportDialog"); } QPixmap GetContentTypeIcon(Core::ContentType type) { return QIcon::fromTheme( QString::fromUtf8(std::get<2>(ContentTypeMap.at(static_cast(type))))) .pixmap(24); } QPixmap GetContentIcon(const Core::ContentSpecifier& specifier, bool use_category_icon = false) { if (specifier.icon.empty()) { // Return a category icon, or a null icon return use_category_icon ? GetContentTypeIcon(specifier.type) : QIcon::fromTheme(QStringLiteral("unknown")).pixmap(24); } return QPixmap::fromImage(QImage(reinterpret_cast(specifier.icon.data()), 24, 24, QImage::Format::Format_RGB16)); } ImportDialog::ImportDialog(QWidget* parent, const Core::Config& config) : QDialog(parent), ui(std::make_unique()), user_path(config.user_path), importer(config) { qRegisterMetaType("u64"); qRegisterMetaType(); ui->setupUi(this); if (!importer.IsGood()) { QMessageBox::critical( this, tr("Importer Error"), tr("Failed to initalize the importer.\nRefer to the log for details.")); reject(); } ui->title_view_button->setChecked(true); ui->buttonBox->button(QDialogButtonBox::StandardButton::Reset)->setText(tr("Refresh")); connect(ui->buttonBox, &QDialogButtonBox::clicked, [this](QAbstractButton* button) { if (button == ui->buttonBox->button(QDialogButtonBox::StandardButton::Ok)) { StartImporting(); } else if (button == ui->buttonBox->button(QDialogButtonBox::StandardButton::Cancel)) { reject(); } else { RelistContent(); } }); connect(ui->title_view_button, &QRadioButton::toggled, this, &ImportDialog::RepopulateContent); RelistContent(); UpdateSizeDisplay(); // Set up column widths. // These values are tweaked with regard to the default dialog size. ui->main->setColumnWidth(0, width() * 0.11); ui->main->setColumnWidth(1, width() * 0.415); ui->main->setColumnWidth(2, width() * 0.14); ui->main->setColumnWidth(3, width() * 0.17); ui->main->setColumnWidth(4, width() * 0.08); } ImportDialog::~ImportDialog() = default; void ImportDialog::RelistContent() { auto* dialog = new QProgressDialog(tr("Loading Contents..."), tr("Cancel"), 0, 0, this); dialog->setWindowModality(Qt::WindowModal); dialog->setCancelButton(nullptr); dialog->setMinimumDuration(0); dialog->setValue(0); using FutureWatcher = QFutureWatcher; auto* future_watcher = new FutureWatcher(this); connect(future_watcher, &FutureWatcher::finished, this, [this, dialog] { dialog->hide(); RepopulateContent(); }); auto future = QtConcurrent::run([& contents = this->contents, &importer = this->importer] { contents = importer.ListContent(); }); future_watcher->setFuture(future); } void ImportDialog::InsertTopLevelItem(const QString& text, QPixmap icon) { auto* checkBox = new QCheckBox(); checkBox->setText(text); if (!icon.isNull()) { checkBox->setIcon(QIcon(icon)); } checkBox->setStyleSheet(QStringLiteral("margin-left: 7px; icon-size: 24px")); checkBox->setTristate(true); checkBox->setProperty("previousState", static_cast(Qt::Unchecked)); auto* item = new QTreeWidgetItem; item->setFirstColumnSpanned(true); ui->main->invisibleRootItem()->addChild(item); connect(checkBox, &QCheckBox::stateChanged, [this, checkBox, item](int state) { SCOPE_EXIT({ checkBox->setProperty("previousState", state); }); if (program_trigger) { program_trigger = false; return; } if (state == Qt::PartiallyChecked) { if (checkBox->property("previousState").toInt() == Qt::Unchecked) { checkBox->setCheckState(static_cast(state = Qt::Checked)); } else { checkBox->setCheckState(static_cast(state = Qt::Unchecked)); } return; } program_trigger = true; for (int i = 0; i < item->childCount(); ++i) { static_cast(ui->main->itemWidget(item->child(i), 0)) ->setCheckState(static_cast(state)); } program_trigger = false; }); ui->main->setItemWidget(item, 0, checkBox); } void ImportDialog::InsertSecondLevelItem(std::size_t row, const Core::ContentSpecifier& content, std::size_t id, QString replace_name, QPixmap replace_icon) { auto* checkBox = new QCheckBox(); checkBox->setStyleSheet(QStringLiteral("margin-left:7px")); // HACK: The checkbox is used to record ID. Is there a better way? checkBox->setProperty("id", id); const bool use_title_view = ui->title_view_button->isChecked(); QString name; if (use_title_view) { if (row == 0) { name = QStringLiteral("%1 (%2)") .arg(GetContentName(content)) .arg(GetContentTypeName(content.type)); } else if (row <= 2) { name = GetContentName(content); } else { name = GetContentTypeName(content.type); } } else { name = GetContentName(content); } if (!replace_name.isEmpty()) { name = replace_name; } QString encryption = tr(EncryptionTypeMap.at(content.encryption)); if (content.seed_crypto) { encryption.append(tr(" (Seed)")); } if (content.type != Core::ContentType::Application && content.type != Core::ContentType::Update && content.type != Core::ContentType::DLC) { // Do not display encryption in this case encryption.clear(); } auto* item = new QTreeWidgetItem{ {QString{}, name, ReadableByteSize(content.maximum_size), encryption, content.already_exists ? QStringLiteral("Yes") : QStringLiteral("No")}}; QPixmap icon; if (replace_icon.isNull()) { // When not in title view, only System Data and System Archive groups use category icons. const bool use_category_icon = content.type == Core::ContentType::Sysdata || content.type == Core::ContentType::SystemArchive; icon = use_title_view ? GetContentTypeIcon(content.type) : GetContentIcon(content, use_category_icon); } else { icon = replace_icon; } item->setData(1, Qt::DecorationRole, icon); ui->main->invisibleRootItem()->child(row)->addChild(item); ui->main->setItemWidget(item, 0, checkBox); connect(checkBox, &QCheckBox::stateChanged, [this, item, size = content.maximum_size, type = content.type, exists = content.already_exists](int state) { if (state == Qt::Checked) { total_size += size; } else { if (!warning_shown && !exists && (type == Core::ContentType::SystemArchive || type == Core::ContentType::Sysdata)) { QMessageBox::warning( this, tr("Warning"), tr("System Archive and System Data are important files that may " "be necessary for your imported games to run.\nIt is highly " "recommended to import these contents if they do not exist yet.")); warning_shown = true; } total_size -= size; } UpdateSizeDisplay(); if (!program_trigger) { UpdateItemCheckState(item->parent()); } }); if (!content.already_exists) { checkBox->setChecked(true); } } void ImportDialog::RepopulateContent() { total_size = 0; ui->main->clear(); ui->main->setSortingEnabled(false); std::map title_name_map; // title ID -> title name std::map title_icon_map; // title ID -> title icon std::unordered_map extdata_id_map; // extdata ID -> title ID for (const auto& content : contents) { if (content.type == Core::ContentType::Application) { title_name_map.emplace(content.id, GetContentName(content)); title_icon_map.emplace(content.id, GetContentIcon(content)); extdata_id_map.emplace(content.extdata_id, content.id); } } const bool use_title_view = ui->title_view_button->isChecked(); if (use_title_view) { title_name_map.insert_or_assign(0, tr("Ungrouped")); title_name_map.insert_or_assign(1, tr("System Archive")); title_name_map.insert_or_assign(2, tr("System Data")); title_icon_map.insert_or_assign(0, QIcon::fromTheme(QStringLiteral("unknown")).pixmap(24)); title_icon_map.insert_or_assign(1, GetContentTypeIcon(Core::ContentType::SystemArchive)); title_icon_map.insert_or_assign(2, GetContentTypeIcon(Core::ContentType::Sysdata)); std::unordered_map title_row_map; for (const auto& [id, name] : title_name_map) { InsertTopLevelItem(name, title_icon_map.count(id) ? title_icon_map.at(id) : QPixmap{}); title_row_map[id] = ui->main->invisibleRootItem()->childCount() - 1; } for (std::size_t i = 0; i < contents.size(); ++i) { const auto& content = contents[i]; std::size_t row = title_row_map.at(0); switch (content.type) { case Core::ContentType::Application: case Core::ContentType::Update: case Core::ContentType::DLC: case Core::ContentType::Savegame: { // Fix the id const auto real_id = content.id & 0xffffff00ffffffff; row = title_row_map.count(real_id) ? title_row_map.at(real_id) : title_row_map.at(0); break; } case Core::ContentType::Extdata: { const auto real_id = extdata_id_map.count(content.id) ? extdata_id_map.at(content.id) : 0; row = title_row_map.at(real_id); break; } case Core::ContentType::SystemArchive: { row = title_row_map.at(1); // System archive break; } case Core::ContentType::Sysdata: { row = title_row_map.at(2); // System data break; } } InsertSecondLevelItem(row, content, i); } } else { for (const auto& [type, name, _] : ContentTypeMap) { InsertTopLevelItem(tr(name), GetContentTypeIcon(type)); } for (std::size_t i = 0; i < contents.size(); ++i) { const auto& content = contents[i]; QString name; QPixmap icon; if (content.type == Core::ContentType::Savegame) { name = title_name_map.count(content.id) ? title_name_map.at(content.id) : QString{}; icon = title_icon_map.count(content.id) ? title_icon_map.at(content.id) : QPixmap{}; } else if (content.type == Core::ContentType::Extdata) { if (extdata_id_map.count(content.id)) { u64 title_id = extdata_id_map.at(content.id); name = title_name_map.count(title_id) ? title_name_map.at(title_id) : QString{}; icon = title_icon_map.count(title_id) ? title_icon_map.at(title_id) : QPixmap{}; } } InsertSecondLevelItem(static_cast(content.type), content, i, name, icon); } } ui->main->setSortingEnabled(true); } void ImportDialog::UpdateSizeDisplay() { QStorageInfo storage(QString::fromStdString(user_path)); if (!storage.isValid() || !storage.isReady()) { LOG_ERROR(Frontend, "Storage {} is not good", user_path); QMessageBox::critical( this, tr("Bad Storage"), tr("An error occured while trying to get available space for the storage.")); reject(); } ui->availableSpace->setText( tr("Available Space: %1").arg(ReadableByteSize(storage.bytesAvailable()))); ui->totalSize->setText(tr("Total Size: %1").arg(ReadableByteSize(total_size))); ui->buttonBox->button(QDialogButtonBox::StandardButton::Ok) ->setEnabled(total_size > 0 && total_size <= static_cast(storage.bytesAvailable())); } void ImportDialog::UpdateItemCheckState(QTreeWidgetItem* item) { bool has_checked = false, has_unchecked = false; auto* item_checkBox = static_cast(ui->main->itemWidget(item, 0)); for (int i = 0; i < item->childCount(); ++i) { auto* checkBox = static_cast(ui->main->itemWidget(item->child(i), 0)); if (checkBox->isChecked()) { has_checked = true; } else { has_unchecked = true; } if (has_checked && has_unchecked) { program_trigger = true; item_checkBox->setCheckState(Qt::PartiallyChecked); program_trigger = false; return; } } program_trigger = true; if (has_checked) { item_checkBox->setCheckState(Qt::Checked); } else { item_checkBox->setCheckState(Qt::Unchecked); } program_trigger = false; } std::vector ImportDialog::GetSelectedContentList() { std::vector to_import; for (int i = 0; i < ui->main->invisibleRootItem()->childCount(); ++i) { const auto* item = ui->main->invisibleRootItem()->child(i); for (int j = 0; j < item->childCount(); ++j) { const auto* checkBox = static_cast(ui->main->itemWidget(item->child(j), 0)); if (checkBox->isChecked()) { to_import.emplace_back(contents[checkBox->property("id").toInt()]); } } } return to_import; } void ImportDialog::StartImporting() { UpdateSizeDisplay(); if (!ui->buttonBox->button(QDialogButtonBox::StandardButton::Ok)->isEnabled()) { // Space is no longer enough QMessageBox::warning(this, tr("Not Enough Space"), tr("Your disk does not have enough space to hold imported data.")); return; } const auto& to_import = GetSelectedContentList(); const std::size_t total_count = to_import.size(); // Try to map total_size to int range // This is equal to ceil(total_size / INT_MAX) const u64 multiplier = (total_size + std::numeric_limits::max() - 1) / std::numeric_limits::max(); auto* dialog = new QProgressDialog(tr("Initializing..."), tr("Cancel"), 0, static_cast(total_size / multiplier), this); dialog->setWindowModality(Qt::WindowModal); dialog->setMinimumDuration(0); dialog->setValue(0); auto* job = new ImportJob(this, importer, std::move(to_import)); connect(job, &ImportJob::NextContent, this, [this, dialog, multiplier, total_count](u64 size_imported, u64 count, Core::ContentSpecifier next_content) { dialog->setValue(static_cast(size_imported / multiplier)); dialog->setLabelText(tr("(%1/%2) Importing %3 (%4)...") .arg(count) .arg(total_count) .arg(GetContentName(next_content)) .arg(GetContentTypeName(next_content.type))); current_content = next_content; current_count = count; }); connect(job, &ImportJob::ProgressUpdated, this, [this, dialog, multiplier, total_count](u64 total_size_imported, u64 current_size_imported) { dialog->setValue(static_cast(total_size_imported / multiplier)); dialog->setLabelText(tr("(%1/%2) Importing %3 (%4) (%5/%6)...") .arg(current_count) .arg(total_count) .arg(GetContentName(current_content)) .arg(GetContentTypeName(current_content.type)) .arg(ReadableByteSize(current_size_imported)) .arg(ReadableByteSize(current_content.maximum_size))); }); connect(job, &ImportJob::ErrorOccured, this, [this, dialog](Core::ContentSpecifier current_content) { QMessageBox::critical(this, tr("Error"), tr("Failed to import content %1 (%2)!") .arg(GetContentName(current_content)) .arg(GetContentTypeName(current_content.type))); dialog->hide(); }); connect(job, &ImportJob::Completed, this, [this, dialog] { dialog->setValue(dialog->maximum()); RepopulateContent(); }); connect(dialog, &QProgressDialog::canceled, this, [this, dialog, job] { // Add yet-another-ProgressDialog to indicate cancel progress auto* cancel_dialog = new QProgressDialog(tr("Canceling..."), tr("Cancel"), 0, 0, this); cancel_dialog->setWindowModality(Qt::WindowModal); cancel_dialog->setCancelButton(nullptr); cancel_dialog->setMinimumDuration(0); cancel_dialog->setValue(0); connect(job, &ImportJob::Completed, cancel_dialog, &QProgressDialog::hide); job->Cancel(); }); job->start(); }