This commit is contained in:
2026-09-29 09:40:37 +03:00
parent 441021b9a6
commit 6567e731d1
34 changed files with 57 additions and 709 deletions
+1 -36
View File
@@ -20,27 +20,18 @@
namespace {
// Формат каталога, который понимает этот лаунчер. Сервер, отдающий версию
// новее, обслуживает лаунчер новее — молча показывать половину полей нельзя.
constexpr int kSupportedFormatVersion = 1;
// Каталог весит килобайты, а новая ревизия сборки должна доезжать до игроков
// быстро. Четверть часа — это и не поток запросов при каждом открытии окна, и
// не полдня ожидания после публикации; кнопка «Обновить список» ходит с force.
constexpr qint64 kCacheTtlSeconds = 15 * 60;
constexpr int kTransferTimeoutMs = 20000;
// id попадает в имена файлов и в сравнение с локальными записями, поэтому
// набор символов узкий и проверяется до всего остального.
bool isSafeCatalogId(const QString &id)
{
static const QRegularExpression re(QStringLiteral("^[a-z0-9][a-z0-9._-]{0,63}$"));
return re.match(id).hasMatch();
}
// Тот же запрет, что у идентификаторов версий в minecraftversion.cpp: строка
// становится именем папки в versions.
bool isSafeVersionId(const QString &id)
{
if (id.isEmpty() || id.size() > 128)
@@ -61,7 +52,6 @@ bool isSha256Hex(const QString &value)
return true;
}
// null или пустая строка — даты нет; в таблице на её месте прочерк.
QDate readDate(const QJsonValue &value)
{
const QString text = value.toString();
@@ -73,7 +63,7 @@ QString dateToJson(const QDate &date)
return date.isValid() ? date.toString(Qt::ISODate) : QString();
}
} // namespace
}
SeasonalBuildService::SeasonalBuildService(QObject *parent)
: QObject(parent)
@@ -95,9 +85,6 @@ void SeasonalBuildService::setBaseUrl(const QUrl &baseUrl)
return;
m_baseUrl = baseUrl;
// Ссылки на архивы привязаны к прежнему хосту, а проверка их происхождения
// сверяется с базовым адресом — держать такие данные после смены сервера
// нельзя.
m_builds.clear();
m_indexById.clear();
m_fetchedAt = QDateTime();
@@ -154,28 +141,21 @@ QList<SeasonalBuildEntry> SeasonalBuildService::parseCatalog(const QJsonObject &
entry.archiveSize = qint64(archive.value(QStringLiteral("size")).toDouble());
entry.archiveSha256 = archive.value(QStringLiteral("sha256")).toString().toLower();
// Данные пришли из сети и превратятся в пути на диске и в запуск игры,
// поэтому строка отбрасывается целиком при любой странности.
if (!isSafeCatalogId(entry.id) || entry.name.isEmpty() || entry.revision <= 0)
continue;
if (!isSafeVersionId(entry.minecraftVersion))
continue;
if (!entry.loader.isEmpty() && !loaderFromKey(entry.loader))
continue;
// Загрузчик без версии установить нечем.
if (!entry.loader.isEmpty() && entry.loaderVersion.isEmpty())
continue;
if (!isSha256Hex(entry.archiveSha256) || entry.archiveSize < 0)
continue;
// Ссылка обязана вести на тот же сервер, с которого пришёл каталог:
// подменённый index.json иначе увёл бы загрузку куда угодно.
if (entry.archiveUrl.scheme() != m_baseUrl.scheme()
|| entry.archiveUrl.host() != m_baseUrl.host()
|| entry.archiveUrl.port(-1) != m_baseUrl.port(-1)) {
continue;
}
// http допустим только если сам адрес сервера задан по http — так
// работает отладка против локального python3 -m http.server.
if (entry.archiveUrl.scheme() != QLatin1String("https")
&& entry.archiveUrl.scheme() != QLatin1String("http")) {
continue;
@@ -184,7 +164,6 @@ QList<SeasonalBuildEntry> SeasonalBuildService::parseCatalog(const QJsonObject &
list << entry;
}
// Свежие сезоны сверху: сначала те, что ещё идут, потом по дате старта.
std::stable_sort(list.begin(), list.end(),
[](const SeasonalBuildEntry &a, const SeasonalBuildEntry &b) {
const bool aOpen = !a.seasonEnd.isValid();
@@ -220,8 +199,6 @@ void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh)
const bool fresh = hasData() && m_fetchedAt.isValid()
&& m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
if (!forceRefresh && fresh) {
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
// на попадании в кэш отвечаем через событийный цикл.
if (callback)
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
return;
@@ -260,10 +237,6 @@ void SeasonalBuildService::startRefresh()
QString parseError;
QList<SeasonalBuildEntry> parsed =
parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError);
// Пустой разбор — испорченный ответ; хороший кэш им не затираем.
// Пустой каталог на сервере от испорченного не отличить, поэтому
// «сборок нет» и «ответ битый» — одно и то же состояние: список
// остаётся прежним, а причина уходит в lastError.
if (parsed.isEmpty()) {
finishRefresh(false,
parseError.isEmpty() ? Loc::text("seasonal.error.catalogEmpty")
@@ -284,14 +257,10 @@ void SeasonalBuildService::finishRefresh(bool ok, const QString &warning)
m_lastError = ok ? QString() : warning;
m_refreshing = false;
emit refreshingChanged();
// Строка ошибки — часть того, что показывает окно, поэтому список
// перечитывается и на неудаче.
emit buildsChanged();
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
const QList<Callback> pending = std::exchange(m_pending, {});
for (const Callback &callback : pending) {
// Сеть недоступна, но старые данные есть — работаем на них.
if (!ok && hasData())
callback(true, warning);
else
@@ -306,7 +275,6 @@ bool SeasonalBuildService::readCache()
return false;
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
// Кэш от другого сервера бесполезен: ссылки в нём ведут не туда.
if (root.value(QStringLiteral("baseUrl")).toString() != m_baseUrl.toString())
return false;
@@ -322,7 +290,6 @@ bool SeasonalBuildService::readCache()
void SeasonalBuildService::writeCache()
{
// Папку кэша могли удалить уже после запуска лаунчера.
if (!LauncherPaths::ensureRootExists())
return;
@@ -347,8 +314,6 @@ void SeasonalBuildService::writeCache()
{QStringLiteral("sha256"), entry.archiveSha256}}}};
}
// Своя обёртка: в каталоге нет ни отметки свежести кэша, ни адреса, с
// которого он приехал, — а без них кэш нечем проверить.
const QJsonObject root{{QStringLiteral("formatVersion"), kSupportedFormatVersion},
{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
{QStringLiteral("baseUrl"), m_baseUrl.toString()},