/* * Copyright (c) 2025 Meltytech, LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "htmlgenerator.h" #include "Logger.h" #include "settings.h" #include #include #include #include #include #include HtmlGenerator::HtmlGenerator(QObject *parent) : QObject(parent) , m_webSocket(new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this)) , m_networkManager(new QNetworkAccessManager(this)) , m_messageId(1) , m_chromeProcess(new QProcess(this)) { connect(m_webSocket, &QWebSocket::connected, this, &HtmlGenerator::onWebSocketConnected); connect(m_webSocket, &QWebSocket::textMessageReceived, this, &HtmlGenerator::onMessageReceived); connect(m_webSocket, &QWebSocket::disconnected, this, &HtmlGenerator::onWebSocketDisconnected); } HtmlGenerator::~HtmlGenerator() { if (m_chromeProcess && m_chromeProcess->state() == QProcess::Running) { m_chromeProcess->terminate(); m_chromeProcess->waitForFinished(2000); m_chromeProcess->kill(); } } void HtmlGenerator::setAnimationParameters(double fps, int duration) { m_fps = fps; m_duration = duration; m_animationMode = (fps > 0 && duration > 0); } void HtmlGenerator::launchBrowser(const QString &executablePath, const QString &url, const QSize &viewport, const QString &outputPath) { m_url = url; m_viewport = viewport; m_outputPath = outputPath; // Start browser with appropriate arguments QStringList arguments; arguments << "--remote-debugging-port=9222" << "--headless=new" << "--disable-gpu" << "--no-sandbox" << "--no-zygote" // << "--single-process" << "--no-first-run" << "--no-default-browser-check" << "--allow-file-access-from-files" << "--user-data-dir=" + m_tempDir.path(); connect(m_chromeProcess, &QProcess::finished, this, &HtmlGenerator::onChromeProcessFinished); connect(m_chromeProcess, &QProcess::errorOccurred, this, &HtmlGenerator::onChromeProcessError); #if defined(Q_OS_UNIX) && !defined(Q_OS_MAC) auto env = QProcessEnvironment::systemEnvironment(); env.remove("LD_LIBRARY_PATH"); LOG_DEBUG() << env.toStringList(); m_chromeProcess->setProcessEnvironment(env); #endif LOG_DEBUG() << executablePath + " " + arguments.join(' '); m_chromeProcess->start(executablePath, arguments); if (!m_chromeProcess->waitForStarted(5000)) { LOG_ERROR() << "Failed to start browser process"; Settings.setChromiumPath(QString()); return; } // Wait a bit for browser to start up QTimer::singleShot(2000, this, &HtmlGenerator::connectToBrowser); } void HtmlGenerator::connectToBrowser() { // Get the list of pages from Chrome QNetworkRequest request(QUrl("http://localhost:9222/json/list")); QNetworkReply *reply = m_networkManager->get(request); connect(reply, &QNetworkReply::finished, this, [this, reply]() { if (reply->error() != QNetworkReply::NoError) { LOG_ERROR() << "Failed to get Chrome debug info:" << reply->errorString(); Settings.setChromiumPath(QString()); return; } QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); QJsonArray pages = doc.array(); QString webSocketUrl; // Look for an existing page or create a new one for (auto pageValue : std::as_const(pages)) { const auto page = pageValue.toObject(); if (page["type"].toString() == "page") { webSocketUrl = page["webSocketDebuggerUrl"].toString(); break; } } if (webSocketUrl.isEmpty()) { // Create a new page LOG_DEBUG() << "No existing page found, creating a new one"; createNewPage(); } else { LOG_DEBUG() << "Using existing page, connecting to WebSocket:" << webSocketUrl; m_webSocket->open(QUrl(webSocketUrl)); } reply->deleteLater(); }); } void HtmlGenerator::createNewPage() { QNetworkRequest request(QUrl("http://localhost:9222/json/new")); auto *reply = m_networkManager->get(request); connect(reply, &QNetworkReply::finished, this, [this, reply]() { if (reply->error() != QNetworkReply::NoError) { LOG_ERROR() << "Failed to create new page:" << reply->errorString(); return; } const auto doc = QJsonDocument::fromJson(reply->readAll()); const auto page = doc.object(); const auto webSocketUrl = page["webSocketDebuggerUrl"].toString(); if (webSocketUrl.isEmpty()) { LOG_ERROR() << "No WebSocket URL found in new page response"; return; } LOG_DEBUG() << "Created new page, connecting to WebSocket:" << webSocketUrl; m_webSocket->open(QUrl(webSocketUrl)); reply->deleteLater(); }); } void HtmlGenerator::onWebSocketConnected() { LOG_DEBUG() << "Connected to Chrome DevTools"; // Enable Runtime and Page events sendCommand("Runtime.enable"); sendCommand("Page.enable"); // Set default background color to transparent QJsonObject params; params["color"] = QJsonObject{{"r", 0}, {"g", 0}, {"b", 0}, {"a", 0}}; sendCommand("Emulation.setDefaultBackgroundColorOverride", params); // Set viewport params = QJsonObject(); params["width"] = m_viewport.width(); params["height"] = m_viewport.height(); params["deviceScaleFactor"] = 1; params["mobile"] = false; sendCommand("Emulation.setDeviceMetricsOverride", params); // Navigate to the URL QJsonObject navParams; navParams["url"] = m_url; sendCommand("Page.navigate", navParams); // Set a timeout for taking screenshot in case page load event doesn't fire QTimer::singleShot(10000, this, &HtmlGenerator::takeScreenshot); } void HtmlGenerator::onMessageReceived(const QString &message) { // if (!message.contains("\"data\"")) // LOG_DEBUG() << "Received message:" << message; const auto doc = QJsonDocument::fromJson(message.toUtf8()); const auto obj = doc.object(); if (obj.contains("method")) { const auto method = obj["method"].toString(); if (method == "Page.loadEventFired") { LOG_DEBUG() << "Page loaded, starting capture"; if (m_animationMode) { startAnimationCapture(); } else { takeScreenshot(); } } } else if (obj.contains("id") && obj.contains("result")) { const auto id = obj["id"].toInt(); if (m_pendingScreenshot && id == m_screenshotMessageId) { if (m_animationMode) { handleAnimationFrame(obj["result"].toObject()); } else { handleScreenshotResult(obj["result"].toObject()); } } } else if (obj.contains("error")) { LOG_ERROR() << "Error received:" << QJsonDocument(obj["error"].toObject()).toJson(); } } void HtmlGenerator::onWebSocketDisconnected() { LOG_DEBUG() << "Disconnected from Chrome DevTools"; } void HtmlGenerator::onChromeProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) { // Q_UNUSED(exitCode) Q_UNUSED(exitStatus) LOG_DEBUG() << "Chrome process finished" << exitCode; } void HtmlGenerator::onChromeProcessError(QProcess::ProcessError error) { // Ignore crash errors after successful screenshot completion if (error == QProcess::Crashed && m_screenshotCompleted) { LOG_DEBUG() << "Chrome process crashed after successful screenshot completion (expected)"; return; } LOG_ERROR() << "Chrome process error:" << error; } void HtmlGenerator::startAnimationCapture() { // Create output directory const QDir outputDir(m_outputPath); if (!outputDir.exists()) { if (!outputDir.mkpath(".")) { LOG_ERROR() << "Failed to create output directory:" << m_outputPath; return; } } // Calculate animation parameters const auto frameInterval = 1000.0 / m_fps; // milliseconds per frame m_totalFrames = static_cast(std::ceil(m_duration / frameInterval)); m_currentFrame = 0; LOG_DEBUG() << QString("Capturing %1 frames at %2 fps over %3ms...") .arg(m_totalFrames) .arg(m_fps) .arg(m_duration); // Start timing m_animationElapsed.start(); // Take first frame captureAnimationFrame(); } void HtmlGenerator::captureAnimationFrame() { if (m_currentFrame >= m_totalFrames) { completeAnimationCapture(); return; } // LOG_DEBUG() << "Taking screenshot..."; m_pendingScreenshot = true; QJsonObject params; params["format"] = "png"; params["omitBackground"] = true; params["captureBeyondViewport"] = false; m_screenshotMessageId = sendCommand("Page.captureScreenshot", params); } void HtmlGenerator::handleAnimationFrame(const QJsonObject &result) { m_pendingScreenshot = false; const auto base64Data = result["data"].toString(); const auto imageData = QByteArray::fromBase64(base64Data.toUtf8()); // Save frame with zero-padded filename const auto frameNumber = QString("%1").arg(m_currentFrame, 4, 10, QLatin1Char('0')); const auto filename = QDir(m_outputPath).filePath(QString("frame_%1.png").arg(frameNumber)); QFile file(filename); if (file.open(QIODevice::WriteOnly)) { file.write(imageData); file.close(); LOG_DEBUG() << "Captured frame" << (m_currentFrame + 1) << "/" << m_totalFrames; emit progressUpdate(float(m_currentFrame + 1) / m_totalFrames); } else { LOG_ERROR() << "Failed to save frame:" << file.errorString(); return; } m_currentFrame++; // Schedule next frame if (m_currentFrame < m_totalFrames) { // Calculate when the next frame should be captured const auto frameInterval = 1000.0 / m_fps; const auto targetTime = static_cast(m_currentFrame * frameInterval); const auto currentTime = m_animationElapsed.elapsed(); int delay = std::max(0LL, targetTime - currentTime); if (delay == 0) LOG_DEBUG() << "frame duration" << frameInterval << "delay" << targetTime - currentTime << "ms"; QTimer::singleShot(delay, this, &HtmlGenerator::captureAnimationFrame); } else { completeAnimationCapture(); } } void HtmlGenerator::completeAnimationCapture() { m_screenshotCompleted = true; LOG_DEBUG() << QString("Animation frames saved to %1/").arg(m_outputPath); // Close the browser and exit m_webSocket->close(); if (m_chromeProcess && m_chromeProcess->state() == QProcess::Running) { m_chromeProcess->terminate(); } // Emit signal indicating animation frames are ready emit imageReady(m_outputPath); } void HtmlGenerator::takeScreenshot() { if (m_pendingScreenshot) { LOG_DEBUG() << "Screenshot already pending, skipping"; return; } LOG_DEBUG() << "Taking screenshot..."; m_pendingScreenshot = true; QJsonObject params; params["format"] = "png"; params["omitBackground"] = true; m_screenshotMessageId = sendCommand("Page.captureScreenshot", params); } void HtmlGenerator::handleScreenshotResult(const QJsonObject &result) { m_pendingScreenshot = false; m_screenshotCompleted = true; const auto base64Data = result["data"].toString(); const auto imageData = QByteArray::fromBase64(base64Data.toUtf8()); bool success = false; QFile file(m_outputPath); if (file.open(QIODevice::WriteOnly)) { file.write(imageData); file.close(); success = true; LOG_DEBUG() << "Screenshot saved to:" << m_outputPath; } else { LOG_ERROR() << "Failed to save screenshot:" << file.errorString(); } // Close the browser and exit sendCommand("Browser.close"); QTimer::singleShot(100, m_chromeProcess, [=]() { m_webSocket->close(); if (m_chromeProcess && m_chromeProcess->state() == QProcess::Running) m_chromeProcess->terminate(); }); if (success) emit imageReady(m_outputPath); } int HtmlGenerator::sendCommand(const QString &method, const QJsonObject ¶ms) { QJsonObject command; command["id"] = m_messageId; command["method"] = method; if (!params.isEmpty()) { command["params"] = params; } const QJsonDocument doc(command); const QString message = doc.toJson(QJsonDocument::Compact); // LOG_DEBUG() << "Sending command:" << message; m_webSocket->sendTextMessage(message); return m_messageId++; }