Files
iDescriptor/src/devdiskimageswidget.cpp
T

771 lines
27 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 "devdiskimageswidget.h"
#include "appcontext.h"
#include "devdiskmanager.h"
#include "iDescriptor.h"
#include "qprocessindicator.h"
#include "servicemanager.h"
#include "settingsmanager.h"
#include <QCloseEvent>
#include <QComboBox>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QGraphicsDropShadowEffect>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMessageBox>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProgressBar>
#include <QPushButton>
#include <QStackedWidget>
#include <QStandardPaths>
#include <QStringList>
#include <QVBoxLayout>
#include <string>
DevDiskImagesWidget::DevDiskImagesWidget(iDescriptorDevice *device,
QWidget *parent)
: Tool(parent),
m_currentDeviceUdid(
device != nullptr ? QString::fromStdString(device->udid) : QString())
{
setMinimumSize(400, 400);
resize(800, 600);
setupUi();
connect(DevDiskManager::sharedInstance(), &DevDiskManager::imageListFetched,
this, &DevDiskImagesWidget::onImageListFetched);
updateDeviceList();
connect(AppContext::sharedInstance(), &AppContext::deviceAdded, this,
&DevDiskImagesWidget::updateDeviceList);
connect(AppContext::sharedInstance(), &AppContext::deviceRemoved, this,
&DevDiskImagesWidget::updateDeviceList);
connect(m_deviceComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged), this,
&DevDiskImagesWidget::onDeviceSelectionChanged);
connect(m_imageListWidget, &QListWidget::itemClicked, this,
[this](QListWidgetItem *item) {
m_mountButton->setEnabled(item != nullptr);
});
}
void DevDiskImagesWidget::setupUi()
{
setWindowTitle("Developer Disk Images - iDescriptor");
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
auto *mountLayout = new QHBoxLayout();
mountLayout->addWidget(new QLabel("Device:"));
m_deviceComboBox = new QComboBox(this);
mountLayout->addWidget(m_deviceComboBox);
m_mountButton = new QPushButton("Mount", this);
m_mountButton->setEnabled(false);
m_check_mountedButton = new QPushButton("Check Mounted", this);
connect(m_mountButton, &QPushButton::clicked, this,
&DevDiskImagesWidget::onMountButtonClicked);
connect(m_check_mountedButton, &QPushButton::clicked, this,
&DevDiskImagesWidget::checkMountedImage);
mountLayout->setContentsMargins(10, 10, 10, 10);
mountLayout->addWidget(m_mountButton);
mountLayout->addWidget(m_check_mountedButton);
layout->addLayout(mountLayout);
m_stackedWidget = new QStackedWidget(this);
layout->addWidget(m_stackedWidget);
// Create loading page with process indicator
auto *loadingPage = new QWidget();
auto *loadingLayout = new QVBoxLayout(loadingPage);
loadingLayout->addStretch();
auto *indicatorLayout = new QHBoxLayout();
indicatorLayout->addStretch();
m_processIndicator = new QProcessIndicator(loadingPage);
m_processIndicator->setFixedSize(40, 40);
m_processIndicator->setType(QProcessIndicator::line_rotate);
indicatorLayout->addWidget(m_processIndicator);
indicatorLayout->addStretch();
loadingLayout->addLayout(indicatorLayout);
m_statusLabel = new QLabel("Fetching image list...");
m_statusLabel->setAlignment(Qt::AlignCenter);
m_statusLabel->setStyleSheet("QLabel { color: #666; margin-top: 10px; }");
loadingLayout->addWidget(m_statusLabel);
loadingLayout->addStretch();
m_stackedWidget->addWidget(loadingPage);
m_imageListWidget = new QListWidget(this);
m_imageListWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_imageListWidget->setStyleSheet(
"QListWidget { background: transparent; border: none; }");
m_stackedWidget->addWidget(m_imageListWidget);
m_processIndicator->start();
m_stackedWidget->setCurrentIndex(0); // Show loading page
// TODO: we may force to refetch most up to date image list
QTimer::singleShot(500, this, [this]() {
displayImages();
m_stackedWidget->setCurrentWidget(m_imageListWidget);
});
}
void DevDiskImagesWidget::fetchImages()
{
m_processIndicator->start();
m_stackedWidget->setCurrentIndex(0); // Show loading page
m_statusLabel->setText("Fetching image list...");
// DevDiskManager::sharedInstance()->fetchImageList();
}
void DevDiskImagesWidget::onImageListFetched(bool success,
const QString &errorMessage)
{
m_processIndicator->stop();
if (!success) {
qDebug() << "Error fetching image list:" << errorMessage;
m_statusLabel->setText(
QString("Error fetching image list: %1").arg(errorMessage));
// Keep showing the loading page with error message
return;
}
qDebug() << "Image list fetched successfully";
displayImages();
m_stackedWidget->setCurrentWidget(m_imageListWidget);
}
void DevDiskImagesWidget::onDeviceSelectionChanged(int index)
{
if (index < 0 ||
index >= AppContext::sharedInstance()->getAllDevices().size())
return;
auto device = AppContext::sharedInstance()->getAllDevices()[index];
if (device == nullptr)
return;
m_currentDeviceUdid = QString::fromStdString(device->udid);
displayImages();
}
void DevDiskImagesWidget::displayImages()
{
qDebug() << "Displaying images for device";
m_imageListWidget->clear();
// Look up device by UDID
iDescriptorDevice *currentDevice = nullptr;
if (!m_currentDeviceUdid.isEmpty()) {
currentDevice = AppContext::sharedInstance()->getDevice(
m_currentDeviceUdid.toStdString());
}
bool hasConnectedDevice = (currentDevice != nullptr);
int major = hasConnectedDevice
? currentDevice->deviceInfo.parsedDeviceVersion.major
: 0;
int minor = hasConnectedDevice
? currentDevice->deviceInfo.parsedDeviceVersion.minor
: 0;
QString path = SettingsManager::sharedInstance()->mkDevDiskImgPath();
QList<ImageInfo> allImages =
DevDiskManager::sharedInstance()->parseImageList(
path, major, minor, m_mounted_sig.c_str(), m_mounted_sig_len);
qDebug() << "Total images:" << allImages.size();
int itemIndex = 0;
// Create UI items
auto createVersionItem = [&](const ImageInfo &info) {
bool isCompatible =
(info.compatibility == ImageCompatibility::Compatible ||
info.compatibility == ImageCompatibility::MaybeCompatible);
auto *itemWidget = new QWidget();
itemWidget->setObjectName("itemWidget");
auto *itemLayout = new QHBoxLayout(itemWidget);
// TODO: maybe create a custom widget for this, if we ever need this
// elsewhere ?
QColor baseColor = QApplication::palette().color(QPalette::Window);
QColor bgColor =
itemIndex % 2 == 0 ? baseColor.lighter(110) : baseColor;
itemWidget->setStyleSheet(
QString("QWidget#itemWidget { background-color: %1; }")
.arg(bgColor.name()));
itemIndex++;
auto *versionLabel = new QLabel(info.version);
if (isCompatible) {
if (info.compatibility == ImageCompatibility::Compatible) {
versionLabel->setStyleSheet(
"QLabel { font-weight: bold; color: #2E7D32; }");
} else if (info.compatibility ==
ImageCompatibility::MaybeCompatible) {
versionLabel->setStyleSheet(
"QLabel { font-weight: bold; color: #F57C00; }");
}
}
itemLayout->addWidget(versionLabel);
// Add status labels
if (hasConnectedDevice) {
if (isCompatible) {
if (info.isMounted) {
auto *mountedLabel = new QLabel("Mounted");
mountedLabel->setStyleSheet(
"QLabel { color: #1565C0; font-weight: bold; }");
itemLayout->addWidget(mountedLabel);
} else if (info.compatibility ==
ImageCompatibility::MaybeCompatible) {
auto *maybeLabel = new QLabel("Maybe compatible");
maybeLabel->setStyleSheet("QLabel { color: #F57C00; "
"margin-left: 10px; font-weight: "
"bold; }");
itemLayout->addWidget(maybeLabel);
}
} else {
auto *incompatLabel = new QLabel("Not compatible");
incompatLabel->setStyleSheet(
"QLabel { color: #D32F2F; margin-left: 10px; font-weight: "
"bold; }");
itemLayout->addWidget(incompatLabel);
}
}
itemLayout->addStretch();
auto *progressBar = new QProgressBar();
progressBar->setVisible(false);
itemLayout->addWidget(progressBar);
auto *downloadButton =
new QPushButton(info.isDownloaded ? "Re-download" : "Download");
downloadButton->setDefault(true);
downloadButton->setProperty("version", info.version);
connect(downloadButton, &QPushButton::clicked, this,
&DevDiskImagesWidget::onDownloadButtonClicked);
itemLayout->addWidget(downloadButton);
auto *listItem = new QListWidgetItem(m_imageListWidget);
listItem->setSizeHint(itemWidget->sizeHint());
m_imageListWidget->addItem(listItem);
m_imageListWidget->setItemWidget(listItem, itemWidget);
};
bool hasCompatibleImages = false;
bool hasOtherImages = false;
bool separatorAdded = false;
// Add all images, inserting separator when transitioning from compatible to
// not compatible
for (const auto &info : allImages) {
bool isCompatible =
(info.compatibility == ImageCompatibility::Compatible ||
info.compatibility == ImageCompatibility::MaybeCompatible);
if (isCompatible) {
hasCompatibleImages = true;
} else {
hasOtherImages = true;
// Add separator before first non-compatible image if we have
// compatible ones
if (hasCompatibleImages && !separatorAdded) {
auto *separatorItem = new QListWidgetItem(m_imageListWidget);
auto *separatorWidget = new QWidget();
auto *separatorLayout = new QHBoxLayout(separatorWidget);
auto *separatorLabel = new QLabel("Other versions");
separatorLabel->setStyleSheet(
"QLabel { font-weight: bold; color: #757575; margin: 10px "
"0; }");
separatorLayout->addWidget(separatorLabel);
separatorItem->setSizeHint(separatorWidget->sizeHint());
m_imageListWidget->addItem(separatorItem);
m_imageListWidget->setItemWidget(separatorItem,
separatorWidget);
separatorAdded = true;
}
}
createVersionItem(info);
}
// Show device info if available
if (hasConnectedDevice) {
QString deviceVersion = QString("%1.%2").arg(major).arg(minor);
m_statusLabel->setText(
QString("Connected device: iOS %1 - Compatible images shown at top")
.arg(deviceVersion));
}
}
void DevDiskImagesWidget::onDownloadButtonClicked()
{
auto *button = qobject_cast<QPushButton *>(sender());
if (!button)
return;
QString version = button->property("version").toString();
QString versionPath =
QDir(SettingsManager::sharedInstance()->devdiskimgpath())
.filePath(version);
if (QDir(versionPath).exists()) {
auto reply = QMessageBox::question(
this, "Confirm Overwrite",
QString(
"Directory '%1' already exists. Do you want to overwrite it?")
.arg(version),
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::No) {
return;
}
}
startDownload(version);
}
void DevDiskImagesWidget::startDownload(const QString &version)
{
// Find the button and progress bar for this version
QPushButton *downloadButton = nullptr;
QProgressBar *progressBar = nullptr;
for (int i = 0; i < m_imageListWidget->count(); ++i) {
auto *item = m_imageListWidget->item(i);
auto *widget = m_imageListWidget->itemWidget(item);
auto *button = widget->findChild<QPushButton *>();
if (button && button->property("version") == version) {
downloadButton = button;
progressBar = widget->findChild<QProgressBar *>();
break;
}
}
if (!downloadButton || !progressBar)
return;
downloadButton->setEnabled(false);
progressBar->setVisible(true);
progressBar->setValue(0);
QString targetDir =
QDir(SettingsManager::sharedInstance()->devdiskimgpath())
.filePath(version);
if (!QDir().mkpath(targetDir)) {
QMessageBox::critical(
this, "Error",
QString("Could not create directory: %1").arg(targetDir));
downloadButton->setEnabled(true);
progressBar->setVisible(false);
return;
}
// todo is this safe ?
auto *downloadItem = new DownloadItem();
downloadItem->version = version;
downloadItem->progressBar = progressBar;
downloadItem->downloadButton = downloadButton;
auto replies = DevDiskManager::sharedInstance()->downloadImage(version);
downloadItem->dmgReply = replies.first;
downloadItem->sigReply = replies.second;
if (!downloadItem->dmgReply || !downloadItem->sigReply) {
delete downloadItem;
downloadButton->setEnabled(true);
progressBar->setVisible(false);
return;
}
connect(downloadItem->dmgReply, &QNetworkReply::downloadProgress, this,
&DevDiskImagesWidget::onDownloadProgress);
connect(downloadItem->dmgReply, &QNetworkReply::finished, this,
&DevDiskImagesWidget::onFileDownloadFinished);
connect(downloadItem->sigReply, &QNetworkReply::downloadProgress, this,
&DevDiskImagesWidget::onDownloadProgress);
connect(downloadItem->sigReply, &QNetworkReply::finished, this,
&DevDiskImagesWidget::onFileDownloadFinished);
m_activeDownloads[downloadItem->dmgReply] = downloadItem;
m_activeDownloads[downloadItem->sigReply] = downloadItem;
}
void DevDiskImagesWidget::onDownloadProgress(qint64 bytesReceived,
qint64 bytesTotal)
{
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || !m_activeDownloads.contains(reply))
return;
auto *item = m_activeDownloads[reply];
if (reply->property("totalSizeAdded").isNull() && bytesTotal > 0) {
item->totalSize += bytesTotal;
reply->setProperty("totalSizeAdded", true);
}
if (reply == item->dmgReply) {
item->dmgReceived = bytesReceived;
} else if (reply == item->sigReply) {
item->sigReceived = bytesReceived;
}
item->totalReceived = item->dmgReceived + item->sigReceived;
if (item->totalSize > 0) {
item->progressBar->setValue((item->totalReceived * 100) /
item->totalSize);
}
}
// TODO: file saving should be in manager
void DevDiskImagesWidget::onFileDownloadFinished()
{
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || !m_activeDownloads.contains(reply))
return;
auto *item = m_activeDownloads[reply];
m_activeDownloads.remove(reply);
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::critical(this, "Download Error",
QString("Failed to download %1: %2")
.arg(reply->url().path())
.arg(reply->errorString()));
if (reply == item->dmgReply && item->sigReply)
item->sigReply->abort();
if (reply == item->sigReply && item->dmgReply)
item->dmgReply->abort();
item->downloadButton->setEnabled(true);
item->downloadButton->setText("Retry");
item->progressBar->setVisible(false);
if (m_activeDownloads.key(item) == nullptr) {
delete item;
}
reply->deleteLater();
return;
}
QString path = QUrl::fromPercentEncoding(reply->url().path().toUtf8());
QFileInfo fileInfo(path);
QString filename = fileInfo.fileName();
QString targetPath =
QDir(QDir(SettingsManager::sharedInstance()->devdiskimgpath())
.filePath(item->version))
.filePath(filename);
QFile file(targetPath);
if (!file.open(QIODevice::WriteOnly)) {
QMessageBox::critical(
this, "File Error",
QString("Could not save file: %1").arg(targetPath));
} else {
file.write(reply->readAll());
file.close();
}
reply->deleteLater();
if (m_activeDownloads.key(item) == nullptr) { // Both files downloaded
item->downloadButton->setText("Downloaded");
item->downloadButton->setEnabled(false);
item->progressBar->setValue(100);
item->progressBar->setVisible(false);
delete item;
}
}
void DevDiskImagesWidget::updateDeviceList()
{
auto devices = AppContext::sharedInstance()->getAllDevices();
if (devices.isEmpty()) {
m_currentDeviceUdid.clear();
m_check_mountedButton->setEnabled(false);
m_deviceComboBox->setEnabled(false);
} else {
m_deviceComboBox->setEnabled(true);
m_check_mountedButton->setEnabled(true);
}
QString currentUdid = "";
if (m_deviceComboBox->count() > 0 &&
m_deviceComboBox->currentIndex() >= 0) {
currentUdid = m_deviceComboBox->currentData().toString();
} else if (!m_currentDeviceUdid.isEmpty()) {
currentUdid = m_currentDeviceUdid;
}
m_deviceComboBox->clear();
int newIndex = -1;
for (int i = 0; i < devices.size(); ++i) {
auto *device = devices.at(i);
m_deviceComboBox->addItem(
QString("%1 / (%2)")
.arg(QString::fromStdString(device->deviceInfo.deviceName))
.arg(QString::fromStdString(device->deviceInfo.productType)),
QString::fromStdString(device->udid));
if (QString().fromStdString((device->udid)) == currentUdid) {
newIndex = i;
}
}
if (newIndex != -1) {
m_deviceComboBox->setCurrentIndex(newIndex);
}
displayImages();
}
void DevDiskImagesWidget::onMountButtonClicked()
{
qDebug() << "Current index:" << m_deviceComboBox->currentIndex();
if (m_deviceComboBox->currentIndex() < 0) {
QMessageBox::warning(this, "No Device",
"Please select a device to mount the image on.");
return;
}
auto *currentItem = m_imageListWidget->currentItem();
if (!currentItem) {
QMessageBox::warning(this, "No Image Selected",
"Please select a disk image to mount.");
return;
}
auto *widget = m_imageListWidget->itemWidget(currentItem);
auto *button = widget->findChild<QPushButton *>();
if (!button)
return;
QString version = button->property("version").toString();
this->mountImage(version);
}
void DevDiskImagesWidget::mountImage(const QString &version)
{
QString udid = m_deviceComboBox->currentData().toString();
m_deviceComboBox->setEnabled(false);
if (udid.isEmpty()) {
QMessageBox::warning(this, "No Device", "Please select a device.");
return;
}
if (!DevDiskManager::sharedInstance()->isImageDownloaded(
version, SettingsManager::sharedInstance()->devdiskimgpath())) {
QMessageBox::warning(
this, "Image Not Found",
QString("The selected disk image for version %1 is not downloaded. "
"Please download it first.")
.arg(version));
return;
}
m_mountButton->setEnabled(false);
m_mountButton->setText("Mounting...");
auto updateUI = [&]() {
m_mountButton->setEnabled(true);
m_mountButton->setText("Mount");
m_deviceComboBox->setEnabled(true);
};
auto paths = DevDiskManager::sharedInstance()->getPathsForVersion(version);
MountedImageInfo info = ServiceManager::getMountedImage(
AppContext::sharedInstance()->getDevice(udid.toStdString()));
if (info.err == nullptr && info.signature && info.signature_len) {
qDebug() << "Mount image: already mounted sig found"
<< QString::fromStdString(std::string((char *)info.signature,
info.signature_len));
QMessageBox::information(this, "Already Mounted",
QString("A developer disk image is already "
"mounted on %1.")
.arg(m_deviceComboBox->currentText()));
return updateUI();
} else if (info.err->code == DeviceLockedMountErrorCode) {
/* Never returns DeviceLockedMountErrorCode when doing
image_mounter_lookup_image but maybe used in future */
} else if (info.err->code == NotFoundErrorCode) {
// OK, no image mounted
qDebug() << "Mount image: no mounted image found";
} else {
QMessageBox::critical(
this, "Mount Check Failed",
QString("Failed to check mounted image on %1. Try with a "
"genuine cable.")
.arg(m_deviceComboBox->currentText()));
mounted_image_info_free(info);
return updateUI();
}
mounted_image_info_free(info);
iDescriptorDevice *currentDevice =
m_currentDeviceUdid.isEmpty() ? nullptr
: AppContext::sharedInstance()->getDevice(
m_currentDeviceUdid.toStdString());
if (!currentDevice) {
QMessageBox::warning(this, "No Device",
"Device is no longer connected.");
return updateUI();
}
IdeviceFfiError *err = ServiceManager::mountImage(
currentDevice, paths.first.toStdString().c_str(),
paths.second.toStdString().c_str());
if (err == nullptr) {
QMessageBox::information(this, "Success",
QString("Image mounted successfully on %1.")
.arg(m_deviceComboBox->currentText()));
return updateUI();
}
qDebug() << "Mount image result:" << err->code
<< QString::fromStdString(err->message);
if (err->code == DeviceLockedMountErrorCode) {
QMessageBox::critical(this, "Mount Failed",
"The device is locked. Please unlock it and try"
" again.");
} else {
QMessageBox::critical(
this, "Mount Failed",
QString("Failed to mount image on %1. Try with a genuine cable.")
.arg(m_deviceComboBox->currentText()));
}
idevice_error_free(err);
updateUI();
}
void DevDiskImagesWidget::closeEvent(QCloseEvent *event)
{
if (!m_activeDownloads.isEmpty()) {
auto reply = QMessageBox::question(
this, "Downloads in Progress",
QString(
"There are %1 download(s) in progress. Do you really want to "
"close and cancel all downloads?")
.arg(m_activeDownloads.size()),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (reply == QMessageBox::No) {
event->ignore();
return;
}
// Cancel all active downloads
for (auto it = m_activeDownloads.begin(); it != m_activeDownloads.end();
++it) {
QNetworkReply *reply = it.key();
if (reply) {
reply->abort();
}
}
}
event->accept();
}
void DevDiskImagesWidget::checkMountedImage()
{
iDescriptorDevice *currentDevice =
m_currentDeviceUdid.isEmpty() ? nullptr
: AppContext::sharedInstance()->getDevice(
m_currentDeviceUdid.toStdString());
if (!currentDevice) {
qDebug() << "No device selected";
auto devices = AppContext::sharedInstance()->getAllDevices();
for (const auto &dev : devices) {
qDebug() << "Device:"
<< QString::fromStdString(dev->deviceInfo.deviceName)
<< "UDID:" << QString::fromStdString(dev->udid);
}
return;
}
if (m_deviceComboBox->currentIndex() < 0) {
qDebug() << "No device selected in combo box";
return;
}
/*
older devices return something like this:
{
"ImagePresent": true,
"ImageSignature": <7b16200b 2ead1830 a59809d1 51e9060b ... 8a 9844eb07
e0b8e0>, "Status": "Complete"
}
*/
MountedImageInfo info = ServiceManager::getMountedImage(currentDevice);
if (info.err == nullptr && info.signature != nullptr &&
info.signature_len > 0) {
m_mounted_sig = std::string(
reinterpret_cast<const char *>(info.signature), info.signature_len);
m_mounted_sig_len = info.signature_len;
displayImages(); // Refresh to show mounted status
QMessageBox::information(
this, "Check Mounted Image",
"There is already a developer disk image mounted on the device.");
mounted_image_info_free(info);
} else if (info.err->code == DeviceLockedMountErrorCode) {
QMessageBox::critical(this, "Device Locked",
"The device is locked. Please unlock it and try"
" again.");
mounted_image_info_free(info);
} else if (info.err->code == NotFoundErrorCode) {
QMessageBox::critical(
this, "No Mounted Image",
"No developer disk image is mounted on the device.");
mounted_image_info_free(info);
} else {
QMessageBox::critical(
this, "Check Mounted Image Failed",
QString("Failed to check mounted image on %1. Try with a "
"genuine cable. Error message: %2")
.arg(m_deviceComboBox->currentText())
.arg(QString::fromStdString(info.err->message)));
mounted_image_info_free(info);
}
}