From 2a71f011d952ace795cc00cad9ca82a3ee7f030a Mon Sep 17 00:00:00 2001 From: uncor3 Date: Thu, 8 Jan 2026 21:09:47 +0000 Subject: [PATCH] implement statusballoon, refactor export logic and add new icons --- resources.qrc | 2 + .../icons/QlementineIconsWireless116.png | Bin 0 -> 14728 bytes resources/icons/UimProcess.png | Bin 0 -> 19003 bytes src/afcexplorerwidget.cpp | 11 +- src/appcontext.cpp | 79 ++- src/appcontext.h | 11 +- src/core/services/init_device.cpp | 60 +- src/devicemanagerwidget.cpp | 5 +- src/devicesidebarwidget.cpp | 23 +- src/devicesidebarwidget.h | 5 +- src/exportmanager.cpp | 274 ++------ src/exportmanager.h | 53 +- src/exportmanagerthread.cpp | 166 +++++ src/exportmanagerthread.h | 40 ++ src/gallerywidget.cpp | 109 +-- src/gallerywidget.h | 5 +- src/heartbeat.h | 64 +- src/iDescriptor.h | 60 +- src/livescreenwidget.cpp | 1 + src/mainwindow.cpp | 72 +- src/photomodel.cpp | 35 +- src/photomodel.h | 2 +- src/qballoontip.cpp | 86 +-- src/qballoontip.h | 21 +- src/servicemanager.cpp | 127 +++- src/servicemanager.h | 12 + src/statusballoon.cpp | 643 ++++++++++++++++++ src/statusballoon.h | 109 +++ src/toolboxwidget.cpp | 3 +- 29 files changed, 1532 insertions(+), 546 deletions(-) create mode 100644 resources/icons/QlementineIconsWireless116.png create mode 100644 resources/icons/UimProcess.png create mode 100644 src/exportmanagerthread.cpp create mode 100644 src/exportmanagerthread.h create mode 100644 src/statusballoon.cpp create mode 100644 src/statusballoon.h diff --git a/resources.qrc b/resources.qrc index 7bc83ba..0024a9c 100644 --- a/resources.qrc +++ b/resources.qrc @@ -35,6 +35,8 @@ resources/icons/ClarityEyeLine.png resources/icons/MaterialSymbolsLightImageOutlineSharp.png resources/icons/MaterialSymbolsFolder.png + resources/icons/QlementineIconsWireless116.png + resources/icons/UimProcess.png qml/MapView.qml resources/iphone.png resources/ios-wallpapers/iphone-ios4.png diff --git a/resources/icons/QlementineIconsWireless116.png b/resources/icons/QlementineIconsWireless116.png new file mode 100644 index 0000000000000000000000000000000000000000..0fa302d329e02c10d104f6a2a1f3fefd66b41769 GIT binary patch literal 14728 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 KuhnyT@_zu4e|jze literal 0 HcmV?d00001 diff --git a/resources/icons/UimProcess.png b/resources/icons/UimProcess.png new file mode 100644 index 0000000000000000000000000000000000000000..0ff6cff2c4578be283b0e25d3006432e69319c63 GIT binary patch literal 19003 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 literal 0 HcmV?d00001 diff --git a/src/afcexplorerwidget.cpp b/src/afcexplorerwidget.cpp index 386d796..9d49691 100644 --- a/src/afcexplorerwidget.cpp +++ b/src/afcexplorerwidget.cpp @@ -291,11 +291,14 @@ void AfcExplorerWidget::onFileListContextMenu(const QPoint &pos) if (!currPath.endsWith("/")) currPath += "/"; + // FIXME: index + int index = 0; for (QListWidgetItem *selItem : filesToExport) { QString fileName = selItem->text(); QString devicePath = currPath == "/" ? "/" + fileName : currPath + fileName; - exportItems.append(ExportItem(devicePath, fileName)); + exportItems.append(ExportItem(devicePath, fileName, index)); + index++; } // Start export with singleton - manager will show its own dialog @@ -346,11 +349,13 @@ void AfcExplorerWidget::onExportClicked() if (!currPath.endsWith("/")) currPath += "/"; + int index = 0; for (QListWidgetItem *item : filesToExport) { QString fileName = item->text(); QString devicePath = currPath == "/" ? "/" + fileName : currPath + fileName; - exportItems.append(ExportItem(devicePath, fileName)); + exportItems.append(ExportItem(devicePath, fileName, index)); + index++; } // Start export with singleton - manager will show its own dialog @@ -869,4 +874,4 @@ void AfcExplorerWidget::goUp() // Add the new path to history and load it m_history.push(parentPath); loadPath(parentPath); -} \ No newline at end of file +} diff --git a/src/appcontext.cpp b/src/appcontext.cpp index 13b41eb..970f8eb 100644 --- a/src/appcontext.cpp +++ b/src/appcontext.cpp @@ -66,24 +66,20 @@ AppContext::AppContext(QObject *parent) : QObject{parent} const std::string wifiMacAddress = PlistNavigator(fileData)["WiFiMACAddress"].getString(); // plist_free(fileData); - qDebug() << "Found pairing file for MAC" - << QString::fromStdString(wifiMacAddress); bool isCompatible = !wifiMacAddress.empty(); // TODO: !important invalidate old pairing files - // libimobiledevice does not append WIFIMACAddress to the pairing file + // sometimes there is no WiFiMACAddress if (!isCompatible) { continue; } + qDebug() << "Found pairing file for MAC" + << QString::fromStdString(wifiMacAddress); - IdevicePairingFile *pairing_file = nullptr; - idevice_pairing_file_read( - lockdowndir.filePath(fileName).toUtf8().constData(), &pairing_file); - if (pairing_file) { - qDebug() << "Caching pairing file for MAC" - << QString::fromStdString(wifiMacAddress); - m_pairingFileCache[QString::fromStdString(wifiMacAddress)] = - pairing_file; - } + qDebug() << "Caching pairing file for MAC" + << QString::fromStdString(wifiMacAddress) << "Local Path" + << lockdowndir.filePath(fileName); + m_pairingFileCache[QString::fromStdString(wifiMacAddress)] = + lockdowndir.filePath(fileName); } } @@ -99,14 +95,24 @@ void AppContext::addDevice(QString udid, addType, wifiMacAddress, initResult]() { if (addType == AddType::UpgradeToWireless) { - const IdevicePairingFile *pairingFile = - getCachedPairingFile(udid); - if (!pairingFile) { + // udid is mac address here + const QString _pairingFilePath = getCachedPairingFile(udid); + + if (_pairingFilePath.isEmpty()) { + qDebug() << "Cannot upgrade to wireless, no cached pairing " + "file for" + << udid; + return; + } + + QFile pairingFilePath(_pairingFilePath); + if (!pairingFilePath.exists()) { qDebug() << "Cannot upgrade to wireless, no pairing file for" << udid; return; } + pairingFilePath.close(); QList networkDevices = NetworkDeviceManager::sharedInstance() @@ -121,15 +127,8 @@ void AppContext::addDevice(QString udid, if (it != networkDevices.constEnd()) { - IdevicePairingFile *pairing_file = nullptr; - idevice_pairing_file_read( - QString("/var/lib/lockdown/%1.plist") - .arg(udid) - .toUtf8() - .constData(), - &pairing_file); *initResult = init_idescriptor_device( - udid, {it->address, pairing_file}); + udid, {it->address, pairingFilePath.fileName()}); } else { qDebug() << "No network device found with MAC address:" << wifiMacAddress; @@ -137,19 +136,29 @@ void AppContext::addDevice(QString udid, } } else if (addType == AddType::Wireless) { // FIXME: its not udid here its macAddress - const IdevicePairingFile *pairingFile = - getCachedPairingFile(udid); - if (!pairingFile) { - qDebug() << "Cannot initialize wireless device, no pairing " + const QString _pairingFilePath = getCachedPairingFile(udid); + + if (_pairingFilePath.isEmpty()) { + qDebug() << "Cannot upgrade to wireless, no cached pairing " "file for" << udid; return; } + QFile pairingFilePath(_pairingFilePath); + if (!pairingFilePath.exists()) { + qDebug() + << "Cannot upgrade to wireless, no pairing file for" + << udid; + return; + } + pairingFilePath.close(); + QList networkDevices = NetworkDeviceManager::sharedInstance() ->m_networkProvider->getNetworkDevices(); + // todo : retry logic if not found auto it = std::find_if( networkDevices.constBegin(), networkDevices.constEnd(), [wifiMacAddress](const NetworkDevice &device) { @@ -159,7 +168,7 @@ void AppContext::addDevice(QString udid, if (it != networkDevices.constEnd()) { *initResult = init_idescriptor_device( - udid, {it->address, pairingFile}); + udid, {it->address, pairingFilePath.fileName()}); } else { qDebug() << "No network device found with MAC address:" << wifiMacAddress; @@ -473,14 +482,13 @@ AppContext::getDeviceByMacAddress(const QString &macAddress) const } void AppContext::cachePairingFile(const QString &udid, - IdevicePairingFile *pairingFile) + const QString &pairingFilePath) { - m_pairingFileCache.insert(udid, pairingFile); + m_pairingFileCache.insert(udid, pairingFilePath); } -const IdevicePairingFile * -AppContext::getCachedPairingFile(const QString &udid) const +const QString AppContext::getCachedPairingFile(const QString &udid) const { - const IdevicePairingFile *pairingFile = nullptr; + QString pairingFile; // Retrieve the pairing file from the cache if (m_pairingFileCache.contains(udid)) { @@ -489,3 +497,8 @@ AppContext::getCachedPairingFile(const QString &udid) const return pairingFile; } + +void AppContext::heartbeatFailed(const QString &macAddress, int tries) +{ + emit deviceHeartbeatFailed(macAddress, tries); +} \ No newline at end of file diff --git a/src/appcontext.h b/src/appcontext.h index c4d5200..49d2584 100644 --- a/src/appcontext.h +++ b/src/appcontext.h @@ -34,8 +34,9 @@ public: QList getAllDevices(); explicit AppContext(QObject *parent = nullptr); bool noDevicesConnected() const; - void cachePairingFile(const QString &udid, IdevicePairingFile *pairingFile); - const IdevicePairingFile *getCachedPairingFile(const QString &udid) const; + // QMap + void cachePairingFile(const QString &udid, const QString &pairingFilePath); + const QString getCachedPairingFile(const QString &udid) const; // #ifdef ENABLE_RECOVERY_DEVICE_SUPPORT // QList getAllRecoveryDevices(); @@ -55,8 +56,7 @@ private: // #endif QStringList m_pendingDevices; DeviceSelection m_currentSelection = DeviceSelection(""); - // FIXME: QString can be macAddress or udid - both works fine for now - QMap m_pairingFileCache; + QMap m_pairingFileCache; signals: void deviceAdded(iDescriptorDevice *device); void deviceRemoved(const std::string &udid, const std::string &macAddress); @@ -79,11 +79,14 @@ signals: */ void deviceChange(); void currentDeviceSelectionChanged(const DeviceSelection &selection); + void deviceHeartbeatFailed(const QString &macAddress, int tries); public slots: void removeDevice(QString udid); void addDevice(QString udid, DeviceMonitorThread::IdeviceConnectionType connType, AddType addType, QString wifiMacAddress = QString()); + void heartbeatFailed(const QString &macAddress, int tries); + // void heartbeatThreadExited(const QString &macAddress); #ifdef ENABLE_RECOVERY_DEVICE_SUPPORT void addRecoveryDevice(uint64_t ecid); void removeRecoveryDevice(uint64_t ecid); diff --git a/src/core/services/init_device.cpp b/src/core/services/init_device.cpp index bfc94d7..b502eee 100644 --- a/src/core/services/init_device.cpp +++ b/src/core/services/init_device.cpp @@ -24,16 +24,15 @@ #ifdef ENABLE_RECOVERY_DEVICE_SUPPORT #include "libirecovery.h" #endif -#include -#include - #include "../../heartbeat.h" +#include #include #include -#include - #include +#include +#include #include + std::string safeGetXML(const char *key, pugi::xml_node dict) { for (pugi::xml_node child = dict.first_child(); child; @@ -365,15 +364,12 @@ DeviceInfo fullDeviceInfo(const pugi::xml_document &doc, } } -// FIXME:spawn on a new thread? -// wireless connections sometimes take more than 10sec to connect -// and ofc it freezes the ui -// TODO:idevice_start_session ? iDescriptorInitDeviceResult -init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) +init_idescriptor_device(const QString &udid, + const WirelessInitArgs &wirelessArgs) { const bool isWireless = - !wirelessArgs.ip.isEmpty() && wirelessArgs.pairing_file; + !wirelessArgs.ip.isEmpty() && !wirelessArgs.pairing_file.isEmpty(); qDebug() << "Initializing iDescriptor device with UDID: " << udid << (isWireless ? "over wireless" : "over USB"); @@ -391,16 +387,13 @@ init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) IdevicePairingFile *pairing_file = nullptr; IdeviceHandle *deviceHandle = nullptr; HeartbeatClientHandle *heartbeat = nullptr; - HeartBeatThread *heartbeatThread = nullptr; + HeartbeatThread *heartbeatThread = nullptr; ImageMounterHandle *image_mounter = nullptr; DiagnosticsRelayClientHandle *diagnostics_relay = nullptr; ScreenshotrClientHandle *screenshotr_client = nullptr; LocationSimulationHandle *location_simulation = nullptr; - // FIXME: remove debug - std::stringstream ss; plist_t val = nullptr; - // 1. Connect to usbmuxd IdeviceFfiError *err = idevice_usbmuxd_new_default_connection(0, &usbmuxd_conn); if (err) { @@ -410,7 +403,6 @@ init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) } } - // 2. Create default address handle err = idevice_usbmuxd_default_addr_new(&addr_handle); if (err) { qDebug() << "Failed to create address handle"; @@ -422,16 +414,20 @@ init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) struct sockaddr_in addr_in; memset(&addr_in, 0, sizeof(addr_in)); addr_in.sin_family = AF_INET; - addr_in.sin_port = htons(0); // Port doesn't matter for provider + addr_in.sin_port = htons(0); inet_pton(AF_INET, wirelessArgs.ip.toUtf8().constData(), &addr_in.sin_addr); - // IdevicePairingFile *pairing_file = nullptr; - // idevice_pairing_file_read( - // wirelessArgs.pairing_file.toUtf8().constData(), &pairing_file); + err = idevice_pairing_file_read( + wirelessArgs.pairing_file.toUtf8().constData(), &pairing_file); + if (err) { + qDebug() << "Failed to read pairing file"; + goto cleanup; + } + err = idevice_tcp_provider_new( (const idevice_sockaddr *)&addr_in, - const_cast(wirelessArgs.pairing_file), - APP_LABEL, &provider); + const_cast(pairing_file), APP_LABEL, + &provider); if (err) { qDebug() << "Failed to create wireless provider"; goto cleanup; @@ -441,7 +437,8 @@ init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) qDebug() << "Failed to start Heartbeat service"; goto cleanup; } - heartbeatThread = new HeartBeatThread(heartbeat); + // udid is mac address here for wireless + heartbeatThread = new HeartbeatThread(heartbeat, udid); heartbeatThread->start(); while (!heartbeatThread->initialCompleted()) { @@ -539,16 +536,7 @@ init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) // goto cleanup; // } get_device_info_xml(udid.toUtf8().constData(), lockdown, infoXml); - // infoXml.print(ss, " "); // " " for indentation - // qDebug().noquote() << "--- Full Device Info XML ---" - // << QString::fromStdString(ss.str()); - // Received plist: { - // Domain: "com.apple.mobile.wireless_lockdown", - // Key: "EnableWifiConnections", - // Request: "GetValue", - // Value: true - // } lockdownd_get_value(lockdown, "EnableWifiConnections", "com.apple.mobile.wireless_lockdown", &val); if (val) @@ -564,10 +552,14 @@ init_idescriptor_device(const QString &udid, WirelessInitArgs wirelessArgs) result.diagRelay = std::make_shared( DiagnosticsRelay::adopt(diagnostics_relay)); result.locationSimulation = location_simulation; - AppContext::sharedInstance()->cachePairingFile(udid, pairing_file); + // TODO cache pairing file path result.deviceInfo.isWireless = isWireless; fullDeviceInfo(infoXml, afc_client, result.diagRelay.get(), result); - + ::QObject::connect(heartbeatThread, &HeartbeatThread::heartbeatFailed, + AppContext::sharedInstance(), + &AppContext::heartbeatFailed); + ::QObject::connect(heartbeatThread, &HeartbeatThread::heartbeatThreadExited, + AppContext::sharedInstance(), &AppContext::removeDevice); cleanup: // Cleanup on error // FIXME: implement proper cleanup diff --git a/src/devicemanagerwidget.cpp b/src/devicemanagerwidget.cpp index 9a9accd..aedfe34 100644 --- a/src/devicemanagerwidget.cpp +++ b/src/devicemanagerwidget.cpp @@ -145,8 +145,9 @@ void DeviceManagerWidget::addDevice(iDescriptorDevice *device) QString tabTitle = QString::fromStdString(device->deviceInfo.productType); m_stackedWidget->addWidget(deviceWidget); - m_deviceWidgets[device->udid] = - std::pair{deviceWidget, m_sidebar->addDevice(tabTitle, device->udid)}; + m_deviceWidgets[device->udid] = std::pair{ + deviceWidget, m_sidebar->addDevice(tabTitle, device->udid, + device->deviceInfo.isWireless)}; } // #ifdef ENABLE_RECOVERY_DEVICE_SUPPORT diff --git a/src/devicesidebarwidget.cpp b/src/devicesidebarwidget.cpp index d3afda0..028bd79 100644 --- a/src/devicesidebarwidget.cpp +++ b/src/devicesidebarwidget.cpp @@ -26,9 +26,10 @@ // DeviceSidebarItem Implementation DeviceSidebarItem::DeviceSidebarItem(const QString &deviceName, - const std::string &uuid, QWidget *parent) + const std::string &uuid, bool isWireless, + QWidget *parent) : QFrame(parent), m_deviceName(deviceName), m_uuid(uuid), m_selected(false), - m_collapsed(false) + m_wireless(isWireless), m_collapsed(false) { setupUI(); setFrameStyle(QFrame::StyledPanel); @@ -51,10 +52,20 @@ void DeviceSidebarItem::setupUI() [this]() { emit deviceSelected(m_uuid); }); // Device name label + QHBoxLayout *nameLayout = new QHBoxLayout(); + nameLayout->setContentsMargins(0, 0, 0, 0); m_deviceLabel = new QLabel(m_deviceName); m_deviceLabel->setStyleSheet("QLabel { font-weight: bold; }"); m_deviceLabel->setWordWrap(true); - headerLayout->addWidget(m_deviceLabel); + nameLayout->addWidget(m_deviceLabel); + if (m_wireless) { + auto wirelessIcon = new ZIconLabel( + QIcon(":/resources/icons/QlementineIconsWireless116.png"), + "Wireless", this); + nameLayout->setSpacing(5); + nameLayout->addWidget(wirelessIcon); + } + headerLayout->addLayout(nameLayout); // Toggle button m_toggleButton = new QPushButton(); @@ -292,9 +303,11 @@ DeviceSidebarWidget::DeviceSidebarWidget(QWidget *parent) } DeviceSidebarItem *DeviceSidebarWidget::addDevice(const QString &deviceName, - const std::string &uuid) + const std::string &uuid, + bool isWireless) { - DeviceSidebarItem *item = new DeviceSidebarItem(deviceName, uuid, this); + DeviceSidebarItem *item = + new DeviceSidebarItem(deviceName, uuid, isWireless, this); // Connect to unified handler connect(item, &DeviceSidebarItem::deviceSelected, this, diff --git a/src/devicesidebarwidget.h b/src/devicesidebarwidget.h index fe3ade1..846faa3 100644 --- a/src/devicesidebarwidget.h +++ b/src/devicesidebarwidget.h @@ -36,7 +36,7 @@ class DeviceSidebarItem : public QFrame public: explicit DeviceSidebarItem(const QString &deviceName, - const std::string &uuid, + const std::string &uuid, bool isWireless, QWidget *parent = nullptr); const std::string &getDeviceUuid() const; @@ -67,6 +67,7 @@ private: QWidget *m_optionsWidget; QPushButton *m_toggleButton; QLabel *m_deviceLabel; + bool m_wireless; // Navigation buttons QPushButton *m_infoButton; @@ -164,7 +165,7 @@ public: // Unified interface DeviceSidebarItem *addDevice(const QString &deviceName, - const std::string &uuid); + const std::string &uuid, bool isWireless); DevicePendingSidebarItem *addPendingDevice(const QString &uuid); RecoveryDeviceSidebarItem *addRecoveryDevice(uint64_t ecid); diff --git a/src/exportmanager.cpp b/src/exportmanager.cpp index adef72b..9422558 100644 --- a/src/exportmanager.cpp +++ b/src/exportmanager.cpp @@ -18,8 +18,9 @@ */ #include "exportmanager.h" -#include "exportprogressdialog.h" #include "servicemanager.h" +#include "statusballoon.h" +#include #include #include #include @@ -32,29 +33,23 @@ ExportManager *ExportManager::sharedInstance() static ExportManager self; return &self; } +// TODO: unfinished -ExportManager::ExportManager(QObject *parent) : QObject(parent) -{ - // The singleton now creates and owns the dialog. - // No parent is passed, so it's a top-level window. - m_exportProgressDialog = new ExportProgressDialog(this, nullptr); -} +ExportManager::ExportManager(QObject *parent) : QObject(parent) {} ExportManager::~ExportManager() { // Cancel all active jobs QMutexLocker locker(&m_jobsMutex); - for (auto jobPtr : m_activeJobs) { - jobPtr->cancelRequested = true; - if (jobPtr->watcher) { - jobPtr->watcher->cancel(); - jobPtr->watcher->waitForFinished(); - } - delete jobPtr; - } + // for (auto jobPtr : m_activeJobs) { + // jobPtr->cancelRequested = true; + // if (jobPtr->watcher) { + // jobPtr->watcher->cancel(); + // jobPtr->watcher->waitForFinished(); + // } + // // delete jobPtr; + // } m_activeJobs.clear(); - - // The dialog will be deleted automatically due to parent-child relationship } QUuid ExportManager::startExport(iDescriptorDevice *device, @@ -62,6 +57,8 @@ QUuid ExportManager::startExport(iDescriptorDevice *device, const QString &destinationPath, std::optional altAfc) { + qDebug() << "startExport() entry - items:" << items.size() + << "dest:" << destinationPath; if (!device || !device->mutex) { qWarning() << "Invalid device provided to ExportManager"; return QUuid(); @@ -89,32 +86,30 @@ QUuid ExportManager::startExport(iDescriptorDevice *device, job->items = items; job->destinationPath = destinationPath; job->altAfc = altAfc; - job->watcher = new QFutureWatcher(this); - const QUuid jobId = job->jobId; + // fixme : pass ExportJob + job->statusBalloonProcessId = + StatusBalloon::sharedInstance()->startExportProcess( + QString("Exporting %1 items").arg(items.size()), items.size(), + destinationPath); - connect(job->watcher, &QFutureWatcher::finished, this, - [this, jobId]() { cleanupJob(jobId); }); + // Use ExportManager's own jobId for its internal tracking and signals + const QUuid managerJobId = job->jobId; + + // todo:cleanupJob ? + // connect(job->watcher, &QFutureWatcher::finished, this, + // [this, managerJobId]() { cleanupJob(managerJobId); }); // Store job before starting { QMutexLocker locker(&m_jobsMutex); - m_activeJobs[jobId] = job; + m_activeJobs[managerJobId] = job; } - emit exportStarted(jobId, items.size(), destinationPath); - - // The manager now shows its own dialog - m_exportProgressDialog->showForJob(jobId); - - ExportJob *jobPtr = m_activeJobs[jobId]; - jobPtr->future = - QtConcurrent::run([this, jobPtr]() { executeExportJob(jobPtr); }); - jobPtr->watcher->setFuture(jobPtr->future); - - qDebug() << "Started export job" << jobId << "for" << items.size() + m_exportThread->executeExportJob(job); + qDebug() << "Started export job" << managerJobId << "for" << items.size() << "items"; - return jobId; + return managerJobId; } void ExportManager::cancelExport(const QUuid &jobId) @@ -139,198 +134,7 @@ bool ExportManager::isJobRunning(const QUuid &jobId) const return m_activeJobs.contains(jobId); } -void ExportManager::executeExportJob(ExportJob *job) -{ - ExportJobSummary summary; - summary.jobId = job->jobId; - summary.totalItems = job->items.size(); - summary.destinationPath = job->destinationPath; - - qDebug() << "Executing export job" << job->jobId << "with" - << job->items.size() << "items"; - - for (int i = 0; i < job->items.size(); ++i) { - // Check for cancellation - if (job->cancelRequested.load()) { - summary.wasCancelled = true; - qDebug() << "Export job" << job->jobId << "was cancelled"; - emit exportCancelled(job->jobId); - return; - } - - const ExportItem &item = job->items.at(i); - - emit exportProgress(job->jobId, i + 1, job->items.size(), - item.suggestedFileName); - - ExportResult result = - exportSingleItem(job->device, item, job->destinationPath, - job->altAfc, job->cancelRequested, job->jobId); - - if (result.success) { - summary.successfulItems++; - summary.totalBytesTransferred += result.bytesTransferred; - } else { - summary.failedItems++; - } - - emit itemExported(job->jobId, result); - - // Check for cancellation again after potentially long file operation - if (job->cancelRequested.load()) { - summary.wasCancelled = true; - qDebug() << "Export job" << job->jobId - << "was cancelled during execution"; - emit exportCancelled(job->jobId); - return; - } - } - - qDebug() << "Export job" << job->jobId - << "completed - Success:" << summary.successfulItems - << "Failed:" << summary.failedItems - << "Bytes:" << summary.totalBytesTransferred; - - emit exportFinished(job->jobId, summary); -} -// TODO: implement -ExportResult ExportManager::exportSingleItem( - iDescriptorDevice *device, const ExportItem &item, - const QString &destinationDir, std::optional altAfc, - std::atomic &cancelRequested, const QUuid &jobId) -{ - ExportResult result; - result.sourceFilePath = item.sourcePathOnDevice; - - // // Generate output path - // QString outputPath = - // QDir(destinationDir).filePath(item.suggestedFileName); outputPath = - // generateUniqueOutputPath(outputPath); result.outputFilePath = outputPath; - - // // Get file size first - // char **info = nullptr; - // afc_error_t infoResult = ServiceManager::safeAfcGetFileInfo( - // device, item.sourcePathOnDevice.toUtf8().constData(), &info, altAfc); - - // qint64 totalFileSize = 0; - // if (infoResult == AFC_E_SUCCESS && info) { - // for (int i = 0; info[i]; i += 2) { - // if (strcmp(info[i], "st_size") == 0) { - // totalFileSize = QString::fromUtf8(info[i + 1]).toLongLong(); - // break; - // } - // } - // afc_dictionary_free(info); - // } - - // // Open file on device - // uint64_t handle = 0; - // afc_error_t openResult = ServiceManager::safeAfcFileOpen( - // device, item.sourcePathOnDevice.toUtf8().constData(), - // AFC_FOPEN_RDONLY, &handle, altAfc); - - // if (openResult != AFC_E_SUCCESS) { - // result.errorMessage = - // QString("Failed to open file on device: %1 (AFC error: %2)") - // .arg(item.sourcePathOnDevice) - // .arg(static_cast(openResult)); - // return result; - // } - - // // Open local output file - // QFile outputFile(outputPath); - // if (!outputFile.open(QIODevice::WriteOnly)) { - // result.errorMessage = QString("Failed to create local file: %1 (%2)") - // .arg(outputPath) - // .arg(outputFile.errorString()); - // ServiceManager::safeAfcFileClose(device, handle, altAfc); - // return result; - // } - - // char buffer[8192]; - // uint32_t bytesRead = 0; - // qint64 totalBytes = 0; - - // while (true) { - // // Check for cancellation during file copy - // if (cancelRequested.load()) { - // outputFile.close(); - // outputFile.remove(); // Clean up partial file - // ServiceManager::safeAfcFileClose(device, handle, altAfc); - // result.errorMessage = "Export cancelled by user"; - // return result; - // } - - // afc_error_t readResult = ServiceManager::safeAfcFileRead( - // device, handle, buffer, sizeof(buffer), &bytesRead, altAfc); - - // if (readResult != AFC_E_SUCCESS || bytesRead == 0) { - // break; // End of file or error - // } - - // qint64 bytesWritten = outputFile.write(buffer, bytesRead); - // if (bytesWritten != bytesRead) { - // result.errorMessage = - // QString("Write error: only wrote %1 of %2 bytes") - // .arg(bytesWritten) - // .arg(bytesRead); - // outputFile.close(); - // outputFile.remove(); // Clean up partial file - // ServiceManager::safeAfcFileClose(device, handle, altAfc); - // return result; - // } - - // totalBytes += bytesRead; - - // // Emit progress update every 64KB or at end of file - // if (totalBytes % (64 * 1024) == 0 || totalBytes == totalFileSize) { - // emit fileTransferProgress(jobId, item.suggestedFileName, - // totalBytes, - // totalFileSize); - // } - // } - - // // Clean up - // outputFile.close(); - // ServiceManager::safeAfcFileClose(device, handle, altAfc); - - // if (totalBytes == 0) { - // result.errorMessage = "No data read from device file"; - // outputFile.remove(); // Clean up empty file - // return result; - // } - - result.success = true; - // result.bytesTransferred = totalBytes; - return result; -} - -QString ExportManager::generateUniqueOutputPath(const QString &basePath) const -{ - if (!QFile::exists(basePath)) { - return basePath; - } - - QFileInfo fileInfo(basePath); - QString baseName = fileInfo.completeBaseName(); - QString suffix = fileInfo.suffix(); - QString directory = fileInfo.absolutePath(); - - int counter = 1; - QString uniquePath; - - do { - QString newName = QString("%1_%2").arg(baseName).arg(counter); - if (!suffix.isEmpty()) { - newName += "." + suffix; - } - uniquePath = QDir(directory).filePath(newName); - counter++; - } while (QFile::exists(uniquePath) && counter < 10000); - - return uniquePath; -} - +// TODO: is not being used ? QString ExportManager::extractFileName(const QString &devicePath) const { int lastSlash = devicePath.lastIndexOf('/'); @@ -344,13 +148,13 @@ void ExportManager::cleanupJob(const QUuid &jobId) { QMutexLocker locker(&m_jobsMutex); auto it = m_activeJobs.find(jobId); - if (it != m_activeJobs.end()) { - if (it.value()->watcher) { - it.value()->watcher->deleteLater(); - } + // if (it != m_activeJobs.end()) { + // if (it.value()->watcher) { + // it.value()->watcher->deleteLater(); + // } - delete it.value(); - m_activeJobs.erase(it); - qDebug() << "Cleaned up export job" << jobId; - } -} + // // delete it.value(); + // m_activeJobs.erase(it); + // qDebug() << "Cleaned up export job" << jobId; + // } +} \ No newline at end of file diff --git a/src/exportmanager.h b/src/exportmanager.h index 7594a18..4926435 100644 --- a/src/exportmanager.h +++ b/src/exportmanager.h @@ -20,6 +20,7 @@ #ifndef EXPORTMANAGER_H #define EXPORTMANAGER_H +#include "exportmanagerthread.h" #include "iDescriptor.h" #include #include @@ -35,35 +36,6 @@ // Forward declaration class ExportProgressDialog; -struct ExportItem { - QString sourcePathOnDevice; - QString suggestedFileName; - - ExportItem() = default; - ExportItem(const QString &sourcePath, const QString &fileName) - : sourcePathOnDevice(sourcePath), suggestedFileName(fileName) - { - } -}; - -struct ExportResult { - QString sourceFilePath; - QString outputFilePath; - bool success = false; - QString errorMessage; - qint64 bytesTransferred = 0; -}; - -struct ExportJobSummary { - QUuid jobId; - int totalItems = 0; - int successfulItems = 0; - int failedItems = 0; - qint64 totalBytesTransferred = 0; - QString destinationPath; - bool wasCancelled = false; -}; - class ExportManager : public QObject { Q_OBJECT @@ -85,7 +57,10 @@ public: bool isExporting() const; bool isJobRunning(const QUuid &jobId) const; + static QString generateUniqueOutputPath(const QString &basePath); + // todo: should we delete this in ~ExportManager? + ExportManagerThread *m_exportThread = new ExportManagerThread(this); signals: void exportStarted(const QUuid &jobId, int totalItems, @@ -108,28 +83,8 @@ private: explicit ExportManager(QObject *parent = nullptr); ~ExportManager(); - struct ExportJob { - QUuid jobId; - iDescriptorDevice *device = nullptr; - QList items; - QString destinationPath; - std::optional altAfc; - std::atomic cancelRequested{false}; - QFuture future; - QFutureWatcher *watcher = nullptr; - }; - void executeExportJob(ExportJob *job); - ExportResult exportSingleItem(iDescriptorDevice *device, - const ExportItem &item, - const QString &destinationDir, - std::optional altAfc, - std::atomic &cancelRequested, - const QUuid &jobId); - - QString generateUniqueOutputPath(const QString &basePath) const; - QString extractFileName(const QString &devicePath) const; void cleanupJob(const QUuid &jobId); diff --git a/src/exportmanagerthread.cpp b/src/exportmanagerthread.cpp new file mode 100644 index 0000000..278f709 --- /dev/null +++ b/src/exportmanagerthread.cpp @@ -0,0 +1,166 @@ + +#include "exportmanagerthread.h" +#include "iDescriptor.h" +#include "servicemanager.h" +#include +#include +#include +#include + +// TODO: unfinished +void ExportManagerThread::executeExportJob(ExportJob *job) +{ + // FIXME: limit to 1 at a time + QtConcurrent::run([this, job]() { executeExportJobInternal(job); }); +} + +void ExportManagerThread::executeExportJobInternal(ExportJob *job) +{ + qDebug() << "Worker thread started for export job" << job->jobId; + ExportJobSummary summary; + summary.jobId = job->jobId; + summary.totalItems = job->items.size(); + summary.destinationPath = job->destinationPath; + + qDebug() << "Executing export job" << job->jobId << "with" + << job->items.size() << "items"; + + for (int i = 0; i < job->items.size(); ++i) { + // todo:Check for cancellation + // if (job->cancelRequested.load() || + // balloon->isCancelRequested( + // job->statusBalloonProcessId)) { // Use + // // statusBalloonProcessId + // summary.wasCancelled = true; + // qDebug() << "Export job" << job->jobId << "was cancelled"; + + // emit exportCancelled(job->jobId); + // return; + // } + + const ExportItem &item = job->items.at(i); + + // emit exportProgress(job->jobId, i + 1, job->items.size(), + // item.suggestedFileName); + + ExportResult result = exportSingleItem( + job->device, item, job->destinationPath, job->altAfc, + job->cancelRequested, job->statusBalloonProcessId); + if (result.success) { + summary.successfulItems++; + summary.totalBytesTransferred += result.bytesTransferred; + } else { + summary.failedItems++; + } + + emit itemExported(job->jobId, result); + + // // Check for cancellation again after potentially long file + // // operation + // if (job->cancelRequested.load() || + // balloon->isCancelRequested( + // job->statusBalloonProcessId)) { // Use + // // statusBalloonProcessId + // summary.wasCancelled = true; + // qDebug() << "Export job" << job->jobId + // << "was cancelled during execution"; + + // QMetaObject::invokeMethod( + // QCoreApplication::instance(), + // [balloon,2 + // id = + // job->statusBalloonProcessId]() { // Use + // // + // statusBalloonProcessId + // balloon->markProcessCancelled(id); + // }, + // Qt::QueuedConnection); + + // emit exportCancelled(job->jobId); + // return; + // } + } + + qDebug() << "Export job" << job->jobId + << "completed - Success:" << summary.successfulItems + << "Failed:" << summary.failedItems + << "Bytes:" << summary.totalBytesTransferred; + + emit exportFinished(job->jobId, summary); +} + +ExportResult ExportManagerThread::exportSingleItem( + iDescriptorDevice *device, const ExportItem &item, + const QString &destinationDir, std::optional altAfc, + std::atomic &cancelRequested, + const QUuid &statusBalloonProcessId) // Change parameter name and type +{ + ExportResult result; + result.sourceFilePath = item.sourcePathOnDevice; + + // Generate output path + QString outputPath = QDir(destinationDir).filePath(item.suggestedFileName); + // todo problem + outputPath = generateUniqueOutputPath(outputPath); + result.outputFilePath = outputPath; + + // Progress callback + const QString ¤tFile = item.suggestedFileName; + int fileIndex = item.itemIndex; + auto progressCallback = + [this, statusBalloonProcessId, fileIndex, + currentFile](qint64 transferred, // Use statusBalloonProcessId + qint64 total) { + qDebug() << "Export progress callback for" << fileIndex + << "- transferred:" << transferred << "total:" << total; + emit fileTransferProgress(statusBalloonProcessId, fileIndex, + currentFile, transferred, total); + }; + + qDebug() << "About to export file from device:" << item.sourcePathOnDevice + << "to" << outputPath; + // Export file using ServiceManager + IdeviceFfiError *err = ServiceManager::exportFileToPath( + device, item.sourcePathOnDevice.toUtf8().constData(), + outputPath.toUtf8().constData(), progressCallback, &cancelRequested); + + if (err != nullptr) { + result.errorMessage = + QString("Failed to export file: %1").arg(err->message); + qDebug() << result.errorMessage; + idevice_error_free(err); + return result; + } + + // Get file size for statistics + QFileInfo fileInfo(outputPath); + result.bytesTransferred = fileInfo.size(); + result.success = true; + + return result; +} +QString ExportManagerThread::generateUniqueOutputPath(const QString &basePath) +{ + if (!QFile::exists(basePath)) { + return basePath; + } + + QFileInfo fileInfo(basePath); + QString baseName = fileInfo.completeBaseName(); + QString suffix = fileInfo.suffix(); + QString directory = fileInfo.absolutePath(); + + int counter = 1; + QString uniquePath; + + do { + QString newName = QString("%1_%2").arg(baseName).arg(counter); + if (!suffix.isEmpty()) { + newName += "." + suffix; + } + uniquePath = QDir(directory).filePath(newName); + counter++; + } while (QFile::exists(uniquePath) && counter < 10000); + + return uniquePath; +} \ No newline at end of file diff --git a/src/exportmanagerthread.h b/src/exportmanagerthread.h new file mode 100644 index 0000000..4e3c5b4 --- /dev/null +++ b/src/exportmanagerthread.h @@ -0,0 +1,40 @@ +#ifndef EXPORTMANAGERTHREAD_H +#define EXPORTMANAGERTHREAD_H +#include "iDescriptor.h" +#include "servicemanager.h" +#include +#include +#include + +class ExportManager; + +using namespace IdeviceFFI; + +class ExportManagerThread : public QObject +{ + Q_OBJECT +public: + ExportManagerThread(QObject *parent = nullptr) : QObject(parent) {} + + void executeExportJob(ExportJob *job); + ExportResult exportSingleItem(iDescriptorDevice *device, + const ExportItem &item, + const QString &destinationDir, + std::optional altAfc, + std::atomic &cancelRequested, + const QUuid &statusBalloonProcessId); + +private: + void executeExportJobInternal(ExportJob *job); + QString generateUniqueOutputPath(const QString &basePath); +signals: + void exportProgress(const QUuid &jobId, int currentItem, int totalItems, + const QString ¤tFileName); + void fileTransferProgress(const QUuid &jobId, int fileIndex, + const QString ¤tFile, + qint64 bytesTransferred, qint64 totalFileSize); + void itemExported(const QUuid &jobId, const ExportResult &result); + void exportFinished(const QUuid &jobId, const ExportJobSummary &summary); + void exportCancelled(const QUuid &jobId); +}; +#endif // EXPORTMANAGERTHREAD_H diff --git a/src/gallerywidget.cpp b/src/gallerywidget.cpp index f0759ee..c855088 100644 --- a/src/gallerywidget.cpp +++ b/src/gallerywidget.cpp @@ -18,7 +18,7 @@ */ #include "gallerywidget.h" -// #include "exportmanager.h" +#include "exportmanager.h" #include "iDescriptor.h" #include "mediapreviewdialog.h" #include "photomodel.h" @@ -42,6 +42,7 @@ #include #include +// todo: dont load paths on main thread, handle /* FIXME: this needs to be refactored once we figure out how to query Photos.sqlite @@ -85,7 +86,7 @@ GalleryWidget::GalleryWidget(iDescriptorDevice *device, QWidget *parent) m_retryButton = new QPushButton("Retry", this); connect(m_retryButton, &QPushButton::clicked, this, [this]() { m_stackedWidget->setCurrentWidget(m_loadingWidget); - QTimer::singleShot(100, this, &GalleryWidget::loadAlbumList); + QTimer::singleShot(100, this, &GalleryWidget::reload); }); errorLayout->addWidget(m_retryButton, 0, Qt::AlignCenter); m_errorWidget = new QWidget(); @@ -95,6 +96,13 @@ GalleryWidget::GalleryWidget(iDescriptorDevice *device, QWidget *parent) m_stackedWidget->setCurrentWidget(m_loadingWidget); setControlsEnabled(false); // Disable controls until album is selected } + +void GalleryWidget::reload() +{ + m_loaded = false; + load(); +} + /*Load is called when the tab is active*/ void GalleryWidget::load() { @@ -102,8 +110,16 @@ void GalleryWidget::load() return; m_loaded = true; + qDebug() << "Before reading DCIM directory"; - loadAlbumList(); + auto *watcher = new QFutureWatcher(this); + auto future = ServiceManager::getFileTreeAsync(m_device, "/DCIM", true); + watcher->setFuture(future); + + connect(watcher, &QFutureWatcher::finished, [this, watcher]() { + watcher->deleteLater(); + loadAlbumList(watcher->result()); + }); } void GalleryWidget::setupControlsLayout() @@ -112,6 +128,8 @@ void GalleryWidget::setupControlsLayout() m_controlsLayout->setSpacing(5); m_controlsLayout->setContentsMargins(7, 7, 7, 7); + m_importButton = new QPushButton("Import"); + // Sort order combo box QLabel *sortLabel = new QLabel("Sort:"); sortLabel->setStyleSheet("font-weight: bold;"); @@ -128,13 +146,13 @@ void GalleryWidget::setupControlsLayout() QLabel *filterLabel = new QLabel("Filter:"); filterLabel->setStyleSheet("font-weight: bold;"); m_filterComboBox = new QComboBox(); - m_filterComboBox->addItem("All Media", - static_cast(PhotoModel::ImagesOnly)); + m_filterComboBox->addItem("All Media", static_cast(PhotoModel::All)); m_filterComboBox->addItem("Images Only", static_cast(PhotoModel::ImagesOnly)); m_filterComboBox->addItem("Videos Only", static_cast(PhotoModel::VideosOnly)); - m_filterComboBox->setCurrentIndex(2); // Default to All + m_filterComboBox->setCurrentIndex( + static_cast(PhotoModel::All)); // Default to All m_filterComboBox->setMinimumWidth(100); // Ensure text fits m_filterComboBox->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); @@ -164,6 +182,7 @@ void GalleryWidget::setupControlsLayout() // Add widgets to layout m_controlsLayout->addWidget(m_backButton); + m_controlsLayout->addWidget(m_importButton); m_controlsLayout->addWidget(sortLabel); m_controlsLayout->addWidget(m_sortComboBox); m_controlsLayout->addWidget(filterLabel); @@ -222,39 +241,42 @@ void GalleryWidget::onExportSelected() return; } - // if (ExportManager::sharedInstance()->isExporting()) { - // QMessageBox::information(this, "Export in Progress", - // "An export is already in progress."); - // return; - // } + if (ExportManager::sharedInstance()->isExporting()) { + QMessageBox::information(this, "Export in Progress", + "An export is already in progress."); + return; + } - // QModelIndexList selectedIndexes = - // m_listView->selectionModel()->selectedIndexes(); - // QStringList filePaths = m_model->getSelectedFilePaths(selectedIndexes); + QModelIndexList selectedIndexes = + m_listView->selectionModel()->selectedIndexes(); + QStringList filePaths = m_model->getSelectedFilePaths(selectedIndexes); - // if (filePaths.isEmpty()) { - // QMessageBox::information(this, "No Items", - // "No valid items selected for export."); - // return; - // } + if (filePaths.isEmpty()) { + QMessageBox::information(this, "No Items", + "No valid items selected for export."); + return; + } - // QString exportDir = selectExportDirectory(); - // if (exportDir.isEmpty()) { - // return; - // } + QString exportDir = selectExportDirectory(); + if (exportDir.isEmpty()) { + return; + } - // // Convert QStringList to QList - // QList exportItems; - // for (const QString &filePath : filePaths) { - // QString fileName = filePath.split('/').last(); - // exportItems.append(ExportItem(filePath, fileName)); - // } + // Convert QStringList to QList + QList exportItems; + // FIXME: index + int index = 0; + for (const QString &filePath : filePaths) { + QString fileName = filePath.split('/').last(); + exportItems.append(ExportItem(filePath, fileName, index)); + ++index; + } - // qDebug() << "Starting export of selected files:" << exportItems.size() - // << "items to" << exportDir; + qDebug() << "Starting export of selected files:" << exportItems.size() + << "items to" << exportDir; - // ExportManager::sharedInstance()->startExport(m_device, exportItems, - // exportDir); + ExportManager::sharedInstance()->startExport(m_device, exportItems, + exportDir); } void GalleryWidget::onExportAll() @@ -419,12 +441,8 @@ void GalleryWidget::setupPhotoGalleryView() &GalleryWidget::onPhotoContextMenu); } -void GalleryWidget::loadAlbumList() +void GalleryWidget::loadAlbumList(const AFCFileTree &dcimTree) { - qDebug() << "Before reading DCIM directory"; - AFCFileTree dcimTree = - ServiceManager::safeGetFileTree(m_device, "/DCIM", true); - if (!dcimTree.success) { qDebug() << "Failed to read DCIM directory"; m_stackedWidget->setCurrentWidget(m_errorWidget); @@ -433,8 +451,6 @@ void GalleryWidget::loadAlbumList() return; } - m_stackedWidget->setCurrentWidget(m_albumSelectionWidget); - qDebug() << "DCIM directory read successfully, found" << dcimTree.entries.size() << "entries"; @@ -464,6 +480,7 @@ void GalleryWidget::loadAlbumList() } m_albumListView->setModel(albumModel); + m_stackedWidget->setCurrentWidget(m_albumSelectionWidget); } void GalleryWidget::onAlbumSelected(const QString &albumPath) @@ -484,6 +501,8 @@ void GalleryWidget::onAlbumSelected(const QString &albumPath) }); } + connect(m_model, &PhotoModel::thumbnailNeedsToBeLoaded, m_model, + &PhotoModel::requestThumbnail, Qt::QueuedConnection); // Set album path and load photos m_model->setAlbumPath(albumPath); @@ -497,9 +516,16 @@ void GalleryWidget::onAlbumSelected(const QString &albumPath) void GalleryWidget::onBackToAlbums() { + if (m_model) { + disconnect(m_model, &PhotoModel::thumbnailNeedsToBeLoaded, m_model, + &PhotoModel::requestThumbnail); + } + // Switch back to album selection view m_stackedWidget->setCurrentWidget(m_albumSelectionWidget); - m_model->clear(); + if (m_model) { + m_model->clear(); + } // Disable controls and hide back button setControlsEnabled(false); @@ -566,6 +592,7 @@ QIcon GalleryWidget::loadAlbumThumbnail(const QString &albumPath) if (firstImagePath.endsWith(".HEIC", Qt::CaseInsensitive)) { qDebug() << "Loading HEIC thumbnail from:" << firstImagePath; + // FIXME: move to servicemanager thumbnail = load_heic(imageData); } else { // Load regular image formats diff --git a/src/gallerywidget.h b/src/gallerywidget.h index d8073e0..d1976ee 100644 --- a/src/gallerywidget.h +++ b/src/gallerywidget.h @@ -58,10 +58,11 @@ private slots: void onBackToAlbums(); private: + void reload(); void setupControlsLayout(); void setupAlbumSelectionView(); void setupPhotoGalleryView(); - void loadAlbumList(); + void loadAlbumList(const AFCFileTree &dcimTree); void setControlsEnabled(bool enabled); QString selectExportDirectory(); QIcon loadAlbumThumbnail(const QString &albumPath); @@ -80,7 +81,7 @@ private: ZLoadingWidget *m_loadingWidget; QWidget *m_errorWidget; QPushButton *m_retryButton; - + QPushButton *m_importButton; // Album selection view QWidget *m_albumSelectionWidget; QListView *m_albumListView; diff --git a/src/heartbeat.h b/src/heartbeat.h index 7026d4b..5874a76 100644 --- a/src/heartbeat.h +++ b/src/heartbeat.h @@ -1,26 +1,19 @@ #ifndef HEARTBEATTHREAD_H #define HEARTBEATTHREAD_H +#include "iDescriptor.h" #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include using namespace IdeviceFFI; -class HeartBeatThread : public QThread +class HeartbeatThread : public QThread { Q_OBJECT public: - HeartBeatThread(HeartbeatClientHandle *heartbeat, QObject *parent = nullptr) - : QThread(parent), m_hb(Heartbeat::adopt(heartbeat)) + HeartbeatThread(HeartbeatClientHandle *heartbeat, QString macAddress, + QObject *parent = nullptr) + : QThread(parent), m_hb(Heartbeat::adopt(heartbeat)), + m_macAddress(macAddress) { } @@ -28,7 +21,6 @@ public: { qDebug() << "Heartbeat thread started"; try { - // Start with initial interval (15 seconds as per the tool example) u_int64_t interval = 15; while (!isInterruptionRequested()) { @@ -38,7 +30,19 @@ public: qDebug() << "Failed to get marco:" << QString::fromStdString(result.unwrap_err().message); - break; + m_tries++; + emit heartbeatFailed(m_macAddress, m_tries); + if (m_tries >= HEARTBEAT_RETRY_LIMIT) { + qDebug() + << "Maximum heartbeat retries reached, exiting for " + "device" + << m_macAddress; + emit heartbeatThreadExited(m_macAddress); + break; + } + // If get_marco failed, skip the rest of this iteration + // and try again with the current interval. + continue; } // 2. Get the new interval from device @@ -51,15 +55,31 @@ public: qDebug() << "Failed to send polo:" << QString::fromStdString( polo_result.unwrap_err().message); - break; + m_tries++; + emit heartbeatFailed(m_macAddress, m_tries); + if (m_tries >= HEARTBEAT_RETRY_LIMIT) { + qDebug() << "Maximum heartbeat retries reached, " + "exiting for " + "device" + << m_macAddress; + emit heartbeatThreadExited(m_macAddress); + break; + } + // If send_polo failed, skip the rest of this iteration + // and try again with the current interval. + continue; } - qDebug() << "Sent polo successfully"; - interval += 5; - m_initialCompleted = true; + // If both marco and polo succeeded: + qDebug() << "Sent polo successfully"; + interval += 5; // Increment interval for the next cycle + m_initialCompleted = true; // Mark as initially completed after + // first successful full cycle } } catch (const std::exception &e) { qDebug() << "Heartbeat error:" << e.what(); + + emit heartbeatThreadExited(m_macAddress); } } @@ -68,5 +88,11 @@ public: private: Heartbeat m_hb; bool m_initialCompleted = false; + QString m_macAddress; + unsigned int m_tries = 0; + +signals: + void heartbeatFailed(const QString &macAddress, unsigned int tries = 0); + void heartbeatThreadExited(const QString &macAddress); }; #endif // HEARTBEATTHREAD_H \ No newline at end of file diff --git a/src/iDescriptor.h b/src/iDescriptor.h index 9409e02..be62981 100644 --- a/src/iDescriptor.h +++ b/src/iDescriptor.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +71,16 @@ #define NotFoundErrorCode -14 #define DISK_IMAGE_TYPE_DEVELOPER "Developer" +#define HEARTBEAT_RETRY_LIMIT 2 + +#ifdef __linux__ +#define LOCKDOWN_PATH "/var/lib/lockdown" +#elif __APPLE__ +#define LOCKDOWN_PATH "/var/db/lockdown" +#else +#define LOCKDOWN_PATH "" +#endif + struct BatteryInfo { QString health; uint64_t cycleCount; @@ -412,12 +423,12 @@ void get_device_info_xml(const char *udid, LockdowndClientHandle *client, pugi::xml_document &infoXml); struct WirelessInitArgs { - QString ip; - const IdevicePairingFile *pairing_file; + const QString ip; + const QString pairing_file; }; iDescriptorInitDeviceResult init_idescriptor_device(const QString &udid, - WirelessInitArgs wirelessArgs = {nullptr, nullptr}); + const WirelessInitArgs &wirelessArgs = {"", ""}); // #ifdef ENABLE_RECOVERY_DEVICE_SUPPORT // iDescriptorInitDeviceResultRecovery @@ -669,4 +680,45 @@ inline int read_file(const char *filename, uint8_t **data, size_t *length) fclose(file); return 1; -} \ No newline at end of file +} + +struct ExportItem { + QString sourcePathOnDevice; + QString suggestedFileName; + int itemIndex = -1; + + ExportItem() = default; + ExportItem(const QString &sourcePath, const QString &fileName, int index) + : sourcePathOnDevice(sourcePath), suggestedFileName(fileName), + itemIndex(index) + { + } +}; + +struct ExportResult { + QString sourceFilePath; + QString outputFilePath; + bool success = false; + QString errorMessage; + qint64 bytesTransferred = 0; +}; + +struct ExportJobSummary { + QUuid jobId; + int totalItems = 0; + int successfulItems = 0; + int failedItems = 0; + qint64 totalBytesTransferred = 0; + QString destinationPath; + bool wasCancelled = false; +}; + +struct ExportJob { + QUuid jobId; + iDescriptorDevice *device = nullptr; + QList items; + QString destinationPath; + std::optional altAfc; + std::atomic cancelRequested{false}; + QUuid statusBalloonProcessId; +}; \ No newline at end of file diff --git a/src/livescreenwidget.cpp b/src/livescreenwidget.cpp index 16870c4..30f6aee 100644 --- a/src/livescreenwidget.cpp +++ b/src/livescreenwidget.cpp @@ -228,6 +228,7 @@ void LiveScreenWidget::updateScreenshot() // return; // } qDebug() << "Updating screenshot..."; + // FIXME: move to services try { // TakeScreenshotResult result = take_screenshot(m_shotrClient); ScreenshotData screenshot; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7c876a7..f2608a0 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -44,8 +44,10 @@ #include "appcontext.h" #include "settingsmanager.h" // #include "devicemonitor.h" +#include "Toast.h" #include "networkdevicemanager.h" #include "networkdeviceswidget.h" +#include "statusballoon.h" #include #include #include @@ -205,6 +207,12 @@ MainWindow::MainWindow(QWidget *parent) "QLabel:hover { background-color : #13131319; }"); ui->statusbar->addWidget(m_connectedDeviceCountLabel); + // TODO: implement downloads/uploads progress stuff + + StatusBalloon *statusBalloon = StatusBalloon::sharedInstance(); + + ui->statusbar->addWidget(statusBalloon->getButton()); + ui->statusbar->setContentsMargins(0, 0, 0, 0); QLabel *appVersionLabel = new QLabel(QString("v%1").arg(APP_VERSION)); appVersionLabel->setContentsMargins(5, 0, 5, 0); @@ -410,33 +418,16 @@ MainWindow::MainWindow(QWidget *parent) connect(AppContext::sharedInstance(), &AppContext::deviceRemoved, this, [](const std::string &udid, const std::string &wifiMacAddress) { - const IdevicePairingFile *pairingFile = - AppContext::sharedInstance()->getCachedPairingFile( - QString::fromStdString(udid)); - - if (pairingFile) { - // qDebug() << "Device removed, pairing file for UDID" - // << QString::fromStdString(udid) << "MAC" - // << QString::fromStdString(wifiMacAddress) - // << "exists in cache."; - // try to upgrade device to wireless if possible - qDebug() - << "Upgrading device to wireless connection for UDID" - << QString::fromStdString(udid); - QMetaObject::invokeMethod( - AppContext::sharedInstance(), "addDevice", - Qt::QueuedConnection, - Q_ARG(QString, QString::fromStdString(udid)), - Q_ARG(DeviceMonitorThread::IdeviceConnectionType, - DeviceMonitorThread::CONNECTION_NETWORK), - Q_ARG(AddType, AddType::UpgradeToWireless), - Q_ARG(QString, QString::fromStdString(wifiMacAddress))); - - } else { - qDebug() - << "Device removed, no cached pairing file for UDID" - << QString::fromStdString(udid); - } + qDebug() << "Upgrading device to wireless connection for UDID" + << QString::fromStdString(udid); + QMetaObject::invokeMethod( + AppContext::sharedInstance(), "addDevice", + Qt::QueuedConnection, + Q_ARG(QString, QString::fromStdString(udid)), + Q_ARG(DeviceMonitorThread::IdeviceConnectionType, + DeviceMonitorThread::CONNECTION_NETWORK), + Q_ARG(AddType, AddType::UpgradeToWireless), + Q_ARG(QString, QString::fromStdString(wifiMacAddress))); }); connect(NetworkDeviceManager::sharedInstance(), @@ -462,18 +453,6 @@ MainWindow::MainWindow(QWidget *parent) << device.macAddress; return; } - - const IdevicePairingFile *pairingFile = - AppContext::sharedInstance()->getCachedPairingFile( - device.macAddress); - - if (!pairingFile) { - qDebug() << "No cached pairing file for network device MAC:" - << device.macAddress - << "Cannot add as wireless device."; - return; - } - qDebug() << "Trying to add network device with MAC:" << device.macAddress; @@ -488,6 +467,21 @@ MainWindow::MainWindow(QWidget *parent) // Handle network device addition if needed }); + connect(AppContext::sharedInstance(), &AppContext::deviceHeartbeatFailed, + this, [this](const QString &macAddress, int tries) { + Toast *toast = new Toast(this); + toast->setAttribute(Qt::WA_DeleteOnClose); + toast->setDuration(8000); // Hide after 8 seconds + toast->setTitle("Heartbeat failed"); + toast->setText( + QString("Heartbeat failed for device with MAC %1. " + "Number of failed attempts: %2") + .arg(macAddress) + .arg(tries)); + toast->setPosition(ToastPosition::BOTTOM_MIDDLE); + toast->show(); + }); + // NetworkDevicesWidget *m_networkDevicesWidget = new // NetworkDevicesWidget(); // m_networkDevicesWidget->setAttribute(Qt::WA_DeleteOnClose); diff --git a/src/photomodel.cpp b/src/photomodel.cpp index 74dfed4..f5304f9 100644 --- a/src/photomodel.cpp +++ b/src/photomodel.cpp @@ -40,9 +40,8 @@ extern "C" { #include #include } +// todo implement std::priority_queue with thread pool -// Limit concurrent video thumbnail generation to 2 to prevent resource -// exhaustion QSemaphore PhotoModel::m_videoThumbnailSemaphore(4); PhotoModel::PhotoModel(iDescriptorDevice *device, FilterType filterType, @@ -59,17 +58,27 @@ PhotoModel::PhotoModel(iDescriptorDevice *device, FilterType filterType, void PhotoModel::clear() { + blockSignals(true); + // Clean up any active watchers for (auto *watcher : m_activeLoaders.values()) { if (watcher) { + watcher->disconnect(); watcher->cancel(); - watcher->waitForFinished(); + // watcher->waitForFinished(); watcher->deleteLater(); } } m_activeLoaders.clear(); m_loadingPaths.clear(); m_thumbnailCache.clear(); + + beginResetModel(); + m_photos.clear(); + m_allPhotos.clear(); + endResetModel(); + + blockSignals(false); } PhotoModel::~PhotoModel() @@ -448,18 +457,15 @@ QVariant PhotoModel::data(const QModelIndex &index, int role) const return info.filePath; case Qt::DecorationRole: { - qDebug() << "DecorationRole requested for index:" << index.row(); // Check memory cache first if (QPixmap *cached = m_thumbnailCache.object(info.filePath)) { - qDebug() << "Cache HIT for:" << info.fileName; return QIcon(*cached); } // Prevent duplicate requests if (m_loadingPaths.contains(info.filePath) || m_activeLoaders.contains(info.filePath)) { - qDebug() << "Already loading:" << info.fileName; // Return appropriate placeholder based on file type if (info.fileName.endsWith(".MOV", Qt::CaseInsensitive) || info.fileName.endsWith(".MP4", Qt::CaseInsensitive) || @@ -473,7 +479,6 @@ QVariant PhotoModel::data(const QModelIndex &index, int role) const // Start async loading for both images and videos if (!m_loadingPaths.contains(info.filePath)) { - qDebug() << "Starting load for:" << info.fileName; emit const_cast(this)->thumbnailNeedsToBeLoaded( index.row()); } @@ -516,7 +521,6 @@ void PhotoModel::requestThumbnail(int index) connect(watcher, &QFutureWatcher::finished, this, [this, watcher, filePath = info.filePath]() { - qDebug() << "Thumbnail load finished for:" << filePath; QPixmap thumbnail = watcher->result(); m_loadingPaths.remove(filePath); @@ -549,16 +553,12 @@ void PhotoModel::requestThumbnail(int index) if (isVideo) { future = QtConcurrent::run([this, info]() { // Acquire semaphore FIRST to limit concurrent video processing - qDebug() << "Waiting for semaphore for:" << info.fileName; m_videoThumbnailSemaphore.acquire(); - qDebug() << "Acquired semaphore for:" << info.fileName; // Generate video thumbnail using FFmpeg directly (no QMediaPlayer) QPixmap thumbnail = generateVideoThumbnailFFmpeg( m_device, info.filePath, m_thumbnailSize); - // Release semaphore - qDebug() << "Releasing semaphore for:" << info.fileName; m_videoThumbnailSemaphore.release(); return thumbnail; }); @@ -587,7 +587,6 @@ QPixmap PhotoModel::loadThumbnailFromDevice(iDescriptorDevice *device, } if (filePath.endsWith(".HEIC", Qt::CaseInsensitive)) { - qDebug() << "Loading HEIC image from data for:" << filePath; QPixmap img = load_heic(imageData); return img.isNull() ? QPixmap() : img.scaled(size, Qt::KeepAspectRatio, @@ -872,13 +871,11 @@ PhotoInfo::FileType PhotoModel::determineFileType(const QString &fileName) const void PhotoModel::setAlbumPath(const QString &albumPath) { - if (m_albumPath != albumPath) { - qDebug() << "Setting new album path:" << albumPath; - clear(); + qDebug() << "Setting new album path:" << albumPath; + clear(); - m_albumPath = albumPath; - populatePhotoPaths(); - } + m_albumPath = albumPath; + populatePhotoPaths(); } void PhotoModel::refreshPhotos() { populatePhotoPaths(); } \ No newline at end of file diff --git a/src/photomodel.h b/src/photomodel.h index bc9866f..015501e 100644 --- a/src/photomodel.h +++ b/src/photomodel.h @@ -90,7 +90,7 @@ signals: void thumbnailNeedsToBeLoaded(int index); void exportRequested(const QStringList &filePaths); -private slots: +public slots: void requestThumbnail(int index); private: diff --git a/src/qballoontip.cpp b/src/qballoontip.cpp index 815afcb..1ec676a 100644 --- a/src/qballoontip.cpp +++ b/src/qballoontip.cpp @@ -36,7 +36,7 @@ void QBalloonTip::hideBalloon() if (!theSolitaryBalloonTip) return; theSolitaryBalloonTip->hide(); - delete theSolitaryBalloonTip; + // delete theSolitaryBalloonTip; theSolitaryBalloonTip = nullptr; } @@ -52,12 +52,17 @@ bool QBalloonTip::isBalloonVisible() { return theSolitaryBalloonTip; } QBalloonTip::QBalloonTip(const QIcon &icon, const QString &title, const QString &message, QWidget *widget) - : QWidget(nullptr, Qt::ToolTip), widget(widget), showArrow(true) + : QWidget(widget ? widget->window() : QApplication::activeWindow(), + Qt::ToolTip), + widget(widget), showArrow(true) { - setAttribute(Qt::WA_DeleteOnClose); + // setAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_TranslucentBackground); if (widget) { connect(widget, &QWidget::destroyed, this, &QBalloonTip::close); + } else if (QApplication::activeWindow()) { + connect(QApplication::activeWindow(), &QWidget::destroyed, this, + &QBalloonTip::close); } // Add drop shadow effect @@ -68,49 +73,50 @@ QBalloonTip::QBalloonTip(const QIcon &icon, const QString &title, shadowEffect->setOffset(0, 5); setGraphicsEffect(shadowEffect); - QLabel *titleLabel = new QLabel; - titleLabel->installEventFilter(this); - titleLabel->setText(title); - QFont f = titleLabel->font(); - f.setBold(true); - titleLabel->setFont(f); - titleLabel->setTextFormat(Qt::PlainText); // to maintain compat with windows + // QLabel *titleLabel = new QLabel; + // titleLabel->installEventFilter(this); + // titleLabel->setText(title); + // QFont f = titleLabel->font(); + // f.setBold(true); + // titleLabel->setFont(f); + // titleLabel->setTextFormat(Qt::PlainText); // to maintain compat with + // windows - const int iconSize = 18; - const int closeButtonSize = 15; + // const int iconSize = 18; + // const int closeButtonSize = 15; - QPushButton *closeButton = new QPushButton; - closeButton->setIcon(style()->standardIcon(QStyle::SP_TitleBarCloseButton)); - closeButton->setIconSize(QSize(closeButtonSize, closeButtonSize)); - closeButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - closeButton->setFixedSize(closeButtonSize, closeButtonSize); - QObject::connect(closeButton, SIGNAL(clicked()), this, SLOT(close())); + // QPushButton *closeButton = new QPushButton; + // closeButton->setIcon(style()->standardIcon(QStyle::SP_TitleBarCloseButton)); + // closeButton->setIconSize(QSize(closeButtonSize, closeButtonSize)); + // closeButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + // closeButton->setFixedSize(closeButtonSize, closeButtonSize); + // QObject::connect(closeButton, SIGNAL(clicked()), this, SLOT(close())); - QLabel *msgLabel = new QLabel; - msgLabel->installEventFilter(this); - msgLabel->setText(message); - msgLabel->setTextFormat(Qt::PlainText); - msgLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); + // QLabel *msgLabel = new QLabel; + // msgLabel->installEventFilter(this); + // msgLabel->setText(message); + // msgLabel->setTextFormat(Qt::PlainText); + // msgLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); - QGridLayout *layout = new QGridLayout; - if (!icon.isNull()) { - QLabel *iconLabel = new QLabel; - iconLabel->setPixmap( - icon.pixmap(QSize(iconSize, iconSize), devicePixelRatio())); - iconLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - iconLabel->setMargin(2); - layout->addWidget(iconLabel, 0, 0); - layout->addWidget(titleLabel, 0, 1); - } else { - layout->addWidget(titleLabel, 0, 0, 1, 2); - } + // QGridLayout *layout = new QGridLayout; + // if (!icon.isNull()) { + // QLabel *iconLabel = new QLabel; + // iconLabel->setPixmap( + // icon.pixmap(QSize(iconSize, iconSize), devicePixelRatio())); + // iconLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + // iconLabel->setMargin(2); + // layout->addWidget(iconLabel, 0, 0); + // layout->addWidget(titleLabel, 0, 1); + // } else { + // layout->addWidget(titleLabel, 0, 0, 1, 2); + // } - layout->addWidget(closeButton, 0, 2); + // layout->addWidget(closeButton, 0, 2); - layout->addWidget(msgLabel, 1, 0, 1, 3); - layout->setSizeConstraint(QLayout::SetFixedSize); - layout->setContentsMargins(3, 3, 3, 3); - setLayout(layout); + // layout->addWidget(msgLabel, 1, 0, 1, 3); + // layout->setSizeConstraint(QLayout::SetFixedSize); + // layout->setContentsMargins(3, 3, 3, 3); + // setLayout(layout); } QBalloonTip::~QBalloonTip() { theSolitaryBalloonTip = nullptr; } diff --git a/src/qballoontip.h b/src/qballoontip.h index 2c53374..ea932e7 100644 --- a/src/qballoontip.h +++ b/src/qballoontip.h @@ -10,24 +10,21 @@ class QBalloonTip : public QWidget { Q_OBJECT public: - static void showBalloon(const QIcon &icon, const QString &title, - const QString &msg, QWidget *widget, - const QPoint &pos, int timeout, - bool showArrow = true); - static void hideBalloon(); - static bool isBalloonVisible(); - static void updateBalloonPosition(const QPoint &pos); - -private: - QBalloonTip(const QIcon &icon, const QString &title, const QString &msg, - QWidget *widget); - ~QBalloonTip(); + explicit QBalloonTip(const QIcon &icon, const QString &title, + const QString &msg, QWidget *widget); + void hideBalloon(); + bool isBalloonVisible(); + void updateBalloonPosition(const QPoint &pos); + void showBalloon(const QIcon &icon, const QString &title, + const QString &msg, QWidget *widget, const QPoint &pos, + int timeout, bool showArrow = true); void balloon(const QPoint &, int, bool); signals: void messageClicked(); protected: + ~QBalloonTip(); void paintEvent(QPaintEvent *) override; void resizeEvent(QResizeEvent *) override; void mousePressEvent(QMouseEvent *e) override; diff --git a/src/servicemanager.cpp b/src/servicemanager.cpp index f85d454..f814586 100644 --- a/src/servicemanager.cpp +++ b/src/servicemanager.cpp @@ -19,6 +19,7 @@ #include "servicemanager.h" #include "iDescriptor.h" +#include IdeviceFfiError * ServiceManager::safeAfcReadDirectory(const iDescriptorDevice *device, @@ -139,6 +140,15 @@ AFCFileTree ServiceManager::safeGetFileTree(const iDescriptorDevice *device, }); } +QFuture +ServiceManager::getFileTreeAsync(const iDescriptorDevice *device, + const std::string &path, bool checkDir) +{ + return QtConcurrent::run([device, path, checkDir]() { + return get_file_tree(device, checkDir, path); + }); +} + MountedImageInfo ServiceManager::getMountedImage(const iDescriptorDevice *device) { @@ -194,4 +204,119 @@ bool ServiceManager::enableWirelessConnections(const iDescriptorDevice *device) plist_free(value); return success; }); -} \ No newline at end of file +} + +IdeviceFfiError *ServiceManager::exportFileToPath( + const iDescriptorDevice *device, const char *device_path, + const char *local_path, + std::function progressCallback, + std::atomic *cancelRequested) +{ + qDebug() + << "[serviceManager::exportFileToPath] Exporting file from device path:" + << device_path << "to local path:" << local_path; + return executeOperation( + device, + [device, device_path, local_path, progressCallback, + cancelRequested]() -> IdeviceFfiError * { + AfcFileHandle *afcHandle = nullptr; + qDebug() << "Opening file on device:" << device_path; + IdeviceFfiError *err_open = safeAfcFileOpen( + device, device_path, AfcFopenMode::AfcRdOnly, &afcHandle); + + if (err_open != nullptr) { + qDebug() << "Failed to open file on device:" << device_path + << "Error Code:" << err_open->code + << "Message:" << err_open->message; + return err_open; + } + qDebug() << "File opened on device successfully"; + + FILE *out = fopen(local_path, "wb"); + if (!out) { + qDebug() << "Failed to open local file:" << local_path; + IdeviceFfiError *err_close = + safeAfcFileClose(device, afcHandle); + if (err_close != nullptr) { + // idevice_error_free(err_close); + } + return new IdeviceFfiError{1, "FAILED_TO_OPEN_LOCAL_FILE"}; + } + qDebug() << "Local file opened successfully"; + + const size_t CHUNK_SIZE = 256 * 1024; // 256KB chunks + uint8_t *chunkData = nullptr; + size_t bytesRead = 0; + qint64 totalBytesRead = 0; + + // Get file size for progress + AfcFileInfo fileInfo; + IdeviceFfiError *info_err = + safeAfcGetFileInfo(device, device_path, &fileInfo); + qint64 totalFileSize = 0; + if (info_err == nullptr) { + totalFileSize = fileInfo.size; + // afc_file_info_free(&fileInfo); + } else { + // idevice_error_free(info_err); + } + + IdeviceFfiError *read_err = nullptr; + // Read file in chunks + while (true) { + std::this_thread::sleep_for(std::chrono::seconds(2)); + // Check for cancellation + if (cancelRequested && cancelRequested->load()) { + fclose(out); + safeAfcFileClose(device, afcHandle); + return new IdeviceFfiError{1, "OPERATION_CANCELLED"}; + } + + read_err = safeAfcFileRead(device, afcHandle, &chunkData, + CHUNK_SIZE, &bytesRead); + + if (read_err != nullptr) { + qDebug() << "Error reading file:" << read_err->message; + fclose(out); + safeAfcFileClose(device, afcHandle); + return read_err; + } + + if (bytesRead == 0) { + // End of file reached + break; + } + + // Write chunk to local file + size_t written = fwrite(chunkData, 1, bytesRead, out); + + // Free the memory allocated by afc_file_read + afc_file_read_data_free(chunkData, bytesRead); + chunkData = nullptr; + + if (written != bytesRead) { + qDebug() << "Failed to write all bytes to local file"; + fclose(out); + safeAfcFileClose(device, afcHandle); + return new IdeviceFfiError{1, "WRITE_ERROR"}; + } + + totalBytesRead += bytesRead; + + // Report progress + if (progressCallback) { + progressCallback(totalBytesRead, totalFileSize); + } + } + + fclose(out); + + IdeviceFfiError *err_close = safeAfcFileClose(device, afcHandle); + if (err_close != nullptr) { + qDebug() << "Failed to close AFC file:" << err_close->message; + return err_close; + } + + return nullptr; // Success + }); +} diff --git a/src/servicemanager.h b/src/servicemanager.h index d8a8821..695684e 100644 --- a/src/servicemanager.h +++ b/src/servicemanager.h @@ -22,6 +22,7 @@ #include "iDescriptor.h" #include +#include #include #include #include @@ -199,6 +200,7 @@ public: // altAfc was explicitly provided but is null, which is an // invalid state. qDebug() << "[executeAfcClientOperation] altAfc is null"; + // c string is not safe in IdeviceFfiError ? return new IdeviceFfiError{1, "ALT_AFC_CLIENT_IS_NULL"}; } @@ -248,6 +250,9 @@ public: const char *path); static AFCFileTree safeGetFileTree(const iDescriptorDevice *device, const std::string &path, bool checkDir); + static QFuture + getFileTreeAsync(const iDescriptorDevice *device, const std::string &path, + bool checkDir); static MountedImageInfo getMountedImage(const iDescriptorDevice *device); static IdeviceFfiError *mountImage(const iDescriptorDevice *device, const char *image_file, @@ -259,6 +264,13 @@ public: const char *filePath, const char *fileName); static bool enableWirelessConnections(const iDescriptorDevice *device); + + // File export operations + static IdeviceFfiError *exportFileToPath( + const iDescriptorDevice *device, const char *device_path, + const char *local_path, + std::function progressCallback = nullptr, + std::atomic *cancelRequested = nullptr); }; #endif // SERVICEMANAGER_H diff --git a/src/statusballoon.cpp b/src/statusballoon.cpp new file mode 100644 index 0000000..2245a6c --- /dev/null +++ b/src/statusballoon.cpp @@ -0,0 +1,643 @@ +#include "statusballoon.h" +#include "exportmanager.h" +#include "exportmanagerthread.h" +#include "iDescriptor.h" +#include "qballoontip.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Process::Process(QWidget *parent) : QWidget(parent) {} + +StatusBalloon *StatusBalloon::sharedInstance() +{ + static StatusBalloon instance; + return &instance; +} + +StatusBalloon::StatusBalloon(QWidget *parent) + : QBalloonTip(QIcon(), "", "", parent) +{ + setMinimumHeight(300); + setMinimumWidth(300); + // Create main layout + m_mainLayout = new QVBoxLayout(); + m_mainLayout->setSpacing(8); + m_mainLayout->setContentsMargins(12, 12, 12, 12); + + // Header label + m_headerLabel = new QLabel("Processes"); + QFont headerFont = m_headerLabel->font(); + headerFont.setPointSize(headerFont.pointSize() + 2); + headerFont.setBold(true); + m_headerLabel->setFont(headerFont); + m_mainLayout->addWidget(m_headerLabel); + + // Container for processes + m_processesContainer = new QWidget(); + m_processesLayout = new QVBoxLayout(m_processesContainer); + m_processesLayout->setSpacing(12); + m_processesLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->addWidget(m_processesContainer); + + setLayout(m_mainLayout); + connect(m_button, &ZIconWidget::clicked, this, &StatusBalloon::showBalloon); + connectExportThreadSignals(); +} + +void StatusBalloon::connectExportThreadSignals() +{ + ExportManager *exportManager = ExportManager::sharedInstance(); + + connect(exportManager->m_exportThread, &ExportManagerThread::exportFinished, + this, &StatusBalloon::onExportFinished); + + connect(exportManager->m_exportThread, &ExportManagerThread::itemExported, + this, &StatusBalloon::onItemExported); + + connect(exportManager->m_exportThread, + &ExportManagerThread::fileTransferProgress, this, + &StatusBalloon::onFileTransferProgress); + QTimer::singleShot(0, this, [this]() { + // test + startExportProcess("Test Export Process", 10, "/path/to/destination"); + }); +} + +void StatusBalloon::onFileTransferProgress(const QUuid &processId, + int currentItem, + const QString ¤tFile, + qint64 bytesTransferred, + qint64 totalBytes) +{ + qDebug() << "StatusBalloon::updateProcessProgress entry:" << processId + << currentItem << currentFile << bytesTransferred << totalBytes; + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + qDebug() << "StatusBalloon::updateProcessProgress: unknown processId" + << processId; + return; + } + + ProcessItem *item = m_processes[processId]; + item->completedItems = currentItem; + item->currentFile = currentFile; + item->transferredBytes = bytesTransferred; + item->totalBytes = totalBytes; + + if (!item->processWidget) + qDebug() + << "StatusBalloon::updateProcessProgress: no widget for processId" + << processId; + + // Update status label + QString statusText; + if (item->status == ProcessStatus::Running) { + if (!item->currentFile.isEmpty()) { + statusText = item->currentFile; + } else { + statusText = "Processing..."; + } + } else if (item->status == ProcessStatus::Completed) { + statusText = "Completed successfully"; + } else if (item->status == ProcessStatus::Failed) { + statusText = "Failed"; + } else if (item->status == ProcessStatus::Cancelled) { + statusText = "Cancelled"; + } + item->statusLabel->setText(statusText); + + // Update progress bar + // progess should be based on exported bytes vs total bytes of the current + // file + if (item->totalItems > 0) { + int progress = (item->transferredBytes * 100) / item->totalBytes; + item->progressBar->setValue(progress); + } + + // Update stats + QString statsText = QString("%1 of %2 items") + .arg(item->completedItems) + .arg(item->totalItems); + if (item->failedItems > 0) { + statsText += QString(" • %1 failed").arg(item->failedItems); + } + + if (item->status == ProcessStatus::Running && item->transferredBytes > 0) { + // Calculate transfer rate + QDateTime now = QDateTime::currentDateTime(); + qint64 elapsed = m_lastUpdateTime[item->processId].msecsTo(now); + if (elapsed > 0) { + qint64 bytesDiff = item->transferredBytes - + m_lastBytesTransferred[item->processId]; + qint64 bytesPerSecond = (bytesDiff * 1000) / elapsed; + if (bytesPerSecond > 0) { + statsText += " • " + formatTransferRate(bytesPerSecond); + } + m_lastBytesTransferred[item->processId] = item->transferredBytes; + m_lastUpdateTime[item->processId] = now; + } + } + + item->statsLabel->setText(statsText); + + // Update buttons + if (item->status == ProcessStatus::Running) { + item->cancelButton->setVisible(true); + item->actionButton->setVisible(false); + } else { + item->cancelButton->setVisible(false); + if (item->type == ProcessType::Export && + item->status == ProcessStatus::Completed) { + item->actionButton->setVisible(true); + } + } +} + +// todo fix these +// StatusBalloon::onItemExported entry: +// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") Success: true +// StatusBalloon::onItemExported: unknown processId +// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") +// StatusBalloon::onExportFinished entry: +// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") WasCancelled: false +// StatusBalloon::onExportFinished: unknown processId +// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") + +void StatusBalloon::onExportFinished(const QUuid &processId, + const ExportJobSummary &summary) +{ + qDebug() << "StatusBalloon::onExportFinished entry:" << processId + << "WasCancelled:" << summary.wasCancelled; + QMutexLocker locker(&m_processesMutex); + if (!m_processes.contains(processId)) { + qDebug() << "StatusBalloon::onExportFinished: unknown processId" + << processId; + return; + } + + // todo: handle failed ? + ProcessItem *item = m_processes[processId]; + if (summary.wasCancelled) { + item->status = ProcessStatus::Cancelled; + } else { + item->status = ProcessStatus::Completed; + } + item->endTime = QDateTime::currentDateTime(); + + updateUI(); +} + +void StatusBalloon::onItemExported(const QUuid &processId, + const ExportResult &result) +{ + qDebug() << "StatusBalloon::onItemExported entry:" << processId + << "Success:" << result.success; + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + qDebug() << "StatusBalloon::onItemExported: unknown processId" + << processId; + return; + } + + ProcessItem *item = m_processes[processId]; + if (result.success) { + item->completedItems += 1; + } else { + item->failedItems += 1; + } + + updateUI(); +} + +QUuid StatusBalloon::startExportProcess(const QString &title, int totalItems, + const QString &destinationPath) +{ + qDebug() << "StatusBalloon::startExportProcess entry:" << title + << totalItems << destinationPath; + + // allocate item first so it can be used after unlocking + auto *item = new ProcessItem(); + item->processId = QUuid::createUuid(); + item->type = ProcessType::Export; + item->status = ProcessStatus::Running; + item->title = title; + item->totalItems = totalItems; + item->completedItems = 0; + item->failedItems = 0; + item->totalBytes = 0; + item->transferredBytes = 0; + item->startTime = QDateTime::currentDateTime(); + item->destinationPath = destinationPath; + + { // scope the lock only for shared-state mutation + QMutexLocker locker(&m_processesMutex); + m_processes[item->processId] = item; + m_currentProcessId = item->processId; + m_lastBytesTransferred[item->processId] = 0; + m_lastUpdateTime[item->processId] = QDateTime::currentDateTime(); + } // mutex released here + + // UI work must run without holding m_processesMutex to avoid re-locking + // deadlock + createProcessWidget(item); + updateUI(); + + return item->processId; +} + +QUuid StatusBalloon::startUploadProcess(const QString &title, int totalItems) +{ + // allocate item first + auto *item = new ProcessItem(); + item->processId = QUuid::createUuid(); + item->type = ProcessType::Upload; + item->status = ProcessStatus::Running; + item->title = title; + item->totalItems = totalItems; + item->completedItems = 0; + item->failedItems = 0; + item->totalBytes = 0; + item->transferredBytes = 0; + item->startTime = QDateTime::currentDateTime(); + + { // scope the lock only for shared-state mutation + QMutexLocker locker(&m_processesMutex); + m_processes[item->processId] = item; + m_currentProcessId = item->processId; + m_lastBytesTransferred[item->processId] = 0; + m_lastUpdateTime[item->processId] = QDateTime::currentDateTime(); + } // mutex released here + + createProcessWidget(item); + updateUI(); + + return item->processId; +} + +void StatusBalloon::createProcessWidget(ProcessItem *item) +{ + item->processWidget = new QWidget(); + auto *layout = new QVBoxLayout(item->processWidget); + layout->setSpacing(6); + layout->setContentsMargins(0, 0, 0, 0); + + // Title + item->titleLabel = new QLabel(item->title); + QFont titleFont = item->titleLabel->font(); + titleFont.setBold(true); + item->titleLabel->setFont(titleFont); + layout->addWidget(item->titleLabel); + + // Status + item->statusLabel = new QLabel("Starting..."); + layout->addWidget(item->statusLabel); + + // Progress bar + item->progressBar = new QProgressBar(); + item->progressBar->setRange(0, 100); + item->progressBar->setValue(0); + item->progressBar->setTextVisible(true); // show text for debugging + item->progressBar->setFixedHeight(12); // make it visible + layout->addWidget(item->progressBar); + + // Stats + item->statsLabel = new QLabel(); + QFont statsFont = item->statsLabel->font(); + statsFont.setPointSize(statsFont.pointSize() - 1); + item->statsLabel->setFont(statsFont); + layout->addWidget(item->statsLabel); + + // Buttons layout + auto *buttonsLayout = new QHBoxLayout(); + buttonsLayout->setSpacing(6); + + // Action button (Open Folder for export, hidden initially) + item->actionButton = new QPushButton(); + item->actionButton->setVisible(false); + if (item->type == ProcessType::Export) { + item->actionButton->setText("Open Folder"); + connect(item->actionButton, &QPushButton::clicked, this, + &StatusBalloon::onOpenFolderClicked); + } + buttonsLayout->addWidget(item->actionButton); + + buttonsLayout->addStretch(); + + // Cancel button + item->cancelButton = new QPushButton("Cancel"); + connect(item->cancelButton, &QPushButton::clicked, this, + &StatusBalloon::onCancelClicked); + buttonsLayout->addWidget(item->cancelButton); + + layout->addLayout(buttonsLayout); + + m_processesLayout->addWidget(item->processWidget); +} + +void StatusBalloon::markProcessCompleted(const QUuid &processId) +{ + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + return; + } + + ProcessItem *item = m_processes[processId]; + item->status = ProcessStatus::Completed; + item->endTime = QDateTime::currentDateTime(); + + updateUI(); + + // Check if all processes are done + bool allDone = true; + for (auto *proc : m_processes) { + if (proc->status == ProcessStatus::Running) { + allDone = false; + break; + } + } +} + +void StatusBalloon::markProcessFailed(const QUuid &processId, + const QString &error) +{ + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + return; + } + + ProcessItem *item = m_processes[processId]; + item->status = ProcessStatus::Failed; + item->endTime = QDateTime::currentDateTime(); + + updateUI(); +} + +void StatusBalloon::markProcessCancelled(const QUuid &processId) +{ + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + return; + } + + ProcessItem *item = m_processes[processId]; + item->status = ProcessStatus::Cancelled; + item->endTime = QDateTime::currentDateTime(); + + updateUI(); +} + +void StatusBalloon::incrementFailedItems(const QUuid &processId) +{ + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + return; + } + + m_processes[processId]->failedItems++; + updateUI(); +} + +void StatusBalloon::updateUI() +{ + QMutexLocker locker(&m_processesMutex); + + // Update header + int running = 0, completed = 0, failed = 0; + for (auto *item : m_processes) { + if (item->status == ProcessStatus::Running) + running++; + else if (item->status == ProcessStatus::Completed) + completed++; + else if (item->status == ProcessStatus::Failed) + failed++; + } + + QString headerText = QString("Processes: %1 running").arg(running); + if (completed > 0 || failed > 0) { + headerText += QString(" • %1 completed").arg(completed); + if (failed > 0) { + headerText += QString(" • %1 failed").arg(failed); + } + } + m_headerLabel->setText(headerText); + + // Update each process widget + for (auto *item : m_processes) { + if (!item->processWidget) + continue; + + // Update status label + QString statusText; + if (item->status == ProcessStatus::Running) { + if (!item->currentFile.isEmpty()) { + statusText = item->currentFile; + } else { + statusText = "Processing..."; + } + } else if (item->status == ProcessStatus::Completed) { + statusText = "Completed successfully"; + } else if (item->status == ProcessStatus::Failed) { + statusText = "Failed"; + } else if (item->status == ProcessStatus::Cancelled) { + statusText = "Cancelled"; + } + item->statusLabel->setText(statusText); + + // Update progress bar + if (item->totalItems > 0) { + int progress = (item->completedItems * 100) / item->totalItems; + item->progressBar->setValue(progress); + } + + // Update stats + QString statsText = QString("%1 of %2 items") + .arg(item->completedItems) + .arg(item->totalItems); + if (item->failedItems > 0) { + statsText += QString(" • %1 failed").arg(item->failedItems); + } + + if (item->status == ProcessStatus::Running && + item->transferredBytes > 0) { + // Calculate transfer rate + QDateTime now = QDateTime::currentDateTime(); + qint64 elapsed = m_lastUpdateTime[item->processId].msecsTo(now); + if (elapsed > 0) { + qint64 bytesDiff = item->transferredBytes - + m_lastBytesTransferred[item->processId]; + qint64 bytesPerSecond = (bytesDiff * 1000) / elapsed; + if (bytesPerSecond > 0) { + statsText += " • " + formatTransferRate(bytesPerSecond); + } + m_lastBytesTransferred[item->processId] = + item->transferredBytes; + m_lastUpdateTime[item->processId] = now; + } + } + + item->statsLabel->setText(statsText); + + // Update buttons + if (item->status == ProcessStatus::Running) { + item->cancelButton->setVisible(true); + item->actionButton->setVisible(false); + } else { + item->cancelButton->setVisible(false); + if (item->type == ProcessType::Export && + item->status == ProcessStatus::Completed) { + item->actionButton->setVisible(true); + } + } + } + + showBalloon(); +} + +void StatusBalloon::showBalloon() +{ + qDebug() << "StatusBalloon::showBalloon" << sender(); + QPoint pos = m_button->mapToGlobal( + QPoint(m_button->width() / 2, m_button->height())); + + balloon(pos, -1, true); +} + +bool StatusBalloon::isProcessRunning(const QUuid &processId) const +{ + QMutexLocker locker(&m_processesMutex); + if (!m_processes.contains(processId)) { + return false; + } + return m_processes[processId]->status == ProcessStatus::Running; +} + +bool StatusBalloon::hasActiveProcesses() const +{ + QMutexLocker locker(&m_processesMutex); + for (auto *item : m_processes) { + if (item->status == ProcessStatus::Running) { + return true; + } + } + return false; +} + +bool StatusBalloon::isCancelRequested(const QUuid &processId) const +{ + QMutexLocker locker(&m_processesMutex); + if (!m_processes.contains(processId)) { + return false; + } + return m_processes[processId]->cancelRequested.load(); +} + +void StatusBalloon::onCancelClicked() +{ + QPushButton *button = qobject_cast(sender()); + if (!button) + return; + + QMutexLocker locker(&m_processesMutex); + + // Find which process this button belongs to + for (auto *item : m_processes) { + if (item->cancelButton == button) { + item->cancelRequested.store(true); + button->setEnabled(false); + button->setText("Cancelling..."); + break; + } + } +} + +void StatusBalloon::onOpenFolderClicked() +{ + QPushButton *button = qobject_cast(sender()); + if (!button) + return; + + QMutexLocker locker(&m_processesMutex); + + for (auto *item : m_processes) { + if (item->actionButton == button && item->type == ProcessType::Export) { + QDesktopServices::openUrl( + QUrl::fromLocalFile(item->destinationPath)); + break; + } + } +} + +QString StatusBalloon::formatFileSize(qint64 bytes) const +{ + const qint64 KB = 1024; + const qint64 MB = KB * 1024; + const qint64 GB = MB * 1024; + + if (bytes >= GB) { + return QString("%1 GB").arg( + QString::number(bytes / double(GB), 'f', 2)); + } else if (bytes >= MB) { + return QString("%1 MB").arg( + QString::number(bytes / double(MB), 'f', 1)); + } else if (bytes >= KB) { + return QString("%1 KB").arg( + QString::number(bytes / double(KB), 'f', 0)); + } else { + return QString("%1 B").arg(bytes); + } +} + +QString StatusBalloon::formatTransferRate(qint64 bytesPerSecond) const +{ + return formatFileSize(bytesPerSecond) + "/s"; +} + +void StatusBalloon::removeProcessWidget(const QUuid &processId) +{ + QMutexLocker locker(&m_processesMutex); + + if (!m_processes.contains(processId)) { + return; + } + + ProcessItem *item = m_processes[processId]; + if (item->processWidget) { + m_processesLayout->removeWidget(item->processWidget); + item->processWidget->deleteLater(); + } + + // delete item; + m_processes.remove(processId); + + if (m_processes.isEmpty()) { + hide(); + } +} + +ZIconWidget *StatusBalloon::getButton() { return m_button; } diff --git a/src/statusballoon.h b/src/statusballoon.h new file mode 100644 index 0000000..784b995 --- /dev/null +++ b/src/statusballoon.h @@ -0,0 +1,109 @@ +#ifndef STATUSBALLOON_H +#define STATUSBALLOON_H + +#include "iDescriptor-ui.h" +#include "iDescriptor.h" +#include "qballoontip.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum class ProcessType { Export, Upload }; + +enum class ProcessStatus { Queued, Running, Completed, Failed, Cancelled }; + +struct ProcessItem { + QUuid processId; + ProcessType type; + ProcessStatus status; + QString title; + QString currentFile; + int totalItems; + int completedItems; + int failedItems; + qint64 totalBytes; + qint64 transferredBytes; + QDateTime startTime; + QDateTime endTime; + QString destinationPath; // For export + QWidget *processWidget; + QLabel *titleLabel; + QLabel *statusLabel; + QLabel *statsLabel; + QProgressBar *progressBar; + QPushButton *actionButton; + QPushButton *cancelButton; + std::atomic cancelRequested{false}; +}; + +class Process : public QWidget +{ + Q_OBJECT +public: + explicit Process(QWidget *parent = nullptr); +}; + +class StatusBalloon : public QBalloonTip +{ + Q_OBJECT +public: + explicit StatusBalloon(QWidget *parent = nullptr); + static StatusBalloon *sharedInstance(); + + // Process management + QUuid startExportProcess(const QString &title, int totalItems, + const QString &destinationPath); + QUuid startUploadProcess(const QString &title, int totalItems); + + void onFileTransferProgress(const QUuid &processId, int currentItem, + const QString ¤tFile, + qint64 bytesTransferred, qint64 totalBytes); + void markProcessCompleted(const QUuid &processId); + void markProcessFailed(const QUuid &processId, const QString &error); + void markProcessCancelled(const QUuid &processId); + void incrementFailedItems(const QUuid &processId); + + bool isProcessRunning(const QUuid &processId) const; + bool hasActiveProcesses() const; + bool isCancelRequested(const QUuid &processId) const; + ZIconWidget *getButton(); +private slots: + void onCancelClicked(); + void onOpenFolderClicked(); + +private: + void updateUI(); + void showBalloon(); + void createProcessWidget(ProcessItem *item); + QString formatFileSize(qint64 bytes) const; + QString formatTransferRate(qint64 bytesPerSecond) const; + void removeProcessWidget(const QUuid &processId); + void connectExportThreadSignals(); + void onExportFinished(const QUuid &processId, + const ExportJobSummary &summary); + void onItemExported(const QUuid &processId, const ExportResult &result); + + QVBoxLayout *m_mainLayout; + QLabel *m_headerLabel; + QWidget *m_processesContainer; + QVBoxLayout *m_processesLayout; + + QMap m_processes; + QUuid m_currentProcessId; + mutable QMutex m_processesMutex; + + QMap m_lastBytesTransferred; + QMap m_lastUpdateTime; + ZIconWidget *m_button = + new ZIconWidget(QIcon(":/resources/icons/UimProcess.png"), "Processes"); +}; +#endif // STATUSBALLOON_H diff --git a/src/toolboxwidget.cpp b/src/toolboxwidget.cpp index a403d13..a8d56f1 100644 --- a/src/toolboxwidget.cpp +++ b/src/toolboxwidget.cpp @@ -298,7 +298,8 @@ void ToolboxWidget::updateDeviceList() QString::fromStdString(device->udid).left(8) + "..."; m_deviceCombo->addItem( QString::fromStdString(device->deviceInfo.productType) + " / " + - shortUdid, + shortUdid + + (device->deviceInfo.isWireless ? " (Wi-Fi)" : ""), QString::fromStdString(device->udid)); } }