Files
iDescriptor/src/installedappswidget.cpp
T

704 lines
21 KiB
C++

/*
* iDescriptor: A free and open-source idevice management tool.
*
* Copyright (C) 2025 Uncore <https://github.com/uncor3>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#include "installedappswidget.h"
#include "afcexplorerwidget.h"
#include "iDescriptor-ui.h"
#include "iDescriptor.h"
#include "qprocessindicator.h"
#include "zlineedit.h"
AppTabWidget::AppTabWidget(const QString &appName, const QString &bundleId,
const QString &version, const QPixmap &icon,
QWidget *parent)
: QWidget(parent), m_appName(appName), m_bundleId(bundleId),
m_version(version), m_selected(false)
{
#ifndef WIN32
setFixedHeight(60);
#else
setMinimumHeight(60);
#endif
setMinimumWidth(100);
setCursor(Qt::PointingHandCursor);
setAttribute(Qt::WA_StyledBackground, true);
setObjectName("AppTabWidget");
setupUI(icon);
}
void AppTabWidget::setSelected(bool selected)
{
m_selected = selected;
updateStyles();
}
void AppTabWidget::setupUI(const QPixmap &icon)
{
QHBoxLayout *mainLayout = new QHBoxLayout(this);
mainLayout->setContentsMargins(10, 8, 10, 8);
mainLayout->setSpacing(10);
m_iconLabel = new IDLoadingIconLabel(this);
m_iconLabel->setFixedSize(32, 32);
if (!icon.isNull()) {
m_iconLabel->setLoadedPixmap(icon);
}
mainLayout->addWidget(m_iconLabel);
// Text container
QVBoxLayout *textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(2);
// App name label
m_nameLabel = new QLabel();
QFont nameFont = m_nameLabel->font();
nameFont.setWeight(QFont::Medium);
m_nameLabel->setFont(nameFont);
QString displayText = m_appName;
if (displayText.length() > 20) {
displayText = displayText.left(17) + "...";
}
m_nameLabel->setText(displayText);
textLayout->addWidget(m_nameLabel);
// Version label
if (!m_version.isEmpty()) {
m_versionLabel = new QLabel(m_version);
m_versionLabel->setStyleSheet("font-size: 11px;");
textLayout->addWidget(m_versionLabel);
} else {
m_versionLabel = nullptr;
}
mainLayout->addLayout(textLayout);
mainLayout->addStretch();
updateStyles();
}
void AppTabWidget::setIcon(const QPixmap &icon)
{
if (!m_iconLabel)
return;
if (!icon.isNull()) {
m_iconLabel->setLoadedPixmap(icon);
} else {
m_iconLabel->setLoadFailed();
}
}
void AppTabWidget::mousePressEvent(QMouseEvent *event)
{
Q_UNUSED(event)
emit clicked();
}
void AppTabWidget::updateStyles()
{
QString style;
#ifndef WIN32
QColor bgColor = isDarkMode() ? qApp->palette().color(QPalette::Light)
: qApp->palette().color(QPalette::Dark);
#else
QColor bgColor =
isDarkMode() ? QColor(255, 255, 255, 25) : QColor(0, 0, 0, 25);
#endif
if (m_selected) {
style =
"#AppTabWidget { background-color: " + COLOR_ACCENT_BLUE.name() +
"; border-radius: "
"10px; border : 1px solid " +
bgColor.lighter().name() + "; }";
} else {
style = "#AppTabWidget { background-color: " +
bgColor.name(QColor::HexArgb) +
"; border-radius: 10px; border: 1px solid " +
bgColor.lighter().name() + "; }";
}
// prevent infinite loop
if (style != styleSheet()) {
setStyleSheet(style);
}
}
InstalledAppsWidget::InstalledAppsWidget(
const std::shared_ptr<iDescriptorDevice> device, QWidget *parent)
: QWidget(parent), m_device(device)
{
QVBoxLayout *rootLayout = new QVBoxLayout(this);
rootLayout->setContentsMargins(0, 0, 0, 0);
m_zloadingWidget = new ZLoadingWidget(true, this);
rootLayout->addWidget(m_zloadingWidget);
}
void InstalledAppsWidget::init()
{
if (m_loaded) {
qDebug()
<< "[InstalledAppsWidget]: Already initialized, skipping init()";
return;
}
m_loaded = true;
setupUI();
connect(m_device->service_manager,
&CXX::ServiceManager::installed_apps_retrieved, this,
&InstalledAppsWidget::onAppsDataReady);
setStyleSheet("InstalledAppsWidget { background: transparent; }");
m_device->service_manager->fetch_installed_apps();
}
InstalledAppsWidget::~InstalledAppsWidget() { cleanupHouseArrestClients(); }
void InstalledAppsWidget::setupUI()
{
QWidget *contentContainer = new QWidget(this);
m_mainLayout = new QHBoxLayout(contentContainer);
m_mainLayout->setContentsMargins(0, 0, 0, 0);
m_mainLayout->setSpacing(0);
m_zloadingWidget->setupContentWidget(contentContainer);
// Create stacked widget for different states
m_stackedWidget = new QStackedWidget(this);
m_mainLayout->addWidget(m_stackedWidget);
// Create loading widget
createLoadingWidget();
// Create error widget
createErrorWidget();
// Create content widget
createContentWidget();
// Start in loading state
showLoadingState();
}
void InstalledAppsWidget::showLoadingState()
{
m_stackedWidget->setCurrentWidget(m_loadingWidget);
}
void InstalledAppsWidget::showErrorState(const QString &error)
{
m_zloadingWidget->stop(true);
m_errorLabel->setText(QString("Error loading apps: %1").arg(error));
m_stackedWidget->setCurrentWidget(m_errorWidget);
}
void InstalledAppsWidget::createLoadingWidget()
{
m_loadingWidget = new QWidget();
QVBoxLayout *loadingLayout = new QVBoxLayout(m_loadingWidget);
loadingLayout->setAlignment(Qt::AlignCenter);
QProcessIndicator *spinner = new QProcessIndicator();
spinner->setType(QProcessIndicator::line_rotate);
spinner->setFixedSize(48, 48);
spinner->start();
loadingLayout->addWidget(spinner, 0, Qt::AlignCenter);
QLabel *loadingLabel = new QLabel("Loading installed apps...");
loadingLabel->setAlignment(Qt::AlignCenter);
loadingLabel->setStyleSheet(
"font-size: 14px; color: #666; margin-top: 10px;");
loadingLayout->addWidget(loadingLabel);
m_stackedWidget->addWidget(m_loadingWidget);
}
void InstalledAppsWidget::createErrorWidget()
{
m_errorWidget = new QWidget();
QVBoxLayout *errorLayout = new QVBoxLayout(m_errorWidget);
errorLayout->setAlignment(Qt::AlignCenter);
m_errorLabel = new QLabel();
m_errorLabel->setAlignment(Qt::AlignCenter);
m_errorLabel->setStyleSheet(
"font-size: 14px; color: #d32f2f; margin: 20px;");
m_errorLabel->setWordWrap(true);
errorLayout->addWidget(m_errorLabel);
QPushButton *retryButton = new QPushButton("Retry");
retryButton->setFixedSize(100, 30);
// FIXME:
// connect(retryButton, &QPushButton::clicked, this,
// &InstalledAppsWidget::fetchInstalledApps);
errorLayout->addWidget(retryButton, 0, Qt::AlignCenter);
m_stackedWidget->addWidget(m_errorWidget);
}
void InstalledAppsWidget::createContentWidget()
{
m_contentWidget = new QWidget();
QHBoxLayout *contentLayout = new QHBoxLayout(m_contentWidget);
contentLayout->setContentsMargins(0, 0, 0, 0);
contentLayout->setSpacing(0);
// Create main splitter
m_splitter = new ModernSplitter(Qt::Horizontal, m_contentWidget);
m_splitter->setChildrenCollapsible(false);
contentLayout->addWidget(m_splitter);
// Left side - App list
createLeftPanel();
// Right side - Content area
createRightPanel();
// Set initial splitter sizes (400px for tabs, rest for content)
m_splitter->setSizes({400, 600});
// Connect signals
connect(m_searchEdit, &QLineEdit::textChanged, this,
&InstalledAppsWidget::filterApps);
connect(m_fileSharingCheckBox, &QCheckBox::toggled, this,
&InstalledAppsWidget::onFileSharingFilterChanged);
m_stackedWidget->addWidget(m_contentWidget);
}
void InstalledAppsWidget::onAppsDataReady(const QMap<QString, QVariant> &result)
{
m_zloadingWidget->stop(true);
if (result.isEmpty()) {
showErrorState("No apps found or failed to retrieve apps.");
return;
}
m_stackedWidget->setCurrentWidget(m_contentWidget);
// Clear existing tabs
qDeleteAll(m_appTabs);
m_appTabs.clear();
m_selectedTab = nullptr;
m_iconLoadQueue.clear();
m_iconLoading = false;
connect(m_device->service_manager, &CXX::ServiceManager::app_icon_loaded,
this, &InstalledAppsWidget::onAppIconLoaded);
// Create tabs for each app
for (const QVariant &appVariant : result) {
// variant is json object
// Step 3: Parse JSON
QJsonParseError error;
QJsonDocument doc =
QJsonDocument::fromJson(appVariant.toString().toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
qDebug() << "JSON parse error:" << error.errorString();
// return;
continue;
}
QString displayName = doc["CFBundleDisplayName"].toString();
QString bundleId = doc["bundle_id"].toString();
QString version = doc["CFBundleShortVersionString"].toString();
QString appType = doc["app_type"].toString();
bool fileSharingEnabled = doc["UIFileSharingEnabled"].toBool();
/*
Always fails to load Fitness app container
even though file sharing is enabled
*/
if (bundleId == "com.apple.Fitness") {
continue;
}
// // Filter by file sharing status if checkbox is checked
if (m_fileSharingCheckBox->isChecked() && !fileSharingEnabled) {
continue;
}
if (displayName.isEmpty()) {
displayName = bundleId;
}
// Create tab name with type indicator
QString tabName = displayName;
if (appType == "System") {
tabName += " (System)";
}
createAppTab(tabName, bundleId, version, QPixmap());
enqueueIconLoad(bundleId);
// Select first tab if available
m_device->service_manager->fetch_app_icon(bundleId);
}
if (!m_appTabs.isEmpty()) {
selectAppTab(m_appTabs.first());
}
}
void InstalledAppsWidget::createAppTab(const QString &appName,
const QString &bundleId,
const QString &version,
const QPixmap &icon)
{
AppTabWidget *tabWidget =
new AppTabWidget(appName, bundleId, version, icon, this);
connect(tabWidget, &AppTabWidget::clicked, this,
&InstalledAppsWidget::onAppTabClicked);
// Remove the stretch before adding the new tab
m_tabLayout->removeItem(m_tabLayout->itemAt(m_tabLayout->count() - 1)); //
m_tabLayout->addWidget(tabWidget);
m_tabLayout->addStretch(); // Add stretch back at the end
m_appTabs[bundleId] = tabWidget;
}
void InstalledAppsWidget::onAppTabClicked()
{
AppTabWidget *clickedTab = qobject_cast<AppTabWidget *>(sender());
if (clickedTab) {
selectAppTab(clickedTab);
}
}
void InstalledAppsWidget::selectAppTab(AppTabWidget *tab)
{
// Deselect previous tab
if (m_selectedTab) {
m_selectedTab->setSelected(false);
}
// Select new tab
m_selectedTab = tab;
tab->setSelected(true);
QString bundleId = tab->getBundleId();
// Load app container data
loadAppContainer(bundleId);
}
void InstalledAppsWidget::filterApps(const QString &searchText)
{
QString lowerSearchText = searchText.toLower();
for (AppTabWidget *tab : m_appTabs) {
bool shouldShow = false;
if (lowerSearchText.isEmpty()) {
shouldShow = true;
} else {
// Search in app name and bundle ID
QString appName = tab->getAppName().toLower();
QString bundleId = tab->getBundleId().toLower();
shouldShow = appName.contains(lowerSearchText) ||
bundleId.contains(lowerSearchText);
}
tab->setVisible(shouldShow);
}
}
void InstalledAppsWidget::loadAppContainer(const QString &bundleId)
{
if (!m_device || m_loadingContainer) {
return;
}
m_loadingContainer = true;
disableTabs(true);
// Clean up previous house arrest clients before creating new ones
cleanupHouseArrestClients();
// Clear previous container data
QLayoutItem *item;
while ((item = m_containerLayout->takeAt(0)) != nullptr) {
if (item->widget()) {
item->widget()->deleteLater();
}
delete item;
}
// Create a centered loading widget
QWidget *loadingWidget = new QWidget();
QVBoxLayout *loadingLayout = new QVBoxLayout(loadingWidget);
loadingLayout->setAlignment(Qt::AlignCenter);
QProcessIndicator *l = new QProcessIndicator();
l->setType(QProcessIndicator::line_rotate);
l->setFixedSize(32, 32);
l->start();
loadingLayout->addWidget(l, 0, Qt::AlignCenter);
m_containerLayout->addWidget(loadingWidget);
m_houseArrestAfcClient =
std::make_shared<CXX::HauseArrest>(m_device->udid, bundleId);
connect(m_houseArrestAfcClient.get(),
&CXX::HauseArrest::init_session_finished, this,
&InstalledAppsWidget::onContainerDataReady,
Qt::SingleShotConnection);
m_houseArrestAfcClient->init_session();
}
void InstalledAppsWidget::onContainerDataReady(bool success)
{
QLayoutItem *item;
while ((item = m_containerLayout->takeAt(0)) != nullptr) {
if (item->widget()) {
item->widget()->deleteLater();
}
delete item;
}
m_loadingContainer = false;
disableTabs(false);
if (!success) {
qDebug() << "Error loading app container:";
QLabel *errorLabel = new QLabel("No data available for this app");
errorLabel->setAlignment(Qt::AlignCenter);
m_containerLayout->addWidget(errorLabel);
return;
}
// Create AfcExplorerWidget with the house arrest AFC client
AfcExplorerWidget *explorer = new AfcExplorerWidget(
m_device, true, m_houseArrestAfcClient, false, "/Documents", this);
explorer->setStyleSheet("border :none;");
m_containerLayout->addWidget(explorer);
}
void InstalledAppsWidget::onAppIconLoaded(const QString &bundleId,
const QByteArray &icon)
{
qDebug() << "Icon loaded for bundle ID:" << bundleId;
AppTabWidget *tab = m_appTabs.value(bundleId, nullptr);
if (tab) {
qDebug() << "Setting icon for bundle ID:" << bundleId;
QPixmap pixmap;
pixmap.loadFromData(icon);
tab->setIcon(pixmap);
}
// startNextIconLoad();
}
void InstalledAppsWidget::onFileSharingFilterChanged(bool enabled)
{
Q_UNUSED(enabled)
// Refresh the apps list when filter changes
// fetchInstalledApps();
}
void InstalledAppsWidget::cleanupHouseArrestClients()
{
if (m_houseArrestAfcClient) {
m_houseArrestAfcClient = nullptr;
// delete m_houseArrestAfcClient;
}
}
void InstalledAppsWidget::createLeftPanel()
{
QWidget *tabWidget = new QWidget();
tabWidget->setMinimumWidth(100);
tabWidget->setMaximumWidth(500);
QVBoxLayout *tabWidgetLayout = new QVBoxLayout(tabWidget);
tabWidgetLayout->setContentsMargins(0, 0, 0, 0);
tabWidgetLayout->setSpacing(0);
// Search container
QWidget *searchContainer = new QWidget();
searchContainer->setFixedHeight(60);
QHBoxLayout *searchLayout = new QHBoxLayout(searchContainer);
searchLayout->setContentsMargins(5, 0, 5, 5);
// Search box
m_searchEdit = new ZLineEdit();
m_searchEdit->setPlaceholderText("Search apps...");
searchLayout->addWidget(m_searchEdit);
// File sharing filter checkbox
// FIXME: crash when toggled
m_fileSharingCheckBox = new QCheckBox("Show Only File Sharing Enabled");
m_fileSharingCheckBox->setChecked(true);
m_fileSharingCheckBox->setStyleSheet("QCheckBox { font-size: 10px; }");
searchLayout->addWidget(m_fileSharingCheckBox);
tabWidgetLayout->addWidget(searchContainer);
// App list scroll area
m_tabScrollArea = new QScrollArea();
m_tabScrollArea->setWidgetResizable(true);
m_tabScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_tabScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_tabScrollArea->setStyleSheet(
"QScrollArea { background: transparent; border: none; }");
m_tabScrollArea->viewport()->setStyleSheet("background: transparent;");
m_tabContainer = new QWidget();
m_tabContainer->setStyleSheet("QWidget { background: transparent; }");
m_tabLayout = new QVBoxLayout(m_tabContainer);
m_tabLayout->setContentsMargins(0, 0, 10, 0);
m_tabLayout->setSpacing(10);
m_tabLayout->addStretch();
m_tabScrollArea->setWidget(m_tabContainer);
tabWidgetLayout->addWidget(m_tabScrollArea);
m_splitter->addWidget(tabWidget);
}
void InstalledAppsWidget::createRightPanel()
{
QWidget *rightContentWidget = new QWidget();
QVBoxLayout *contentLayout = new QVBoxLayout(rightContentWidget);
contentLayout->setContentsMargins(0, 0, 0, 5);
contentLayout->setSpacing(0);
m_containerWidget = new QWidget();
m_containerWidget->setObjectName("containerWidget");
m_containerWidget->setStyleSheet(
"QWidget#containerWidget { border: none; }");
m_containerLayout = new QVBoxLayout(m_containerWidget);
m_containerLayout->setContentsMargins(0, 0, 0, 0);
m_containerLayout->setSpacing(0);
contentLayout->addWidget(m_containerWidget);
m_splitter->addWidget(rightContentWidget);
}
void InstalledAppsWidget::disableTabs(bool disable)
{
for (AppTabWidget *tab : m_appTabs) {
tab->setEnabled(!disable);
}
}
void InstalledAppsWidget::enqueueIconLoad(const QString &bundleId)
{
if (bundleId.isEmpty())
return;
if (!m_iconLoadQueue.contains(bundleId)) {
m_iconLoadQueue.enqueue(bundleId);
}
if (!m_iconLoading) {
startNextIconLoad();
}
}
// FIXME: we better use this
void InstalledAppsWidget::startNextIconLoad()
{
// if (!m_device || QCoreApplication::closingDown()) {
// m_iconLoading = false;
// return;
// }
// if (m_iconLoadQueue.isEmpty()) {
// m_iconLoading = false;
// return;
// }
// m_iconLoading = true;
// const QString bundleId = m_iconLoadQueue.dequeue();
// QtConcurrent::run([this, bundleId]() {
// if (QCoreApplication::closingDown() || !m_device)
// return;
// QPixmap iconPixmap;
// {
// std::lock_guard<std::recursive_mutex> lock(m_device->mutex);
// IdeviceFfiError *err = nullptr;
// SpringBoardServicesClientHandle *springboardClient = nullptr;
// err = springboard_services_connect(m_device->provider,
// &springboardClient);
// if (err != nullptr) {
// qWarning() << "Error connecting to SpringBoard services for"
// << bundleId << ":"
// << QString::fromUtf8(err->message);
// idevice_error_free(err);
// } else {
// void *out_result = nullptr;
// size_t out_result_len = 0;
// err = springboard_services_get_icon(
// springboardClient, bundleId.toUtf8().constData(),
// &out_result, &out_result_len);
// if (err != nullptr) {
// qWarning() << "Error getting icon for" << bundleId << ":"
// << QString::fromUtf8(err->message);
// idevice_error_free(err);
// } else if (out_result && out_result_len > 0) {
// QByteArray byteArray(
// reinterpret_cast<const char *>(out_result),
// static_cast<int>(out_result_len));
// QImage image;
// image.loadFromData(byteArray);
// iconPixmap = QPixmap::fromImage(image);
// springboard_services_free_icon_result(out_result,
// out_result_len);
// }
// springboard_services_free(springboardClient);
// }
// }
// QMetaObject::invokeMethod(
// this,
// [this, bundleId, iconPixmap]() {
// if (QCoreApplication::closingDown())
// return;
// for (AppTabWidget *tab : m_appTabs) {
// if (tab->getBundleId() == bundleId) {
// tab->setIcon(iconPixmap);
// break;
// }
// }
// m_iconLoading = false;
// startNextIconLoad();
// },
// Qt::QueuedConnection);
// });
}