diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index e380f431e..8f0be97de 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -39,6 +39,8 @@ set(CORE_SOURCES QVariantUtils.h RuntimeContext.h PSaveFile.h + PNGUtils.h + PNGUtils.cpp # Basic instance manipulation tasks (derived from InstanceTask) InstanceCreationTask.h diff --git a/launcher/PNGUtils.cpp b/launcher/PNGUtils.cpp new file mode 100644 index 000000000..2a444b446 --- /dev/null +++ b/launcher/PNGUtils.cpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Freesm Launcher - Minecraft Launcher + * Copyright (C) 2026 so5iso4ka + * + * 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, version 3. + * + * 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 +#include + +#include "PNGUtils.h" + +bool PNGUtils::isCompletePNG(QByteArrayView bytes) +{ + using chunkLengthType = quint32; + constexpr qsizetype chunkLengthSize = 4, chunkTypeSize = 4, chunkCRCSize = 4, pngHeaderSize = 8; + constexpr qsizetype overheadSize = chunkLengthSize + chunkTypeSize + chunkCRCSize; + constexpr std::array pngHeader{ '\x89', 'P', 'N', 'G', '\r', '\n', '\x1a', '\n' }; + constexpr std::array iendCRC{ '\xAE', '\x42', '\x60', '\x82' }; + static_assert(sizeof(chunkLengthType) == chunkLengthSize); + + const qsizetype size = bytes.size(); + if (!bytes.startsWith(pngHeader)) { + return false; + } + + qsizetype i = pngHeaderSize; + while (i + chunkLengthSize + chunkTypeSize + chunkCRCSize <= size) { + chunkLengthType len = qFromBigEndian(bytes.sliced(i, chunkLengthSize).constData()); + if (len > size - i - overheadSize) { + return false; + } + + auto typeBytes = bytes.sliced(i + chunkLengthSize, chunkTypeSize); + if ((typeBytes == "IHDR" && i != pngHeaderSize) || (i == pngHeaderSize && typeBytes != "IHDR")) { + return false; + } + if (typeBytes == "IEND" && len == 0) { + auto crcBytes = bytes.sliced(i + chunkLengthSize + chunkTypeSize + len, chunkCRCSize); + return crcBytes == QByteArrayView(iendCRC) && i + overheadSize == size; + } + + i += overheadSize + len; + } + + return false; +} diff --git a/launcher/PNGUtils.h b/launcher/PNGUtils.h new file mode 100644 index 000000000..b5ccc3dc8 --- /dev/null +++ b/launcher/PNGUtils.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Freesm Launcher - Minecraft Launcher + * Copyright (C) 2026 so5iso4ka + * + * 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, version 3. + * + * 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 . + */ + +#pragma once + +#include + +namespace PNGUtils { +bool isCompletePNG(QByteArrayView bytes); +} diff --git a/launcher/minecraft/ScreenshotsWatcher.cpp b/launcher/minecraft/ScreenshotsWatcher.cpp index 8bdc55521..2f27646e6 100644 --- a/launcher/minecraft/ScreenshotsWatcher.cpp +++ b/launcher/minecraft/ScreenshotsWatcher.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only /* * Freesm Launcher - Minecraft Launcher - * Copyright (C) 2025 so5iso4ka + * Copyright (C) 2026 so5iso4ka * * 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 @@ -18,19 +18,22 @@ #include #include +#include +#include #include "Application.h" #include "FileSystem.h" #include "MinecraftInstance.h" +#include "PNGUtils.h" #include "ScreenshotsWatcher.h" ScreenshotsWatcher::ScreenshotsWatcher(const QString& path) : m_watcher(QStringList{ path }) { m_retryTimer.setSingleShot(true); - m_retryTimer.setInterval(200); connect(&m_retryTimer, &QTimer::timeout, this, &ScreenshotsWatcher::tryCopy); connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &ScreenshotsWatcher::dirUpdated); + connect(&m_futureWatcher, &QFutureWatcher::finished, this, &ScreenshotsWatcher::onTryFinished); } void ScreenshotsWatcher::dirUpdated(const QString& path) @@ -47,34 +50,44 @@ void ScreenshotsWatcher::dirUpdated(const QString& path) m_pendingPath = file; m_attempt = 0; - m_lastSize = QFileInfo(m_pendingPath).size(); scheduleRetry(); } void ScreenshotsWatcher::tryCopy() { - const QFileInfo fileInfo(m_pendingPath); - if (!fileInfo.exists()) - return; + m_futureWatcher.setFuture(QtConcurrent::run(QThreadPool::globalInstance(), tryLoadPNG, m_pendingPath)); +} - if (m_lastSize == 0 || m_lastSize != fileInfo.size()) { - m_lastSize = fileInfo.size(); - scheduleRetry(); +void ScreenshotsWatcher::onTryFinished() +{ + auto future = m_futureWatcher.future(); + if (!future.isValid()) { return; } - m_lastSize = fileInfo.size(); - QImage img; - bool loaded = img.load(m_pendingPath); + auto [status, img, path] = future.result(); - if (!loaded) { - scheduleRetry(); + if (path != m_pendingPath) { return; } - const auto clipboard = QApplication::clipboard(); - if (clipboard == nullptr) { + switch (status) { + case LoadResult::Status::Ok: { + break; + } + case LoadResult::Status::Retry: { + scheduleRetry(); + return; + } + case LoadResult::Status::Failed: { + qDebug() << "Unable to load the image:" << path; + return; + } + } + + auto clipboard = QApplication::clipboard(); + if (!clipboard) { qDebug() << "Clipboard is null"; return; } @@ -85,10 +98,34 @@ void ScreenshotsWatcher::tryCopy() void ScreenshotsWatcher::scheduleRetry() { ++m_attempt; - if (m_attempt > 5) { - qDebug() << "Image wasn't loaded after 5 retries"; + if (m_attempt > 12) { // 100 + 200 + 400 + 800 + 1000 * 8 = 9500ms + qDebug() << "Image wasn't loaded in time: " << m_pendingPath; return; } - m_retryTimer.start(); + m_retryTimer.start(std::min(100 << (m_attempt - 1), 1000)); +} + +ScreenshotsWatcher::LoadResult ScreenshotsWatcher::tryLoadPNG(QString path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return { LoadResult::Status::Retry, {}, path }; + } + + if (file.size() > qint64{ 512 * 1024 * 1024 }) { + return { LoadResult::Status::Failed, {}, path }; + } + + auto data = file.readAll(); + if (!PNGUtils::isCompletePNG(data)) { + return { LoadResult::Status::Retry, {}, path }; + } + + QImage img; + if (!img.loadFromData(data, "PNG")) { + return { LoadResult::Status::Failed, {}, path }; + } + + return { LoadResult::Status::Ok, img, path }; } diff --git a/launcher/minecraft/ScreenshotsWatcher.h b/launcher/minecraft/ScreenshotsWatcher.h index 523629ea3..c6049a9d0 100644 --- a/launcher/minecraft/ScreenshotsWatcher.h +++ b/launcher/minecraft/ScreenshotsWatcher.h @@ -19,10 +19,11 @@ #pragma once #include +#include +#include #include #include #include -#include class ScreenshotsWatcher : public QObject { Q_OBJECT @@ -32,13 +33,22 @@ class ScreenshotsWatcher : public QObject { private slots: void dirUpdated(const QString& path); void tryCopy(); + void onTryFinished(); private: void scheduleRetry(); + struct LoadResult { + enum class Status { Ok, Retry, Failed }; + Status status{ Status::Failed }; + QImage image; + QString path; + }; + static LoadResult tryLoadPNG(QString path); + QFileSystemWatcher m_watcher; + QFutureWatcher m_futureWatcher; QString m_pendingPath; - qsizetype m_attempt{}; - qsizetype m_lastSize{}; + int m_attempt{}; QTimer m_retryTimer; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bdfc11442..300fea2aa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -66,3 +66,6 @@ ecm_add_test(XmlLogs_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR ecm_add_test(ConfigMigration_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}::Test TEST_NAME ConfigMigration) + +ecm_add_test(PNGUtils_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}::Test + TEST_NAME PNGUtils) \ No newline at end of file diff --git a/tests/PNGUtils_test.cpp b/tests/PNGUtils_test.cpp new file mode 100644 index 000000000..bdae08b8e --- /dev/null +++ b/tests/PNGUtils_test.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Freesm Launcher - Minecraft Launcher + * Copyright (C) 2026 so5iso4ka + * + * 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, version 3. + * + * 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 +#include +#include +#include +#include + +#include "PNGUtils.h" + +class PNGUtilsTest : public QObject { + Q_OBJECT + + private slots: + void test_Trivial() + { + QVERIFY(!PNGUtils::isCompletePNG(QByteArrayView{})); + QVERIFY(!PNGUtils::isCompletePNG(QByteArrayView{ "123" })); + QVERIFY(!PNGUtils::isCompletePNG(QByteArrayView{ "1234567890abcdef" })); + } + + void test_File() + { + QFile png(QFINDTESTDATA("testdata/PNGUtils/kitteh.png")); + QVERIFY(png.open(QIODevice::ReadOnly)); + + const QByteArray data = png.readAll(); + const qsizetype size = data.size(); + + QVERIFY(PNGUtils::isCompletePNG(data)); + QVERIFY(!PNGUtils::isCompletePNG(data.first(size - 1))); + QVERIFY(!PNGUtils::isCompletePNG(data.last(size - 1))); + QVERIFY(!PNGUtils::isCompletePNG(data + '\0')); + QVERIFY(!PNGUtils::isCompletePNG('\0' + data)); + QVERIFY(!PNGUtils::isCompletePNG(data.repeated(2))); + + auto badCRC = data; + auto badIHDR = data; + badCRC.back() ^= 1; + badIHDR.replace(12, 4, "IDAT"); + + QVERIFY(!PNGUtils::isCompletePNG(badCRC)); + QVERIFY(!PNGUtils::isCompletePNG(badIHDR)); + + for (qsizetype i = 0; i < size - 1; ++i) { + QVERIFY(!PNGUtils::isCompletePNG(QByteArrayView{ data }.first(i))); + } + } +}; + +QTEST_GUILESS_MAIN(PNGUtilsTest) + +#include "PNGUtils_test.moc" diff --git a/tests/testdata/PNGUtils/kitteh.png b/tests/testdata/PNGUtils/kitteh.png new file mode 100644 index 000000000..e6ede241e Binary files /dev/null and b/tests/testdata/PNGUtils/kitteh.png differ