/* * iDescriptor: A free and open-source idevice management tool. * * Copyright (C) 2025 Uncore * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ #include "mediastreamer.h" #include #include "iDescriptor.h" #include "servicemanager.h" #include #include #include #include #include #include #include MediaStreamer::MediaStreamer(const iDescriptorDevice *device, AfcClientHandle *afcClient, const QString &filePath, QObject *parent) : QTcpServer(parent), m_device(device), m_afcClient(afcClient), m_filePath(filePath), m_cachedFileSize(-1), m_fileSizeCached(false) { // Listen on localhost with automatic port assignment if (!listen(QHostAddress::LocalHost, 0)) { qWarning() << "MediaStreamer failed to start:" << errorString(); } else { qDebug() << "MediaStreamer listening on" << getUrl().toString(); } } MediaStreamer::~MediaStreamer() { // Close all active connections QMutexLocker locker(&m_connectionsMutex); for (QTcpSocket *socket : m_activeConnections) { socket->disconnectFromHost(); if (socket->state() != QAbstractSocket::UnconnectedState) { socket->waitForDisconnected(1000); } socket->deleteLater(); } m_activeConnections.clear(); } QUrl MediaStreamer::getUrl() const { if (!isListening()) { return QUrl(); } return QUrl(QString("http://127.0.0.1:%1/%2") .arg(serverPort()) .arg(QUrl::toPercentEncoding(m_filePath))); } bool MediaStreamer::isListening() const { return QTcpServer::isListening(); } void MediaStreamer::incomingConnection(qintptr socketDescriptor) { auto *socket = new QTcpSocket(this); if (!socket->setSocketDescriptor(socketDescriptor)) { qWarning() << "Failed to set socket descriptor"; socket->deleteLater(); return; } // Add to active connections { QMutexLocker locker(&m_connectionsMutex); m_activeConnections.append(socket); } connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { QByteArray requestData = socket->readAll(); HttpRequest request = parseHttpRequest(requestData); handleRequest(socket, request); }); connect(socket, &QTcpSocket::disconnected, this, &MediaStreamer::handleClientDisconnected); connect(socket, QOverload::of( &QAbstractSocket::errorOccurred), this, [this, socket](QAbstractSocket::SocketError error) { qWarning() << "Socket error:" << error << socket->errorString(); socket->deleteLater(); }); qDebug() << "MediaStreamer: Client connected from" << socket->peerAddress().toString(); } void MediaStreamer::handleClientDisconnected() { auto *socket = qobject_cast(sender()); if (!socket) return; { QMutexLocker locker(&m_connectionsMutex); m_activeConnections.removeAll(socket); } qDebug() << "MediaStreamer: Client disconnected"; socket->deleteLater(); } MediaStreamer::HttpRequest MediaStreamer::parseHttpRequest(const QByteArray &requestData) { HttpRequest request; const QString requestStr = QString::fromUtf8(requestData); const QStringList lines = requestStr.split("\r\n"); if (lines.isEmpty()) { return request; } // Parse request line: "GET /path HTTP/1.1" const QStringList requestLine = lines[0].split(" "); if (requestLine.size() >= 3) { request.method = requestLine[0]; request.path = requestLine[1]; request.httpVersion = requestLine[2]; } // Parse headers for (int i = 1; i < lines.size(); ++i) { const QString &line = lines[i]; if (line.isEmpty()) break; // End of headers const int colonPos = line.indexOf(':'); if (colonPos > 0) { const QString key = line.left(colonPos).trimmed(); const QString value = line.mid(colonPos + 1).trimmed(); request.headers[key.toLower()] = value; } } // Parse Range header if present if (request.headers.contains("range")) { const QString rangeHeader = request.headers["range"]; if (rangeHeader.startsWith("bytes=")) { const QString rangeValue = rangeHeader.mid(6); // Remove "bytes=" const QStringList rangeParts = rangeValue.split('-'); if (rangeParts.size() == 2) { request.hasRange = true; bool ok; request.rangeStart = rangeParts[0].toLongLong(&ok); if (!ok) request.rangeStart = 0; if (!rangeParts[1].isEmpty()) { request.rangeEnd = rangeParts[1].toLongLong(&ok); if (!ok) request.rangeEnd = -1; } } } } return request; } void MediaStreamer::handleRequest(QTcpSocket *socket, const HttpRequest &request) { if (request.method != "GET" && request.method != "HEAD") { sendErrorResponse(socket, 405, "Method Not Allowed"); return; } const qint64 fileSize = getFileSize(); if (fileSize <= 0) { sendErrorResponse(socket, 404, "File Not Found"); return; } qint64 rangeStart = 0; qint64 rangeEnd = fileSize - 1; if (request.hasRange) { rangeStart = request.rangeStart; if (request.rangeEnd >= 0 && request.rangeEnd < fileSize) { rangeEnd = request.rangeEnd; } // Validate range if (rangeStart < 0 || rangeStart >= fileSize || rangeStart > rangeEnd) { sendErrorResponse(socket, 416, "Range Not Satisfiable"); return; } } const qint64 contentLength = rangeEnd - rangeStart + 1; const QString mimeType = getMimeType(); // Send response headers QByteArray response; if (request.hasRange) { response += "HTTP/1.1 206 Partial Content\r\n"; response += QString("Content-Range: bytes %1-%2/%3\r\n") .arg(rangeStart) .arg(rangeEnd) .arg(fileSize) .toUtf8(); } else { response += "HTTP/1.1 200 OK\r\n"; } response += "Accept-Ranges: bytes\r\n"; response += QString("Content-Length: %1\r\n").arg(contentLength).toUtf8(); response += QString("Content-Type: %1\r\n").arg(mimeType).toUtf8(); response += "Connection: close\r\n"; response += "Cache-Control: no-cache\r\n"; response += "\r\n"; socket->write(response); // For HEAD requests, don't send body if (request.method == "HEAD") { socket->disconnectFromHost(); return; } // Stream file content streamFileRange(socket, rangeStart, rangeEnd); } void MediaStreamer::sendErrorResponse(QTcpSocket *socket, int statusCode, const QString &statusText) { const QByteArray response = QString("HTTP/1.1 %1 %2\r\n" "Content-Length: 0\r\n" "Connection: close\r\n" "\r\n") .arg(statusCode) .arg(statusText) .toUtf8(); socket->write(response); socket->disconnectFromHost(); } void MediaStreamer::streamFileRange(QTcpSocket *socket, qint64 startByte, qint64 endByte) { StreamingContext *context = new StreamingContext(); context->socket = socket; context->device = m_device; context->filePath = m_filePath; context->startByte = startByte; context->endByte = endByte; context->bytesRemaining = endByte - startByte + 1; context->afcHandle = nullptr; const QByteArray pathBytes = m_filePath.toUtf8(); IdeviceFfiError *err_open = ServiceManager::safeAfcFileOpen( m_device, pathBytes.constData(), AfcRdOnly, &context->afcHandle); if (err_open || context->afcHandle == 0) { qWarning() << "Failed to open file on device:" << m_filePath; delete context; socket->disconnectFromHost(); return; } /* TODO: can it be optimized by doing SEEK_END 2 /* Seek from end of file. ??? */ if (startByte > 0) { IdeviceFfiError *seek_err = ServiceManager::safeAfcFileSeek( m_device, context->afcHandle, startByte, SEEK_SET); if (seek_err) { qWarning() << "Failed to seek in file:" << m_filePath; IdeviceFfiError *err = ServiceManager::safeAfcFileClose(m_device, context->afcHandle); if (err) { idevice_error_free(err); } delete context; socket->disconnectFromHost(); return; } } // Store context as socket property for cleanup socket->setProperty("streamingContext", QVariant::fromValue(static_cast(context))); // Connect to socket signals for async streaming connect(socket, &QTcpSocket::bytesWritten, this, [this, context](qint64 bytes) { Q_UNUSED(bytes) // Check if context is still valid QTcpSocket *senderSocket = qobject_cast(sender()); if (!senderSocket || senderSocket->property("streamingContext").isNull()) { return; } // Continue streaming when socket buffer has space if (context->socket->bytesToWrite() < 50000) { streamNextChunk(context); } }); connect(socket, &QTcpSocket::disconnected, this, [this, context]() { // Check if context is still valid before cleanup QTcpSocket *senderSocket = qobject_cast(sender()); if (!senderSocket || senderSocket->property("streamingContext").isNull()) { return; } cleanupStreamingContext(context); }); // Start streaming the first chunk streamNextChunk(context); } qint64 MediaStreamer::getFileSize() { QMutexLocker locker(&m_fileSizeMutex); if (m_fileSizeCached) { return m_cachedFileSize; } // Get file info from device using ServiceManager const QByteArray pathBytes = m_filePath.toUtf8(); AfcFileInfo info = {}; IdeviceFfiError *info_err = ServiceManager::safeAfcGetFileInfo( m_device, pathBytes.constData(), &info); if (info_err || info.size == 0) { qWarning() << "Failed to get file info for:" << m_filePath; idevice_error_free(info_err); return -1; } size_t fileSize = info.size; // FIXME : safe to free ? // afc_file_info_free(&info); if (fileSize > 0) { m_cachedFileSize = fileSize; m_fileSizeCached = true; } return fileSize; } QString MediaStreamer::getMimeType() const { const QString lower = m_filePath.toLower(); if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) { return "video/mp4"; } else if (lower.endsWith(".mov")) { return "video/quicktime"; } else if (lower.endsWith(".avi")) { return "video/x-msvideo"; } else if (lower.endsWith(".mkv")) { return "video/x-matroska"; } return "application/octet-stream"; } void MediaStreamer::streamNextChunk(StreamingContext *context) { if (!context || !context->socket) { return; } if (context->socket->property("streamingContext").isNull()) { return; } if (context->bytesRemaining <= 0) { qDebug() << "Streaming completed for" << QFileInfo(context->filePath).fileName(); cleanupStreamingContext(context); return; } if (context->socket->state() != QAbstractSocket::ConnectedState) { cleanupStreamingContext(context); return; } const int CHUNK_SIZE = 64 * 1024; const uint32_t bytesToRead = static_cast( qMin(static_cast(CHUNK_SIZE), context->bytesRemaining)); uint8_t *chunkData = nullptr; size_t bytesRead = 0; IdeviceFfiError *read_err = ServiceManager::safeAfcFileRead( m_device, context->afcHandle, &chunkData, bytesToRead, &bytesRead); if (read_err) { qWarning() << "AFC read error during streaming:" << read_err->message; idevice_error_free(read_err); cleanupStreamingContext(context); return; } if (bytesRead == 0 && bytesToRead > 0) { qWarning() << "AFC read returned 0 bytes but expected to read:" << bytesToRead; // FIXME: in such situation, freeing shouldn't be safe ? // if (chunkData) { // afc_file_read_data_free(chunkData, 0); // } cleanupStreamingContext(context); return; } if (bytesRead > 0 && chunkData) { const qint64 bytesWritten = context->socket->write( reinterpret_cast(chunkData), bytesRead); afc_file_read_data_free(chunkData, bytesRead); if (bytesWritten == -1) { qWarning() << "Socket write error"; cleanupStreamingContext(context); return; } context->bytesRemaining -= bytesWritten; } else { qWarning() << "AFC read error: No data or null chunkData despite " "bytesRead > 0."; cleanupStreamingContext(context); return; } if (context->bytesRemaining <= 0) { qDebug() << "Streaming completed for" << QFileInfo(context->filePath).fileName(); cleanupStreamingContext(context); return; } if (context->socket->bytesToWrite() >= 50000) { // Wait for bytesWritten signal return; } else { // Continue immediately with safety check QTimer::singleShot(0, this, [this, context]() { // Double-check context is still valid when timer fires if (context && context->socket && !context->socket->property("streamingContext").isNull()) { streamNextChunk(context); } }); } } void MediaStreamer::cleanupStreamingContext(StreamingContext *context) { if (!context) return; // Mark as cleaned up immediately to prevent double cleanup if (context->socket) { // Check if already cleaned up if (context->socket->property("streamingContext").isNull()) { return; // Already cleaned up } context->socket->setProperty("streamingContext", QVariant()); } if (context->afcHandle != 0) { ServiceManager::safeAfcFileClose(context->device, context->afcHandle); context->afcHandle = 0; } if (context->socket) { // Disconnect all our custom signals to prevent further callbacks disconnect(context->socket, &QTcpSocket::bytesWritten, this, nullptr); disconnect(context->socket, &QTcpSocket::disconnected, this, nullptr); context->socket->disconnectFromHost(); context->socket = nullptr; } qDebug() << "Streaming context cleaned up for" << QFileInfo(context->filePath).fileName(); delete context; }