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 0000000..0fa302d Binary files /dev/null and b/resources/icons/QlementineIconsWireless116.png differ diff --git a/resources/icons/UimProcess.png b/resources/icons/UimProcess.png new file mode 100644 index 0000000..0ff6cff Binary files /dev/null and b/resources/icons/UimProcess.png differ 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)); } }