/* * iDescriptor: A free and open-source idevice management tool. * * Copyright (C) 2025 Uncore * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ #include "appswidget.h" #include "appcontext.h" #include "appdownloadbasedialog.h" #include "appdownloaddialog.h" #include "appinstalldialog.h" #include "appstoremanager.h" #include "creddialog.h" #include "iDescriptor-ui.h" #include "iDescriptor.h" #include "keychaindialog.h" #include "logindialog.h" #include "mainwindow.h" #include "settingsmanager.h" #include "sponsorwidget.h" #include "zlineedit.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // FIXME: we dont watch for login and logout events because there is no such api AppsWidget *AppsWidget::sharedInstance() { static AppsWidget *instance = new AppsWidget(); return instance; } AppsWidget::AppsWidget(QWidget *parent) : QWidget(parent), m_isLoggedIn(false) { m_debounceTimer = new QTimer(this); setupUI(); } void AppsWidget::setupUI() { QVBoxLayout *mainLayout = new QVBoxLayout(this); mainLayout->setContentsMargins(0, 0, 0, 0); mainLayout->setSpacing(0); // Header with login m_networkManager = new QNetworkAccessManager(this); QWidget *headerWidget = new QWidget(); headerWidget->setFixedHeight(60); headerWidget->setStyleSheet("border-bottom: 1px solid #363d32;"); QHBoxLayout *headerLayout = new QHBoxLayout(headerWidget); headerLayout->setContentsMargins(20, 10, 20, 10); // Create status label first m_statusLabel = new QLabel("Not signed in"); m_statusLabel->setStyleSheet("margin-right: 20px;"); m_loginButton = new QPushButton(); m_searchEdit = new ZLineEdit(); m_searchEdit->setMaximumWidth(350); // --- Status and Login Button --- m_manager = AppStoreManager::sharedInstance(); m_statusLabel->setStyleSheet("font-size: 14px; color: #666;"); mainLayout->addWidget(headerWidget); static ZIcon searchIcon(":/resources/icons/MdiLightMagnify.png"); m_searchAction = m_searchEdit->addAction( searchIcon.getThemedPixmap(QSize(16, 16), palette()), QLineEdit::TrailingPosition); m_searchAction->setToolTip("Search"); connect(m_searchAction, &QAction::triggered, this, &AppsWidget::performSearch); // Update search icon when theme changes connect(qApp, &QApplication::paletteChanged, this, [this]() { m_searchAction->setIcon( searchIcon.getThemedPixmap(QSize(16, 16), palette())); }); headerLayout->addWidget(m_searchEdit); headerLayout->addStretch(); headerLayout->addWidget(m_statusLabel); headerLayout->addWidget(m_loginButton); // Stacked widget for different pages m_stackedWidget = new QStackedWidget(); setupDefaultAppsPage(); setupLoadingPage(); setupErrorPage(); mainLayout->addWidget(m_stackedWidget); // Show default apps initially showLoading("Loading apps..."); // Connections connect(m_loginButton, &QPushButton::clicked, this, &AppsWidget::onLoginClicked); connect(m_searchEdit, &QLineEdit::textChanged, this, &AppsWidget::onSearchTextChanged); m_debounceTimer->setSingleShot(true); connect(m_debounceTimer, &QTimer::timeout, this, &AppsWidget::performSearch); connect(m_manager, &AppStoreManager::loginSuccessful, this, &AppsWidget::onAppStoreInitialized); connect(m_manager, &AppStoreManager::loggedOut, this, &AppsWidget::onAppStoreInitialized); } void AppsWidget::init() { QUrl sponsorsUrl(SPONSORS_JSON_URL); QNetworkRequest request(sponsorsUrl); QNetworkReply *reply = m_networkManager->get(request); connect(reply, &QNetworkReply::finished, this, [this, reply]() { try { if (reply->error() == QNetworkReply::NoError) { QByteArray responseData = reply->readAll(); QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); if (jsonDoc.isNull() || !jsonDoc.isObject()) { qDebug() << "Failed to parse sponsors JSON"; showDefaultApps(); reply->deleteLater(); return; } QJsonObject rootObj = jsonDoc.object(); QJsonObject versioned = getVersionedConfig(rootObj); if (versioned.isEmpty()) { qDebug() << "No sponsor configuration found for version" << APP_VERSION << "or default."; showDefaultApps(); reply->deleteLater(); return; } QJsonObject sponsorObj = versioned["sponsors"].toObject(); QJsonObject platinumObj = sponsorObj["platinum"].toObject(); QJsonObject goldObj = sponsorObj["gold"].toObject(); QJsonObject silverObj = sponsorObj["silver"].toObject(); QJsonObject bronzeObj = sponsorObj["bronze"].toObject(); m_platinumSponsors = platinumObj["members"].toArray(); m_goldSponsors = goldObj["members"].toArray(); m_silverSponsors = silverObj["members"].toArray(); m_bronzeSponsors = bronzeObj["members"].toArray(); if (!m_platinumSponsors.isEmpty()) { qDebug() << "Platinum Sponsors found"; } } qDebug() << "Sponsors fetch completed"; reply->deleteLater(); QTimer::singleShot(0, this, &AppsWidget::handleInit); } catch (...) { qDebug() << "Exception occurred while processing sponsors"; reply->deleteLater(); QTimer::singleShot(0, this, &AppsWidget::handleInit); } }); } void AppsWidget::handleInit() { if (!m_manager) { qDebug() << "AppStoreManager failed to initialize"; m_statusLabel->setText("Failed to initialize"); m_loginButton->setText("Failed to initialize"); m_loginButton->setEnabled(false); m_loginButton->setStyleSheet( "background-color: #ccc; color: #666; " "border: " "none; border-radius: " "4px; padding: 8px 16px; font-size: 14px;"); return; } /* FIXME: ipatoolinitialze still uses the secure backends when if the user rejects it, the moment he/she tries to sign in prompt(keychain or secret-service whatever the backend is) will be seen again */ if (!SettingsManager::sharedInstance()->useUnsecureBackend() && SettingsManager::sharedInstance()->showKeychainDialog()) { #ifdef __APPLE__ KeychainDialog dialog(this); if (dialog.exec() == QDialog::Rejected) { // pass empty QJsonObject to skip signing in onAppStoreInitialized(QJsonObject()); showDefaultApps(); return; } // windows doesn't show any keychain dialog #elif __linux__ CredDialog dialog(this); if (dialog.exec() == QDialog::Rejected) { // pass empty QJsonObject to skip signing in onAppStoreInitialized(QJsonObject()); showDefaultApps(); return; } #endif } onAppStoreInitialized(m_manager->getAccountInfo()); showDefaultApps(); } void AppsWidget::onAppStoreInitialized(const QJsonObject &accountInfo) { if (accountInfo.contains("success") && accountInfo.value("success").toBool()) { if (accountInfo.contains("email")) { QString email = accountInfo.value("email").toString(); m_statusLabel->setText("Signed in as " + email); m_isLoggedIn = true; m_searchEdit->setDisabled(false); } else { m_statusLabel->setText("Not signed in"); m_searchEdit->setDisabled(true); } } else { m_searchEdit->setDisabled(true); m_statusLabel->setText("Not signed in"); } m_loginButton->setText(m_isLoggedIn ? "Sign Out" : "Sign In"); m_loginButton->setStyleSheet( "background-color: #007AFF; color: white; border: none; " "border-radius: " "4px; padding: 8px 16px; font-size: 14px;"); m_searchEdit->setPlaceholderText(m_isLoggedIn ? "Search for apps..." : "Sign in to search"); } void AppsWidget::setupDefaultAppsPage() { m_defaultAppsPage = new QWidget(); // Scroll area for apps m_scrollArea = new QScrollArea(); m_scrollArea->setWidgetResizable(true); m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_scrollArea->setStyleSheet( "QScrollArea { background: transparent; border: none; }"); m_scrollArea->viewport()->setStyleSheet("background: transparent;"); m_contentWidget = new QWidget(); QGridLayout *gridLayout = new QGridLayout(m_contentWidget); gridLayout->setContentsMargins(20, 20, 20, 20); gridLayout->setSpacing(20); m_scrollArea->setWidget(m_contentWidget); QVBoxLayout *pageLayout = new QVBoxLayout(m_defaultAppsPage); pageLayout->setContentsMargins(0, 0, 0, 0); pageLayout->addWidget(m_scrollArea); m_stackedWidget->addWidget(m_defaultAppsPage); } void AppsWidget::setupLoadingPage() { m_loadingPage = new QWidget(); QVBoxLayout *loadingLayout = new QVBoxLayout(m_loadingPage); loadingLayout->setAlignment(Qt::AlignCenter); m_loadingIndicator = new QProcessIndicator(); m_loadingIndicator->setType(QProcessIndicator::line_rotate); m_loadingIndicator->setFixedSize(64, 32); m_loadingLabel = new QLabel("Loading..."); m_loadingLabel->setAlignment(Qt::AlignCenter); m_loadingLabel->setStyleSheet( "font-size: 16px; color: #666; margin-top: 20px;"); loadingLayout->addWidget(m_loadingIndicator, 0, Qt::AlignCenter); loadingLayout->addWidget(m_loadingLabel, 0, Qt::AlignCenter); m_stackedWidget->addWidget(m_loadingPage); } void AppsWidget::setupErrorPage() { m_errorPage = new QWidget(); QVBoxLayout *errorLayout = new QVBoxLayout(m_errorPage); errorLayout->setAlignment(Qt::AlignCenter); m_errorLabel = new QLabel("Error occurred"); m_errorLabel->setAlignment(Qt::AlignCenter); m_errorLabel->setWordWrap(true); m_errorLabel->setStyleSheet("font-size: 16px; color: #666;"); errorLayout->addWidget(m_errorLabel, 0, Qt::AlignCenter); m_stackedWidget->addWidget(m_errorPage); } void AppsWidget::showDefaultApps() { clearAppGrid(); populateDefaultApps(); m_stackedWidget->setCurrentWidget(m_defaultAppsPage); } void AppsWidget::showLoading(const QString &message) { m_loadingLabel->setText(message); m_loadingIndicator->start(); m_stackedWidget->setCurrentWidget(m_loadingPage); } void AppsWidget::showError(const QString &message) { m_loadingIndicator->stop(); m_errorLabel->setText(message); m_stackedWidget->setCurrentWidget(m_errorPage); } void AppsWidget::populateDefaultApps() { QGridLayout *gridLayout = qobject_cast(m_contentWidget->layout()); if (!gridLayout) return; int row = 0; int col = 0; const int maxCols = 3; auto advanceGridPos = [&]() { col++; if (col >= maxCols) { col = 0; row++; } }; for (const QJsonValue &sponsorValue : m_platinumSponsors) { QJsonObject sponsorObj = sponsorValue.toObject(); QString name = sponsorObj.value("name").toString(); QString bundleId = sponsorObj.value("bundleId").toString(); QString logoUrl = sponsorObj.value("logo").toString(); QString description = sponsorObj.value("description").toString(); QString url = sponsorObj.value("url").toString(); bool useBundleIdForIcon = sponsorObj.value("useBundleIdForIcon").toBool(true); createAppCard(name, bundleId, description, logoUrl, url, gridLayout, row, col, useBundleIdForIcon, SponsorType(SponsorType::Platinum)); advanceGridPos(); } for (const QJsonValue &sponsorValue : m_goldSponsors) { QJsonObject sponsorObj = sponsorValue.toObject(); QString name = sponsorObj.value("name").toString(); QString bundleId = sponsorObj.value("bundleId").toString(); QString description = sponsorObj.value("description").toString(); QString logoUrl = sponsorObj.value("logo").toString(); QString url = sponsorObj.value("url").toString(); bool useBundleIdForIcon = sponsorObj.value("useBundleIdForIcon").toBool(true); createAppCard(name, bundleId, description, logoUrl, url, gridLayout, row, col, useBundleIdForIcon, SponsorType(SponsorType::Gold)); advanceGridPos(); } for (const QJsonValue &sponsorValue : m_silverSponsors) { QJsonObject sponsorObj = sponsorValue.toObject(); QString name = sponsorObj.value("name").toString(); QString bundleId = sponsorObj.value("bundleId").toString(); QString description = sponsorObj.value("description").toString(); QString url = sponsorObj.value("url").toString(); QString logoUrl = sponsorObj.value("logo").toString(); bool useBundleIdForIcon = sponsorObj.value("useBundleIdForIcon").toBool(true); createAppCard(name, bundleId, description, logoUrl, url, gridLayout, row, col, useBundleIdForIcon, SponsorType(SponsorType::Silver)); advanceGridPos(); } for (const QJsonValue &sponsorValue : m_bronzeSponsors) { QJsonObject sponsorObj = sponsorValue.toObject(); QString name = sponsorObj.value("name").toString(); QString bundleId = sponsorObj.value("bundleId").toString(); QString description = sponsorObj.value("description").toString(); QString url = sponsorObj.value("url").toString(); QString logoUrl = sponsorObj.value("logo").toString(); bool useBundleIdForIcon = sponsorObj.value("useBundleIdForIcon").toBool(true); createAppCard(name, bundleId, description, logoUrl, url, gridLayout, row, col, useBundleIdForIcon, SponsorType(SponsorType::Bronze)); advanceGridPos(); } if (m_platinumSponsors.empty() && m_goldSponsors.empty()) { createSponsorCard(gridLayout, row, col); advanceGridPos(); } createAppCard("Instagram", "com.burbn.instagram", "Photo & Video sharing social network", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("Spotify", "com.spotify.client", "Music streaming and podcast platform", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("YouTube", "com.google.ios.youtube", "Video sharing and streaming platform", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("X", "com.atebits.Tweetie2", "Social media and microblogging", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("TikTok", "com.zhiliaoapp.musically", "Short-form video hosting service", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("Twitch", "tv.twitch", "Live streaming platform", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("Telegram", "ph.telegra.Telegraph", "Cloud-based instant messaging", "", "", gridLayout, row, col); advanceGridPos(); createAppCard("Reddit", "com.reddit.Reddit", "Social news aggregation platform", "", "", gridLayout, row, col); advanceGridPos(); gridLayout->setRowStretch(gridLayout->rowCount(), 1); } void AppsWidget::clearAppGrid() { QGridLayout *gridLayout = qobject_cast(m_contentWidget->layout()); if (!gridLayout) return; QLayoutItem *item; while ((item = gridLayout->takeAt(0)) != nullptr) { if (item->widget()) { item->widget()->deleteLater(); } delete item; } } void AppsWidget::createSponsorCard(QGridLayout *gridLayout, int row, int col) { if (!gridLayout) return; ClickableWidget *sponsorCard = new ClickableWidget(); sponsorCard->setStyleSheet("border: 1px solid #ddd; border-radius: 8px;"); sponsorCard->setCursor(Qt::PointingHandCursor); connect(sponsorCard, &ClickableWidget::clicked, this, [this]() { auto sWidget = new SponsorWidget(); sWidget->setAttribute(Qt::WA_DeleteOnClose); sWidget->show(); }); QVBoxLayout *sponsorLayout = new QVBoxLayout(sponsorCard); sponsorLayout->setContentsMargins(12, 12, 12, 12); sponsorLayout->setSpacing(8); QLabel *sponsorLabel = new QLabel("Become a Sponsor!"); sponsorLabel->setAlignment(Qt::AlignCenter); sponsorLabel->setStyleSheet("font-size: 14px; font-weight: bold;"); sponsorLayout->addWidget(sponsorLabel); gridLayout->addWidget(sponsorCard, row, col); } void AppsWidget::createAppCard( const QString &name, const QString &bundleId, const QString &description, const QString &logoUrl, const QString &websiteUrl, QGridLayout *gridLayout, int row, int col, bool useBundleIdForIcon, const SponsorType &sponsorType) { QWidget *cardWidget = new QWidget(); QHBoxLayout *cardLayout = new QHBoxLayout(cardWidget); cardLayout->setContentsMargins(15, 15, 15, 15); cardLayout->setSpacing(10); // App icon QLabel *iconLabel = new QLabel(); QPointer safeIconLabel = iconLabel; QPixmap placeholderIcon = QApplication::style() ->standardIcon(QStyle::SP_ComputerIcon) .pixmap(64, 64); iconLabel->setPixmap(placeholderIcon); iconLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(iconLabel); if (!logoUrl.isEmpty() && !useBundleIdForIcon) { QUrl url(logoUrl); QNetworkRequest request(url); QNetworkReply *reply = m_networkManager->get(request); connect( reply, &QNetworkReply::finished, this, [reply, safeIconLabel]() { if (reply->error() == QNetworkReply::NoError && safeIconLabel) { QByteArray data = reply->readAll(); QPixmap pixmap; if (pixmap.loadFromData(data)) { QPixmap scaled = pixmap.scaled( 64, 64, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); QPixmap rounded(64, 64); rounded.fill(Qt::transparent); QPainter painter(&rounded); painter.setRenderHint(QPainter::Antialiasing); QPainterPath path; path.addRoundedRect(QRectF(0, 0, 64, 64), 16, 16); painter.setClipPath(path); painter.drawPixmap(0, 0, scaled); painter.end(); safeIconLabel->setPixmap(rounded); } } reply->deleteLater(); }); connect(iconLabel, &QObject::destroyed, reply, &QNetworkReply::abort); } else if (!bundleId.isEmpty()) { fetchAppIconFromApple( m_networkManager, bundleId, [safeIconLabel](const QPixmap &pixmap) { // Check if iconLabel still exists if (safeIconLabel && !pixmap.isNull()) { QPixmap scaled = pixmap.scaled(64, 64, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); QPixmap rounded(64, 64); rounded.fill(Qt::transparent); QPainter painter(&rounded); painter.setRenderHint(QPainter::Antialiasing); QPainterPath path; path.addRoundedRect(QRectF(0, 0, 64, 64), 16, 16); painter.setClipPath(path); painter.drawPixmap(0, 0, scaled); painter.end(); safeIconLabel->setPixmap(rounded); } }); } // Vertical layout for name and description QVBoxLayout *textLayout = new QVBoxLayout(); // App name with sponsor indicator QHBoxLayout *nameLayout = new QHBoxLayout(); QLabel *nameLabel = new QLabel(name); nameLabel->setStyleSheet("font-size: 16px;"); nameLabel->setWordWrap(true); nameLayout->addWidget(nameLabel); // Add sponsor type indicator if (!sponsorType.isEmpty()) { QLabel *sponsorLabel = new QLabel(sponsorType.name); sponsorLabel->setStyleSheet(QString("font-size: 10px; " "font-weight: bold; " "color: #333; " "background-color: %2; " "border-radius: 4px; " "padding: 2px 6px; " "margin-left: 8px;") .arg(sponsorType.color)); sponsorLabel->setFixedHeight(16); sponsorLabel->setAlignment(Qt::AlignCenter); nameLayout->addWidget(sponsorLabel); } nameLayout->addStretch(); textLayout->addLayout(nameLayout); // App description QLabel *descLabel = new QLabel(description); descLabel->setStyleSheet("font-size: 12px; color: #666;"); descLabel->setAlignment(Qt::AlignLeft); descLabel->setWordWrap(true); textLayout->addWidget(descLabel); cardLayout->addLayout(textLayout); QVBoxLayout *buttonsLayout = new QVBoxLayout(); // Install button placeholder if (!bundleId.isEmpty()) { ZLabel *installLabel = new ZLabel("Install"); installLabel->setAlignment(Qt::AlignCenter); installLabel->setStyleSheet( "font-size: 12px; color: #007AFF; font-weight: " "bold; background-color: transparent;"); installLabel->setCursor(Qt::PointingHandCursor); installLabel->setFixedHeight(30); buttonsLayout->addStretch(); buttonsLayout->addWidget(installLabel); connect(installLabel, &ZLabel::clicked, this, [this, name, bundleId, description]() { onAppCardClicked(name, bundleId, description); }); } if (websiteUrl.isEmpty()) { ZLabel *downloadIpaLabel = new ZLabel("Download IPA"); downloadIpaLabel->setAlignment(Qt::AlignCenter); downloadIpaLabel->setStyleSheet("font-size: 12px; font-weight: " "bold; background-color: transparent;"); downloadIpaLabel->setCursor(Qt::PointingHandCursor); connect( downloadIpaLabel, &ZLabel::clicked, this, [this, name, bundleId]() { onDownloadIpaClicked(name, bundleId); }); buttonsLayout->addWidget(downloadIpaLabel); } else { ZLabel *websiteLabel = new ZLabel("Website"); websiteLabel->setStyleSheet("font-size: 12px; font-weight: " "bold; background-color: transparent;"); websiteLabel->setAlignment(Qt::AlignCenter); websiteLabel->setCursor(Qt::PointingHandCursor); connect(websiteLabel, &ZLabel::clicked, this, [this, websiteUrl]() { QDesktopServices::openUrl(QUrl(websiteUrl)); }); buttonsLayout->addWidget(websiteLabel); } buttonsLayout->addStretch(); cardLayout->addLayout(buttonsLayout); gridLayout->addWidget(cardWidget, row, col); } void AppsWidget::onDownloadIpaClicked(const QString &name, const QString &bundleId) { if (!m_isLoggedIn) { QMessageBox::information(this, "Sign In Required", "Please sign in to download IPA files."); return; } QString description = "Download the IPA file for " + name; AppDownloadDialog dialog(name, bundleId, description, this); dialog.exec(); } void AppsWidget::onLoginClicked() { if (m_isLoggedIn) { AppStoreManager *manager = AppStoreManager::sharedInstance(); if (manager) { manager->revokeCredentials(); } m_isLoggedIn = false; m_loginButton->setText("Sign In"); m_statusLabel->setText("Not signed in"); m_searchEdit->setPlaceholderText("Sign in to search"); return; } LoginDialog dialog(this); if (dialog.exec() == QDialog::Accepted) { // Login was successful, update UI AppStoreManager *manager = AppStoreManager::sharedInstance(); if (manager) { QJsonObject accountInfo = manager->getAccountInfo(); if (accountInfo.contains("success") && accountInfo.value("success").toBool()) { if (accountInfo.contains("email")) { QString email = accountInfo.value("email").toString(); m_statusLabel->setText("Signed in as " + email); m_isLoggedIn = true; m_loginButton->setText("Sign Out"); m_searchEdit->setPlaceholderText("Search for apps..."); } } } } } void AppsWidget::onAppCardClicked(const QString &appName, const QString &bundleId, const QString &description) { if (!m_isLoggedIn) { QMessageBox::information(this, "Sign In Required", "Please sign in to install apps."); return; } AppInstallDialog dialog(appName, description, bundleId, this); dialog.exec(); } void AppsWidget::onSearchTextChanged() { m_debounceTimer->start(300); } void AppsWidget::performSearch() { QString searchTerm = m_searchEdit->text().trimmed(); if (searchTerm.isEmpty()) { showDefaultApps(); return; } showLoading(QString("Searching for \"%1\"...").arg(searchTerm)); AppStoreManager *manager = AppStoreManager::sharedInstance(); if (!manager) { showError("Failed to initialize App Store manager."); return; } manager->searchApps(searchTerm, 20, [this](bool success, const QString &results) { onSearchFinished(success, results); }); } void AppsWidget::onSearchFinished(bool success, const QString &results) { // FIXME: cancel fetch instead of just ignoring results QString searchTerm = m_searchEdit->text().trimmed(); if (searchTerm.isEmpty()) { showDefaultApps(); return; } if (!success || results.isEmpty()) { showError("No apps found or search failed."); return; } QJsonParseError parseError; QJsonDocument doc = QJsonDocument::fromJson(results.toUtf8(), &parseError); if (parseError.error != QJsonParseError::NoError) { qDebug() << "JSON parse error:" << parseError.errorString() << " on output: " << results; showError("Failed to parse search results."); return; } qDebug() << "Search results:" << doc; QJsonObject rootObj = doc.object(); if (!rootObj.value("success").toBool()) { QString errorMessage = rootObj.value("error").toString("Unknown search error."); showError(QString("Search error: %1").arg(errorMessage)); return; } QJsonArray resultsArray = rootObj.value("results").toArray(); if (resultsArray.isEmpty()) { showError("No apps found."); return; } clearAppGrid(); QGridLayout *gridLayout = qobject_cast(m_contentWidget->layout()); if (!gridLayout) return; int row = 0; int col = 0; const int maxCols = 3; for (const QJsonValue &appValue : resultsArray) { QJsonObject appObj = appValue.toObject(); QString name = appObj.value("trackName").toString(); QString bundleId = appObj.value("bundleId").toString(); QString description = "Version: " + appObj.value("version").toString(); createAppCard(name, bundleId, description, "", "", gridLayout, row, col); col++; if (col >= maxCols) { col = 0; row++; } } gridLayout->setRowStretch(gridLayout->rowCount(), 1); m_stackedWidget->setCurrentWidget(m_defaultAppsPage); }