From 01af372ef2d0ec6fd3bc984042c88394468265a3 Mon Sep 17 00:00:00 2001 From: galeon Date: Thu, 3 Sep 2026 09:18:48 +0300 Subject: [PATCH] translation of all project, and new translation mechanism --- BuildsDialog.qml | 56 +- CMakeLists.txt | 4 +- JavaPickerDialog.qml | 32 +- LoaderRow.qml | 4 +- Main.qml | 150 +-- MicrosoftLoginDialog.qml | 6 +- SeasonalBuildsDialog.qml | 36 +- VersionPickerDialog.qml | 44 +- authservice.cpp | 35 +- buildarchiveworker.cpp | 45 +- buildswitcher.cpp | 31 +- gamelauncher.cpp | 19 +- i18n/translations.json | 1739 +++++++++++++++++++++++++++++++++++ javainstaller.cpp | 67 +- javalocator.cpp | 10 +- javaruntime.cpp | 33 +- javaruntimeservice.cpp | 11 +- launcherbackend.cpp | 236 ++--- launcherpaths.cpp | 11 +- localization.cpp | 291 ++++++ localization.h | 108 +++ main.cpp | 9 + minecraftversion.cpp | 17 +- modloaderinstaller.cpp | 68 +- modloaderversionservice.cpp | 9 +- msaauthservice.cpp | 66 +- seasonalbuildservice.cpp | 11 +- seasonalpackdownloader.cpp | 15 +- tools/check_translations.py | 102 ++ versioninstaller.cpp | 64 +- versionmanifestservice.cpp | 5 +- zlibreference.cpp | 16 +- 32 files changed, 2813 insertions(+), 537 deletions(-) create mode 100644 i18n/translations.json create mode 100644 localization.cpp create mode 100644 localization.h create mode 100644 tools/check_translations.py diff --git a/BuildsDialog.qml b/BuildsDialog.qml index 13cfa66..b6f2c0e 100644 --- a/BuildsDialog.qml +++ b/BuildsDialog.qml @@ -87,14 +87,14 @@ Dialog { const problems = buildsDialog.backend.checkInstallation(buildsDialog.editIndex) bdStatus.ok = problems.length === 0 bdStatus.text = problems.length === 0 - ? qsTr("Всё на месте, сборку можно запускать") - : qsTr("Не хватает файлов (%1): %2").arg(problems.length).arg(problems[0]) + ? Loc.t.build.status.ready + : Loc.t.build.error.missingFiles.arg(problems.length).arg(problems[0]) } function newBuildName() { const taken = buildsDialog.backend.customBuildNames for (var n = 1; ; ++n) { - const candidate = qsTr("Сборка %1").arg(n) + const candidate = Loc.t.build.title.numbered.arg(n) if (taken.indexOf(candidate) === -1) return candidate } @@ -128,7 +128,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: qsTr("Удалить сборку?") + text: Loc.t.build.title.removeConfirm color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -152,7 +152,7 @@ Dialog { wrapMode: Text.Wrap color: "#ffffff" font.pixelSize: 13 - text: qsTr("Сборка «%1» будет удалена без возможности восстановления.") + text: Loc.t.build.hint.removeIrreversible .arg(removeConfirm.buildName) } @@ -163,7 +163,7 @@ Dialog { wrapMode: Text.Wrap color: "#cc6666" font.pixelSize: 12 - text: qsTr("Вместе с ней удалится её архив: моды, конфиги и миры этой сборки восстановить будет нельзя.") + text: Loc.t.build.warning.archiveGoesToo } Text { @@ -173,7 +173,7 @@ Dialog { wrapMode: Text.Wrap color: "#cc6666" font.pixelSize: 12 - text: qsTr("Сборка сейчас активна: содержимое .minecraft принадлежит ей и будет очищено, а на его место развернётся следующая сборка.") + text: Loc.t.build.warning.removingActive } Text { @@ -183,7 +183,7 @@ Dialog { wrapMode: Text.Wrap color: "#888888" font.pixelSize: 12 - text: qsTr("Это последняя сборка. Содержимое .minecraft останется на месте.") + text: Loc.t.build.hint.removingLast } } @@ -200,7 +200,7 @@ Dialog { spacing: 12 Button { - text: qsTr("Отмена") + text: Loc.t.common.button.cancel width: 130; height: 36 contentItem: Text { text: parent.text @@ -216,7 +216,7 @@ Dialog { } Button { - text: qsTr("Удалить") + text: Loc.t.common.button.remove width: 130; height: 36 contentItem: Text { text: parent.text @@ -271,7 +271,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: qsTr("Пользовательские сборки") + text: Loc.t.common.title.customBuilds color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -330,7 +330,7 @@ Dialog { anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter visible: buildRow.index === buildsDialog.backend.activeBuildIndex - text: qsTr("активна") + text: Loc.t.build.label.active color: "#91B315" font.pixelSize: 10 } @@ -380,7 +380,7 @@ Dialog { Text { anchors.centerIn: parent - text: qsTr("+ Новая сборка") + text: Loc.t.build.button.new color: "#91B315" font.pixelSize: 12 } @@ -425,21 +425,21 @@ Dialog { LabelledField { id: bdName width: parent.width - label: qsTr("Название сборки") - placeholder: qsTr("как её видно в списке") + label: Loc.t.build.label.name + placeholder: Loc.t.build.placeholder.name onEditingFinished: buildsDialog.commit({"name": bdName.text.trim()}) } LabelledField { id: bdServer width: parent.width - label: qsTr("Адрес сервера") - placeholder: qsTr("host:port (необязательно)") + label: Loc.t.build.label.serverAddress + placeholder: Loc.t.build.placeholder.serverAddress onEditingFinished: buildsDialog.commit({"serverUrl": bdServer.text.trim()}) } Text { - text: qsTr("Версия Minecraft") + text: Loc.t.common.label.minecraftVersion color: "#aaaaaa" font.pixelSize: 11 } @@ -464,7 +464,7 @@ Dialog { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight color: bdMinecraft.selectedId === "" ? "#666666" : "#ffffff" - text: bdMinecraft.selectedId === "" ? qsTr("Выберите версию") + text: bdMinecraft.selectedId === "" ? Loc.t.build.button.pickVersion : bdMinecraft.selectedId } @@ -472,7 +472,7 @@ Dialog { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: qsTr("Выбрать…") + text: Loc.t.common.button.choose color: "#91B315" font.pixelSize: 12 } @@ -489,7 +489,7 @@ Dialog { } Text { - text: qsTr("Модлоадер") + text: Loc.t.build.label.modLoader color: "#aaaaaa" font.pixelSize: 11 } @@ -541,7 +541,7 @@ Dialog { width: parent.width backend: buildsDialog.backend loaderKey: "forge" - title: qsTr("Minecraft Forge") + title: Loc.t.loader.name.forge gameVersion: bdMinecraft.selectedId onUserChecked: loaderPanel.keepOnly(bdForge) onChanged: loaderPanel.commitSelection() @@ -551,7 +551,7 @@ Dialog { width: parent.width backend: buildsDialog.backend loaderKey: "fabric" - title: qsTr("Fabric Loader") + title: Loc.t.loader.name.fabric gameVersion: bdMinecraft.selectedId onUserChecked: loaderPanel.keepOnly(bdFabric) onChanged: loaderPanel.commitSelection() @@ -561,7 +561,7 @@ Dialog { width: parent.width backend: buildsDialog.backend loaderKey: "neoforge" - title: qsTr("NeoForge") + title: Loc.t.loader.name.neoforge gameVersion: bdMinecraft.selectedId onUserChecked: loaderPanel.keepOnly(bdNeoForge) onChanged: loaderPanel.commitSelection() @@ -571,7 +571,7 @@ Dialog { width: parent.width backend: buildsDialog.backend loaderKey: "quilt" - title: qsTr("Quilt Loader") + title: Loc.t.loader.name.quilt gameVersion: bdMinecraft.selectedId onUserChecked: loaderPanel.keepOnly(bdQuilt) onChanged: loaderPanel.commitSelection() @@ -604,7 +604,7 @@ Dialog { spacing: 12 Button { - text: qsTr("Установить") + text: Loc.t.common.button.install width: 140; height: 36 enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.busy opacity: enabled ? 1.0 : 0.45 @@ -622,7 +622,7 @@ Dialog { } Button { - text: qsTr("Сделать активной") + text: Loc.t.build.button.makeActive width: 170; height: 36 enabled: buildsDialog.editIndex >= 0 && buildsDialog.editIndex !== buildsDialog.backend.activeBuildIndex @@ -642,7 +642,7 @@ Dialog { } Button { - text: qsTr("Закрыть") + text: Loc.t.common.button.close width: 120; height: 36 enabled: !buildsDialog.backend.switching opacity: enabled ? 1.0 : 0.45 diff --git a/CMakeLists.txt b/CMakeLists.txt index e4670ed..46f2e51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,8 @@ qt_add_executable(appMinecraft_launcher qt_add_qml_module(appMinecraft_launcher URI Minecraft_launcher SOURCES + localization.h + localization.cpp launcherbackend.h launcherbackend.cpp launcherpaths.h @@ -90,7 +92,7 @@ qt_add_qml_module(appMinecraft_launcher LoaderRow.qml ProgressPanel.qml ${LAUNCHER_MSA_QML_FILES} - RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg + RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg i18n/translations.json ) # Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1. diff --git a/JavaPickerDialog.qml b/JavaPickerDialog.qml index f930e94..516be97 100644 --- a/JavaPickerDialog.qml +++ b/JavaPickerDialog.qml @@ -38,9 +38,9 @@ Dialog { } readonly property var categories: [ - { key: "java", title: qsTr("Java"), hint: qsTr("сборки Mojang") }, - { key: "jdk", title: qsTr("JDK"), hint: qsTr("Temurin, с инструментами") }, - { key: "jre", title: qsTr("JRE"), hint: qsTr("Temurin, только запуск") } + { key: "java", title: Loc.t.common.java.kind.java, hint: Loc.t.java.filter.mojang }, + { key: "jdk", title: Loc.t.common.java.kind.jdk, hint: Loc.t.java.filter.temurinJdk }, + { key: "jre", title: Loc.t.common.java.kind.jre, hint: Loc.t.java.filter.temurinJre } ] // Каталог приходит из C++ уже отсортированным (новые сверху, скачанные @@ -122,7 +122,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: qsTr("Версия Java") + text: Loc.t.java.label.version color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -203,7 +203,7 @@ Dialog { wrapMode: Text.Wrap color: "#888888" font.pixelSize: 10 - text: qsTr("Выбранной версии игры нужна Java %1 или новее") + text: Loc.t.java.hint.required .arg(picker.requiredMajor) } } @@ -227,7 +227,7 @@ Dialog { anchors.right: parent.right anchors.margins: 8 height: 32 - placeholderText: qsTr("Поиск версии…") + placeholderText: Loc.t.common.placeholder.searchVersion color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -281,7 +281,7 @@ Dialog { } contentItem: Text { leftPadding: installedOnlyBox.indicator.width + 8 - text: qsTr("Только скачанные") + text: Loc.t.java.filter.downloadedOnly color: "#aaaaaa" font.pixelSize: 11 verticalAlignment: Text.AlignVCenter @@ -364,9 +364,9 @@ Dialog { const parts = [runtimeRow.modelData.detail, runtimeRow.modelData.coverage] if (runtimeRow.modelData.sizeMb > 0) - parts.push(qsTr("%1 МБ").arg(runtimeRow.modelData.sizeMb)) + parts.push(Loc.t.common.status.megabytes.arg(runtimeRow.modelData.sizeMb)) if (runtimeRow.tooOld) - parts.push(qsTr("не хватит для выбранной версии игры")) + parts.push(Loc.t.java.warning.tooOldForVersion) return parts.join(" · ") } } @@ -441,10 +441,10 @@ Dialog { font.pixelSize: 12 font.italic: true text: picker.backend.javaCatalogLoading - ? qsTr("Загрузка списка сборок Java…") + ? Loc.t.java.progress.loadingCatalog : picker.backend.javaCatalog.length === 0 - ? qsTr("Список сборок Java недоступен — проверьте соединение") - : qsTr("Ничего не найдено") + ? Loc.t.java.error.catalogUnavailable + : Loc.t.common.status.nothingFound } } } @@ -467,10 +467,10 @@ Dialog { color: picker.selectedEntry === null ? "#666666" : "#91B315" font.pixelSize: 13 text: picker.selectedEntry === null - ? qsTr("Сборка не выбрана") + ? Loc.t.common.status.noBuildSelected : picker.selectedEntry.installed ? picker.selectedEntry.label - : qsTr("%1 — будет скачана").arg(picker.selectedEntry.label) + : Loc.t.java.status.willDownload.arg(picker.selectedEntry.label) } Row { @@ -480,7 +480,7 @@ Dialog { spacing: 12 Button { - text: qsTr("Отмена") + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text @@ -497,7 +497,7 @@ Dialog { Button { text: picker.selectedEntry !== null && !picker.selectedEntry.installed - ? qsTr("Скачать") : qsTr("Выбрать") + ? Loc.t.common.button.download : Loc.t.common.button.select width: 110; height: 36 enabled: picker.selectedEntry !== null opacity: enabled ? 1.0 : 0.45 diff --git a/LoaderRow.qml b/LoaderRow.qml index 1c7374a..92721e5 100644 --- a/LoaderRow.qml +++ b/LoaderRow.qml @@ -169,8 +169,8 @@ Column { color: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion) ? "#888888" : "#cc6666" text: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion) - ? qsTr("Загрузка списка версий %1…").arg(loaderRow.title) - : qsTr("%1 не поддерживает Minecraft %2").arg(loaderRow.title) + ? Loc.t.loader.progress.loadingVersions.arg(loaderRow.title) + : Loc.t.loader.status.unsupported.arg(loaderRow.title) .arg(loaderRow.gameVersion) } } diff --git a/Main.qml b/Main.qml index b278704..d35fc50 100644 --- a/Main.qml +++ b/Main.qml @@ -9,14 +9,14 @@ Window { height: 720 visible: true flags: Qt.Window - title: qsTr("Minecraft Launcher") + title: Loc.t.app.title // ── Backend ──────────────────────────────────────────────────────────── LauncherBackend { id: backend onLaunched: (profileName, buildName, serverUrl) => { - window.showToast(qsTr("Запущено: %1 — %2").arg(profileName).arg(buildName), "#4b7a1f") + window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f") } onLaunchProgress: (message) => window.showToast(message, "#3a5a8c", 0) onLaunchError: (message) => window.showToast(message, "#cc3333", 8000) @@ -27,20 +27,20 @@ Window { } onGameFinished: (exitCode, crashed) => { window.showToast(crashed || exitCode !== 0 - ? qsTr("Игра завершилась с ошибкой (код %1)").arg(exitCode) - : qsTr("Игра закрыта"), + ? Loc.t.game.status.crashed.arg(exitCode) + : Loc.t.game.status.closed, crashed || exitCode !== 0 ? "#cc3333" : "#555555") } onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url) // Выбор в списке здесь не трогаем: новый профиль уже выбран тем, кто // его создал, а повторный вход мог быть и не в последний профиль. onMicrosoftLoginSucceeded: (playerName) => - window.showToast(qsTr("Вход выполнен: %1").arg(playerName), "#4b7a1f") + window.showToast(Loc.t.auth.status.signedInAs.arg(playerName), "#4b7a1f") onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000) onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex) onGameOutput: (line) => console.log(line) onSeasonalInstallFinished: (seasonalId, buildName) => { - window.showToast(qsTr("Сборка «%1» установлена — можно запускать").arg(buildName), + window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName), "#4b7a1f", 8000) } } @@ -69,7 +69,7 @@ Window { const component = Qt.createComponent("MicrosoftLoginDialog.qml") if (component.status !== Component.Ready) { backend.cancelMicrosoftLogin() - window.showToast(qsTr("Окно входа Microsoft недоступно: сборка без Qt WebEngine"), + window.showToast(Loc.t.auth.msa.error.windowUnavailable, "#cc3333", 8000) return } @@ -134,11 +134,11 @@ Window { anchors.leftMargin: 24 anchors.bottomMargin: 24 - title: qsTr("Загрузка %1").arg(backend.downloadVersion) + title: Loc.t.common.progress.downloadingNamed.arg(backend.downloadVersion) fraction: backend.downloadProgress status: backend.downloadStatus detail: backend.downloadBytesTotal > 0 - ? qsTr("%1 — %2 / %3 МБ").arg(backend.downloadStatus) + ? Loc.t.common.progress.bytes.arg(backend.downloadStatus) .arg(window.formatMb(backend.downloadBytesDone)) .arg(window.formatMb(backend.downloadBytesTotal)) : "" @@ -201,8 +201,8 @@ Window { // висела бы поверх собственного меню. ToolTip.visible: profileBox.hovered && !profileBox.down ToolTip.text: backend.profileNames.length === 0 - ? qsTr("Профили игрока — добавьте первый профиль") - : qsTr("Профиль игрока: %1").arg(profileBox.displayText) + ? Loc.t.profile.hint.addFirst + : Loc.t.profile.label.current.arg(profileBox.displayText) contentItem: Text { id: profileText @@ -263,7 +263,7 @@ Window { Text { anchors.centerIn: parent - text: "+ Добавить профиль" + text: Loc.t.profile.button.add color: "#91B315" font.pixelSize: 12 } @@ -394,7 +394,7 @@ Window { contentItem: Text { text: backend.activeBuildName !== "" ? backend.activeBuildName - : qsTr("Сборка не выбрана") + : Loc.t.common.status.noBuildSelected color: "#ffffff" font: buildButton.font verticalAlignment: Text.AlignVCenter @@ -424,7 +424,7 @@ Window { } ToolTip.visible: hovered && !buildsDialog.opened - ToolTip.text: qsTr("Пользовательские сборки") + ToolTip.text: Loc.t.common.title.customBuilds onClicked: buildsDialog.open() } @@ -450,7 +450,7 @@ Window { } ToolTip.visible: hovered - ToolTip.text: qsTr("Открыть папку с модами Minecraft") + ToolTip.text: Loc.t.app.tooltip.openMods onClicked: backend.openMinecraftFolder() } @@ -478,7 +478,7 @@ Window { } ToolTip.visible: hovered - ToolTip.text: qsTr("Настройки запуска: Java, память, папка игры") + ToolTip.text: Loc.t.app.tooltip.settings onClicked: settingsDialog.load() } @@ -497,7 +497,7 @@ Window { hoverEnabled: true contentItem: Text { - text: qsTr("Сезонные сборки") + text: Loc.t.common.title.seasonalBuilds color: "#ffffff" font.pixelSize: 14 elide: Text.ElideRight @@ -513,7 +513,7 @@ Window { } ToolTip.visible: hovered - ToolTip.text: qsTr("Готовые сборки с сервера: моды, конфиги и Java одной кнопкой") + ToolTip.text: Loc.t.app.tooltip.seasonal onClicked: seasonalDialog.openCatalog() } @@ -548,7 +548,7 @@ Window { implicitHeight: 52 // tells Dialog how tall the header is Text { anchors.centerIn: parent - text: "Новый профиль" + text: Loc.t.profile.title.new color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -570,7 +570,7 @@ Window { id: pfName x: 20 width: parent.width - 40 - placeholderText: "Имя профиля" + placeholderText: Loc.t.profile.placeholder.name color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -587,7 +587,7 @@ Window { width: parent.width - 40 // Ник лицензионного профиля приходит из аккаунта: поля здесь нет. visible: pfAuth.currentIndex !== 2 - placeholderText: "Логин" + placeholderText: Loc.t.profile.placeholder.login color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -603,7 +603,7 @@ Window { x: 20 width: parent.width - 40 visible: pfAuth.currentIndex === 1 - placeholderText: "Пароль Ely.by" + placeholderText: Loc.t.profile.placeholder.elyPassword echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" @@ -620,8 +620,8 @@ Window { x: 20 width: parent.width - 40 model: backend.microsoftAvailable - ? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"] - : ["Офлайн (без пароля)", "Ely.by"] + ? Loc.t.profile.authTypes + : Loc.t.profile.authTypes.slice(0, 2) } } @@ -638,7 +638,7 @@ Window { spacing: 12 Button { - text: "Отмена" + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text @@ -654,7 +654,7 @@ Window { } Button { - text: "Добавить" + text: Loc.t.common.button.add width: 110; height: 36 contentItem: Text { text: parent.text @@ -736,7 +736,7 @@ Window { implicitHeight: 52 Text { anchors.centerIn: parent - text: "Редактирование профиля" + text: Loc.t.profile.title.edit color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -758,7 +758,7 @@ Window { id: epName x: 20 width: parent.width - 40 - placeholderText: "Имя профиля" + placeholderText: Loc.t.profile.placeholder.name color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -775,7 +775,7 @@ Window { width: parent.width - 40 // Для лицензии логин не вводят: ниже показан ник из аккаунта. visible: epAuth.currentIndex !== 2 - placeholderText: "Логин" + placeholderText: Loc.t.profile.placeholder.login color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -810,7 +810,7 @@ Window { x: 20 width: parent.width - 40 visible: epAuth.currentIndex === 1 - placeholderText: "Пароль Ely.by" + placeholderText: Loc.t.profile.placeholder.elyPassword echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" @@ -829,8 +829,8 @@ Window { // Тип уже сохранённого профиля показываем всегда: иначе в // сборке без WebEngine он молча стал бы офлайновым. model: (backend.microsoftAvailable || editProfileDialog.msProfile) - ? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"] - : ["Офлайн (без пароля)", "Ely.by"] + ? Loc.t.profile.authTypes + : Loc.t.profile.authTypes.slice(0, 2) } // Состояние аккаунта Microsoft и кнопка повторного входа: токен @@ -842,8 +842,8 @@ Window { visible: epAuth.currentIndex === 2 Text { - text: editProfileDialog.msLinked ? qsTr("Вход выполнен") - : qsTr("Вход не выполнен") + text: editProfileDialog.msLinked ? Loc.t.auth.status.signedIn + : Loc.t.auth.status.signedOut color: editProfileDialog.msLinked ? "#91B315" : "#aaaaaa" font.pixelSize: 13 } @@ -888,7 +888,7 @@ Window { spacing: 12 Button { - text: "Отмена" + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text @@ -904,7 +904,7 @@ Window { } Button { - text: "ОК" + text: Loc.t.common.button.ok width: 110; height: 36 contentItem: Text { text: parent.text @@ -972,7 +972,7 @@ Window { implicitHeight: 52 Text { anchors.centerIn: parent - text: "Двухфакторная аутентификация" + text: Loc.t.auth.ely.title.twoFactor color: "#ffffff" font.pixelSize: 15 font.bold: true @@ -993,7 +993,7 @@ Window { Text { x: 20 width: parent.width - 40 - text: qsTr("Введите код из приложения-аутентификатора для «%1»") + text: Loc.t.auth.ely.hint.enterCode .arg(twoFactorDialog.profileName) color: "#aaaaaa" font.pixelSize: 12 @@ -1031,7 +1031,7 @@ Window { spacing: 12 Button { - text: "Отмена" + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text @@ -1047,7 +1047,7 @@ Window { } Button { - text: "Войти" + text: Loc.t.common.button.signIn width: 110; height: 36 contentItem: Text { text: parent.text @@ -1080,6 +1080,9 @@ Window { // Выбранная сборка Java живёт в свойстве, а не в поле ввода: её выбирают // в отдельном окне, а записывается она только по «Сохранить». property string javaRuntimeId: "" + // Раскрытый путь к папке игры: хранится свойством, а не присваивается + // тексту напрямую, — иначе подпись не пережила бы смену языка. + property string resolvedGameDir: "" // Не привязка: javaRuntimeInfo() — обычный вызов, и сам он не // пересчитается, когда сборка докачается. Обновляем по событиям. property var javaRuntimeInfo: null @@ -1107,8 +1110,8 @@ Window { stWidth.text = String(s.windowWidth) stHeight.text = String(s.windowHeight) stFullscreen.checked = s.fullscreen === true - stResolved.text = qsTr("Папка игры: %1").arg(s.resolvedGameDir) - stJavaList.text = backend.detectedJava().join("\n") || qsTr("Java не найдена") + stLanguage.currentIndex = Math.max(0, stLanguage.codes.indexOf(s.language || "system")) + settingsDialog.resolvedGameDir = s.resolvedGameDir open() } @@ -1123,7 +1126,7 @@ Window { implicitHeight: 52 Text { anchors.centerIn: parent - text: "Настройки запуска" + text: Loc.t.settings.title color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -1141,19 +1144,43 @@ Window { topPadding: 18 bottomPadding: 18 + // Язык — настройка уровня приложения, поэтому стоит над параметрами + // запуска. Подписи в модели переводятся, коды рядом — нет. + Column { + x: 20 + width: parent.width - 40 + spacing: 3 + + Text { + text: Loc.t.settings.language.label + color: "#aaaaaa" + font.pixelSize: 11 + } + + DarkCombo { + id: stLanguage + width: parent.width + height: 32 + readonly property var codes: ["system", "ru", "en"] + model: [Loc.t.settings.language.system, + Loc.t.settings.language.ru, + Loc.t.settings.language.en] + } + } + LabelledField { id: stGameDir x: 20 width: parent.width - 40 - label: "Папка .minecraft" - placeholder: "по умолчанию" + label: Loc.t.settings.label.gameDir + placeholder: Loc.t.settings.placeholder.gameDir } LabelledField { id: stJavaPath x: 20 width: parent.width - 40 - label: "Путь к Java" - placeholder: "автоопределение" + label: Loc.t.settings.label.javaPath + placeholder: Loc.t.settings.placeholder.javaPath } // Сборка Java из папки лаунчера. Выбрана — запуск идёт ей, а не тем, @@ -1164,7 +1191,7 @@ Window { spacing: 3 Text { - text: "Версия Java из папки лаунчера" + text: Loc.t.settings.label.javaRuntime color: "#aaaaaa" font.pixelSize: 11 } @@ -1186,12 +1213,12 @@ Window { color: settingsDialog.javaRuntimeId === "" ? "#666666" : "#ffffff" text: { if (settingsDialog.javaRuntimeId === "") - return qsTr("не выбрана — искать в системе") + return Loc.t.settings.status.javaRuntimeNone const info = settingsDialog.javaRuntimeInfo if (!info) return settingsDialog.javaRuntimeId return info.installed ? info.label - : qsTr("%1 — скачивается").arg(info.label) + : Loc.t.settings.status.javaRuntimeDownloading.arg(info.label) } } @@ -1199,7 +1226,7 @@ Window { anchors.right: clearJava.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: qsTr("Выбрать…") + text: Loc.t.common.button.choose color: "#91B315" font.pixelSize: 12 } @@ -1240,25 +1267,25 @@ Window { LabelledField { id: stMinMemory width: 100 - label: "-Xms, МБ" + label: Loc.t.settings.label.minMemory validator: IntValidator { bottom: 0; top: 1048576 } } LabelledField { id: stMaxMemory width: 100 - label: "-Xmx, МБ" + label: Loc.t.settings.label.maxMemory validator: IntValidator { bottom: 0; top: 1048576 } } LabelledField { id: stWidth width: 100 - label: "Ширина окна" + label: Loc.t.settings.label.windowWidth validator: IntValidator { bottom: 0; top: 16384 } } LabelledField { id: stHeight width: 100 - label: "Высота окна" + label: Loc.t.settings.label.windowHeight validator: IntValidator { bottom: 0; top: 16384 } } } @@ -1267,14 +1294,14 @@ Window { id: stJvmArgs x: 20 width: parent.width - 40 - label: "Дополнительные аргументы JVM" + label: Loc.t.settings.label.jvmArgs placeholder: "-XX:+UseG1GC …" } CheckBox { id: stFullscreen x: 20 - text: "Запускать в полноэкранном режиме" + text: Loc.t.settings.label.fullscreen // Индикатор Basic-стиля — крупный светлый квадрат, на тёмном // фоне диалога он не читается. Рисуем свой в палитре окна. @@ -1307,6 +1334,7 @@ Window { Text { id: stResolved + text: Loc.t.settings.status.resolvedGameDir.arg(settingsDialog.resolvedGameDir) x: 20 width: parent.width - 40 color: "#888888" @@ -1315,6 +1343,7 @@ Window { } Text { id: stJavaList + text: backend.detectedJava().join("\n") || Loc.t.settings.status.javaNotFound x: 20 width: parent.width - 40 color: "#888888" @@ -1336,7 +1365,7 @@ Window { spacing: 12 Button { - text: "Отмена" + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text @@ -1352,7 +1381,7 @@ Window { } Button { - text: "Сохранить" + text: Loc.t.common.button.save width: 110; height: 36 contentItem: Text { text: parent.text @@ -1378,7 +1407,8 @@ Window { "jvmArgs": stJvmArgs.text.trim(), "windowWidth": parseInt(stWidth.text) || 0, "windowHeight": parseInt(stHeight.text) || 0, - "fullscreen": stFullscreen.checked + "fullscreen": stFullscreen.checked, + "language": stLanguage.codes[stLanguage.currentIndex] }) } diff --git a/MicrosoftLoginDialog.qml b/MicrosoftLoginDialog.qml index f5163bd..667f47d 100644 --- a/MicrosoftLoginDialog.qml +++ b/MicrosoftLoginDialog.qml @@ -47,7 +47,7 @@ Dialog { backend.finishMicrosoftLogin(info.code) } else { backend.cancelMicrosoftLogin() - msLogin.failed(info.error !== "" ? info.error : "Вход в Microsoft не завершён") + msLogin.failed(info.error !== "" ? info.error : Loc.t.auth.msa.error.incomplete) } } @@ -62,7 +62,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: "Вход в аккаунт Microsoft" + text: Loc.t.auth.msa.title.signIn color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -110,7 +110,7 @@ Dialog { } Button { anchors.centerIn: parent - text: "Отмена" + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text diff --git a/SeasonalBuildsDialog.qml b/SeasonalBuildsDialog.qml index 46e7016..a22346b 100644 --- a/SeasonalBuildsDialog.qml +++ b/SeasonalBuildsDialog.qml @@ -21,13 +21,13 @@ Dialog { // Ширины колонок в одном месте: их повторяют и шапка, и делегат. readonly property var columns: [ - { key: "name", title: qsTr("Название"), width: 250, align: Text.AlignLeft }, - { key: "minecraftVersion", title: qsTr("Версия"), width: 90, align: Text.AlignLeft }, - { key: "loaderLabel", title: qsTr("Загрузчик"), width: 150, align: Text.AlignLeft }, - { key: "modCount", title: qsTr("Модов"), width: 70, align: Text.AlignRight }, - { key: "seasonStart", title: qsTr("Начало"), width: 110, align: Text.AlignLeft }, - { key: "seasonEnd", title: qsTr("Конец"), width: 110, align: Text.AlignLeft }, - { key: "status", title: qsTr("Статус"), width: 140, align: Text.AlignLeft } + { key: "name", title: Loc.t.common.header.name, width: 250, align: Text.AlignLeft }, + { key: "minecraftVersion", title: Loc.t.common.header.version, width: 90, align: Text.AlignLeft }, + { key: "loaderLabel", title: Loc.t.common.header.loader, width: 150, align: Text.AlignLeft }, + { key: "modCount", title: Loc.t.seasonal.header.mods, width: 70, align: Text.AlignRight }, + { key: "seasonStart", title: Loc.t.seasonal.header.start, width: 110, align: Text.AlignLeft }, + { key: "seasonEnd", title: Loc.t.seasonal.header.end, width: 110, align: Text.AlignLeft }, + { key: "status", title: Loc.t.common.header.status, width: 140, align: Text.AlignLeft } ] modal: true @@ -86,7 +86,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: qsTr("Сезонные сборки") + text: Loc.t.common.title.seasonalBuilds color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -151,9 +151,9 @@ Dialog { color: seasons.backend.seasonalCatalogError === "" ? "#888888" : "#cc6666" font.pixelSize: 13 text: seasons.backend.seasonalCatalogLoading - ? qsTr("Загрузка списка сборок…") + ? Loc.t.seasonal.progress.loadingCatalog : (seasons.backend.seasonalCatalogError === "" - ? qsTr("Сборок пока нет") + ? Loc.t.seasonal.status.catalogEmptyYet : seasons.backend.seasonalCatalogError) } @@ -268,16 +268,16 @@ Dialog { font.pixelSize: 11 text: { if (!seasons.selectedEntry) - return qsTr("Выберите сборку в списке") + return Loc.t.seasonal.hint.pickBuild const entry = seasons.selectedEntry - var line = qsTr("Ревизия %1").arg(entry.revision) + var line = Loc.t.seasonal.status.revision.arg(entry.revision) const size = seasons.formatMb(entry.sizeBytes) if (size !== "") - line += qsTr(" · %1 МБ").arg(size) + line += Loc.t.seasonal.status.sizeSuffix.arg(size) if (entry.serverUrl !== "") line += " · " + entry.serverUrl if (entry.updateAvailable) - line += qsTr(" · установлена ревизия %1").arg(entry.installedRevision) + line += Loc.t.seasonal.status.installedRevisionSuffix.arg(entry.installedRevision) return line } } @@ -296,8 +296,8 @@ Dialog { height: 36 enabled: !seasons.backend.seasonalCatalogLoading contentItem: Text { - text: seasons.backend.seasonalCatalogLoading ? qsTr("Обновление…") - : qsTr("Обновить список") + text: seasons.backend.seasonalCatalogLoading ? Loc.t.common.status.updating + : Loc.t.common.button.refreshList color: refreshButton.enabled ? "#ffffff" : "#888888" font.pixelSize: 13 horizontalAlignment: Text.AlignHCenter @@ -319,8 +319,8 @@ Dialog { enabled: seasons.selectedEntry !== null && !seasons.backend.busy contentItem: Text { text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable - ? qsTr("Обновить") - : qsTr("Установить") + ? Loc.t.common.button.update + : Loc.t.common.button.install color: installButton.enabled ? "#ffffff" : "#888888" font.pixelSize: 13 font.bold: true diff --git a/VersionPickerDialog.qml b/VersionPickerDialog.qml index 4fbd825..7834ac8 100644 --- a/VersionPickerDialog.qml +++ b/VersionPickerDialog.qml @@ -39,11 +39,11 @@ Dialog { } readonly property var categories: [ - { key: "release", title: qsTr("Релизы") }, - { key: "snapshot", title: qsTr("Снапшоты") }, - { key: "old_beta", title: qsTr("Беты") }, - { key: "old_alpha", title: qsTr("Альфы") }, - { key: "other", title: qsTr("Прочие") } + { key: "release", title: Loc.t.version.filter.releases }, + { key: "snapshot", title: Loc.t.version.filter.snapshots }, + { key: "old_beta", title: Loc.t.version.filter.betas }, + { key: "old_alpha", title: Loc.t.version.filter.alphas }, + { key: "other", title: Loc.t.version.filter.other } ] // Каталог приходит из C++ уже отсортированным (новые сверху), поэтому здесь @@ -167,7 +167,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: qsTr("Удалить версию?") + text: Loc.t.version.title.removeConfirm color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -192,9 +192,9 @@ Dialog { color: "#ffffff" font.pixelSize: 13 text: removeConfirm.sizeMb > 0 - ? qsTr("Файлы версии «%1» будут удалены из versions/ — освободится около %2 МБ. Скачать её заново можно в любой момент.") + ? Loc.t.version.hint.removeWithSize .arg(removeConfirm.versionId).arg(removeConfirm.sizeMb) - : qsTr("Файлы версии «%1» будут удалены из versions/. Скачать её заново можно в любой момент.") + : Loc.t.version.hint.remove .arg(removeConfirm.versionId) } @@ -205,7 +205,7 @@ Dialog { wrapMode: Text.Wrap color: "#cc6666" font.pixelSize: 12 - text: qsTr("На эту версию опираются установленные профили модлоадеров: %1. Без неё они не запустятся — их придётся переустановить.") + text: Loc.t.version.warning.loaderProfilesDepend .arg(removeConfirm.dependents.join(", ")) } @@ -216,7 +216,7 @@ Dialog { wrapMode: Text.Wrap color: "#cc6666" font.pixelSize: 12 - text: qsTr("Версию используют сборки: %1. Файлы понадобится скачать заново кнопкой «Установить».") + text: Loc.t.version.warning.buildsDepend .arg(removeConfirm.builds.join(", ")) } @@ -226,7 +226,7 @@ Dialog { wrapMode: Text.Wrap color: "#888888" font.pixelSize: 12 - text: qsTr("Библиотеки и ресурсы в libraries/ и assets/ общие для всех версий и остаются на месте.") + text: Loc.t.version.hint.sharedFilesStay } } @@ -243,7 +243,7 @@ Dialog { spacing: 12 Button { - text: qsTr("Отмена") + text: Loc.t.common.button.cancel width: 130; height: 36 contentItem: Text { text: parent.text @@ -259,7 +259,7 @@ Dialog { } Button { - text: qsTr("Удалить") + text: Loc.t.common.button.remove width: 130; height: 36 contentItem: Text { text: parent.text @@ -289,7 +289,7 @@ Dialog { implicitHeight: 52 Text { anchors.centerIn: parent - text: qsTr("Версия Minecraft") + text: Loc.t.common.label.minecraftVersion color: "#ffffff" font.pixelSize: 17 font.bold: true @@ -370,7 +370,7 @@ Dialog { anchors.right: parent.right anchors.margins: 8 height: 32 - placeholderText: qsTr("Поиск версии…") + placeholderText: Loc.t.common.placeholder.searchVersion color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -424,7 +424,7 @@ Dialog { } contentItem: Text { leftPadding: installedOnlyBox.indicator.width + 8 - text: qsTr("Только установленные") + text: Loc.t.version.filter.installedOnly color: "#aaaaaa" font.pixelSize: 11 verticalAlignment: Text.AlignVCenter @@ -542,10 +542,10 @@ Dialog { font.pixelSize: 12 font.italic: true text: picker.backend.catalogLoading - ? qsTr("Загрузка списка версий…") + ? Loc.t.version.progress.loadingList : picker.backend.versionCatalog.length === 0 - ? qsTr("Список версий недоступен — проверьте соединение") - : qsTr("Ничего не найдено") + ? Loc.t.version.error.listUnreachable + : Loc.t.common.status.nothingFound } } } @@ -567,7 +567,7 @@ Dialog { elide: Text.ElideRight color: picker.selectedId === "" ? "#666666" : "#91B315" font.pixelSize: 13 - text: picker.selectedId === "" ? qsTr("Версия не выбрана") : picker.selectedId + text: picker.selectedId === "" ? Loc.t.version.status.notSelected : picker.selectedId } Row { @@ -577,7 +577,7 @@ Dialog { spacing: 12 Button { - text: qsTr("Отмена") + text: Loc.t.common.button.cancel width: 110; height: 36 contentItem: Text { text: parent.text @@ -593,7 +593,7 @@ Dialog { } Button { - text: qsTr("Выбрать") + text: Loc.t.common.button.select width: 110; height: 36 enabled: picker.selectedId !== "" opacity: enabled ? 1.0 : 0.45 diff --git a/authservice.cpp b/authservice.cpp index 4d353fd..34eb082 100644 --- a/authservice.cpp +++ b/authservice.cpp @@ -1,4 +1,5 @@ #include "authservice.h" +#include "localization.h" #include #include @@ -30,7 +31,7 @@ QString describeElyError(const QJsonObject &response, int status) const QString error = response.value(QStringLiteral("error")).toString(); if (!error.isEmpty()) return error; - return QObject::tr("Сервер авторизации вернул код %1").arg(status); + return Loc::text("auth.error.serverCode").arg(status); } } // namespace @@ -45,7 +46,7 @@ AuthResult AuthService::offline(const QString &nickname) { AuthResult result; if (nickname.isEmpty()) { - result.error = tr("Для офлайн-запуска нужен никнейм"); + result.error = Loc::text("auth.offline.error.noNickname"); return result; } @@ -102,7 +103,7 @@ void AuthService::loginElyBy(const QString &login, { if (login.isEmpty()) { AuthResult result; - result.error = tr("Укажите логин Ely.by в настройках профиля"); + result.error = Loc::text("auth.ely.error.noLogin"); callback(result); return; } @@ -115,14 +116,14 @@ void AuthService::loginElyBy(const QString &login, return; } - emit progress(tr("Проверка сохранённого токена Ely.by…")); + emit progress(Loc::text("auth.ely.progress.checkingToken")); postJson(QStringLiteral("/auth/validate"), {{QStringLiteral("accessToken"), accessToken}}, [this, login, password, token, accessToken, callback]( int status, const QJsonObject &, const QString &transportError) { if (!transportError.isEmpty()) { AuthResult result; - result.error = tr("Нет связи с authserver.ely.by: %1").arg(transportError); + result.error = Loc::text("auth.ely.error.unreachable").arg(transportError); callback(result); return; } @@ -138,7 +139,7 @@ void AuthService::loginElyBy(const QString &login, } // Токен протух — пробуем продлить его, не спрашивая пароль. - emit progress(tr("Обновление токена Ely.by…")); + emit progress(Loc::text("auth.ely.progress.refreshingToken")); postJson(QStringLiteral("/auth/refresh"), {{QStringLiteral("accessToken"), accessToken}, {QStringLiteral("clientToken"), token}, @@ -183,12 +184,12 @@ void AuthService::authenticate(const QString &login, { if (password.isEmpty()) { AuthResult result; - result.error = tr("Укажите пароль Ely.by в настройках профиля"); + result.error = Loc::text("auth.ely.error.noPassword"); callback(result); return; } - emit progress(tr("Авторизация на Ely.by…")); + emit progress(Loc::text("auth.ely.progress.signingIn")); postJson(QStringLiteral("/auth/authenticate"), {{QStringLiteral("username"), login}, {QStringLiteral("password"), password}, @@ -199,7 +200,7 @@ void AuthService::authenticate(const QString &login, result.clientToken = clientToken; if (!transportError.isEmpty()) { - result.error = AuthService::tr("Нет связи с authserver.ely.by: %1").arg(transportError); + result.error = Loc::text("auth.ely.error.unreachable").arg(transportError); callback(result); return; } @@ -210,7 +211,7 @@ void AuthService::authenticate(const QString &login, result.twoFactorRequired = message.contains(QStringLiteral("two factor"), Qt::CaseInsensitive); result.error = result.twoFactorRequired - ? AuthService::tr("Аккаунт защищён двухфакторной аутентификацией") + ? Loc::text("auth.ely.error.twoFactor") : message; callback(result); return; @@ -223,7 +224,7 @@ void AuthService::authenticate(const QString &login, result.userType = QStringLiteral("ELYBY"); result.ok = !result.accessToken.isEmpty() && !result.playerName.isEmpty(); if (!result.ok) - result.error = AuthService::tr("Ely.by не вернул игровой профиль для этого аккаунта"); + result.error = Loc::text("auth.ely.error.noGameProfile"); callback(result); }); } @@ -239,7 +240,7 @@ void AuthService::ensureAuthlibInjector( return; } - emit progress(tr("Загрузка authlib-injector…")); + emit progress(Loc::text("auth.ely.progress.downloadingInjector")); QNetworkRequest manifestRequest{QUrl(kInjectorManifest)}; manifestRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); @@ -248,7 +249,7 @@ void AuthService::ensureAuthlibInjector( connect(manifestReply, &QNetworkReply::finished, this, [this, manifestReply, jarPath, callback]() { manifestReply->deleteLater(); if (manifestReply->error() != QNetworkReply::NoError) { - callback({}, tr("Не удалось получить сведения об authlib-injector: %1") + callback({}, Loc::text("auth.ely.error.injectorInfoFailed") .arg(manifestReply->errorString())); return; } @@ -260,7 +261,7 @@ void AuthService::ensureAuthlibInjector( .value(QStringLiteral("sha256")) .toString(); if (url.isEmpty()) { - callback({}, tr("В манифесте authlib-injector нет ссылки на файл")); + callback({}, Loc::text("auth.ely.error.injectorNoUrl")); return; } @@ -271,7 +272,7 @@ void AuthService::ensureAuthlibInjector( connect(jarReply, &QNetworkReply::finished, this, [jarReply, jarPath, expectedSha256, callback]() { jarReply->deleteLater(); if (jarReply->error() != QNetworkReply::NoError) { - callback({}, AuthService::tr("Не удалось скачать authlib-injector: %1").arg(jarReply->errorString())); + callback({}, Loc::text("auth.ely.error.injectorDownloadFailed").arg(jarReply->errorString())); return; } @@ -280,14 +281,14 @@ void AuthService::ensureAuthlibInjector( const QString actual = QString::fromLatin1( QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex()); if (actual != expectedSha256) { - callback({}, AuthService::tr("Контрольная сумма authlib-injector не совпала")); + callback({}, Loc::text("auth.ely.error.injectorChecksum")); return; } } QFile file(jarPath); if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) { - callback({}, AuthService::tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(jarPath))); + callback({}, Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(jarPath))); return; } file.close(); diff --git a/buildarchiveworker.cpp b/buildarchiveworker.cpp index d7e1c93..e59b4ac 100644 --- a/buildarchiveworker.cpp +++ b/buildarchiveworker.cpp @@ -1,4 +1,5 @@ #include "buildarchiveworker.h" +#include "localization.h" #include #include @@ -102,7 +103,7 @@ void BuildArchiveWorker::archive(const QString &gameDir, { const QDir root(gameDir); if (!root.exists()) { - emit finished(false, tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(gameDir))); + emit finished(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(gameDir))); return; } @@ -113,7 +114,7 @@ void BuildArchiveWorker::archive(const QString &gameDir, QZipWriter writer(tempZipPath); if (writer.status() != QZipWriter::NoError) { emit finished(false, - tr("Не удалось создать архив %1") + Loc::text("switch.error.archiveCreateFailed") .arg(QDir::toNativeSeparators(tempZipPath))); return; } @@ -126,7 +127,7 @@ void BuildArchiveWorker::archive(const QString &gameDir, if (canceled()) { writer.close(); QFile::remove(tempZipPath); - emit finished(false, tr("Архивация отменена")); + emit finished(false, Loc::text("switch.status.archivingCancelled")); return; } @@ -138,7 +139,7 @@ void BuildArchiveWorker::archive(const QString &gameDir, writer.close(); QFile::remove(tempZipPath); emit finished(false, - tr("Не удалось прочитать %1: %2") + Loc::text("common.error.readFailed") .arg(QDir::toNativeSeparators(relative), file.errorString())); return; } @@ -149,7 +150,7 @@ void BuildArchiveWorker::archive(const QString &gameDir, writer.close(); QFile::remove(tempZipPath); emit finished(false, - tr("Ошибка записи архива на файле %1") + Loc::text("switch.error.archiveWriteFailed") .arg(QDir::toNativeSeparators(relative))); return; } @@ -187,7 +188,7 @@ void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTo : QFile::remove(entry.absoluteFilePath()); if (!ok) { emit finished(false, - tr("Не удалось удалить %1") + Loc::text("switch.error.removeFailed") .arg(QDir::toNativeSeparators(entry.absoluteFilePath()))); return; } @@ -202,7 +203,7 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir) QZipReader reader(zipPath); if (!reader.isReadable()) { emit finished(false, - tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath))); + Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath))); return; } @@ -212,14 +213,14 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir) const QDir root(gameDir); if (!QDir().mkpath(gameDir)) { emit finished(false, - tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir))); + Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir))); return; } int done = 0; for (const QZipReader::FileInfo &entry : entries) { if (canceled()) { - emit finished(false, tr("Распаковка отменена")); + emit finished(false, Loc::text("switch.status.extractCancelled")); return; } if (!entry.isFile) { @@ -228,14 +229,14 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir) } if (!isSafeRelativePath(entry.filePath)) { emit finished(false, - tr("Архив повреждён: недопустимый путь «%1»").arg(entry.filePath)); + Loc::text("switch.error.archiveBadPath").arg(entry.filePath)); return; } const QString target = root.absoluteFilePath(entry.filePath); if (!QDir().mkpath(QFileInfo(target).absolutePath())) { emit finished(false, - tr("Не удалось создать %1") + Loc::text("common.error.createFailed") .arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath()))); return; } @@ -244,7 +245,7 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir) const QByteArray data = reader.fileData(entry.filePath); if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) { emit finished(false, - tr("Не удалось записать %1: %2") + Loc::text("switch.error.writeFailed") .arg(QDir::toNativeSeparators(entry.filePath), file.errorString())); return; } @@ -263,7 +264,7 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, QZipReader reader(zipPath); if (!reader.isReadable()) { emit finished(false, - tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath))); + Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath))); return; } @@ -277,12 +278,12 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, continue; if (!isSafeRelativePath(entry.filePath)) { emit finished(false, - tr("Архив сборки повреждён: недопустимый путь «%1»").arg(entry.filePath)); + Loc::text("switch.error.packBadPath").arg(entry.filePath)); return; } if (forbiddenTop.contains(topSegment(entry.filePath))) { emit finished(false, - tr("Архив сборки пытается изменить общие файлы лаунчера: «%1»") + Loc::text("switch.error.packTouchesLauncher") .arg(entry.filePath)); return; } @@ -291,7 +292,7 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, const QDir root(gameDir); if (!QDir().mkpath(gameDir)) { - emit finished(false, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir))); + emit finished(false, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir))); return; } @@ -303,7 +304,7 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, QStringList touchedDirs; for (const QString &relative : removeRelative) { if (canceled()) { - emit finished(false, tr("Установка сборки отменена")); + emit finished(false, Loc::text("switch.status.installCancelled")); return; } // Список пришёл из нашего же файла состояния, но он лежит на диске @@ -321,7 +322,7 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, } if (QFileInfo::exists(target) && !QFile::remove(target)) { emit finished(false, - tr("Не удалось удалить %1").arg(QDir::toNativeSeparators(target))); + Loc::text("switch.error.removeFailed").arg(QDir::toNativeSeparators(target))); return; } const QString parent = QFileInfo(relative).path(); @@ -334,20 +335,20 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, // ── Раскладываем новую ревизию поверх ──────────────────────────────── for (const QString &relative : std::as_const(applied)) { if (canceled()) { - emit finished(false, tr("Установка сборки отменена")); + emit finished(false, Loc::text("switch.status.installCancelled")); return; } const QString target = root.absoluteFilePath(relative); if (!isInside(root, target)) { emit finished(false, - tr("Архив сборки повреждён: путь «%1» ведёт за пределы папки игры") + Loc::text("switch.error.packEscapesGameDir") .arg(relative)); return; } if (!QDir().mkpath(QFileInfo(target).absolutePath())) { emit finished(false, - tr("Не удалось создать %1") + Loc::text("common.error.createFailed") .arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath()))); return; } @@ -359,7 +360,7 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(data) != data.size()) { emit finished(false, - tr("Не удалось записать %1: %2") + Loc::text("switch.error.writeFailed") .arg(QDir::toNativeSeparators(relative), file.errorString())); return; } diff --git a/buildswitcher.cpp b/buildswitcher.cpp index a05d019..57570ad 100644 --- a/buildswitcher.cpp +++ b/buildswitcher.cpp @@ -1,4 +1,5 @@ #include "buildswitcher.h" +#include "localization.h" #include "buildarchiveworker.h" #include "launcherpaths.h" @@ -145,7 +146,7 @@ void BuildSwitcher::switchTo(int fromBuildId, const QString &gameDir) { if (m_state != Idle) { - emit failed(toBuildId, tr("Смена сборки уже выполняется"), true); + emit failed(toBuildId, Loc::text("common.error.switchInProgress"), true); return; } @@ -173,7 +174,7 @@ void BuildSwitcher::switchTo(int fromBuildId, if (!QDir().mkpath(buildDir(fromBuildId))) { emit failed(toBuildId, - tr("Не удалось создать %1") + Loc::text("common.error.createFailed") .arg(QDir::toNativeSeparators(buildDir(fromBuildId))), true); return; @@ -183,7 +184,7 @@ void BuildSwitcher::switchTo(int fromBuildId, const QStorageInfo storage(LauncherPaths::buildStorageDir()); if (storage.isValid() && storage.bytesAvailable() < needed) { emit failed(toBuildId, - tr("Недостаточно места для архива сборки «%1»: нужно около %2 МБ") + Loc::text("switch.error.notEnoughSpace") .arg(fromName) .arg(needed / (1024 * 1024)), true); @@ -194,7 +195,7 @@ void BuildSwitcher::switchTo(int fromBuildId, + QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip"); m_state = Archiving; - setStage(tr("Сохранение сборки «%1»").arg(fromName)); + setStage(Loc::text("switch.progress.saving").arg(fromName)); requestArchive(gameDir, m_tempZip, sharedTopLevel()); } @@ -296,12 +297,12 @@ void BuildSwitcher::commitArchive() // На сборку — один архив: новый встаёт на место, все прежние уходят. if (QFile::exists(target) && !QFile::remove(target)) { QFile::remove(m_tempZip); - fail(tr("Не удалось заменить архив %1").arg(QDir::toNativeSeparators(target)), true); + fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true); return; } if (!QFile::rename(m_tempZip, target)) { QFile::remove(m_tempZip); - fail(tr("Не удалось сохранить архив %1").arg(QDir::toNativeSeparators(target)), true); + fail(Loc::text("switch.error.archiveSaveFailed").arg(QDir::toNativeSeparators(target)), true); return; } @@ -335,7 +336,7 @@ void BuildSwitcher::commitArchive() void BuildSwitcher::beginClearing() { - setStage(tr("Подготовка сборки «%1»").arg(m_toName)); + setStage(Loc::text("switch.progress.preparing").arg(m_toName)); requestClear(m_gameDir, sharedTopLevel()); } @@ -351,7 +352,7 @@ void BuildSwitcher::beginRestoring() } m_state = Restoring; - setStage(tr("Восстановление сборки «%1»").arg(m_toName)); + setStage(Loc::text("switch.progress.restoring").arg(m_toName)); requestRestore(archive, m_gameDir); } @@ -365,7 +366,7 @@ void BuildSwitcher::applyPack(int buildId, const QString &gameDir) { if (m_state != Idle) { - emit failed(buildId, tr("Операция со сборками уже выполняется"), true); + emit failed(buildId, Loc::text("switch.error.busy"), true); return; } @@ -376,7 +377,7 @@ void BuildSwitcher::applyPack(int buildId, } if (!QFile::exists(packZipPath)) { emit failed(buildId, - tr("Архив сборки не найден: %1") + Loc::text("switch.error.archiveMissing") .arg(QDir::toNativeSeparators(packZipPath)), true); return; @@ -412,7 +413,7 @@ void BuildSwitcher::applyPack(int buildId, void BuildSwitcher::beginApplying() { - setStage(tr("Установка сборки «%1»").arg(m_toName)); + setStage(Loc::text("switch.progress.installing").arg(m_toName)); requestApplyPack(m_packPath, m_gameDir, m_packRemove); } @@ -507,8 +508,8 @@ QString BuildSwitcher::interruptedSwitchWarning() const if (state == kStateApplying) { const QString name = index.value(QStringLiteral("packName")).toString(); - return tr("Установка сборки%1 не завершилась — докатываем её файлы.") - .arg(name.isEmpty() ? QString() : tr(" «%1»").arg(name)); + return Loc::text("switch.status.applyInterrupted") + .arg(name.isEmpty() ? QString() : Loc::text("switch.status.nameSuffix").arg(name)); } if (state != kStateSwitching) return {}; @@ -519,8 +520,8 @@ QString BuildSwitcher::interruptedSwitchWarning() const .toObject() .value(QStringLiteral("name")) .toString(); - return tr("Прошлая смена сборки%1 не завершилась — восстанавливаем содержимое .minecraft.") - .arg(name.isEmpty() ? QString() : tr(" на «%1»").arg(name)); + return Loc::text("switch.status.switchInterrupted") + .arg(name.isEmpty() ? QString() : Loc::text("switch.status.targetSuffix").arg(name)); } void BuildSwitcher::resumeInterrupted(const QString &gameDir) diff --git a/gamelauncher.cpp b/gamelauncher.cpp index 649e4a9..a2439fc 100644 --- a/gamelauncher.cpp +++ b/gamelauncher.cpp @@ -1,4 +1,5 @@ #include "gamelauncher.h" +#include "localization.h" #include "javalocator.h" @@ -234,7 +235,7 @@ bool GameLauncher::extractNatives(const LaunchOptions &options, { if (!QDir().mkpath(nativesDir)) { if (error) - *error = tr("Не удалось создать папку %1").arg(QDir::toNativeSeparators(nativesDir)); + *error = Loc::text("launch.error.createFolderFailed").arg(QDir::toNativeSeparators(nativesDir)); return false; } @@ -246,7 +247,7 @@ bool GameLauncher::extractNatives(const LaunchOptions &options, QZipReader reader(jar); if (!reader.isReadable()) { if (error) - *error = tr("Не удалось прочитать натив-библиотеку %1") + *error = Loc::text("launch.error.nativeReadFailed") .arg(QDir::toNativeSeparators(jar)); return false; } @@ -282,7 +283,7 @@ bool GameLauncher::extractNatives(const LaunchOptions &options, QFile file(target); if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) { if (error) - *error = tr("Не удалось распаковать %1 в %2") + *error = Loc::text("launch.error.extractFailed") .arg(fileName, QDir::toNativeSeparators(nativesDir)); return false; } @@ -298,27 +299,27 @@ bool GameLauncher::launch(const LaunchOptions &options, { if (isRunning()) { if (error) - *error = tr("Игра уже запущена"); + *error = Loc::text("common.error.gameRunning"); return false; } - emit progress(tr("Проверка файлов игры…")); + emit progress(Loc::text("launch.progress.checkingFiles")); const QStringList missing = missingFiles(options, version); if (!missing.isEmpty()) { if (error) - *error = tr("В .minecraft не хватает файлов (%1):\n%2") + *error = Loc::text("launch.error.filesMissing") .arg(missing.size()) .arg(missing.join(u'\n')); return false; } - emit progress(tr("Распаковка нативных библиотек…")); + emit progress(Loc::text("launch.progress.extractingNatives")); const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id + QStringLiteral("/natives"); if (!extractNatives(options, version, nativesDir, error)) return false; - emit progress(tr("Поиск Java %1…").arg(version.javaMajor)); + emit progress(Loc::text("launch.progress.lookingForJava").arg(version.javaMajor)); const QString java = JavaLocator::select(options.gameDir, version.javaMajor, options.javaPath, error); if (java.isEmpty()) return false; @@ -352,7 +353,7 @@ bool GameLauncher::launch(const LaunchOptions &options, m_process->start(); if (!m_process->waitForStarted(15000)) { if (error) - *error = tr("Не удалось запустить %1: %2") + *error = Loc::text("launch.error.startFailed") .arg(QDir::toNativeSeparators(executable), m_process->errorString()); m_process->deleteLater(); m_process = nullptr; diff --git a/i18n/translations.json b/i18n/translations.json new file mode 100644 index 0000000..46a3913 --- /dev/null +++ b/i18n/translations.json @@ -0,0 +1,1739 @@ +{ + "_meta": { + "languages": [ + "ru", + "en" + ], + "displayNames": { + "ru": "Русский", + "en": "English" + }, + "_readme": [ + "Единственный файл со всеми текстами интерфейса. Правится руками.", + "Ключ: <домен>.<вид>.<имя>, сегменты в lowerCamelCase.", + "Домены: app, common, settings, profile, build, seasonal, version, java,", + "loader, auth, launch, game, switch, storage, zlib.", + "Виды: title, label, button, placeholder, hint, header, error, progress,", + "status, warning.", + "Имя описывает условие, а не формулировку: downloadFailed, gameRunning.", + "Литерал, встречающийся в двух и более файлах, живёт в common.*.", + "Набор %N в ru и en обязан совпадать; порядок слов может отличаться.", + "Проверка целостности: python3 tools/check_translations.py" + ], + "_glossary": { + "сборка": "build", + "версия": "version", + "профиль": "profile", + "загрузка": "download", + "установка": "install", + "папка": "folder", + "лаунчер": "launcher" + } + }, + "strings": { + "app.title": { + "ru": "Minecraft Launcher", + "en": "Minecraft Launcher" + }, + "app.tooltip.openMods": { + "ru": "Открыть папку с модами Minecraft", + "en": "Open the Minecraft mods folder" + }, + "app.tooltip.seasonal": { + "ru": "Готовые сборки с сервера: моды, конфиги и Java одной кнопкой", + "en": "Ready-made builds from the server: mods, configs and Java in one click" + }, + "app.tooltip.settings": { + "ru": "Настройки запуска: Java, память, папка игры", + "en": "Launch settings: Java, memory, game folder" + }, + "auth.ely.error.enterCode": { + "ru": "Введите код двухфакторной аутентификации", + "en": "Enter the two-factor authentication code" + }, + "auth.ely.error.injectorChecksum": { + "ru": "Контрольная сумма authlib-injector не совпала", + "en": "The authlib-injector checksum does not match" + }, + "auth.ely.error.injectorDownloadFailed": { + "ru": "Не удалось скачать authlib-injector: %1", + "en": "Could not download authlib-injector: %1" + }, + "auth.ely.error.injectorInfoFailed": { + "ru": "Не удалось получить сведения об authlib-injector: %1", + "en": "Could not fetch information about authlib-injector: %1" + }, + "auth.ely.error.injectorNoUrl": { + "ru": "В манифесте authlib-injector нет ссылки на файл", + "en": "The authlib-injector manifest has no file link" + }, + "auth.ely.error.noGameProfile": { + "ru": "Ely.by не вернул игровой профиль для этого аккаунта", + "en": "Ely.by returned no game profile for this account" + }, + "auth.ely.error.noLogin": { + "ru": "Укажите логин Ely.by в настройках профиля", + "en": "Set your Ely.by login in the profile settings" + }, + "auth.ely.error.noPassword": { + "ru": "Укажите пароль Ely.by в настройках профиля", + "en": "Set your Ely.by password in the profile settings" + }, + "auth.ely.error.twoFactor": { + "ru": "Аккаунт защищён двухфакторной аутентификацией", + "en": "The account is protected by two-factor authentication" + }, + "auth.ely.error.unreachable": { + "ru": "Нет связи с authserver.ely.by: %1", + "en": "Cannot reach authserver.ely.by: %1" + }, + "auth.ely.hint.enterCode": { + "ru": "Введите код из приложения-аутентификатора для «%1»", + "en": "Enter the code from your authenticator app for \"%1\"" + }, + "auth.ely.progress.checkingToken": { + "ru": "Проверка сохранённого токена Ely.by…", + "en": "Checking the stored Ely.by token…" + }, + "auth.ely.progress.downloadingInjector": { + "ru": "Загрузка authlib-injector…", + "en": "Downloading authlib-injector…" + }, + "auth.ely.progress.refreshingToken": { + "ru": "Обновление токена Ely.by…", + "en": "Refreshing the Ely.by token…" + }, + "auth.ely.progress.signingIn": { + "ru": "Авторизация на Ely.by…", + "en": "Signing in to Ely.by…" + }, + "auth.ely.title.twoFactor": { + "ru": "Двухфакторная аутентификация", + "en": "Two-factor authentication" + }, + "auth.error.serverCode": { + "ru": "Сервер авторизации вернул код %1", + "en": "The authentication server returned code %1" + }, + "auth.msa.error.ageVerification": { + "ru": "Аккаунту нужна проверка возраста на xbox.com", + "en": "The account needs age verification at xbox.com" + }, + "auth.msa.error.childAccount": { + "ru": "Детский аккаунт: добавьте его в семейную группу Microsoft, иначе вход в Xbox Live невозможен", + "en": "Child account: add it to a Microsoft family group, otherwise it cannot sign in to Xbox Live" + }, + "auth.msa.error.countryBlocked": { + "ru": "Xbox Live недоступен в стране, указанной в аккаунте", + "en": "Xbox Live is not available in the country set on the account" + }, + "auth.msa.error.incomplete": { + "ru": "Вход в Microsoft не завершён", + "en": "The Microsoft sign-in did not finish" + }, + "auth.msa.error.microsoftCode": { + "ru": "Microsoft вернул код %1", + "en": "Microsoft returned code %1" + }, + "auth.msa.error.microsoftUnreachable": { + "ru": "Нет связи с сервером Microsoft: %1", + "en": "Cannot reach the Microsoft server: %1" + }, + "auth.msa.error.noAccessToken": { + "ru": "Microsoft не вернул токен доступа", + "en": "Microsoft returned no access token" + }, + "auth.msa.error.noAuthCode": { + "ru": "Microsoft не вернул код авторизации", + "en": "Microsoft returned no authorization code" + }, + "auth.msa.error.noLicence": { + "ru": "На этом аккаунте Microsoft нет копии Minecraft: Java Edition", + "en": "This Microsoft account does not own Minecraft: Java Edition" + }, + "auth.msa.error.noProfileYet": { + "ru": "На аккаунте Microsoft есть покупка, но игровой профиль не создан. Зайдите один раз в официальный лаунчер и выберите ник.", + "en": "The Microsoft account owns the game but has no game profile yet. Sign in to the official launcher once and pick a nickname." + }, + "auth.msa.error.noWebEngine": { + "ru": "Эта сборка лаунчера собрана без Qt WebEngine — показать окно входа Microsoft нечем", + "en": "This launcher build was made without Qt WebEngine — there is nothing to show the Microsoft sign-in window with" + }, + "auth.msa.error.noXboxProfile": { + "ru": "К аккаунту Microsoft не привязан профиль Xbox. Создайте его на xbox.com и повторите вход.", + "en": "The Microsoft account has no Xbox profile. Create one at xbox.com and sign in again." + }, + "auth.msa.error.profileFetchFailed": { + "ru": "Не удалось получить профиль Minecraft (код %1)", + "en": "Could not fetch the Minecraft profile (code %1)" + }, + "auth.msa.error.profileNeedsLogin": { + "ru": "Профиль «%1» не подключён к аккаунту Microsoft — войдите в него", + "en": "Profile \"%1\" is not linked to a Microsoft account — sign in to it" + }, + "auth.msa.error.profileNotLinked": { + "ru": "Профиль не подключён к аккаунту Microsoft", + "en": "This profile is not linked to a Microsoft account" + }, + "auth.msa.error.servicesNoProfile": { + "ru": "Minecraft Services не вернули игровой профиль", + "en": "Minecraft Services returned no game profile" + }, + "auth.msa.error.servicesNoToken": { + "ru": "Minecraft Services не вернули токен доступа", + "en": "Minecraft Services returned no access token" + }, + "auth.msa.error.servicesRejected": { + "ru": "Minecraft Services отклонили вход (код %1)", + "en": "Minecraft Services rejected the sign-in (code %1)" + }, + "auth.msa.error.servicesUnreachable": { + "ru": "Нет связи с api.minecraftservices.com: %1", + "en": "Cannot reach api.minecraftservices.com: %1" + }, + "auth.msa.error.sessionExpired": { + "ru": "Сессия Microsoft истекла — войдите в аккаунт заново (%1)", + "en": "The Microsoft session has expired — sign in to the account again (%1)" + }, + "auth.msa.error.windowUnavailable": { + "ru": "Окно входа Microsoft недоступно: сборка без Qt WebEngine", + "en": "The Microsoft sign-in window is unavailable: this build has no Qt WebEngine" + }, + "auth.msa.error.xboxNoToken": { + "ru": "Xbox Live не вернул токен", + "en": "Xbox Live returned no token" + }, + "auth.msa.error.xboxRejected": { + "ru": "Xbox Live отклонил вход (XErr %1)", + "en": "Xbox Live rejected the sign-in (XErr %1)" + }, + "auth.msa.error.xboxTokenRejected": { + "ru": "Xbox Live отклонил токен Microsoft (код %1)", + "en": "Xbox Live rejected the Microsoft token (code %1)" + }, + "auth.msa.error.xboxUnreachable": { + "ru": "Нет связи с Xbox Live: %1", + "en": "Cannot reach Xbox Live: %1" + }, + "auth.msa.error.xstsNoUser": { + "ru": "XSTS не вернул данные пользователя", + "en": "XSTS returned no user data" + }, + "auth.msa.error.xstsRejected": { + "ru": "XSTS отклонил запрос (код %1)", + "en": "XSTS rejected the request (code %1)" + }, + "auth.msa.error.xstsUnreachable": { + "ru": "Нет связи с сервером XSTS: %1", + "en": "Cannot reach the XSTS server: %1" + }, + "auth.msa.progress.checkingLicence": { + "ru": "Проверка лицензии Minecraft…", + "en": "Checking the Minecraft licence…" + }, + "auth.msa.progress.exchangingCode": { + "ru": "Обмен кода Microsoft на токен…", + "en": "Exchanging the Microsoft code for a token…" + }, + "auth.msa.progress.minecraftServices": { + "ru": "Авторизация в Minecraft Services…", + "en": "Signing in to Minecraft Services…" + }, + "auth.msa.progress.refreshingSession": { + "ru": "Обновление сессии Microsoft…", + "en": "Refreshing the Microsoft session…" + }, + "auth.msa.progress.xboxSignIn": { + "ru": "Вход в Xbox Live…", + "en": "Signing in to Xbox Live…" + }, + "auth.msa.progress.xsts": { + "ru": "Проверка прав XSTS…", + "en": "Checking XSTS permissions…" + }, + "auth.msa.status.alreadyCancelled": { + "ru": "Вход в Microsoft уже отменён", + "en": "The Microsoft sign-in has already been cancelled" + }, + "auth.msa.title.signIn": { + "ru": "Вход в аккаунт Microsoft", + "en": "Microsoft account sign-in" + }, + "auth.offline.error.noNickname": { + "ru": "Для офлайн-запуска нужен никнейм", + "en": "An offline launch needs a nickname" + }, + "auth.status.signedIn": { + "ru": "Вход выполнен", + "en": "Signed in" + }, + "auth.status.signedInAs": { + "ru": "Вход выполнен: %1", + "en": "Signed in: %1" + }, + "auth.status.signedOut": { + "ru": "Вход не выполнен", + "en": "Not signed in" + }, + "build.button.makeActive": { + "ru": "Сделать активной", + "en": "Make active" + }, + "build.button.new": { + "ru": "+ Новая сборка", + "en": "+ New build" + }, + "build.button.pickVersion": { + "ru": "Выберите версию", + "en": "Pick a version" + }, + "build.error.closeGameFilesMoving": { + "ru": "Сначала закройте игру — её файлы сейчас переносятся", + "en": "Close the game first — its files are being moved right now" + }, + "build.error.filesIncomplete": { + "ru": "Файлы сборки установлены не полностью: %1\nПовторите установку сборки.", + "en": "The build's files are only partially installed: %1\nInstall the build again." + }, + "build.error.filesNotInstalled": { + "ru": "Файлы сборки не установлены: %1", + "en": "The build's files are not installed: %1" + }, + "build.error.installBusy": { + "ru": "Идёт установка «%1» — дождитесь окончания", + "en": "\"%1\" is being installed — wait for it to finish" + }, + "build.error.missingFiles": { + "ru": "Не хватает файлов (%1): %2", + "en": "Files are missing (%1): %2" + }, + "build.error.noMinecraftVersion": { + "ru": "У сборки не выбрана версия Minecraft. Проверьте %1", + "en": "The build has no Minecraft version selected. Check %1" + }, + "build.error.pickLoaderVersion": { + "ru": "Выберите версию %1 для сборки «%2»", + "en": "Pick a %1 version for build \"%2\"" + }, + "build.error.pickMinecraftVersion": { + "ru": "Сначала выберите версию Minecraft для сборки «%1»", + "en": "First pick a Minecraft version for build \"%1\"" + }, + "build.error.removedDuringAuth": { + "ru": "Сборка была удалена, пока шла авторизация", + "en": "The build was removed while signing in" + }, + "build.error.switchFailed": { + "ru": "Сборка не переключена: %1", + "en": "The build was not switched: %1" + }, + "build.error.switchFailedDirty": { + "ru": "Сборка не переключена: %1\nСодержимое .minecraft осталось разобранным — повторите переключение.", + "en": "The build was not switched: %1\nThe contents of .minecraft are left half-applied — switch again." + }, + "build.error.versionNotInstalled": { + "ru": "Версия «%1» из сборки «%2» не установлена в %3", + "en": "Version \"%1\" from build \"%2\" is not installed in %3" + }, + "build.hint.removeIrreversible": { + "ru": "Сборка «%1» будет удалена без возможности восстановления.", + "en": "Build \"%1\" will be removed and cannot be recovered." + }, + "build.hint.removingLast": { + "ru": "Это последняя сборка. Содержимое .minecraft останется на месте.", + "en": "This is the last build. The contents of .minecraft will stay where they are." + }, + "build.label.active": { + "ru": "активна", + "en": "active" + }, + "build.label.modLoader": { + "ru": "Модлоадер", + "en": "Mod loader" + }, + "build.label.name": { + "ru": "Название сборки", + "en": "Build name" + }, + "build.label.serverAddress": { + "ru": "Адрес сервера", + "en": "Server address" + }, + "build.placeholder.name": { + "ru": "как её видно в списке", + "en": "as it appears in the list" + }, + "build.placeholder.serverAddress": { + "ru": "host:port (необязательно)", + "en": "host:port (optional)" + }, + "build.progress.activating": { + "ru": "Активна сборка «%1»…", + "en": "Activating build \"%1\"…" + }, + "build.progress.switchingTo": { + "ru": "Смена сборки на «%1»…", + "en": "Switching to build \"%1\"…" + }, + "build.status.active": { + "ru": "Активна сборка «%1»", + "en": "Build \"%1\" is active" + }, + "build.status.named": { + "ru": "Сборка «%1»: %2", + "en": "Build \"%1\": %2" + }, + "build.status.ready": { + "ru": "Всё на месте, сборку можно запускать", + "en": "Everything is in place, the build can be launched" + }, + "build.title.numbered": { + "ru": "Сборка %1", + "en": "Build %1" + }, + "build.title.removeConfirm": { + "ru": "Удалить сборку?", + "en": "Remove the build?" + }, + "build.warning.archiveGoesToo": { + "ru": "Вместе с ней удалится её архив: моды, конфиги и миры этой сборки восстановить будет нельзя.", + "en": "Its archive goes with it: the mods, configs and worlds of this build cannot be recovered." + }, + "build.warning.archiveNotRemoved": { + "ru": "Сборка «%1» удалена, но её архив в %2 стереть не удалось", + "en": "Build \"%1\" was removed, but its archive in %2 could not be deleted" + }, + "build.warning.removingActive": { + "ru": "Сборка сейчас активна: содержимое .minecraft принадлежит ей и будет очищено, а на его место развернётся следующая сборка.", + "en": "This build is currently active: the contents of .minecraft belong to it and will be cleared, and the next build will be unpacked in their place." + }, + "common.button.add": { + "ru": "Добавить", + "en": "Add" + }, + "common.button.cancel": { + "ru": "Отмена", + "en": "Cancel" + }, + "common.button.choose": { + "ru": "Выбрать…", + "en": "Choose…" + }, + "common.button.close": { + "ru": "Закрыть", + "en": "Close" + }, + "common.button.download": { + "ru": "Скачать", + "en": "Download" + }, + "common.button.install": { + "ru": "Установить", + "en": "Install" + }, + "common.button.ok": { + "ru": "ОК", + "en": "OK" + }, + "common.button.refreshList": { + "ru": "Обновить список", + "en": "Refresh list" + }, + "common.button.remove": { + "ru": "Удалить", + "en": "Remove" + }, + "common.button.save": { + "ru": "Сохранить", + "en": "Save" + }, + "common.button.select": { + "ru": "Выбрать", + "en": "Select" + }, + "common.button.signIn": { + "ru": "Войти", + "en": "Sign in" + }, + "common.button.update": { + "ru": "Обновить", + "en": "Update" + }, + "common.error.busyDownload": { + "ru": "Дождитесь окончания текущей загрузки", + "en": "Wait for the current download to finish" + }, + "common.error.busyOperation": { + "ru": "Дождитесь окончания текущей операции", + "en": "Wait for the current operation to finish" + }, + "common.error.checksumMismatch": { + "ru": "контрольная сумма не совпала", + "en": "checksum mismatch" + }, + "common.error.closeGameFirst": { + "ru": "Сначала закройте игру", + "en": "Close the game first" + }, + "common.error.createFailed": { + "ru": "Не удалось создать %1", + "en": "Could not create %1" + }, + "common.error.downloadFailed": { + "ru": "Не удалось скачать %1: %2", + "en": "Could not download %1: %2" + }, + "common.error.gameRunning": { + "ru": "Игра уже запущена", + "en": "The game is already running" + }, + "common.error.inheritsChainTooLong": { + "ru": "Слишком длинная цепочка inheritsFrom у версии «%1»", + "en": "The inheritsFrom chain for version \"%1\" is too long" + }, + "common.error.openForWriteFailed": { + "ru": "Не удалось открыть на запись %1", + "en": "Could not open %1 for writing" + }, + "common.error.readFailed": { + "ru": "Не удалось прочитать %1: %2", + "en": "Could not read %1: %2" + }, + "common.error.removeFailed": { + "ru": "не удалось удалить %1", + "en": "could not delete %1" + }, + "common.error.saveFailed": { + "ru": "Не удалось сохранить %1", + "en": "Could not save %1" + }, + "common.error.switchInProgress": { + "ru": "Смена сборки уже выполняется", + "en": "A build switch is already running" + }, + "common.error.versionReadFailed": { + "ru": "Не удалось прочитать версию «%1»", + "en": "Could not read version \"%1\"" + }, + "common.error.versionSaveFailed": { + "ru": "Не удалось сохранить описание версии «%1»", + "en": "Could not save the description for version \"%1\"" + }, + "common.header.loader": { + "ru": "Загрузчик", + "en": "Loader" + }, + "common.header.name": { + "ru": "Название", + "en": "Name" + }, + "common.header.status": { + "ru": "Статус", + "en": "Status" + }, + "common.header.version": { + "ru": "Версия", + "en": "Version" + }, + "common.java.kind.java": { + "ru": "Java", + "en": "Java" + }, + "common.java.kind.jdk": { + "ru": "JDK", + "en": "JDK" + }, + "common.java.kind.jre": { + "ru": "JRE", + "en": "JRE" + }, + "common.label.minecraftVersion": { + "ru": "Версия Minecraft", + "en": "Minecraft version" + }, + "common.placeholder.searchVersion": { + "ru": "Поиск версии…", + "en": "Search version…" + }, + "common.progress.bytes": { + "ru": "%1 — %2 / %3 МБ", + "en": "%1 — %2 / %3 MB" + }, + "common.progress.downloadingNamed": { + "ru": "Загрузка %1", + "en": "Downloading %1" + }, + "common.status.megabytes": { + "ru": "%1 МБ", + "en": "%1 MB" + }, + "common.status.noBuildSelected": { + "ru": "Сборка не выбрана", + "en": "No build selected" + }, + "common.status.nothingFound": { + "ru": "Ничего не найдено", + "en": "Nothing found" + }, + "common.status.pair": { + "ru": "%1: %2", + "en": "%1: %2" + }, + "common.status.updating": { + "ru": "Обновление…", + "en": "Updating…" + }, + "common.title.customBuilds": { + "ru": "Пользовательские сборки", + "en": "Custom builds" + }, + "common.title.seasonalBuilds": { + "ru": "Сезонные сборки", + "en": "Seasonal builds" + }, + "game.status.closed": { + "ru": "Игра закрыта", + "en": "The game has been closed" + }, + "game.status.crashed": { + "ru": "Игра завершилась с ошибкой (код %1)", + "en": "The game exited with an error (code %1)" + }, + "java.error.archiveBadPath": { + "ru": "Архив Java содержит недопустимый путь: %1", + "en": "The Java archive contains an invalid path: %1" + }, + "java.error.archiveChecksum": { + "ru": "Контрольная сумма архива Java не совпала — загрузка повреждена", + "en": "The Java archive's checksum does not match — the download is corrupted" + }, + "java.error.archiveReadFailed": { + "ru": "Не удалось прочитать архив %1", + "en": "Could not read archive %1" + }, + "java.error.buildNotInCatalog": { + "ru": "Сборка Java «%1» не найдена в каталоге", + "en": "Java build \"%1\" was not found in the catalog" + }, + "java.error.buildUnavailable": { + "ru": "Сборка Java недоступна для загрузки", + "en": "This Java build is not available for download" + }, + "java.error.buildsFetchFailed": { + "ru": "Не удалось получить сборки Java %1: %2", + "en": "Could not fetch Java %1 builds: %2" + }, + "java.error.catalogUnavailable": { + "ru": "Список сборок Java недоступен — проверьте соединение", + "en": "The Java build list is unavailable — check your connection" + }, + "java.error.currentlyDownloading": { + "ru": "Эта сборка Java сейчас скачивается", + "en": "This Java build is being downloaded right now" + }, + "java.error.downloadFailed": { + "ru": "Не удалось скачать Java %1: %2", + "en": "Could not download Java %1: %2" + }, + "java.error.extractCrashed": { + "ru": "Распаковка архива Java не удалась: %1", + "en": "Extracting the Java archive failed: %1" + }, + "java.error.extractEntryFailed": { + "ru": "Не удалось распаковать %1", + "en": "Could not extract %1" + }, + "java.error.extractFailed": { + "ru": "Не удалось распаковать архив Java: %1", + "en": "Could not extract the Java archive: %1" + }, + "java.error.installBusy": { + "ru": "Идёт установка Java «%1» — дождитесь окончания", + "en": "Java \"%1\" is being installed — wait for it to finish" + }, + "java.error.installFailed": { + "ru": "Не удалось установить Java «%1»: %2", + "en": "Could not install Java \"%1\": %2" + }, + "java.error.invalidBuildId": { + "ru": "Недопустимый идентификатор сборки Java", + "en": "Invalid Java build id" + }, + "java.error.invalidRuntimeId": { + "ru": "недопустимый идентификатор сборки Java", + "en": "invalid Java build id" + }, + "java.error.launcherDirUnavailable": { + "ru": "Папка лаунчера недоступна: %1", + "en": "The launcher folder is not accessible: %1" + }, + "java.error.linkFailed": { + "ru": "Не удалось создать ссылку %1", + "en": "Could not create link %1" + }, + "java.error.manifestBadPath": { + "ru": "Состав рантайма содержит недопустимый путь: %1", + "en": "The runtime's contents include an invalid path: %1" + }, + "java.error.manifestFetchFailed": { + "ru": "Не удалось получить состав рантайма: %1", + "en": "Could not fetch the runtime's contents: %1" + }, + "java.error.manifestUnparsable": { + "ru": "Состав рантайма Java не удалось разобрать", + "en": "The Java runtime's contents could not be parsed" + }, + "java.error.mojangRuntimesFailed": { + "ru": "Не удалось получить рантаймы Mojang: %1", + "en": "Could not fetch the Mojang runtimes: %1" + }, + "java.error.noExecutable": { + "ru": "В скачанной сборке не нашлось исполняемого файла java", + "en": "No java executable was found in the downloaded build" + }, + "java.error.notAtPath": { + "ru": "Java по указанному пути не найдена: %1", + "en": "No Java found at the given path: %1" + }, + "java.error.notFound": { + "ru": "Java не найдена. Установите JDK %1 или укажите путь в настройках.", + "en": "No Java found. Install JDK %1 or set the path in the settings." + }, + "java.error.removeFailed": { + "ru": "Не удалось удалить сборку Java: %1", + "en": "Could not remove the Java build: %1" + }, + "java.error.tooOld": { + "ru": "Для этой версии нужна Java %1 или новее, найдена только Java %2 (%3).", + "en": "This version needs Java %1 or newer, but only Java %2 (%3) was found." + }, + "java.error.unknownArchiveFormat": { + "ru": "Неизвестный формат архива: %1", + "en": "Unknown archive format: %1" + }, + "java.error.versionUnknown": { + "ru": "Не удалось определить версию установленной Java. Укажите путь к JDK %1 в настройках.", + "en": "Could not determine the installed Java version. Set the path to JDK %1 in the settings." + }, + "java.error.versionsFetchFailed": { + "ru": "Не удалось получить список версий Java: %1", + "en": "Could not fetch the Java version list: %1" + }, + "java.error.versionsUnparsable": { + "ru": "Список версий Java не удалось разобрать", + "en": "The Java version list could not be parsed" + }, + "java.error.writeFailed": { + "ru": "не удалось записать %1", + "en": "could not write %1" + }, + "java.filter.downloadedOnly": { + "ru": "Только скачанные", + "en": "Downloaded only" + }, + "java.filter.mojang": { + "ru": "сборки Mojang", + "en": "Mojang builds" + }, + "java.filter.temurinJdk": { + "ru": "Temurin, с инструментами", + "en": "Temurin, with tools" + }, + "java.filter.temurinJre": { + "ru": "Temurin, только запуск", + "en": "Temurin, runtime only" + }, + "java.hint.required": { + "ru": "Выбранной версии игры нужна Java %1 или новее", + "en": "The selected game version needs Java %1 or newer" + }, + "java.label.downloadedByLauncher": { + "ru": "скачана лаунчером", + "en": "downloaded by the launcher" + }, + "java.label.mojang": { + "ru": "Mojang · %1", + "en": "Mojang · %1" + }, + "java.label.temurin": { + "ru": "Temurin · %1", + "en": "Temurin · %1" + }, + "java.label.version": { + "ru": "Версия Java", + "en": "Java version" + }, + "java.progress.downloading": { + "ru": "Загрузка Java «%1»…", + "en": "Downloading Java \"%1\"…" + }, + "java.progress.downloadingBuild": { + "ru": "Загрузка Java %1", + "en": "Downloading Java %1" + }, + "java.progress.extracting": { + "ru": "Распаковка Java %1", + "en": "Extracting Java %1" + }, + "java.progress.fileList": { + "ru": "Список файлов Java %1", + "en": "Java %1 file list" + }, + "java.progress.loadingCatalog": { + "ru": "Загрузка списка сборок Java…", + "en": "Loading the Java build list…" + }, + "java.progress.verifying": { + "ru": "Проверка сборки Java", + "en": "Checking the Java build" + }, + "java.range.legacy": { + "ru": "Minecraft 1.16.5 и старше", + "en": "Minecraft 1.16.5 and older" + }, + "java.range.mid": { + "ru": "Minecraft 1.18 — 1.20.4", + "en": "Minecraft 1.18 — 1.20.4" + }, + "java.range.modern": { + "ru": "Minecraft 1.20.5 и новее", + "en": "Minecraft 1.20.5 and newer" + }, + "java.range.seventeen": { + "ru": "Minecraft 1.17 — 1.17.1", + "en": "Minecraft 1.17 — 1.17.1" + }, + "java.range.unsuitable": { + "ru": "для Minecraft не подходит", + "en": "not suitable for Minecraft" + }, + "java.status.catalogFromCache": { + "ru": "Список сборок Java взят из кэша", + "en": "The Java build list was taken from the cache" + }, + "java.status.downloadCancelled": { + "ru": "Загрузка Java «%1» отменена", + "en": "Download of Java \"%1\" cancelled" + }, + "java.status.installed": { + "ru": "Java установлена: %1", + "en": "Java installed: %1" + }, + "java.status.nameAndVersion": { + "ru": "%1 %2", + "en": "%1 %2" + }, + "java.status.removed": { + "ru": "Сборка Java удалена", + "en": "Java build removed" + }, + "java.status.willDownload": { + "ru": "%1 — будет скачана", + "en": "%1 — will be downloaded" + }, + "java.warning.runtimeMissing": { + "ru": "Выбранная сборка Java не найдена в папке лаунчера — ищем установленную в системе", + "en": "The selected Java build was not found in the launcher folder — looking for one installed on the system" + }, + "java.warning.tooOldForVersion": { + "ru": "не хватит для выбранной версии игры", + "en": "not enough for the selected game version" + }, + "launch.error.alreadyLaunching": { + "ru": "Запуск уже выполняется", + "en": "A launch is already in progress" + }, + "launch.error.buildNoVersion": { + "ru": "У сборки «%1» не выбрана версия Minecraft. Проверьте %2", + "en": "Build \"%1\" has no Minecraft version selected. Check %2" + }, + "launch.error.createFolderFailed": { + "ru": "Не удалось создать папку %1", + "en": "Could not create folder %1" + }, + "launch.error.extractFailed": { + "ru": "Не удалось распаковать %1 в %2", + "en": "Could not extract %1 into %2" + }, + "launch.error.filesMissing": { + "ru": "В .minecraft не хватает файлов (%1):\n%2", + "en": "Files are missing from .minecraft (%1):\n%2" + }, + "launch.error.gameDirMissing": { + "ru": "Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.", + "en": "The .minecraft folder was not found: %1. Set its path in the settings." + }, + "launch.error.gameFilesMissing": { + "ru": "В папке игры не хватает файлов (%1):\n%2", + "en": "Files are missing from the game folder (%1):\n%2" + }, + "launch.error.nativeReadFailed": { + "ru": "Не удалось прочитать натив-библиотеку %1", + "en": "Could not read native library %1" + }, + "launch.error.noBuild": { + "ru": "Выберите сборку перед запуском", + "en": "Pick a build before launching" + }, + "launch.error.noProfile": { + "ru": "Выберите профиль перед запуском", + "en": "Pick a profile before launching" + }, + "launch.error.noVersionsInstalled": { + "ru": "В %1 нет ни одной установленной версии", + "en": "There is no installed version in %1" + }, + "launch.error.openGameDirFailed": { + "ru": "Не удалось открыть папку игры", + "en": "Could not open the game folder" + }, + "launch.error.openModsFailed": { + "ru": "Не удалось открыть папку с модами", + "en": "Could not open the mods folder" + }, + "launch.error.startFailed": { + "ru": "Не удалось запустить %1: %2", + "en": "Could not start %1: %2" + }, + "launch.error.versionNotInstalled": { + "ru": "Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4", + "en": "Version \"%1\" from build \"%2\" is not installed in %3. Available: %4" + }, + "launch.progress.checkingFiles": { + "ru": "Проверка файлов игры…", + "en": "Checking the game's files…" + }, + "launch.progress.extractingNatives": { + "ru": "Распаковка нативных библиотек…", + "en": "Extracting native libraries…" + }, + "launch.progress.lookingForJava": { + "ru": "Поиск Java %1…", + "en": "Looking for Java %1…" + }, + "launch.progress.readingVersion": { + "ru": "Чтение описания версии «%1»…", + "en": "Reading the description of version \"%1\"…" + }, + "launch.status.alreadyCancelled": { + "ru": "Запуск уже отменён", + "en": "The launch has already been cancelled" + }, + "launch.status.cancelled": { + "ru": "Запуск был отменён", + "en": "The launch was cancelled" + }, + "launch.status.started": { + "ru": "Запущено: %1 — %2", + "en": "Launched: %1 — %2" + }, + "loader.error.cleanupFailed": { + "ru": "Не удалось убрать незавершённый профиль «%1»: %2", + "en": "Could not remove the unfinished profile \"%1\": %2" + }, + "loader.error.descriptionBadId": { + "ru": "Описание %1 испорчено: недопустимый идентификатор версии", + "en": "The %1 description is malformed: invalid version id" + }, + "loader.error.descriptionFetchFailed": { + "ru": "Не удалось получить описание %1: %2", + "en": "Could not fetch the %1 description: %2" + }, + "loader.error.descriptionWrongMinecraft": { + "ru": "Описание %1 рассчитано на Minecraft %2, а не %3", + "en": "The %1 description targets Minecraft %2, not %3" + }, + "loader.error.fullLog": { + "ru": "Полный лог: %1", + "en": "Full log: %1" + }, + "loader.error.incompatible": { + "ru": "%1 %2 не подходит к Minecraft %3 — выберите версию из списка", + "en": "%1 %2 does not fit Minecraft %3 — pick a version from the list" + }, + "loader.error.installInProgress": { + "ru": "Установка модлоадера уже идёт", + "en": "A mod loader installation is already running" + }, + "loader.error.installerBadUrl": { + "ru": "Установщик %1 доступен только по недопустимой ссылке", + "en": "The %1 installer is only available at an invalid link" + }, + "loader.error.installerDownloadFailed": { + "ru": "Не удалось скачать установщик %1: %2", + "en": "Could not download the %1 installer: %2" + }, + "loader.error.installerExitCode": { + "ru": "Установщик %1 завершился с ошибкой (код %2).", + "en": "The %1 installer exited with an error (code %2)." + }, + "loader.error.installerFailedToStart": { + "ru": "Установщик %1 не запустился: %2", + "en": "The %1 installer did not start: %2" + }, + "loader.error.installerSaveFailed": { + "ru": "Не удалось сохранить установщик %1", + "en": "Could not save the %1 installer" + }, + "loader.error.installerStartFailed": { + "ru": "Не удалось запустить установщик %1: %2", + "en": "Could not start the %1 installer: %2" + }, + "loader.error.installerTimeout": { + "ru": "Установщик %1 не завершился за отведённое время", + "en": "The %1 installer did not finish within the allotted time" + }, + "loader.error.invalidVersionId": { + "ru": "Недопустимый идентификатор версии «%1»", + "en": "Invalid version id \"%1\"" + }, + "loader.error.launcherDirUnavailable": { + "ru": "Нет доступа к папке лаунчера", + "en": "The launcher folder is not accessible" + }, + "loader.error.listUnavailable": { + "ru": "Список версий %1 недоступен", + "en": "The %1 version list is unavailable" + }, + "loader.error.noJava": { + "ru": "Java не найдена — установщик %1 запустить нечем", + "en": "No Java found — there is nothing to run the %1 installer with" + }, + "loader.error.noLauncherProfiles": { + "ru": "Не удалось создать launcher_profiles.json — без него установщик %1 не работает", + "en": "Could not create launcher_profiles.json — the %1 installer does not work without it" + }, + "loader.error.noVersionProduced": { + "ru": "Установщик %1 отработал, но новая версия в %2 не появилась.", + "en": "The %1 installer finished, but no new version appeared in %2." + }, + "loader.error.noVersionSelected": { + "ru": "Не выбрана версия %1", + "en": "No %1 version selected" + }, + "loader.error.versionsFetchFailed": { + "ru": "Не удалось получить список версий %1: %2", + "en": "Could not fetch the %1 version list: %2" + }, + "loader.error.versionsUnparsable": { + "ru": "Список версий %1 не удалось разобрать", + "en": "The %1 version list could not be parsed" + }, + "loader.error.zlibNg": { + "ru": "Системный libz.so.1 — это zlib-ng: он сжимает иначе, чем обычный zlib, поэтому sha1 собранных установщиком файлов не совпадает с эталоном. Пересоберите лаунчер, чтобы рядом с ним появился эталонный libz.so.1, либо замените zlib-ng в системе: sudo pacman -S zlib lib32-zlib", + "en": "The system libz.so.1 is zlib-ng: it compresses differently from ordinary zlib, so the sha1 of the files the installer builds does not match the reference. Rebuild the launcher so that a reference libz.so.1 appears next to it, or replace zlib-ng on the system: sudo pacman -S zlib lib32-zlib" + }, + "loader.label.recommended": { + "ru": " — рекомендуемая", + "en": " — recommended" + }, + "loader.label.unstable": { + "ru": " — нестабильная", + "en": " — unstable" + }, + "loader.label.vanilla": { + "ru": "Ванильная", + "en": "Vanilla" + }, + "loader.name.fabric": { + "ru": "Fabric Loader", + "en": "Fabric Loader" + }, + "loader.name.forge": { + "ru": "Minecraft Forge", + "en": "Minecraft Forge" + }, + "loader.name.neoforge": { + "ru": "NeoForge", + "en": "NeoForge" + }, + "loader.name.quilt": { + "ru": "Quilt Loader", + "en": "Quilt Loader" + }, + "loader.progress.description": { + "ru": "Описание версии %1", + "en": "%1 version description" + }, + "loader.progress.downloadingInstaller": { + "ru": "Загрузка установщика %1", + "en": "Downloading the %1 installer" + }, + "loader.progress.downloadingVersionFiles": { + "ru": "Загрузка файлов версии", + "en": "Downloading the version's files" + }, + "loader.progress.installerRunning": { + "ru": "работает установщик, это может занять несколько минут", + "en": "the installer is working, this may take a few minutes" + }, + "loader.progress.installing": { + "ru": "Установка %1", + "en": "Installing %1" + }, + "loader.progress.installingMinecraft": { + "ru": "Установка Minecraft %1", + "en": "Installing Minecraft %1" + }, + "loader.progress.installingShort": { + "ru": "Установка %1…", + "en": "Installing %1…" + }, + "loader.progress.loadingVersions": { + "ru": "Загрузка списка версий %1…", + "en": "Loading the %1 version list…" + }, + "loader.progress.preparing": { + "ru": "Подготовка %1", + "en": "Preparing %1" + }, + "loader.status.done": { + "ru": "Готово: %1 для Minecraft %2", + "en": "Done: %1 for Minecraft %2" + }, + "loader.status.installCancelled": { + "ru": "Установка %1 отменена", + "en": "Installation of %1 cancelled" + }, + "loader.status.unsupported": { + "ru": "%1 не поддерживает Minecraft %2", + "en": "%1 does not support Minecraft %2" + }, + "loader.status.versionId": { + "ru": "Идентификатор версии: %1", + "en": "Version id: %1" + }, + "profile.authTypes": { + "ru": [ + "Офлайн (без пароля)", + "Ely.by", + "Microsoft (лицензия)" + ], + "en": [ + "Offline (no password)", + "Ely.by", + "Microsoft (licensed)" + ] + }, + "profile.button.add": { + "ru": "+ Добавить профиль", + "en": "+ Add profile" + }, + "profile.error.notFound": { + "ru": "Профиль не найден", + "en": "Profile not found" + }, + "profile.error.removedDuringLogin": { + "ru": "Профиль был удалён, пока шёл вход", + "en": "The profile was removed while signing in" + }, + "profile.hint.addFirst": { + "ru": "Профили игрока — добавьте первый профиль", + "en": "Player profiles — add your first profile" + }, + "profile.label.current": { + "ru": "Профиль игрока: %1", + "en": "Player profile: %1" + }, + "profile.placeholder.elyPassword": { + "ru": "Пароль Ely.by", + "en": "Ely.by password" + }, + "profile.placeholder.login": { + "ru": "Логин", + "en": "Login" + }, + "profile.placeholder.name": { + "ru": "Имя профиля", + "en": "Profile name" + }, + "profile.title.edit": { + "ru": "Редактирование профиля", + "en": "Edit profile" + }, + "profile.title.new": { + "ru": "Новый профиль", + "en": "New profile" + }, + "seasonal.error.catalogEmpty": { + "ru": "Список сборок пуст или не разобран", + "en": "The build list is empty or could not be parsed" + }, + "seasonal.error.catalogFetchFailed": { + "ru": "Не удалось получить список сборок: %1", + "en": "Could not fetch the build list: %1" + }, + "seasonal.error.checksumMismatch": { + "ru": "Контрольная сумма архива сборки не совпала — загрузка повреждена", + "en": "The build archive's checksum does not match — the download is corrupted" + }, + "seasonal.error.downloadFailed": { + "ru": "Не удалось скачать сборку: %1", + "en": "Could not download the build: %1" + }, + "seasonal.error.downloadInProgress": { + "ru": "Загрузка сборки уже идёт", + "en": "A build download is already running" + }, + "seasonal.error.entryGone": { + "ru": "запись сборки исчезла", + "en": "the build's entry has disappeared" + }, + "seasonal.error.filesNotDownloaded": { + "ru": "файлы сборки не скачаны", + "en": "the build's files were not downloaded" + }, + "seasonal.error.formatTooNew": { + "ru": "Сервер сборок отдаёт формат %1 — обновите лаунчер", + "en": "The build server speaks format %1 — update the launcher" + }, + "seasonal.error.formatUnknown": { + "ru": "Неизвестный формат каталога сборок (%1)", + "en": "Unknown build catalog format (%1)" + }, + "seasonal.error.installAborted": { + "ru": "Установка сборки «%1» прервана", + "en": "Installation of build \"%1\" was interrupted" + }, + "seasonal.error.installAbortedWhy": { + "ru": "Установка сборки «%1» прервана: %2", + "en": "Installation of build \"%1\" was interrupted: %2" + }, + "seasonal.error.installInProgress": { + "ru": "Установка сборки уже идёт", + "en": "A build installation is already running" + }, + "seasonal.error.javaCatalogFailed": { + "ru": "не удалось получить каталог Java", + "en": "could not fetch the Java catalog" + }, + "seasonal.error.noJavaBuild": { + "ru": "в каталоге нет сборки Java %1 для этой системы", + "en": "the catalog has no Java %1 build for this system" + }, + "seasonal.error.noServerUrl": { + "ru": "Адрес сервера сборок не задан", + "en": "No build server address is set" + }, + "seasonal.error.notInCatalog": { + "ru": "Сборка «%1» не найдена в списке — обновите его", + "en": "Build \"%1\" was not found in the list — refresh it" + }, + "seasonal.header.end": { + "ru": "Конец", + "en": "End" + }, + "seasonal.header.mods": { + "ru": "Модов", + "en": "Mods" + }, + "seasonal.header.start": { + "ru": "Начало", + "en": "Start" + }, + "seasonal.hint.pickBuild": { + "ru": "Выберите сборку в списке", + "en": "Pick a build from the list" + }, + "seasonal.progress.downloadingBuild": { + "ru": "Загрузка файлов сборки «%1»…", + "en": "Downloading the files of build \"%1\"…" + }, + "seasonal.progress.downloadingFiles": { + "ru": "Загрузка файлов сборки", + "en": "Downloading the build's files" + }, + "seasonal.progress.installing": { + "ru": "Установка сборки «%1»…", + "en": "Installing build \"%1\"…" + }, + "seasonal.progress.installingJava": { + "ru": "Для сборки нужна Java %1 — устанавливаем", + "en": "The build needs Java %1 — installing it" + }, + "seasonal.progress.loadingCatalog": { + "ru": "Загрузка списка сборок…", + "en": "Loading the build list…" + }, + "seasonal.status.buildInstalled": { + "ru": "Сборка «%1» установлена", + "en": "Build \"%1\" is installed" + }, + "seasonal.status.catalogEmptyYet": { + "ru": "Сборок пока нет", + "en": "There are no builds yet" + }, + "seasonal.status.downloadCancelled": { + "ru": "Загрузка сборки «%1» отменена", + "en": "Download of build \"%1\" cancelled" + }, + "seasonal.status.installed": { + "ru": "Установлена", + "en": "Installed" + }, + "seasonal.status.installedRevisionSuffix": { + "ru": " · установлена ревизия %1", + "en": " · revision %1 installed" + }, + "seasonal.status.notInstalled": { + "ru": "Не установлена", + "en": "Not installed" + }, + "seasonal.status.readyToLaunch": { + "ru": "Сборка «%1» установлена — можно запускать", + "en": "Build \"%1\" is installed — you can launch it" + }, + "seasonal.status.revision": { + "ru": "Ревизия %1", + "en": "Revision %1" + }, + "seasonal.status.sizeSuffix": { + "ru": " · %1 МБ", + "en": " · %1 MB" + }, + "seasonal.status.updateAvailable": { + "ru": "Есть обновление", + "en": "Update available" + }, + "settings.label.fullscreen": { + "ru": "Запускать в полноэкранном режиме", + "en": "Launch in fullscreen" + }, + "settings.label.gameDir": { + "ru": "Папка .minecraft", + "en": ".minecraft folder" + }, + "settings.label.javaPath": { + "ru": "Путь к Java", + "en": "Path to Java" + }, + "settings.label.javaRuntime": { + "ru": "Версия Java из папки лаунчера", + "en": "Java build from the launcher folder" + }, + "settings.label.jvmArgs": { + "ru": "Дополнительные аргументы JVM", + "en": "Extra JVM arguments" + }, + "settings.label.maxMemory": { + "ru": "-Xmx, МБ", + "en": "-Xmx, MB" + }, + "settings.label.minMemory": { + "ru": "-Xms, МБ", + "en": "-Xms, MB" + }, + "settings.label.windowHeight": { + "ru": "Высота окна", + "en": "Window height" + }, + "settings.label.windowWidth": { + "ru": "Ширина окна", + "en": "Window width" + }, + "settings.language.en": { + "ru": "English", + "en": "English" + }, + "settings.language.label": { + "ru": "Язык интерфейса", + "en": "Interface language" + }, + "settings.language.ru": { + "ru": "Русский", + "en": "Русский" + }, + "settings.language.system": { + "ru": "Как в системе", + "en": "Same as system" + }, + "settings.placeholder.gameDir": { + "ru": "по умолчанию", + "en": "default" + }, + "settings.placeholder.javaPath": { + "ru": "автоопределение", + "en": "auto-detect" + }, + "settings.status.javaNotFound": { + "ru": "Java не найдена", + "en": "No Java found" + }, + "settings.status.javaRuntimeDownloading": { + "ru": "%1 — скачивается", + "en": "%1 — downloading" + }, + "settings.status.javaRuntimeNone": { + "ru": "не выбрана — искать в системе", + "en": "not selected — search the system" + }, + "settings.status.resolvedGameDir": { + "ru": "Папка игры: %1", + "en": "Game folder: %1" + }, + "settings.title": { + "ru": "Настройки запуска", + "en": "Launch settings" + }, + "storage.error.corruptNotSetAside": { + "ru": "Файл %1 повреждён (%2), и его не удалось отложить в сторону", + "en": "File %1 is corrupted (%2) and could not be set aside" + }, + "storage.error.createFailed": { + "ru": "не удалось создать %1", + "en": "could not create %1" + }, + "storage.error.expectedArray": { + "ru": "ожидался список записей", + "en": "an array of entries was expected" + }, + "storage.error.expectedObject": { + "ru": "ожидался набор настроек", + "en": "a settings object was expected" + }, + "storage.error.launcherDirFailed": { + "ru": "Не удалось создать папку лаунчера: %1", + "en": "Could not create the launcher folder: %1" + }, + "storage.error.notWritable": { + "ru": "нет доступа на запись в %1", + "en": "no write access to %1" + }, + "storage.error.saveFailedWhy": { + "ru": "Не удалось сохранить %1: %2", + "en": "Could not save %1: %2" + }, + "storage.warning.buildsSkipped": { + "ru": "В %1 пропущены сборки без имени и версии Minecraft: %2", + "en": "Builds without a name and a Minecraft version were skipped in %1: %2" + }, + "storage.warning.corruptRecreated": { + "ru": "Файл %1 повреждён (%2). Копия сохранена как %3, файл создан заново.", + "en": "File %1 is corrupted (%2). A copy was saved as %3 and the file was created anew." + }, + "storage.warning.migrationLeftover": { + "ru": "Сборки перенесены в %1, но старый файл %2 удалить не удалось", + "en": "The builds were moved to %1, but the old file %2 could not be deleted" + }, + "switch.error.archiveBadPath": { + "ru": "Архив повреждён: недопустимый путь «%1»", + "en": "The archive is corrupted: invalid path \"%1\"" + }, + "switch.error.archiveCreateFailed": { + "ru": "Не удалось создать архив %1", + "en": "Could not create archive %1" + }, + "switch.error.archiveMissing": { + "ru": "Архив сборки не найден: %1", + "en": "Build archive not found: %1" + }, + "switch.error.archiveOpenFailed": { + "ru": "Не удалось открыть архив %1", + "en": "Could not open archive %1" + }, + "switch.error.archiveReplaceFailed": { + "ru": "Не удалось заменить архив %1", + "en": "Could not replace archive %1" + }, + "switch.error.archiveSaveFailed": { + "ru": "Не удалось сохранить архив %1", + "en": "Could not save archive %1" + }, + "switch.error.archiveWriteFailed": { + "ru": "Ошибка записи архива на файле %1", + "en": "Archive write error on file %1" + }, + "switch.error.busy": { + "ru": "Операция со сборками уже выполняется", + "en": "A build operation is already running" + }, + "switch.error.cannotCancel": { + "ru": "Сейчас переносятся файлы — отменить нельзя", + "en": "Files are being moved right now — this cannot be cancelled" + }, + "switch.error.gameDirMissing": { + "ru": "Папка игры не найдена: %1", + "en": "Game folder not found: %1" + }, + "switch.error.notEnoughSpace": { + "ru": "Недостаточно места для архива сборки «%1»: нужно около %2 МБ", + "en": "Not enough space for the archive of build \"%1\": about %2 MB is needed" + }, + "switch.error.packBadPath": { + "ru": "Архив сборки повреждён: недопустимый путь «%1»", + "en": "The build archive is corrupted: invalid path \"%1\"" + }, + "switch.error.packEscapesGameDir": { + "ru": "Архив сборки повреждён: путь «%1» ведёт за пределы папки игры", + "en": "The build archive is corrupted: path \"%1\" leads outside the game folder" + }, + "switch.error.packTouchesLauncher": { + "ru": "Архив сборки пытается изменить общие файлы лаунчера: «%1»", + "en": "The build archive tries to modify the launcher's shared files: \"%1\"" + }, + "switch.error.removeFailed": { + "ru": "Не удалось удалить %1", + "en": "Could not delete %1" + }, + "switch.error.writeFailed": { + "ru": "Не удалось записать %1: %2", + "en": "Could not write %1: %2" + }, + "switch.progress.installing": { + "ru": "Установка сборки «%1»", + "en": "Installing build \"%1\"" + }, + "switch.progress.preparing": { + "ru": "Подготовка сборки «%1»", + "en": "Preparing build \"%1\"" + }, + "switch.progress.restoring": { + "ru": "Восстановление сборки «%1»", + "en": "Restoring build \"%1\"" + }, + "switch.progress.saving": { + "ru": "Сохранение сборки «%1»", + "en": "Saving build \"%1\"" + }, + "switch.status.applyInterrupted": { + "ru": "Установка сборки%1 не завершилась — докатываем её файлы.", + "en": "Installing the build%1 did not finish — rolling its files forward." + }, + "switch.status.archivingCancelled": { + "ru": "Архивация отменена", + "en": "Archiving cancelled" + }, + "switch.status.extractCancelled": { + "ru": "Распаковка отменена", + "en": "Extraction cancelled" + }, + "switch.status.installCancelled": { + "ru": "Установка сборки отменена", + "en": "Build installation cancelled" + }, + "switch.status.nameSuffix": { + "ru": " «%1»", + "en": " \"%1\"" + }, + "switch.status.switchInterrupted": { + "ru": "Прошлая смена сборки%1 не завершилась — восстанавливаем содержимое .minecraft.", + "en": "The previous build switch%1 did not finish — restoring the contents of .minecraft." + }, + "switch.status.targetSuffix": { + "ru": " на «%1»", + "en": " to \"%1\"" + }, + "version.error.assetIndexReadFailed": { + "ru": "Не удалось прочитать индекс ресурсов %1", + "en": "Could not read asset index %1" + }, + "version.error.assetPrepareFailed": { + "ru": "Не удалось подготовить ресурс %1", + "en": "Could not prepare asset %1" + }, + "version.error.cancelPending": { + "ru": "Идёт отмена предыдущей загрузки — попробуйте ещё раз", + "en": "The previous download is still being cancelled — try again" + }, + "version.error.currentlyDownloading": { + "ru": "Эта версия сейчас скачивается", + "en": "This version is being downloaded right now" + }, + "version.error.descriptionChecksum": { + "ru": "Контрольная сумма описания версии «%1» не совпала", + "en": "The checksum of the description for version \"%1\" does not match" + }, + "version.error.descriptionFetchFailed": { + "ru": "Не удалось получить описание версии: %1", + "en": "Could not fetch the version description: %1" + }, + "version.error.descriptionUnparsable": { + "ru": "Описание версии «%1» не удалось разобрать", + "en": "The description for version \"%1\" could not be parsed" + }, + "version.error.downloadBusy": { + "ru": "Идёт загрузка версии «%1» — дождитесь окончания", + "en": "Version \"%1\" is downloading — wait for it to finish" + }, + "version.error.downloadFailed": { + "ru": "Не удалось загрузить версию «%1»: %2", + "en": "Could not download version \"%1\": %2" + }, + "version.error.fileCorrupt": { + "ru": "Повреждён файл версии %1: %2", + "en": "Version file %1 is corrupted: %2" + }, + "version.error.fileMissing": { + "ru": "Не найден файл версии: %1", + "en": "Version file not found: %1" + }, + "version.error.filesMissingAfterDownload": { + "ru": "После загрузки не хватает файлов (%1):\n%2", + "en": "Files are missing after the download (%1):\n%2" + }, + "version.error.inheritsCycle": { + "ru": "Циклическая зависимость inheritsFrom в версии «%1»", + "en": "Circular inheritsFrom dependency in version \"%1\"" + }, + "version.error.invalidId": { + "ru": "недопустимый идентификатор версии", + "en": "invalid version id" + }, + "version.error.invalidName": { + "ru": "Недопустимое имя версии «%1»", + "en": "Invalid version name \"%1\"" + }, + "version.error.libraryNoUrl": { + "ru": "Нет ссылки на библиотеку %1", + "en": "No link for library %1" + }, + "version.error.listUnavailable": { + "ru": "Список версий Mojang недоступен", + "en": "The Mojang version list is unavailable" + }, + "version.error.listUnreachable": { + "ru": "Список версий недоступен — проверьте соединение", + "en": "The version list is unavailable — check your connection" + }, + "version.error.manifestFetchFailed": { + "ru": "Не удалось обновить список версий: %1", + "en": "Could not update the version list: %1" + }, + "version.error.manifestUnparsable": { + "ru": "Манифест версий Mojang не удалось разобрать", + "en": "The Mojang version manifest could not be parsed" + }, + "version.error.noMainClass": { + "ru": "В файле версии «%1» не указан mainClass", + "en": "Version file \"%1\" does not specify a mainClass" + }, + "version.error.notInManifest": { + "ru": "Версия «%1» отсутствует в манифесте Mojang — установить её автоматически нельзя", + "en": "Version \"%1\" is not in the Mojang manifest — it cannot be installed automatically" + }, + "version.error.removeFailed": { + "ru": "Не удалось удалить версию: %1", + "en": "Could not remove the version: %1" + }, + "version.filter.alphas": { + "ru": "Альфы", + "en": "Alphas" + }, + "version.filter.betas": { + "ru": "Беты", + "en": "Betas" + }, + "version.filter.installedOnly": { + "ru": "Только установленные", + "en": "Installed only" + }, + "version.filter.other": { + "ru": "Прочие", + "en": "Other" + }, + "version.filter.releases": { + "ru": "Релизы", + "en": "Releases" + }, + "version.filter.snapshots": { + "ru": "Снапшоты", + "en": "Snapshots" + }, + "version.hint.remove": { + "ru": "Файлы версии «%1» будут удалены из versions/. Скачать её заново можно в любой момент.", + "en": "The files of version \"%1\" will be deleted from versions/. You can download it again at any time." + }, + "version.hint.removeWithSize": { + "ru": "Файлы версии «%1» будут удалены из versions/ — освободится около %2 МБ. Скачать её заново можно в любой момент.", + "en": "The files of version \"%1\" will be deleted from versions/ — about %2 MB will be freed. You can download it again at any time." + }, + "version.hint.sharedFilesStay": { + "ru": "Библиотеки и ресурсы в libraries/ и assets/ общие для всех версий и остаются на месте.", + "en": "Libraries and assets in libraries/ and assets/ are shared by all versions and stay where they are." + }, + "version.progress.assetIndex": { + "ru": "индекс ресурсов", + "en": "asset index" + }, + "version.progress.assets": { + "ru": "Ресурсы", + "en": "Assets" + }, + "version.progress.countOf": { + "ru": "%1 из %2", + "en": "%1 of %2" + }, + "version.progress.description": { + "ru": "Описание версии", + "en": "Version description" + }, + "version.progress.downloading": { + "ru": "Загрузка версии «%1»…", + "en": "Downloading version \"%1\"…" + }, + "version.progress.fetchingList": { + "ru": "Получение списка версий", + "en": "Fetching the version list" + }, + "version.progress.gameFiles": { + "ru": "Файлы игры", + "en": "Game files" + }, + "version.progress.loadingList": { + "ru": "Загрузка списка версий…", + "en": "Loading the version list…" + }, + "version.progress.preparing": { + "ru": "Подготовка", + "en": "Preparing" + }, + "version.progress.preparingAssets": { + "ru": "Подготовка ресурсов", + "en": "Preparing assets" + }, + "version.status.downloadCancelled": { + "ru": "Загрузка версии «%1» отменена", + "en": "Download of version \"%1\" cancelled" + }, + "version.status.downloaded": { + "ru": "Версия «%1» загружена", + "en": "Version \"%1\" downloaded" + }, + "version.status.notSelected": { + "ru": "Версия не выбрана", + "en": "No version selected" + }, + "version.status.removed": { + "ru": "Версия «%1» удалена", + "en": "Version \"%1\" removed" + }, + "version.title.removeConfirm": { + "ru": "Удалить версию?", + "en": "Remove the version?" + }, + "version.warning.buildsDepend": { + "ru": "Версию используют сборки: %1. Файлы понадобится скачать заново кнопкой «Установить».", + "en": "These builds use the version: %1. Their files will have to be downloaded again with the \"Install\" button." + }, + "version.warning.loaderProfilesDepend": { + "ru": "На эту версию опираются установленные профили модлоадеров: %1. Без неё они не запустятся — их придётся переустановить.", + "en": "Installed mod loader profiles rely on this version: %1. They will not start without it — you will have to reinstall them." + }, + "version.warning.skippedBadHash": { + "ru": "Пропущен ресурс %1: некорректный хэш", + "en": "Skipped asset %1: malformed hash" + }, + "version.warning.skippedBadUrl": { + "ru": "Пропущен %1: недопустимая ссылка", + "en": "Skipped %1: invalid link" + }, + "zlib.status.preloaded": { + "ru": "Системный zlib-ng подменён эталонным zlib: %1", + "en": "The system zlib-ng has been replaced with the reference zlib: %1" + }, + "zlib.warning.referenceMissing": { + "ru": "В системе zlib-ng, а эталонный libz.so.1 рядом с лаунчером не найден — установщик почти наверняка не сойдётся по sha1.", + "en": "The system provides zlib-ng and no reference libz.so.1 was found next to the launcher — the installer will almost certainly fail its sha1 check." + } + } +} diff --git a/javainstaller.cpp b/javainstaller.cpp index 85500f1..9de5fa5 100644 --- a/javainstaller.cpp +++ b/javainstaller.cpp @@ -1,4 +1,5 @@ #include "javainstaller.h" +#include "localization.h" #include "launcherpaths.h" @@ -114,21 +115,21 @@ void JavaInstaller::install(const JavaRuntimeEntry &entry) if (m_running) return; if (!entry.isValid()) { - emit failed(entry.version, tr("Сборка Java недоступна для загрузки")); + emit failed(entry.version, Loc::text("java.error.buildUnavailable")); return; } QString error; if (!LauncherPaths::ensureRootExists(&error)) { - emit failed(entry.version, tr("Папка лаунчера недоступна: %1").arg(error)); + emit failed(entry.version, Loc::text("java.error.launcherDirUnavailable").arg(error)); return; } m_entry = entry; - m_label = tr("%1 %2").arg(javaKindTitle(entry.kind), entry.version); + m_label = Loc::text("java.status.nameAndVersion").arg(javaKindTitle(entry.kind), entry.version); m_targetDir = JavaRuntimeStore::dirFor(entry.id); if (m_targetDir.isEmpty()) { - emit failed(m_label, tr("Недопустимый идентификатор сборки Java")); + emit failed(m_label, Loc::text("java.error.invalidBuildId")); return; } @@ -139,7 +140,7 @@ void JavaInstaller::install(const JavaRuntimeEntry &entry) return; } if (!QDir().mkpath(m_targetDir)) { - emit failed(m_label, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(m_targetDir))); + emit failed(m_label, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(m_targetDir))); return; } @@ -191,7 +192,7 @@ void JavaInstaller::beginArchive() { if (m_entry.archive != QLatin1String("zip") && m_entry.archive != QLatin1String("tar.gz")) { - fail(tr("Неизвестный формат архива: %1").arg(m_entry.archive)); + fail(Loc::text("java.error.unknownArchiveFormat").arg(m_entry.archive)); return; } @@ -199,7 +200,7 @@ void JavaInstaller::beginArchive() m_archivePath = LauncherPaths::javaDownloadDir() + u'/' + (name.isEmpty() ? m_entry.id + QStringLiteral(".archive") : name); m_bytesTotal = m_entry.size; - setStage(tr("Загрузка Java %1").arg(m_entry.version)); + setStage(Loc::text("java.progress.downloadingBuild").arg(m_entry.version)); downloadArchive(); } @@ -207,7 +208,7 @@ void JavaInstaller::downloadArchive() { m_archiveFile = std::make_unique(m_archivePath); if (!m_archiveFile->open(QIODevice::WriteOnly)) { - fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(m_archivePath))); + fail(Loc::text("common.error.openForWriteFailed").arg(QDir::toNativeSeparators(m_archivePath))); return; } m_archiveHash.reset(); @@ -260,7 +261,7 @@ void JavaInstaller::handleArchiveFinished(QNetworkReply *reply) if (reply->error() != QNetworkReply::NoError) { m_archiveFile->cancelWriting(); m_archiveFile.reset(); - fail(tr("Не удалось скачать Java %1: %2").arg(m_entry.version, reply->errorString())); + fail(Loc::text("java.error.downloadFailed").arg(m_entry.version, reply->errorString())); return; } @@ -269,14 +270,14 @@ void JavaInstaller::handleArchiveFinished(QNetworkReply *reply) if (actual.compare(m_entry.checksum, Qt::CaseInsensitive) != 0) { m_archiveFile->cancelWriting(); m_archiveFile.reset(); - fail(tr("Контрольная сумма архива Java не совпала — загрузка повреждена")); + fail(Loc::text("java.error.archiveChecksum")); return; } } if (!m_archiveFile->commit()) { m_archiveFile.reset(); - fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(m_archivePath))); + fail(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(m_archivePath))); return; } m_archiveFile.reset(); @@ -290,7 +291,7 @@ void JavaInstaller::startExtraction() // показывать мегабайты, а проценты продолжит. m_bytesTotal = 0; m_bytesSettled = 0; - setStage(tr("Распаковка Java %1").arg(m_entry.version)); + setStage(Loc::text("java.progress.extracting").arg(m_entry.version)); if (m_entry.archive == QLatin1String("tar.gz")) { startTarExtraction(); @@ -300,7 +301,7 @@ void JavaInstaller::startExtraction() m_zip = std::make_unique(m_archivePath); if (!m_zip->reader.isReadable()) { m_zip.reset(); - fail(tr("Не удалось прочитать архив %1").arg(QDir::toNativeSeparators(m_archivePath))); + fail(Loc::text("java.error.archiveReadFailed").arg(QDir::toNativeSeparators(m_archivePath))); return; } @@ -324,7 +325,7 @@ void JavaInstaller::extractZipChunk() const QString target = m_targetDir + u'/' + info.filePath; if (!isInside(m_targetDir, target)) { m_extractTimer->stop(); - fail(tr("Архив Java содержит недопустимый путь: %1").arg(info.filePath)); + fail(Loc::text("java.error.archiveBadPath").arg(info.filePath)); return; } @@ -338,7 +339,7 @@ void JavaInstaller::extractZipChunk() if (!QDir().mkpath(QFileInfo(target).absolutePath())) { m_extractTimer->stop(); - fail(tr("Не удалось создать %1") + fail(Loc::text("common.error.createFailed") .arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath()))); return; } @@ -347,7 +348,7 @@ void JavaInstaller::extractZipChunk() QFile file(target); if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) { m_extractTimer->stop(); - fail(tr("Не удалось распаковать %1").arg(info.filePath)); + fail(Loc::text("java.error.extractEntryFailed").arg(info.filePath)); return; } file.close(); @@ -389,7 +390,7 @@ void JavaInstaller::startTarExtraction() emit canceled(label); return; } - fail(tr("Не удалось распаковать архив Java: %1").arg(message)); + fail(Loc::text("java.error.extractFailed").arg(message)); }); connect(m_tar, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) { @@ -407,7 +408,7 @@ void JavaInstaller::startTarExtraction() return; } if (status != QProcess::NormalExit || exitCode != 0) { - fail(tr("Распаковка архива Java не удалась: %1") + fail(Loc::text("java.error.extractCrashed") .arg(QString::fromLocal8Bit(errorOutput).trimmed())); return; } @@ -421,7 +422,7 @@ void JavaInstaller::startTarExtraction() void JavaInstaller::beginMojang() { - setStage(tr("Список файлов Java %1").arg(m_entry.version)); + setStage(Loc::text("java.progress.fileList").arg(m_entry.version)); m_currentFile = QStringLiteral("manifest.json"); QNetworkRequest request{m_entry.url}; @@ -445,7 +446,7 @@ void JavaInstaller::beginMojang() if (!m_running) return; if (reply->error() != QNetworkReply::NoError) { - fail(tr("Не удалось получить состав рантайма: %1").arg(reply->errorString())); + fail(Loc::text("java.error.manifestFetchFailed").arg(reply->errorString())); return; } handleManifest(reply->readAll()); @@ -459,13 +460,13 @@ void JavaInstaller::handleManifest(const QByteArray &payload) .value(QStringLiteral("files")) .toObject(); if (files.isEmpty()) { - fail(tr("Состав рантайма Java не удалось разобрать")); + fail(Loc::text("java.error.manifestUnparsable")); return; } if (!prepareTree(files)) return; - setStage(tr("Загрузка Java %1").arg(m_entry.version)); + setStage(Loc::text("java.progress.downloadingBuild").arg(m_entry.version)); if (m_queue.isEmpty()) { if (applyLinks()) complete(); @@ -481,7 +482,7 @@ bool JavaInstaller::prepareTree(const QJsonObject &files) for (auto it = files.constBegin(); it != files.constEnd(); ++it) { const QString target = m_targetDir + u'/' + it.key(); if (!isInside(m_targetDir, target)) { - fail(tr("Состав рантайма содержит недопустимый путь: %1").arg(it.key())); + fail(Loc::text("java.error.manifestBadPath").arg(it.key())); return false; } @@ -490,7 +491,7 @@ bool JavaInstaller::prepareTree(const QJsonObject &files) if (type == QLatin1String("directory")) { if (!QDir().mkpath(target)) { - fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(target))); + fail(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(target))); return false; } continue; @@ -546,7 +547,7 @@ void JavaInstaller::startTask(JavaFileTask task) { const QFileInfo info(task.path); if (!QDir().mkpath(info.absolutePath())) { - fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath()))); + fail(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(info.absolutePath()))); return; } @@ -554,7 +555,7 @@ void JavaInstaller::startTask(JavaFileTask task) context->task = task; context->file = std::make_unique(task.path); if (!context->file->open(QIODevice::WriteOnly)) { - fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path))); + fail(Loc::text("common.error.openForWriteFailed").arg(QDir::toNativeSeparators(task.path))); return; } @@ -614,7 +615,7 @@ void JavaInstaller::handleFileFinished(const std::shared_ptr if (!retryOrFail(context->task, reason)) return; } else { - fail(tr("Не удалось скачать %1: %2") + fail(Loc::text("common.error.downloadFailed") .arg(QDir(m_targetDir).relativeFilePath(context->task.path), reason)); return; } @@ -626,7 +627,7 @@ void JavaInstaller::handleFileFinished(const std::shared_ptr const QString actual = QString::fromLatin1(context->hash.result().toHex()); if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) { context->file->cancelWriting(); - if (!retryOrFail(context->task, tr("контрольная сумма не совпала"))) + if (!retryOrFail(context->task, Loc::text("common.error.checksumMismatch"))) return; pump(); return; @@ -634,7 +635,7 @@ void JavaInstaller::handleFileFinished(const std::shared_ptr } if (!context->file->commit()) { - fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path))); + fail(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(context->task.path))); return; } if (context->task.executable) @@ -649,7 +650,7 @@ void JavaInstaller::handleFileFinished(const std::shared_ptr bool JavaInstaller::retryOrFail(JavaFileTask task, const QString &reason) { if (++task.attempts >= kMaxAttempts) { - fail(tr("Не удалось скачать %1: %2") + fail(Loc::text("common.error.downloadFailed") .arg(QDir(m_targetDir).relativeFilePath(task.path), reason)); return false; } @@ -673,7 +674,7 @@ bool JavaInstaller::applyLinks() const QString source = QDir::cleanPath(QFileInfo(linkPath).absolutePath() + u'/' + link.second); if (!QFile::copy(source, linkPath)) { - fail(tr("Не удалось создать ссылку %1") + fail(Loc::text("java.error.linkFailed") .arg(QDir(m_targetDir).relativeFilePath(linkPath))); return false; } @@ -686,11 +687,11 @@ bool JavaInstaller::applyLinks() void JavaInstaller::complete() { - setStage(tr("Проверка сборки Java")); + setStage(Loc::text("java.progress.verifying")); const QString javaPath = JavaRuntimeStore::locateBinary(m_targetDir); if (javaPath.isEmpty()) { - fail(tr("В скачанной сборке не нашлось исполняемого файла java")); + fail(Loc::text("java.error.noExecutable")); return; } #if !defined(Q_OS_WIN) diff --git a/javalocator.cpp b/javalocator.cpp index ffb4369..3146f8e 100644 --- a/javalocator.cpp +++ b/javalocator.cpp @@ -1,4 +1,5 @@ #include "javalocator.h" +#include "localization.h" #include "javaruntime.h" @@ -173,7 +174,7 @@ QString JavaLocator::select(const QString &gameDir, if (candidates.isEmpty()) { if (error) - *error = QObject::tr("Java по указанному пути не найдена: %1") + *error = Loc::text("java.error.notAtPath") .arg(QDir::toNativeSeparators(preferred)); return {}; } @@ -182,7 +183,7 @@ QString JavaLocator::select(const QString &gameDir, if (candidates.isEmpty()) { if (error) - *error = QObject::tr("Java не найдена. Установите JDK %1 или укажите путь в настройках.") + *error = Loc::text("java.error.notFound") .arg(requiredMajor); return {}; } @@ -201,12 +202,11 @@ QString JavaLocator::select(const QString &gameDir, if (error) { *error = bestBelowMajor > 0 - ? QObject::tr("Для этой версии нужна Java %1 или новее, найдена только Java %2 (%3).") + ? Loc::text("java.error.tooOld") .arg(requiredMajor) .arg(bestBelowMajor) .arg(QDir::toNativeSeparators(bestBelow)) - : QObject::tr("Не удалось определить версию установленной Java. " - "Укажите путь к JDK %1 в настройках.") + : Loc::text("java.error.versionUnknown") .arg(requiredMajor); } return {}; diff --git a/javaruntime.cpp b/javaruntime.cpp index f5d428c..adc66c2 100644 --- a/javaruntime.cpp +++ b/javaruntime.cpp @@ -1,8 +1,8 @@ #include "javaruntime.h" +#include "localization.h" #include "launcherpaths.h" -#include #include #include #include @@ -24,11 +24,6 @@ constexpr auto kExecutable = "java"; const QString kMetaFile = QStringLiteral("runtime.json"); -QString translate(const char *text) -{ - return QCoreApplication::translate("JavaRuntime", text); -} - } // namespace QString javaKindKey(JavaRuntimeKind kind) @@ -55,9 +50,9 @@ std::optional javaKindFromKey(const QString &key) QString javaKindTitle(JavaRuntimeKind kind) { switch (kind) { - case JavaRuntimeKind::Mojang: return translate("Java"); - case JavaRuntimeKind::Jdk: return translate("JDK"); - case JavaRuntimeKind::Jre: return translate("JRE"); + case JavaRuntimeKind::Mojang: return Loc::text("common.java.kind.java"); + case JavaRuntimeKind::Jdk: return Loc::text("common.java.kind.jdk"); + case JavaRuntimeKind::Jre: return Loc::text("common.java.kind.jre"); } return {}; } @@ -159,7 +154,7 @@ bool JavaRuntimeStore::writeMeta(const InstalledJavaRuntime &runtime, QString *e const QString dir = dirFor(runtime.id); if (dir.isEmpty()) { if (error) - *error = translate("недопустимый идентификатор сборки Java"); + *error = Loc::text("java.error.invalidRuntimeId"); return false; } @@ -174,14 +169,14 @@ bool JavaRuntimeStore::writeMeta(const InstalledJavaRuntime &runtime, QString *e QSaveFile file(dir + u'/' + kMetaFile); if (!file.open(QIODevice::WriteOnly)) { if (error) - *error = translate("не удалось записать %1").arg(QDir::toNativeSeparators(file.fileName())); + *error = Loc::text("java.error.writeFailed").arg(QDir::toNativeSeparators(file.fileName())); return false; } const QByteArray payload = QJsonDocument(object).toJson(QJsonDocument::Compact); if (file.write(payload) != payload.size() || !file.commit()) { file.cancelWriting(); if (error) - *error = translate("не удалось записать %1").arg(QDir::toNativeSeparators(file.fileName())); + *error = Loc::text("java.error.writeFailed").arg(QDir::toNativeSeparators(file.fileName())); return false; } return true; @@ -194,14 +189,14 @@ bool JavaRuntimeStore::remove(const QString &id, QString *error) // проверка стоит одной строки, а цена ошибки — чужая папка. if (dir.isEmpty() || !dir.startsWith(LauncherPaths::javaDir() + u'/')) { if (error) - *error = translate("недопустимый идентификатор сборки Java"); + *error = Loc::text("java.error.invalidRuntimeId"); return false; } if (!QDir(dir).exists()) return true; if (!QDir(dir).removeRecursively()) { if (error) - *error = translate("не удалось удалить %1").arg(QDir::toNativeSeparators(dir)); + *error = Loc::text("common.error.removeFailed").arg(QDir::toNativeSeparators(dir)); return false; } return true; @@ -257,14 +252,14 @@ int JavaRequirement::minimumFor(const QString &minecraftVersionId) QString JavaRequirement::coverage(int javaMajor) { if (javaMajor >= 21) - return translate("Minecraft 1.20.5 и новее"); + return Loc::text("java.range.modern"); if (javaMajor >= 17) - return translate("Minecraft 1.18 — 1.20.4"); + return Loc::text("java.range.mid"); if (javaMajor == 16) - return translate("Minecraft 1.17 — 1.17.1"); + return Loc::text("java.range.seventeen"); if (javaMajor >= 9) - return translate("для Minecraft не подходит"); + return Loc::text("java.range.unsuitable"); if (javaMajor > 0) - return translate("Minecraft 1.16.5 и старше"); + return Loc::text("java.range.legacy"); return {}; } diff --git a/javaruntimeservice.cpp b/javaruntimeservice.cpp index 5a78780..770814b 100644 --- a/javaruntimeservice.cpp +++ b/javaruntimeservice.cpp @@ -1,4 +1,5 @@ #include "javaruntimeservice.h" +#include "localization.h" #include "launcherpaths.h" @@ -242,7 +243,7 @@ void JavaRuntimeService::requestAdoptiumReleases() connect(reply, &QNetworkReply::finished, this, [this, reply] { reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { - requestFinished(tr("Не удалось получить список версий Java: %1") + requestFinished(Loc::text("java.error.versionsFetchFailed") .arg(reply->errorString())); return; } @@ -260,7 +261,7 @@ void JavaRuntimeService::requestAdoptiumReleases() majors << major; } if (majors.isEmpty()) { - requestFinished(tr("Список версий Java не удалось разобрать")); + requestFinished(Loc::text("java.error.versionsUnparsable")); return; } @@ -315,7 +316,7 @@ void JavaRuntimeService::requestFeatureRelease(int major, QString warning; if (reply->error() != QNetworkReply::NoError && status != 404) { - warning = tr("Не удалось получить сборки Java %1: %2") + warning = Loc::text("java.error.buildsFetchFailed") .arg(major) .arg(reply->errorString()); } @@ -330,7 +331,7 @@ void JavaRuntimeService::requestMojangRuntimes() connect(reply, &QNetworkReply::finished, this, [this, reply] { reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { - requestFinished(tr("Не удалось получить рантаймы Mojang: %1") + requestFinished(Loc::text("java.error.mojangRuntimesFailed") .arg(reply->errorString())); return; } @@ -370,7 +371,7 @@ void JavaRuntimeService::finishRefresh() for (const Callback &callback : pending) { // Сеть подвела, но старые данные есть — работаем на них. if (!ok && hasData()) - callback(true, warning.isEmpty() ? tr("Список сборок Java взят из кэша") : warning); + callback(true, warning.isEmpty() ? Loc::text("java.status.catalogFromCache") : warning); else callback(ok, warning); } diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 7a10439..0733818 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -1,5 +1,7 @@ #include "launcherbackend.h" +#include "localization.h" + #include "authservice.h" #include "gamelauncher.h" #include "javainstaller.h" @@ -92,7 +94,7 @@ LauncherBackend::LauncherBackend(QObject *parent) // следующем — если её удалили, она появится снова. QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) - m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError); + m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError); loadSettings(); // Порядок важен: миграция должна успеть до того, как ensureDataFiles() @@ -137,7 +139,7 @@ LauncherBackend::LauncherBackend(QObject *parent) connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) { emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Загрузка Java «%1»…").arg(label)); + emit launchProgress(Loc::text("java.progress.downloading").arg(label)); }); connect(m_javaInstaller, &JavaInstaller::progressChanged, this, &LauncherBackend::downloadChanged); @@ -147,7 +149,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit downloadChanged(); emit busyChanged(); emit javaRuntimeInstalled(runtimeId); - emit launchProgress(tr("Java установлена: %1") + emit launchProgress(Loc::text("java.status.installed") .arg(QDir::toNativeSeparators(javaPath))); if (m_season.stage == SeasonStage::Java) seasonAdvance(); @@ -157,7 +159,7 @@ LauncherBackend::LauncherBackend(QObject *parent) invalidateJavaCatalog(); emit downloadChanged(); emit busyChanged(); - emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message)); + emit launchError(Loc::text("java.error.installFailed").arg(label, message)); if (m_season.stage == SeasonStage::Java) seasonFail({}); }); @@ -165,7 +167,7 @@ LauncherBackend::LauncherBackend(QObject *parent) invalidateJavaCatalog(); emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label)); + emit launchProgress(Loc::text("java.status.downloadCancelled").arg(label)); if (m_season.stage == SeasonStage::Java) seasonFail({}); }); @@ -173,7 +175,7 @@ LauncherBackend::LauncherBackend(QObject *parent) connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) { emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Загрузка версии «%1»…").arg(id)); + emit launchProgress(Loc::text("version.progress.downloading").arg(id)); }); connect(m_installer, &VersionInstaller::progressChanged, this, &LauncherBackend::downloadChanged); @@ -184,7 +186,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Версия «%1» загружена").arg(id)); + emit launchProgress(Loc::text("version.status.downloaded").arg(id)); // У ванильной сезонной сборки этот установщик и есть весь шаг Loader. // Внутри установки модлоадера он тоже отчитывается — тогда ход // передаст ModLoaderInstaller, а не он. @@ -198,7 +200,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); - emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message)); + emit launchError(Loc::text("version.error.downloadFailed").arg(id, message)); if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) seasonFail({}); }); @@ -217,7 +219,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit switchChanged(); emit busyChanged(); const int index = indexOfBuildId(toBuildId); - emit launchProgress(tr("Активна сборка «%1»") + emit launchProgress(Loc::text("build.status.active") .arg(index >= 0 ? m_builds[index].name : QString())); // Раскатка пака могла быть и той, что доигралась после перезапуска — @@ -243,15 +245,13 @@ LauncherBackend::LauncherBackend(QObject *parent) // раскатка означает лишь наполовину обновлённую сборку. if (m_season.stage == SeasonStage::Applying) { emit launchError(gameDirIntact - ? tr("Файлы сборки не установлены: %1").arg(message) - : tr("Файлы сборки установлены не полностью: %1\n" - "Повторите установку сборки.") + ? Loc::text("build.error.filesNotInstalled").arg(message) + : Loc::text("build.error.filesIncomplete") .arg(message)); } else { emit launchError(gameDirIntact - ? tr("Сборка не переключена: %1").arg(message) - : tr("Сборка не переключена: %1\nСодержимое .minecraft " - "осталось разобранным — повторите переключение.") + ? Loc::text("build.error.switchFailed").arg(message) + : Loc::text("build.error.switchFailedDirty") .arg(message)); } if (m_season.stage == SeasonStage::Switching @@ -270,7 +270,7 @@ LauncherBackend::LauncherBackend(QObject *parent) connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) { emit busyChanged(); emit downloadChanged(); - emit launchProgress(tr("Установка %1…").arg(label)); + emit launchProgress(Loc::text("loader.progress.installingShort").arg(label)); }); connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged, this, &LauncherBackend::downloadChanged); @@ -290,7 +290,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); - emit launchProgress(tr("Готово: %1 для Minecraft %2") + emit launchProgress(Loc::text("loader.status.done") .arg(producedVersionId, gameVersion)); if (m_season.stage == SeasonStage::Loader) seasonAdvance(); @@ -302,7 +302,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); - emit launchError(tr("%1: %2").arg(label, message)); + emit launchError(Loc::text("common.status.pair").arg(label, message)); if (m_season.stage == SeasonStage::Loader) seasonFail({}); }); @@ -312,7 +312,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); - emit launchProgress(tr("Установка %1 отменена").arg(label)); + emit launchProgress(Loc::text("loader.status.installCancelled").arg(label)); if (m_season.stage == SeasonStage::Loader) seasonFail({}); }); @@ -322,7 +322,7 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id)); + emit launchProgress(Loc::text("version.status.downloadCancelled").arg(id)); if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) seasonFail({}); }); @@ -340,7 +340,7 @@ LauncherBackend::LauncherBackend(QObject *parent) connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) { emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Загрузка файлов сборки «%1»…").arg(label)); + emit launchProgress(Loc::text("seasonal.progress.downloadingBuild").arg(label)); }); connect(m_packDownloader, &SeasonalPackDownloader::progressChanged, this, &LauncherBackend::downloadChanged); @@ -356,14 +356,14 @@ LauncherBackend::LauncherBackend(QObject *parent) [this](const QString &label, const QString &message) { emit downloadChanged(); emit busyChanged(); - emit launchError(tr("Сборка «%1»: %2").arg(label, message)); + emit launchError(Loc::text("build.status.named").arg(label, message)); if (m_season.stage == SeasonStage::Downloading) seasonFail({}); }); connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) { emit downloadChanged(); emit busyChanged(); - emit launchProgress(tr("Загрузка сборки «%1» отменена").arg(label)); + emit launchProgress(Loc::text("seasonal.status.downloadCancelled").arg(label)); if (m_season.stage == SeasonStage::Downloading) seasonFail({}); }); @@ -577,11 +577,11 @@ void LauncherBackend::removeCustomBuild(int index) if (m_switcher->isRunning() || m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { - emit launchError(tr("Дождитесь окончания текущей операции")); + emit launchError(Loc::text("common.error.busyOperation")); return; } if (m_launcher->isRunning()) { - emit launchError(tr("Сначала закройте игру")); + emit launchError(Loc::text("common.error.closeGameFirst")); return; } @@ -593,7 +593,7 @@ void LauncherBackend::removeCustomBuild(int index) // Раньше удалялась только запись, а архив сборки оставался лежать в // galeonLauncher/builds вместе со ссылкой на него в index.json. if (!m_switcher->forgetBuild(removed.id)) { - emit launchError(tr("Сборка «%1» удалена, но её архив в %2 стереть не удалось") + emit launchError(Loc::text("build.warning.archiveNotRemoved") .arg(removed.name, QDir::toNativeSeparators(LauncherPaths::buildStorageDir()))); } @@ -616,7 +616,7 @@ void LauncherBackend::removeCustomBuild(int index) } const CustomBuild &next = m_builds.first(); - emit launchProgress(tr("Активна сборка «%1»…").arg(next.name)); + emit launchProgress(Loc::text("build.progress.activating").arg(next.name)); // from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе // с ней. Дальше обычная очистка и распаковка новой активной сборки. m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir()); @@ -649,22 +649,22 @@ void LauncherBackend::setActiveBuildIndex(int index) return; if (m_switcher->isRunning()) { - emit launchError(tr("Смена сборки уже выполняется")); + emit launchError(Loc::text("common.error.switchInProgress")); return; } if (m_launcher->isRunning()) { - emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся")); + emit launchError(Loc::text("build.error.closeGameFilesMoving")); return; } if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { - emit launchError(tr("Дождитесь окончания текущей операции")); + emit launchError(Loc::text("common.error.busyOperation")); return; } // Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в // её архив, и только потом на его место разворачивается выбранная. const CustomBuild &target = m_builds[index]; - emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name)); + emit launchProgress(Loc::text("build.progress.switchingTo").arg(target.name)); m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1, current >= 0 ? m_builds[current].name : QString(), target.id, @@ -700,19 +700,19 @@ QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const QStringList LauncherBackend::checkInstallation(int buildIndex) const { if (buildIndex < 0 || buildIndex >= m_builds.size()) - return {tr("Сборка не выбрана")}; + return {Loc::text("common.status.noBuildSelected")}; const CustomBuild &entry = m_builds[buildIndex]; const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) - return {tr("У сборки не выбрана версия Minecraft. Проверьте %1") + return {Loc::text("build.error.noMinecraftVersion") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))}; QString error; const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error); if (!version.isValid()) { return {error.isEmpty() - ? tr("Версия «%1» из сборки «%2» не установлена в %3") + ? Loc::text("build.error.versionNotInstalled") .arg(id, entry.name, QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions"))) @@ -786,30 +786,30 @@ void LauncherBackend::removeVersion(const QString &versionId) return; if (m_installer->versionId() == id || m_installer->isQueued(id)) { - emit launchError(tr("Эта версия сейчас скачивается")); + emit launchError(Loc::text("version.error.currentlyDownloading")); return; } if (m_switcher->isRunning() || m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { - emit launchError(tr("Дождитесь окончания текущей операции")); + emit launchError(Loc::text("common.error.busyOperation")); return; } // Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на // время игры удаление запрещено целиком. if (m_launcher->isRunning()) { - emit launchError(tr("Сначала закройте игру")); + emit launchError(Loc::text("common.error.closeGameFirst")); return; } QString error; if (!VersionLoader::remove(gameDir(), id, &error)) { - emit launchError(tr("Не удалось удалить версию: %1").arg(error)); + emit launchError(Loc::text("version.error.removeFailed").arg(error)); return; } invalidateCatalog(); emit installedVersionsChanged(); - emit launchProgress(tr("Версия «%1» удалена").arg(id)); + emit launchProgress(Loc::text("version.status.removed").arg(id)); } QVariantList LauncherBackend::versionCatalog() const @@ -1009,9 +1009,9 @@ QVariantList LauncherBackend::loaderVersions(const QString &loaderKey, for (const LoaderVersionEntry &entry : entries) { QString label = entry.loaderVersion; if (entry.recommended) - label += tr(" — рекомендуемая"); + label += Loc::text("loader.label.recommended"); else if (!entry.stable) - label += tr(" — нестабильная"); + label += Loc::text("loader.label.unstable"); result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion}, {QStringLiteral("label"), label}, {QStringLiteral("recommended"), entry.recommended}, @@ -1048,13 +1048,13 @@ void LauncherBackend::installLoaderForBuild(int index) if (index < 0 || index >= m_builds.size()) return; if (busy()) { - emit launchError(tr("Дождитесь окончания текущей загрузки")); + emit launchError(Loc::text("common.error.busyDownload")); return; } const CustomBuild &build = m_builds[index]; if (build.minecraftVersion.trimmed().isEmpty()) { - emit launchError(tr("Сначала выберите версию Minecraft для сборки «%1»").arg(build.name)); + emit launchError(Loc::text("build.error.pickMinecraftVersion").arg(build.name)); return; } @@ -1067,7 +1067,7 @@ void LauncherBackend::installLoaderForBuild(int index) return; } if (build.loaderVersion.trimmed().isEmpty()) { - emit launchError(tr("Выберите версию %1 для сборки «%2»") + emit launchError(Loc::text("build.error.pickLoaderVersion") .arg(loaderTitle(*loader), build.name)); return; } @@ -1085,36 +1085,36 @@ void LauncherBackend::installLoaderForBuild(int index) void LauncherBackend::launchGame(int profileIndex, int buildIndex) { if (m_loaderInstaller->isRunning()) { - emit launchError(tr("Идёт установка «%1» — дождитесь окончания") + emit launchError(Loc::text("build.error.installBusy") .arg(m_loaderInstaller->label())); return; } if (m_installer->isRunning()) { - emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания") + emit launchError(Loc::text("version.error.downloadBusy") .arg(m_installer->versionId())); return; } // Игра ищет java при запуске: стартовать посреди её установки — значит // найти половину распакованного дерева. if (m_javaInstaller->isRunning()) { - emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания") + emit launchError(Loc::text("java.error.installBusy") .arg(m_javaInstaller->label())); return; } if (m_busy) { - emit launchError(tr("Запуск уже выполняется")); + emit launchError(Loc::text("launch.error.alreadyLaunching")); return; } if (m_launcher->isRunning()) { - emit launchError(tr("Игра уже запущена")); + emit launchError(Loc::text("common.error.gameRunning")); return; } if (profileIndex < 0 || profileIndex >= m_profiles.size()) { - emit launchError(tr("Выберите профиль перед запуском")); + emit launchError(Loc::text("launch.error.noProfile")); return; } if (buildIndex < 0 || buildIndex >= m_builds.size()) { - emit launchError(tr("Выберите сборку перед запуском")); + emit launchError(Loc::text("launch.error.noBuild")); return; } @@ -1122,7 +1122,7 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) // в galeonLauncher при запуске. const QString directory = gameDir(); if (!QDir(directory).exists()) { - emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.") + emit launchError(Loc::text("launch.error.gameDirMissing") .arg(QDir::toNativeSeparators(directory))); return; } @@ -1130,7 +1130,7 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) const CustomBuild &entry = m_builds[buildIndex]; const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) { - emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2") + emit launchError(Loc::text("launch.error.buildNoVersion") .arg(entry.name, QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))); return; @@ -1140,8 +1140,8 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) const QStringList installed = VersionLoader::installedVersions(directory); if (!installed.contains(id)) { emit launchError(installed.isEmpty() - ? tr("В %1 нет ни одной установленной версии").arg(versionsDir) - : tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4") + ? Loc::text("launch.error.noVersionsInstalled").arg(versionsDir) + : Loc::text("launch.error.versionNotInstalled") .arg(id, entry.name, versionsDir, @@ -1150,7 +1150,7 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) } setBusy(true); - emit launchProgress(tr("Чтение описания версии «%1»…").arg(id)); + emit launchProgress(Loc::text("launch.progress.readingVersion").arg(id)); QSet features; if (settingInt(m_settings, QStringLiteral("windowWidth"), 0) > 0 @@ -1160,7 +1160,7 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) QString error; m_pendingVersionData = VersionLoader::load(directory, id, features, &error); if (!m_pendingVersionData.isValid()) { - failLaunch(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(id) : error); + failLaunch(error.isEmpty() ? Loc::text("common.error.versionReadFailed").arg(id) : error); return; } @@ -1169,7 +1169,7 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) probe.versionId = id; const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData); if (!missing.isEmpty()) { - failLaunch(tr("В папке игры не хватает файлов (%1):\n%2") + failLaunch(Loc::text("launch.error.gameFilesMissing") .arg(missing.size()) .arg(missing.join(u'\n'))); return; @@ -1186,14 +1186,13 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) void LauncherBackend::startMicrosoftLogin(int profileIndex) { if (!microsoftAvailable()) { - emit microsoftLoginFailed(tr("Эта сборка лаунчера собрана без Qt WebEngine — " - "показать окно входа Microsoft нечем")); + emit microsoftLoginFailed(Loc::text("auth.msa.error.noWebEngine")); return; } // -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать // это состояние от закрытого окна. if (profileIndex >= m_profiles.size()) { - emit microsoftLoginFailed(tr("Профиль не найден")); + emit microsoftLoginFailed(Loc::text("profile.error.notFound")); return; } m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex; @@ -1218,7 +1217,7 @@ QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const void LauncherBackend::finishMicrosoftLogin(const QString &code) { if (m_msaLoginProfile == -1) { - emit microsoftLoginFailed(tr("Вход в Microsoft уже отменён")); + emit microsoftLoginFailed(Loc::text("auth.msa.status.alreadyCancelled")); return; } @@ -1249,7 +1248,7 @@ void LauncherBackend::finishMicrosoftLogin(const QString &code) profile.authType = kMicrosoft; applyMicrosoftResult(profile, result); } else { - emit microsoftLoginFailed(tr("Профиль был удалён, пока шёл вход")); + emit microsoftLoginFailed(Loc::text("profile.error.removedDuringLogin")); return; } @@ -1283,7 +1282,7 @@ void LauncherBackend::beginMicrosoftAuthentication() if (profile.refreshToken.isEmpty()) { const int index = m_pendingProfile; - failLaunch(tr("Профиль «%1» не подключён к аккаунту Microsoft — войдите в него") + failLaunch(Loc::text("auth.msa.error.profileNeedsLogin") .arg(profile.name)); emit microsoftReloginRequired(index); return; @@ -1316,7 +1315,7 @@ void LauncherBackend::beginMicrosoftAuthentication() } if (!result.ok) { const int index = m_pendingProfile; - failLaunch(tr("Сессия Microsoft истекла — войдите в аккаунт заново (%1)") + failLaunch(Loc::text("auth.msa.error.sessionExpired") .arg(result.error)); emit microsoftReloginRequired(index); return; @@ -1382,11 +1381,11 @@ void LauncherBackend::beginAuthentication() void LauncherBackend::submitTwoFactorCode(const QString &code) { if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) { - emit launchError(tr("Запуск уже отменён")); + emit launchError(Loc::text("launch.status.alreadyCancelled")); return; } if (code.trimmed().isEmpty()) { - emit launchError(tr("Введите код двухфакторной аутентификации")); + emit launchError(Loc::text("auth.ely.error.enterCode")); return; } @@ -1418,13 +1417,13 @@ void LauncherBackend::cancelPendingLaunch() void LauncherBackend::continueLaunch(const AuthResult &auth) { if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) { - failLaunch(tr("Запуск был отменён")); + failLaunch(Loc::text("launch.status.cancelled")); return; } const int buildIndex = indexOfBuildId(m_pendingBuild); if (buildIndex < 0) { - failLaunch(tr("Сборка была удалена, пока шла авторизация")); + failLaunch(Loc::text("build.error.removedDuringAuth")); return; } @@ -1464,8 +1463,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) options.javaPath = managedJavaPath(); if (options.javaPath.isEmpty()) { if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty()) - emit launchProgress(tr("Выбранная сборка Java не найдена в папке лаунчера — " - "ищем установленную в системе")); + emit launchProgress(Loc::text("java.warning.runtimeMissing")); options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString(); } options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512); @@ -1573,10 +1571,20 @@ QVariantMap LauncherBackend::settings() const void LauncherBackend::updateSettings(const QVariantMap &values) { const QString previousGameDir = gameDir(); + const QString previousLanguage = m_settings.value(QStringLiteral("language")).toString(); + for (auto it = values.cbegin(); it != values.cend(); ++it) m_settings.insert(it.key(), it.value()); saveSettings(); emit settingsChanged(); + + // Язык переключается после записи: порядок «сохранили → переключили» + // гарантирует, что выбор переживёт падение сразу после переключения. + // Сам Localization в settings.json не пишет — файл ведёт только бэкенд. + const QString language = m_settings.value(QStringLiteral("language")).toString(); + if (language != previousLanguage) + Localization::instance().setLanguage(language); + if (gameDir() != previousGameDir) { invalidateCatalog(); emit installedVersionsChanged(); @@ -1673,9 +1681,9 @@ void LauncherBackend::rebuildJavaCatalog() const listed.insert(runtime.id); const auto remote = m_javaMeta->find(runtime.id); const QString detail = remote ? (remote->kind == JavaRuntimeKind::Mojang - ? tr("Mojang · %1").arg(remote->component) - : tr("Temurin · %1").arg(remote->architecture)) - : tr("скачана лаунчером"); + ? Loc::text("java.label.mojang").arg(remote->component) + : Loc::text("java.label.temurin").arg(remote->architecture)) + : Loc::text("java.label.downloadedByLauncher"); m_javaCatalog.append(makeRow(runtime.id, runtime.kind, runtime.major, @@ -1695,8 +1703,8 @@ void LauncherBackend::rebuildJavaCatalog() const if (listed.contains(entry.id)) continue; const QString detail = kind == JavaRuntimeKind::Mojang - ? tr("Mojang · %1").arg(entry.component) - : tr("Temurin · %1").arg(entry.architecture); + ? Loc::text("java.label.mojang").arg(entry.component) + : Loc::text("java.label.temurin").arg(entry.architecture); m_javaCatalog.append(makeRow(entry.id, javaKindKey(kind), entry.major, @@ -1713,18 +1721,18 @@ void LauncherBackend::rebuildJavaCatalog() const void LauncherBackend::installJavaRuntime(const QString &runtimeId) { if (m_javaInstaller->isRunning()) { - emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания") + emit launchError(Loc::text("java.error.installBusy") .arg(m_javaInstaller->label())); return; } if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) { - emit launchError(tr("Дождитесь окончания текущей операции")); + emit launchError(Loc::text("common.error.busyOperation")); return; } const auto entry = m_javaMeta->find(runtimeId); if (!entry) { - emit launchError(tr("Сборка Java «%1» не найдена в каталоге").arg(runtimeId)); + emit launchError(Loc::text("java.error.buildNotInCatalog").arg(runtimeId)); return; } m_javaInstaller->install(*entry); @@ -1733,13 +1741,13 @@ void LauncherBackend::installJavaRuntime(const QString &runtimeId) void LauncherBackend::removeJavaRuntime(const QString &runtimeId) { if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) { - emit launchError(tr("Эта сборка Java сейчас скачивается")); + emit launchError(Loc::text("java.error.currentlyDownloading")); return; } QString error; if (!JavaRuntimeStore::remove(runtimeId, &error)) { - emit launchError(tr("Не удалось удалить сборку Java: %1").arg(error)); + emit launchError(Loc::text("java.error.removeFailed").arg(error)); return; } @@ -1750,7 +1758,7 @@ void LauncherBackend::removeJavaRuntime(const QString &runtimeId) emit settingsChanged(); } invalidateJavaCatalog(); - emit launchProgress(tr("Сборка Java удалена")); + emit launchProgress(Loc::text("java.status.removed")); } QVariantMap LauncherBackend::javaRuntimeInfo(const QString &runtimeId) const @@ -1851,11 +1859,11 @@ void LauncherBackend::rebuildSeasonalCatalog() const QString status; if (!installed) - status = tr("Не установлена"); + status = Loc::text("seasonal.status.notInstalled"); else if (updateAvailable) - status = tr("Есть обновление"); + status = Loc::text("seasonal.status.updateAvailable"); else - status = tr("Установлена"); + status = Loc::text("seasonal.status.installed"); const auto loader = loaderFromKey(entry.loader); m_seasonalCatalog.append(QVariantMap{ @@ -1863,7 +1871,7 @@ void LauncherBackend::rebuildSeasonalCatalog() const {QStringLiteral("name"), entry.name}, {QStringLiteral("minecraftVersion"), entry.minecraftVersion}, {QStringLiteral("loader"), entry.loader}, - {QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : tr("Ванильная")}, + {QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : Loc::text("loader.label.vanilla")}, {QStringLiteral("loaderVersion"), entry.loaderVersion}, {QStringLiteral("modCount"), entry.modCount}, {QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)}, @@ -1914,21 +1922,21 @@ void LauncherBackend::refreshSeasonalCatalog(bool force) void LauncherBackend::installSeasonalBuild(const QString &seasonalId) { if (m_season.stage != SeasonStage::Idle) { - emit launchError(tr("Установка сборки уже идёт")); + emit launchError(Loc::text("seasonal.error.installInProgress")); return; } if (m_launcher->isRunning()) { - emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся")); + emit launchError(Loc::text("build.error.closeGameFilesMoving")); return; } if (busy()) { - emit launchError(tr("Дождитесь окончания текущей операции")); + emit launchError(Loc::text("common.error.busyOperation")); return; } const auto entry = m_seasonalMeta->find(seasonalId); if (!entry) { - emit launchError(tr("Сборка «%1» не найдена в списке — обновите его").arg(seasonalId)); + emit launchError(Loc::text("seasonal.error.notInCatalog").arg(seasonalId)); return; } @@ -1966,7 +1974,7 @@ void LauncherBackend::installSeasonalBuild(const QString &seasonalId) m_season.buildId = m_builds[index].id; setBusy(true); - emit launchProgress(tr("Установка сборки «%1»…").arg(entry->name)); + emit launchProgress(Loc::text("seasonal.progress.installing").arg(entry->name)); // Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его // вызова seasonalInstalling() ещё вернул бы false. seasonAdvance(); @@ -1976,7 +1984,7 @@ void LauncherBackend::installSeasonalBuild(const QString &seasonalId) void LauncherBackend::seasonAdvance() { if (indexOfBuildId(m_season.buildId) < 0) { - seasonFail(tr("запись сборки исчезла")); + seasonFail(Loc::text("seasonal.error.entryGone")); return; } @@ -2021,7 +2029,7 @@ bool LauncherBackend::seasonBeginSwitch() // должна стать активной до всего остального. Содержимое прежней уезжает в // её архив обычным путём. m_season.stage = SeasonStage::Switching; - emit launchProgress(tr("Смена сборки на «%1»…").arg(m_builds[index].name)); + emit launchProgress(Loc::text("build.progress.switchingTo").arg(m_builds[index].name)); m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1, current >= 0 ? m_builds[current].name : QString(), m_builds[index].id, @@ -2047,19 +2055,19 @@ bool LauncherBackend::seasonBeginJava() return false; m_season.stage = SeasonStage::Java; - emit launchProgress(tr("Для сборки нужна Java %1 — устанавливаем").arg(major)); + emit launchProgress(Loc::text("seasonal.progress.installingJava").arg(major)); // Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать. m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) { if (m_season.stage != SeasonStage::Java) return; if (!ok) { - seasonFail(warning.isEmpty() ? tr("не удалось получить каталог Java") : warning); + seasonFail(warning.isEmpty() ? Loc::text("seasonal.error.javaCatalogFailed") : warning); return; } const auto entry = m_javaMeta->bestFor(major); if (!entry) { - seasonFail(tr("в каталоге нет сборки Java %1 для этой системы").arg(major)); + seasonFail(Loc::text("seasonal.error.noJavaBuild").arg(major)); return; } m_javaInstaller->install(*entry); @@ -2119,7 +2127,7 @@ bool LauncherBackend::seasonBeginDownload() bool LauncherBackend::seasonBeginApply() { if (m_season.packPath.isEmpty()) { - seasonFail(tr("файлы сборки не скачаны")); + seasonFail(Loc::text("seasonal.error.filesNotDownloaded")); return true; } @@ -2157,7 +2165,7 @@ void LauncherBackend::seasonFinish() setBusy(false); invalidateSeasonalCatalog(); emit seasonalInstallingChanged(); - emit launchProgress(tr("Сборка «%1» установлена").arg(name)); + emit launchProgress(Loc::text("seasonal.status.buildInstalled").arg(name)); emit seasonalInstallFinished(seasonalId, name); } @@ -2176,8 +2184,8 @@ void LauncherBackend::seasonFail(const QString &message) // Пустое message означает, что о причине уже сообщил тот, кто её нашёл, — // второй раз ту же ошибку не показываем. emit launchError(message.isEmpty() - ? tr("Установка сборки «%1» прервана").arg(name) - : tr("Установка сборки «%1» прервана: %2").arg(name, message)); + ? Loc::text("seasonal.error.installAborted").arg(name) + : Loc::text("seasonal.error.installAbortedWhy").arg(name, message)); } void LauncherBackend::cancelSeasonalInstall() @@ -2201,7 +2209,7 @@ void LauncherBackend::cancelSeasonalInstall() case SeasonStage::Applying: // На этих шагах содержимое .minecraft уже разобрано: отступать некуда, // операцию нужно довести до конца. - emit launchError(tr("Сейчас переносятся файлы — отменить нельзя")); + emit launchError(Loc::text("switch.error.cannotCancel")); return; } } @@ -2267,7 +2275,7 @@ void LauncherBackend::openMinecraftFolder() QDir().mkpath(modsDir); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir))) - emit launchError(tr("Не удалось открыть папку с модами")); + emit launchError(Loc::text("launch.error.openModsFailed")); } void LauncherBackend::openGameFolder() @@ -2276,7 +2284,7 @@ void LauncherBackend::openGameFolder() QDir().mkpath(directory); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory))) - emit launchError(tr("Не удалось открыть папку игры")); + emit launchError(Loc::text("launch.error.openGameDirFailed")); } // Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти @@ -2286,12 +2294,12 @@ bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason const QString backup = path + QStringLiteral(".bak"); QFile::remove(backup); if (!QFile::rename(path, backup)) { - m_storageIssues << tr("Файл %1 повреждён (%2), и его не удалось отложить в сторону") + m_storageIssues << Loc::text("storage.error.corruptNotSetAside") .arg(QDir::toNativeSeparators(path), reason); return false; } - m_storageIssues << tr("Файл %1 повреждён (%2). Копия сохранена как %3, файл создан заново.") + m_storageIssues << Loc::text("storage.warning.corruptRecreated") .arg(QDir::toNativeSeparators(path), reason, QDir::toNativeSeparators(backup)); @@ -2304,7 +2312,7 @@ QJsonArray LauncherBackend::readArrayFile(const QString &path) if (!file.exists()) return {}; // первый запуск — это не ошибка if (!file.open(QIODevice::ReadOnly)) { - m_storageIssues << tr("Не удалось прочитать %1: %2") + m_storageIssues << Loc::text("common.error.readFailed") .arg(QDir::toNativeSeparators(path), file.errorString()); return {}; } @@ -2318,7 +2326,7 @@ QJsonArray LauncherBackend::readArrayFile(const QString &path) } if (!document.isArray()) { file.close(); - moveAsideBroken(path, tr("ожидался список записей")); + moveAsideBroken(path, Loc::text("storage.error.expectedArray")); return {}; } return document.array(); @@ -2330,7 +2338,7 @@ QJsonObject LauncherBackend::readObjectFile(const QString &path) if (!file.exists()) return {}; if (!file.open(QIODevice::ReadOnly)) { - m_storageIssues << tr("Не удалось прочитать %1: %2") + m_storageIssues << Loc::text("common.error.readFailed") .arg(QDir::toNativeSeparators(path), file.errorString()); return {}; } @@ -2344,7 +2352,7 @@ QJsonObject LauncherBackend::readObjectFile(const QString &path) } if (!document.isObject()) { file.close(); - moveAsideBroken(path, tr("ожидался набор настроек")); + moveAsideBroken(path, Loc::text("storage.error.expectedObject")); return {}; } return document.object(); @@ -2356,14 +2364,14 @@ bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &do { QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) { - emit launchError(tr("Не удалось создать папку лаунчера: %1").arg(storageError)); + emit launchError(Loc::text("storage.error.launcherDirFailed").arg(storageError)); return false; } const QByteArray payload = document.toJson(); QFile file(path); if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) { - emit launchError(tr("Не удалось сохранить %1: %2") + emit launchError(Loc::text("storage.error.saveFailedWhy") .arg(QDir::toNativeSeparators(path), file.errorString())); return false; } @@ -2429,7 +2437,7 @@ void LauncherBackend::loadData() m_nextBuildId = build.id + 1; }); if (skippedBuilds > 0) { - m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2") + m_storageIssues << Loc::text("storage.warning.buildsSkipped") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile())) .arg(skippedBuilds); } @@ -2472,7 +2480,7 @@ bool LauncherBackend::migrateLegacyBuildsFile() const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated"); QFile::remove(retired); if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) { - m_storageIssues << tr("Сборки перенесены в %1, но старый файл %2 удалить не удалось") + m_storageIssues << Loc::text("storage.warning.migrationLeftover") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()), QDir::toNativeSeparators( LauncherPaths::legacyCustomBuildsFile())); @@ -2542,6 +2550,8 @@ void LauncherBackend::loadSettings() {QStringLiteral("windowWidth"), 0}, {QStringLiteral("windowHeight"), 0}, {QStringLiteral("fullscreen"), false}, + // Язык интерфейса: "system" — по системной локали. + {QStringLiteral("language"), QStringLiteral("system")}, {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, {QStringLiteral("activeBuildId"), 0}, diff --git a/launcherpaths.cpp b/launcherpaths.cpp index cd2b1ef..74c75c4 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -1,6 +1,6 @@ #include "launcherpaths.h" +#include "localization.h" -#include #include #include @@ -8,11 +8,6 @@ namespace { const QString kFolderName = QStringLiteral("galeonLauncher"); -QString translate(const char *text) -{ - return QCoreApplication::translate("LauncherPaths", text); -} - } // namespace QString LauncherPaths::containerDir() @@ -129,7 +124,7 @@ bool LauncherPaths::ensureRootExists(QString *error) for (const QString &path : required) { if (!QDir().mkpath(path)) { if (error) - *error = translate("не удалось создать %1").arg(QDir::toNativeSeparators(path)); + *error = Loc::text("storage.error.createFailed").arg(QDir::toNativeSeparators(path)); return false; } @@ -138,7 +133,7 @@ bool LauncherPaths::ensureRootExists(QString *error) const QFileInfo info(path); if (!info.isDir() || !info.isWritable()) { if (error) - *error = translate("нет доступа на запись в %1").arg(QDir::toNativeSeparators(path)); + *error = Loc::text("storage.error.notWritable").arg(QDir::toNativeSeparators(path)); return false; } } diff --git a/localization.cpp b/localization.cpp new file mode 100644 index 0000000..36c94ea --- /dev/null +++ b/localization.cpp @@ -0,0 +1,291 @@ +#include "localization.h" + +#include "launcherpaths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// ── Про потоки ────────────────────────────────────────────────────────────── +// +// Каталог (m_singles, m_lists, m_codes, m_displayNames) заполняется ровно один +// раз в load(), который отрабатывает в main() до того, как BuildSwitcher создаст +// свой QThread. Дальше эти поля только читаются, а копирование QString из хэша +// безопасно само по себе — поэтому Loc::text() зовётся из рабочего потока +// BuildArchiveWorker без единой блокировки. +// +// Единственное, что меняется на ходу, — m_index, и он QAtomicInt. В худшем +// случае сообщение, уже собиравшееся в момент переключения, уедет на прежнем +// языке; заметить это невозможно. +// +// setLanguage(), m_tree, rebuildTree() и languageChanged — только GUI-поток. + +namespace { + +const QString kCatalogPath = + QStringLiteral(":/qt/qml/Minecraft_launcher/i18n/translations.json"); + +const QString kSystem = QStringLiteral("system"); + +// Язык по системной локали. Промежуточных вариантов нет: языков в каталоге два. +QString systemLanguage() +{ + return QLocale::system().language() == QLocale::Russian ? QStringLiteral("ru") + : QStringLiteral("en"); +} + +// Язык из settings.json. Читается напрямую, не через LauncherBackend: тот +// появляется только вместе с QML, а язык нужен раньше первой привязки. +// Обратной зависимости это не создаёт — файл только читается. +QString storedLanguage() +{ + QFile file(LauncherPaths::settingsFile()); + if (!file.open(QIODevice::ReadOnly)) + return kSystem; + + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) + return kSystem; + + const QJsonValue value = doc.object().value(QStringLiteral("language")); + return value.isString() ? value.toString() : kSystem; +} + +} // namespace + +Localization::Localization(QObject *parent) + : QObject(parent) +{ +} + +Localization &Localization::instance() +{ + static Localization loc; + return loc; +} + +Localization *Localization::create(QQmlEngine *, QJSEngine *jsEngine) +{ + Localization &loc = instance(); + loc.m_engine = jsEngine; + loc.rebuildTree(); + // Синглтон живёт до конца процесса и движку не принадлежит — иначе сборщик + // мусора QML попытается удалить статический объект. + QJSEngine::setObjectOwnership(&loc, QJSEngine::CppOwnership); + return &loc; +} + +bool Localization::load() +{ + QFile file(kCatalogPath); + if (!file.open(QIODevice::ReadOnly)) { + qCritical("Каталог переводов не найден: %s", qPrintable(kCatalogPath)); + return false; + } + + QJsonParseError error{}; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCritical("Каталог переводов испорчен: %s (смещение %d)", + qPrintable(error.errorString()), + error.offset); + return false; + } + if (!doc.isObject()) { + qCritical("Каталог переводов должен быть объектом JSON"); + return false; + } + + const QJsonObject root = doc.object(); + const QJsonObject meta = root.value(QStringLiteral("_meta")).toObject(); + + const QJsonArray languages = meta.value(QStringLiteral("languages")).toArray(); + m_codes.clear(); + for (const QJsonValue &code : languages) + m_codes << code.toString(); + if (m_codes.isEmpty()) { + qCritical("В каталоге переводов не перечислен ни один язык (_meta.languages)"); + return false; + } + + const QJsonObject names = meta.value(QStringLiteral("displayNames")).toObject(); + m_displayNames.clear(); + for (auto it = names.constBegin(); it != names.constEnd(); ++it) + m_displayNames.insert(it.key(), it.value().toString()); + + const QJsonObject strings = root.value(QStringLiteral("strings")).toObject(); + if (strings.isEmpty()) { + qCritical("В каталоге переводов нет ни одной строки (strings)"); + return false; + } + + m_singles.clear(); + m_lists.clear(); + for (auto it = strings.constBegin(); it != strings.constEnd(); ++it) { + const QJsonObject entry = it.value().toObject(); + // Тип значения определяется по первому языку и обязан совпадать у всех: + // за этим следит tools/check_translations.py. + const bool isList = entry.value(m_codes.first()).isArray(); + + if (isList) { + QList perLanguage; + perLanguage.reserve(m_codes.size()); + for (const QString &code : std::as_const(m_codes)) { + QStringList values; + const QJsonArray array = entry.value(code).toArray(); + for (const QJsonValue &value : array) + values << value.toString(); + perLanguage << values; + } + m_lists.insert(it.key(), perLanguage); + } else { + QStringList perLanguage; + perLanguage.reserve(m_codes.size()); + for (const QString &code : std::as_const(m_codes)) + perLanguage << entry.value(code).toString(); + m_singles.insert(it.key(), perLanguage); + } + } + + m_language = storedLanguage(); + if (m_language != kSystem && !m_codes.contains(m_language)) { + qWarning("Неизвестный язык в настройках: %s — берём системный", + qPrintable(m_language)); + m_language = kSystem; + } + m_index.storeRelaxed(int(m_codes.indexOf(resolvedLanguage()))); + +#ifndef QT_NO_DEBUG + for (auto it = m_singles.constBegin(); it != m_singles.constEnd(); ++it) { + for (int i = 0; i < m_codes.size(); ++i) { + if (it.value().value(i).isEmpty()) { + qWarning("Пустой перевод: ключ %s, язык %s", + qPrintable(it.key()), + qPrintable(m_codes.at(i))); + } + } + } +#endif + + return true; +} + +QString Localization::resolvedLanguage() const +{ + return m_language == kSystem ? systemLanguage() : m_language; +} + +void Localization::setLanguage(const QString &code) +{ + Q_ASSERT(QThread::currentThread() == qApp->thread()); + + QString wanted = code; + if (wanted != kSystem && !m_codes.contains(wanted)) { + qWarning("Неизвестный язык: %s — берём системный", qPrintable(wanted)); + wanted = kSystem; + } + if (wanted == m_language) + return; + + m_language = wanted; + m_index.storeRelaxed(int(m_codes.indexOf(resolvedLanguage()))); + rebuildTree(); + emit languageChanged(); +} + +QVariantList Localization::availableLanguages() const +{ + QVariantList out; + out << QVariantMap{{QStringLiteral("code"), kSystem}, + {QStringLiteral("name"), textFor("settings.language.system")}}; + for (const QString &code : m_codes) { + out << QVariantMap{{QStringLiteral("code"), code}, + {QStringLiteral("name"), m_displayNames.value(code, code)}}; + } + return out; +} + +QString Localization::textFor(const char *key) const +{ + const QString id = QString::fromLatin1(key); + const auto it = m_singles.constFind(id); + if (it == m_singles.constEnd()) { +#ifndef QT_NO_DEBUG + qWarning("Localization: нет ключа %s", key); +#endif + return id; + } + return it.value().value(m_index.loadRelaxed()); +} + +QStringList Localization::listFor(const char *key) const +{ + const QString id = QString::fromLatin1(key); + const auto it = m_lists.constFind(id); + if (it == m_lists.constEnd()) { +#ifndef QT_NO_DEBUG + qWarning("Localization: нет списка %s", key); +#endif + return {id}; + } + return it.value().value(m_index.loadRelaxed()); +} + +namespace { + +// Раскладывает плоский ключ "a.b.c" по вложенным картам. Рекурсия, а не спуск +// указателем: QVariant не отдаёт ссылку на вложенную карту, её приходится +// доставать копией и класть обратно. +void insertPath(QVariantMap &node, const QStringList &parts, int depth, const QVariant &value) +{ + if (depth == parts.size() - 1) { + node[parts.at(depth)] = value; + return; + } + QVariantMap child = node.value(parts.at(depth)).toMap(); + insertPath(child, parts, depth + 1, value); + node[parts.at(depth)] = child; +} + +} // namespace + +// Дерево для QML: плоские ключи "a.b.c" разбираются в вложенные объекты, чтобы +// в разметке писалось Loc.t.a.b.c. Строится один раз на смену языка. +void Localization::rebuildTree() +{ + if (!m_engine) + return; + + const int index = m_index.loadRelaxed(); + QVariantMap root; + + for (auto it = m_singles.constBegin(); it != m_singles.constEnd(); ++it) + insertPath(root, it.key().split(QLatin1Char('.')), 0, it.value().value(index)); + for (auto it = m_lists.constBegin(); it != m_lists.constEnd(); ++it) + insertPath(root, it.key().split(QLatin1Char('.')), 0, it.value().value(index)); + + m_tree = m_engine->toScriptValue(root); +} + +namespace Loc { + +QString text(const char *key) +{ + return Localization::instance().textFor(key); +} + +QStringList list(const char *key) +{ + return Localization::instance().listFor(key); +} + +} // namespace Loc diff --git a/localization.h b/localization.h new file mode 100644 index 0000000..b952b34 --- /dev/null +++ b/localization.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QQmlEngine; +class QJSEngine; + +// Единственный источник всех текстов интерфейса. Каталог — i18n/translations.json, +// он лежит в ресурсах и правится руками; штатных .ts/.qm в проекте нет намеренно. +// +// Из QML читается свойством: text: Loc.t.settings.title +// Из C++ — свободной функцией: Loc::text("settings.title") +// +// Почему свойство, а не Q_INVOKABLE: вызов метода не регистрирует зависимость +// привязки, и text: Loc.t("ключ") никогда бы не обновился при смене языка. +// Чтение же Q_PROPERTY с сигналом NOTIFY зависимость регистрирует, поэтому +// languageChanged перевычисляет все привязки разом. Дальнейшие .settings.title — +// обычные обращения к членам JS-объекта, и отслеживать их не нужно: при смене +// языка дерево заменяется целиком. +// +// Тип свойства — QJSValue, а не QVariantMap: QVariantMap пересобирался бы в новый +// JS-объект при каждом чтении, а привязок в проекте полторы сотни. QJSValue +// строится один раз на смену языка. +class Localization : public QObject +{ + Q_OBJECT + QML_NAMED_ELEMENT(Loc) + QML_SINGLETON + + Q_PROPERTY(QJSValue t READ t NOTIFY languageChanged) + // Что выбрано пользователем: "system", "ru" или "en". + Q_PROPERTY(QString language READ language NOTIFY languageChanged) + // Во что это разрешилось: всегда "ru" или "en", никогда "system". + Q_PROPERTY(QString resolvedLanguage READ resolvedLanguage NOTIFY languageChanged) + +public: + static Localization &instance(); + + // Фабрика синглтона для движка QML. Объект к этому моменту уже загружен + // из main(), здесь только запоминается движок и строится дерево. + static Localization *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine); + + // Читает каталог из ресурсов и язык из settings.json. Вызывается ровно один + // раз из main() до создания QQmlApplicationEngine. false — каталога нет или + // он битый; это ошибка сборки, и запускаться дальше незачем. + bool load(); + + QJSValue t() const { return m_tree; } + QString language() const { return m_language; } + QString resolvedLanguage() const; + + // Только из GUI-потока: меняет дерево и рассылает languageChanged. + void setLanguage(const QString &code); + + // Для комбобокса в настройках: [{ "code": "ru", "name": "Русский" }, …], + // первым пунктом — "system". + Q_INVOKABLE QVariantList availableLanguages() const; + + // Рабочие лошадки Loc::text/Loc::list. Потокобезопасны: см. комментарий к + // полям ниже. + [[nodiscard]] QString textFor(const char *key) const; + [[nodiscard]] QStringList listFor(const char *key) const; + +signals: + void languageChanged(); + +private: + explicit Localization(QObject *parent = nullptr); + + void rebuildTree(); + + // Каталог заполняется один раз в load() и дальше только читается — поэтому + // обращаться к нему можно из любого потока без блокировок. Единственное, что + // меняется на ходу, — m_index, и он атомарный. + QHash m_singles; // ключ -> значения по языкам + QHash> m_lists; // ключ -> списки по языкам + QStringList m_codes; // ["ru", "en"] — порядок из _meta + QHash m_displayNames; + + QAtomicInt m_index{0}; // индекс текущего языка в m_codes + QString m_language{QStringLiteral("system")}; + + QJSEngine *m_engine = nullptr; + QJSValue m_tree; +}; + +// ── Доступ из C++ ─────────────────────────────────────────────────────────── +// +// Свободные функции, а не методы: их вызывают и из namespace-обёрток +// (LauncherPaths, JavaLocator, ZlibReference), где никакого QObject нет. +namespace Loc { + +// Перевод по ключу. Ключа нет — возвращается сам ключ, в отладочной сборке ещё +// и qWarning: строка вида "launch.error.gameRunning" в интерфейсе сразу видна. +[[nodiscard]] QString text(const char *key); + +// Для ключей со значением-списком (модели комбобоксов). +[[nodiscard]] QStringList list(const char *key); + +} // namespace Loc diff --git a/main.cpp b/main.cpp index c2e0f48..feb8ccd 100644 --- a/main.cpp +++ b/main.cpp @@ -1,3 +1,5 @@ +#include "localization.h" + #include #include #include @@ -17,6 +19,13 @@ int main(int argc, char *argv[]) QGuiApplication app(argc, argv); + // Язык интерфейса нужно знать до того, как QML вычислит первую привязку, + // поэтому каталог читается здесь, а не в LauncherBackend: тот появляется + // только вместе с движком. Каталог лежит в ресурсах — не прочитался, значит + // сломана сборка, и запускаться дальше незачем. + if (!Localization::instance().load()) + return -1; + // Нативный стиль Windows игнорирует пользовательские contentItem/background // и сыплет предупреждениями, а всё окно стилизовано вручную — берём Basic. QQuickStyle::setStyle("Basic"); diff --git a/minecraftversion.cpp b/minecraftversion.cpp index 7f40ddc..bb99fb6 100644 --- a/minecraftversion.cpp +++ b/minecraftversion.cpp @@ -1,4 +1,5 @@ #include "minecraftversion.h" +#include "localization.h" #include #include @@ -373,7 +374,7 @@ bool VersionLoader::remove(const QString &gameDir, const QString &versionId, QSt const QString root = versionsRoot(gameDir); if (gameDir.isEmpty() || !isSafeVersionId(versionId)) { if (error) - *error = QObject::tr("недопустимый идентификатор версии"); + *error = Loc::text("version.error.invalidId"); return false; } @@ -382,14 +383,14 @@ bool VersionLoader::remove(const QString &gameDir, const QString &versionId, QSt const QString dir = QDir::cleanPath(root + u'/' + versionId); if (!dir.startsWith(root + u'/')) { if (error) - *error = QObject::tr("недопустимый идентификатор версии"); + *error = Loc::text("version.error.invalidId"); return false; } if (!QDir(dir).exists()) return true; if (!QDir(dir).removeRecursively()) { if (error) - *error = QObject::tr("не удалось удалить %1").arg(QDir::toNativeSeparators(dir)); + *error = Loc::text("common.error.removeFailed").arg(QDir::toNativeSeparators(dir)); return false; } return true; @@ -410,12 +411,12 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, while (!currentId.isEmpty()) { if (chainIds.contains(currentId)) { if (error) - *error = QObject::tr("Циклическая зависимость inheritsFrom в версии «%1»").arg(currentId); + *error = Loc::text("version.error.inheritsCycle").arg(currentId); return version; } if (chain.size() >= kMaxInheritanceDepth) { if (error) - *error = QObject::tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(versionId); + *error = Loc::text("common.error.inheritsChainTooLong").arg(versionId); return version; } @@ -423,7 +424,7 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, QFile file(path); if (!file.open(QIODevice::ReadOnly)) { if (error) - *error = QObject::tr("Не найден файл версии: %1").arg(QDir::toNativeSeparators(path)); + *error = Loc::text("version.error.fileMissing").arg(QDir::toNativeSeparators(path)); return version; } @@ -431,7 +432,7 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) { if (error) - *error = QObject::tr("Повреждён файл версии %1: %2") + *error = Loc::text("version.error.fileCorrupt") .arg(QDir::toNativeSeparators(path), parseError.errorString()); return version; } @@ -544,7 +545,7 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, version.clientJarPath = versionJarPath(gameDir, chainIds.constLast()); if (version.mainClass.isEmpty() && error) - *error = QObject::tr("В файле версии «%1» не указан mainClass").arg(versionId); + *error = Loc::text("version.error.noMainClass").arg(versionId); return version; } diff --git a/modloaderinstaller.cpp b/modloaderinstaller.cpp index ecc1144..319a934 100644 --- a/modloaderinstaller.cpp +++ b/modloaderinstaller.cpp @@ -1,4 +1,5 @@ #include "modloaderinstaller.h" +#include "localization.h" #include "gamelauncher.h" #include "javalocator.h" @@ -106,11 +107,11 @@ void ModLoaderInstaller::install(const QString &gameDir, const QString &javaPreference) { if (m_running) { - emit failed(loaderTitle(loader), tr("Установка модлоадера уже идёт")); + emit failed(loaderTitle(loader), Loc::text("loader.error.installInProgress")); return; } if (gameVersion.trimmed().isEmpty() || loaderVersion.trimmed().isEmpty()) { - emit failed(loaderTitle(loader), tr("Не выбрана версия %1").arg(loaderTitle(loader))); + emit failed(loaderTitle(loader), Loc::text("loader.error.noVersionSelected").arg(loaderTitle(loader))); return; } @@ -128,7 +129,7 @@ void ModLoaderInstaller::install(const QString &gameDir, m_running = true; emit started(m_label); - setStage(tr("Подготовка %1").arg(loaderTitle(loader))); + setStage(Loc::text("loader.progress.preparing").arg(loaderTitle(loader))); // Список версий нужен и для ссылки на installer.jar, и для точного id // профиля. После перезапуска лаунчера он ещё не прочитан с диска, поэтому @@ -137,7 +138,7 @@ void ModLoaderInstaller::install(const QString &gameDir, if (!m_running || m_canceling) return; if (!ok) { - fail(warning.isEmpty() ? tr("Список версий %1 недоступен").arg(loaderTitle(loader)) + fail(warning.isEmpty() ? Loc::text("loader.error.listUnavailable").arg(loaderTitle(loader)) : warning); return; } @@ -191,14 +192,14 @@ void ModLoaderInstaller::beginFabricLike() + m_loaderVersion + u'-' + m_gameVersion; if (!isSafeSegment(expectedId)) { - fail(tr("Недопустимый идентификатор версии «%1»").arg(expectedId)); + fail(Loc::text("loader.error.invalidVersionId").arg(expectedId)); return; } const QString base = m_loader == ModLoader::Fabric ? kFabricProfile : kQuiltProfile; const QUrl url(base + m_gameVersion + u'/' + m_loaderVersion + QStringLiteral("/profile/json")); - setStage(tr("Описание версии %1").arg(loaderTitle(m_loader))); + setStage(Loc::text("loader.progress.description").arg(loaderTitle(m_loader))); m_currentFile = expectedId + QStringLiteral(".json"); emit progressChanged(); @@ -216,7 +217,7 @@ void ModLoaderInstaller::beginFabricLike() return; if (reply->error() != QNetworkReply::NoError) { - fail(tr("Не удалось получить описание %1: %2") + fail(Loc::text("loader.error.descriptionFetchFailed") .arg(loaderTitle(m_loader), reply->errorString())); return; } @@ -229,21 +230,21 @@ void ModLoaderInstaller::beginFabricLike() // Описание приходит из сети и превращается в путь на диске — проверяем // и id, и то, что версия действительно наследует выбранную ванильную. if (id.isEmpty() || !isSafeSegment(id)) { - fail(tr("Описание %1 испорчено: недопустимый идентификатор версии") + fail(Loc::text("loader.error.descriptionBadId") .arg(loaderTitle(m_loader))); return; } if (inherits != m_gameVersion) { - fail(tr("Описание %1 рассчитано на Minecraft %2, а не %3") + fail(Loc::text("loader.error.descriptionWrongMinecraft") .arg(loaderTitle(m_loader), inherits, m_gameVersion)); return; } if (id != expectedId) - emit log(tr("Идентификатор версии: %1").arg(id)); + emit log(Loc::text("loader.status.versionId").arg(id)); const QString directory = m_gameDir + QStringLiteral("/versions/") + id; if (!QDir().mkpath(directory)) { - fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory))); + fail(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(directory))); return; } @@ -251,7 +252,7 @@ void ModLoaderInstaller::beginFabricLike() if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size() || !file.commit()) { file.cancelWriting(); - fail(tr("Не удалось сохранить описание версии «%1»").arg(id)); + fail(Loc::text("common.error.versionSaveFailed").arg(id)); return; } @@ -265,12 +266,12 @@ void ModLoaderInstaller::beginInstallerJar() { const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion); if (!entry || !entry->installerUrl.isValid()) { - fail(tr("%1 %2 не подходит к Minecraft %3 — выберите версию из списка") + fail(Loc::text("loader.error.incompatible") .arg(loaderTitle(m_loader), m_loaderVersion, m_gameVersion)); return; } if (entry->installerUrl.scheme() != QLatin1String("https")) { - fail(tr("Установщик %1 доступен только по недопустимой ссылке").arg(loaderTitle(m_loader))); + fail(Loc::text("loader.error.installerBadUrl").arg(loaderTitle(m_loader))); return; } @@ -296,7 +297,7 @@ void ModLoaderInstaller::ensureBaseVersion(std::function next) } } - setStage(tr("Установка Minecraft %1").arg(m_gameVersion)); + setStage(Loc::text("loader.progress.installingMinecraft").arg(m_gameVersion)); m_currentFile = m_gameVersion; emit progressChanged(); @@ -329,7 +330,7 @@ void ModLoaderInstaller::ensureBaseVersion(std::function next) void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::function next) { if (!LauncherPaths::ensureRootExists()) { - fail(tr("Нет доступа к папке лаунчера")); + fail(Loc::text("loader.error.launcherDirUnavailable")); return; } @@ -337,7 +338,7 @@ void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::functionerror() != QNetworkReply::NoError) { - fail(tr("Не удалось скачать установщик %1: %2") + fail(Loc::text("loader.error.installerDownloadFailed") .arg(loaderTitle(m_loader), reply->errorString())); return; } @@ -371,7 +372,7 @@ void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::functionerrorString())); }); m_process->start(); if (!m_process->waitForStarted(kTransferTimeoutMs)) { - fail(tr("Установщик %1 не запустился: %2") + fail(Loc::text("loader.error.installerFailedToStart") .arg(loaderTitle(m_loader), m_process->errorString())); return; } @@ -508,7 +509,7 @@ void ModLoaderInstaller::runInstaller(const QString &installerPath) QTimer::singleShot(kInstallerTimeoutMs, this, [this] { if (m_process && m_process->state() != QProcess::NotRunning) { m_process->kill(); - fail(tr("Установщик %1 не завершился за отведённое время").arg(loaderTitle(m_loader))); + fail(Loc::text("loader.error.installerTimeout").arg(loaderTitle(m_loader))); } }); } @@ -525,7 +526,7 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) if (crashed || exitCode != 0) { removeVersionsAddedByInstaller(); - QString message = tr("Установщик %1 завершился с ошибкой (код %2).") + QString message = Loc::text("loader.error.installerExitCode") .arg(loaderTitle(m_loader)) .arg(exitCode); const QString hint = installerFailureHint(); @@ -535,7 +536,7 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) message += u'\n' + m_installerLog.join(u'\n'); if (!m_installerLogPath.isEmpty()) message += u'\n' - + tr("Полный лог: %1").arg(QDir::toNativeSeparators(m_installerLogPath)); + + Loc::text("loader.error.fullLog").arg(QDir::toNativeSeparators(m_installerLogPath)); fail(message); return; } @@ -555,7 +556,7 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) } if (added.isEmpty()) { - QString message = tr("Установщик %1 отработал, но новая версия в %2 не появилась.") + QString message = Loc::text("loader.error.noVersionProduced") .arg(loaderTitle(m_loader), QDir::toNativeSeparators(m_gameDir + QStringLiteral("/versions"))); @@ -603,7 +604,7 @@ void ModLoaderInstaller::removeVersionsAddedByInstaller() for (const QString &id : added) { QString error; if (!VersionLoader::remove(m_gameDir, id, &error)) - emit log(tr("Не удалось убрать незавершённый профиль «%1»: %2").arg(id, error)); + emit log(Loc::text("loader.error.cleanupFailed").arg(id, error)); } m_versionsBefore.clear(); m_installerStarted = false; @@ -622,10 +623,7 @@ QString ModLoaderInstaller::installerFailureHint() const if (!checksumFailure) return {}; - return tr("Системный libz.so.1 — это zlib-ng: он сжимает иначе, чем обычный zlib, " - "поэтому sha1 собранных установщиком файлов не совпадает с эталоном. " - "Пересоберите лаунчер, чтобы рядом с ним появился эталонный libz.so.1, " - "либо замените zlib-ng в системе: sudo pacman -S zlib lib32-zlib"); + return Loc::text("loader.error.zlibNg"); } void ModLoaderInstaller::appendInstallerLog(const QString &line) @@ -650,7 +648,7 @@ void ModLoaderInstaller::closeInstallerLog() void ModLoaderInstaller::handOffToVersionInstaller(const QString &versionId) { m_awaitingVersionId = versionId; - setStage(tr("Загрузка файлов версии")); + setStage(Loc::text("loader.progress.downloadingVersionFiles")); m_currentFile = versionId; emit progressChanged(); m_versionInstaller->install(m_gameDir, versionId); diff --git a/modloaderversionservice.cpp b/modloaderversionservice.cpp index 6b53c81..8c6e223 100644 --- a/modloaderversionservice.cpp +++ b/modloaderversionservice.cpp @@ -1,4 +1,5 @@ #include "modloaderversionservice.h" +#include "localization.h" #include "launcherpaths.h" @@ -229,7 +230,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { finishRefresh(loader, gameVersion, false, - tr("Не удалось получить список версий %1: %2") + Loc::text("loader.error.versionsFetchFailed") .arg(loaderTitle(loader), reply->errorString())); return; } @@ -240,7 +241,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game const QJsonDocument document = QJsonDocument::fromJson(payload); if (!document.isArray()) { finishRefresh(loader, gameVersion, false, - tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader))); + Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(loader))); return; } @@ -296,14 +297,14 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game connect(primary, &QNetworkReply::finished, this, [this, primary, state, loader, settle] { primary->deleteLater(); if (primary->error() != QNetworkReply::NoError) { - state->warning = tr("Не удалось получить список версий %1: %2") + state->warning = Loc::text("loader.error.versionsFetchFailed") .arg(loaderTitle(loader), primary->errorString()); settle(); return; } auto parsed = parseMavenMetadata(primary->readAll(), loader, /*legacyNeoForge=*/false); if (parsed.isEmpty()) { - state->warning = tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader)); + state->warning = Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(loader)); settle(); return; } diff --git a/msaauthservice.cpp b/msaauthservice.cpp index fd62cbb..2f3aaf2 100644 --- a/msaauthservice.cpp +++ b/msaauthservice.cpp @@ -1,4 +1,5 @@ #include "msaauthservice.h" +#include "localization.h" #include #include @@ -64,21 +65,19 @@ QString describeXstsError(const QJsonObject &response) const qint64 code = static_cast(response.value(QStringLiteral("XErr")).toDouble()); switch (code) { case 2148916233: - return MsaAuthService::tr("К аккаунту Microsoft не привязан профиль Xbox. " - "Создайте его на xbox.com и повторите вход."); + return Loc::text("auth.msa.error.noXboxProfile"); case 2148916235: - return MsaAuthService::tr("Xbox Live недоступен в стране, указанной в аккаунте"); + return Loc::text("auth.msa.error.countryBlocked"); case 2148916236: case 2148916237: - return MsaAuthService::tr("Аккаунту нужна проверка возраста на xbox.com"); + return Loc::text("auth.msa.error.ageVerification"); case 2148916238: - return MsaAuthService::tr("Детский аккаунт: добавьте его в семейную группу " - "Microsoft, иначе вход в Xbox Live невозможен"); + return Loc::text("auth.msa.error.childAccount"); default: break; } const QString message = response.value(QStringLiteral("Message")).toString(); - return message.isEmpty() ? MsaAuthService::tr("Xbox Live отклонил вход (XErr %1)").arg(code) + return message.isEmpty() ? Loc::text("auth.msa.error.xboxRejected").arg(code) : message; } @@ -90,7 +89,7 @@ QString describeOauthError(const QJsonObject &response, int status) const QString error = response.value(QStringLiteral("error")).toString(); if (!error.isEmpty()) return error; - return MsaAuthService::tr("Microsoft вернул код %1").arg(status); + return Loc::text("auth.msa.error.microsoftCode").arg(status); } } // namespace @@ -216,7 +215,7 @@ void MsaAuthService::loginWithCode(const QString &code, Callback callback) { if (code.isEmpty()) { AuthResult result; - result.error = tr("Microsoft не вернул код авторизации"); + result.error = Loc::text("auth.msa.error.noAuthCode"); callback(result); return; } @@ -227,7 +226,7 @@ void MsaAuthService::loginWithCode(const QString &code, Callback callback) form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("authorization_code")); form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri); - emit progress(tr("Обмен кода Microsoft на токен…")); + emit progress(Loc::text("auth.msa.progress.exchangingCode")); exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback)); } @@ -235,7 +234,7 @@ void MsaAuthService::loginWithRefreshToken(const QString &refreshToken, Callback { if (refreshToken.isEmpty()) { AuthResult result; - result.error = tr("Профиль не подключён к аккаунту Microsoft"); + result.error = Loc::text("auth.msa.error.profileNotLinked"); callback(result); return; } @@ -247,7 +246,7 @@ void MsaAuthService::loginWithRefreshToken(const QString &refreshToken, Callback form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri); form.addQueryItem(QStringLiteral("scope"), kScope); - emit progress(tr("Обновление сессии Microsoft…")); + emit progress(Loc::text("auth.msa.progress.refreshingSession")); exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback)); } @@ -257,7 +256,7 @@ void MsaAuthService::exchangeToken(const QByteArray &form, Callback callback) [this, callback](int status, const QJsonObject &response, const QString &transportError) { if (!transportError.isEmpty()) { AuthResult result; - result.error = tr("Нет связи с сервером Microsoft: %1").arg(transportError); + result.error = Loc::text("auth.msa.error.microsoftUnreachable").arg(transportError); callback(result); return; } @@ -274,7 +273,7 @@ void MsaAuthService::exchangeToken(const QByteArray &form, Callback callback) const QString refreshToken = response.value(QStringLiteral("refresh_token")).toString(); if (accessToken.isEmpty()) { AuthResult result; - result.error = tr("Microsoft не вернул токен доступа"); + result.error = Loc::text("auth.msa.error.noAccessToken"); callback(result); return; } @@ -286,7 +285,7 @@ void MsaAuthService::authenticateXbox(const QString µsoftToken, const QString &refreshToken, Callback callback) { - emit progress(tr("Вход в Xbox Live…")); + emit progress(Loc::text("auth.msa.progress.xboxSignIn")); QJsonObject properties{{QStringLiteral("AuthMethod"), QStringLiteral("RPS")}, {QStringLiteral("SiteName"), QStringLiteral("user.auth.xboxlive.com")}, @@ -300,19 +299,19 @@ void MsaAuthService::authenticateXbox(const QString µsoftToken, const QString &transportError) { AuthResult result; if (!transportError.isEmpty()) { - result.error = tr("Нет связи с Xbox Live: %1").arg(transportError); + result.error = Loc::text("auth.msa.error.xboxUnreachable").arg(transportError); callback(result); return; } if (status != 200) { - result.error = tr("Xbox Live отклонил токен Microsoft (код %1)").arg(status); + result.error = Loc::text("auth.msa.error.xboxTokenRejected").arg(status); callback(result); return; } const QString token = response.value(QStringLiteral("Token")).toString(); if (token.isEmpty()) { - result.error = tr("Xbox Live не вернул токен"); + result.error = Loc::text("auth.msa.error.xboxNoToken"); callback(result); return; } @@ -324,7 +323,7 @@ void MsaAuthService::authorizeXsts(const QString &xblToken, const QString &refreshToken, Callback callback) { - emit progress(tr("Проверка прав XSTS…")); + emit progress(Loc::text("auth.msa.progress.xsts")); const QJsonObject properties{{QStringLiteral("SandboxId"), QStringLiteral("RETAIL")}, {QStringLiteral("UserTokens"), QJsonArray{xblToken}}}; @@ -338,7 +337,7 @@ void MsaAuthService::authorizeXsts(const QString &xblToken, const QString &transportError) { AuthResult result; if (!transportError.isEmpty()) { - result.error = tr("Нет связи с сервером XSTS: %1").arg(transportError); + result.error = Loc::text("auth.msa.error.xstsUnreachable").arg(transportError); callback(result); return; } @@ -348,7 +347,7 @@ void MsaAuthService::authorizeXsts(const QString &xblToken, return; } if (status != 200) { - result.error = tr("XSTS отклонил запрос (код %1)").arg(status); + result.error = Loc::text("auth.msa.error.xstsRejected").arg(status); callback(result); return; } @@ -357,7 +356,7 @@ void MsaAuthService::authorizeXsts(const QString &xblToken, const QJsonObject claim = displayClaim(response); const QString userHash = claim.value(QStringLiteral("uhs")).toString(); if (token.isEmpty() || userHash.isEmpty()) { - result.error = tr("XSTS не вернул данные пользователя"); + result.error = Loc::text("auth.msa.error.xstsNoUser"); callback(result); return; } @@ -372,7 +371,7 @@ void MsaAuthService::loginWithXbox(const QString &userHash, const QString &refreshToken, Callback callback) { - emit progress(tr("Авторизация в Minecraft Services…")); + emit progress(Loc::text("auth.msa.progress.minecraftServices")); const QJsonObject body{{QStringLiteral("identityToken"), QStringLiteral("XBL3.0 x=%1;%2").arg(userHash, xstsToken)}}; @@ -382,19 +381,19 @@ void MsaAuthService::loginWithXbox(const QString &userHash, const QString &transportError) { AuthResult result; if (!transportError.isEmpty()) { - result.error = tr("Нет связи с api.minecraftservices.com: %1").arg(transportError); + result.error = Loc::text("auth.msa.error.servicesUnreachable").arg(transportError); callback(result); return; } if (status != 200) { - result.error = tr("Minecraft Services отклонили вход (код %1)").arg(status); + result.error = Loc::text("auth.msa.error.servicesRejected").arg(status); callback(result); return; } result.accessToken = response.value(QStringLiteral("access_token")).toString(); if (result.accessToken.isEmpty()) { - result.error = tr("Minecraft Services не вернули токен доступа"); + result.error = Loc::text("auth.msa.error.servicesNoToken"); callback(result); return; } @@ -411,7 +410,7 @@ void MsaAuthService::loginWithXbox(const QString &userHash, void MsaAuthService::checkLicense(AuthResult result, Callback callback) { - emit progress(tr("Проверка лицензии Minecraft…")); + emit progress(Loc::text("auth.msa.progress.checkingLicence")); getJson(QUrl(kEntitlementsUrl), result.accessToken, [this, result, callback](int status, const QJsonObject &response, const QString &) { @@ -430,7 +429,7 @@ void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Call [result, storeEntitlement, callback](int status, const QJsonObject &response, const QString &transportError) mutable { if (!transportError.isEmpty()) { - result.error = MsaAuthService::tr("Нет связи с api.minecraftservices.com: %1") + result.error = Loc::text("auth.msa.error.servicesUnreachable") .arg(transportError); callback(result); return; @@ -442,17 +441,14 @@ void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Call result.licenseMissing = true; result.error = storeEntitlement - ? MsaAuthService::tr("На аккаунте Microsoft есть покупка, но игровой " - "профиль не создан. Зайдите один раз в " - "официальный лаунчер и выберите ник.") - : MsaAuthService::tr("На этом аккаунте Microsoft нет копии " - "Minecraft: Java Edition"); + ? Loc::text("auth.msa.error.noProfileYet") + : Loc::text("auth.msa.error.noLicence"); callback(result); return; } if (status != 200) { result.error = - MsaAuthService::tr("Не удалось получить профиль Minecraft (код %1)").arg(status); + Loc::text("auth.msa.error.profileFetchFailed").arg(status); callback(result); return; } @@ -462,7 +458,7 @@ void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Call result.ok = !result.playerName.isEmpty() && !result.uuid.isEmpty(); if (!result.ok) { result.licenseMissing = true; - result.error = MsaAuthService::tr("Minecraft Services не вернули игровой профиль"); + result.error = Loc::text("auth.msa.error.servicesNoProfile"); } callback(result); }); diff --git a/seasonalbuildservice.cpp b/seasonalbuildservice.cpp index a4132f0..c2a0067 100644 --- a/seasonalbuildservice.cpp +++ b/seasonalbuildservice.cpp @@ -1,4 +1,5 @@ #include "seasonalbuildservice.h" +#include "localization.h" #include "launcherpaths.h" #include "modloader.h" @@ -121,8 +122,8 @@ QList SeasonalBuildService::parseCatalog(const QJsonObject & if (format != kSupportedFormatVersion) { if (error) { *error = format > kSupportedFormatVersion - ? tr("Сервер сборок отдаёт формат %1 — обновите лаунчер").arg(format) - : tr("Неизвестный формат каталога сборок (%1)").arg(format); + ? Loc::text("seasonal.error.formatTooNew").arg(format) + : Loc::text("seasonal.error.formatUnknown").arg(format); } return {}; } @@ -200,7 +201,7 @@ QList SeasonalBuildService::parseCatalog(const QJsonObject & void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh) { if (!m_baseUrl.isValid() || m_baseUrl.host().isEmpty()) { - m_lastError = tr("Адрес сервера сборок не задан"); + m_lastError = Loc::text("seasonal.error.noServerUrl"); if (callback) { const QString error = m_lastError; QTimer::singleShot(0, this, [callback = std::move(callback), error] { @@ -252,7 +253,7 @@ void SeasonalBuildService::startRefresh() if (reply->error() != QNetworkReply::NoError) { finishRefresh(false, - tr("Не удалось получить список сборок: %1").arg(reply->errorString())); + Loc::text("seasonal.error.catalogFetchFailed").arg(reply->errorString())); return; } @@ -265,7 +266,7 @@ void SeasonalBuildService::startRefresh() // остаётся прежним, а причина уходит в lastError. if (parsed.isEmpty()) { finishRefresh(false, - parseError.isEmpty() ? tr("Список сборок пуст или не разобран") + parseError.isEmpty() ? Loc::text("seasonal.error.catalogEmpty") : parseError); return; } diff --git a/seasonalpackdownloader.cpp b/seasonalpackdownloader.cpp index 0b8ad56..9e179ef 100644 --- a/seasonalpackdownloader.cpp +++ b/seasonalpackdownloader.cpp @@ -1,4 +1,5 @@ #include "seasonalpackdownloader.h" +#include "localization.h" #include #include @@ -50,7 +51,7 @@ void SeasonalPackDownloader::download(const QUrl &url, const QString &label) { if (m_running) { - emit failed(label, tr("Загрузка сборки уже идёт")); + emit failed(label, Loc::text("seasonal.error.downloadInProgress")); return; } @@ -61,18 +62,18 @@ void SeasonalPackDownloader::download(const QUrl &url, m_label = label; m_bytesDone = 0; m_bytesTotal = expectedSize; - m_stage = tr("Загрузка файлов сборки"); + m_stage = Loc::text("seasonal.progress.downloadingFiles"); m_currentFile = QFileInfo(targetPath).fileName(); if (!QDir().mkpath(QFileInfo(targetPath).absolutePath())) { - fail(tr("Не удалось создать %1") + fail(Loc::text("common.error.createFailed") .arg(QDir::toNativeSeparators(QFileInfo(targetPath).absolutePath()))); return; } m_file = std::make_unique(targetPath); if (!m_file->open(QIODevice::WriteOnly)) { - fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(targetPath))); + fail(Loc::text("common.error.openForWriteFailed").arg(QDir::toNativeSeparators(targetPath))); return; } m_hash.reset(); @@ -120,7 +121,7 @@ void SeasonalPackDownloader::download(const QUrl &url, if (reply->error() != QNetworkReply::NoError) { m_file->cancelWriting(); m_file.reset(); - fail(tr("Не удалось скачать сборку: %1").arg(reply->errorString())); + fail(Loc::text("seasonal.error.downloadFailed").arg(reply->errorString())); return; } @@ -128,13 +129,13 @@ void SeasonalPackDownloader::download(const QUrl &url, if (actual.compare(m_sha256, Qt::CaseInsensitive) != 0) { m_file->cancelWriting(); m_file.reset(); - fail(tr("Контрольная сумма архива сборки не совпала — загрузка повреждена")); + fail(Loc::text("seasonal.error.checksumMismatch")); return; } if (!m_file->commit()) { m_file.reset(); - fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(m_targetPath))); + fail(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(m_targetPath))); return; } m_file.reset(); diff --git a/tools/check_translations.py b/tools/check_translations.py new file mode 100644 index 0000000..897b827 --- /dev/null +++ b/tools/check_translations.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Проверка каталога переводов. Только чтение; ненулевой код возврата при ошибке. + +Запуск: python3 tools/check_translations.py +""" +import io, json, os, re, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOG = os.path.join(ROOT, "i18n", "translations.json") + +CPP_TEXT = re.compile(r'Loc::text\("([^"]+)"\)') +CPP_LIST = re.compile(r'Loc::list\("([^"]+)"\)') +QML_PATH = re.compile(r'\bLoc\.t((?:\.[A-Za-z_][A-Za-z0-9_]*)+)') +STRAGGLER = re.compile(r'\b(tr|qsTr)\("|QCoreApplication::translate') + +errors, warnings = [], [] + + +def sources(*exts): + for name in sorted(os.listdir(ROOT)): + if name.endswith(exts): + yield name, io.open(os.path.join(ROOT, name), encoding="utf-8").read() + + +doc = json.load(io.open(CATALOG, encoding="utf-8")) +languages = doc["_meta"]["languages"] +strings = doc["strings"] + +# 1. Языки: одинаковый набор ключей, ничего не пустует, типы совпадают. +for key, entry in strings.items(): + missing = [c for c in languages if c not in entry] + if missing: + errors.append(f"{key}: нет языков {missing}") + continue + kinds = {c: type(entry[c]).__name__ for c in languages} + if len(set(kinds.values())) != 1: + errors.append(f"{key}: разный тип значения по языкам — {kinds}") + continue + if isinstance(entry[languages[0]], list): + sizes = {c: len(entry[c]) for c in languages} + if len(set(sizes.values())) != 1: + errors.append(f"{key}: разная длина списка — {sizes}") + for c in languages: + if any(not v.strip() for v in entry[c]): + errors.append(f"{key}: пустой элемент списка в «{c}»") + else: + for c in languages: + if not entry[c].strip(): + errors.append(f"{key}: пустой перевод в «{c}»") + # 2. Набор %N обязан совпадать: порядок слов может отличаться, состав — нет. + holders = {c: set(re.findall(r"%\d", entry[c])) for c in languages} + if len(set(map(frozenset, holders.values()))) != 1: + errors.append(f"{key}: разный набор подстановок — {holders}") + +lists = {k for k, e in strings.items() if isinstance(e[languages[0]], list)} +singles = set(strings) - lists + +# 3. Каждый ключ из C++ существует и совпадает по типу. +used = set() +for name, src in sources(".cpp", ".h"): + for key in CPP_TEXT.findall(src): + if key in singles: + used.add(key) + else: + errors.append(f"{name}: Loc::text(\"{key}\") — нет такого ключа-строки") + for key in CPP_LIST.findall(src): + if key in lists: + used.add(key) + else: + errors.append(f"{name}: Loc::list(\"{key}\") — нет такого ключа-списка") + +# 4. Каждый путь Loc.t.a.b.c из QML разворачивается в существующий ключ. +for name, src in sources(".qml"): + for path in QML_PATH.findall(src): + key = path.lstrip(".") + if key in strings: + used.add(key) + continue + # Loc.t.a.b.slice(0, 2) и подобное: последний сегмент — метод JS. + parent = key.rsplit(".", 1)[0] + if parent in strings: + used.add(parent) + continue + errors.append(f"{name}: Loc.t.{key} — нет такого ключа") + +# 5. Ни одного оставшегося tr()/qsTr()/translate. +for name, src in sources(".cpp", ".h", ".qml"): + for i, line in enumerate(src.splitlines(), 1): + if STRAGGLER.search(line): + errors.append(f"{name}:{i}: остался вызов старой схемы перевода") + +# 6. Ключи, которые никто не использует, — предупреждение. +for key in sorted(set(strings) - used): + warnings.append(f"ключ {key} нигде не используется") + +print(f"ключей: {len(strings)} языки: {languages} использовано: {len(used)}") +for w in warnings: + print(" предупреждение:", w) +for e in errors: + print(" ОШИБКА:", e) +print("ОШИБОК НЕТ" if not errors else f"\nошибок: {len(errors)}") +sys.exit(1 if errors else 0) diff --git a/versioninstaller.cpp b/versioninstaller.cpp index 276b539..bc6d549 100644 --- a/versioninstaller.cpp +++ b/versioninstaller.cpp @@ -1,4 +1,5 @@ #include "versioninstaller.h" +#include "localization.h" #include "gamelauncher.h" #include "versionmanifestservice.h" @@ -178,11 +179,11 @@ void VersionInstaller::install(const QString &gameDir, const QString &versionId) return; if (!isSafeRelativeSegment(id)) { - emit failed(id, tr("Недопустимое имя версии «%1»").arg(id)); + emit failed(id, Loc::text("version.error.invalidName").arg(id)); return; } if (m_canceling) { - emit failed(id, tr("Идёт отмена предыдущей загрузки — попробуйте ещё раз")); + emit failed(id, Loc::text("version.error.cancelPending")); return; } if (m_versionId == id || isQueued(id)) @@ -225,7 +226,7 @@ void VersionInstaller::startNext() m_running = true; if (!wasRunning) emit started(m_versionId); - setStage(tr("Получение списка версий")); + setStage(Loc::text("version.progress.fetchingList")); emit progressChanged(); resolveManifestEntry(); @@ -244,7 +245,7 @@ void VersionInstaller::resolveManifestEntry() adoptVersionJson(local.readAll(), /*alreadySaved=*/true); return; } - failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning); + failInstall(warning.isEmpty() ? Loc::text("version.error.listUnavailable") : warning); return; } @@ -264,15 +265,14 @@ void VersionInstaller::resolveManifestEntry() // Иначе это вручную вписанный id сборки — сообщение не должно // выглядеть как сетевая ошибка. - failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — " - "установить её автоматически нельзя") + failInstall(Loc::text("version.error.notInManifest") .arg(id)); }); } void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expectedSha1) { - setStage(tr("Описание версии")); + setStage(Loc::text("version.progress.description")); m_currentLabel = m_versionId + QStringLiteral(".json"); QNetworkRequest request{url}; @@ -288,7 +288,7 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected return; if (reply->error() != QNetworkReply::NoError) { - failInstall(tr("Не удалось получить описание версии: %1").arg(reply->errorString())); + failInstall(Loc::text("version.error.descriptionFetchFailed").arg(reply->errorString())); return; } @@ -297,7 +297,7 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected const QString actual = QString::fromLatin1( QCryptographicHash::hash(payload, QCryptographicHash::Sha1).toHex()); if (actual.compare(expectedSha1, Qt::CaseInsensitive) != 0) { - failInstall(tr("Контрольная сумма описания версии «%1» не совпала").arg(id)); + failInstall(Loc::text("version.error.descriptionChecksum").arg(id)); return; } } @@ -314,14 +314,14 @@ void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadyS m_versionJson = QJsonDocument::fromJson(payload).object(); if (m_versionJson.isEmpty()) { - failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id)); + failInstall(Loc::text("version.error.descriptionUnparsable").arg(id)); return; } if (!alreadySaved) { const QString directory = m_gameDir + QStringLiteral("/versions/") + id; if (!QDir().mkpath(directory)) { - failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory))); + failInstall(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(directory))); return; } @@ -329,7 +329,7 @@ void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadyS if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size() || !file.commit()) { file.cancelWriting(); - failInstall(tr("Не удалось сохранить описание версии «%1»").arg(id)); + failInstall(Loc::text("common.error.versionSaveFailed").arg(id)); return; } } @@ -340,7 +340,7 @@ void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadyS if (!parentId.isEmpty() && isSafeRelativeSegment(parentId) && !QFile::exists(versionJsonPath(m_gameDir, parentId))) { if (m_inheritDepth >= kMaxInheritDepth) { - failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id)); + failInstall(Loc::text("common.error.inheritsChainTooLong").arg(id)); return; } ++m_inheritDepth; @@ -361,7 +361,7 @@ void VersionInstaller::enqueue(DownloadTask task) { if (task.url.scheme() != QLatin1String("https")) { // Подменённое описание версии не должно уводить загрузку на чужой адрес. - m_warnings << tr("Пропущен %1: недопустимая ссылка").arg(task.label); + m_warnings << Loc::text("version.warning.skippedBadUrl").arg(task.label); return; } m_bytesTotal += task.size; @@ -370,14 +370,14 @@ void VersionInstaller::enqueue(DownloadTask task) bool VersionInstaller::prepareTasks() { - setStage(tr("Подготовка")); + setStage(Loc::text("version.progress.preparing")); QString error; // features пустые намеренно: Mojang отбирает библиотеки только по ОС, // а feature-правила живут в arguments, которые установщику не нужны. m_version = VersionLoader::load(m_gameDir, m_versionId, {}, &error); if (!m_version.isValid()) { - failInstall(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(m_versionId) + failInstall(error.isEmpty() ? Loc::text("common.error.versionReadFailed").arg(m_versionId) : error); return false; } @@ -401,7 +401,7 @@ bool VersionInstaller::prepareTasks() // описания загрузки, разбирать json заново не нужно. for (const MinecraftLibrary &library : std::as_const(m_version.libraries)) { if (library.url.isEmpty() || !isSafeRelativePath(library.path)) { - m_warnings << tr("Нет ссылки на библиотеку %1").arg(library.name); + m_warnings << Loc::text("version.error.libraryNoUrl").arg(library.name); continue; } DownloadTask task; @@ -423,7 +423,7 @@ bool VersionInstaller::prepareTasks() + QStringLiteral(".json"); task.sha1 = assetIndex.value(QStringLiteral("sha1")).toString(); task.size = assetIndex.value(QStringLiteral("size")).toInteger(); - task.label = tr("индекс ресурсов"); + task.label = Loc::text("version.progress.assetIndex"); enqueue(std::move(task)); // totalSize — суммарный вес всех ресурсов, известный ещё до скачивания @@ -450,7 +450,7 @@ bool VersionInstaller::prepareTasks() enqueue(std::move(task)); } - setStage(tr("Файлы игры")); + setStage(Loc::text("version.progress.gameFiles")); return true; } @@ -507,7 +507,7 @@ void VersionInstaller::startTask(DownloadTask task) // Временный файл QSaveFile ложится рядом с целевым, поэтому папка нужна // даже до открытия. if (!QDir().mkpath(info.absolutePath())) { - failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath()))); + failInstall(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(info.absolutePath()))); return; } @@ -515,7 +515,7 @@ void VersionInstaller::startTask(DownloadTask task) context->task = task; context->file = std::make_unique(task.path); if (!context->file->open(QIODevice::WriteOnly)) { - failInstall(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path))); + failInstall(Loc::text("common.error.openForWriteFailed").arg(QDir::toNativeSeparators(task.path))); return; } @@ -572,7 +572,7 @@ void VersionInstaller::handleReplyFinished(const std::shared_ptr if (!retryOrFail(context->task, reason)) return; } else { - failInstall(tr("Не удалось скачать %1: %2").arg(context->task.label, reason)); + failInstall(Loc::text("common.error.downloadFailed").arg(context->task.label, reason)); return; } pump(); @@ -583,7 +583,7 @@ void VersionInstaller::handleReplyFinished(const std::shared_ptr const QString actual = QString::fromLatin1(context->hash.result().toHex()); if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) { context->file->cancelWriting(); - if (!retryOrFail(context->task, tr("контрольная сумма не совпала"))) + if (!retryOrFail(context->task, Loc::text("common.error.checksumMismatch"))) return; pump(); return; @@ -591,7 +591,7 @@ void VersionInstaller::handleReplyFinished(const std::shared_ptr } if (!context->file->commit()) { - failInstall(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path))); + failInstall(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(context->task.path))); return; } @@ -608,7 +608,7 @@ void VersionInstaller::handleReplyFinished(const std::shared_ptr bool VersionInstaller::retryOrFail(DownloadTask task, const QString &reason) { if (task.attempts + 1 >= kMaxAttempts) { - failInstall(tr("Не удалось скачать %1: %2").arg(task.label, reason)); + failInstall(Loc::text("common.error.downloadFailed").arg(task.label, reason)); return false; } @@ -628,7 +628,7 @@ void VersionInstaller::expandAssetIndex(const QString &indexPath) { QFile file(indexPath); if (!file.open(QIODevice::ReadOnly)) { - failInstall(tr("Не удалось прочитать индекс ресурсов %1") + failInstall(Loc::text("version.error.assetIndexReadFailed") .arg(QDir::toNativeSeparators(indexPath))); return; } @@ -638,14 +638,14 @@ void VersionInstaller::expandAssetIndex(const QString &indexPath) m_assetsVirtual = root.value(QStringLiteral("virtual")).toBool(); m_assetsMapToResources = root.value(QStringLiteral("map_to_resources")).toBool(); - setStage(tr("Ресурсы")); + setStage(Loc::text("version.progress.assets")); qint64 actualTotal = 0; for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) { const QJsonObject object = it.value().toObject(); const QString hash = object.value(QStringLiteral("hash")).toString(); if (!isSha1Hex(hash)) { - m_warnings << tr("Пропущен ресурс %1: некорректный хэш").arg(it.key()); + m_warnings << Loc::text("version.warning.skippedBadHash").arg(it.key()); continue; } @@ -684,7 +684,7 @@ void VersionInstaller::afterDownloads() void VersionInstaller::startLegacyCopy() { - setStage(tr("Подготовка ресурсов")); + setStage(Loc::text("version.progress.preparingAssets")); const QString targetRoot = m_assetsMapToResources ? m_gameDir + QStringLiteral("/resources/") @@ -728,10 +728,10 @@ void VersionInstaller::legacyTick() QDir().mkpath(target.absolutePath()); QFile::remove(pair.second); if (!QFile::copy(pair.first, pair.second)) - m_warnings << tr("Не удалось подготовить ресурс %1").arg(target.fileName()); + m_warnings << Loc::text("version.error.assetPrepareFailed").arg(target.fileName()); } - m_currentLabel = tr("%1 из %2").arg(m_legacyIndex).arg(m_legacyCopies.size()); + m_currentLabel = Loc::text("version.progress.countOf").arg(m_legacyIndex).arg(m_legacyCopies.size()); scheduleProgress(); if (m_legacyIndex >= m_legacyCopies.size()) { @@ -749,7 +749,7 @@ void VersionInstaller::complete() probe.versionId = m_versionId; const QStringList missing = GameLauncher::missingFiles(probe, m_version); if (!missing.isEmpty()) { - failInstall(tr("После загрузки не хватает файлов (%1):\n%2") + failInstall(Loc::text("version.error.filesMissingAfterDownload") .arg(missing.size()) .arg(missing.join(u'\n'))); return; diff --git a/versionmanifestservice.cpp b/versionmanifestservice.cpp index 31a0816..a43b359 100644 --- a/versionmanifestservice.cpp +++ b/versionmanifestservice.cpp @@ -1,4 +1,5 @@ #include "versionmanifestservice.h" +#include "localization.h" #include "launcherpaths.h" @@ -126,7 +127,7 @@ void VersionManifestService::startRefresh() if (reply->error() != QNetworkReply::NoError) { finishRefresh(false, - tr("Не удалось обновить список версий: %1").arg(reply->errorString())); + Loc::text("version.error.manifestFetchFailed").arg(reply->errorString())); return; } @@ -134,7 +135,7 @@ void VersionManifestService::startRefresh() parseManifest(QJsonDocument::fromJson(reply->readAll()).object()); // Пустой разбор — испорченный ответ; хороший кэш им не затираем. if (parsed.isEmpty()) { - finishRefresh(false, tr("Манифест версий Mojang не удалось разобрать")); + finishRefresh(false, Loc::text("version.error.manifestUnparsable")); return; } diff --git a/zlibreference.cpp b/zlibreference.cpp index ca330b5..7305e46 100644 --- a/zlibreference.cpp +++ b/zlibreference.cpp @@ -1,6 +1,6 @@ #include "zlibreference.h" +#include "localization.h" -#include #include #include #include @@ -12,15 +12,6 @@ #include #endif -namespace { - -QString translate(const char *text) -{ - return QCoreApplication::translate("ZlibReference", text); -} - -} // namespace - bool ZlibReference::systemIsZlibNg() { #if defined(Q_OS_LINUX) @@ -80,8 +71,7 @@ bool ZlibReference::applyTo(QProcessEnvironment &env, QString *note) const QString path = bundledPath(); if (path.isEmpty()) { if (note) - *note = translate("В системе zlib-ng, а эталонный libz.so.1 рядом с лаунчером " - "не найден — установщик почти наверняка не сойдётся по sha1."); + *note = Loc::text("zlib.warning.referenceMissing"); return false; } @@ -89,7 +79,7 @@ bool ZlibReference::applyTo(QProcessEnvironment &env, QString *note) const QString existing = env.value(QStringLiteral("LD_PRELOAD")); env.insert(QStringLiteral("LD_PRELOAD"), existing.isEmpty() ? path : path + u':' + existing); if (note) - *note = translate("Системный zlib-ng подменён эталонным zlib: %1") + *note = Loc::text("zlib.status.preloaded") .arg(QDir::toNativeSeparators(path)); return true; #else