Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff995ee518 | |||
| 7adcd780b0 | |||
| 2cb4249869 | |||
| 2528b6d587 | |||
| 215ba4d253 | |||
| 4b56720e64 | |||
| 324896a0ea | |||
| cf83a78b1d | |||
| 06bd1b4026 | |||
| 866900c606 | |||
| 2c16647f65 | |||
| 3001813d02 | |||
| 4fa796ea27 | |||
| 1250af21a1 | |||
| a171e63efb |
@@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
@@ -1,60 +1,363 @@
|
||||
# ---> C++
|
||||
# Prerequisites
|
||||
*.d
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.iobj
|
||||
*.pch
|
||||
|
||||
# Compiled Dynamic libraries
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# Fortran module files
|
||||
*.mod
|
||||
*.smod
|
||||
|
||||
# Compiled Static libraries
|
||||
*.lai
|
||||
*.la
|
||||
*.a
|
||||
*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
# ---> Java
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
@@ -1,663 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 2.15
|
||||
|
||||
// Пользовательские сборки одним окном: слева список со сменой активной,
|
||||
// справа карточка выбранной — имя, сервер, версия Minecraft и модлоадеры.
|
||||
//
|
||||
// Карточка сохраняет правки по ходу редактирования, отдельной кнопки
|
||||
// «Сохранить» нет: иначе появляется неочевидное несохранённое состояние,
|
||||
// пока пользователь переключается между сборками в левом списке.
|
||||
Dialog {
|
||||
id: buildsDialog
|
||||
|
||||
required property var backend
|
||||
|
||||
// Сборка, открытая в карточке справа. С активной не связана: активную
|
||||
// меняет отдельная кнопка, чтобы случайный клик по списку не запускал
|
||||
// архивацию .minecraft.
|
||||
property int editIndex: -1
|
||||
// Пока карточка заполняется, обработчики полей не должны писать обратно.
|
||||
property bool loading: false
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 880
|
||||
height: 600
|
||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
function selectBuild(index) {
|
||||
const data = buildsDialog.backend.customBuildAt(index)
|
||||
buildsDialog.loading = true
|
||||
buildsDialog.editIndex = index
|
||||
bdName.text = data.name || ""
|
||||
bdServer.text = data.serverUrl || ""
|
||||
bdMinecraft.selectedId = data.minecraftVersion || ""
|
||||
loaderPanel.applyBuild(data.loader || "", data.loaderVersion || "")
|
||||
buildsDialog.loading = false
|
||||
buildsDialog.refreshStatus()
|
||||
}
|
||||
|
||||
// Удаление необратимо и уносит с собой архив сборки, поэтому сначала
|
||||
// спрашиваем. Индекс запоминаем здесь: к моменту ответа строка списка под
|
||||
// курсором может быть уже другой.
|
||||
function askRemove(index) {
|
||||
const info = buildsDialog.backend.customBuildRemovalInfo(index)
|
||||
if (!info || info.name === undefined)
|
||||
return
|
||||
removeConfirm.buildIndex = index
|
||||
removeConfirm.buildName = info.name
|
||||
removeConfirm.hasArchive = info.hasArchive === true
|
||||
removeConfirm.active = info.active === true
|
||||
removeConfirm.lastOne = info.lastOne === true
|
||||
removeConfirm.open()
|
||||
}
|
||||
|
||||
function performRemove(index) {
|
||||
buildsDialog.backend.removeCustomBuild(index)
|
||||
const left = buildsDialog.backend.customBuildNames.length
|
||||
if (left === 0)
|
||||
buildsDialog.editIndex = -1
|
||||
else
|
||||
buildsDialog.selectBuild(Math.min(index, left - 1))
|
||||
}
|
||||
|
||||
function commit(fields) {
|
||||
if (buildsDialog.loading || buildsDialog.editIndex < 0)
|
||||
return
|
||||
buildsDialog.backend.updateCustomBuild(buildsDialog.editIndex, fields)
|
||||
buildsDialog.refreshStatus()
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
if (buildsDialog.editIndex < 0) {
|
||||
bdStatus.text = ""
|
||||
return
|
||||
}
|
||||
const problems = buildsDialog.backend.checkInstallation(buildsDialog.editIndex)
|
||||
bdStatus.ok = problems.length === 0
|
||||
bdStatus.text = problems.length === 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 = Loc.t.build.title.numbered.arg(n)
|
||||
if (taken.indexOf(candidate) === -1)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: removeConfirm
|
||||
|
||||
property int buildIndex: -1
|
||||
property string buildName: ""
|
||||
property bool hasArchive: false
|
||||
property bool active: false
|
||||
property bool lastOne: false
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 420
|
||||
parent: buildsDialog.parent
|
||||
x: (buildsDialog.parent.width - width) / 2
|
||||
y: (buildsDialog.parent.height - height) / 2
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#cc6666"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.build.title.removeConfirm
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Column {
|
||||
spacing: 10
|
||||
topPadding: 18
|
||||
bottomPadding: 18
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
wrapMode: Text.Wrap
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 13
|
||||
text: Loc.t.build.hint.removeIrreversible
|
||||
.arg(removeConfirm.buildName)
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
visible: removeConfirm.hasArchive
|
||||
wrapMode: Text.Wrap
|
||||
color: "#cc6666"
|
||||
font.pixelSize: 12
|
||||
text: Loc.t.build.warning.archiveGoesToo
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
visible: removeConfirm.active && !removeConfirm.lastOne
|
||||
wrapMode: Text.Wrap
|
||||
color: "#cc6666"
|
||||
font.pixelSize: 12
|
||||
text: Loc.t.build.warning.removingActive
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
visible: removeConfirm.lastOne
|
||||
wrapMode: Text.Wrap
|
||||
color: "#888888"
|
||||
font.pixelSize: 12
|
||||
text: Loc.t.build.hint.removingLast
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.cancel
|
||||
width: 130; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: removeConfirm.close()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.remove
|
||||
width: 130; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#8a3a3a" : "#663333"
|
||||
radius: 6
|
||||
border.color: "#cc6666"
|
||||
border.width: 1
|
||||
}
|
||||
onClicked: {
|
||||
const index = removeConfirm.buildIndex
|
||||
removeConfirm.close()
|
||||
buildsDialog.performRemove(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClosed: removeConfirm.buildIndex = -1
|
||||
}
|
||||
|
||||
VersionPickerDialog {
|
||||
id: versionPicker
|
||||
backend: buildsDialog.backend
|
||||
parent: buildsDialog.parent
|
||||
x: (buildsDialog.parent.width - width) / 2
|
||||
y: (buildsDialog.parent.height - height) / 2
|
||||
onVersionChosen: (versionId) => bdMinecraft.selectedId = versionId
|
||||
}
|
||||
|
||||
// Установка версии или модлоадера меняет комплектность сборки — строка
|
||||
// состояния должна это заметить, не дожидаясь переоткрытия окна.
|
||||
Connections {
|
||||
target: buildsDialog.backend
|
||||
function onInstalledVersionsChanged() { buildsDialog.refreshStatus() }
|
||||
}
|
||||
|
||||
onAboutToShow: {
|
||||
buildsDialog.backend.refreshVersionCatalog()
|
||||
const names = buildsDialog.backend.customBuildNames
|
||||
if (names.length === 0)
|
||||
buildsDialog.editIndex = -1
|
||||
else
|
||||
buildsDialog.selectBuild(Math.max(0, buildsDialog.backend.activeBuildIndex))
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.common.title.customBuilds
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 0
|
||||
|
||||
// ── Список сборок ───────────────────────────────────────────────
|
||||
Item {
|
||||
Layout.preferredWidth: 260
|
||||
Layout.fillHeight: true
|
||||
enabled: !buildsDialog.backend.switching
|
||||
|
||||
ListView {
|
||||
id: buildList
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: addBuildRow.top
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
spacing: 2
|
||||
model: buildsDialog.backend.customBuildNames
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: buildRow
|
||||
required property string modelData
|
||||
required property int index
|
||||
|
||||
width: buildList.width
|
||||
height: 40
|
||||
|
||||
contentItem: Item {
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.right: activeBadge.left
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: buildRow.modelData
|
||||
color: "#ffffff"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Text {
|
||||
id: activeBadge
|
||||
anchors.right: buildTrash.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||
text: Loc.t.build.label.active
|
||||
color: "#91B315"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
Image {
|
||||
id: buildTrash
|
||||
width: 16; height: 16
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: buildRow.hovered
|
||||
opacity: buildTrashArea.containsMouse ? 1.0 : 0.7
|
||||
source: "images/Trash.svg"
|
||||
sourceSize.width: 16; sourceSize.height: 16
|
||||
fillMode: Image.PreserveAspectFit
|
||||
|
||||
MouseArea {
|
||||
id: buildTrashArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
onClicked: buildsDialog.askRemove(buildRow.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||
? "#91B315" : "#232323"
|
||||
radius: 5
|
||||
}
|
||||
|
||||
onClicked: buildsDialog.selectBuild(buildRow.index)
|
||||
}
|
||||
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: addBuildRow
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
height: 34
|
||||
radius: 5
|
||||
color: addBuildArea.containsPress ? "#2d2d2d" : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.build.button.new
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: addBuildArea
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
buildsDialog.backend.addCustomBuild(buildsDialog.newBuildName(), "", "")
|
||||
buildsDialog.selectBuild(buildsDialog.backend.customBuildNames.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillHeight: true
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// ── Карточка выбранной сборки ───────────────────────────────────
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.switching
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
contentHeight: detailsColumn.height
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
Column {
|
||||
id: detailsColumn
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
LabelledField {
|
||||
id: bdName
|
||||
width: parent.width
|
||||
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: Loc.t.build.label.serverAddress
|
||||
placeholder: Loc.t.build.placeholder.serverAddress
|
||||
onEditingFinished: buildsDialog.commit({"serverUrl": bdServer.text.trim()})
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Loc.t.common.label.minecraftVersion
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
// Само поле только показывает выбор: версий около тысячи, и
|
||||
// разбираться в них удобнее в отдельном окне с категориями.
|
||||
Rectangle {
|
||||
id: bdMinecraft
|
||||
property string selectedId: ""
|
||||
|
||||
width: parent.width
|
||||
height: 36
|
||||
radius: 6
|
||||
color: "#2a2a2a"
|
||||
border.color: versionFieldArea.containsMouse ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
leftPadding: 10
|
||||
rightPadding: 110
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
color: bdMinecraft.selectedId === "" ? "#666666" : "#ffffff"
|
||||
text: bdMinecraft.selectedId === "" ? Loc.t.build.button.pickVersion
|
||||
: bdMinecraft.selectedId
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Loc.t.common.button.choose
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: versionFieldArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: versionPicker.openFor(bdMinecraft.selectedId)
|
||||
}
|
||||
|
||||
onSelectedIdChanged: buildsDialog.commit({"minecraftVersion":
|
||||
bdMinecraft.selectedId})
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Loc.t.build.label.modLoader
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
// Лоадеры несовместимы между собой: игра запускается ровно с
|
||||
// одним профилем versions/<id>. Поэтому отметка одного снимает
|
||||
// остальные, а «ничего не отмечено» — это чистая ваниль.
|
||||
Column {
|
||||
id: loaderPanel
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
property var rows: [bdForge, bdFabric, bdNeoForge, bdQuilt]
|
||||
|
||||
function applyBuild(loader, loaderVersion) {
|
||||
for (var i = 0; i < loaderPanel.rows.length; ++i)
|
||||
loaderPanel.rows[i].applyBuild(loader, loaderVersion)
|
||||
}
|
||||
|
||||
function selectedRow() {
|
||||
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||
if (loaderPanel.rows[i].checked)
|
||||
return loaderPanel.rows[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function keepOnly(row) {
|
||||
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||
if (loaderPanel.rows[i] === row)
|
||||
continue
|
||||
loaderPanel.rows[i].applyBuild("", "")
|
||||
}
|
||||
}
|
||||
|
||||
function commitSelection() {
|
||||
const row = loaderPanel.selectedRow()
|
||||
buildsDialog.commit({
|
||||
"loader": row ? row.loaderKey : "",
|
||||
"loaderVersion": row ? row.selectedVersion : "",
|
||||
// Профиль появится только после установки: пока
|
||||
// сборка запускается на чистой ванили.
|
||||
"resolvedVersionId": ""
|
||||
})
|
||||
}
|
||||
|
||||
LoaderRow {
|
||||
id: bdForge
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "forge"
|
||||
title: Loc.t.loader.name.forge
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdForge)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
LoaderRow {
|
||||
id: bdFabric
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "fabric"
|
||||
title: Loc.t.loader.name.fabric
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdFabric)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
LoaderRow {
|
||||
id: bdNeoForge
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "neoforge"
|
||||
title: Loc.t.loader.name.neoforge
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdNeoForge)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
LoaderRow {
|
||||
id: bdQuilt
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "quilt"
|
||||
title: Loc.t.loader.name.quilt
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdQuilt)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: bdStatus
|
||||
property bool ok: false
|
||||
width: parent.width
|
||||
color: bdStatus.ok ? "#8fbf5a" : "#cc6666"
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.install
|
||||
width: 140; height: 36
|
||||
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.busy
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: buildsDialog.backend.installCustomBuild(buildsDialog.editIndex)
|
||||
}
|
||||
|
||||
Button {
|
||||
text: Loc.t.build.button.makeActive
|
||||
width: 170; height: 36
|
||||
enabled: buildsDialog.editIndex >= 0
|
||||
&& buildsDialog.editIndex !== buildsDialog.backend.activeBuildIndex
|
||||
&& !buildsDialog.backend.busy
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: buildsDialog.backend.activeBuildIndex = buildsDialog.editIndex
|
||||
}
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.close
|
||||
width: 120; height: 36
|
||||
enabled: !buildsDialog.backend.switching
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: buildsDialog.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +1,21 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
# C нужен для эталонного zlib: см. блок про установщики Forge ниже.
|
||||
project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX C)
|
||||
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# CorePrivate нужен ради QZipReader — им распаковываются нативные библиотеки LWJGL.
|
||||
# Про привязку к версии Qt из-за него предупреждать не нужно: это осознанный выбор.
|
||||
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Core CorePrivate Gui Network)
|
||||
|
||||
# ── Qt WebEngine: окно входа в аккаунт Microsoft ────────────────────────────
|
||||
# CMakeList.txt: проект CMake для minecraft-launcher; включите исходный код и определения,
|
||||
# укажите здесь логику для конкретного проекта.
|
||||
#
|
||||
# Модуль необязательный, и это принципиально: он ставится отдельной галочкой в
|
||||
# установщике Qt и тянет за собой WebChannel с Positioning, которых в типовой
|
||||
# установке нет. Если сделать его обязательным, у любого, кто их не поставил,
|
||||
# проект перестаёт конфигурироваться целиком — вместе с офлайном и Ely.by.
|
||||
#
|
||||
# Без модуля лаунчер собирается и работает как раньше, только вход через
|
||||
# Microsoft сообщает, что эта сборка его не умеет.
|
||||
find_package(Qt6 QUIET COMPONENTS WebEngineQuick)
|
||||
if(Qt6WebEngineQuick_FOUND)
|
||||
message(STATUS "Qt WebEngine найден: вход через аккаунт Microsoft будет доступен")
|
||||
set(LAUNCHER_MSA_QML_FILES MicrosoftLoginDialog.qml)
|
||||
else()
|
||||
message(WARNING
|
||||
"Qt WebEngine не найден — лаунчер соберётся без входа через аккаунт Microsoft. "
|
||||
"Чтобы его включить, доустановите в MaintenanceTool для своей версии Qt сразу три "
|
||||
"компонента: Qt WebEngine, Qt WebChannel и Qt Positioning.")
|
||||
set(LAUNCHER_MSA_QML_FILES "")
|
||||
cmake_minimum_required (VERSION 3.8)
|
||||
|
||||
# Включение горячей перезагрузки для компиляторов MSVC, если поддерживается.
|
||||
if (POLICY CMP0141)
|
||||
cmake_policy(SET CMP0141 NEW)
|
||||
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<IF:$<AND:$<C_COMPILER_ID:MSVC>,$<CXX_COMPILER_ID:MSVC>>,$<$<CONFIG:Debug,RelWithDebInfo>:EditAndContinue>,$<$<CONFIG:Debug,RelWithDebInfo>:ProgramDatabase>>")
|
||||
endif()
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.8)
|
||||
project ("minecraft-launcher")
|
||||
|
||||
qt_add_executable(appMinecraft_launcher
|
||||
main.cpp
|
||||
)
|
||||
# Добавьте источник в исполняемый файл этого проекта.
|
||||
add_executable (minecraft-launcher "minecraft-launcher.cpp" "minecraft-launcher.h")
|
||||
|
||||
qt_add_qml_module(appMinecraft_launcher
|
||||
URI Minecraft_launcher
|
||||
SOURCES
|
||||
localization.h
|
||||
localization.cpp
|
||||
launcherbackend.h
|
||||
launcherbackend.cpp
|
||||
launcherpaths.h
|
||||
launcherpaths.cpp
|
||||
minecraftversion.h
|
||||
minecraftversion.cpp
|
||||
javalocator.h
|
||||
javalocator.cpp
|
||||
zlibreference.h
|
||||
zlibreference.cpp
|
||||
javaruntime.h
|
||||
javaruntime.cpp
|
||||
javaruntimeservice.h
|
||||
javaruntimeservice.cpp
|
||||
javainstaller.h
|
||||
javainstaller.cpp
|
||||
authservice.h
|
||||
authservice.cpp
|
||||
msaauthservice.h
|
||||
msaauthservice.cpp
|
||||
gamelauncher.h
|
||||
gamelauncher.cpp
|
||||
versionmanifestservice.h
|
||||
versionmanifestservice.cpp
|
||||
versioninstaller.h
|
||||
versioninstaller.cpp
|
||||
modloader.h
|
||||
modloaderversionservice.h
|
||||
modloaderversionservice.cpp
|
||||
modloaderinstaller.h
|
||||
modloaderinstaller.cpp
|
||||
buildarchiveworker.h
|
||||
buildarchiveworker.cpp
|
||||
buildswitcher.h
|
||||
buildswitcher.cpp
|
||||
seasonalbuildservice.h
|
||||
seasonalbuildservice.cpp
|
||||
seasonalpackdownloader.h
|
||||
seasonalpackdownloader.cpp
|
||||
QML_FILES
|
||||
Main.qml
|
||||
DarkCombo.qml
|
||||
VersionPickerDialog.qml
|
||||
JavaPickerDialog.qml
|
||||
LabelledField.qml
|
||||
BuildsDialog.qml
|
||||
SeasonalBuildsDialog.qml
|
||||
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 i18n/translations.json
|
||||
)
|
||||
|
||||
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
|
||||
# If you are developing for iOS or macOS you should consider setting an
|
||||
# explicit, fixed bundle identifier manually though.
|
||||
set_target_properties(appMinecraft_launcher PROPERTIES
|
||||
# MACOSX_BUNDLE_GUI_IDENTIFIER com.example.appMinecraft_launcher
|
||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
|
||||
MACOSX_BUNDLE TRUE
|
||||
WIN32_EXECUTABLE TRUE
|
||||
)
|
||||
|
||||
target_link_libraries(appMinecraft_launcher
|
||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||
)
|
||||
|
||||
if(Qt6WebEngineQuick_FOUND)
|
||||
target_link_libraries(appMinecraft_launcher PRIVATE Qt6::WebEngineQuick)
|
||||
target_compile_definitions(appMinecraft_launcher PRIVATE LAUNCHER_HAS_WEBENGINE)
|
||||
if (CMAKE_VERSION VERSION_GREATER 3.12)
|
||||
set_property(TARGET minecraft-launcher PROPERTY CXX_STANDARD 20)
|
||||
endif()
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
# ── Эталонный zlib для установщиков Forge и NeoForge ────────────────────────
|
||||
#
|
||||
# Установщик Forge сверяет sha1 каждого jar, который сам же и собирает, с
|
||||
# эталоном из install_profile.json. Эталон посчитан на обычном zlib, а
|
||||
# дистрибутивы вроде CachyOS и Fedora подставляют вместо него zlib-ng: сжатие
|
||||
# корректное, но побайтово другое, поэтому установка падает на любой версии
|
||||
# игры («Processor failed, invalid outputs»). Сменить Java не выйдет — сборки
|
||||
# OpenJDK под Linux берут libz.so.1 из системы.
|
||||
#
|
||||
# Поэтому собираем обычный zlib из zlib/ — его исходники лежат в репозитории,
|
||||
# чтобы сборка не зависела от сети, а версия была зафиксирована: от неё зависит
|
||||
# побайтовый результат сжатия. Готовую библиотеку подсовываем через LD_PRELOAD
|
||||
# только процессу установщика. Сам лаунчер с ней не линкуется, системный
|
||||
# zlib-ng остаётся на месте.
|
||||
if(UNIX AND NOT APPLE)
|
||||
set(LAUNCHER_ZLIB_DIR "${CMAKE_INSTALL_LIBDIR}/galeonLauncher")
|
||||
set(LAUNCHER_ZLIB_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/zlib")
|
||||
|
||||
add_library(launcher_zlib_reference SHARED
|
||||
${LAUNCHER_ZLIB_SOURCES}/adler32.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/compress.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/crc32.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/deflate.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/gzclose.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/gzlib.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/gzread.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/gzwrite.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/infback.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/inffast.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/inflate.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/inftrees.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/trees.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/uncompr.c
|
||||
${LAUNCHER_ZLIB_SOURCES}/zutil.c
|
||||
)
|
||||
target_include_directories(launcher_zlib_reference PRIVATE ${LAUNCHER_ZLIB_SOURCES})
|
||||
# То же, что выставляет собственный configure zlib: без этого gz*-часть не
|
||||
# видит unistd.h и не собирается.
|
||||
target_compile_definitions(launcher_zlib_reference PRIVATE
|
||||
HAVE_UNISTD_H=1 HAVE_STDARG_H=1 _LARGEFILE64_SOURCE=1)
|
||||
# SONAME libz.so.1 обязателен: с ним ld.so считает предзагруженный объект
|
||||
# закрывающим зависимость, и системный zlib-ng в процессе java не появится.
|
||||
set_target_properties(launcher_zlib_reference PROPERTIES
|
||||
OUTPUT_NAME z
|
||||
VERSION 1.3.1
|
||||
SOVERSION 1
|
||||
C_VISIBILITY_PRESET default
|
||||
LIBRARY_OUTPUT_DIRECTORY $<TARGET_FILE_DIR:appMinecraft_launcher>/zlib
|
||||
)
|
||||
add_dependencies(appMinecraft_launcher launcher_zlib_reference)
|
||||
|
||||
target_compile_definitions(appMinecraft_launcher PRIVATE
|
||||
LAUNCHER_ZLIB_INSTALL_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/galeonLauncher"
|
||||
)
|
||||
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
||||
endif()
|
||||
|
||||
install(TARGETS appMinecraft_launcher
|
||||
BUNDLE DESTINATION .
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
)
|
||||
# TODO: Добавьте тесты и целевые объекты, если это необходимо.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "windows-base",
|
||||
"hidden": true,
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_C_COMPILER": "cl.exe",
|
||||
"CMAKE_CXX_COMPILER": "cl.exe"
|
||||
},
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x64-debug",
|
||||
"displayName": "x64 Debug",
|
||||
"inherits": "windows-base",
|
||||
"architecture": {
|
||||
"value": "x64",
|
||||
"strategy": "external"
|
||||
},
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x64-release",
|
||||
"displayName": "x64 Release",
|
||||
"inherits": "x64-debug",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x86-debug",
|
||||
"displayName": "x86 Debug",
|
||||
"inherits": "windows-base",
|
||||
"architecture": {
|
||||
"value": "x86",
|
||||
"strategy": "external"
|
||||
},
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x86-release",
|
||||
"displayName": "x86 Release",
|
||||
"inherits": "x86-debug",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linux-debug",
|
||||
"displayName": "Linux Debug",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
},
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Linux"
|
||||
},
|
||||
"vendor": {
|
||||
"microsoft.com/VisualStudioRemoteSettings/CMake/1.0": {
|
||||
"sourceDir": "$env{HOME}/.vs/$ms{projectDirName}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "macos-debug",
|
||||
"displayName": "macOS Debug",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
},
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Darwin"
|
||||
},
|
||||
"vendor": {
|
||||
"microsoft.com/VisualStudioRemoteSettings/CMake/1.0": {
|
||||
"sourceDir": "$env{HOME}/.vs/$ms{projectDirName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Комбобокс в общем тёмном стиле окна.
|
||||
ComboBox {
|
||||
id: darkCombo
|
||||
|
||||
// Та же стрелка, что у комбобоксов профиля и версии на главном окне,
|
||||
// вместо двойного шеврона Basic-стиля.
|
||||
indicator: Image {
|
||||
width: 10; height: 10
|
||||
x: darkCombo.width - width - 12
|
||||
y: (darkCombo.height - height) / 2
|
||||
source: darkCombo.down ? "images/Profile_Box/Asset_23.svg"
|
||||
: "images/Profile_Box/Asset_24.svg"
|
||||
rotation: 180
|
||||
sourceSize.width: 10; sourceSize.height: 10
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 10
|
||||
rightPadding: darkCombo.indicator.width + 22
|
||||
text: darkCombo.displayText
|
||||
color: "#ffffff"
|
||||
font: darkCombo.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: darkCombo.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: darkCombo.width
|
||||
highlighted: darkCombo.highlightedIndex === index
|
||||
contentItem: Text {
|
||||
text: modelData
|
||||
color: "#ffffff"
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" }
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: darkCombo.height
|
||||
width: darkCombo.width
|
||||
implicitHeight: Math.min(contentItem.implicitHeight, 220)
|
||||
padding: 1
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: darkCombo.delegateModel
|
||||
currentIndex: darkCombo.highlightedIndex
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
}
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 2.15
|
||||
|
||||
// Выбор сборки Java отдельным окном: слева типы, справа сами версии с поиском.
|
||||
// Устроено так же, как выбор версии Minecraft, но выбранное здесь ещё и
|
||||
// качается — в отличие от версий игры, где загрузку начинает сама сборка.
|
||||
Dialog {
|
||||
id: picker
|
||||
|
||||
required property var backend
|
||||
|
||||
// Сборка, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
|
||||
property string selectedId: ""
|
||||
property string category: "java"
|
||||
property string filterText: ""
|
||||
property bool installedOnly: false
|
||||
|
||||
// Java, ниже которой выбранной версии игры не запуститься. 0 — версия игры
|
||||
// не выбрана, предупреждать не о чем.
|
||||
property int requiredMajor: 0
|
||||
|
||||
signal runtimeChosen(string runtimeId)
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 720
|
||||
height: 480
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
readonly property var categories: [
|
||||
{ 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++ уже отсортированным (новые сверху, скачанные
|
||||
// первыми), поэтому здесь только отбор — порядок не трогаем.
|
||||
readonly property var visibleEntries: {
|
||||
const query = picker.filterText.trim().toLowerCase()
|
||||
const source = picker.backend.javaCatalog
|
||||
const out = []
|
||||
for (var i = 0; i < source.length; ++i) {
|
||||
const entry = source[i]
|
||||
if (entry.kind !== picker.category)
|
||||
continue
|
||||
if (picker.installedOnly && !entry.installed)
|
||||
continue
|
||||
if (query !== "" && entry.search.indexOf(query) === -1)
|
||||
continue
|
||||
out.push(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
readonly property var selectedEntry: {
|
||||
const list = picker.backend.javaCatalog
|
||||
for (var i = 0; i < list.length; ++i) {
|
||||
if (list[i].id === picker.selectedId)
|
||||
return list[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openFor(runtimeId, required) {
|
||||
picker.selectedId = runtimeId
|
||||
picker.requiredMajor = required || 0
|
||||
picker.filterText = ""
|
||||
searchField.text = ""
|
||||
picker.category = picker.categoryOf(runtimeId)
|
||||
picker.backend.refreshJavaCatalog()
|
||||
picker.open()
|
||||
picker.revealSelected()
|
||||
}
|
||||
|
||||
function categoryOf(runtimeId) {
|
||||
const source = picker.backend.javaCatalog
|
||||
for (var i = 0; i < source.length; ++i) {
|
||||
if (source[i].id === runtimeId)
|
||||
return source[i].kind
|
||||
}
|
||||
return "java"
|
||||
}
|
||||
|
||||
function indexOfSelected() {
|
||||
const list = picker.visibleEntries
|
||||
for (var i = 0; i < list.length; ++i) {
|
||||
if (list[i].id === picker.selectedId)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function revealSelected() {
|
||||
const index = picker.indexOfSelected()
|
||||
runtimeList.currentIndex = index
|
||||
if (index >= 0)
|
||||
runtimeList.positionViewAtIndex(index, ListView.Center)
|
||||
}
|
||||
|
||||
// «Выбрать» и скачивает: сборка, которой нет на диске, запуску не поможет.
|
||||
function acceptSelection() {
|
||||
const entry = picker.selectedEntry
|
||||
if (!entry)
|
||||
return
|
||||
picker.runtimeChosen(entry.id)
|
||||
if (!entry.installed && entry.downloadable)
|
||||
picker.backend.installJavaRuntime(entry.id)
|
||||
picker.close()
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.java.label.version
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 0
|
||||
|
||||
// ── Типы сборок ─────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 170
|
||||
Layout.fillHeight: true
|
||||
color: "transparent"
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: picker.categories
|
||||
|
||||
delegate: Rectangle {
|
||||
id: categoryRow
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: 5
|
||||
color: picker.category === categoryRow.modelData.key ? "#91B315"
|
||||
: categoryArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
text: categoryRow.modelData.title
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Text {
|
||||
text: categoryRow.modelData.hint
|
||||
color: picker.category === categoryRow.modelData.key
|
||||
? "#1e1e1e" : "#777777"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: categoryArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
picker.category = categoryRow.modelData.key
|
||||
picker.revealSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 8 }
|
||||
|
||||
// Требование выбранной сборки — чтобы не пришлось держать в
|
||||
// голове, какая Java нужна какой версии игры.
|
||||
Text {
|
||||
width: parent.width - 4
|
||||
visible: picker.requiredMajor > 0
|
||||
wrapMode: Text.Wrap
|
||||
color: "#888888"
|
||||
font.pixelSize: 10
|
||||
text: Loc.t.java.hint.required
|
||||
.arg(picker.requiredMajor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillHeight: true
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// ── Версии ──────────────────────────────────────────────────────
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
height: 32
|
||||
placeholderText: Loc.t.common.placeholder.searchVersion
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
color: "#232323"
|
||||
radius: 5
|
||||
border.color: searchField.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
onTextChanged: {
|
||||
picker.filterText = text
|
||||
runtimeList.currentIndex = picker.indexOfSelected()
|
||||
}
|
||||
Keys.onEscapePressed: picker.close()
|
||||
Keys.onReturnPressed: picker.acceptSelection()
|
||||
Keys.onEnterPressed: picker.acceptSelection()
|
||||
Keys.onDownPressed: runtimeList.step(1)
|
||||
Keys.onUpPressed: runtimeList.step(-1)
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: installedOnlyBox
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 4
|
||||
height: 28
|
||||
checked: picker.installedOnly
|
||||
onToggled: {
|
||||
picker.installedOnly = checked
|
||||
picker.revealSelected()
|
||||
}
|
||||
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 16
|
||||
implicitHeight: 16
|
||||
x: 0
|
||||
y: (installedOnlyBox.height - height) / 2
|
||||
radius: 4
|
||||
color: installedOnlyBox.checked ? "#91B315" : "#2a2a2a"
|
||||
border.color: installedOnlyBox.checked ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: installedOnlyBox.checked
|
||||
text: "✓"
|
||||
color: "#1e1e1e"
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
leftPadding: installedOnlyBox.indicator.width + 8
|
||||
text: Loc.t.java.filter.downloadedOnly
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: runtimeList
|
||||
anchors.top: searchField.bottom
|
||||
anchors.topMargin: 6
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: installedOnlyBox.top
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
clip: true
|
||||
spacing: 1
|
||||
model: picker.visibleEntries
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
function step(delta) {
|
||||
const next = runtimeList.currentIndex + delta
|
||||
if (next < 0 || next >= runtimeList.count)
|
||||
return
|
||||
runtimeList.currentIndex = next
|
||||
runtimeList.positionViewAtIndex(next, ListView.Contain)
|
||||
picker.selectedId = picker.visibleEntries[next].id
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: runtimeRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool chosen: picker.selectedId === runtimeRow.modelData.id
|
||||
// Сборка ниже требования игры выбирается, но помечается: она
|
||||
// может пригодиться для другой сборки.
|
||||
readonly property bool tooOld: picker.requiredMajor > 0
|
||||
&& runtimeRow.modelData.major < picker.requiredMajor
|
||||
|
||||
width: runtimeList.width
|
||||
height: 44
|
||||
radius: 4
|
||||
color: runtimeRow.chosen ? "#91B315"
|
||||
: runtimeArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: rightMarks.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Row {
|
||||
spacing: 6
|
||||
Text {
|
||||
text: runtimeRow.modelData.label
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Text {
|
||||
visible: runtimeRow.modelData.lts
|
||||
text: "LTS"
|
||||
color: runtimeRow.chosen ? "#1e1e1e" : "#91B315"
|
||||
font.pixelSize: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
// Колонка растянута якорями, поэтому её ширина
|
||||
// задана, а не выведена из детей — цикла тут нет.
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
color: runtimeRow.chosen ? "#1e1e1e"
|
||||
: runtimeRow.tooOld ? "#aa7777" : "#888888"
|
||||
font.pixelSize: 10
|
||||
text: {
|
||||
const parts = [runtimeRow.modelData.detail,
|
||||
runtimeRow.modelData.coverage]
|
||||
if (runtimeRow.modelData.sizeMb > 0)
|
||||
parts.push(Loc.t.common.status.megabytes.arg(runtimeRow.modelData.sizeMb))
|
||||
if (runtimeRow.tooOld)
|
||||
parts.push(Loc.t.java.warning.tooOldForVersion)
|
||||
return parts.join(" · ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: rightMarks
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
visible: runtimeRow.modelData.installed
|
||||
text: "✓"
|
||||
color: runtimeRow.chosen ? "#1e1e1e" : "#91B315"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Сборки весят по двести мегабайт — удалять их нужно
|
||||
// прямо здесь, иначе папка лаунчера растёт молча.
|
||||
Image {
|
||||
visible: runtimeRow.modelData.installed
|
||||
source: "images/Trash.svg"
|
||||
width: 14
|
||||
height: 14
|
||||
fillMode: Image.PreserveAspectFit
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: trashArea.containsMouse ? 1.0 : 0.55
|
||||
|
||||
MouseArea {
|
||||
id: trashArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -5
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
picker.backend.removeJavaRuntime(runtimeRow.modelData.id)
|
||||
if (picker.selectedId === runtimeRow.modelData.id)
|
||||
picker.selectedId = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: runtimeArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 40
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
picker.selectedId = runtimeRow.modelData.id
|
||||
runtimeList.currentIndex = runtimeRow.index
|
||||
}
|
||||
onDoubleClicked: {
|
||||
picker.selectedId = runtimeRow.modelData.id
|
||||
picker.acceptSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустая категория, ничего не нашлось или каталог ещё не пришёл.
|
||||
Text {
|
||||
anchors.centerIn: runtimeList
|
||||
width: runtimeList.width - 20
|
||||
visible: picker.visibleEntries.length === 0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
color: "#888888"
|
||||
font.pixelSize: 12
|
||||
font.italic: true
|
||||
text: picker.backend.javaCatalogLoading
|
||||
? Loc.t.java.progress.loadingCatalog
|
||||
: picker.backend.javaCatalog.length === 0
|
||||
? Loc.t.java.error.catalogUnavailable
|
||||
: Loc.t.common.status.nothingFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 320
|
||||
elide: Text.ElideRight
|
||||
color: picker.selectedEntry === null ? "#666666" : "#91B315"
|
||||
font.pixelSize: 13
|
||||
text: picker.selectedEntry === null
|
||||
? Loc.t.common.status.noBuildSelected
|
||||
: picker.selectedEntry.installed
|
||||
? picker.selectedEntry.label
|
||||
: Loc.t.java.status.willDownload.arg(picker.selectedEntry.label)
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.cancel
|
||||
width: 110; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: picker.close()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: picker.selectedEntry !== null && !picker.selectedEntry.installed
|
||||
? Loc.t.common.button.download : Loc.t.common.button.select
|
||||
width: 110; height: 36
|
||||
enabled: picker.selectedEntry !== null
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: picker.acceptSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: searchField.forceActiveFocus()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [year] [fullname]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,39 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Подпись + поле ввода одной колонкой — используется в настройках.
|
||||
Column {
|
||||
id: labelled
|
||||
|
||||
property alias text: field.text
|
||||
property alias validator: field.validator
|
||||
property string label: ""
|
||||
property string placeholder: ""
|
||||
|
||||
// Правка закончена: поле потеряло фокус или пользователь нажал Enter.
|
||||
signal editingFinished()
|
||||
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
text: labelled.label
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: field
|
||||
width: labelled.width
|
||||
onEditingFinished: labelled.editingFinished()
|
||||
height: 32
|
||||
placeholderText: labelled.placeholder
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: field.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий
|
||||
// именно под выбранную версию Minecraft.
|
||||
//
|
||||
// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже
|
||||
// под конкретную версию игры, поэтому несовместимой строки в нём не бывает.
|
||||
// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже
|
||||
// показывается словами, а не пустым выпадающим списком.
|
||||
Column {
|
||||
id: loaderRow
|
||||
|
||||
required property var backend
|
||||
required property string loaderKey
|
||||
required property string title
|
||||
|
||||
// Версия Minecraft, выбранная в карточке. Её смена сбрасывает выбор.
|
||||
property string gameVersion: ""
|
||||
|
||||
property alias checked: box.checked
|
||||
property string selectedVersion: ""
|
||||
property var entries: []
|
||||
|
||||
// Пользователь щёлкнул чекбокс — карточка снимет отметки с остальных
|
||||
// лоадеров: одновременно в .minecraft может жить только один.
|
||||
signal userChecked()
|
||||
// Отметка или версия изменились — карточке пора сохранить сборку.
|
||||
signal changed()
|
||||
|
||||
// Ставится, пока карточка заполняет строку данными сборки: в это время
|
||||
// обработчики не должны считать изменения пользовательскими.
|
||||
property bool applying: false
|
||||
|
||||
spacing: 4
|
||||
|
||||
function versionIndex(version) {
|
||||
for (var i = 0; i < loaderRow.entries.length; ++i) {
|
||||
if (loaderRow.entries[i].version === version)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Единственное место, где меняются entries и selectedVersion. Через него
|
||||
// проходят все три пути — кэш, ответ сети и заполнение из сборки, — потому
|
||||
// что выбранная версия обязана существовать в списке под текущую версию
|
||||
// игры. Раньше проверял только путь из сети, и версия лоадера от прошлой
|
||||
// версии игры доживала до сохранения сборки.
|
||||
function applyEntries(list) {
|
||||
loaderRow.entries = list
|
||||
|
||||
// Список отсортирован новыми вперёд, поэтому максимально доступная
|
||||
// версия для этой версии игры — всегда первая строка.
|
||||
if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
|
||||
const fallback = list.length > 0 ? list[0].version : ""
|
||||
const changedValue = loaderRow.selectedVersion !== fallback
|
||||
loaderRow.selectedVersion = fallback
|
||||
if (changedValue && !loaderRow.applying)
|
||||
loaderRow.changed()
|
||||
}
|
||||
|
||||
combo.currentIndex = loaderRow.versionIndex(loaderRow.selectedVersion)
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (!box.checked || loaderRow.gameVersion === "") {
|
||||
loaderRow.applyEntries([])
|
||||
return
|
||||
}
|
||||
// Сначала из кэша — список появляется мгновенно, — потом обновление сети.
|
||||
loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey,
|
||||
loaderRow.gameVersion))
|
||||
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||
}
|
||||
|
||||
// Заполнение из сохранённой сборки: без сигналов changed().
|
||||
function applyBuild(loader, loaderVersion) {
|
||||
loaderRow.applying = true
|
||||
box.checked = loader === loaderRow.loaderKey
|
||||
// Версию из сборки берём как пожелание: если под выбранную версию игры
|
||||
// её нет, applyEntries() заменит её на максимально доступную.
|
||||
loaderRow.selectedVersion = box.checked ? loaderVersion : ""
|
||||
loaderRow.reload()
|
||||
loaderRow.applying = false
|
||||
}
|
||||
|
||||
onGameVersionChanged: {
|
||||
// Сборка лоадера привязана к версии игры: под новой версией прежний
|
||||
// выбор недействителен, и список перезапрашивается заново.
|
||||
loaderRow.selectedVersion = ""
|
||||
loaderRow.reload()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: loaderRow.backend
|
||||
function onLoaderVersionsChanged(key, game) {
|
||||
if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked)
|
||||
return
|
||||
loaderRow.applyEntries(loaderRow.backend.loaderVersions(key, game))
|
||||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: box
|
||||
text: loaderRow.title
|
||||
enabled: loaderRow.gameVersion !== ""
|
||||
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
x: 0
|
||||
y: (box.height - height) / 2
|
||||
radius: 4
|
||||
color: box.checked ? "#91B315" : "#2a2a2a"
|
||||
border.color: box.checked ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: box.checked
|
||||
text: "✓"
|
||||
color: "#1e1e1e"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: box.indicator.width + 8
|
||||
text: box.text
|
||||
color: box.enabled ? "#ffffff" : "#666666"
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
onToggled: {
|
||||
if (box.checked)
|
||||
loaderRow.userChecked()
|
||||
else
|
||||
loaderRow.selectedVersion = ""
|
||||
loaderRow.reload()
|
||||
loaderRow.changed()
|
||||
}
|
||||
}
|
||||
|
||||
DarkCombo {
|
||||
id: combo
|
||||
visible: box.checked && loaderRow.entries.length > 0
|
||||
width: loaderRow.width - 26
|
||||
height: 32
|
||||
x: 26
|
||||
model: loaderRow.entries.map(entry => entry.label)
|
||||
|
||||
onActivated: (index) => {
|
||||
if (index < 0 || index >= loaderRow.entries.length)
|
||||
return
|
||||
loaderRow.selectedVersion = loaderRow.entries[index].version
|
||||
loaderRow.changed()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: box.checked && loaderRow.entries.length === 0
|
||||
x: 26
|
||||
width: loaderRow.width - 26
|
||||
wrapMode: Text.Wrap
|
||||
font.pixelSize: 11
|
||||
color: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||
? "#888888" : "#cc6666"
|
||||
text: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||
? Loc.t.loader.progress.loadingVersions.arg(loaderRow.title)
|
||||
: Loc.t.loader.status.unsupported.arg(loaderRow.title)
|
||||
.arg(loaderRow.gameVersion)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
import QtWebEngine
|
||||
|
||||
// Вход в аккаунт Microsoft — то же самое, что делает официальный лаунчер:
|
||||
// страница входа показывается внутри приложения, а лаунчер ждёт, когда браузер
|
||||
// уйдёт на redirect_uri с кодом авторизации в адресе.
|
||||
Dialog {
|
||||
id: msLogin
|
||||
|
||||
required property var backend
|
||||
|
||||
// Окно ничего не знает про тосты главного окна: о неудаче сообщает сигналом.
|
||||
signal failed(string message)
|
||||
|
||||
// Код уже отдан backend — второй раз по тому же адресу не реагируем:
|
||||
// WebEngineView успевает сообщить об urlChanged несколько раз.
|
||||
property bool codeTaken: false
|
||||
|
||||
anchors.centerIn: parent
|
||||
modal: true
|
||||
width: 560
|
||||
height: 680
|
||||
padding: 0
|
||||
closePolicy: Popup.NoAutoClose
|
||||
|
||||
function openAt(url) {
|
||||
codeTaken = false
|
||||
web.url = url
|
||||
open()
|
||||
}
|
||||
|
||||
// Разбор адреса живёт в C++: правила совпадения должны совпадать с теми,
|
||||
// по которым сервис строит сам redirect_uri.
|
||||
function handleUrl(url) {
|
||||
if (codeTaken)
|
||||
return
|
||||
const info = backend.inspectMicrosoftRedirect(String(url))
|
||||
if (!info.matched)
|
||||
return
|
||||
|
||||
codeTaken = true
|
||||
close()
|
||||
if (info.code !== "") {
|
||||
backend.finishMicrosoftLogin(info.code)
|
||||
} else {
|
||||
backend.cancelMicrosoftLogin()
|
||||
msLogin.failed(info.error !== "" ? info.error : Loc.t.auth.msa.error.incomplete)
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.auth.msa.title.signIn
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
// Профиль без storageName — значит без диска: куки живут только пока
|
||||
// работает лаунчер и в общий браузер не попадают. За выбор аккаунта в
|
||||
// пределах сессии отвечает prompt=select_account в адресе входа.
|
||||
WebEngineProfilePrototype {
|
||||
id: msProfile
|
||||
}
|
||||
|
||||
WebEngineView {
|
||||
id: web
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
|
||||
profile: msProfile.instance()
|
||||
|
||||
onUrlChanged: msLogin.handleUrl(url)
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: web.loading
|
||||
visible: running
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
Button {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.common.button.cancel
|
||||
width: 110; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: {
|
||||
msLogin.codeTaken = true
|
||||
msLogin.close()
|
||||
msLogin.backend.cancelMicrosoftLogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Плашка хода долгой операции в левом нижнем углу: не задевает кнопку запуска,
|
||||
// сообщение по центру и кнопки папки с настройками.
|
||||
Rectangle {
|
||||
id: panel
|
||||
|
||||
property string title: ""
|
||||
property string status: ""
|
||||
// -1 — итог ещё неизвестен, показываем «…» вместо процентов.
|
||||
property double fraction: -1
|
||||
// Необязательная вторая строка: мегабайты у загрузки, путь у архивации.
|
||||
property string detail: ""
|
||||
property bool cancellable: true
|
||||
|
||||
signal cancelRequested()
|
||||
|
||||
width: 320
|
||||
height: 72
|
||||
radius: 8
|
||||
color: "#1e1e1e"
|
||||
opacity: 0.94
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
x: 12; y: 8
|
||||
width: parent.width - 70
|
||||
elide: Text.ElideRight
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
text: panel.title
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
y: 8
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
text: panel.fraction < 0 ? "…" : Math.round(panel.fraction * 100) + "%"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
x: 12; y: 30
|
||||
width: parent.width - 24
|
||||
height: 6
|
||||
radius: 3
|
||||
color: "#2a2a2a"
|
||||
|
||||
Rectangle {
|
||||
width: panel.fraction > 0 ? track.width * panel.fraction : 0
|
||||
height: parent.height
|
||||
radius: 3
|
||||
color: "#91B315"
|
||||
Behavior on width { NumberAnimation { duration: 120 } }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 12; y: 44
|
||||
width: parent.width - 40
|
||||
elide: Text.ElideMiddle
|
||||
color: "#888888"
|
||||
font.pixelSize: 10
|
||||
text: panel.detail !== "" ? panel.detail : panel.status
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 10
|
||||
anchors.bottomMargin: 6
|
||||
visible: panel.cancellable
|
||||
text: "✕"
|
||||
color: cancelArea.containsMouse ? "#cc6666" : "#666666"
|
||||
font.pixelSize: 12
|
||||
|
||||
MouseArea {
|
||||
id: cancelArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
onClicked: panel.cancelRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Готовые сезонные сборки с сервера лаунчера: таблица со всем, что нужно
|
||||
// решить, ставить её или нет, и одна кнопка, которая делает всё остальное —
|
||||
// заводит сборку, ставит версию игры, модлоадер, Java и раскладывает файлы.
|
||||
//
|
||||
// Таблица собрана из Row фиксированных колонок, а не из TableView: в проекте
|
||||
// нет ни одной модели QAbstractItemModel, а строки приходят готовыми
|
||||
// QVariantMap — заводить ради семи колонок отдельную модель незачем.
|
||||
Dialog {
|
||||
id: seasons
|
||||
|
||||
required property var backend
|
||||
|
||||
// Выбранная строка. Хранится по id, а не по индексу: список обновляется
|
||||
// под руками, и индекс после обновления указывал бы на другую сборку.
|
||||
property string selectedId: ""
|
||||
|
||||
// Ширины колонок в одном месте: их повторяют и шапка, и делегат.
|
||||
readonly property var columns: [
|
||||
{ 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
|
||||
padding: 0
|
||||
width: 960
|
||||
height: 560
|
||||
// Пока идёт установка, окно закрывается только кнопкой: случайный щелчок
|
||||
// мимо не должен спрятать единственную видимую отмену.
|
||||
closePolicy: seasons.backend.seasonalInstalling
|
||||
? Popup.NoAutoClose
|
||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
// Строки приходят из C++ уже отсортированными и сведёнными с локальными
|
||||
// записями — здесь только показ.
|
||||
readonly property var entries: seasons.backend.seasonalCatalog
|
||||
|
||||
readonly property var selectedEntry: {
|
||||
const list = seasons.entries
|
||||
for (var i = 0; i < list.length; ++i) {
|
||||
if (list[i].id === seasons.selectedId)
|
||||
return list[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openCatalog() {
|
||||
seasons.open()
|
||||
}
|
||||
|
||||
function formatMb(bytes) {
|
||||
if (!bytes || bytes <= 0)
|
||||
return ""
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
function cellText(entry, key) {
|
||||
if (key === "loaderLabel") {
|
||||
return entry.loaderVersion === ""
|
||||
? entry.loaderTitle
|
||||
: entry.loaderTitle + " " + entry.loaderVersion
|
||||
}
|
||||
return String(entry[key])
|
||||
}
|
||||
|
||||
// Свежий кэш отвечает без сети, поэтому дёргаем при каждом открытии.
|
||||
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.common.title.seasonalBuilds
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
|
||||
// ── Шапка таблицы ───────────────────────────────────────────────
|
||||
Row {
|
||||
id: tableHeader
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
height: 30
|
||||
|
||||
Repeater {
|
||||
model: seasons.columns
|
||||
|
||||
delegate: Text {
|
||||
required property var modelData
|
||||
|
||||
width: modelData.width
|
||||
height: tableHeader.height
|
||||
text: modelData.title
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: modelData.align
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
rightPadding: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: headerLine
|
||||
anchors.top: tableHeader.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// ── Сообщение вместо таблицы ────────────────────────────────────
|
||||
// Пустой список и ошибка выглядят одинаково пустыми, поэтому причина
|
||||
// показывается прямо на месте строк.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 80
|
||||
visible: seasons.entries.length === 0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
color: seasons.backend.seasonalCatalogError === "" ? "#888888" : "#cc6666"
|
||||
font.pixelSize: 13
|
||||
text: seasons.backend.seasonalCatalogLoading
|
||||
? Loc.t.seasonal.progress.loadingCatalog
|
||||
: (seasons.backend.seasonalCatalogError === ""
|
||||
? Loc.t.seasonal.status.catalogEmptyYet
|
||||
: seasons.backend.seasonalCatalogError)
|
||||
}
|
||||
|
||||
// ── Строки ──────────────────────────────────────────────────────
|
||||
ListView {
|
||||
id: buildList
|
||||
anchors.top: headerLine.bottom
|
||||
anchors.topMargin: 4
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
clip: true
|
||||
spacing: 1
|
||||
model: seasons.entries
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: buildRow
|
||||
required property var modelData
|
||||
|
||||
width: buildList.width
|
||||
height: 36
|
||||
radius: 4
|
||||
color: seasons.selectedId === buildRow.modelData.id ? "#91B315"
|
||||
: rowArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||
|
||||
readonly property bool highlighted: seasons.selectedId === buildRow.modelData.id
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Repeater {
|
||||
model: seasons.columns
|
||||
|
||||
delegate: Text {
|
||||
required property var modelData
|
||||
|
||||
width: modelData.width
|
||||
text: seasons.cellText(buildRow.modelData, modelData.key)
|
||||
// Подсвеченная строка залита зелёным — тёмный текст
|
||||
// на ней читается, светлый нет.
|
||||
color: buildRow.highlighted
|
||||
? "#1e1e1e"
|
||||
: (modelData.key === "status"
|
||||
&& buildRow.modelData.updateAvailable
|
||||
? "#91B315" : "#ffffff")
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: modelData.align
|
||||
rightPadding: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: seasons.selectedId = buildRow.modelData.id
|
||||
onDoubleClicked: {
|
||||
seasons.selectedId = buildRow.modelData.id
|
||||
seasons.installSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installSelected() {
|
||||
const entry = seasons.selectedEntry
|
||||
if (!entry || seasons.backend.busy)
|
||||
return
|
||||
seasons.backend.installSeasonalBuild(entry.id)
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 76
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// Описание и размер выбранной сборки: они длинные и в таблицу не
|
||||
// помещаются, а решение принимается по ним.
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
anchors.right: footerButtons.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 12
|
||||
text: seasons.selectedEntry ? seasons.selectedEntry.description : ""
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
color: "#888888"
|
||||
font.pixelSize: 11
|
||||
text: {
|
||||
if (!seasons.selectedEntry)
|
||||
return Loc.t.seasonal.hint.pickBuild
|
||||
const entry = seasons.selectedEntry
|
||||
var line = Loc.t.seasonal.status.revision.arg(entry.revision)
|
||||
const size = seasons.formatMb(entry.sizeBytes)
|
||||
if (size !== "")
|
||||
line += Loc.t.seasonal.status.sizeSuffix.arg(size)
|
||||
if (entry.serverUrl !== "")
|
||||
line += " · " + entry.serverUrl
|
||||
if (entry.updateAvailable)
|
||||
line += Loc.t.seasonal.status.installedRevisionSuffix.arg(entry.installedRevision)
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: footerButtons
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
id: refreshButton
|
||||
width: 150
|
||||
height: 36
|
||||
enabled: !seasons.backend.seasonalCatalogLoading
|
||||
contentItem: Text {
|
||||
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
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: refreshButton.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: seasons.backend.refreshSeasonalCatalog(true)
|
||||
}
|
||||
|
||||
Button {
|
||||
id: installButton
|
||||
width: 150
|
||||
height: 36
|
||||
// Установка занимает и панель загрузки, и .minecraft целиком:
|
||||
// пока лаунчер занят чем угодно, вторую начинать нельзя.
|
||||
enabled: seasons.selectedEntry !== null && !seasons.backend.busy
|
||||
contentItem: Text {
|
||||
text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable
|
||||
? Loc.t.common.button.update
|
||||
: Loc.t.common.button.install
|
||||
color: installButton.enabled ? "#ffffff" : "#888888"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: !installButton.enabled ? "#2a2a2a"
|
||||
: installButton.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: seasons.installSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 2.15
|
||||
|
||||
// Выбор версии Minecraft отдельным окном: слева категории, справа сами версии
|
||||
// с поиском сверху.
|
||||
//
|
||||
// Раньше это был выпадающий список на всю тысячу версий: найти в нём бету 1.7
|
||||
// можно было только поиском по точному номеру. Категории делят список на
|
||||
// обозримые части, а поиск работает внутри выбранной.
|
||||
Dialog {
|
||||
id: picker
|
||||
|
||||
required property var backend
|
||||
|
||||
// Версия, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
|
||||
property string selectedId: ""
|
||||
property string category: "release"
|
||||
property string filterText: ""
|
||||
property bool installedOnly: false
|
||||
|
||||
// Выбранная версия подтверждена кнопкой или двойным щелчком. Имя не
|
||||
// accepted(): такой сигнал у Dialog уже есть, и переопределить его нельзя.
|
||||
signal versionChosen(string versionId)
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 720
|
||||
height: 480
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
readonly property var categories: [
|
||||
{ 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++ уже отсортированным (новые сверху), поэтому здесь
|
||||
// только отбор — порядок не трогаем.
|
||||
readonly property var visibleEntries: {
|
||||
const query = picker.filterText.trim().toLowerCase()
|
||||
const source = picker.backend.versionCatalog
|
||||
const out = []
|
||||
for (var i = 0; i < source.length; ++i) {
|
||||
const entry = source[i]
|
||||
if (entry.category !== picker.category)
|
||||
continue
|
||||
if (picker.installedOnly && !entry.installed)
|
||||
continue
|
||||
if (query !== "" && entry.search.indexOf(query) === -1)
|
||||
continue
|
||||
out.push(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function openFor(versionId) {
|
||||
picker.selectedId = versionId
|
||||
picker.filterText = ""
|
||||
searchField.text = ""
|
||||
// Открываемся на категории текущей версии, а не всегда на релизах.
|
||||
picker.category = picker.categoryOf(versionId)
|
||||
picker.backend.refreshVersionCatalog()
|
||||
picker.open()
|
||||
picker.revealSelected()
|
||||
}
|
||||
|
||||
function categoryOf(versionId) {
|
||||
const source = picker.backend.versionCatalog
|
||||
for (var i = 0; i < source.length; ++i) {
|
||||
if (source[i].id === versionId)
|
||||
return source[i].category
|
||||
}
|
||||
return "release"
|
||||
}
|
||||
|
||||
function indexOfSelected() {
|
||||
const list = picker.visibleEntries
|
||||
for (var i = 0; i < list.length; ++i) {
|
||||
if (list[i].id === picker.selectedId)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function revealSelected() {
|
||||
const index = picker.indexOfSelected()
|
||||
versionList.currentIndex = index
|
||||
if (index >= 0)
|
||||
versionList.positionViewAtIndex(index, ListView.Center)
|
||||
}
|
||||
|
||||
function acceptSelection() {
|
||||
if (picker.selectedId === "")
|
||||
return
|
||||
picker.versionChosen(picker.selectedId)
|
||||
picker.close()
|
||||
}
|
||||
|
||||
function catalogHas(versionId) {
|
||||
const source = picker.backend.versionCatalog
|
||||
for (var i = 0; i < source.length; ++i) {
|
||||
if (source[i].id === versionId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Спрашиваем до удаления: версию придётся качать заново, а профили
|
||||
// модлоадеров поверх неё — переустанавливать. Id запоминаем здесь:
|
||||
// к моменту ответа строка под курсором может быть уже другой.
|
||||
function askRemove(versionId) {
|
||||
const info = picker.backend.versionRemovalInfo(versionId)
|
||||
if (!info || info.installed !== true)
|
||||
return
|
||||
removeConfirm.versionId = versionId
|
||||
removeConfirm.sizeMb = info.sizeMb || 0
|
||||
removeConfirm.dependents = info.dependents || []
|
||||
removeConfirm.builds = info.builds || []
|
||||
removeConfirm.open()
|
||||
}
|
||||
|
||||
function performRemove(versionId) {
|
||||
picker.backend.removeVersion(versionId)
|
||||
// Профиль модлоадера был в каталоге только потому, что установлен:
|
||||
// после удаления строка исчезает совсем, и выбор надо снять.
|
||||
if (picker.selectedId === versionId && !picker.catalogHas(versionId))
|
||||
picker.selectedId = ""
|
||||
picker.revealSelected()
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: removeConfirm
|
||||
|
||||
property string versionId: ""
|
||||
property int sizeMb: 0
|
||||
property var dependents: []
|
||||
property var builds: []
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 420
|
||||
parent: picker.parent
|
||||
x: (picker.parent.width - width) / 2
|
||||
y: (picker.parent.height - height) / 2
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#cc6666"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.version.title.removeConfirm
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Column {
|
||||
spacing: 10
|
||||
topPadding: 18
|
||||
bottomPadding: 18
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
wrapMode: Text.Wrap
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 13
|
||||
text: removeConfirm.sizeMb > 0
|
||||
? Loc.t.version.hint.removeWithSize
|
||||
.arg(removeConfirm.versionId).arg(removeConfirm.sizeMb)
|
||||
: Loc.t.version.hint.remove
|
||||
.arg(removeConfirm.versionId)
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
visible: removeConfirm.dependents.length > 0
|
||||
wrapMode: Text.Wrap
|
||||
color: "#cc6666"
|
||||
font.pixelSize: 12
|
||||
text: Loc.t.version.warning.loaderProfilesDepend
|
||||
.arg(removeConfirm.dependents.join(", "))
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
visible: removeConfirm.builds.length > 0
|
||||
wrapMode: Text.Wrap
|
||||
color: "#cc6666"
|
||||
font.pixelSize: 12
|
||||
text: Loc.t.version.warning.buildsDepend
|
||||
.arg(removeConfirm.builds.join(", "))
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
wrapMode: Text.Wrap
|
||||
color: "#888888"
|
||||
font.pixelSize: 12
|
||||
text: Loc.t.version.hint.sharedFilesStay
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.cancel
|
||||
width: 130; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: removeConfirm.close()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.remove
|
||||
width: 130; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#8a3a3a" : "#663333"
|
||||
radius: 6
|
||||
border.color: "#cc6666"
|
||||
border.width: 1
|
||||
}
|
||||
onClicked: {
|
||||
const id = removeConfirm.versionId
|
||||
removeConfirm.close()
|
||||
picker.performRemove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClosed: removeConfirm.versionId = ""
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Loc.t.common.label.minecraftVersion
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 0
|
||||
|
||||
// ── Категории ───────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 150
|
||||
Layout.fillHeight: true
|
||||
color: "transparent"
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: picker.categories
|
||||
|
||||
delegate: Rectangle {
|
||||
id: categoryRow
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: 34
|
||||
radius: 5
|
||||
color: picker.category === categoryRow.modelData.key ? "#91B315"
|
||||
: categoryArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: categoryRow.modelData.title
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: categoryArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
picker.category = categoryRow.modelData.key
|
||||
picker.revealSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillHeight: true
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// ── Версии ──────────────────────────────────────────────────────
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
height: 32
|
||||
placeholderText: Loc.t.common.placeholder.searchVersion
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
color: "#232323"
|
||||
radius: 5
|
||||
border.color: searchField.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
onTextChanged: {
|
||||
picker.filterText = text
|
||||
versionList.currentIndex = picker.indexOfSelected()
|
||||
}
|
||||
Keys.onEscapePressed: picker.close()
|
||||
Keys.onReturnPressed: picker.acceptSelection()
|
||||
Keys.onEnterPressed: picker.acceptSelection()
|
||||
Keys.onDownPressed: versionList.step(1)
|
||||
Keys.onUpPressed: versionList.step(-1)
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: installedOnlyBox
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 4
|
||||
height: 28
|
||||
checked: picker.installedOnly
|
||||
onToggled: {
|
||||
picker.installedOnly = checked
|
||||
picker.revealSelected()
|
||||
}
|
||||
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 16
|
||||
implicitHeight: 16
|
||||
x: 0
|
||||
y: (installedOnlyBox.height - height) / 2
|
||||
radius: 4
|
||||
color: installedOnlyBox.checked ? "#91B315" : "#2a2a2a"
|
||||
border.color: installedOnlyBox.checked ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: installedOnlyBox.checked
|
||||
text: "✓"
|
||||
color: "#1e1e1e"
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
leftPadding: installedOnlyBox.indicator.width + 8
|
||||
text: Loc.t.version.filter.installedOnly
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: versionList
|
||||
anchors.top: searchField.bottom
|
||||
anchors.topMargin: 6
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: installedOnlyBox.top
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
clip: true
|
||||
spacing: 1
|
||||
model: picker.visibleEntries
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
function step(delta) {
|
||||
const next = versionList.currentIndex + delta
|
||||
if (next < 0 || next >= versionList.count)
|
||||
return
|
||||
versionList.currentIndex = next
|
||||
versionList.positionViewAtIndex(next, ListView.Contain)
|
||||
picker.selectedId = picker.visibleEntries[next].id
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: versionRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: versionList.width
|
||||
height: 32
|
||||
radius: 4
|
||||
color: picker.selectedId === versionRow.modelData.id ? "#91B315"
|
||||
: versionArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: rightMarks.left
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: versionRow.modelData.label
|
||||
color: "#ffffff"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
Row {
|
||||
id: rightMarks
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
visible: versionRow.modelData.installed
|
||||
text: "✓"
|
||||
color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Версия весит от десятков мегабайт, а галочка рядом —
|
||||
// единственное место, где видно, что именно скачано.
|
||||
Image {
|
||||
visible: versionRow.modelData.installed
|
||||
source: "images/Trash.svg"
|
||||
width: 14
|
||||
height: 14
|
||||
fillMode: Image.PreserveAspectFit
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: trashArea.containsMouse ? 1.0 : 0.55
|
||||
|
||||
MouseArea {
|
||||
id: trashArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -5
|
||||
hoverEnabled: true
|
||||
onClicked: picker.askRemove(versionRow.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: versionArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 40
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
picker.selectedId = versionRow.modelData.id
|
||||
versionList.currentIndex = versionRow.index
|
||||
}
|
||||
onDoubleClicked: {
|
||||
picker.selectedId = versionRow.modelData.id
|
||||
picker.acceptSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустая категория, ничего не нашлось или манифест ещё не пришёл.
|
||||
Text {
|
||||
anchors.centerIn: versionList
|
||||
width: versionList.width - 20
|
||||
visible: picker.visibleEntries.length === 0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
color: "#888888"
|
||||
font.pixelSize: 12
|
||||
font.italic: true
|
||||
text: picker.backend.catalogLoading
|
||||
? Loc.t.version.progress.loadingList
|
||||
: picker.backend.versionCatalog.length === 0
|
||||
? Loc.t.version.error.listUnreachable
|
||||
: Loc.t.common.status.nothingFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 300
|
||||
elide: Text.ElideRight
|
||||
color: picker.selectedId === "" ? "#666666" : "#91B315"
|
||||
font.pixelSize: 13
|
||||
text: picker.selectedId === "" ? Loc.t.version.status.notSelected : picker.selectedId
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.cancel
|
||||
width: 110; height: 36
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: picker.close()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: Loc.t.common.button.select
|
||||
width: 110; height: 36
|
||||
enabled: picker.selectedId !== ""
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: picker.acceptSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: searchField.forceActiveFocus()
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
#include "authservice.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kAuthServer = QStringLiteral("https://authserver.ely.by");
|
||||
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
||||
const QString kInjectorFileName = QStringLiteral("authlib-injector.jar");
|
||||
|
||||
// Ely.by отдаёт UUID без дефисов, вручную введённый мог прийти и с ними.
|
||||
QString normalizeUuid(const QString &uuid)
|
||||
{
|
||||
return QString(uuid).remove(u'-');
|
||||
}
|
||||
|
||||
QString describeElyError(const QJsonObject &response, int status)
|
||||
{
|
||||
const QString message = response.value(QStringLiteral("errorMessage")).toString();
|
||||
if (!message.isEmpty())
|
||||
return message;
|
||||
const QString error = response.value(QStringLiteral("error")).toString();
|
||||
if (!error.isEmpty())
|
||||
return error;
|
||||
return Loc::text("auth.error.serverCode").arg(status);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AuthService::AuthService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
AuthResult AuthService::offline(const QString &nickname)
|
||||
{
|
||||
AuthResult result;
|
||||
if (nickname.isEmpty()) {
|
||||
result.error = Loc::text("auth.offline.error.noNickname");
|
||||
return result;
|
||||
}
|
||||
|
||||
QByteArray digest = QCryptographicHash::hash(
|
||||
QStringLiteral("OfflinePlayer:%1").arg(nickname).toUtf8(), QCryptographicHash::Md5);
|
||||
digest[6] = static_cast<char>((digest[6] & 0x0f) | 0x30); // версия UUID = 3
|
||||
digest[8] = static_cast<char>((digest[8] & 0x3f) | 0x80); // вариант RFC 4122
|
||||
|
||||
result.ok = true;
|
||||
result.playerName = nickname;
|
||||
result.uuid = QString::fromLatin1(digest.toHex());
|
||||
result.accessToken = QStringLiteral("0");
|
||||
result.clientToken = QStringLiteral("0");
|
||||
result.userType = QStringLiteral("legacy");
|
||||
return result;
|
||||
}
|
||||
|
||||
QString AuthService::generateClientToken()
|
||||
{
|
||||
return QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
}
|
||||
|
||||
void AuthService::postJson(
|
||||
const QString &endpoint,
|
||||
const QJsonObject &body,
|
||||
std::function<void(int, const QJsonObject &, const QString &)> handler)
|
||||
{
|
||||
QNetworkRequest request{QUrl(kAuthServer + endpoint)};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
|
||||
QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
||||
reply->deleteLater();
|
||||
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray payload = reply->readAll();
|
||||
|
||||
// Сетевую ошибку отличаем от ответа сервера: при 401 статус есть, а error — есть всегда.
|
||||
QString transportError;
|
||||
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
||||
transportError = reply->errorString();
|
||||
|
||||
handler(status, QJsonDocument::fromJson(payload).object(), transportError);
|
||||
});
|
||||
}
|
||||
|
||||
void AuthService::loginElyBy(const QString &login,
|
||||
const QString &password,
|
||||
const QString &clientToken,
|
||||
const QString &accessToken,
|
||||
Callback callback)
|
||||
{
|
||||
if (login.isEmpty()) {
|
||||
AuthResult result;
|
||||
result.error = Loc::text("auth.ely.error.noLogin");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString token = clientToken.isEmpty() ? generateClientToken() : clientToken;
|
||||
|
||||
// Токена ещё нет — сразу за паролем.
|
||||
if (accessToken.isEmpty()) {
|
||||
authenticate(login, password, token, std::move(callback));
|
||||
return;
|
||||
}
|
||||
|
||||
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 = Loc::text("auth.ely.error.unreachable").arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == 200) {
|
||||
AuthResult result;
|
||||
result.ok = true;
|
||||
result.accessToken = accessToken;
|
||||
result.clientToken = token;
|
||||
result.userType = QStringLiteral("ELYBY");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Токен протух — пробуем продлить его, не спрашивая пароль.
|
||||
emit progress(Loc::text("auth.ely.progress.refreshingToken"));
|
||||
postJson(QStringLiteral("/auth/refresh"),
|
||||
{{QStringLiteral("accessToken"), accessToken},
|
||||
{QStringLiteral("clientToken"), token},
|
||||
{QStringLiteral("requestUser"), true}},
|
||||
[this, login, password, token, callback](
|
||||
int refreshStatus, const QJsonObject &response, const QString &error) {
|
||||
if (error.isEmpty() && refreshStatus == 200) {
|
||||
AuthResult result;
|
||||
result.ok = true;
|
||||
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
||||
result.clientToken = token;
|
||||
const QJsonObject profile =
|
||||
response.value(QStringLiteral("selectedProfile")).toObject();
|
||||
result.playerName = profile.value(QStringLiteral("name")).toString();
|
||||
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
||||
result.userType = QStringLiteral("ELYBY");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
authenticate(login, password, token, callback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void AuthService::loginElyByWithTotp(const QString &login,
|
||||
const QString &password,
|
||||
const QString &totp,
|
||||
const QString &clientToken,
|
||||
Callback callback)
|
||||
{
|
||||
// Ely.by ждёт двухфакторный код прямо в пароле, через двоеточие.
|
||||
authenticate(login,
|
||||
password + u':' + totp,
|
||||
clientToken.isEmpty() ? generateClientToken() : clientToken,
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void AuthService::authenticate(const QString &login,
|
||||
const QString &password,
|
||||
const QString &clientToken,
|
||||
Callback callback)
|
||||
{
|
||||
if (password.isEmpty()) {
|
||||
AuthResult result;
|
||||
result.error = Loc::text("auth.ely.error.noPassword");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
emit progress(Loc::text("auth.ely.progress.signingIn"));
|
||||
postJson(QStringLiteral("/auth/authenticate"),
|
||||
{{QStringLiteral("username"), login},
|
||||
{QStringLiteral("password"), password},
|
||||
{QStringLiteral("clientToken"), clientToken},
|
||||
{QStringLiteral("requestUser"), true}},
|
||||
[clientToken, callback](int status, const QJsonObject &response, const QString &transportError) {
|
||||
AuthResult result;
|
||||
result.clientToken = clientToken;
|
||||
|
||||
if (!transportError.isEmpty()) {
|
||||
result.error = Loc::text("auth.ely.error.unreachable").arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status != 200) {
|
||||
const QString message = describeElyError(response, status);
|
||||
// Единственный случай, когда ошибка лечится вводом кода, а не пароля.
|
||||
result.twoFactorRequired = message.contains(QStringLiteral("two factor"),
|
||||
Qt::CaseInsensitive);
|
||||
result.error = result.twoFactorRequired
|
||||
? Loc::text("auth.ely.error.twoFactor")
|
||||
: message;
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject profile = response.value(QStringLiteral("selectedProfile")).toObject();
|
||||
result.playerName = profile.value(QStringLiteral("name")).toString();
|
||||
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
||||
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
||||
result.userType = QStringLiteral("ELYBY");
|
||||
result.ok = !result.accessToken.isEmpty() && !result.playerName.isEmpty();
|
||||
if (!result.ok)
|
||||
result.error = Loc::text("auth.ely.error.noGameProfile");
|
||||
callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
void AuthService::ensureAuthlibInjector(
|
||||
const QString &targetDir,
|
||||
std::function<void(const QString &, const QString &)> callback)
|
||||
{
|
||||
QDir().mkpath(targetDir);
|
||||
const QString jarPath = targetDir + u'/' + kInjectorFileName;
|
||||
if (QFileInfo::exists(jarPath)) {
|
||||
callback(jarPath, {});
|
||||
return;
|
||||
}
|
||||
|
||||
emit progress(Loc::text("auth.ely.progress.downloadingInjector"));
|
||||
QNetworkRequest manifestRequest{QUrl(kInjectorManifest)};
|
||||
manifestRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
|
||||
QNetworkReply *manifestReply = m_network->get(manifestRequest);
|
||||
connect(manifestReply, &QNetworkReply::finished, this, [this, manifestReply, jarPath, callback]() {
|
||||
manifestReply->deleteLater();
|
||||
if (manifestReply->error() != QNetworkReply::NoError) {
|
||||
callback({}, Loc::text("auth.ely.error.injectorInfoFailed")
|
||||
.arg(manifestReply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject manifest = QJsonDocument::fromJson(manifestReply->readAll()).object();
|
||||
const QString url = manifest.value(QStringLiteral("download_url")).toString();
|
||||
const QString expectedSha256 = manifest.value(QStringLiteral("checksums"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("sha256"))
|
||||
.toString();
|
||||
if (url.isEmpty()) {
|
||||
callback({}, Loc::text("auth.ely.error.injectorNoUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest jarRequest{QUrl(url)};
|
||||
jarRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
QNetworkReply *jarReply = m_network->get(jarRequest);
|
||||
connect(jarReply, &QNetworkReply::finished, this, [jarReply, jarPath, expectedSha256, callback]() {
|
||||
jarReply->deleteLater();
|
||||
if (jarReply->error() != QNetworkReply::NoError) {
|
||||
callback({}, Loc::text("auth.ely.error.injectorDownloadFailed").arg(jarReply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray payload = jarReply->readAll();
|
||||
if (!expectedSha256.isEmpty()) {
|
||||
const QString actual = QString::fromLatin1(
|
||||
QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex());
|
||||
if (actual != expectedSha256) {
|
||||
callback({}, Loc::text("auth.ely.error.injectorChecksum"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
QFile file(jarPath);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
|
||||
callback({}, Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(jarPath)));
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
callback(jarPath, {});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Результат авторизации — то, что подставляется в ${auth_*} аргументы запуска.
|
||||
struct AuthResult
|
||||
{
|
||||
bool ok = false;
|
||||
bool twoFactorRequired = false;
|
||||
// Вход в Microsoft прошёл, но копии игры на аккаунте нет: чинится только
|
||||
// покупкой, поэтому обрабатывается отдельно от прочих ошибок.
|
||||
bool licenseMissing = false;
|
||||
QString error;
|
||||
|
||||
QString playerName; // ${auth_player_name}
|
||||
QString uuid; // ${auth_uuid}, hex без дефисов
|
||||
QString accessToken; // ${auth_access_token}
|
||||
QString clientToken; // ${clientid}
|
||||
QString userType; // ${user_type}: legacy / msa / ELYBY
|
||||
|
||||
// Только для аккаунтов Microsoft.
|
||||
QString refreshToken; // продлевает сессию без ввода пароля
|
||||
QString xuid; // ${auth_xuid}
|
||||
QDateTime expiresAt; // UTC-время, когда протухает accessToken
|
||||
};
|
||||
|
||||
// Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные:
|
||||
// колбэк вызывается один раз в потоке GUI.
|
||||
class AuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using Callback = std::function<void(const AuthResult &)>;
|
||||
|
||||
explicit AuthService(QObject *parent = nullptr);
|
||||
|
||||
// UUID выводится из ника так же, как это делает сам Minecraft в офлайне:
|
||||
// UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8)).
|
||||
static AuthResult offline(const QString &nickname);
|
||||
|
||||
// Случайный clientToken лаунчера — генерируется один раз на профиль.
|
||||
static QString generateClientToken();
|
||||
|
||||
// Полный цикл: validate -> refresh -> authenticate. password можно оставить
|
||||
// пустым, если уже есть рабочий accessToken.
|
||||
void loginElyBy(const QString &login,
|
||||
const QString &password,
|
||||
const QString &clientToken,
|
||||
const QString &accessToken,
|
||||
Callback callback);
|
||||
|
||||
// Повтор авторизации с одноразовым кодом двухфакторки (пароль:код).
|
||||
void loginElyByWithTotp(const QString &login,
|
||||
const QString &password,
|
||||
const QString &totp,
|
||||
const QString &clientToken,
|
||||
Callback callback);
|
||||
|
||||
// Скачивает authlib-injector в targetDir, если его там ещё нет.
|
||||
// Колбэк получает путь к jar либо текст ошибки.
|
||||
void ensureAuthlibInjector(const QString &targetDir,
|
||||
std::function<void(const QString &path, const QString &error)> callback);
|
||||
|
||||
signals:
|
||||
void progress(const QString &message);
|
||||
|
||||
private:
|
||||
void postJson(const QString &endpoint,
|
||||
const QJsonObject &body,
|
||||
std::function<void(int status, const QJsonObject &response, const QString &transportError)> handler);
|
||||
|
||||
void authenticate(const QString &login,
|
||||
const QString &password,
|
||||
const QString &clientToken,
|
||||
Callback callback);
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
};
|
||||
@@ -1,373 +0,0 @@
|
||||
#include "buildarchiveworker.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
#include <QtCore/private/qzipwriter_p.h>
|
||||
|
||||
namespace {
|
||||
|
||||
// Имена внутри архива приходят из файла и превращаются в пути на диске —
|
||||
// «../..» увёл бы распаковку за пределы .minecraft. Правило то же, что у
|
||||
// установщика версий.
|
||||
bool isSafeRelativePath(const QString &path)
|
||||
{
|
||||
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
|
||||
return false;
|
||||
const QStringList parts = path.split(u'/');
|
||||
for (const QString &part : parts) {
|
||||
if (part == QLatin1String(".") || part == QLatin1String(".."))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Последняя проверка перед записью: куда бы ни указывало имя из архива после
|
||||
// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён
|
||||
// выше это уже гарантирует, но проверка по готовому пути не зависит от того,
|
||||
// как именно QZipReader разбирает записи.
|
||||
bool isInside(const QDir &root, const QString &absolutePath)
|
||||
{
|
||||
const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
|
||||
return QDir::cleanPath(absolutePath).startsWith(base);
|
||||
}
|
||||
|
||||
// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в
|
||||
// общие каталоги лаунчера.
|
||||
QString topSegment(const QString &path)
|
||||
{
|
||||
const int slash = path.indexOf(u'/');
|
||||
return slash < 0 ? path : path.left(slash);
|
||||
}
|
||||
|
||||
// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого
|
||||
// набора модов выглядит как незавершённая установка.
|
||||
void pruneEmptyDirs(const QDir &root, QStringList dirs)
|
||||
{
|
||||
// Сначала самые глубокие: удаление вложенной может опустошить родителя.
|
||||
std::sort(dirs.begin(), dirs.end(),
|
||||
[](const QString &a, const QString &b) { return a.size() > b.size(); });
|
||||
for (const QString &relative : std::as_const(dirs)) {
|
||||
if (relative.isEmpty() || relative == QLatin1String("."))
|
||||
continue;
|
||||
QDir dir(root.absoluteFilePath(relative));
|
||||
if (dir.exists() && dir.isEmpty())
|
||||
root.rmdir(relative);
|
||||
}
|
||||
}
|
||||
|
||||
// Файлы, которые попадут в архив, — относительными путями от gameDir.
|
||||
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
|
||||
{
|
||||
QStringList result;
|
||||
const QDir root(gameDir);
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (excludeTop.contains(entry.fileName()))
|
||||
continue;
|
||||
if (entry.isSymLink())
|
||||
continue; // ссылку в архив не положить, а идти по ней опасно
|
||||
if (entry.isFile()) {
|
||||
result << entry.fileName();
|
||||
continue;
|
||||
}
|
||||
if (!entry.isDir())
|
||||
continue;
|
||||
|
||||
QDirIterator it(entry.absoluteFilePath(),
|
||||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||
QDirIterator::Subdirectories);
|
||||
while (it.hasNext())
|
||||
result << root.relativeFilePath(it.next());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::archive(const QString &gameDir,
|
||||
const QString &tempZipPath,
|
||||
const QStringList &excludeTop)
|
||||
{
|
||||
const QDir root(gameDir);
|
||||
if (!root.exists()) {
|
||||
emit finished(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(gameDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
const QStringList files = collectFiles(gameDir, excludeTop);
|
||||
emit progress(0, files.size(), {});
|
||||
|
||||
{
|
||||
QZipWriter writer(tempZipPath);
|
||||
if (writer.status() != QZipWriter::NoError) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.archiveCreateFailed")
|
||||
.arg(QDir::toNativeSeparators(tempZipPath)));
|
||||
return;
|
||||
}
|
||||
// Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть
|
||||
// решает эвристика, а не мы.
|
||||
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
||||
|
||||
int done = 0;
|
||||
for (const QString &relative : files) {
|
||||
if (canceled()) {
|
||||
writer.close();
|
||||
QFile::remove(tempZipPath);
|
||||
emit finished(false, Loc::text("switch.status.archivingCancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(root.absoluteFilePath(relative));
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
// Один нечитаемый файл не повод потерять всю сборку, но и молча
|
||||
// выкидывать его нельзя — иначе пользователь узнает о пропаже
|
||||
// уже после очистки .minecraft.
|
||||
writer.close();
|
||||
QFile::remove(tempZipPath);
|
||||
emit finished(false,
|
||||
Loc::text("common.error.readFailed")
|
||||
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||
return;
|
||||
}
|
||||
writer.addFile(relative, &file);
|
||||
file.close();
|
||||
|
||||
if (writer.status() != QZipWriter::NoError) {
|
||||
writer.close();
|
||||
QFile::remove(tempZipPath);
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.archiveWriteFailed")
|
||||
.arg(QDir::toNativeSeparators(relative)));
|
||||
return;
|
||||
}
|
||||
emit progress(++done, files.size(), relative);
|
||||
}
|
||||
writer.close();
|
||||
}
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
|
||||
{
|
||||
QDir root(gameDir);
|
||||
if (!root.exists()) {
|
||||
emit finished(true, {}); // чистить нечего
|
||||
return;
|
||||
}
|
||||
|
||||
QFileInfoList victims;
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (!keepTop.contains(entry.fileName()))
|
||||
victims << entry;
|
||||
}
|
||||
|
||||
emit progress(0, victims.size(), {});
|
||||
int done = 0;
|
||||
for (const QFileInfo &entry : victims) {
|
||||
// Отмена посреди очистки оставила бы полупустую папку, которую нечем
|
||||
// склеить обратно, — на этом шаге её не слушаем.
|
||||
const bool ok = entry.isDir() && !entry.isSymLink()
|
||||
? QDir(entry.absoluteFilePath()).removeRecursively()
|
||||
: QFile::remove(entry.absoluteFilePath());
|
||||
if (!ok) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.removeFailed")
|
||||
.arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
|
||||
return;
|
||||
}
|
||||
emit progress(++done, victims.size(), entry.fileName());
|
||||
}
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
|
||||
{
|
||||
QZipReader reader(zipPath);
|
||||
if (!reader.isReadable()) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||
emit progress(0, entries.size(), {});
|
||||
|
||||
const QDir root(gameDir);
|
||||
if (!QDir().mkpath(gameDir)) {
|
||||
emit finished(false,
|
||||
Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
int done = 0;
|
||||
for (const QZipReader::FileInfo &entry : entries) {
|
||||
if (canceled()) {
|
||||
emit finished(false, Loc::text("switch.status.extractCancelled"));
|
||||
return;
|
||||
}
|
||||
if (!entry.isFile) {
|
||||
emit progress(++done, entries.size(), entry.filePath);
|
||||
continue;
|
||||
}
|
||||
if (!isSafeRelativePath(entry.filePath)) {
|
||||
emit finished(false,
|
||||
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,
|
||||
Loc::text("common.error.createFailed")
|
||||
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(target);
|
||||
const QByteArray data = reader.fileData(entry.filePath);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.writeFailed")
|
||||
.arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
emit progress(++done, entries.size(), entry.filePath);
|
||||
}
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::applyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &removeRelative,
|
||||
const QStringList &forbiddenTop)
|
||||
{
|
||||
QZipReader reader(zipPath);
|
||||
if (!reader.isReadable()) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Весь архив проверяется до первой записи на диск: наполовину раскатанный
|
||||
// пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии.
|
||||
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||
QStringList applied;
|
||||
applied.reserve(entries.size());
|
||||
for (const QZipReader::FileInfo &entry : entries) {
|
||||
if (!entry.isFile)
|
||||
continue;
|
||||
if (!isSafeRelativePath(entry.filePath)) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.packBadPath").arg(entry.filePath));
|
||||
return;
|
||||
}
|
||||
if (forbiddenTop.contains(topSegment(entry.filePath))) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.packTouchesLauncher")
|
||||
.arg(entry.filePath));
|
||||
return;
|
||||
}
|
||||
applied << entry.filePath;
|
||||
}
|
||||
|
||||
const QDir root(gameDir);
|
||||
if (!QDir().mkpath(gameDir)) {
|
||||
emit finished(false, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
const int total = removeRelative.size() + applied.size();
|
||||
emit progress(0, total, {});
|
||||
int done = 0;
|
||||
|
||||
// ── Убираем то, чего в новой ревизии больше нет ──────────────────────
|
||||
QStringList touchedDirs;
|
||||
for (const QString &relative : removeRelative) {
|
||||
if (canceled()) {
|
||||
emit finished(false, Loc::text("switch.status.installCancelled"));
|
||||
return;
|
||||
}
|
||||
// Список пришёл из нашего же файла состояния, но он лежит на диске
|
||||
// рядом с игрой и мог быть отредактирован — правила те же, что у архива.
|
||||
if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
|
||||
emit progress(++done, total, relative);
|
||||
continue;
|
||||
}
|
||||
// Файл мог быть удалён игроком или прийти заново в новой ревизии —
|
||||
// и то и другое нормально.
|
||||
const QString target = root.absoluteFilePath(relative);
|
||||
if (!isInside(root, target)) {
|
||||
emit progress(++done, total, relative);
|
||||
continue;
|
||||
}
|
||||
if (QFileInfo::exists(target) && !QFile::remove(target)) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.removeFailed").arg(QDir::toNativeSeparators(target)));
|
||||
return;
|
||||
}
|
||||
const QString parent = QFileInfo(relative).path();
|
||||
if (!touchedDirs.contains(parent))
|
||||
touchedDirs << parent;
|
||||
emit progress(++done, total, relative);
|
||||
}
|
||||
pruneEmptyDirs(root, touchedDirs);
|
||||
|
||||
// ── Раскладываем новую ревизию поверх ────────────────────────────────
|
||||
for (const QString &relative : std::as_const(applied)) {
|
||||
if (canceled()) {
|
||||
emit finished(false, Loc::text("switch.status.installCancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString target = root.absoluteFilePath(relative);
|
||||
if (!isInside(root, target)) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.packEscapesGameDir")
|
||||
.arg(relative));
|
||||
return;
|
||||
}
|
||||
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||
emit finished(false,
|
||||
Loc::text("common.error.createFailed")
|
||||
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(target);
|
||||
const QByteArray data = reader.fileData(relative);
|
||||
// QIODevice::Truncate обязателен: поверх более длинного старого файла
|
||||
// иначе остался бы его хвост.
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||
|| file.write(data) != data.size()) {
|
||||
emit finished(false,
|
||||
Loc::text("switch.error.writeFailed")
|
||||
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
emit progress(++done, total, relative);
|
||||
}
|
||||
|
||||
emit packEntries(applied);
|
||||
emit finished(true, {});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAtomicInt>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
// Три операции над содержимым .minecraft, выполняемые в отдельном потоке:
|
||||
// упаковать, вычистить, распаковать. Гигабайты модов и миров нельзя гонять в
|
||||
// GUI-потоке — окно бы замерзало на всё время смены сборки.
|
||||
//
|
||||
// Объект живёт в своём QThread, вызывается только через очередь сигналов и
|
||||
// сам ничего не знает ни о сборках, ни о путях лаунчера: порядок шагов и
|
||||
// восстановление после сбоя — дело BuildSwitcher.
|
||||
class BuildArchiveWorker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BuildArchiveWorker(QObject *parent = nullptr);
|
||||
|
||||
// Вызывается из GUI-потока во время работы: отмена проверяется между файлами.
|
||||
void requestCancel() { m_cancel.storeRelaxed(1); }
|
||||
void clearCancel() { m_cancel.storeRelaxed(0); }
|
||||
|
||||
public slots:
|
||||
// Упаковывает всё содержимое gameDir, кроме элементов верхнего уровня из
|
||||
// excludeTop, во временный файл tempZipPath.
|
||||
void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop);
|
||||
|
||||
// Удаляет из gameDir всё, кроме элементов верхнего уровня из keepTop.
|
||||
void clear(const QString &gameDir, const QStringList &keepTop);
|
||||
|
||||
// Распаковывает архив в gameDir.
|
||||
void restore(const QString &zipPath, const QString &gameDir);
|
||||
|
||||
// Докатывает пак сезонной сборки поверх уже разложенного содержимого:
|
||||
// сначала удаляет removeRelative (файлы, ушедшие из сборки в новой
|
||||
// ревизии), затем распаковывает архив с перезаписью. Того, чего нет ни в
|
||||
// списке, ни в архиве, операция не касается — миры и скриншоты остаются.
|
||||
//
|
||||
// forbiddenTop — элементы верхнего уровня, которые паку трогать нельзя
|
||||
// (общие каталоги лаунчера). Проверяется здесь, а не только у издателя:
|
||||
// архив приезжает из сети.
|
||||
void applyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &removeRelative,
|
||||
const QStringList &forbiddenTop);
|
||||
|
||||
signals:
|
||||
void progress(int done, int total, const QString ¤tPath);
|
||||
void finished(bool ok, const QString &error);
|
||||
// Что именно принёс пак — относительными путями. Пригодится следующему
|
||||
// обновлению, чтобы вычислить, какие файлы из сборки ушли.
|
||||
void packEntries(const QStringList &entries);
|
||||
|
||||
private:
|
||||
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
|
||||
|
||||
QAtomicInt m_cancel;
|
||||
};
|
||||
@@ -1,643 +0,0 @@
|
||||
#include "buildswitcher.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "buildarchiveworker.h"
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QThread>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kIndexFile = QStringLiteral("index.json");
|
||||
const QString kStateSwitching = QStringLiteral("switching");
|
||||
const QString kStateApplying = QStringLiteral("applying");
|
||||
const QString kStateIdle = QStringLiteral("idle");
|
||||
|
||||
// Запас на архив: точный размер заранее неизвестен, но упасть на середине
|
||||
// записи из-за полного диска хуже, чем отказаться заранее.
|
||||
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;
|
||||
|
||||
// Имя файла из названия сборки: оно попадает в файловую систему, поэтому всё
|
||||
// небезопасное заменяется подчёркиванием.
|
||||
QString slug(const QString &name)
|
||||
{
|
||||
QString result;
|
||||
result.reserve(name.size());
|
||||
for (const QChar c : name) {
|
||||
if (c.isLetterOrNumber() || c == u'-' || c == u'_')
|
||||
result.append(c);
|
||||
else if (c.isSpace() || c == u'.')
|
||||
result.append(u'_');
|
||||
}
|
||||
while (result.startsWith(u'_'))
|
||||
result.remove(0, 1);
|
||||
return result.isEmpty() ? QStringLiteral("build") : result.left(48);
|
||||
}
|
||||
|
||||
qint64 directorySize(const QString &path, const QStringList &excludeTop)
|
||||
{
|
||||
qint64 total = 0;
|
||||
const QDir root(path);
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
|
||||
continue;
|
||||
if (entry.isFile()) {
|
||||
total += entry.size();
|
||||
continue;
|
||||
}
|
||||
QDirIterator it(entry.absoluteFilePath(),
|
||||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||
QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
total += it.fileInfo().size();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList BuildSwitcher::sharedTopLevel()
|
||||
{
|
||||
// Общий кэш, одинаковый для всех сборок и всегда восстановимый загрузкой.
|
||||
// versions/ здесь не случайно: без libraries/ описания версий бесполезны, а
|
||||
// две сборки на одной версии игры иначе таскали бы по копии клиентского jar.
|
||||
// Чем сборка запускается, помнит resolvedVersionId, а не набор папок.
|
||||
return {QStringLiteral("assets"),
|
||||
QStringLiteral("libraries"),
|
||||
QStringLiteral("versions"),
|
||||
QStringLiteral("launcher_profiles.json"),
|
||||
QStringLiteral("runtime")};
|
||||
}
|
||||
|
||||
BuildSwitcher::BuildSwitcher(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thread(new QThread(this))
|
||||
, m_worker(new BuildArchiveWorker)
|
||||
{
|
||||
m_worker->moveToThread(m_thread);
|
||||
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
|
||||
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
|
||||
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
|
||||
connect(m_worker, &BuildArchiveWorker::packEntries, this,
|
||||
[this](const QStringList &entries) { m_lastPackEntries = entries; });
|
||||
m_thread->start();
|
||||
|
||||
sweepTempArchives();
|
||||
}
|
||||
|
||||
BuildSwitcher::~BuildSwitcher()
|
||||
{
|
||||
m_worker->requestCancel();
|
||||
m_thread->quit();
|
||||
m_thread->wait(5000);
|
||||
}
|
||||
|
||||
double BuildSwitcher::fraction() const
|
||||
{
|
||||
if (m_state == Idle || m_total <= 0)
|
||||
return -1.0;
|
||||
return double(m_done) / double(m_total);
|
||||
}
|
||||
|
||||
QString BuildSwitcher::buildDir(int buildId) const
|
||||
{
|
||||
return LauncherPaths::buildDir(buildId);
|
||||
}
|
||||
|
||||
// На сборку — ровно один архив: имя с датой меняется от переключения к
|
||||
// переключению, поэтому ищем по расширению, а не по имени.
|
||||
QString BuildSwitcher::archivePathOf(int buildId) const
|
||||
{
|
||||
const QDir dir(buildDir(buildId));
|
||||
// Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в
|
||||
// выборку не попадают, на Windows — попадают, поэтому отсекаем по имени.
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
|
||||
for (const QString &name : zips) {
|
||||
if (!name.startsWith(u'.'))
|
||||
return dir.absoluteFilePath(name);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void BuildSwitcher::setStage(const QString &stage)
|
||||
{
|
||||
m_stage = stage;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void BuildSwitcher::switchTo(int fromBuildId,
|
||||
const QString &fromName,
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir)
|
||||
{
|
||||
if (m_state != Idle) {
|
||||
emit failed(toBuildId, Loc::text("common.error.switchInProgress"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QString rootError;
|
||||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||
emit failed(toBuildId, rootError, true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_fromId = fromBuildId;
|
||||
m_toId = toBuildId;
|
||||
m_fromName = fromName;
|
||||
m_toName = toName;
|
||||
m_gameDir = gameDir;
|
||||
m_done = 0;
|
||||
m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
|
||||
// Первая сборка за всё время: архивировать нечего, сразу разворачиваем цель.
|
||||
if (fromBuildId < 0 || !QDir(gameDir).exists()) {
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QDir().mkpath(buildDir(fromBuildId))) {
|
||||
emit failed(toBuildId,
|
||||
Loc::text("common.error.createFailed")
|
||||
.arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
|
||||
const QStorageInfo storage(LauncherPaths::buildStorageDir());
|
||||
if (storage.isValid() && storage.bytesAvailable() < needed) {
|
||||
emit failed(toBuildId,
|
||||
Loc::text("switch.error.notEnoughSpace")
|
||||
.arg(fromName)
|
||||
.arg(needed / (1024 * 1024)),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
|
||||
+ QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");
|
||||
|
||||
m_state = Archiving;
|
||||
setStage(Loc::text("switch.progress.saving").arg(fromName));
|
||||
requestArchive(gameDir, m_tempZip, sharedTopLevel());
|
||||
}
|
||||
|
||||
void BuildSwitcher::cancel()
|
||||
{
|
||||
// Отмена имеет смысл только на архивации: после очистки папки отступать
|
||||
// уже некуда, там нужно доводить дело до конца.
|
||||
if (m_state == Archiving)
|
||||
m_worker->requestCancel();
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestArchive(const QString &gameDir,
|
||||
const QString &tempZip,
|
||||
const QStringList &exclude)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
|
||||
Q_ARG(QStringList, exclude));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestApplyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &remove)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection,
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir),
|
||||
Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel()));
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath)
|
||||
{
|
||||
m_done = done;
|
||||
m_total = total;
|
||||
m_status = currentPath;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||||
{
|
||||
switch (m_state) {
|
||||
case Idle:
|
||||
return;
|
||||
case Archiving:
|
||||
if (!ok) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(error, /*gameDirIntact=*/true);
|
||||
return;
|
||||
}
|
||||
commitArchive();
|
||||
return;
|
||||
case Clearing:
|
||||
if (!ok) {
|
||||
// Папка осталась разобранной: активной сборкой считается прежняя,
|
||||
// отметка о незавершённом переключении в index.json остаётся.
|
||||
fail(error, /*gameDirIntact=*/false);
|
||||
return;
|
||||
}
|
||||
beginRestoring();
|
||||
return;
|
||||
case Restoring:
|
||||
if (!ok) {
|
||||
fail(error, /*gameDirIntact=*/false);
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
case Applying:
|
||||
if (!ok) {
|
||||
// Архив проверяется целиком до первой записи, поэтому нулевой
|
||||
// счётчик означает, что в папке игры ничего не тронуто. Иначе
|
||||
// часть файлов уже заменена: отметка в index.json остаётся, и
|
||||
// следующий запуск доиграет раскатку из сохранённого пака.
|
||||
fail(error, /*gameDirIntact=*/m_done == 0);
|
||||
return;
|
||||
}
|
||||
finishApplying();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void BuildSwitcher::commitArchive()
|
||||
{
|
||||
const QString target = buildDir(m_fromId) + u'/'
|
||||
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
|
||||
+ u'_' + slug(m_fromName) + QStringLiteral(".zip");
|
||||
|
||||
// На сборку — один архив: новый встаёт на место, все прежние уходят.
|
||||
if (QFile::exists(target) && !QFile::remove(target)) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||
return;
|
||||
}
|
||||
if (!QFile::rename(m_tempZip, target)) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(Loc::text("switch.error.archiveSaveFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||
return;
|
||||
}
|
||||
|
||||
const QDir dir(buildDir(m_fromId));
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||||
for (const QString &name : zips) {
|
||||
// Служебные файлы под удаление не попадают: в .pack-*.zip может лежать
|
||||
// пак недоигранной раскатки, без которого её нечем довести до конца.
|
||||
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
|
||||
// Отметка ставится до первого разрушающего действия: по ней следующий
|
||||
// запуск поймёт, что переключение оборвалось на середине.
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateSwitching);
|
||||
index.insert(QStringLiteral("from"), m_fromId);
|
||||
index.insert(QStringLiteral("to"), m_toId);
|
||||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
builds.insert(QString::number(m_fromId),
|
||||
QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
|
||||
{QStringLiteral("name"), m_fromName},
|
||||
{QStringLiteral("savedAt"),
|
||||
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||
index.insert(QStringLiteral("builds"), builds);
|
||||
writeIndex(index);
|
||||
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginClearing()
|
||||
{
|
||||
setStage(Loc::text("switch.progress.preparing").arg(m_toName));
|
||||
requestClear(m_gameDir, sharedTopLevel());
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginRestoring()
|
||||
{
|
||||
const QString archive = archivePathOf(m_toId);
|
||||
if (archive.isEmpty()) {
|
||||
// Сборка ещё ни разу не запускалась: пустая .minecraft рядом с общими
|
||||
// кэшами — это и есть её правильное начальное состояние.
|
||||
m_state = Restoring;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
m_state = Restoring;
|
||||
setStage(Loc::text("switch.progress.restoring").arg(m_toName));
|
||||
requestRestore(archive, m_gameDir);
|
||||
}
|
||||
|
||||
// ── Раскатка пака сезонной сборки ──────────────────────────────────────────
|
||||
|
||||
void BuildSwitcher::applyPack(int buildId,
|
||||
const QString &buildName,
|
||||
const QString &packZipPath,
|
||||
const QStringList &removeRelative,
|
||||
const QJsonObject ¬e,
|
||||
const QString &gameDir)
|
||||
{
|
||||
if (m_state != Idle) {
|
||||
emit failed(buildId, Loc::text("switch.error.busy"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QString rootError;
|
||||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||
emit failed(buildId, rootError, true);
|
||||
return;
|
||||
}
|
||||
if (!QFile::exists(packZipPath)) {
|
||||
emit failed(buildId,
|
||||
Loc::text("switch.error.archiveMissing")
|
||||
.arg(QDir::toNativeSeparators(packZipPath)),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = buildId;
|
||||
m_toName = buildName;
|
||||
m_gameDir = gameDir;
|
||||
m_packPath = packZipPath;
|
||||
m_packRemove = removeRelative;
|
||||
m_packNote = note;
|
||||
m_lastPackEntries.clear();
|
||||
m_lastPackNote = {};
|
||||
m_done = 0;
|
||||
m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
|
||||
// Отметка ставится до первого разрушающего действия — как и при смене
|
||||
// сборки. Пак лежит рядом и переживёт перезапуск вместе с ней.
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateApplying);
|
||||
index.insert(QStringLiteral("to"), buildId);
|
||||
index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName());
|
||||
index.insert(QStringLiteral("packName"), buildName);
|
||||
index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative));
|
||||
index.insert(QStringLiteral("packNote"), note);
|
||||
writeIndex(index);
|
||||
|
||||
m_state = Applying;
|
||||
beginApplying();
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginApplying()
|
||||
{
|
||||
setStage(Loc::text("switch.progress.installing").arg(m_toName));
|
||||
requestApplyPack(m_packPath, m_gameDir, m_packRemove);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finishApplying()
|
||||
{
|
||||
// Пак сделал своё дело: держать сотни мегабайт рядом с архивом сборки не
|
||||
// за чем, а его наличие означало бы недоигранную раскатку.
|
||||
QFile::remove(m_packPath);
|
||||
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateIdle);
|
||||
index.insert(QStringLiteral("active"), m_toId);
|
||||
index.remove(QStringLiteral("from"));
|
||||
index.remove(QStringLiteral("to"));
|
||||
index.remove(QStringLiteral("pack"));
|
||||
index.remove(QStringLiteral("packName"));
|
||||
index.remove(QStringLiteral("packRemove"));
|
||||
index.remove(QStringLiteral("packNote"));
|
||||
writeIndex(index);
|
||||
|
||||
m_lastPackNote = m_packNote;
|
||||
m_packPath.clear();
|
||||
m_packRemove.clear();
|
||||
m_packNote = {};
|
||||
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit finished(toId);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finish()
|
||||
{
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateIdle);
|
||||
index.insert(QStringLiteral("active"), m_toId);
|
||||
index.remove(QStringLiteral("from"));
|
||||
index.remove(QStringLiteral("to"));
|
||||
writeIndex(index);
|
||||
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit finished(toId);
|
||||
}
|
||||
|
||||
void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
|
||||
{
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit failed(toId, message, gameDirIntact);
|
||||
}
|
||||
|
||||
// ── Удаление сборки ────────────────────────────────────────────────────────
|
||||
|
||||
bool BuildSwitcher::forgetBuild(int buildId)
|
||||
{
|
||||
if (buildId < 0)
|
||||
return true;
|
||||
|
||||
// Запись убираем в любом случае: даже если файл не удалось стереть, ссылаться
|
||||
// на архив несуществующей сборки нельзя — иначе он «воскреснет» под новой
|
||||
// сборкой с тем же id.
|
||||
QJsonObject index = readIndex();
|
||||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
builds.remove(QString::number(buildId));
|
||||
index.insert(QStringLiteral("builds"), builds);
|
||||
if (index.value(QStringLiteral("active")).toInt(-1) == buildId)
|
||||
index.remove(QStringLiteral("active"));
|
||||
writeIndex(index);
|
||||
|
||||
QDir dir(buildDir(buildId));
|
||||
return !dir.exists() || dir.removeRecursively();
|
||||
}
|
||||
|
||||
// ── Незавершённое переключение ─────────────────────────────────────────────
|
||||
|
||||
QString BuildSwitcher::interruptedSwitchWarning() const
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
const QString state = index.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == kStateApplying) {
|
||||
const QString name = index.value(QStringLiteral("packName")).toString();
|
||||
return Loc::text("switch.status.applyInterrupted")
|
||||
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.nameSuffix").arg(name));
|
||||
}
|
||||
if (state != kStateSwitching)
|
||||
return {};
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
const QString name = builds.value(QString::number(toId))
|
||||
.toObject()
|
||||
.value(QStringLiteral("name"))
|
||||
.toString();
|
||||
return Loc::text("switch.status.switchInterrupted")
|
||||
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.targetSuffix").arg(name));
|
||||
}
|
||||
|
||||
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
const QString state = index.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == kStateApplying) {
|
||||
resumeApplying(index, gameDir);
|
||||
return;
|
||||
}
|
||||
if (state != kStateSwitching)
|
||||
return;
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
if (toId < 0 || m_state != Idle)
|
||||
return;
|
||||
|
||||
// Архив исходной сборки уже записан целиком — доигрываем только вторую
|
||||
// половину: очистку и распаковку цели.
|
||||
m_fromId = -1;
|
||||
m_toId = toId;
|
||||
m_toName = index.value(QStringLiteral("builds"))
|
||||
.toObject()
|
||||
.value(QString::number(toId))
|
||||
.toObject()
|
||||
.value(QStringLiteral("name"))
|
||||
.toString();
|
||||
m_gameDir = gameDir;
|
||||
m_done = m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir)
|
||||
{
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
if (toId < 0 || m_state != Idle)
|
||||
return;
|
||||
|
||||
const QString packName = index.value(QStringLiteral("pack")).toString();
|
||||
const QString packPath = buildDir(toId) + u'/' + packName;
|
||||
if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) {
|
||||
// Пак не сохранился — доигрывать нечем. Отметку снимаем, иначе
|
||||
// предупреждение о незавершённой установке будет всплывать вечно;
|
||||
// сборка при этом остаётся наполовину обновлённой, и лаунчер покажет
|
||||
// её как требующую обновления.
|
||||
QJsonObject cleaned = index;
|
||||
cleaned.insert(QStringLiteral("state"), kStateIdle);
|
||||
cleaned.remove(QStringLiteral("pack"));
|
||||
cleaned.remove(QStringLiteral("packName"));
|
||||
cleaned.remove(QStringLiteral("packRemove"));
|
||||
cleaned.remove(QStringLiteral("packNote"));
|
||||
cleaned.remove(QStringLiteral("to"));
|
||||
writeIndex(cleaned);
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList remove;
|
||||
const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray();
|
||||
for (const QJsonValue &value : removeArray)
|
||||
remove << value.toString();
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = toId;
|
||||
m_toName = index.value(QStringLiteral("packName")).toString();
|
||||
m_gameDir = gameDir;
|
||||
m_packPath = packPath;
|
||||
m_packRemove = remove;
|
||||
m_packNote = index.value(QStringLiteral("packNote")).toObject();
|
||||
m_lastPackEntries.clear();
|
||||
m_lastPackNote = {};
|
||||
m_done = m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
m_state = Applying;
|
||||
beginApplying();
|
||||
}
|
||||
|
||||
// ── Реестр архивов ─────────────────────────────────────────────────────────
|
||||
|
||||
QJsonObject BuildSwitcher::readIndex() const
|
||||
{
|
||||
QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
return QJsonDocument::fromJson(file.readAll()).object();
|
||||
}
|
||||
|
||||
void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||||
{
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
|
||||
// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
|
||||
// занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается
|
||||
// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying.
|
||||
void BuildSwitcher::sweepTempArchives() const
|
||||
{
|
||||
QDir root(LauncherPaths::buildStorageDir());
|
||||
const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &build : builds) {
|
||||
QDir dir(root.absoluteFilePath(build));
|
||||
const QStringList leftovers =
|
||||
dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
|
||||
for (const QString &name : leftovers)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class QThread;
|
||||
class BuildArchiveWorker;
|
||||
|
||||
// Смена активной сборки: содержимое .minecraft уезжает в архив своей сборки,
|
||||
// папка чистится, на её место разворачивается архив выбранной.
|
||||
//
|
||||
// Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и
|
||||
// не переименован на место, из .minecraft не удаляется ничего. Отметка о
|
||||
// начатом переключении пишется в builds/index.json до первого разрушающего
|
||||
// действия, поэтому обрыв питания или kill -9 всегда обнаружим на следующем
|
||||
// запуске — см. recoverIfNeeded().
|
||||
class BuildSwitcher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BuildSwitcher(QObject *parent = nullptr);
|
||||
~BuildSwitcher() override;
|
||||
|
||||
bool isRunning() const { return m_state != Idle; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString status() const { return m_status; }
|
||||
double fraction() const; // 0..1, либо -1 пока итог неизвестен
|
||||
|
||||
// Общие для всех сборок каталоги: они не архивируются и не чистятся.
|
||||
static QStringList sharedTopLevel();
|
||||
|
||||
void switchTo(int fromBuildId,
|
||||
const QString &fromName,
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir);
|
||||
|
||||
// Докатывает пак сезонной сборки поверх содержимого .minecraft. Вызывается
|
||||
// только для уже активной сборки: пак ложится на то, что сейчас разложено,
|
||||
// а не внутрь чужого архива.
|
||||
//
|
||||
// note — непрозрачные данные вызывающей стороны: они переживают перезапуск
|
||||
// вместе с отметкой о незавершённой операции и возвращаются через
|
||||
// lastPackNote(), когда раскатка доиграна. Сам переключатель в них не
|
||||
// заглядывает.
|
||||
void applyPack(int buildId,
|
||||
const QString &buildName,
|
||||
const QString &packZipPath,
|
||||
const QStringList &removeRelative,
|
||||
const QJsonObject ¬e,
|
||||
const QString &gameDir);
|
||||
|
||||
// Что принёс последний успешно раскатанный пак и с чем он пришёл.
|
||||
QStringList lastPackEntries() const { return m_lastPackEntries; }
|
||||
QJsonObject lastPackNote() const { return m_lastPackNote; }
|
||||
|
||||
void cancel();
|
||||
|
||||
// Сборку удалили: убираем её архив и запись о нём. Возвращает false, если
|
||||
// папку архива не удалось удалить целиком.
|
||||
bool forgetBuild(int buildId);
|
||||
|
||||
// Есть ли у сборки сохранённый архив (для текста предупреждения об удалении).
|
||||
bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); }
|
||||
|
||||
// Незавершённое переключение с прошлого запуска. Возвращает пустую строку,
|
||||
// если всё в порядке, иначе — текст для пользователя.
|
||||
QString interruptedSwitchWarning() const;
|
||||
// Доигрывает прерванное переключение: очистка и распаковка целевой сборки.
|
||||
void resumeInterrupted(const QString &gameDir);
|
||||
|
||||
signals:
|
||||
void progressChanged();
|
||||
void finished(int toBuildId);
|
||||
void failed(int toBuildId, const QString &message, bool gameDirIntact);
|
||||
|
||||
private:
|
||||
enum State { Idle, Archiving, Clearing, Restoring, Applying };
|
||||
|
||||
// Сигналы к рабочему объекту в другом потоке.
|
||||
void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
|
||||
void requestClear(const QString &gameDir, const QStringList &keep);
|
||||
void requestRestore(const QString &zipPath, const QString &gameDir);
|
||||
void requestApplyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &remove);
|
||||
|
||||
void onWorkerFinished(bool ok, const QString &error);
|
||||
void onWorkerProgress(int done, int total, const QString ¤tPath);
|
||||
|
||||
void commitArchive();
|
||||
void beginApplying();
|
||||
void finishApplying();
|
||||
void resumeApplying(const QJsonObject &index, const QString &gameDir);
|
||||
void beginClearing();
|
||||
void beginRestoring();
|
||||
void finish();
|
||||
void fail(const QString &message, bool gameDirIntact);
|
||||
|
||||
QString buildDir(int buildId) const;
|
||||
QString archivePathOf(int buildId) const;
|
||||
void setStage(const QString &stage);
|
||||
|
||||
QJsonObject readIndex() const;
|
||||
void writeIndex(const QJsonObject &index) const;
|
||||
void sweepTempArchives() const;
|
||||
|
||||
QThread *m_thread;
|
||||
BuildArchiveWorker *m_worker;
|
||||
|
||||
State m_state = Idle;
|
||||
QString m_stage;
|
||||
QString m_status;
|
||||
int m_done = 0;
|
||||
int m_total = 0;
|
||||
|
||||
int m_fromId = -1;
|
||||
int m_toId = -1;
|
||||
QString m_fromName;
|
||||
QString m_toName;
|
||||
QString m_gameDir;
|
||||
QString m_tempZip;
|
||||
|
||||
// Раскатка пака: путь к архиву, список уходящих файлов и данные вызывающей
|
||||
// стороны, которые надо вернуть по завершении.
|
||||
QString m_packPath;
|
||||
QStringList m_packRemove;
|
||||
QJsonObject m_packNote;
|
||||
QStringList m_lastPackEntries;
|
||||
QJsonObject m_lastPackNote;
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
# AuthService
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
|
||||
Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
|
||||
плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
|
||||
|
||||
Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
|
||||
ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
|
||||
по общему пути независимо от того, как пользователь вошёл.
|
||||
|
||||
Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
|
||||
к серверу авторизации на Ely.by.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
|
||||
Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
|
||||
[GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
|
||||
авторизации.
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QJsonObject`, `QString`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: даёт мета-объектную систему, сигнал `progress` и владение по родителю.
|
||||
Виртуальных методов базового класса не переопределяет.
|
||||
|
||||
## Публичные структуры
|
||||
|
||||
### AuthResult
|
||||
|
||||
Результат авторизации — то, что подставляется в `${auth_*}` аргументы запуска.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `ok` | `bool` | Авторизация удалась |
|
||||
| `twoFactorRequired` | `bool` | Ely.by отклонил пароль с пометкой two factor — нужен одноразовый код |
|
||||
| `licenseMissing` | `bool` | Вход в Microsoft прошёл, но копии игры на аккаунте нет. Обрабатывается отдельно от прочих ошибок, потому что чинится только покупкой |
|
||||
| `error` | `QString` | Текст ошибки, когда `ok` равен `false` |
|
||||
| `playerName` | `QString` | Подставляется в `${auth_player_name}` |
|
||||
| `uuid` | `QString` | Подставляется в `${auth_uuid}`; hex без дефисов |
|
||||
| `accessToken` | `QString` | Подставляется в `${auth_access_token}` |
|
||||
| `clientToken` | `QString` | Подставляется в `${clientid}` |
|
||||
| `userType` | `QString` | Подставляется в `${user_type}`; принимает значения `legacy` (офлайн), `msa` (Microsoft) и `ELYBY` |
|
||||
| `refreshToken` | `QString` | Только для аккаунтов Microsoft: продлевает сессию без ввода пароля |
|
||||
| `xuid` | `QString` | Только для Microsoft; подставляется в `${auth_xuid}` |
|
||||
| `expiresAt` | `QDateTime` | Только для Microsoft: UTC-время, когда протухает `accessToken` |
|
||||
|
||||
## Псевдонимы типов
|
||||
|
||||
`AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
|
||||
асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit AuthService(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
||||
|
||||
#### static AuthResult offline(const QString &nickname)
|
||||
|
||||
Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
|
||||
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
|
||||
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
|
||||
на сервере не теряется.
|
||||
|
||||
#### static QString generateClientToken()
|
||||
|
||||
Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним:
|
||||
Yggdrasil связывает выданный `accessToken` именно с этим значением.
|
||||
|
||||
#### void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback)
|
||||
|
||||
Полный цикл входа в Ely.by: сначала проверка имеющегося токена, затем его продление, и только при
|
||||
неудаче — авторизация по паролю. Пароль можно оставить пустым, если уже есть рабочий
|
||||
`accessToken`, — тогда пользователю не придётся вводить его заново.
|
||||
|
||||
По ходу работы испускает `progress` с описанием текущего шага. Результат приходит в `callback`
|
||||
один раз; при ответе с пометкой двухфакторной аутентификации в нём выставлен
|
||||
`twoFactorRequired`, и вызывающий код должен спросить у пользователя код и продолжить через
|
||||
`loginElyByWithTotp()`.
|
||||
|
||||
#### void loginElyByWithTotp(const QString &login, const QString &password, const QString &totp, const QString &clientToken, Callback callback)
|
||||
|
||||
Повтор авторизации с одноразовым кодом двухфакторной аутентификации. Пароль и код объединяются в
|
||||
одно поле в формате «пароль:код», как того требует Ely.by.
|
||||
|
||||
#### void ensureAuthlibInjector(const QString &targetDir, std::function<void(const QString &path, const QString &error)> callback)
|
||||
|
||||
Скачивает `authlib-injector` в `targetDir`, если его там ещё нет. Колбэк получает либо путь к
|
||||
готовому jar, либо текст ошибки — заполнено всегда ровно одно из двух.
|
||||
|
||||
Библиотека нужна только для профилей Ely.by: она подключается к JVM аргументом `-javaagent` и
|
||||
перенаправляет обращения игры к серверу авторизации.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### progress(const QString &message)
|
||||
|
||||
Описание текущего шага авторизации. Испускается по ходу всех сетевых операций.
|
||||
|
||||
Обработчик показывает сообщение пользователю: в главном окне лаунчера оно попадает в плашку
|
||||
статуса и держится до следующего сообщения, потому что шаг может занять заметное время.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` в конструкторе — родитель его и удалит.
|
||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя и уничтожается вместе
|
||||
с ним.
|
||||
|
||||
Колбэки захватываются по значению и живут до своего единственного вызова. Уничтожение сервиса во
|
||||
время незавершённого запроса отменяет запрос вместе с менеджером сети — колбэк в этом случае не
|
||||
вызывается, поэтому захватывать в него сырые указатели на объекты с меньшим временем жизни, чем у
|
||||
сервиса, нельзя.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Все сетевые методы асинхронные, и их колбэки вызываются в том же потоке, в
|
||||
котором создан сервис. Собственной синхронизации в классе нет.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает методы входа при запуске игры и переправляет сигнал `progress` в
|
||||
интерфейс. Полученный `AuthResult` он раскладывает по полям `LaunchOptions`, которые уходят в
|
||||
[GameLauncher](GameLauncher.md). Путь, возвращённый `ensureAuthlibInjector()`, попадает в поле
|
||||
`authlibInjectorPath` тех же параметров запуска.
|
||||
|
||||
Сохранением токенов между запусками занимается `LauncherBackend`: сам сервис ничего не пишет на
|
||||
диск, кроме скачанного jar.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс общается с сервером авторизации Ely.by через
|
||||
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запросы инициирует всегда лаунчер. Внутренний
|
||||
помощник `postJson()` разделяет три исхода: успешный ответ, ответ с кодом ошибки и транспортную
|
||||
ошибку — последняя отдаётся отдельным параметром, чтобы отличить недоступную сеть от отказа
|
||||
сервера.
|
||||
|
||||
Отдельным каналом идёт загрузка `authlib-injector` — обычная HTTPS-загрузка файла в
|
||||
`targetDir`. Повторных попыток при неудаче класс не делает: решение о повторе принимает вызывающий
|
||||
код.
|
||||
|
||||
Все сигналы и колбэки приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *auth = new AuthService(this);
|
||||
connect(auth, &AuthService::progress, this, &Backend::showStatus);
|
||||
|
||||
auth->loginElyBy(profile.login, profile.password,
|
||||
profile.clientToken, profile.accessToken,
|
||||
[this](const AuthResult &result) {
|
||||
if (result.twoFactorRequired) {
|
||||
emit twoFactorRequired(m_pendingProfileName);
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
emit launchError(result.error);
|
||||
return;
|
||||
}
|
||||
continueLaunch(result);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,166 +0,0 @@
|
||||
# BuildArchiveWorker
|
||||
|
||||
## Обзор класса
|
||||
|
||||
Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
|
||||
архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
|
||||
конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
|
||||
|
||||
`BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
|
||||
упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
|
||||
содержимого.
|
||||
|
||||
Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
|
||||
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
|
||||
собственный `QThread`. Больше к классу никто не обращается.
|
||||
|
||||
Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
|
||||
`QZipWriter`, которыми читаются и пишутся архивы сборок.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
|
||||
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
|
||||
|
||||
Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
|
||||
слотов из потока GUI быть не должно.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit BuildArchiveWorker(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт исполнителя. Конструктор помечен `explicit`.
|
||||
|
||||
#### void requestCancel()
|
||||
|
||||
Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
|
||||
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
|
||||
|
||||
Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
|
||||
`QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
|
||||
|
||||
#### void clearCancel()
|
||||
|
||||
Сбрасывает признак отмены перед началом новой операции.
|
||||
|
||||
## Публичные слоты
|
||||
|
||||
Все четыре слота вызываются через очередь сигналов и выполняются в рабочем потоке. Каждый
|
||||
завершается сигналом `finished`.
|
||||
|
||||
#### void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop)
|
||||
|
||||
Упаковывает всё содержимое `gameDir` во временный файл `tempZipPath`. Элементы верхнего уровня,
|
||||
перечисленные в `excludeTop`, в архив не попадают — так из архива сборки исключаются общие
|
||||
каталоги лаунчера, которые не принадлежат ни одной сборке.
|
||||
|
||||
Запись идёт во временный файл, чтобы прерванная упаковка не оставила повреждённый архив на месте
|
||||
настоящего.
|
||||
|
||||
#### void clear(const QString &gameDir, const QStringList &keepTop)
|
||||
|
||||
Удаляет из `gameDir` всё, кроме элементов верхнего уровня, перечисленных в `keepTop`. Выполняется
|
||||
после упаковки, перед распаковкой другой сборки.
|
||||
|
||||
#### void restore(const QString &zipPath, const QString &gameDir)
|
||||
|
||||
Распаковывает архив сборки в `gameDir`.
|
||||
|
||||
#### void applyPack(const QString &zipPath, const QString &gameDir, const QStringList &removeRelative, const QStringList &forbiddenTop)
|
||||
|
||||
Докатывает пак сезонной сборки поверх уже разложенного содержимого. Сначала удаляет файлы из
|
||||
`removeRelative` — те, что ушли из сборки в новой ревизии, — затем распаковывает архив с
|
||||
перезаписью.
|
||||
|
||||
Того, чего нет ни в списке, ни в архиве, операция не касается: миры и скриншоты игрока остаются на
|
||||
месте. Именно это отличает обновление сезонной сборки от её переустановки.
|
||||
|
||||
Параметр `forbiddenTop` — элементы верхнего уровня, которые паку трогать нельзя: общие каталоги
|
||||
лаунчера. Проверяется здесь, а не только у издателя пака, потому что архив приезжает из сети.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### progress(int done, int total, const QString ¤tPath)
|
||||
|
||||
Ход операции: сколько файлов обработано из скольких и какой обрабатывается сейчас. Испускается по
|
||||
ходу всех четырёх операций.
|
||||
|
||||
Обработчик — `BuildSwitcher` — пересчитывает долю выполнения и обновляет панель прогресса. Сигнал
|
||||
приходит в поток GUI через очередь, поэтому прямого доступа к состоянию рабочего потока у
|
||||
обработчика нет.
|
||||
|
||||
#### finished(bool ok, const QString &error)
|
||||
|
||||
Операция завершена. При `ok == false` во втором параметре — текст ошибки.
|
||||
|
||||
Обработчик решает, что делать дальше: перейти к следующему шагу смены сборки или откатить уже
|
||||
сделанное.
|
||||
|
||||
#### packEntries(const QStringList &entries)
|
||||
|
||||
Что именно принёс пак — относительными путями. Испускается только из `applyPack()`.
|
||||
|
||||
Список сохраняется в описании сезонной сборки: следующему обновлению он нужен, чтобы вычислить,
|
||||
какие файлы из сборки ушли, и передать их в `removeRelative`.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent`, но на практике родителя не имеет: объект,
|
||||
перенесённый в другой поток через `moveToThread()`, не может иметь родителя в потоке GUI.
|
||||
Ответственность за его удаление лежит на `BuildSwitcher`, который создаёт и поток, и исполнителя.
|
||||
|
||||
Удалять объект следует безопасным для потоков способом — не напрямую из потока GUI во время
|
||||
работы. Уничтожение потока раньше исполнителя приведёт к обрыву незавершённой операции.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Класс рассчитан на жизнь в отдельном потоке. Все четыре слота выполняются в рабочем потоке;
|
||||
вызывать их напрямую нельзя — только через очередь сигналов.
|
||||
|
||||
Единственная точка межпотокового взаимодействия — признак отмены в `QAtomicInt`:
|
||||
`requestCancel()` и `clearCancel()` пишут его из потока GUI, а рабочий поток читает между файлами.
|
||||
Другого разделяемого состояния у класса нет, поэтому иных блокировок не требуется.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
Единственный собеседник — [BuildSwitcher](BuildSwitcher.md). Он создаёт поток и исполнителя,
|
||||
переносит второй в первый, вызывает слоты через очередь сигналов и принимает `progress`,
|
||||
`finished` и `packEntries`.
|
||||
|
||||
Пути и списки исключений `BuildSwitcher` берёт из [launcherpaths.h](launcherpaths.md) и из
|
||||
описаний сборок; сам исполнитель к ним не обращается.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Файловая система.** Класс читает, пишет и удаляет файлы в папке игры и в каталоге архивов
|
||||
сборок. Сетевых обращений и дочерних процессов у него нет: архив сезонной сборки к моменту вызова
|
||||
`applyPack()` уже скачан [SeasonalPackDownloader](SeasonalPackDownloader.md).
|
||||
|
||||
Все операции выполняются в рабочем потоке, все сигналы приходят в поток GUI через очередь.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *thread = new QThread(this);
|
||||
auto *worker = new BuildArchiveWorker; // без родителя: уедет в другой поток
|
||||
worker->moveToThread(thread);
|
||||
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
|
||||
thread->start();
|
||||
|
||||
connect(worker, &BuildArchiveWorker::progress, this, &Switcher::onProgress);
|
||||
connect(worker, &BuildArchiveWorker::finished, this, &Switcher::onStepFinished);
|
||||
|
||||
worker->clearCancel();
|
||||
QMetaObject::invokeMethod(worker, "archive", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir),
|
||||
Q_ARG(QString, tempZipPath),
|
||||
Q_ARG(QStringList, excludeTop));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,210 +0,0 @@
|
||||
# BuildSwitcher
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
|
||||
папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
|
||||
сборки поверх уже разложенного содержимого.
|
||||
|
||||
Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и не переименован на
|
||||
место, из `.minecraft` не удаляется ничего. Отметка о начатом переключении пишется в
|
||||
`builds/index.json` до первого разрушающего действия, поэтому обрыв питания или принудительное
|
||||
завершение процесса всегда обнаружим на следующем запуске.
|
||||
|
||||
Сами файловые операции выполняет [BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке;
|
||||
`BuildSwitcher` — это порядок шагов, учёт состояния и восстановление после сбоя.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
|
||||
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).
|
||||
|
||||
Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
|
||||
(`buildStorageDir()`, `buildDir()`).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
|
||||
через исполнителя.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
|
||||
виртуальный деструктор — класс обязан корректно остановить свой поток.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit BuildSwitcher(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
|
||||
Конструктор помечен `explicit`.
|
||||
|
||||
#### bool isRunning() const
|
||||
|
||||
Идёт ли сейчас переключение или раскатка пака.
|
||||
|
||||
#### QString stage() const
|
||||
|
||||
Текущий этап словами — заголовок панели прогресса.
|
||||
|
||||
#### QString status() const
|
||||
|
||||
Строка состояния: что обрабатывается сейчас.
|
||||
|
||||
#### double fraction() const
|
||||
|
||||
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
|
||||
|
||||
#### static QStringList sharedTopLevel()
|
||||
|
||||
Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
|
||||
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
|
||||
как `keepTop` при очистке.
|
||||
|
||||
#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
|
||||
|
||||
Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
|
||||
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
|
||||
сборки.
|
||||
|
||||
Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
|
||||
|
||||
#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject ¬e, const QString &gameDir)
|
||||
|
||||
Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
|
||||
сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.
|
||||
|
||||
Параметр `removeRelative` — файлы, ушедшие из сборки в новой ревизии; они удаляются перед
|
||||
распаковкой. Всего остального операция не касается, поэтому миры и скриншоты игрока переживают
|
||||
обновление.
|
||||
|
||||
Параметр `note` — непрозрачные данные вызывающей стороны. Они переживают перезапуск вместе с
|
||||
отметкой о незавершённой операции и возвращаются через `lastPackNote()`, когда раскатка доиграна.
|
||||
Сам переключатель в них не заглядывает.
|
||||
|
||||
#### QStringList lastPackEntries() const
|
||||
|
||||
Что принёс последний успешно раскатанный пак — относительными путями. Список нужен следующему
|
||||
обновлению, чтобы вычислить, какие файлы из сборки ушли.
|
||||
|
||||
#### QJsonObject lastPackNote() const
|
||||
|
||||
Данные, с которыми пришёл последний успешно раскатанный пак, — те самые, что передавались в
|
||||
`applyPack()`.
|
||||
|
||||
#### void cancel()
|
||||
|
||||
Отменяет текущую операцию. Отмена доходит до исполнителя через признак, проверяемый между
|
||||
файлами.
|
||||
|
||||
Отменять имеет смысл только на этапе упаковки: после очистки `.minecraft` отступать некуда, и
|
||||
операцию нужно довести до конца. Именно поэтому панель прогресса смены сборки объявлена
|
||||
неотменяемой.
|
||||
|
||||
#### bool forgetBuild(int buildId)
|
||||
|
||||
Сборку удалили — убирает её архив и запись о нём. Возвращает `false`, если папку архива не удалось
|
||||
удалить целиком.
|
||||
|
||||
#### bool hasArchive(int buildId) const
|
||||
|
||||
Есть ли у сборки сохранённый архив. Используется в тексте предупреждения об удалении: вместе со
|
||||
сборкой пропадут её моды, конфиги и миры.
|
||||
|
||||
#### QString interruptedSwitchWarning() const
|
||||
|
||||
Незавершённое переключение с прошлого запуска. Возвращает пустую строку, если всё в порядке, иначе
|
||||
— готовый текст для пользователя.
|
||||
|
||||
Проверяется при старте лаунчера: отметка в `builds/index.json` пишется до первого разрушающего
|
||||
действия, поэтому прерванная операция обнаруживается всегда.
|
||||
|
||||
#### void resumeInterrupted(const QString &gameDir)
|
||||
|
||||
Доигрывает прерванное переключение: очистку и распаковку целевой сборки. Архив исходной сборки к
|
||||
этому моменту уже записан — правило порядка шагов это гарантирует.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### progressChanged()
|
||||
|
||||
Изменились числа прогресса. Обработчик перечитывает `stage()`, `status()` и `fraction()`.
|
||||
|
||||
#### finished(int toBuildId)
|
||||
|
||||
Переключение или раскатка завершены успешно; в параметре — идентификатор сборки, ставшей активной.
|
||||
|
||||
Обработчик снимает признак переключения, обновляет активную сборку и, после раскатки пака,
|
||||
забирает `lastPackEntries()` и `lastPackNote()`, чтобы записать их в описание сезонной сборки.
|
||||
|
||||
#### failed(int toBuildId, const QString &message, bool gameDirIntact)
|
||||
|
||||
Операция не удалась. Третий параметр — ключевой: он говорит, цела ли папка игры. Неудача на этапе
|
||||
упаковки оставляет `.minecraft` нетронутой, неудача после очистки — нет, и сообщение пользователю
|
||||
должно различать эти случаи.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
|
||||
Рабочий поток и исполнитель создаются в конструкторе и принадлежат переключателю. Исполнитель
|
||||
родителя не имеет — объект, живущий в другом потоке, не может принадлежать объекту из потока GUI;
|
||||
его удаление привязано к завершению потока. Деструктор обязан остановить поток и дождаться его
|
||||
завершения, иначе рабочая операция переживёт своего владельца.
|
||||
|
||||
Временный архив на диске переживает аварийное завершение процесса; накопившиеся временные файлы
|
||||
подчищаются при следующем запуске.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Сам переключатель живёт в потоке GUI: все его публичные методы вызываются оттуда. Тяжёлая работа
|
||||
вынесена в отдельный поток к [BuildArchiveWorker](BuildArchiveWorker.md), общение с которым идёт
|
||||
исключительно через очередь сигналов — прямых вызовов слотов исполнителя нет.
|
||||
|
||||
Единственный межпотоковый примитив — атомарный признак отмены внутри исполнителя.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `switchTo()` при смене активной сборки, `applyPack()` — при установке
|
||||
или обновлении сезонной сборки, `forgetBuild()` — при удалении сборки. Сигналы переключателя он
|
||||
переправляет в свойства, которые читает QML: панель прогресса главного окна показывает `stage()`,
|
||||
`status()` и `fraction()`, а признак переключения блокирует список сборок и кнопки в
|
||||
[BuildsDialog](../qml/BuildsDialog.md).
|
||||
|
||||
При старте бэкенд спрашивает `interruptedSwitchWarning()` и, если операция была прервана,
|
||||
предлагает доиграть её через `resumeInterrupted()`.
|
||||
|
||||
Архив сезонной сборки к моменту вызова `applyPack()` уже скачан
|
||||
[SeasonalPackDownloader](SeasonalPackDownloader.md).
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Файловая система.** Класс читает и пишет `builds/index.json` — отметку о состоянии переключения,
|
||||
— управляет папками архивов сборок и подчищает временные файлы. Все операции с содержимым самой
|
||||
папки игры делегированы исполнителю в рабочем потоке.
|
||||
|
||||
Сети и дочерних процессов у класса нет.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *switcher = new BuildSwitcher(this);
|
||||
|
||||
connect(switcher, &BuildSwitcher::progressChanged, this, &Backend::switchChanged);
|
||||
connect(switcher, &BuildSwitcher::finished, this, &Backend::onSwitchFinished);
|
||||
connect(switcher, &BuildSwitcher::failed, this,
|
||||
[this](int, const QString &message, bool gameDirIntact) {
|
||||
emit launchError(gameDirIntact
|
||||
? message
|
||||
: tr("%1. Папка игры осталась незавершённой.").arg(message));
|
||||
});
|
||||
|
||||
// при старте лаунчера
|
||||
const QString warning = switcher->interruptedSwitchWarning();
|
||||
if (!warning.isEmpty()) {
|
||||
showStatus(warning);
|
||||
switcher->resumeInterrupted(gameDir);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,200 +0,0 @@
|
||||
# GameLauncher
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
|
||||
Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
|
||||
авторизован, — и класс превращает это в командную строку и дочерний процесс.
|
||||
|
||||
Кроме самого запуска класс умеет проверять комплектность `.minecraft` и распаковывать нативные
|
||||
библиотеки, без которых игра не стартует.
|
||||
|
||||
Один экземпляр — одна игра одновременно.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает [minecraftversion.h](minecraftversion.md): разобранная версия — половина входных данных
|
||||
запуска, вторая половина приходит структурой `LaunchOptions` из этого же заголовка.
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
|
||||
`LaunchOptions` из настроек, выбранной сборки и результата авторизации.
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
|
||||
ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
|
||||
модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
|
||||
предупреждение о нём отключено.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
|
||||
методов базового класса не переопределяет.
|
||||
|
||||
## Публичные структуры
|
||||
|
||||
### LaunchOptions
|
||||
|
||||
Всё, что лаунчер знает к моменту нажатия кнопки запуска.
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|------|-----|--------------|----------|
|
||||
| `gameDir` | `QString` | — | Папка `.minecraft` |
|
||||
| `versionId` | `QString` | — | Папка в `versions`, которую запускаем |
|
||||
| `playerName` | `QString` | — | Ник игрока из результата авторизации |
|
||||
| `uuid` | `QString` | — | UUID игрока |
|
||||
| `accessToken` | `QString` | — | Токен доступа |
|
||||
| `userType` | `QString` | — | Тип учётной записи: `legacy`, `msa` или `ELYBY` |
|
||||
| `clientToken` | `QString` | — | Токен клиента |
|
||||
| `xuid` | `QString` | — | Идентификатор Xbox; пустое значение заменяется на `0`, как в офлайне |
|
||||
| `javaPath` | `QString` | — | Путь к java; пустое значение означает «искать самим» |
|
||||
| `minMemoryMb` | `int` | `512` | Значение `-Xms` |
|
||||
| `maxMemoryMb` | `int` | `4096` | Значение `-Xmx` |
|
||||
| `extraJvmArgs` | `QStringList` | — | Дополнительные аргументы JVM из настроек |
|
||||
| `windowWidth` | `int` | `0` | Ширина окна игры; `0` — не передавать `--width` и `--height` |
|
||||
| `windowHeight` | `int` | `0` | Высота окна игры |
|
||||
| `fullscreen` | `bool` | `false` | Запускать в полноэкранном режиме |
|
||||
| `serverAddress` | `QString` | — | `host[:port]` для автоматического захода на сервер |
|
||||
| `authlibInjectorPath` | `QString` | — | Путь к `authlib-injector`; пустое значение — не подключать |
|
||||
| `authlibInjectorApi` | `QString` | `ely.by` | Сервер авторизации, на который перенаправляется игра |
|
||||
| `launcherName` | `QString` | `KishkaLauncher` | Имя лаунчера, которое видит игра |
|
||||
| `launcherVersion` | `QString` | `1.0` | Версия лаунчера |
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit GameLauncher(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт объект. Процесс игры при этом не запускается. Конструктор помечен `explicit`.
|
||||
|
||||
#### bool isRunning() const
|
||||
|
||||
Идёт ли сейчас игра. От этого зависит доступность кнопки запуска в интерфейсе.
|
||||
|
||||
#### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
|
||||
|
||||
Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
|
||||
означает, что всё на месте и сборку можно запускать.
|
||||
|
||||
Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
|
||||
версии бессмысленно, важен сам факт и пара примеров.
|
||||
|
||||
Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
|
||||
|
||||
#### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
|
||||
|
||||
Собирает аргументы ровно в том порядке, в котором их ждёт JVM: сначала аргументы JVM, затем главный
|
||||
класс, затем аргументы игры. Подстановки вида `${...}` из версии заменяются значениями из
|
||||
параметров запуска.
|
||||
|
||||
Метод статический и не имеет побочных эффектов, поэтому годится и для показа собранной командной
|
||||
строки без запуска.
|
||||
|
||||
#### static bool extractNatives(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir, QString \*error)
|
||||
|
||||
Распаковывает файлы `.dll`, `.so` и `.dylib` из нативных библиотек в `<версия>/natives`. Учитывает
|
||||
поле `extractExclude` каждой библиотеки — перечисленные там префиксы не распаковываются.
|
||||
|
||||
Возвращает `false` и заполняет `error` при неудаче. Без этого шага игра не стартует: LWJGL ищет
|
||||
нативные библиотеки именно в этой папке.
|
||||
|
||||
#### bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString \*error)
|
||||
|
||||
Полный цикл запуска: проверка комплектности, распаковка нативных библиотек, поиск java, старт
|
||||
процесса.
|
||||
|
||||
Возвращает `false` и заполняет `error`, если что-то из перечисленного не удалось; `true` означает,
|
||||
что процесс запущен — дальнейшая судьба игры приходит сигналами.
|
||||
|
||||
Путь к java берётся из `options.javaPath`, а при пустом значении ищется через
|
||||
[JavaLocator](javalocator.md) с учётом требования версии.
|
||||
|
||||
#### void terminate()
|
||||
|
||||
Завершает процесс игры. Если игра не запущена, ничего не делает.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### progress(const QString &message)
|
||||
|
||||
Описание текущего шага подготовки: проверка файлов, распаковка нативных библиотек, поиск java.
|
||||
Обработчик показывает сообщение пользователю — подготовка занимает заметное время.
|
||||
|
||||
#### output(const QString &line)
|
||||
|
||||
Одна строка вывода процесса игры. Обработчик пишет её в журнал; в лаунчере это вывод в консоль.
|
||||
|
||||
#### gameStarted(const QString &commandLine)
|
||||
|
||||
Процесс запущен; в параметре — собранная командная строка целиком. Удобно для диагностики: по ней
|
||||
видно, с какими аргументами и какой java стартовала игра.
|
||||
|
||||
#### gameFinished(int exitCode, bool crashed)
|
||||
|
||||
Игра завершилась. `exitCode` — код выхода процесса, `crashed` отличает аварийное завершение от
|
||||
обычного.
|
||||
|
||||
Обработчик снимает признак «игра идёт», возвращает доступность кнопки запуска и сообщает
|
||||
пользователю итог: ненулевой код или выставленный `crashed` показываются как ошибка.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
|
||||
Процесс игры хранится в поле `m_process` и создаётся при запуске. Один экземпляр рассчитан ровно
|
||||
на одну игру одновременно: повторный вызов `launch()` при работающем процессе не предусмотрен, и
|
||||
вызывающий код обязан проверять `isRunning()`.
|
||||
|
||||
Дочерний процесс переживает уничтожение объекта не сам по себе — завершать игру перед выходом
|
||||
должен вызывающий код через `terminate()`.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. `QProcess` привязан к потоку, в котором создан, и все сигналы приходят туда же.
|
||||
Статические методы (`missingFiles()`, `buildArguments()`, `extractNatives()`) состояния не имеют,
|
||||
но выполняют файловый ввод-вывод и на большой версии могут заметно задержать вызывающий поток.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` собирает `LaunchOptions` из трёх источников: настроек лаунчера, описания
|
||||
выбранной сборки и `AuthResult` от [AuthService](AuthService.md) или
|
||||
[MsaAuthService](MsaAuthService.md). Разобранную версию он получает через
|
||||
`VersionLoader::load()` из [minecraftversion.h](minecraftversion.md).
|
||||
|
||||
Все четыре сигнала бэкенд переправляет в QML: `progress` и `gameFinished` попадают в плашку
|
||||
сообщений главного окна, `output` — в консоль, а `gameStarted` меняет признак `gameRunning`.
|
||||
|
||||
Статический `missingFiles()` вызывается отдельно от запуска — из метода проверки комплектности
|
||||
сборки, результат которого показывает [BuildsDialog](../qml/BuildsDialog.md).
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Дочерний процесс.** Класс запускает java через `QProcess`. Аргументы собираются
|
||||
`buildArguments()`; для профилей Ely.by в них добавляется `-javaagent` с путём к
|
||||
`authlib-injector`. Стандартный вывод процесса читается построчно и отдаётся сигналом `output`,
|
||||
завершение — сигналом `gameFinished`. Направление обмена одностороннее: лаунчер запускает процесс
|
||||
и читает его вывод, ничего не передавая обратно после старта.
|
||||
|
||||
Все сигналы процесса приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *launcher = new GameLauncher(this);
|
||||
connect(launcher, &GameLauncher::progress, this, &Backend::showStatus);
|
||||
connect(launcher, &GameLauncher::gameFinished, this, &Backend::onGameFinished);
|
||||
|
||||
LaunchOptions options;
|
||||
options.gameDir = settings.resolvedGameDir;
|
||||
options.versionId = build.resolvedVersionId;
|
||||
options.playerName = auth.playerName;
|
||||
options.uuid = auth.uuid;
|
||||
options.accessToken = auth.accessToken;
|
||||
options.userType = auth.userType;
|
||||
options.maxMemoryMb = settings.maxMemoryMb;
|
||||
|
||||
QString error;
|
||||
if (!launcher->launch(options, version, &error))
|
||||
emit launchError(error);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,201 +0,0 @@
|
||||
# JavaInstaller
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
|
||||
|
||||
**Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
|
||||
отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
|
||||
лаунчер.
|
||||
|
||||
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
|
||||
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
|
||||
`JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
|
||||
от [JavaRuntimeService](JavaRuntimeService.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
|
||||
`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
||||
виртуальный деструктор — класс владеет незавершёнными загрузками, открытым архивом и процессом
|
||||
распаковки.
|
||||
|
||||
## Публичные структуры
|
||||
|
||||
### JavaFileTask
|
||||
|
||||
Один файл рантайма Mojang.
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|------|-----|--------------|----------|
|
||||
| `url` | `QUrl` | — | Откуда качать |
|
||||
| `path` | `QString` | — | Абсолютный путь назначения |
|
||||
| `sha1` | `QString` | — | Контрольная сумма файла |
|
||||
| `size` | `qint64` | `0` | Размер в байтах |
|
||||
| `executable` | `bool` | `false` | Файлу нужно выставить право на исполнение — иначе `bin/java` не запустится |
|
||||
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
|
||||
|
||||
### JavaActiveDownload
|
||||
|
||||
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
|
||||
контрольная сумма и число принятых байт. Файлы пишутся потоком — рантайм весит около двухсот
|
||||
мегабайт.
|
||||
|
||||
Структура `ZipExtraction`, хранящая состояние распаковки, объявлена вперёд и спрятана в
|
||||
`.cpp`: так приватный заголовок `QZipReader` не расходится по проекту вместе с этим заголовком.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit JavaInstaller(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт установщик, его `QNetworkAccessManager` и два таймера. Конструктор помечен `explicit`.
|
||||
|
||||
#### bool isRunning() const
|
||||
|
||||
Идёт ли установка прямо сейчас.
|
||||
|
||||
#### QString runtimeId() const
|
||||
|
||||
Идентификатор устанавливаемой сборки.
|
||||
|
||||
#### QString label() const
|
||||
|
||||
Подпись установки для интерфейса.
|
||||
|
||||
#### QString stage() const
|
||||
|
||||
Текущий этап словами: загрузка, проверка, распаковка.
|
||||
|
||||
#### QString currentFile() const
|
||||
|
||||
Файл, который обрабатывается сейчас.
|
||||
|
||||
#### qint64 bytesDone() const
|
||||
|
||||
Сколько байт уже получено. Байты считаются только на загрузке: на распаковке считать нечего, и
|
||||
панель по нулевому итогу сама прячет мегабайты.
|
||||
|
||||
#### qint64 bytesTotal() const
|
||||
|
||||
Ожидаемый общий объём загрузки.
|
||||
|
||||
#### double fraction() const
|
||||
|
||||
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. На этапе распаковки доля считается
|
||||
по числу обработанных записей архива или файлов дерева, а не по байтам.
|
||||
|
||||
#### void install(const JavaRuntimeEntry &entry)
|
||||
|
||||
Ставит сборку по записи каталога. Путь установки выбирается по полю `archive` записи: значения
|
||||
`zip` и `tar.gz` ведут по пути Temurin, значение `mojang` — по пути манифеста.
|
||||
|
||||
По завершении установщик находит исполняемый файл java в распакованном дереве через
|
||||
`JavaRuntimeStore::locateBinary()` и записывает описание сборки рядом с ней.
|
||||
|
||||
Одновременно ставится одна сборка; очереди у этого установщика нет.
|
||||
|
||||
#### void cancel()
|
||||
|
||||
Отменяет установку. Отмена проверяется между файлами и между кусками распаковки, поэтому
|
||||
срабатывает не мгновенно, но без замораживания интерфейса.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### started(const QString &label)
|
||||
|
||||
Установка началась. Обработчик показывает панель прогресса.
|
||||
|
||||
#### progressChanged()
|
||||
|
||||
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер. Обработчик
|
||||
перечитывает геттеры.
|
||||
|
||||
#### finished(const QString &runtimeId, const QString &javaPath)
|
||||
|
||||
Сборка установлена; во втором параметре — абсолютный путь к исполняемому файлу java.
|
||||
|
||||
Обработчик обновляет каталог: сборка становится помеченной как скачанная, а диалог настроек
|
||||
перечитывает её описание.
|
||||
|
||||
#### failed(const QString &label, const QString &message)
|
||||
|
||||
Установка не удалась; в параметре — текст ошибки для пользователя.
|
||||
|
||||
#### canceled(const QString &label)
|
||||
|
||||
Установка отменена пользователем.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
`QNetworkAccessManager` и оба таймера создаются в конструкторе с установщиком в роли родителя.
|
||||
|
||||
Владение внутренними ресурсами построено на RAII: скачиваемый архив хранится как
|
||||
`std::unique_ptr<QSaveFile>`, состояние распаковки — как `std::unique_ptr<ZipExtraction>`,
|
||||
активные загрузки — как `std::shared_ptr<JavaActiveDownload>` с собственным `QSaveFile` внутри.
|
||||
Незавершённая запись отменяется вместе с уничтожением объекта, и испорченный файл не попадает на
|
||||
место назначения.
|
||||
|
||||
Процесс `tar`, используемый для распаковки архивов `tar.gz`, создаётся по ходу работы и
|
||||
завершается в деструкторе.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Отдельного потока у класса нет намеренно: распаковка zip идёт по кускам по
|
||||
таймеру — держать поток GUI занятым на всю сотню мегабайт нельзя, а заводить поток ради одной
|
||||
операции незачем. Распаковка `tar.gz` отдана внешнему процессу, который работает параллельно сам.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `install()` с записью, полученной от
|
||||
[JavaRuntimeService](JavaRuntimeService.md), и переправляет сигналы прогресса в те же свойства,
|
||||
что и остальные установщики. Сигнал `finished` бэкенд переправляет в QML под собственным именем —
|
||||
на него подписан диалог настроек, чтобы обновить строку выбранной сборки, когда та докачается.
|
||||
|
||||
Раскладку папки `<root>/java`, поиск исполняемого файла и запись описания обеспечивает
|
||||
`JavaRuntimeStore` из [javaruntime.h](javaruntime.md).
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Загрузка по HTTPS с серверов Adoptium или Mojang. Архив Temurin
|
||||
скачивается одним запросом с проверкой sha256; дерево Mojang — множеством параллельных запросов,
|
||||
каждый с проверкой sha1. Неудачная задача повторяется, счётчик попыток хранится в самой задаче.
|
||||
|
||||
**Дочерний процесс.** Архивы `tar.gz` распаковываются системным `tar` через `QProcess` —
|
||||
собственного распаковщика для этого формата в Qt нет. Обмен односторонний: лаунчер запускает
|
||||
процесс и ждёт его завершения.
|
||||
|
||||
**Файловая система.** После распаковки дерева Mojang применяются символические ссылки из
|
||||
манифеста, а файлам с признаком `executable` выставляется право на исполнение.
|
||||
|
||||
Все сигналы приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *javaInstaller = new JavaInstaller(this);
|
||||
|
||||
connect(javaInstaller, &JavaInstaller::finished, this,
|
||||
[this](const QString &runtimeId, const QString &javaPath) {
|
||||
m_settings.javaRuntime = runtimeId;
|
||||
m_resolvedJavaPath = javaPath;
|
||||
emit javaRuntimeInstalled(runtimeId);
|
||||
});
|
||||
|
||||
const auto entry = javaCatalog->find(runtimeId);
|
||||
if (entry)
|
||||
javaInstaller->install(*entry);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,156 +0,0 @@
|
||||
# JavaRuntimeService
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
|
||||
папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
|
||||
[VersionManifestService](VersionManifestService.md).
|
||||
|
||||
Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
|
||||
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
|
||||
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
|
||||
которых Mojang ещё не дошёл.
|
||||
|
||||
Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
|
||||
неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
|
||||
вовсе и приходится брать `x64`, который работает через Rosetta.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
|
||||
`JavaRuntimeEntry` приходят оттуда.
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
|
||||
пользуется [JavaInstaller](JavaInstaller.md).
|
||||
|
||||
Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||
методов базового класса не переопределяет.
|
||||
|
||||
## Псевдонимы типов
|
||||
|
||||
`JavaRuntimeService::Callback` — `std::function<void(bool ok, const QString &warning)>`. Как и в
|
||||
остальных каталогах лаунчера, `ok == true` с непустым `warning` означает данные из устаревшего
|
||||
кэша.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit JavaRuntimeService(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт сервис и его `QNetworkAccessManager`. Кэш читается лениво. Конструктор помечен `explicit`.
|
||||
|
||||
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
||||
|
||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
||||
обновление. Параметр `forceRefresh` обходит проверку свежести — так работает кнопка обновления
|
||||
каталога.
|
||||
|
||||
Одно обновление складывается из нескольких запросов сразу к обоим источникам. Ответы приходят
|
||||
вразнобой, поэтому они накапливаются, и каталог подменяется целиком только когда пришли все.
|
||||
|
||||
#### QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const
|
||||
|
||||
Сборки одной категории: `Mojang`, `Jdk` или `Jre`. Новые версии идут первыми.
|
||||
|
||||
#### std::optional<JavaRuntimeEntry> find(const QString &id) const
|
||||
|
||||
Запись по идентификатору сборки; `std::nullopt`, если такой нет. Поиск идёт по внутреннему
|
||||
указателю.
|
||||
|
||||
#### std::optional<JavaRuntimeEntry> bestFor(int major) const
|
||||
|
||||
Что скачать, если для версии игры нужна Java указанной мажорной версии, а подходящей в системе
|
||||
нет.
|
||||
|
||||
Правила выбора: точное совпадение мажорной версии предпочтительнее более новой, а JDK
|
||||
предпочтительнее JRE — установщики Forge и NeoForge иногда требуют инструментов из полного
|
||||
комплекта. `std::nullopt` означает, что предложить нечего.
|
||||
|
||||
#### bool isRefreshing() const
|
||||
|
||||
Идут ли сейчас запросы. Признак остаётся истинным, пока не завершится последний из них.
|
||||
|
||||
#### bool hasData() const
|
||||
|
||||
Есть ли в каталоге хоть что-то — из сети или из кэша.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### catalogChanged()
|
||||
|
||||
Каталог заменён новыми данными. Испускается один раз за обновление, когда пришли ответы от всех
|
||||
источников, а не по каждому из них.
|
||||
|
||||
Обработчик перечитывает `entries()` для нужных категорий и обновляет интерфейс.
|
||||
|
||||
#### refreshingChanged()
|
||||
|
||||
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||
|
||||
Отложенные колбэки хранятся до завершения текущего обновления. Уничтожение сервиса с
|
||||
незавершёнными запросами обрывает их вместе с менеджером сети, и колбэки не вызываются.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора Java и по кнопке обновления,
|
||||
а сигналы переправляет в свойства, которые читает QML. Перед отдачей в интерфейс он сводит записи
|
||||
каталога с уже установленными сборками из `JavaRuntimeStore` — окно выбора показывает статус, не
|
||||
считая ничего само.
|
||||
|
||||
[JavaInstaller](JavaInstaller.md) получает запись каталога и по ней скачивает и распаковывает
|
||||
сборку. `bestFor()` используется, когда для запуска не хватает Java и лаунчер должен сам
|
||||
предложить, что поставить.
|
||||
|
||||
Требования версий игры к Java берутся из пространства имён `JavaRequirement` в
|
||||
[javaruntime.h](javaruntime.md).
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс обращается к двум внешним каталогам: API Adoptium (сборки
|
||||
Temurin) и списку рантаймов Mojang. Формат обоих — JSON поверх HTTPS, разбор разделён на две
|
||||
функции.
|
||||
|
||||
Запросов на одно обновление несколько: список сборок Temurin запрашивается по мажорным версиям,
|
||||
и на macOS с Apple Silicon неудачный запрос сборки `aarch64` может быть переспрошен для `x64` —
|
||||
это и есть тот самый случай Java 8 и 16.
|
||||
|
||||
Общий счётчик незавершённых запросов сводит их воедино: пока он не обнулился, каталог не
|
||||
подменяется, а предупреждения от отдельных источников накапливаются.
|
||||
|
||||
Все сигналы и колбэки приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *javaCatalog = new JavaRuntimeService(this);
|
||||
connect(javaCatalog, &JavaRuntimeService::catalogChanged, this, &Backend::rebuildJavaCatalog);
|
||||
|
||||
javaCatalog->ensureLoaded([this, javaCatalog](bool ok, const QString &warning) {
|
||||
if (!ok) {
|
||||
emit javaCatalogError(warning);
|
||||
return;
|
||||
}
|
||||
const auto best = javaCatalog->bestFor(version.javaMajor);
|
||||
if (best)
|
||||
emit suggestJavaInstall(best->id, best->version);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,541 +0,0 @@
|
||||
# LauncherBackend
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
|
||||
лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
|
||||
вызывает его методы и слушает его сигналы.
|
||||
|
||||
Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
|
||||
игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
|
||||
переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
|
||||
хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
|
||||
читает и пишет в файлы папки лаунчера.
|
||||
|
||||
Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
|
||||
сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
|
||||
установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Единственный экземпляр создаётся декларативно в [Main.qml](../qml/Main.md); в `main.cpp` он не
|
||||
упоминается.
|
||||
|
||||
Владеет двенадцатью сервисами, каждому из которых посвящена своя страница:
|
||||
|
||||
| Поле | Класс | Роль |
|
||||
|------|-------|------|
|
||||
| `m_auth` | [AuthService](AuthService.md) | вход через Ely.by и офлайн |
|
||||
| `m_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
|
||||
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM с игрой |
|
||||
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
|
||||
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
|
||||
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | списки версий модлоадеров |
|
||||
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
|
||||
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки |
|
||||
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог сборок Java |
|
||||
| `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java |
|
||||
| `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок |
|
||||
| `m_packDownloader` | [SeasonalPackDownloader](SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||
|
||||
Пути ко всем файлам состояния берутся из [launcherpaths.h](launcherpaths.md), описания версий — из
|
||||
[minecraftversion.h](minecraftversion.md), словарь модлоадеров — из [modloader.h](modloader.md),
|
||||
поиск системной Java — из [javalocator.md](javalocator.md).
|
||||
|
||||
Требования сборки: `Qt6::Core`, `Qt6::Gui`, `Qt6::Network`, `Qt6::CorePrivate` (опосредованно) и
|
||||
`Qt6::Quick` — класс зарегистрирован в QML-модуле `Minecraft_launcher`, объявленном в
|
||||
`CMakeLists.txt`.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
|
||||
виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
|
||||
|
||||
## Свойства Q_PROPERTY
|
||||
|
||||
### Профили и сборки
|
||||
|
||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||
|----------|-----|------|-------|--------|----------|
|
||||
| `profileNames` | `QStringList` | `profileNames` | — | `profilesChanged` | Имена профилей игрока в порядке добавления. Только для чтения; модель выпадающего списка профилей |
|
||||
| `customBuildNames` | `QStringList` | `customBuildNames` | — | `customBuildsChanged` | Имена пользовательских сборок. Только для чтения; модель списка в окне сборок |
|
||||
| `activeBuildIndex` | `int` | `activeBuildIndex` | `setActiveBuildIndex` | `activeBuildChanged` | Сборка, которую запускает кнопка игры. Хранится по идентификатору сборки, а не по индексу: удаление соседней записи не должно переназначать активную. Запись в свойство запускает смену сборки |
|
||||
| `activeBuildName` | `QString` | `activeBuildName` | — | `activeBuildChanged` | Имя активной сборки для подписи на кнопке. Только для чтения |
|
||||
| `installedVersions` | `QStringList` | `installedVersions` | — | `installedVersionsChanged` | Версии, реально установленные в папке игры. Только для чтения |
|
||||
|
||||
### Занятость и смена сборки
|
||||
|
||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||
|----------|-----|------|-------|--------|----------|
|
||||
| `gameRunning` | `bool` | `gameRunning` | — | `gameRunningChanged` | Игра запущена. Только для чтения; выключает кнопку запуска |
|
||||
| `switching` | `bool` | `switching` | — | `switchChanged` | Идёт архивация или распаковка `.minecraft` при смене сборки. Отдельно от `busy`, потому что на это время блокируется ещё и список сборок. Только для чтения |
|
||||
| `switchProgress` | `double` | `switchProgress` | — | `switchChanged` | Доля выполнения смены сборки от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
|
||||
| `switchStage` | `QString` | `switchStage` | — | `switchChanged` | Этап смены сборки словами. Только для чтения |
|
||||
| `switchStatus` | `QString` | `switchStatus` | — | `switchChanged` | Строка состояния смены сборки. Только для чтения |
|
||||
| `busy` | `bool` | `busy` | — | `busyChanged` | Лаунчер занят: пока идёт загрузка версии, кнопка запуска гаснет. Только для чтения |
|
||||
| `microsoftAvailable` | `bool` | `microsoftAvailable` | — | — | Собран ли лаунчер с Qt WebEngine. Константное свойство: без WebEngine окно входа Microsoft показать нечем, и интерфейс не должен предлагать этот путь |
|
||||
|
||||
### Каталог версий
|
||||
|
||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||
|----------|-----|------|-------|--------|----------|
|
||||
| `versionCatalog` | `QVariantList` | `versionCatalog` | — | `versionCatalogChanged` | Объединённый список для выбора версии: установленные, разделитель, затем весь каталог Mojang. Строка содержит поля `id`, `label`, `category`, `installed` и `search`. Только для чтения |
|
||||
| `catalogLoading` | `bool` | `catalogLoading` | — | `catalogLoadingChanged` | Идёт загрузка манифеста версий. Только для чтения |
|
||||
|
||||
### Каталог Java
|
||||
|
||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||
|----------|-----|------|-------|--------|----------|
|
||||
| `javaCatalog` | `QVariantList` | `javaCatalog` | — | `javaCatalogChanged` | Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат в папке лаунчера. Строка содержит поля `id`, `label`, `kind`, `major`, `installed`, `downloadable`, `lts`, `sizeMb`, `detail`, `coverage` и `search`. Только для чтения |
|
||||
| `javaCatalogLoading` | `bool` | `javaCatalogLoading` | — | `javaCatalogLoadingChanged` | Идёт загрузка каталога Java. Только для чтения |
|
||||
|
||||
### Сезонные сборки
|
||||
|
||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||
|----------|-----|------|-------|--------|----------|
|
||||
| `seasonalCatalog` | `QVariantList` | `seasonalCatalog` | — | `seasonalCatalogChanged` | Готовые сборки с сервера. Строки уже сведены с локальными записями: окно показывает статус, не считая ничего само. Только для чтения |
|
||||
| `seasonalCatalogLoading` | `bool` | `seasonalCatalogLoading` | — | `seasonalCatalogLoadingChanged` | Идёт загрузка каталога сезонных сборок. Только для чтения |
|
||||
| `seasonalCatalogError` | `QString` | `seasonalCatalogError` | — | `seasonalCatalogChanged` | Текст ошибки обращения к серверу сборок; пусто — всё в порядке. Только для чтения |
|
||||
| `seasonalInstalling` | `bool` | `seasonalInstalling` | — | `seasonalInstallingChanged` | Идёт установка сезонной сборки: окно не даёт начать вторую. Только для чтения |
|
||||
|
||||
### Загрузка
|
||||
|
||||
Одного сигнала на все свойства загрузки достаточно: установщик уже ограничивает частоту, а QML всё
|
||||
равно перечитывает их разом.
|
||||
|
||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||
|----------|-----|------|-------|--------|----------|
|
||||
| `downloading` | `bool` | `downloading` | — | `downloadChanged` | Идёт какая-либо загрузка. Только для чтения |
|
||||
| `downloadProgress` | `double` | `downloadProgress` | — | `downloadChanged` | Доля выполнения от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
|
||||
| `downloadVersion` | `QString` | `downloadVersion` | — | `downloadChanged` | Что именно качается — версия, модлоадер, сборка Java или пак. Только для чтения |
|
||||
| `downloadStatus` | `QString` | `downloadStatus` | — | `downloadChanged` | Строка состояния загрузки. Только для чтения |
|
||||
| `downloadBytesDone` | `qint64` | `downloadBytesDone` | — | `downloadChanged` | Принято байт. Только для чтения |
|
||||
| `downloadBytesTotal` | `qint64` | `downloadBytesTotal` | — | `downloadChanged` | Ожидаемый объём в байтах; `0` — неизвестен. Только для чтения |
|
||||
|
||||
Панель загрузки одна на все четыре источника: свойства отдают числа того установщика или
|
||||
загрузчика, который работает сейчас.
|
||||
|
||||
## Методы Q_INVOKABLE
|
||||
|
||||
Все перечисленные ниже методы вызываются из QML.
|
||||
|
||||
### Профили
|
||||
|
||||
#### void addProfile(const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
|
||||
|
||||
Добавляет профиль игрока. Параметр `authType` принимает значения `offline`, `elyby` и `microsoft`.
|
||||
Для офлайн-профиля пароль не нужен, для профиля Microsoft не нужны ни логин, ни пароль. Испускает
|
||||
`profilesChanged`.
|
||||
|
||||
#### void updateProfile(int index, const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
|
||||
|
||||
Перезаписывает профиль по индексу теми же полями. Испускает `profilesChanged`.
|
||||
|
||||
#### QVariantMap profileAt(int index) const
|
||||
|
||||
Данные профиля для диалога редактирования: имя, логин, пароль, тип, а также признак наличия
|
||||
действующей сессии Microsoft и ник, полученный при официальной авторизации.
|
||||
|
||||
#### void removeProfile(int index)
|
||||
|
||||
Удаляет профиль. Испускает `profilesChanged`.
|
||||
|
||||
### Вход через Microsoft
|
||||
|
||||
#### void startMicrosoftLogin(int profileIndex)
|
||||
|
||||
Начинает вход в аккаунт Microsoft: испускает `microsoftLoginUrlReady` с адресом страницы входа.
|
||||
|
||||
Значение `-1` в `profileIndex` означает, что вход ещё не привязан к профилю: профиль создастся по
|
||||
нику, который вернут Minecraft Services.
|
||||
|
||||
#### void finishMicrosoftLogin(const QString &code)
|
||||
|
||||
Завершает вход по коду авторизации, перехваченному окном браузера. Итог приходит сигналом
|
||||
`microsoftLoginSucceeded` или `microsoftLoginFailed`.
|
||||
|
||||
#### void cancelMicrosoftLogin()
|
||||
|
||||
Сбрасывает начатую сессию входа. Вызывается, когда пользователь закрыл окно или адрес возврата
|
||||
пришёл без кода.
|
||||
|
||||
#### QVariantMap inspectMicrosoftRedirect(const QString &url) const
|
||||
|
||||
Разбирает адрес, на который встроенное окно возвращается после входа. Возвращает карту с полями
|
||||
`matched` (является ли адрес адресом возврата), `code` и `error`.
|
||||
|
||||
Разбор живёт в C++, чтобы правила совпадения не разъезжались с теми, по которым сервис сам строит
|
||||
`redirect_uri`.
|
||||
|
||||
### Сборки
|
||||
|
||||
#### void addCustomBuild(const QString &name, const QString &serverUrl, const QString &minecraftVersion = QString())
|
||||
|
||||
Создаёт пользовательскую сборку. Испускает `customBuildsChanged`.
|
||||
|
||||
#### void updateCustomBuild(int index, const QVariantMap &fields)
|
||||
|
||||
Мержит в сборку только присланные ключи: `name`, `serverUrl`, `minecraftVersion`, `loader`,
|
||||
`loaderVersion`, `resolvedVersionId`. Остальные поля остаются как были — это и позволяет карточке
|
||||
сборки сохранять правки по одному полю за раз.
|
||||
|
||||
#### QVariantMap customBuildAt(int index) const
|
||||
|
||||
Данные сборки для карточки редактирования.
|
||||
|
||||
#### QVariantMap customBuildRemovalInfo(int index) const
|
||||
|
||||
Что именно потеряется при удалении сборки — для текста предупреждения. Возвращает имя сборки и три
|
||||
признака: есть ли у неё архив, активна ли она сейчас и последняя ли она.
|
||||
|
||||
#### void removeCustomBuild(int index)
|
||||
|
||||
Удаляет сборку вместе с её архивом. Испускает `customBuildsChanged`.
|
||||
|
||||
#### void installCustomBuild(int index)
|
||||
|
||||
Докачивает то, чего не хватает выбранной сборке: версию игры и, если он выбран, модлоадер.
|
||||
Вынесено отдельной кнопкой, потому что карточка сборки сохраняет правки по ходу редактирования и
|
||||
установка не должна начинаться сама при каждой правке.
|
||||
|
||||
#### QStringList checkInstallation(int buildIndex) const
|
||||
|
||||
Проверка комплектности без запуска — для подсказки в интерфейсе. Пустой список означает, что
|
||||
сборку можно запускать; иначе возвращаются описания недостающих файлов.
|
||||
|
||||
### Запуск игры
|
||||
|
||||
#### void launchGame(int profileIndex, int buildIndex)
|
||||
|
||||
Главная кнопка. Проверяет выбор профиля и версии, комплектность `.minecraft`, при необходимости
|
||||
авторизуется и стартует игру.
|
||||
|
||||
Авторизация асинхронна, поэтому метод возвращается сразу; дальнейший ход виден по сигналам
|
||||
`launchProgress`, `launched`, `launchError` и `twoFactorRequired`.
|
||||
|
||||
#### void submitTwoFactorCode(const QString &code)
|
||||
|
||||
Продолжает прерванный запуск, отдавая одноразовый код двухфакторной аутентификации. Вызывается
|
||||
после сигнала `twoFactorRequired`.
|
||||
|
||||
#### void cancelPendingLaunch()
|
||||
|
||||
Отменяет запуск, остановленный на ожидании кода двухфакторной аутентификации.
|
||||
|
||||
#### void stopGame()
|
||||
|
||||
Завершает процесс игры.
|
||||
|
||||
### Каталог версий
|
||||
|
||||
#### void refreshVersionCatalog(bool force = false)
|
||||
|
||||
Обновляет каталог версий. Вызывается при открытии окна выбора версии: свежий кэш отвечает без
|
||||
сети. Параметр `force` обходит проверку свежести.
|
||||
|
||||
#### bool isVersionInstalled(const QString &versionId) const
|
||||
|
||||
Установлена ли версия в папке игры.
|
||||
|
||||
#### void installVersion(const QString &versionId)
|
||||
|
||||
Ставит версию игры в фоне. Ход виден по свойствам загрузки.
|
||||
|
||||
#### QVariantMap versionRemovalInfo(const QString &versionId) const
|
||||
|
||||
Что потеряется при удалении версии — для текста предупреждения. Возвращает признак установки,
|
||||
занимаемый объём в мегабайтах, список зависящих профилей модлоадеров и список сборок, которые эту
|
||||
версию используют.
|
||||
|
||||
#### void removeVersion(const QString &versionId)
|
||||
|
||||
Удаляет файлы версии из `versions/`. Библиотеки и ресурсы остаются: они общие для всех версий.
|
||||
Испускает `installedVersionsChanged`.
|
||||
|
||||
#### void cancelDownload()
|
||||
|
||||
Отменяет текущую загрузку.
|
||||
|
||||
### Модлоадеры
|
||||
|
||||
#### QVariantList loaderVersions(const QString &loaderKey, const QString &gameVersion) const
|
||||
|
||||
Версии модлоадера для выбранной версии игры. Возвращает список карт с полями `version`, `label`,
|
||||
`recommended` и `stable`.
|
||||
|
||||
Несовместимых строк в списке нет — отбор заложен в сам источник данных, поэтому проверять
|
||||
совместимость вызывающему коду не нужно. Пустой список означает, что лоадер эту версию игры не
|
||||
поддерживает.
|
||||
|
||||
Параметр `loaderKey` принимает значения `forge`, `fabric`, `neoforge` и `quilt`.
|
||||
|
||||
#### void refreshLoaderVersions(const QString &loaderKey, const QString &gameVersion, bool force = false)
|
||||
|
||||
Запрашивает обновление списка версий лоадера. Результат приходит сигналом `loaderVersionsChanged`.
|
||||
|
||||
#### bool loaderVersionsLoading(const QString &loaderKey, const QString &gameVersion) const
|
||||
|
||||
Идёт ли сейчас запрос по этой паре. Позволяет интерфейсу отличить «ещё грузим» от «не
|
||||
поддерживается».
|
||||
|
||||
#### void installLoaderForBuild(int index)
|
||||
|
||||
Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль в `resolvedVersionId`.
|
||||
|
||||
### Настройки
|
||||
|
||||
#### QVariantMap settings() const
|
||||
|
||||
Настройки запуска одной картой: `gameDir`, `javaPath`, `javaRuntime`, `minMemoryMb`,
|
||||
`maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
|
||||
`resolvedGameDir`.
|
||||
|
||||
#### void updateSettings(const QVariantMap &values)
|
||||
|
||||
Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
|
||||
|
||||
Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
|
||||
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
|
||||
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
|
||||
|
||||
#### QStringList detectedJava() const
|
||||
|
||||
Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
|
||||
|
||||
### Сборки Java
|
||||
|
||||
#### void refreshJavaCatalog(bool force = false)
|
||||
|
||||
Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
|
||||
|
||||
#### void installJavaRuntime(const QString &runtimeId)
|
||||
|
||||
Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
|
||||
|
||||
#### void removeJavaRuntime(const QString &runtimeId)
|
||||
|
||||
Удаляет скачанную сборку Java из папки лаунчера.
|
||||
|
||||
#### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
|
||||
|
||||
Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
|
||||
означает, что сборки с таким идентификатором в папке лаунчера нет.
|
||||
|
||||
Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
|
||||
обновляет его по сигналу `javaRuntimeInstalled`.
|
||||
|
||||
#### int requiredJavaMajor(int buildIndex) const
|
||||
|
||||
Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
|
||||
Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
|
||||
|
||||
### Сезонные сборки
|
||||
|
||||
#### void refreshSeasonalCatalog(bool force = false)
|
||||
|
||||
Обновляет каталог сезонных сборок.
|
||||
|
||||
#### void installSeasonalBuild(const QString &seasonalId)
|
||||
|
||||
Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, Java и
|
||||
файлы.
|
||||
|
||||
Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
|
||||
установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
|
||||
поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
|
||||
|
||||
Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
|
||||
то, что ставится. По завершении испускается `seasonalInstallFinished`.
|
||||
|
||||
#### void cancelSeasonalInstall()
|
||||
|
||||
Отменяет установку сезонной сборки.
|
||||
|
||||
### Папки
|
||||
|
||||
#### void openMinecraftFolder()
|
||||
|
||||
Открывает папку модов Minecraft в файловом менеджере системы.
|
||||
|
||||
#### void openGameFolder()
|
||||
|
||||
Открывает корневую папку игры в файловом менеджере системы.
|
||||
|
||||
## Сигналы
|
||||
|
||||
### Сигналы уведомления свойств
|
||||
|
||||
Эти сигналы объявлены как `NOTIFY` соответствующих свойств; обработчик перечитывает свойство.
|
||||
`profilesChanged`, `customBuildsChanged`, `activeBuildChanged`, `switchChanged`,
|
||||
`installedVersionsChanged`, `settingsChanged`, `gameRunningChanged`, `busyChanged`,
|
||||
`versionCatalogChanged`, `catalogLoadingChanged`, `javaCatalogChanged`,
|
||||
`javaCatalogLoadingChanged`, `seasonalCatalogChanged`, `seasonalCatalogLoadingChanged`,
|
||||
`seasonalInstallingChanged` и `downloadChanged`.
|
||||
|
||||
#### loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion)
|
||||
|
||||
Список версий модлоадера изменился. Параметры сужают событие до конкретной пары, поэтому
|
||||
обработчик обязан сверить их со своим текущим состоянием: обновление может относиться к другой
|
||||
строке лоадера или к прошлой версии игры. Именно так поступает [LoaderRow](../qml/LoaderRow.md).
|
||||
|
||||
### События установки
|
||||
|
||||
#### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
|
||||
|
||||
Сезонная сборка установлена и активна — можно запускать игру. Обработчик показывает сообщение
|
||||
пользователю.
|
||||
|
||||
#### javaRuntimeInstalled(const QString &runtimeId)
|
||||
|
||||
Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
|
||||
переоткрываясь.
|
||||
|
||||
### Запуск игры
|
||||
|
||||
#### launchProgress(const QString &message)
|
||||
|
||||
Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
|
||||
заметное время, и сообщение должно держаться до следующего.
|
||||
|
||||
#### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
|
||||
|
||||
Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
|
||||
|
||||
#### launchError(const QString &message)
|
||||
|
||||
Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
|
||||
сообщается о проблемах записи файлов лаунчера.
|
||||
|
||||
#### twoFactorRequired(const QString &profileName)
|
||||
|
||||
Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
|
||||
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
|
||||
запуск останется висеть в ожидании.
|
||||
|
||||
#### gameOutput(const QString &line)
|
||||
|
||||
Строка вывода процесса игры. Обработчик пишет её в журнал.
|
||||
|
||||
#### gameFinished(int exitCode, bool crashed)
|
||||
|
||||
Игра завершилась. Обработчик сообщает итог: ненулевой код или выставленный `crashed` показываются
|
||||
как ошибка.
|
||||
|
||||
### Вход через Microsoft
|
||||
|
||||
#### microsoftLoginUrlReady(const QString &url)
|
||||
|
||||
Окну входа Microsoft: открыться на этом адресе. Обработчик создаёт окно (в сборке с Qt WebEngine)
|
||||
и открывает его.
|
||||
|
||||
#### microsoftLoginSucceeded(const QString &playerName)
|
||||
|
||||
Вход выполнен. Обработчик сообщает об этом пользователю, но не трогает выбор в списке профилей:
|
||||
новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль.
|
||||
|
||||
#### microsoftLoginFailed(const QString &message)
|
||||
|
||||
Вход не удался.
|
||||
|
||||
#### microsoftReloginRequired(int profileIndex)
|
||||
|
||||
Сессия профиля протухла настолько, что нужен повторный вход руками. Обработчик обычно сразу
|
||||
вызывает `startMicrosoftLogin()` для этого профиля.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent`. Экземпляр создаётся декларативно в QML, поэтому
|
||||
временем его жизни управляет движок QML: объект живёт столько же, сколько главное окно.
|
||||
|
||||
Все двенадцать сервисов создаются в конструкторе с бэкендом в роли родителя и уничтожаются вместе
|
||||
с ним. Порядок создания важен для двоих: [VersionInstaller](VersionInstaller.md) принимает в
|
||||
конструктор сервис манифеста, а [ModLoaderInstaller](ModLoaderInstaller.md) — сервис версий
|
||||
лоадеров и установщик версий; эти указатели не переходят во владение принимающей стороны.
|
||||
|
||||
Кэши каталогов помечены `mutable` и пересобираются лениво из константных геттеров: QML читает
|
||||
свойства помногу раз за кадр, пока открыт список, и пересборка по каждому чтению обошлась бы
|
||||
дорого.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Единственная работа в другом потоке — файловые операции над содержимым
|
||||
`.minecraft`, и она полностью инкапсулирована в [BuildSwitcher](BuildSwitcher.md): сам бэкенд
|
||||
общается с ним обычными сигналами и слотами.
|
||||
|
||||
## Доступ из QML
|
||||
|
||||
Класс зарегистрирован макросом `QML_ELEMENT` в модуле `Minecraft_launcher`, объявленном через
|
||||
`qt_add_qml_module` в `CMakeLists.txt`. Имя типа в QML совпадает с именем класса —
|
||||
`LauncherBackend`.
|
||||
|
||||
Из QML доступны все 26 свойств, все 42 метода `Q_INVOKABLE` и все сигналы, перечисленные выше.
|
||||
Синглтоном тип не объявлен: экземпляр создаётся декларативно в [Main.qml](../qml/Main.md) и
|
||||
передаётся во вложенные диалоги через их свойство `backend`. Все диалоги проекта объявляют его как
|
||||
`required property var backend`.
|
||||
|
||||
Объект, созданный из QML, принадлежит движку QML — удалять его из C++ нельзя.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
**Вниз, к сервисам.** Бэкенд подписан на сигналы всех двенадцати сервисов и сводит их к своим
|
||||
свойствам. Четыре разных источника загрузки — установщик версий, установщик модлоадеров,
|
||||
установщик Java и загрузчик паков — отображаются в одну группу свойств `download*`, поэтому панель
|
||||
в интерфейсе не различает, кто работает; какой из источников показывать, бэкенд решает сам.
|
||||
|
||||
**Вверх, к QML.** Интерфейс не обращается ни к одному сервису напрямую. Каталоги отдаются уже
|
||||
сведёнными с локальным состоянием: строка версии знает про `installed`, строка сезонной сборки —
|
||||
про установленную ревизию и доступное обновление.
|
||||
|
||||
**Состояние на диске.** Профили, сборки и настройки читаются при создании и пишутся при каждом
|
||||
изменении. Отсутствие файла — норма (первый запуск), а повреждённое содержимое отводится в файл с
|
||||
расширением `.bak`, чтобы рабочий файл создался заново. Проблемы хранилища, замеченные на старте,
|
||||
накапливаются и показываются одним сообщением, когда интерфейс уже подключился к сигналам.
|
||||
|
||||
Отдельно предусмотрена миграция: файл `versions.json` от прежней схемы именования переносится в
|
||||
`customBuilds.json` при первом запуске после переименования.
|
||||
|
||||
**Восстановление после сбоя.** При старте бэкенд спрашивает у переключателя сборок, не было ли
|
||||
прервано переключение, и предлагает доиграть его.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
Собственных сетевых обращений и дочерних процессов у класса нет: всё внешнее взаимодействие
|
||||
делегировано сервисам — сеть у каталогов, установщиков и служб авторизации, процессы у
|
||||
[GameLauncher](GameLauncher.md), [ModLoaderInstaller](ModLoaderInstaller.md) и
|
||||
[JavaInstaller](JavaInstaller.md), файловые операции над `.minecraft` у
|
||||
[BuildSwitcher](BuildSwitcher.md).
|
||||
|
||||
Единственное прямое обращение к системе — открытие папки игры в файловом менеджере методами
|
||||
`openMinecraftFolder()` и `openGameFolder()`.
|
||||
|
||||
## Пример использования
|
||||
|
||||
Класс предназначен для создания из QML, а не из C++:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Minecraft_launcher
|
||||
|
||||
Window {
|
||||
id: window
|
||||
visible: true
|
||||
|
||||
LauncherBackend {
|
||||
id: backend
|
||||
|
||||
onLaunchError: (message) => console.warn(message)
|
||||
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
|
||||
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
|
||||
}
|
||||
|
||||
Button {
|
||||
text: backend.activeBuildName
|
||||
enabled: !backend.busy && !backend.gameRunning
|
||||
onClicked: backend.launchGame(profileBox.currentIndex, backend.activeBuildIndex)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,164 +0,0 @@
|
||||
# Localization
|
||||
|
||||
`localization.h` / `localization.cpp`
|
||||
|
||||
Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
|
||||
он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
|
||||
`lrelease` и Linguist не нужны, а все переводы видны в одном файле.
|
||||
|
||||
Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
|
||||
|
||||
## Доступ из QML
|
||||
|
||||
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
|
||||
в любом файле модуля без импорта.
|
||||
|
||||
```qml
|
||||
Text { text: Loc.t.settings.title }
|
||||
Text { text: Loc.t.java.progress.downloading.arg(label) }
|
||||
DarkCombo { model: Loc.t.profile.authTypes }
|
||||
```
|
||||
|
||||
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
|
||||
плоских ключей каталога разбиением по точке.
|
||||
|
||||
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
|
||||
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
|
||||
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
|
||||
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
|
||||
потому что при смене языка дерево заменяется целиком.
|
||||
|
||||
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
|
||||
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
|
||||
языка.
|
||||
|
||||
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
|
||||
|
||||
```qml
|
||||
onLaunched: (profileName, buildName) =>
|
||||
window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")
|
||||
```
|
||||
|
||||
## Доступ из C++
|
||||
|
||||
Свободные функции, а не методы: их вызывают и из namespace-обёрток
|
||||
([LauncherPaths](launcherpaths.md), [JavaLocator](javalocator.md),
|
||||
[ZlibReference](zlibreference.md)), где никакого `QObject` нет.
|
||||
|
||||
```cpp
|
||||
#include "localization.h"
|
||||
|
||||
emit launchError(Loc::text("launch.error.noProfile"));
|
||||
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
|
||||
const QStringList kinds = Loc::list("profile.authTypes");
|
||||
```
|
||||
|
||||
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
|
||||
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
|
||||
|
||||
### Потоки
|
||||
|
||||
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
|
||||
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
|
||||
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
|
||||
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
|
||||
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
|
||||
|
||||
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
|
||||
|
||||
## Каталог
|
||||
|
||||
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
|
||||
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"languages": ["ru", "en"],
|
||||
"displayNames": { "ru": "Русский", "en": "English" }
|
||||
},
|
||||
"strings": {
|
||||
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
|
||||
"java.progress.downloading": { "ru": "Загрузка Java «%1»…", "en": "Downloading Java \"%1\"…" },
|
||||
"profile.authTypes": {
|
||||
"ru": ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"],
|
||||
"en": ["Offline (no password)", "Ely.by", "Microsoft (licensed)"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок
|
||||
файла, а третий язык добавляется колонкой без правок загрузчика.
|
||||
|
||||
**Имя ключа — `<домен>.<вид>.<имя>`**, сегменты в lowerCamelCase.
|
||||
|
||||
- **Домен** — область, а не имя файла: `app`, `common`, `settings`, `profile`, `build`,
|
||||
`seasonal`, `version`, `java`, `loader`, `auth` (с `auth.ely.*`, `auth.msa.*`), `launch`,
|
||||
`game`, `switch`, `storage`, `zlib`.
|
||||
- **Вид** — `title`, `label`, `button`, `placeholder`, `hint`, `header`; для сообщений `error`,
|
||||
`progress`, `status`, `warning`.
|
||||
- **Имя** описывает условие, а не формулировку, чтобы перевод не переименовывал ключ:
|
||||
`gameRunning`, `downloadFailed`, `checksumMismatch`.
|
||||
|
||||
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
|
||||
|
||||
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
|
||||
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
|
||||
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
|
||||
строки, которой оно требуется.
|
||||
|
||||
## Как добавить строку
|
||||
|
||||
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
|
||||
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
|
||||
3. Прогнать `python3 tools/check_translations.py`.
|
||||
|
||||
## Как добавить язык
|
||||
|
||||
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
|
||||
2. Добавить колонку с этим кодом в каждую запись `strings`.
|
||||
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
|
||||
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
|
||||
одинаково во всех колонках).
|
||||
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
|
||||
русский для русской системной локали и английский во всех остальных случаях.
|
||||
|
||||
## Выбор языка и его хранение
|
||||
|
||||
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
|
||||
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
|
||||
диалога «Настройки запуска», применяется по кнопке «Сохранить».
|
||||
|
||||
Круг замкнут в одну сторону, циклической зависимости нет:
|
||||
|
||||
```
|
||||
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
|
||||
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
|
||||
```
|
||||
|
||||
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
|
||||
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
|
||||
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
|
||||
|
||||
Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
|
||||
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
|
||||
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
|
||||
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
|
||||
не запустился.
|
||||
|
||||
**Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
|
||||
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
|
||||
[BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
|
||||
диагностические и короткоживущие.
|
||||
|
||||
## Проверка
|
||||
|
||||
`tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
|
||||
|
||||
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
|
||||
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
|
||||
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
|
||||
- наборы `%N` совпадают по языкам, длины списков равны;
|
||||
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
|
||||
- ключи, на которые никто не ссылается, — предупреждением.
|
||||
@@ -1,191 +0,0 @@
|
||||
# ModLoaderInstaller
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
|
||||
разных пути установки.
|
||||
|
||||
**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
|
||||
`versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
|
||||
полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
|
||||
|
||||
**Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
|
||||
запускает официальный `installer.jar` найденной Java в headless-режиме и смотрит, какой профиль
|
||||
появился в `versions`. Процессу установщика при этом подсовывается эталонный zlib — см.
|
||||
[ZlibReference](zlibreference.md).
|
||||
|
||||
Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, ставить лоадер некуда.
|
||||
|
||||
Набор геттеров прогресса намеренно повторяет `VersionInstaller`: панель загрузки в интерфейсе
|
||||
читает их одинаково, независимо от того, кто сейчас работает.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает [modloader.h](modloader.md). В конструктор принимает
|
||||
[ModLoaderVersionService](ModLoaderVersionService.md) — за адресом установщика — и
|
||||
[VersionInstaller](VersionInstaller.md) — за базовой версией и за докачиванием того, что
|
||||
`installer.jar` не положил.
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QProcess`, `QFile`) и `Qt6::Network` (`QNetworkAccessManager`,
|
||||
`QNetworkReply`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, шесть сигналов и владение по родителю. Объявлен
|
||||
виртуальный деструктор — класс владеет процессом установщика и открытым файлом журнала.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### ModLoaderInstaller(ModLoaderVersionService \*meta, VersionInstaller \*versionInstaller, QObject \*parent = nullptr)
|
||||
|
||||
Создаёт установщик поверх двух сервисов. Ни один из них не переходит во владение установщика —
|
||||
оба обязаны пережить его.
|
||||
|
||||
#### bool isRunning() const
|
||||
|
||||
Идёт ли установка прямо сейчас.
|
||||
|
||||
#### QString label() const
|
||||
|
||||
Подпись текущей установки для интерфейса — название лоадера с версиями.
|
||||
|
||||
#### QString stage() const
|
||||
|
||||
Текущий этап словами.
|
||||
|
||||
#### QString currentFile() const
|
||||
|
||||
Файл, который обрабатывается сейчас.
|
||||
|
||||
#### qint64 bytesDone() const
|
||||
|
||||
Сколько байт уже получено.
|
||||
|
||||
#### qint64 bytesTotal() const
|
||||
|
||||
Ожидаемый общий объём; `0` — неизвестен.
|
||||
|
||||
#### double fraction() const
|
||||
|
||||
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. У пути с `installer.jar` доля
|
||||
почти всё время равна `-1`: сколько работы осталось внутри чужого процесса, лаунчер не знает.
|
||||
|
||||
#### void install(const QString &gameDir, ModLoader loader, const QString &gameVersion, const QString &loaderVersion, const QString &javaPreference)
|
||||
|
||||
Ставит модлоадер. Параметр `javaPreference` — путь к java, указанный пользователем в настройках;
|
||||
он проверяется первым, а при пустом или неподходящем значении java ищется сама. Java нужна даже на
|
||||
пути Fabric и Quilt, потому что перед установкой лоадера скачивается базовая версия игры.
|
||||
|
||||
Перед запуском `installer.jar` установщик записывает заглушку `launcher_profiles.json`: без этого
|
||||
файла официальные установщики Forge и NeoForge отказываются работать.
|
||||
|
||||
#### void cancel()
|
||||
|
||||
Отменяет установку. Если `installer.jar` уже успел отработать, за появившиеся в `versions` папки
|
||||
отвечает лаунчер, и при отмене они убираются.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### started(const QString &label)
|
||||
|
||||
Установка началась. Обработчик показывает панель прогресса.
|
||||
|
||||
#### progressChanged()
|
||||
|
||||
Изменились числа прогресса. Обработчик перечитывает геттеры — тот же набор, что у
|
||||
[VersionInstaller](VersionInstaller.md).
|
||||
|
||||
#### finished(const QString &loaderKey, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId)
|
||||
|
||||
Модлоадер установлен. Последний параметр — идентификатор появившегося профиля `versions/<id>`.
|
||||
Он важен именно для Forge: заранее этот идентификатор неизвестен, поэтому новый профиль ищется
|
||||
разницей между списком версий до и после запуска установщика, а не угадыванием строки —
|
||||
идентификаторы Forge отличаются по эпохам.
|
||||
|
||||
Обработчик записывает `producedVersionId` в сборку: именно этот профиль будет запускаться.
|
||||
|
||||
#### failed(const QString &label, const QString &message)
|
||||
|
||||
Установка не удалась. Текст ошибки по возможности объясняет причину: отдельно распознаётся случай,
|
||||
когда в системе стоит zlib-ng, а подменить его было нечем — это почти наверняка причина
|
||||
расхождения sha1 у Forge, и о ней стоит сказать прямым текстом.
|
||||
|
||||
#### canceled(const QString &label)
|
||||
|
||||
Установка отменена пользователем.
|
||||
|
||||
#### log(const QString &line)
|
||||
|
||||
Строка вывода процесса `installer.jar`. Обработчик пишет её в журнал; вывод также сохраняется в
|
||||
файл, чтобы разбираться с неудачной установкой после закрытия лаунчера.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
|
||||
Указатели на `ModLoaderVersionService` и `VersionInstaller`, переданные в конструктор, **не
|
||||
принадлежат** установщику: оба сервиса создаются раньше и живут дольше.
|
||||
|
||||
`QNetworkAccessManager` создаётся в конструкторе с установщиком в роли родителя; сетевой ответ,
|
||||
процесс установщика и файл журнала создаются по ходу работы и закрываются в деструкторе.
|
||||
|
||||
Установка асинхронна и состоит из вложенных продолжений: `ensureBaseVersion()` принимает функцию,
|
||||
которая будет вызвана после появления базовой версии. Уничтожение установщика посреди этой цепочки
|
||||
обрывает её.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Дочерний процесс `installer.jar` работает параллельно, но общение с ним идёт
|
||||
через сигналы `QProcess`, которые приходят в поток GUI.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `install()` при установке сборки с модлоадером и переправляет сигналы
|
||||
прогресса в те же свойства, что и у `VersionInstaller`, — панель загрузки не различает, кто
|
||||
работает. По сигналу `finished` бэкенд записывает `producedVersionId` в поле сборки
|
||||
`resolvedVersionId`, которое до установки пусто и означает запуск на чистой ванили.
|
||||
|
||||
[ModLoaderVersionService](ModLoaderVersionService.md) даёт адрес `installer.jar`,
|
||||
[VersionInstaller](VersionInstaller.md) ставит базовую версию до начала работы и докачивает
|
||||
недостающие библиотеки после неё, [ZlibReference](zlibreference.md) готовит окружение процесса.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Загрузка `installer.jar` по HTTPS для Forge и NeoForge. Для Fabric и
|
||||
Quilt сеть используется только через сервис версий и `VersionInstaller`.
|
||||
|
||||
**Дочерний процесс.** `installer.jar` запускается java в headless-режиме через `QProcess`.
|
||||
Окружение процесса готовится `ZlibReference::applyTo()`: на Linux с zlib-ng туда дописывается
|
||||
`LD_PRELOAD` с эталонной библиотекой, иначе установщик Forge падает с сообщением «Processor
|
||||
failed, invalid outputs» — он сверяет sha1 собранных им же jar-файлов с эталоном, посчитанным на
|
||||
обычном zlib.
|
||||
|
||||
Вывод процесса читается построчно, отдаётся сигналом `log` и параллельно пишется в файл. Результат
|
||||
установки определяется не кодом выхода, а появлением новой папки в `versions`.
|
||||
|
||||
Все сигналы приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *loaderInstaller = new ModLoaderInstaller(loaderVersions, versionInstaller, this);
|
||||
|
||||
connect(loaderInstaller, &ModLoaderInstaller::finished, this,
|
||||
[this](const QString &key, const QString &game,
|
||||
const QString &loaderVersion, const QString &producedVersionId) {
|
||||
m_build.resolvedVersionId = producedVersionId;
|
||||
saveBuilds();
|
||||
});
|
||||
connect(loaderInstaller, &ModLoaderInstaller::failed, this,
|
||||
[this](const QString &, const QString &message) { emit launchError(message); });
|
||||
|
||||
loaderInstaller->install(gameDir, ModLoader::Forge,
|
||||
QStringLiteral("1.20.1"), QStringLiteral("47.4.0"),
|
||||
settings.javaPath);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,152 +0,0 @@
|
||||
# ModLoaderVersionService
|
||||
|
||||
## Обзор класса
|
||||
|
||||
Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
|
||||
приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
|
||||
тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md).
|
||||
|
||||
Главная особенность класса — в том, чего в нём нет: отдельной проверки совместимости с версией
|
||||
игры. Совместимость заложена в структуру данных. Fabric и Quilt отдают список сразу под нужную
|
||||
версию игры, а `maven-metadata` Forge и NeoForge раскладывается по версиям игры при разборе.
|
||||
Версии игры, под которую сборок нет, соответствует пустой список — выбрать несовместимый лоадер
|
||||
физически нечем.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает [modloader.h](modloader.md): перечисление `ModLoader` и структура `LoaderVersionEntry`
|
||||
приходят оттуда.
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
|
||||
|
||||
Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||
методов базового класса не переопределяет.
|
||||
|
||||
## Псевдонимы типов
|
||||
|
||||
`ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
||||
Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
|
||||
устаревшего кэша.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit ModLoaderVersionService(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
|
||||
конкретному лоадеру. Конструктор помечен `explicit`.
|
||||
|
||||
#### void ensureLoaded(ModLoader loader, const QString &gameVersion, Callback callback, bool forceRefresh = false)
|
||||
|
||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
||||
один сетевой запрос на всех, кто успел попросить.
|
||||
|
||||
Свежесть считается по-разному в зависимости от лоадера: Fabric и Quilt спрашиваются по каждой
|
||||
версии игры отдельно, поэтому отметка времени у них своя на каждую версию; Forge и NeoForge
|
||||
приходят одним `maven-metadata` на все версии сразу, и отметка у них одна на весь лоадер.
|
||||
|
||||
Параметр `forceRefresh` обходит проверку свежести.
|
||||
|
||||
#### QList<LoaderVersionEntry> versions(ModLoader loader, const QString &gameVersion) const
|
||||
|
||||
Список сборок лоадера под конкретную версию игры. Новые сборки идут первыми, поэтому первая строка
|
||||
— самая свежая; именно её интерфейс подставляет по умолчанию.
|
||||
|
||||
Пустой список означает, что лоадер эту версию игры не поддерживает.
|
||||
|
||||
#### bool isRefreshing(ModLoader loader, const QString &gameVersion) const
|
||||
|
||||
Идёт ли сейчас запрос по этой паре. Интерфейс по этому признаку отличает «ещё грузим» от «не
|
||||
поддерживается» — оба случая выглядят пустым списком.
|
||||
|
||||
#### std::optional<LoaderVersionEntry> find(ModLoader loader, const QString &gameVersion, const QString &loaderVersion) const
|
||||
|
||||
Запись по версии лоадера; `std::nullopt`, если такой нет. Из неё установщик берёт ссылку на
|
||||
`installer.jar`.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### versionsChanged(const QString &loaderKey, const QString &gameVersion)
|
||||
|
||||
Список версий изменился. Параметры сужают событие до конкретной пары: `loaderKey` принимает
|
||||
значения `forge`, `fabric`, `neoforge`, `quilt`.
|
||||
|
||||
Обработчик должен сверить оба параметра со своим текущим состоянием и перечитать `versions()`,
|
||||
только если они совпадают, — иначе обновление относится к другой строке лоадера. Именно так
|
||||
поступает [LoaderRow](../qml/LoaderRow.md).
|
||||
|
||||
#### refreshingChanged()
|
||||
|
||||
Изменился признак сетевого обновления у какой-либо пары. Обработчик перечитывает
|
||||
`isRefreshing()` для интересующей его пары.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||
|
||||
Отложенные колбэки хранятся по ключу запроса до завершения соответствующего обращения к сети.
|
||||
Уничтожение сервиса с незавершённым запросом обрывает его, и накопленные колбэки не вызываются.
|
||||
|
||||
Ссылку на сервис держит установщик модлоадеров; уничтожать сервис раньше установщика нельзя.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` оборачивает сервис тремя методами, доступными из QML: получить список, запросить
|
||||
обновление и узнать, идёт ли загрузка. Сигнал `versionsChanged` он переправляет в QML под тем же
|
||||
именем, поэтому строка лоадера в карточке сборки подписывается прямо на него.
|
||||
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md) обращается к `find()` за адресом установщика перед
|
||||
началом установки.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс обращается к четырём разным источникам метаданных, и форматы
|
||||
ответов различаются: у Fabric и Quilt это JSON, у Forge и NeoForge — XML `maven-metadata`. Разбор
|
||||
разделён на две функции соответственно.
|
||||
|
||||
Испорченный ответ разбирается в пустой результат, и хороший кэш им не затирается — это сознательное
|
||||
решение: лучше показать вчерашний список, чем стереть его из-за сбоя на сервере.
|
||||
|
||||
При недоступной сети данные отдаются из устаревшего кэша с `ok == true` и заполненным `warning`.
|
||||
|
||||
Все сигналы и колбэки приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *loaders = new ModLoaderVersionService(this);
|
||||
|
||||
connect(loaders, &ModLoaderVersionService::versionsChanged,
|
||||
this, [this](const QString &key, const QString &game) {
|
||||
if (key == loaderKey(ModLoader::Fabric) && game == m_gameVersion)
|
||||
emit fabricVersionsChanged();
|
||||
});
|
||||
|
||||
loaders->ensureLoaded(ModLoader::Fabric, QStringLiteral("1.21.1"),
|
||||
[this, loaders](bool ok, const QString &warning) {
|
||||
if (!ok) {
|
||||
showStatus(warning);
|
||||
return;
|
||||
}
|
||||
const auto list = loaders->versions(ModLoader::Fabric,
|
||||
QStringLiteral("1.21.1"));
|
||||
if (!list.isEmpty())
|
||||
selectVersion(list.first().loaderVersion);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,161 +0,0 @@
|
||||
# MsaAuthService
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
|
||||
игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
|
||||
Services → проверка лицензии.
|
||||
|
||||
Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
|
||||
поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
|
||||
|
||||
Класс не показывает окно входа сам: страницу Microsoft открывает
|
||||
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
|
||||
страницы и разбирает адрес возврата.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
|
||||
[LauncherBackend](LauncherBackend.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`).
|
||||
|
||||
Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
|
||||
окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
|
||||
воспользоваться им нельзя: показать страницу нечем.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
|
||||
методов базового класса не переопределяет.
|
||||
|
||||
## Псевдонимы типов
|
||||
|
||||
`MsaAuthService::Callback` — `std::function<void(const AuthResult &)>`. Колбэк вызывается ровно
|
||||
один раз и в потоке GUI.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit MsaAuthService(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
||||
|
||||
#### static QString clientId()
|
||||
|
||||
Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
|
||||
подставляет туда именно идентификатор приложения, а не случайный токен сессии.
|
||||
|
||||
#### static QUrl authorizationUrl()
|
||||
|
||||
Адрес страницы входа для встроенного окна браузера. В адресе уже собраны идентификатор клиента,
|
||||
`redirect_uri` и параметр выбора аккаунта — вызывающему коду достаточно открыть эту ссылку.
|
||||
|
||||
#### static bool matchRedirect(const QUrl &url, QString \*code, QString \*error)
|
||||
|
||||
Отличает адрес, на который Microsoft возвращает управление после входа, от остальной навигации
|
||||
внутри окна. Возвращает `true` только для адреса возврата.
|
||||
|
||||
При совпадении заполняется ровно одно из двух: `code` — код авторизации при успешном входе, либо
|
||||
`error` — текст отказа. Разбор адреса живёт здесь, а не в QML, потому что правила совпадения
|
||||
обязаны совпадать с теми, по которым сервис сам строит `redirect_uri`.
|
||||
|
||||
#### void loginWithCode(const QString &code, Callback callback)
|
||||
|
||||
Полный вход по коду, полученному из окна браузера. Проходит всю цепочку: обмен кода на токен
|
||||
Microsoft, аутентификация в Xbox Live, авторизация XSTS, вход в Minecraft Services, проверка
|
||||
лицензии и получение профиля.
|
||||
|
||||
По ходу испускает `progress` с описанием текущего шага — цепочка длинная, и без обратной связи
|
||||
вход выглядел бы зависанием. Результат приходит в `callback` один раз.
|
||||
|
||||
Если вход прошёл, но копии игры на аккаунте нет, в результате выставлен `licenseMissing`: этот
|
||||
случай чинится только покупкой, поэтому обрабатывается отдельно от прочих ошибок.
|
||||
|
||||
#### void loginWithRefreshToken(const QString &refreshToken, Callback callback)
|
||||
|
||||
Продление сессии без участия пользователя. Refresh-токен Microsoft живёт куда дольше суточного
|
||||
токена Minecraft, так что при повторном запуске лаунчера обычно хватает его, и окно входа
|
||||
показывать не приходится.
|
||||
|
||||
Проходит ту же цепочку, начиная с обмена refresh-токена. Неудача означает, что токен окончательно
|
||||
протух и нужен полноценный вход через окно.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### progress(const QString &message)
|
||||
|
||||
Описание текущего шага цепочки авторизации.
|
||||
|
||||
Обработчик показывает сообщение пользователю. Сигнал особенно важен для этого класса: шагов пять,
|
||||
каждый — отдельный сетевой запрос, и между ними проходит заметное время.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит. `QNetworkAccessManager`
|
||||
создаётся в конструкторе с сервисом в роли родителя.
|
||||
|
||||
Каждый шаг цепочки вызывается из колбэка предыдущего, поэтому незавершённый вход держит цепочку
|
||||
захваченных колбэков до своего конца. Уничтожение сервиса посреди цепочки обрывает её вместе с
|
||||
менеджером сети, и колбэк не вызывается.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Все методы асинхронные, колбэки и сигналы приходят в поток, где создан сервис.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` создаёт сервис, отдаёт в QML адрес страницы входа, принимает от окна код
|
||||
авторизации и вызывает `loginWithCode()`. Полученные `refreshToken` и `expiresAt` он сохраняет в
|
||||
профиле, чтобы при следующем запуске обойтись `loginWithRefreshToken()`.
|
||||
|
||||
Заполненный `AuthResult` дальше раскладывается по полям `LaunchOptions` для
|
||||
[GameLauncher](GameLauncher.md) — ровно так же, как результат от [AuthService](AuthService.md).
|
||||
|
||||
Со стороны QML вход выглядит так: `LauncherBackend` испускает сигнал с адресом страницы, главное
|
||||
окно открывает [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), тот следит за навигацией и
|
||||
возвращает код обратно в бэкенд.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс последовательно обращается к пяти внешним службам: конечной
|
||||
точке OAuth2 Microsoft, Xbox Live, XSTS, Minecraft Services и профильной конечной точке Minecraft.
|
||||
Формат — JSON поверх HTTPS, кроме первого шага, где тело запроса отправляется как форма
|
||||
(`postForm()`); дальше используются `postJson()` и `getJson()` с токеном в заголовке
|
||||
авторизации.
|
||||
|
||||
Все запросы инициирует лаунчер. Ответ каждого шага разбирается тремя исходами: успех, ошибка с
|
||||
кодом состояния и транспортная ошибка — последняя приходит отдельным параметром, чтобы отличить
|
||||
недоступную сеть от отказа службы.
|
||||
|
||||
Повторных попыток класс не делает. Все сигналы и колбэки приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *msa = new MsaAuthService(this);
|
||||
connect(msa, &MsaAuthService::progress, this, &Backend::showStatus);
|
||||
|
||||
// 1. открыть окно браузера на этом адресе
|
||||
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl());
|
||||
|
||||
// 2. когда окно поймало адрес возврата
|
||||
QString code, error;
|
||||
if (MsaAuthService::matchRedirect(url, &code, &error) && !code.isEmpty()) {
|
||||
msa->loginWithCode(code, [this](const AuthResult &result) {
|
||||
if (result.licenseMissing) {
|
||||
emit loginFailed(tr("На аккаунте нет копии Minecraft"));
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
emit loginFailed(result.error);
|
||||
return;
|
||||
}
|
||||
storeSession(result);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,175 +0,0 @@
|
||||
# SeasonalBuildService
|
||||
|
||||
## Обзор класса
|
||||
|
||||
Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с
|
||||
собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный
|
||||
заранее и выдаваемый целиком.
|
||||
|
||||
`SeasonalBuildService` — каталог этих сборок: скачивает `index.json` с файлового сервера, кэширует
|
||||
в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство повторяет
|
||||
[VersionManifestService](VersionManifestService.md) — включая то, что пустой разбор считается
|
||||
испорченным ответом и хороший кэш им не затирается.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
|
||||
используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
|
||||
его контрольная сумма.
|
||||
|
||||
Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
|
||||
[launcherpaths.h](launcherpaths.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||
методов базового класса не переопределяет.
|
||||
|
||||
## Публичные структуры
|
||||
|
||||
### SeasonalBuildEntry
|
||||
|
||||
Одна готовая сборка с сервера сезонных сборок.
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|------|-----|--------------|----------|
|
||||
| `id` | `QString` | — | Идентификатор вида `season-5`; он же ключ, по которому сборка узнаётся среди локальных записей |
|
||||
| `name` | `QString` | — | Название для интерфейса, например «Сезон 5: Пустоши» |
|
||||
| `revision` | `int` | `0` | Растёт при каждой публикации; сравнение с установленной ревизией даёт признак доступного обновления |
|
||||
| `minecraftVersion` | `QString` | — | Версия игры, например `1.20.1` |
|
||||
| `loader` | `QString` | — | Ключ модлоадера: пустая строка (чистая ваниль), `forge`, `fabric`, `neoforge` или `quilt` |
|
||||
| `loaderVersion` | `QString` | — | Версия модлоадера |
|
||||
| `modCount` | `int` | `0` | Число модов в сборке; показывается колонкой в таблице |
|
||||
| `seasonStart` | `QDate` | — | Начало сезона |
|
||||
| `seasonEnd` | `QDate` | — | Конец сезона; невалидная дата означает, что сезон ещё не закончен |
|
||||
| `serverUrl` | `QString` | — | Адрес игрового сервера — не файлового, с которого качается сборка |
|
||||
| `javaMajor` | `int` | `0` | Требуемая версия Java; `0` означает «определять по версии игры» |
|
||||
| `description` | `QString` | — | Описание сборки; показывается в подвале окна каталога |
|
||||
| `archiveUrl` | `QUrl` | — | Адрес архива сборки |
|
||||
| `archiveSize` | `qint64` | `0` | Размер архива в байтах |
|
||||
| `archiveSha256` | `QString` | — | Контрольная сумма архива |
|
||||
|
||||
Метод `isValid()` возвращает `true`, когда заполнен `id`, `revision` больше нуля и `archiveUrl`
|
||||
корректен.
|
||||
|
||||
## Псевдонимы типов
|
||||
|
||||
`SeasonalBuildService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
||||
Сочетание `ok == true` с непустым `warning` означает данные из устаревшего кэша.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit SeasonalBuildService(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
||||
|
||||
#### QList<SeasonalBuildEntry> builds() const
|
||||
|
||||
Текущий каталог сборок. Возвращает копию.
|
||||
|
||||
#### bool hasData() const
|
||||
|
||||
Есть ли в каталоге хоть что-то — из сети или из кэша.
|
||||
|
||||
#### bool isRefreshing() const
|
||||
|
||||
Идёт ли сейчас сетевое обновление.
|
||||
|
||||
#### QString lastError() const
|
||||
|
||||
Последняя ошибка обращения к серверу; пустая строка означает, что всё в порядке.
|
||||
|
||||
В отличие от остальных каталогов лаунчера, ошибка здесь хранится отдельным полем: пустой список и
|
||||
ошибка выглядят одинаково пустыми, и окно каталога показывает причину прямо на месте строк.
|
||||
|
||||
#### std::optional<SeasonalBuildEntry> find(const QString &id) const
|
||||
|
||||
Запись по идентификатору сборки; `std::nullopt`, если такой нет.
|
||||
|
||||
#### void setBaseUrl(const QUrl &baseUrl)
|
||||
|
||||
Задаёт адрес сервера сборок. Адрес меняется из настроек, поэтому при смене хоста накопленные
|
||||
данные и кэш сбрасываются: ссылки в них указывают на старый сервер и после смены недействительны.
|
||||
|
||||
#### QUrl baseUrl() const
|
||||
|
||||
Текущий адрес сервера сборок.
|
||||
|
||||
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
||||
|
||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
||||
один сетевой запрос на всех, кто успел попросить. Параметр `forceRefresh` обходит проверку
|
||||
свежести — так работает кнопка обновления списка.
|
||||
|
||||
Окно каталога вызывает этот метод при каждом открытии без принудительного обновления: свежий кэш
|
||||
отвечает без сети, поэтому вызов ничего не стоит.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### buildsChanged()
|
||||
|
||||
Каталог изменился — пришли новые данные или прочитан кэш. Обработчик перечитывает `builds()` и
|
||||
обновляет таблицу.
|
||||
|
||||
#### refreshingChanged()
|
||||
|
||||
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки; в окне
|
||||
каталога по нему же выключается кнопка обновления списка.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||
|
||||
Отложенные колбэки хранятся до завершения текущего запроса; уничтожение сервиса с незавершённым
|
||||
запросом обрывает его, и колбэки не вызываются.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна каталога и по кнопке обновления, а
|
||||
сигналы переправляет в свойства для QML. Перед отдачей в интерфейс он сводит записи каталога с
|
||||
локальными: строка таблицы уже содержит готовый статус и признак доступного обновления, поэтому
|
||||
[SeasonalBuildsDialog](../qml/SeasonalBuildsDialog.md) ничего не считает сам.
|
||||
|
||||
Установка сезонной сборки начинается с `find()`: по записи бэкенд получает адрес архива и передаёт
|
||||
его [SeasonalPackDownloader](SeasonalPackDownloader.md), а скачанный пак раскатывает
|
||||
[BuildSwitcher](BuildSwitcher.md).
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс скачивает `index.json` с файлового сервера сборок, адрес
|
||||
которого задаётся через `setBaseUrl()`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
|
||||
|
||||
Испорченный или пустой ответ не затирает хороший кэш. При недоступной сети данные отдаются из
|
||||
устаревшего кэша с `ok == true` и заполненным `warning`; текст ошибки при этом попадает и в
|
||||
`lastError()`.
|
||||
|
||||
Все сигналы и колбэки приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *seasonal = new SeasonalBuildService(this);
|
||||
seasonal->setBaseUrl(QUrl(settings.seasonalServer));
|
||||
connect(seasonal, &SeasonalBuildService::buildsChanged, this, &Backend::rebuildSeasonalCatalog);
|
||||
|
||||
seasonal->ensureLoaded([this, seasonal](bool ok, const QString &warning) {
|
||||
if (!ok) {
|
||||
emit seasonalCatalogError(seasonal->lastError());
|
||||
return;
|
||||
}
|
||||
if (!warning.isEmpty())
|
||||
showStatus(warning);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,162 +0,0 @@
|
||||
# SeasonalPackDownloader
|
||||
|
||||
## Обзор класса
|
||||
|
||||
`SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
|
||||
двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
|
||||
sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
|
||||
нельзя.
|
||||
|
||||
Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
|
||||
вместе с остальными операциями над содержимым папки игры.
|
||||
|
||||
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md) и
|
||||
[JavaInstaller](JavaInstaller.md): панель загрузки в интерфейсе читает их одинаково, независимо от
|
||||
того, кто сейчас работает.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Адрес архива, его размер
|
||||
и контрольную сумму даёт запись каталога от [SeasonalBuildService](SeasonalBuildService.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`, `QNetworkReply`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
||||
виртуальный деструктор — класс владеет незавершённой загрузкой и открытым файлом.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit SeasonalPackDownloader(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт загрузчик, его `QNetworkAccessManager` и таймер сглаживания прогресса. Конструктор помечен
|
||||
`explicit`.
|
||||
|
||||
#### bool isRunning() const
|
||||
|
||||
Идёт ли загрузка прямо сейчас.
|
||||
|
||||
#### QString label() const
|
||||
|
||||
Подпись загрузки для интерфейса — как правило, название сезонной сборки.
|
||||
|
||||
#### QString stage() const
|
||||
|
||||
Текущий этап словами.
|
||||
|
||||
#### QString currentFile() const
|
||||
|
||||
Файл, который качается сейчас.
|
||||
|
||||
#### qint64 bytesDone() const
|
||||
|
||||
Сколько байт уже получено.
|
||||
|
||||
#### qint64 bytesTotal() const
|
||||
|
||||
Ожидаемый размер архива.
|
||||
|
||||
#### double fraction() const
|
||||
|
||||
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен — например, когда сервер не сообщил
|
||||
размер, а в записи каталога он не был указан.
|
||||
|
||||
#### void download(const QUrl &url, const QString &targetPath, const QString &sha256, qint64 expectedSize, const QString &label)
|
||||
|
||||
Скачивает архив по адресу `url` в `targetPath`, сверяя sha256 с переданным значением.
|
||||
|
||||
Файл `targetPath` перезаписывается: недокачанный пак с прошлой попытки не должен пережить новую.
|
||||
Запись идёт через `QSaveFile`, поэтому на месте назначения файл появляется только целиком и только
|
||||
после успешной проверки контрольной суммы.
|
||||
|
||||
Параметр `expectedSize` берётся из записи каталога и используется для расчёта доли выполнения,
|
||||
пока сервер не сообщил размер сам.
|
||||
|
||||
#### void cancel()
|
||||
|
||||
Отменяет загрузку. Недокачанный файл на месте назначения не остаётся.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### started(const QString &label)
|
||||
|
||||
Загрузка началась. Обработчик показывает панель прогресса.
|
||||
|
||||
#### progressChanged()
|
||||
|
||||
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер, — иначе сигнал
|
||||
на каждый принятый блок обошёлся бы дороже самой загрузки. Обработчик перечитывает геттеры.
|
||||
|
||||
#### finished(const QString &path)
|
||||
|
||||
Архив скачан и проверен; в параметре — путь к готовому файлу.
|
||||
|
||||
Обработчик передаёт этот путь [BuildSwitcher](BuildSwitcher.md) для раскатки поверх содержимого
|
||||
`.minecraft`.
|
||||
|
||||
#### failed(const QString &label, const QString &message)
|
||||
|
||||
Загрузка не удалась: сеть недоступна, сервер ответил ошибкой или не сошлась контрольная сумма.
|
||||
Последний случай особенно важен — он означает, что архив повреждён и распаковывать его нельзя.
|
||||
|
||||
#### canceled(const QString &label)
|
||||
|
||||
Загрузка отменена пользователем.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
`QNetworkAccessManager` и таймер создаются в конструкторе с загрузчиком в роли родителя.
|
||||
|
||||
Скачиваемый файл хранится как `std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе
|
||||
с уничтожением объекта, и повреждённый архив не попадает на место назначения. Сетевой ответ
|
||||
создаётся по ходу работы и закрывается в деструкторе.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI. Данные пишутся на диск блоками по мере поступления, поэтому длительных
|
||||
синхронных операций в потоке нет.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `download()` при установке или обновлении сезонной сборки, передавая
|
||||
адрес и контрольную сумму из записи [SeasonalBuildService](SeasonalBuildService.md). Сигналы
|
||||
прогресса он переправляет в те же свойства, что и остальные загрузчики, поэтому панель в главном
|
||||
окне не различает, кто работает.
|
||||
|
||||
По сигналу `finished` бэкенд передаёт путь к архиву в `BuildSwitcher::applyPack()` вместе со
|
||||
списком уходящих файлов из описания предыдущей ревизии.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Одна загрузка по HTTPS с файлового сервера сборок. Направление
|
||||
одностороннее, тело ответа — двоичный архив.
|
||||
|
||||
Данные пишутся потоком через `QSaveFile` с одновременным подсчётом sha256; несовпадение суммы
|
||||
приводит к сигналу `failed`, и файл на месте назначения не появляется. Повторных попыток класс не
|
||||
делает: решение о повторе принимает пользователь.
|
||||
|
||||
Все сигналы приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *packLoader = new SeasonalPackDownloader(this);
|
||||
|
||||
connect(packLoader, &SeasonalPackDownloader::progressChanged, this, &Backend::downloadChanged);
|
||||
connect(packLoader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
|
||||
m_switcher->applyPack(m_buildId, m_buildName, path,
|
||||
m_previousEntries, m_note, m_gameDir);
|
||||
});
|
||||
connect(packLoader, &SeasonalPackDownloader::failed, this,
|
||||
[this](const QString &, const QString &message) { emit launchError(message); });
|
||||
|
||||
packLoader->download(entry.archiveUrl, targetPath,
|
||||
entry.archiveSha256, entry.archiveSize, entry.name);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,211 +0,0 @@
|
||||
# VersionInstaller
|
||||
|
||||
## Обзор класса
|
||||
|
||||
Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
|
||||
клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
|
||||
файлов.
|
||||
|
||||
`VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
|
||||
загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
|
||||
прогресс. Одна версия ставится за раз, остальные ждут в очереди.
|
||||
|
||||
Класс также разворачивает цепочку наследования: если у версии есть `inheritsFrom`, родительская
|
||||
версия ставится перед ней.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
|
||||
качать. В конструктор принимает [VersionManifestService](VersionManifestService.md): оттуда
|
||||
берётся адрес описания версии.
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). На него же опирается
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md) — профиль модлоадера ставится поверх установленной
|
||||
версии игры.
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QQueue`, `QTimer`) и
|
||||
`Qt6::Network` (`QNetworkAccessManager`, `QNetworkReply`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
||||
виртуальный деструктор — класс владеет незавершёнными загрузками и обязан их закрыть.
|
||||
|
||||
## Публичные структуры
|
||||
|
||||
### DownloadTask
|
||||
|
||||
Один файл, который нужно положить в `.minecraft`.
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|------|-----|--------------|----------|
|
||||
| `kind` | `DownloadTask::Kind` | `ClientJar` | Что это за файл |
|
||||
| `url` | `QUrl` | — | Откуда качать |
|
||||
| `path` | `QString` | — | Абсолютный путь назначения |
|
||||
| `sha1` | `QString` | — | Контрольная сумма; пустая означает «не проверять» |
|
||||
| `size` | `qint64` | `0` | Ожидаемый размер; `0` — неизвестен |
|
||||
| `label` | `QString` | — | Что показать в панели прогресса |
|
||||
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
|
||||
|
||||
#### Перечисление DownloadTask::Kind
|
||||
|
||||
| Значение | Описание |
|
||||
|----------|----------|
|
||||
| `ClientJar` | Клиентский jar версии |
|
||||
| `Library` | Библиотека из `libraries/` |
|
||||
| `AssetIndex` | Индекс ресурсов |
|
||||
| `AssetObject` | Один файл ресурсов |
|
||||
| `LoggingConfig` | Конфигурация журналирования log4j |
|
||||
|
||||
### ActiveDownload
|
||||
|
||||
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
|
||||
контрольная сумма и число принятых байт. Файлы пишутся потоком — держать десятки мегабайт в
|
||||
памяти незачем, а нескольких параллельных загрузок хватило бы на сотни.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit VersionInstaller(VersionManifestService \*manifest, QObject \*parent = nullptr)
|
||||
|
||||
Создаёт установщик поверх сервиса манифеста. Сервис не переходит во владение установщика и обязан
|
||||
пережить его. Конструктор помечен `explicit`.
|
||||
|
||||
#### bool isRunning() const
|
||||
|
||||
Идёт ли установка прямо сейчас.
|
||||
|
||||
#### QString versionId() const
|
||||
|
||||
Идентификатор версии, которая ставится в данный момент.
|
||||
|
||||
#### QString stage() const
|
||||
|
||||
Текущий этап установки словами — это же значение показывается в заголовке панели прогресса.
|
||||
|
||||
#### QString currentFile() const
|
||||
|
||||
Подпись файла, который качается сейчас.
|
||||
|
||||
#### qint64 bytesDone() const
|
||||
|
||||
Сколько байт уже получено, с учётом идущих загрузок.
|
||||
|
||||
#### qint64 bytesTotal() const
|
||||
|
||||
Ожидаемый общий объём. Растёт по ходу установки: полный размер ресурсов становится известен
|
||||
только после разбора их индекса.
|
||||
|
||||
#### double fraction() const
|
||||
|
||||
Доля выполнения от `0` до `1` либо `-1`, пока итоговый объём неизвестен. Значение `-1` панель
|
||||
прогресса показывает многоточием вместо процентов.
|
||||
|
||||
#### void install(const QString &gameDir, const QString &versionId)
|
||||
|
||||
Ставит версию в указанную папку игры. Если установка уже идёт, версия становится в очередь.
|
||||
|
||||
Порядок работы: разрешение записи манифеста, загрузка описания версии, подготовка списка задач,
|
||||
скачивание, разворачивание индекса ресурсов и — для версий до 1.6 — раскладка ресурсов в плоскую
|
||||
папку, которую те версии умеют читать.
|
||||
|
||||
#### bool isQueued(const QString &versionId) const
|
||||
|
||||
Стоит ли версия в очереди на установку. Позволяет не ставить одну и ту же версию дважды.
|
||||
|
||||
#### void cancel()
|
||||
|
||||
Отменяет текущую установку и очищает очередь. Незавершённые файлы не остаются на диске: они
|
||||
пишутся через `QSaveFile` и фиксируются только целиком.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### started(const QString &versionId)
|
||||
|
||||
Установка версии началась. Обработчик показывает панель прогресса и выставляет признак занятости.
|
||||
|
||||
#### progressChanged()
|
||||
|
||||
Изменились числа прогресса. Испускается не чаще десяти раз в секунду: при тысячах мелких файлов
|
||||
сигнал на каждый принятый блок обошёлся бы дороже самой загрузки.
|
||||
|
||||
Обработчик перечитывает `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()` и `fraction()`.
|
||||
|
||||
#### finished(const QString &versionId)
|
||||
|
||||
Версия установлена успешно. Обработчик убирает панель прогресса, обновляет список установленных
|
||||
версий и пересчитывает комплектность сборок.
|
||||
|
||||
#### failed(const QString &versionId, const QString &message)
|
||||
|
||||
Установка не удалась; в параметре — текст ошибки для пользователя. Часть файлов при этом может
|
||||
остаться на диске: повторная установка докачает недостающее.
|
||||
|
||||
#### canceled(const QString &versionId)
|
||||
|
||||
Установка отменена пользователем. В отличие от `failed`, ошибку показывать не нужно.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
|
||||
Указатель на `VersionManifestService`, переданный в конструктор, **не принадлежит** установщику:
|
||||
сервис создаётся раньше и живёт дольше. `QNetworkAccessManager` и оба таймера создаются в
|
||||
конструкторе с установщиком в роли родителя.
|
||||
|
||||
Активные загрузки хранятся как `std::shared_ptr<ActiveDownload>`, а файл внутри каждой — как
|
||||
`std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе с уничтожением объекта, и
|
||||
испорченный файл не попадает на место назначения.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI, как и остальной сетевой код лаунчера. Параллелизм здесь — не потоки, а
|
||||
несколько одновременных сетевых запросов в одном цикле событий. Раскладка ресурсов для старых
|
||||
версий выполняется порциями по таймеру, чтобы не занимать поток надолго.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `install()` при установке сборки и переправляет все пять сигналов в
|
||||
свойства, которые читает QML: панель прогресса главного окна показывает `stage()`, `fraction()` и
|
||||
байты, а `finished` обновляет список установленных версий.
|
||||
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md) держит ссылку на установщик: профиль модлоадера
|
||||
требует, чтобы базовая версия игры была уже на месте.
|
||||
|
||||
Разбор описания версии идёт через `VersionLoader::load()` из
|
||||
[minecraftversion.h](minecraftversion.md).
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс качает файлы с серверов Mojang через `QNetworkAccessManager`.
|
||||
Направление одностороннее, протокол — HTTPS; описание версии и индекс ресурсов приходят как JSON,
|
||||
остальное — двоичными файлами.
|
||||
|
||||
Каждый файл пишется потоком через `QSaveFile` с одновременным подсчётом sha1; несовпадение
|
||||
контрольной суммы считается неудачей загрузки. Неудачная задача повторяется — счётчик попыток
|
||||
хранится в самой задаче, — и только исчерпав попытки, приводит к сигналу `failed`.
|
||||
|
||||
Существующие файлы сверяются только по размеру: перехеширование сотен мегабайт при каждом
|
||||
добавлении версии дороже, чем риск битого файла.
|
||||
|
||||
Все сигналы приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *installer = new VersionInstaller(manifestService, this);
|
||||
|
||||
connect(installer, &VersionInstaller::progressChanged, this, [this, installer] {
|
||||
emit downloadProgress(installer->fraction(), installer->currentFile());
|
||||
});
|
||||
connect(installer, &VersionInstaller::finished, this, &Backend::onVersionInstalled);
|
||||
connect(installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) {
|
||||
emit launchError(tr("Не удалось установить %1: %2").arg(id, message));
|
||||
});
|
||||
|
||||
if (!installer->isQueued(versionId))
|
||||
installer->install(gameDir, versionId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,167 +0,0 @@
|
||||
# VersionManifestService
|
||||
|
||||
## Обзор класса
|
||||
|
||||
Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
|
||||
свежайших снапшотов. `VersionManifestService` отвечает за него целиком: скачивает манифест,
|
||||
кэширует в папке лаунчера и отдаёт из кэша, пока тот не устарел.
|
||||
|
||||
Класс нужен двум потребителям: окну выбора версии, которому нужен весь список, и установщику,
|
||||
которому по идентификатору версии нужна ссылка на её описание.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
|
||||
[VersionInstaller](VersionInstaller.md) — установщик берёт из манифеста адрес описания версии.
|
||||
|
||||
Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
|
||||
[launcherpaths.h](launcherpaths.md).
|
||||
|
||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
|
||||
(`QNetworkAccessManager`).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||
методов базового класса не переопределяет.
|
||||
|
||||
## Публичные структуры
|
||||
|
||||
### RemoteVersionEntry
|
||||
|
||||
Одна строка манифеста Mojang.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `QString` | Идентификатор версии: `1.21.8`, `25w33a`, `b1.7.3` |
|
||||
| `type` | `QString` | Категория: `release`, `snapshot`, `old_beta` или `old_alpha` |
|
||||
| `url` | `QUrl` | Адрес `<id>.json` с описанием версии |
|
||||
| `sha1` | `QString` | Контрольная сумма самого описания |
|
||||
| `releaseTime` | `QDateTime` | Дата выпуска; по ней список сортируется новыми вперёд |
|
||||
|
||||
Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
|
||||
попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
|
||||
|
||||
## Псевдонимы типов
|
||||
|
||||
`VersionManifestService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
||||
Сочетание `ok == true` с непустым `warning` означает особый случай: данные отдали, но из
|
||||
устаревшего кэша — сеть недоступна, а показать что-то нужно.
|
||||
|
||||
## Публичные методы
|
||||
|
||||
#### explicit VersionManifestService(QObject \*parent = nullptr)
|
||||
|
||||
Создаёт сервис и его `QNetworkAccessManager`. Манифест при этом не читается — чтение кэша
|
||||
откладывается до первого обращения. Конструктор помечен `explicit`.
|
||||
|
||||
#### QList<RemoteVersionEntry> versions() const
|
||||
|
||||
Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет.
|
||||
|
||||
#### bool hasData() const
|
||||
|
||||
Есть ли хоть какие-то данные — из сети или из кэша.
|
||||
|
||||
#### bool isRefreshing() const
|
||||
|
||||
Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо
|
||||
пустого списка.
|
||||
|
||||
#### QDateTime fetchedAt() const
|
||||
|
||||
Когда данные были получены. По этой отметке решается, устарел ли кэш.
|
||||
|
||||
#### std::optional<RemoteVersionEntry> find(const QString &id) const
|
||||
|
||||
Запись по идентификатору версии; `std::nullopt`, если такой версии в манифесте нет. Из неё
|
||||
установщик берёт ссылку на описание версии. Поиск идёт по внутреннему указателю, а не перебором.
|
||||
|
||||
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
||||
|
||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без обращения к сети; иначе
|
||||
запускается один сетевой запрос на всех, кто успел попросить, — колбэки накапливаются и вызываются
|
||||
все разом по его завершении.
|
||||
|
||||
Параметр `forceRefresh` обходит проверку свежести кэша: так работает кнопка принудительного
|
||||
обновления.
|
||||
|
||||
Колбэк вызывается ровно один раз и всегда в потоке GUI, в том числе когда данные уже есть.
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### versionsChanged()
|
||||
|
||||
Список версий изменился — пришли новые данные из сети или прочитан кэш.
|
||||
|
||||
Обработчик перечитывает `versions()` и обновляет интерфейс. В лаунчере на этот сигнал завязано
|
||||
свойство каталога версий, которое читает окно выбора.
|
||||
|
||||
#### refreshingChanged()
|
||||
|
||||
Изменился признак сетевого обновления. Обработчик показывает или убирает индикатор загрузки.
|
||||
|
||||
## Владение и время жизни
|
||||
|
||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||
|
||||
Отложенные колбэки хранятся в списке до завершения текущего запроса. Уничтожение сервиса с
|
||||
незавершённым запросом обрывает его вместе с менеджером сети, и накопленные колбэки не
|
||||
вызываются.
|
||||
|
||||
Ссылку на сервис держит установщик версий; уничтожать сервис раньше установщика нельзя.
|
||||
|
||||
## Потокобезопасность
|
||||
|
||||
Только поток GUI — так же, как [AuthService](AuthService.md). Чтение и запись кэша выполняются в
|
||||
вызывающем потоке, поэтому первое обращение к манифесту делает короткую файловую операцию
|
||||
синхронно.
|
||||
|
||||
## Взаимодействие с другими классами
|
||||
|
||||
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора версии и по кнопке
|
||||
обновления, а сигналы `versionsChanged` и `refreshingChanged` переправляет в свойства, которые
|
||||
читает QML. Список из `versions()` он сводит с установленными версиями и отдаёт в интерфейс уже
|
||||
готовыми строками.
|
||||
|
||||
[VersionInstaller](VersionInstaller.md) обращается к `find()`, чтобы получить адрес описания
|
||||
версии перед началом загрузки.
|
||||
|
||||
## Внешнее взаимодействие
|
||||
|
||||
**Сеть, исходящие запросы.** Класс скачивает манифест версий Mojang через
|
||||
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
|
||||
|
||||
Стратегия при недоступной сети встроена в контракт колбэка: если есть устаревший кэш, он
|
||||
отдаётся с `ok == true` и заполненным `warning`, и интерфейс показывает список вместо ошибки.
|
||||
Полное отсутствие данных даёт `ok == false`.
|
||||
|
||||
**Файловый кэш.** Манифест сохраняется в файл, путь к которому даёт
|
||||
`LauncherPaths::versionManifestFile()`, вместе с отметкой времени получения.
|
||||
|
||||
Сигналы и колбэки приходят в поток GUI.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
auto *manifest = new VersionManifestService(this);
|
||||
connect(manifest, &VersionManifestService::versionsChanged, this, &Backend::rebuildCatalog);
|
||||
|
||||
manifest->ensureLoaded([this, manifest](bool ok, const QString &warning) {
|
||||
if (!ok) {
|
||||
emit catalogError(warning);
|
||||
return;
|
||||
}
|
||||
if (!warning.isEmpty())
|
||||
showStatus(warning); // список из устаревшего кэша
|
||||
|
||||
const auto entry = manifest->find(QStringLiteral("1.21.8"));
|
||||
if (entry)
|
||||
startDownload(entry->url);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,77 +0,0 @@
|
||||
# javalocator.h — JavaLocator
|
||||
|
||||
## Обзор
|
||||
|
||||
Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
|
||||
семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
|
||||
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
|
||||
|
||||
`JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
|
||||
подходящую под требование версии игры. Пространство имён используется при подготовке запуска и при
|
||||
показе списка найденных Java в настройках.
|
||||
|
||||
## Пространства имён
|
||||
|
||||
`JavaLocator` группирует четыре функции поиска и выбора. Состояния нет, но результат определения
|
||||
версии кэшируется внутри реализации.
|
||||
|
||||
## Функции
|
||||
|
||||
#### QStringList findAll(const QString &gameDir)
|
||||
|
||||
Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
|
||||
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
|
||||
стандартные каталоги установки JDK и JRE.
|
||||
|
||||
Параметр `gameDir` — папка игры; из неё берётся первый источник.
|
||||
|
||||
Результат показывается в диалоге настроек списком «что нашлось в системе».
|
||||
|
||||
#### int majorVersion(const QString &javaPath)
|
||||
|
||||
Мажорная версия java по её пути: 8, 17, 21 и так далее. Возвращает `0`, если запустить
|
||||
исполняемый файл не удалось — путь неверен, файл не исполняемый или это не java.
|
||||
|
||||
Результат кэшируется: определение версии требует запуска процесса, а один и тот же путь
|
||||
проверяется многократно.
|
||||
|
||||
#### QString windowlessVariant(const QString &javaPath)
|
||||
|
||||
Заменяет `java.exe` на `javaw.exe`, чтобы игра не открывала окно консоли. На Unix возвращает вход
|
||||
без изменений — там разницы нет.
|
||||
|
||||
#### QString select(const QString &gameDir, int requiredMajor, const QString &preferred, QString \*error)
|
||||
|
||||
Выбирает java не ниже `requiredMajor`. Если задан `preferred` — путь, указанный пользователем в
|
||||
настройках, — он проверяется первым и, если подходит, побеждает. Иначе перебираются найденные
|
||||
`findAll()` варианты.
|
||||
|
||||
При неудаче возвращает пустую строку и заполняет `error` — текст объясняет, что именно не нашлось:
|
||||
подходящей версии нет вовсе или указанный пользователем путь не подошёл.
|
||||
|
||||
Функция запускает процессы для определения версий, поэтому может занять заметное время; вызывать
|
||||
её в обработчике нажатия не стоит.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Подключает `QString` и `QStringList`. От классов проекта не зависит.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
QString error;
|
||||
const QString java = JavaLocator::select(gameDir,
|
||||
version.javaMajor,
|
||||
settings.javaPath,
|
||||
&error);
|
||||
if (java.isEmpty()) {
|
||||
emit launchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
process.start(JavaLocator::windowlessVariant(java), arguments);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,153 +0,0 @@
|
||||
# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement
|
||||
|
||||
## Обзор
|
||||
|
||||
`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
|
||||
запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
|
||||
живут в папке лаунчера, по одной подпапке на сборку.
|
||||
|
||||
Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
|
||||
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
|
||||
`<root>/java` и таблицу требований версий игры к Java.
|
||||
|
||||
Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
|
||||
[JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
|
||||
[LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
|
||||
|
||||
## Типы
|
||||
|
||||
| Имя | Вид | Описание |
|
||||
|-----|-----|----------|
|
||||
| `JavaRuntimeKind` | `enum class` | Откуда взялась сборка и что именно в ней лежит |
|
||||
| `JavaRuntimeEntry` | `struct` | Строка каталога — то, что можно скачать |
|
||||
| `InstalledJavaRuntime` | `struct` | Сборка, уже распакованная в папке лаунчера |
|
||||
|
||||
### JavaRuntimeKind
|
||||
|
||||
| Значение | Ключ | Описание |
|
||||
|----------|------|----------|
|
||||
| `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
|
||||
| `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
|
||||
| `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
|
||||
|
||||
Эти же ключи служат именами категорий в окне выбора Java.
|
||||
|
||||
### JavaRuntimeEntry
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `QString` | Идентификатор сборки, например `temurin-jdk-21.0.12.1_1` или `mojang-java-runtime-delta` |
|
||||
| `kind` | `JavaRuntimeKind` | Вид сборки; по умолчанию `Jdk` |
|
||||
| `major` | `int` | Мажорная версия: 8, 17, 21… |
|
||||
| `version` | `QString` | Полная версия, например `21.0.12.1+1` или `21.0.7` |
|
||||
| `component` | `QString` | Имя компонента вида `java-runtime-delta`; только у сборок Mojang |
|
||||
| `released` | `QDateTime` | Дата выпуска; по ней каталог сортируется новыми вперёд |
|
||||
| `url` | `QUrl` | Архив Temurin либо `manifest.json` компонента Mojang |
|
||||
| `checksum` | `QString` | sha256 архива Temurin или sha1 манифеста Mojang |
|
||||
| `size` | `qint64` | Размер загрузки в байтах; `0` — неизвестен |
|
||||
| `archive` | `QString` | Формат: `zip`, `tar.gz` или `mojang` — последний означает не архив, а манифест с пофайловой загрузкой |
|
||||
| `architecture` | `QString` | `x64` или `aarch64`; на macOS бывает и не родная архитектура |
|
||||
| `lts` | `bool` | Версия с длительной поддержкой; помечается в списке |
|
||||
|
||||
Метод `isValid()` возвращает `true`, когда заполнен `id`, `major` больше нуля и `url` корректен.
|
||||
|
||||
### InstalledJavaRuntime
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `QString` | Идентификатор сборки, он же имя подпапки |
|
||||
| `kind` | `QString` | Вид сборки строкой: `java`, `jdk` или `jre` |
|
||||
| `major` | `int` | Мажорная версия |
|
||||
| `version` | `QString` | Полная версия |
|
||||
| `javaPath` | `QString` | Абсолютный путь к исполняемому файлу `java` или `java.exe` |
|
||||
| `size` | `qint64` | Сколько сборка заняла на диске по итогам установки |
|
||||
|
||||
## Функции
|
||||
|
||||
### Перевод видов сборок
|
||||
|
||||
#### QString javaKindKey(JavaRuntimeKind kind)
|
||||
|
||||
Строковый ключ вида сборки: `java`, `jdk` или `jre`.
|
||||
|
||||
#### std::optional<JavaRuntimeKind> javaKindFromKey(const QString &key)
|
||||
|
||||
Обратный перевод; `std::nullopt` для неизвестного ключа.
|
||||
|
||||
#### QString javaKindTitle(JavaRuntimeKind kind)
|
||||
|
||||
Человекочитаемое название вида сборки для интерфейса.
|
||||
|
||||
### JavaRuntimeStore — папка `<root>/java`
|
||||
|
||||
Устройство папки: одна подпапка на сборку плюс её описание внутри. Отдельного индекса нет
|
||||
намеренно — удалённую вручную папку не пришлось бы вычищать ещё и из общего файла.
|
||||
|
||||
#### QString dirFor(const QString &id)
|
||||
|
||||
Папка конкретной сборки внутри `<root>/java`.
|
||||
|
||||
#### QList<InstalledJavaRuntime> installed()
|
||||
|
||||
Всё, что лежит в `<root>/java` и на что нашлась java. Новые версии идут первыми.
|
||||
|
||||
#### std::optional<InstalledJavaRuntime> find(const QString &id)
|
||||
|
||||
Описание одной установленной сборки; `std::nullopt`, если такой нет.
|
||||
|
||||
#### QString locateBinary(const QString &rootDir)
|
||||
|
||||
Ищет `bin/java` в распакованном дереве. Раскладка отличается между поставщиками: у Temurin на macOS
|
||||
это `Contents/Home/bin`, у Mojang — `jre.bundle/Contents/Home/bin`.
|
||||
|
||||
#### bool writeMeta(const InstalledJavaRuntime &runtime, QString \*error)
|
||||
|
||||
Записывает описание сборки в её папку. Вызывается по завершении установки. При неудаче возвращает
|
||||
`false` и заполняет `error`.
|
||||
|
||||
#### bool remove(const QString &id, QString \*error)
|
||||
|
||||
Удаляет папку сборки целиком. При неудаче возвращает `false` и заполняет `error`.
|
||||
|
||||
#### QString sanitizeId(const QString &id)
|
||||
|
||||
Превращает идентификатор каталога в безопасное имя папки: всё, кроме букв, цифр, точки, дефиса и
|
||||
подчёркивания, заменяется. Идентификатор приходит из сети, поэтому подставлять его в путь как есть
|
||||
нельзя.
|
||||
|
||||
### JavaRequirement — какая Java нужна какой версии игры
|
||||
|
||||
Точный ответ лежит в `client.json` версии (поле `javaVersion.majorVersion`) и берётся оттуда при
|
||||
запуске. Эта таблица нужна раньше — когда версия ещё не скачана, а подсказку в каталоге показать
|
||||
надо.
|
||||
|
||||
#### int minimumFor(const QString &minecraftVersionId)
|
||||
|
||||
Минимальная мажорная версия Java для версии игры; `0`, если разобрать идентификатор версии не
|
||||
удалось.
|
||||
|
||||
#### QString coverage(int javaMajor)
|
||||
|
||||
Подпись к строке каталога вида «Minecraft 1.20.5 и новее» — какие версии игры покрывает эта версия
|
||||
Java.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Подключает `QDateTime`, `QList`, `QString`, `QUrl` и `<optional>` — только Qt Core. От классов
|
||||
проекта не зависит.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
const auto runtime = JavaRuntimeStore::find(settings.javaRuntime);
|
||||
if (runtime && QFileInfo::exists(runtime->javaPath)) {
|
||||
javaPath = runtime->javaPath;
|
||||
} else {
|
||||
// сборка удалена вручную — возвращаемся к поиску в системе
|
||||
javaPath = JavaLocator::select(gameDir, version.javaMajor, settings.javaPath, &error);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,145 +0,0 @@
|
||||
# launcherpaths.h — LauncherPaths
|
||||
|
||||
## Обзор
|
||||
|
||||
`Minecraft_launcher` хранит собственные данные отдельно от папки игры: настройки, профили, описания
|
||||
сборок, архивы содержимого `.minecraft`, скачанные сборки Java и кэши каталогов. Пространство имён
|
||||
`LauncherPaths` — единственное место, где эти пути вычисляются.
|
||||
|
||||
Собственная папка `galeonLauncher` лежит рядом со стандартной `.minecraft`, а не в системном
|
||||
каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся
|
||||
вместе с ней.
|
||||
|
||||
К этому заголовку обращается почти каждый сервис проекта — везде, где нужно прочитать или записать
|
||||
файл в папке лаунчера.
|
||||
|
||||
## Пространства имён
|
||||
|
||||
`LauncherPaths` группирует функции, возвращающие абсолютные пути, и одну функцию создания корневой
|
||||
папки. Состояния у пространства имён нет: все функции вычисляют путь заново при каждом вызове.
|
||||
|
||||
## Функции
|
||||
|
||||
### Корневые каталоги
|
||||
|
||||
#### QString containerDir()
|
||||
|
||||
Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `galeonLauncher`. От неё
|
||||
отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
|
||||
|
||||
#### QString rootDir()
|
||||
|
||||
Корень данных лаунчера — `<containerDir>/galeonLauncher`.
|
||||
|
||||
#### QString defaultMinecraftDir()
|
||||
|
||||
Стандартная папка игры. Используется, когда пользователь не задал свою в настройках.
|
||||
|
||||
#### bool ensureRootExists(QString *error = nullptr)
|
||||
|
||||
Создаёт папку лаунчера, если её ещё нет. Вызывается при каждом запуске и перед каждой записью.
|
||||
|
||||
Возвращает `false` и заполняет `error`, если папку не удалось создать или в неё не пишется. Все
|
||||
остальные функции пространства имён только считают строки и в этом смысле не могут завершиться
|
||||
неудачей — проверять доступность каталога нужно этой функцией.
|
||||
|
||||
### Файлы состояния
|
||||
|
||||
#### QString settingsFile()
|
||||
|
||||
Файл настроек запуска: папка игры, путь к Java, память, аргументы JVM, размер окна.
|
||||
|
||||
#### QString profilesFile()
|
||||
|
||||
Файл профилей игрока.
|
||||
|
||||
#### QString customBuildsFile()
|
||||
|
||||
`<root>/customBuilds.json` — пользовательские сборки.
|
||||
|
||||
#### QString legacyCustomBuildsFile()
|
||||
|
||||
`<root>/versions.json` — как сборки назывались до переименования. Читается один раз при миграции и
|
||||
больше ни для чего не нужен.
|
||||
|
||||
### Сборки и их архивы
|
||||
|
||||
#### QString buildStorageDir()
|
||||
|
||||
`<root>/builds` — архивы содержимого `.minecraft`, по одному на сборку.
|
||||
|
||||
#### QString buildDir(int buildId)
|
||||
|
||||
`<root>/builds/<id>` — папка одной сборки: её архив и, у сезонных, скачанный пак с описанием
|
||||
установленной ревизии.
|
||||
|
||||
#### QString seasonalStateFile(int buildId)
|
||||
|
||||
`<root>/builds/<id>/season.json` — какая ревизия сезонной сборки установлена и какие файлы она
|
||||
принесла. Список файлов нужен, чтобы при обновлении убрать те, что из сборки ушли.
|
||||
|
||||
### Java
|
||||
|
||||
#### QString javaDir()
|
||||
|
||||
`<root>/java` — сборки Java, скачанные лаунчером. Каждая в своей подпапке, имя подпапки —
|
||||
идентификатор сборки из каталога.
|
||||
|
||||
#### QString javaCatalogFile()
|
||||
|
||||
Слепок каталога доступных сборок Java с отметкой времени.
|
||||
|
||||
#### QString javaDownloadDir()
|
||||
|
||||
Каталог, куда качаются архивы Temurin до распаковки.
|
||||
|
||||
### Кэши и загрузки
|
||||
|
||||
#### QString cacheDir()
|
||||
|
||||
`<root>/cache` — данные, которые можно удалить без потерь.
|
||||
|
||||
#### QString versionManifestFile()
|
||||
|
||||
Слепок манифеста версий Mojang с отметкой времени.
|
||||
|
||||
#### QString seasonalCatalogFile()
|
||||
|
||||
Слепок каталога сезонных сборок с отметкой времени.
|
||||
|
||||
#### QString loaderCacheFile(const QString &loaderKey)
|
||||
|
||||
Слепок списка версий одного модлоадера. Параметр `loaderKey` принимает значения `forge`, `fabric`,
|
||||
`neoforge` и `quilt` — те же ключи, что возвращает `loaderKey()` из [modloader.h](modloader.md).
|
||||
|
||||
#### QString loaderDownloadDir()
|
||||
|
||||
Каталог, куда качаются `installer.jar` модлоадеров.
|
||||
|
||||
#### QString runtimeDir()
|
||||
|
||||
Каталог, куда качается `authlib-injector` — библиотека, подменяющая сервер авторизации при входе
|
||||
через Ely.by.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Единственный подключаемый заголовок — `QString`. Пространство имён не зависит ни от одного класса
|
||||
проекта, поэтому его можно подключать откуда угодно без риска циклических зависимостей.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
QString error;
|
||||
if (!LauncherPaths::ensureRootExists(&error)) {
|
||||
qWarning() << "папка лаунчера недоступна:" << error;
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(LauncherPaths::customBuildsFile());
|
||||
if (file.open(QIODevice::WriteOnly))
|
||||
file.write(document.toJson());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,97 +0,0 @@
|
||||
# main.cpp — точка входа
|
||||
|
||||
## Обзор
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt 6 Quick. Интерфейс написан на QML,
|
||||
вся работа — авторизация, скачивание версий, установка модлоадеров и Java, запуск игры — лежит в
|
||||
C++-классе [LauncherBackend](LauncherBackend.md) и его сервисах.
|
||||
|
||||
`main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
|
||||
Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
|
||||
Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
|
||||
ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
|
||||
|
||||
## Настройка приложения Qt
|
||||
|
||||
Создаётся `QGuiApplication` — не `QApplication`: интерфейс целиком на Qt Quick, виджеты не
|
||||
используются, и модуль Qt Widgets в проект не подключён.
|
||||
|
||||
До создания приложения, при сборке с Qt WebEngine, вызывается `QtWebEngineQuick::initialize()`.
|
||||
Порядок здесь принципиален: инициализация выставляет общий контекст OpenGL, а после создания
|
||||
объекта приложения это уже не действует. Вызов обёрнут в условную компиляцию по макросу
|
||||
`LAUNCHER_HAS_WEBENGINE`.
|
||||
|
||||
## Каталог переводов
|
||||
|
||||
Сразу после создания приложения вызывается `Localization::instance().load()` — он читает
|
||||
`i18n/translations.json` из ресурсов и определяет язык интерфейса. Порядок важен: язык нужно
|
||||
знать до того, как QML вычислит первую привязку, а [LauncherBackend](LauncherBackend.md),
|
||||
который ведёт `settings.json`, появляется только вместе с движком — поэтому ключ `language`
|
||||
[Localization](Localization.md) читает из файла сам.
|
||||
|
||||
Неудача — не повод продолжать: каталог вкомпилирован в бинарник, значит его отсутствие или
|
||||
поломка означают ошибку сборки. `main()` пишет причину и возвращает `-1`.
|
||||
|
||||
## Стиль Qt Quick Controls
|
||||
|
||||
Стиль Qt Quick Controls принудительно выставляется в `Basic`
|
||||
вызовом `QQuickStyle::setStyle()`. Причина в оформлении: всё окно лаунчера стилизовано вручную, а
|
||||
нативный стиль Windows игнорирует пользовательские `contentItem` и `background` и сыплет
|
||||
предупреждениями.
|
||||
|
||||
## Обработка командной строки
|
||||
|
||||
Аргументы командной строки не разбираются: `argc` и `argv` передаются в конструктор
|
||||
`QGuiApplication` и дальше не используются. Ни `QCommandLineParser`, ни собственного разбора в
|
||||
файле нет.
|
||||
|
||||
## Создание объектов верхнего уровня
|
||||
|
||||
В `main()` создаётся ровно два объекта.
|
||||
|
||||
| Объект | Тип | Роль |
|
||||
|--------|-----|------|
|
||||
| `app` | `QGuiApplication` | объект приложения и цикл событий |
|
||||
| `engine` | `QQmlApplicationEngine` | загружает и исполняет QML-модуль лаунчера |
|
||||
|
||||
Экземпляр `LauncherBackend` здесь не создаётся: тип зарегистрирован через `QML_ELEMENT`, и главное
|
||||
окно объявляет его само декларативно. Поэтому в `main.cpp` нет ни одного `#include` классов
|
||||
предметной области.
|
||||
|
||||
## Связывание и подключения
|
||||
|
||||
Единственное подключение — обработка неудачи создания корневого объекта: сигнал
|
||||
`QQmlApplicationEngine::objectCreationFailed` замыкается на лямбду, которая завершает приложение
|
||||
с кодом `-1`. Соединение создаётся с типом `Qt::QueuedConnection` и с объектом `app` в роли
|
||||
контекста, чтобы выход из приложения происходил уже внутри цикла событий, а не в разгар загрузки
|
||||
QML.
|
||||
|
||||
Контекстные свойства не задаются, начальные свойства корневому объекту не передаются: связь между
|
||||
QML и C++ идёт исключительно через зарегистрированный тип.
|
||||
|
||||
## Цикл событий
|
||||
|
||||
QML загружается вызовом `engine.loadFromModule("Minecraft_launcher", "Main")` — по URI модуля и
|
||||
имени типа, а не по пути к файлу. Модуль объявлен в `CMakeLists.txt` через `qt_add_qml_module`, а
|
||||
`Main` — это [Main.qml](../qml/Main.md), корневой элемент которого `Window` с `visible: true`,
|
||||
поэтому окно показывается само.
|
||||
|
||||
Цикл событий запускается `app.exec()`, его результат возвращается из `main()` как код завершения
|
||||
процесса.
|
||||
|
||||
## Зависимости
|
||||
|
||||
| Заголовок | Что даёт |
|
||||
|-----------|----------|
|
||||
| `QGuiApplication` | объект приложения и цикл событий для приложения без виджетов |
|
||||
| `QQmlApplicationEngine` | загрузка QML-модуля и создание корневого объекта |
|
||||
| `QQuickStyle` | выбор стиля Qt Quick Controls до загрузки QML |
|
||||
| `QtWebEngineQuick` | инициализация WebEngine; подключается только при сборке с Qt WebEngine |
|
||||
|
||||
Модули сборки: `Qt6::Quick`, `Qt6::QuickControls2`, `Qt6::Core`, `Qt6::CorePrivate`, `Qt6::Gui`,
|
||||
`Qt6::Network` и опционально `Qt6::WebEngineQuick`. Макрос `LAUNCHER_HAS_WEBENGINE` определяется в
|
||||
`CMakeLists.txt` только тогда, когда `find_package` нашёл `Qt6WebEngineQuick`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,136 +0,0 @@
|
||||
# minecraftversion.h — MinecraftVersion и VersionLoader
|
||||
|
||||
## Обзор
|
||||
|
||||
Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
|
||||
аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
|
||||
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
|
||||
очередь, может наследоваться дальше.
|
||||
|
||||
Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
|
||||
пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
|
||||
установленными версиями на диске.
|
||||
|
||||
Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
|
||||
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
|
||||
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
|
||||
|
||||
## Типы
|
||||
|
||||
| Имя | Вид | Описание |
|
||||
|-----|-----|----------|
|
||||
| `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
|
||||
| `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
|
||||
|
||||
### MinecraftLibrary
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `name` | `QString` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
|
||||
| `path` | `QString` | Путь относительно `.minecraft/libraries` |
|
||||
| `url` | `QString` | Откуда качать, если файла нет; может быть пустым — тогда файл должен уже лежать на месте |
|
||||
| `sha1` | `QString` | Контрольная сумма для проверки скачанного |
|
||||
| `size` | `qint64` | Размер в байтах; `0` — неизвестен |
|
||||
| `native` | `bool` | Библиотека распаковывается в `natives`, а не кладётся в classpath |
|
||||
| `extractExclude` | `QStringList` | Префиксы путей внутри архива, которые не распаковываются |
|
||||
|
||||
### MinecraftVersion
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `QString` | Имя папки в `versions`, оно же значение аргумента `--version` |
|
||||
| `mainClass` | `QString` | Главный класс, который запускает java |
|
||||
| `type` | `QString` | `release`, `snapshot` или `modified`; идёт в `--versionType` |
|
||||
| `assetIndexId` | `QString` | Идентификатор индекса ресурсов для `--assetIndex` |
|
||||
| `assetsKind` | `QString` | Поле `assets` версии: `legacy`, `pre-1.6` либо тот же идентификатор |
|
||||
| `clientJarPath` | `QString` | Абсолютный путь к `<id>.jar`; может лежать у родительской версии |
|
||||
| `javaMajor` | `int` | Требуемая мажорная версия Java; по умолчанию `8` |
|
||||
| `jvmArgs` | `QStringList` | Аргументы JVM ещё с неподставленными подстановками вида `${...}` |
|
||||
| `gameArgs` | `QStringList` | Аргументы игры, тоже с неподставленными подстановками |
|
||||
| `libraries` | `QList<MinecraftLibrary>` | Библиотеки версии с уже применёнными правилами |
|
||||
| `loggingArgument` | `QString` | Аргумент вида `-Dlog4j.configurationFile=${path}` |
|
||||
| `loggingConfigPath` | `QString` | Абсолютный путь к xml-конфигурации журнала; пуст, если конфигурации нет |
|
||||
| `supportsQuickPlay` | `bool` | Версия 1.20 и новее понимает `--quickPlayMultiplayer` — так лаунчер подключается к серверу сразу при запуске |
|
||||
| `hasCustomResolutionArgs` | `bool` | Версия принимает аргументы размера окна |
|
||||
|
||||
Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
|
||||
успешность чтения версии.
|
||||
|
||||
## Функции
|
||||
|
||||
Пространство имён `VersionLoader`.
|
||||
|
||||
#### QStringList installedVersions(const QString &gameDir)
|
||||
|
||||
Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
|
||||
только папки недостаточно — она остаётся после неудачной установки.
|
||||
|
||||
#### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
|
||||
|
||||
Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
|
||||
запустятся, поэтому список показывается пользователю перед удалением версии.
|
||||
|
||||
#### qint64 installedSize(const QString &gameDir, const QString &versionId)
|
||||
|
||||
Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
|
||||
удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
|
||||
|
||||
#### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
|
||||
|
||||
Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
|
||||
`false` и заполняет `error`.
|
||||
|
||||
Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
|
||||
|
||||
#### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet<QString> &features, QString \*error)
|
||||
|
||||
Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
|
||||
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
|
||||
размер окна).
|
||||
|
||||
При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`.
|
||||
|
||||
#### bool rulesAllow(const QJsonArray &rules, const QSet<QString> &features = {})
|
||||
|
||||
Стандартный алгоритм Mojang для блоков `rules`: правила применяются по порядку, побеждает последнее
|
||||
совпавшее. Используется и для библиотек, и для аргументов запуска.
|
||||
|
||||
#### QString nativeClassifier()
|
||||
|
||||
Классификатор нативных библиотек для текущей машины: `natives-windows`, `natives-macos-arm64`,
|
||||
`natives-linux` и подобные. По нему из списка библиотек отбираются те, что нужно распаковать.
|
||||
|
||||
#### QString osName()
|
||||
|
||||
Имя операционной системы в терминах Mojang — оно подставляется в правила и аргументы.
|
||||
|
||||
#### QString osArch()
|
||||
|
||||
Архитектура в терминах Mojang.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Подключает `QJsonArray`, `QList`, `QSet`, `QString` и `QStringList` — только Qt Core. От классов
|
||||
проекта не зависит.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
QString error;
|
||||
const MinecraftVersion version = VersionLoader::load(gameDir, versionId, features, &error);
|
||||
if (!version.isValid()) {
|
||||
emit launchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList classpath;
|
||||
for (const MinecraftLibrary &library : version.libraries) {
|
||||
if (!library.native)
|
||||
classpath << gameDir + "/libraries/" + library.path;
|
||||
}
|
||||
classpath << version.clientJarPath;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,87 +0,0 @@
|
||||
# modloader.h — ModLoader
|
||||
|
||||
## Обзор
|
||||
|
||||
`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и
|
||||
Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление
|
||||
самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым
|
||||
ключом.
|
||||
|
||||
Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `<gameDir>/versions`, поэтому в
|
||||
сборке лаунчера хранится один ключ лоадера, а не набор.
|
||||
|
||||
Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
|
||||
|
||||
## Типы
|
||||
|
||||
| Имя | Вид | Описание |
|
||||
|-----|-----|----------|
|
||||
| `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
|
||||
| `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
|
||||
|
||||
### ModLoader
|
||||
|
||||
| Значение | Ключ | Название | Описание |
|
||||
|----------|------|----------|----------|
|
||||
| `Forge` | `forge` | Minecraft Forge | Старейший загрузчик; ставится собственным установщиком, который собирает часть файлов на месте |
|
||||
| `Fabric` | `fabric` | Fabric Loader | Лёгкий загрузчик; профиль версии формируется из метаданных без запуска установщика |
|
||||
| `NeoForge` | `neoforge` | NeoForge | Ответвление Forge; ставится так же собственным установщиком |
|
||||
| `Quilt` | `quilt` | Quilt Loader | Ответвление Fabric; ставится так же, как Fabric |
|
||||
|
||||
Перечисление объявлено как `enum class`, поэтому неявного приведения к целому нет.
|
||||
|
||||
### LoaderVersionEntry
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `loaderVersion` | `QString` | Версия самого лоадера — например `47.4.0`, `0.19.3` или `21.1.248` |
|
||||
| `gameVersion` | `QString` | Версия Minecraft, под которую эта сборка — например `1.20.1` |
|
||||
| `versionId` | `QString` | Предсказанный идентификатор профиля `versions/<id>`. У Forge пуст: он выясняется только после работы установщика |
|
||||
| `installerUrl` | `QUrl` | Адрес установщика; заполнен только у Forge и NeoForge |
|
||||
| `recommended` | `bool` | Сборка помечена авторами как рекомендуемая; по умолчанию `false` |
|
||||
| `stable` | `bool` | Сборка стабильна, а не тестовая; по умолчанию `true` |
|
||||
|
||||
## Функции
|
||||
|
||||
#### QString loaderKey(ModLoader loader)
|
||||
|
||||
Строковый ключ лоадера: `forge`, `fabric`, `neoforge` или `quilt`. Один и тот же ключ используется
|
||||
в трёх местах — в интерфейсе, в имени файла кэша на диске и в поле `loader` файла
|
||||
`customBuilds.json`, — поэтому менять его нельзя без миграции сохранённых сборок.
|
||||
|
||||
Объявлена `inline` в заголовке.
|
||||
|
||||
#### QString loaderTitle(ModLoader loader)
|
||||
|
||||
Человекочитаемое название лоадера для интерфейса: «Minecraft Forge», «Fabric Loader», «NeoForge»,
|
||||
«Quilt Loader». Объявлена `inline`.
|
||||
|
||||
#### std::optional<ModLoader> loaderFromKey(const QString &key)
|
||||
|
||||
Обратный перевод: ключ в перечисление. Возвращает `std::nullopt` для неизвестного ключа, в том
|
||||
числе для пустой строки — а пустая строка в сборке означает чистую ваниль без лоадера. Объявлена
|
||||
`inline`.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Подключает `QString`, `QUrl` и `<optional>`. От классов проекта не зависит и сам подключается
|
||||
всюду, где речь идёт о модлоадерах.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
const auto loader = loaderFromKey(build.loader);
|
||||
if (!loader) {
|
||||
// сборка без модлоадера — запускаем чистую ваниль
|
||||
return;
|
||||
}
|
||||
|
||||
qInfo() << "ставим" << loaderTitle(*loader)
|
||||
<< "версии" << entry.loaderVersion
|
||||
<< "под Minecraft" << entry.gameVersion;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,76 +0,0 @@
|
||||
# zlibreference.h — ZlibReference
|
||||
|
||||
## Обзор
|
||||
|
||||
`Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
|
||||
которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
|
||||
файла с эталоном из `install_profile.json`.
|
||||
|
||||
Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
|
||||
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
|
||||
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
|
||||
берут `libz.so.1` из системы.
|
||||
|
||||
`ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
|
||||
установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
|
||||
подмена живёт ровно один процесс.
|
||||
|
||||
Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
|
||||
процесса Java.
|
||||
|
||||
## Пространства имён
|
||||
|
||||
`ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
|
||||
подготовку окружения процесса. Состояния нет.
|
||||
|
||||
## Функции
|
||||
|
||||
#### bool systemIsZlibNg()
|
||||
|
||||
Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
|
||||
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
|
||||
|
||||
#### QString bundledPath()
|
||||
|
||||
Путь к собранному рядом эталонному `libz.so.1` или пустая строка, если его нет. Библиотека
|
||||
собирается целью `launcher_zlib_reference` в `CMakeLists.txt` из исходников каталога `zlib/`,
|
||||
которые лежат в репозитории, чтобы сборка не зависела от сети, а версия была зафиксирована — от
|
||||
неё зависит побайтовый результат сжатия. Собирается только на Linux: на Windows и macOS проблемы
|
||||
подмены системного zlib нет.
|
||||
|
||||
#### bool applyTo(QProcessEnvironment &env, QString *note = nullptr)
|
||||
|
||||
Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
|
||||
|
||||
Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
|
||||
система с обычным zlib или платформа, где вопрос не стоит. `false` означает, что подмена нужна, но
|
||||
эталонной библиотеки нет на месте.
|
||||
|
||||
Необязательный параметр `note` заполняется строкой, пригодной и для журнала, и для текста ошибки:
|
||||
она объясняет, была ли подмена применена и почему.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Подключает `QString`; `QProcessEnvironment` объявлен вперёд и используется только по ссылке.
|
||||
Реализация опирается на макрос `LAUNCHER_ZLIB_INSTALL_DIR`, который `CMakeLists.txt` определяет на
|
||||
Linux — это путь установки библиотеки в системе, помимо каталога рядом с исполняемым файлом.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```cpp
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
|
||||
QString note;
|
||||
if (!ZlibReference::applyTo(env, ¬e)) {
|
||||
emit failed(note);
|
||||
return;
|
||||
}
|
||||
|
||||
QProcess installer;
|
||||
installer.setProcessEnvironment(env);
|
||||
installer.start(javaPath, arguments);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,165 +0,0 @@
|
||||
# Minecraft Launcher — справочник по исходному коду
|
||||
|
||||
Десктопный лаунчер Minecraft на Qt 6. Интерфейс написан на QML, вся работа — авторизация,
|
||||
скачивание версий, установка модлоадеров и Java, запуск игры — лежит в C++.
|
||||
|
||||
Документация разделена на две части: [QML-компоненты](#qml-компоненты) и
|
||||
[C++-классы](#c-классы).
|
||||
|
||||
## Как устроено приложение
|
||||
|
||||
Слоёв четыре, и каждый общается только с соседними:
|
||||
|
||||
1. **QML.** [Main](qml/Main.md) — единственное настоящее окно и точка входа. Оно держит
|
||||
единственный экземпляр `LauncherBackend` и раздаёт его вложенным диалогам через свойство
|
||||
`backend`. Ни один QML-файл не обращается к сервисам напрямую.
|
||||
2. **Фасад.** [LauncherBackend](cpp/LauncherBackend.md) — главный класс, видимый из QML.
|
||||
Хранит профили, сборки и настройки, владеет двенадцатью сервисами и сводит их состояние к
|
||||
свойствам, которые читает интерфейс. Каталоги он отдаёт уже сведёнными с локальным состоянием,
|
||||
поэтому окна показывают статус, не считая ничего сами.
|
||||
3. **Сервисы.** Каталоги (версий, модлоадеров, Java, сезонных сборок), установщики, службы
|
||||
авторизации, переключатель сборок и запуск игры. Каждый занят одним делом и ничего не знает об
|
||||
интерфейсе.
|
||||
Особняком стоит [Localization](cpp/Localization.md) — второй и последний тип, видимый из QML.
|
||||
Это синглтон `Loc`, через который проходят все тексты интерфейса: `Loc.t.домен.вид.имя` в QML и
|
||||
`Loc::text("домен.вид.имя")` в C++. Он ни от чего не зависит и доступен всем слоям сразу.
|
||||
|
||||
4. **Внешний мир.** Сеть (Mojang, Ely.by, Microsoft, Adoptium, файловый сервер сборок), файловая
|
||||
система (`.minecraft` и папка лаунчера) и дочерние процессы (java, установщики модлоадеров,
|
||||
`tar`).
|
||||
|
||||
Почти весь код работает в потоке GUI: параллелизм даёт асинхронная сеть, а не потоки. Единственное
|
||||
исключение — операции над содержимым `.minecraft` (гигабайты модов и миров), вынесенные в
|
||||
отдельный поток к [BuildArchiveWorker](cpp/BuildArchiveWorker.md).
|
||||
|
||||
## QML-компоненты
|
||||
|
||||
| Компонент | Описание |
|
||||
|-----------|----------|
|
||||
| [Main](qml/Main.md) | Главное окно и точка входа: экран запуска, профили, плашка сообщений, панели прогресса и внутренние диалоги |
|
||||
| [BuildsDialog](qml/BuildsDialog.md) | Пользовательские сборки: список слева, карточка редактирования справа |
|
||||
| [VersionPickerDialog](qml/VersionPickerDialog.md) | Выбор версии Minecraft: категории, поиск, удаление скачанных версий |
|
||||
| [JavaPickerDialog](qml/JavaPickerDialog.md) | Выбор сборки Java; подтверждение при необходимости сразу начинает загрузку |
|
||||
| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | Каталог готовых сезонных сборок таблицей и установка одной кнопкой |
|
||||
| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | Окно входа в аккаунт Microsoft; собирается только с Qt WebEngine |
|
||||
| [LoaderRow](qml/LoaderRow.md) | Одна строка модлоадера в карточке сборки: чекбокс и список версий |
|
||||
| [ProgressPanel](qml/ProgressPanel.md) | Плашка хода долгой операции в левом нижнем углу |
|
||||
| [DarkCombo](qml/DarkCombo.md) | Выпадающий список в тёмном стиле окна |
|
||||
| [LabelledField](qml/LabelledField.md) | Подпись и поле ввода одной колонкой |
|
||||
|
||||
## C++-классы
|
||||
|
||||
### Точка входа
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| [main.cpp](cpp/main.md) | Инициализация WebEngine, объект приложения, каталог переводов, стиль `Basic`, загрузка QML-модуля |
|
||||
|
||||
### Фасад
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [Localization](cpp/Localization.md) | Синглтон `Loc`: все тексты интерфейса в одном файле `i18n/translations.json`, переключение языка на лету |
|
||||
| [LauncherBackend](cpp/LauncherBackend.md) | Единственный тип, видимый из QML: 26 свойств, 42 вызываемых метода, состояние лаунчера и порядок работы всех сервисов |
|
||||
|
||||
### Авторизация и запуск
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [AuthService](cpp/AuthService.md) | Вход через Ely.by и офлайн-режим; структура `AuthResult` |
|
||||
| [MsaAuthService](cpp/MsaAuthService.md) | Вход через аккаунт Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
|
||||
| [GameLauncher](cpp/GameLauncher.md) | Сборка командной строки, распаковка нативных библиотек и запуск JVM |
|
||||
|
||||
### Версии игры
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [VersionManifestService](cpp/VersionManifestService.md) | Каталог версий Mojang с кэшем в папке лаунчера |
|
||||
| [VersionInstaller](cpp/VersionInstaller.md) | Фоновая установка версии: jar, библиотеки, индекс ресурсов и сами ресурсы |
|
||||
|
||||
### Модлоадеры
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | Списки версий Forge, Fabric, NeoForge и Quilt; совместимость заложена в структуру данных |
|
||||
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | Два пути установки под одним фасадом: готовое описание версии либо запуск `installer.jar` |
|
||||
|
||||
### Java
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | Каталог сборок Mojang и Eclipse Temurin под текущую платформу |
|
||||
| [JavaInstaller](cpp/JavaInstaller.md) | Установка сборки Java: архив Temurin либо дерево файлов Mojang |
|
||||
|
||||
### Сборки и их содержимое
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [BuildSwitcher](cpp/BuildSwitcher.md) | Порядок шагов смены активной сборки и восстановление после прерванной операции |
|
||||
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | Упаковка, очистка, распаковка и раскатка пака в отдельном потоке |
|
||||
|
||||
### Сезонные сборки
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | Каталог готовых сборок с файлового сервера |
|
||||
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | Загрузка архива сборки потоком с проверкой sha256 |
|
||||
|
||||
### Общие типы и утилиты
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| [minecraftversion.h](cpp/minecraftversion.md) | Структуры версии и библиотеки, чтение и разворачивание цепочки `inheritsFrom` |
|
||||
| [javaruntime.h](cpp/javaruntime.md) | Виды сборок Java, папка `<root>/java` и таблица требований версий игры |
|
||||
| [modloader.h](cpp/modloader.md) | Перечисление модлоадеров, запись версии и перевод ключей |
|
||||
| [launcherpaths.h](cpp/launcherpaths.md) | Все пути к данным лаунчера в одном месте |
|
||||
| [javalocator.h](cpp/javalocator.md) | Поиск установленной в системе Java и выбор подходящей версии |
|
||||
| [zlibreference.h](cpp/zlibreference.md) | Подмена zlib-ng эталонным zlib для установщиков Forge и NeoForge |
|
||||
|
||||
## Сборка
|
||||
|
||||
Требуется Qt 6.8 или новее. Обязательные модули: `Quick`, `QuickControls2`, `Core`, `CorePrivate`,
|
||||
`Gui`, `Network`. `CorePrivate` нужен ради `QZipReader` и `QZipWriter` — ими распаковываются
|
||||
нативные библиотеки LWJGL и архивы сборок; привязка к версии Qt из-за приватного модуля —
|
||||
осознанный выбор, и предупреждение о ней в `CMakeLists.txt` отключено.
|
||||
|
||||
`Qt6::WebEngineQuick` необязателен, и это принципиально: модуль ставится отдельной галочкой в
|
||||
установщике Qt и тянет за собой WebChannel с Positioning, которых в типовой установке нет. Если
|
||||
сделать его обязательным, у любого, кто их не поставил, проект перестанет конфигурироваться
|
||||
целиком — вместе с офлайном и Ely.by. Когда модуль найден, определяется макрос
|
||||
`LAUNCHER_HAS_WEBENGINE`, а [MicrosoftLoginDialog.qml](qml/MicrosoftLoginDialog.md) добавляется в
|
||||
QML-модуль; без модуля лаунчер собирается и работает как обычно, только вход через Microsoft
|
||||
сообщает, что эта сборка его не умеет. В QML различие видно через свойство
|
||||
`backend.microsoftAvailable`.
|
||||
|
||||
QML-модуль объявлен как `qt_add_qml_module` с URI `Minecraft_launcher`; точка входа —
|
||||
`engine.loadFromModule("Minecraft_launcher", "Main")`. Туда же, в список `RESOURCES`, попадает
|
||||
каталог переводов `i18n/translations.json`.
|
||||
|
||||
### Тексты интерфейса
|
||||
|
||||
Все подписи, сообщения и ошибки лежат в одном файле `i18n/translations.json` и достаются через
|
||||
синглтон `Loc` — подробности, правила именования ключей и порядок добавления языка описаны в
|
||||
[Localization](cpp/Localization.md). Штатные `.ts`/`.qm` не используются, `lupdate` и `lrelease`
|
||||
в сборке не участвуют. Целостность каталога проверяет `python3 tools/check_translations.py`.
|
||||
|
||||
### Каталог `zlib/`
|
||||
|
||||
В репозитории лежат исходники обычного zlib версии 1.3.1 — это чужой upstream-код, и
|
||||
документацией он не покрыт. Он нужен вот зачем.
|
||||
|
||||
Установщики Forge и NeoForge сверяют sha1 каждого jar, который сами же и собирают, с эталоном из
|
||||
`install_profile.json`. Эталон посчитан на обычном zlib, а дистрибутивы вроде CachyOS и Fedora
|
||||
подставляют вместо него zlib-ng: сжатие корректное, но побайтово другое, поэтому установка падает
|
||||
на любой версии игры сообщением «Processor failed, invalid outputs». Сменить Java не выйдет —
|
||||
сборки OpenJDK под Linux берут `libz.so.1` из системы.
|
||||
|
||||
Поэтому на Linux собирается цель `launcher_zlib_reference` из этих исходников, и готовая
|
||||
библиотека подсовывается через `LD_PRELOAD` только процессу установщика — см.
|
||||
[zlibreference.h](cpp/zlibreference.md). Сам лаунчер с ней не линкуется, системный zlib-ng
|
||||
остаётся на месте. Исходники хранятся в репозитории, чтобы сборка не зависела от сети, а версия
|
||||
была зафиксирована: от неё зависит побайтовый результат сжатия.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,179 +0,0 @@
|
||||
# BuildsDialog
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Сборка в нём — это именованный
|
||||
набор «версия игры + модлоадер + адрес сервера» вместе с собственным содержимым `.minecraft`:
|
||||
модами, конфигами и мирами. Активная сборка одна, её содержимое лежит в `.minecraft`, остальные
|
||||
хранятся в архивах и разворачиваются при переключении.
|
||||
|
||||
`BuildsDialog` — окно управления этими сборками: слева список со сменой активной, справа карточка
|
||||
выбранной — имя, сервер, версия Minecraft и модлоадеры. Отсюда же сборка ставится (скачивание
|
||||
версии игры и модлоадера) и удаляется.
|
||||
|
||||
Карточка сохраняет правки по ходу редактирования, отдельной кнопки «Сохранить» нет: иначе
|
||||
появляется неочевидное несохранённое состояние, пока пользователь переключается между сборками в
|
||||
левом списке.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
||||
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
||||
|
||||
Использует три компонента из того же QML-модуля:
|
||||
|
||||
- [LabelledField](LabelledField.md) — поля названия сборки и адреса сервера;
|
||||
- [LoaderRow](LoaderRow.md) — по одной строке на каждый из четырёх модлоадеров;
|
||||
- [VersionPickerDialog](VersionPickerDialog.md) — вложенное окно выбора версии Minecraft.
|
||||
|
||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||
`Minecraft_launcher`) через свойство `backend`. Читает свойства `customBuildNames`,
|
||||
`activeBuildIndex`, `switching` и `busy`; вызывает `customBuildAt()`, `addCustomBuild()`,
|
||||
`updateCustomBuild()`, `customBuildRemovalInfo()`, `removeCustomBuild()`, `checkInstallation()`,
|
||||
`installCustomBuild()` и `refreshVersionCatalog()`; слушает сигнал `installedVersionsChanged`.
|
||||
|
||||
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`.
|
||||
Инстанцируется в [Main](Main.md).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Dialog`: модальный, 880×600 px, нулевой внутренний отступ, тёмный фон со
|
||||
скруглением и акцентной рамкой. Пока идёт смена активной сборки (`backend.switching`), окно
|
||||
закрывается только кнопкой: архивация и распаковка `.minecraft` не должны прерываться случайным
|
||||
щелчком мимо.
|
||||
|
||||
Содержимое — `RowLayout` из двух частей:
|
||||
|
||||
- **Слева** (260 px) список сборок и строка «+ Новая сборка» под ним. Строка списка показывает имя,
|
||||
пометку «активна» у активной сборки и корзину, появляющуюся при наведении. Вся колонка
|
||||
выключается на время смены активной сборки.
|
||||
- **Справа** карточка выбранной сборки внутри `Flickable` — она может не поместиться по высоте.
|
||||
Карточка выключается и приглушается, когда сборка не выбрана или идёт переключение.
|
||||
|
||||
Карточка сверху вниз: название, адрес сервера, поле версии Minecraft, панель модлоадеров и строка
|
||||
состояния комплектности. Поле версии — не выпадающий список, а прямоугольник, который только
|
||||
показывает выбор и открывает отдельное окно: версий около тысячи, и разбираться в них удобнее в
|
||||
окне с категориями.
|
||||
|
||||
Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой,
|
||||
привязанный к тому же родителю, что и само окно.
|
||||
|
||||
Подвал несёт три кнопки: «Установить», «Сделать активной» и «Закрыть».
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — хранилище сборок и исполнитель установки, удаления и переключения. |
|
||||
| `editIndex` | `int` | `-1` | Нет | Индекс сборки, открытой в карточке справа. Значение `-1` означает, что карточка пуста и выключена. С активной сборкой не связан: активную меняет отдельная кнопка, чтобы случайный клик по списку не запускал архивацию `.minecraft`. |
|
||||
| `loading` | `bool` | `false` | Нет | Признак того, что карточка сейчас заполняется данными сборки. Пока он выставлен, обработчики полей не должны писать обратно — иначе открытие сборки немедленно перезаписывало бы её. |
|
||||
|
||||
### Внутренняя панель модлоадеров
|
||||
|
||||
Панель `loaderPanel` — это `Column` с четырьмя строками [LoaderRow](LoaderRow.md) и собственным
|
||||
маленьким API. Лоадеры несовместимы между собой: игра запускается ровно с одним профилем
|
||||
`versions/<id>`, поэтому отметка одного снимает остальные, а «ничего не отмечено» — это чистая
|
||||
ваниль.
|
||||
|
||||
| Свойство панели | Тип | Описание |
|
||||
|-----------------|-----|----------|
|
||||
| `rows` | `var` | Массив из четырёх строк лоадеров в порядке показа: Minecraft Forge (`forge`), Fabric Loader (`fabric`), NeoForge (`neoforge`), Quilt Loader (`quilt`). |
|
||||
|
||||
Функции панели: `applyBuild(loader, loaderVersion)` раздаёт данные сборки всем строкам;
|
||||
`selectedRow()` возвращает отмеченную строку или `null`; `keepOnly(row)` очищает все строки,
|
||||
кроме переданной; `commitSelection()` сохраняет выбор в сборку, записывая ключ лоадера, его версию
|
||||
и пустой `resolvedVersionId` — профиль появится только после установки, а до неё сборка
|
||||
запускается на чистой ванили.
|
||||
|
||||
## Сигналы
|
||||
|
||||
Собственных сигналов компонент не объявляет: все изменения уходят прямо в бэкенд, и о них
|
||||
остальной интерфейс узнаёт по его сигналам.
|
||||
|
||||
## Методы
|
||||
|
||||
#### selectBuild(int index) : void
|
||||
|
||||
Открывает сборку с указанным индексом в карточке. Читает данные через `customBuildAt()`,
|
||||
выставляет `loading` на время заполнения полей, раздаёт лоадеры панели через `applyBuild()` и в
|
||||
конце обновляет строку состояния.
|
||||
|
||||
#### askRemove(int index) : void
|
||||
|
||||
Спрашивает подтверждение перед удалением: оно необратимо и уносит с собой архив сборки. Запрашивает
|
||||
`customBuildRemovalInfo()` и наполняет окно подтверждения именем сборки и тремя признаками — есть
|
||||
ли у неё архив, активна ли она сейчас и последняя ли она. Индекс запоминается в самом окне
|
||||
подтверждения, потому что к моменту ответа строка списка под курсором может быть уже другой.
|
||||
|
||||
Окно подтверждения объясняет последствия по этим признакам: вместе со сборкой удалится её архив, и
|
||||
моды, конфиги и миры восстановить будет нельзя; активная сборка владеет содержимым `.minecraft`,
|
||||
оно будет очищено, а на его место развернётся следующая сборка; для последней сборки содержимое
|
||||
`.minecraft` остаётся на месте.
|
||||
|
||||
#### performRemove(int index) : void
|
||||
|
||||
Удаляет сборку через бэкенд и восстанавливает состояние карточки: если сборок не осталось,
|
||||
сбрасывает `editIndex` в `-1`, иначе открывает соседнюю — ту же позицию или последнюю оставшуюся.
|
||||
|
||||
#### commit(var fields) : void
|
||||
|
||||
Сохраняет часть полей сборки: передаёт `QVariantMap` в `updateCustomBuild()` и обновляет строку
|
||||
состояния. Ничего не делает во время заполнения карточки (`loading`) и при пустом `editIndex` —
|
||||
это и есть защита от записи при открытии сборки.
|
||||
|
||||
Вызывается по завершении правки каждого поля: имени, адреса сервера, версии Minecraft и выбора
|
||||
модлоадера.
|
||||
|
||||
#### refreshStatus() : void
|
||||
|
||||
Пересчитывает строку комплектности под карточкой. Спрашивает у бэкенда `checkInstallation()` и
|
||||
показывает либо сообщение о готовности к запуску, либо число недостающих файлов вместе с первым из
|
||||
них. При пустом `editIndex` очищает строку.
|
||||
|
||||
#### newBuildName() : string
|
||||
|
||||
Придумывает имя для новой сборки: перебирает «Сборка 1», «Сборка 2» и так далее, пока не найдёт
|
||||
свободное среди существующих имён.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
**Со стороны родителя.** [Main](Main.md) задаёт `backend` и открывает окно. Начальное состояние
|
||||
окно выбирает само в обработчике `onAboutToShow`: просит обновить каталог версий и открывает
|
||||
активную сборку, а при пустом списке оставляет карточку выключенной.
|
||||
|
||||
**Внутрь — к вложенным компонентам.** Поля [LabelledField](LabelledField.md) сообщают о правке
|
||||
сигналом `editingFinished`, и карточка сразу вызывает `commit()` с обрезанным по краям значением.
|
||||
Поле версии открывает [VersionPickerDialog](VersionPickerDialog.md) вызовом `openFor()` и получает
|
||||
результат сигналом `versionChosen`; запись выбранной версии сама вызывает `commit()` через
|
||||
обработчик изменения. Строки [LoaderRow](LoaderRow.md) получают `gameVersion` привязкой к
|
||||
выбранной версии игры, поэтому смена версии Minecraft автоматически перезапрашивает списки версий
|
||||
лоадеров.
|
||||
|
||||
**Наружу — к бэкенду.** Кнопка «Установить» вызывает `installCustomBuild()`, кнопка «Сделать
|
||||
активной» пишет в свойство `activeBuildIndex`. Обе выключены, пока лаунчер занят (`busy`), а
|
||||
кнопка активации — ещё и когда выбранная сборка уже активна. Ход установки и переключения
|
||||
показывает плашка [ProgressPanel](ProgressPanel.md) главного окна, а не это окно.
|
||||
|
||||
**Обратная связь от бэкенда.** Окно подписано на `installedVersionsChanged` через `Connections`:
|
||||
установка версии или модлоадера меняет комплектность сборки, и строка состояния должна это
|
||||
заметить, не дожидаясь переоткрытия окна.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
BuildsDialog {
|
||||
id: buildsDialog
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
backend: launcherBackend
|
||||
}
|
||||
|
||||
Button {
|
||||
text: qsTr("Сборки")
|
||||
onClicked: buildsDialog.open()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,89 +0,0 @@
|
||||
# DarkCombo
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Всё окно оформлено вручную в
|
||||
тёмной теме, а стиль Qt Quick Controls принудительно выставлен в `Basic` (`main.cpp`), потому что
|
||||
нативные стили игнорируют пользовательские `contentItem` и `background`. Из-за этого каждый
|
||||
стандартный элемент управления, попадающий в интерфейс, приходится переопределять самому.
|
||||
|
||||
`DarkCombo` — как раз такое переопределение: выпадающий список в общем тёмном стиле окна.
|
||||
Компонент не добавляет логики, он целиком про внешний вид — фон, рамку, стрелку, делегат строки и
|
||||
всплывающую панель. К нему обращаются всюду, где нужен выпадающий список внутри диалогов: выбор
|
||||
версии модлоадера, выбор Java, поля в настройках.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Импортирует `QtQuick` и `QtQuick.Controls 2.15`; собственных C++-типов не использует.
|
||||
|
||||
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (см. `CMakeLists.txt`), поэтому доступен по
|
||||
имени `DarkCombo` в любом файле того же модуля без явного импорта.
|
||||
|
||||
Используется в [LoaderRow](LoaderRow.md) — список версий модлоадера — и дважды в [Main](Main.md):
|
||||
в выпадающих списках профиля и сборки на главном окне.
|
||||
|
||||
Компонент ссылается на ресурсы `images/Profile_Box/Asset_23.svg` и
|
||||
`images/Profile_Box/Asset_24.svg` — они перечислены в `RESOURCES` того же QML-модуля, отдельного
|
||||
подключения не требуют.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `ComboBox` из Qt Quick Controls. Всё поведение (модель, `currentIndex`,
|
||||
`activated`, `displayText`, клавиатурная навигация) наследуется без изменений; `DarkCombo`
|
||||
переопределяет только четыре визуальных слота базового типа:
|
||||
|
||||
| Слот | Что даёт `DarkCombo` |
|
||||
|------|----------------------|
|
||||
| `indicator` | стрелка из SVG-ресурса вместо двойного шеврона Basic-стиля; при открытом списке (`down`) картинка меняется |
|
||||
| `contentItem` | текст текущего значения белым, с обрезкой справа многоточием и отступом под стрелку |
|
||||
| `background` | тёмный прямоугольник со скруглением 6 px; рамка подсвечивается акцентным цветом при фокусе |
|
||||
| `delegate` | строка списка: белый текст, подсветка фона у элемента под курсором |
|
||||
| `popup` | всплывающая панель шириной с сам комбобокс, высотой не более 220 px, с вертикальным индикатором прокрутки |
|
||||
|
||||
## Свойства
|
||||
|
||||
Собственных свойств компонент не объявляет — доступен весь набор свойств `ComboBox`
|
||||
(`model`, `currentIndex`, `currentText`, `displayText`, `editable` и прочие).
|
||||
|
||||
Внутри `delegate` объявлены два обязательных свойства делегата, они относятся к строке списка,
|
||||
а не к самому комбобоксу:
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `modelData` | `var` | — | **Да** | Значение строки модели; выводится текстом в строке списка. Модель ожидается плоской — списком строк, а не объектов. |
|
||||
| `index` | `int` | — | **Да** | Позиция строки; сравнивается с `highlightedIndex` комбобокса, чтобы подсветить строку под курсором. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
Собственных сигналов нет. Наследуются сигналы `ComboBox`, из которых на практике используется
|
||||
`activated(int index)` — выбор строки пользователем (в отличие от `currentIndexChanged`, он не
|
||||
срабатывает при программной смене значения).
|
||||
|
||||
## Методы
|
||||
|
||||
Собственных функций нет.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
Компонент самодостаточен и ничего не знает ни о бэкенде, ни о родителе: модель приходит извне
|
||||
через `model`, результат выбора родитель получает через унаследованный `activated`. Так,
|
||||
[LoaderRow](LoaderRow.md) передаёт в `model` список подписей версий модлоадера и в обработчике
|
||||
`activated` переводит индекс обратно в номер версии.
|
||||
|
||||
Поскольку модель ожидается списком строк, вызывающий код обычно сам приводит массив объектов к
|
||||
массиву подписей перед присваиванием.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
DarkCombo {
|
||||
width: 200
|
||||
height: 32
|
||||
model: ["1.21.1", "1.20.6", "1.20.4"]
|
||||
onActivated: (index) => console.log("выбрано:", model[index])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,149 +0,0 @@
|
||||
# JavaPickerDialog
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Разным версиям игры нужны разные
|
||||
версии Java, и лаунчер умеет скачивать их сам: официальные сборки Mojang и сборки Temurin в двух
|
||||
вариантах — JDK и JRE. Держать в голове, какая Java нужна какой версии игры, пользователь не
|
||||
обязан.
|
||||
|
||||
`JavaPickerDialog` — окно выбора сборки Java: слева типы сборок, справа сами версии с поиском.
|
||||
Устроено так же, как [VersionPickerDialog](VersionPickerDialog.md), с одним принципиальным
|
||||
отличием: выбранное здесь ещё и качается. У версий игры загрузку начинает сама сборка, а сборка
|
||||
Java, которой нет на диске, запуску ничем не поможет — поэтому кнопка подтверждения при
|
||||
необходимости сразу ставит выбранную сборку в очередь на скачивание.
|
||||
|
||||
Окно также показывает, какая версия Java нужна выбранной версии игры, помечает сборки, которые
|
||||
для неё слишком старые, и позволяет удалять скачанное прямо из списка.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
||||
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
||||
|
||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства `javaCatalog` и
|
||||
`javaCatalogLoading`, вызывает `refreshJavaCatalog()`, `installJavaRuntime()` и
|
||||
`removeJavaRuntime()`.
|
||||
|
||||
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`.
|
||||
Инстанцируется в диалоге настроек внутри [Main](Main.md) — сборка Java выбирается один раз для
|
||||
всего лаунчера, а не отдельно для каждой сборки Minecraft.
|
||||
|
||||
Каталог приходит из C++ уже отсортированным — новые сверху, скачанные первыми, — поэтому окно
|
||||
только отбирает записи и порядок не трогает. Запись каталога содержит поля `id`, `label`, `kind`
|
||||
(тип сборки), `major` (мажорная версия Java числом), `installed`, `downloadable`, `lts`, `sizeMb`,
|
||||
`detail`, `coverage` и `search`.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
|
||||
щелчку мимо, оформление тёмное со скруглением и акцентной рамкой.
|
||||
|
||||
Содержимое — `RowLayout` из колонки типов шириной 170 px и области версий, разделённых линией.
|
||||
Каждый пункт колонки типов показывает название и пояснение мелким шрифтом, а под списком типов —
|
||||
подсказка о требовании выбранной версии игры, видимая только когда это требование известно.
|
||||
|
||||
Область версий повторяет устройство окна выбора версии Minecraft: поле поиска сверху, флажок
|
||||
«Только скачанные» снизу, `ListView` между ними. Строка списка выше обычной (44 px), потому что
|
||||
содержит две строки текста: подпись сборки с меткой LTS и строку подробностей, где через точку
|
||||
собраны описание, покрытие версий игры, размер в мегабайтах и — при необходимости —
|
||||
предупреждение о том, что сборки не хватит.
|
||||
|
||||
Пустое состояние объясняется текстом по центру и различает загрузку каталога, отсутствие
|
||||
соединения и пустой результат поиска.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога Java, исполнитель установки и удаления. |
|
||||
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной сборки. Окно открывается с текущей сборкой, и по «Отмене» выбор возвращается к ней. |
|
||||
| `category` | `string` | `"java"` | Нет | Ключ активного типа сборок. Допустимые значения: `java` (сборки Mojang), `jdk` (Temurin с инструментами), `jre` (Temurin, только запуск). |
|
||||
| `filterText` | `string` | `""` | Нет | Текст поиска; сравнивается в нижнем регистре с полем `search` записи внутри активного типа. |
|
||||
| `installedOnly` | `bool` | `false` | Нет | Показывать только скачанные сборки. |
|
||||
| `requiredMajor` | `int` | `0` | Нет | Мажорная версия Java, ниже которой выбранной версии игры не запуститься. Значение `0` означает, что версия игры не выбрана и предупреждать не о чем: подсказка в колонке типов скрывается, пометка «слишком старая» не ставится. |
|
||||
| `categories` | `var` (только чтение) | список из трёх записей | Нет | Описание колонки типов: массив объектов с полями `key`, `title` и `hint`. Задаёт порядок пунктов и набор допустимых значений `category`. |
|
||||
| `visibleEntries` | `var` (только чтение) | вычисляется | Нет | Отобранные строки каталога: записи активного типа, прошедшие флажок «только скачанные» и текст поиска. Модель списка. |
|
||||
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки из каталога или `null`, если ничего не выбрано. Ищется по всему каталогу, а не по видимым строкам, поэтому выбор не теряется при смене фильтра. От неё зависят подпись в подвале и надпись на кнопке подтверждения. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### runtimeChosen(string runtimeId)
|
||||
|
||||
Сборка Java подтверждена — кнопкой, двойным щелчком по строке или клавишей Enter в поле поиска. В
|
||||
параметре приходит идентификатор сборки.
|
||||
|
||||
Обработчик записывает выбранную сборку в настройки лаунчера — сборка Java общая для всех сборок
|
||||
Minecraft, а требование конкретной версии игры влияет только на пометки в списке. Сигнал испускается и для ещё не скачанной сборки — загрузка при
|
||||
этом начинается сама, и обработчику ждать её завершения не нужно.
|
||||
|
||||
## Методы
|
||||
|
||||
#### openFor(string runtimeId, int required) : void
|
||||
|
||||
Открывает окно на переданной сборке. Запоминает её в `selectedId`, выставляет `requiredMajor` из
|
||||
второго аргумента (отсутствующее или нулевое значение означает «требование неизвестно»), очищает
|
||||
поиск, переключается на тип именно этой сборки, просит бэкенд обновить каталог и прокручивает
|
||||
список к выбранной строке.
|
||||
|
||||
#### categoryOf(string runtimeId) : string
|
||||
|
||||
Возвращает тип сборки по каталогу; для неизвестного идентификатора — `java`.
|
||||
|
||||
#### indexOfSelected() : int
|
||||
|
||||
Позиция выбранной сборки в `visibleEntries` или `-1`, если под текущим фильтром её не видно.
|
||||
|
||||
#### revealSelected() : void
|
||||
|
||||
Выставляет текущий индекс списка на выбранную сборку и прокручивает список так, чтобы строка
|
||||
оказалась по центру.
|
||||
|
||||
#### acceptSelection() : void
|
||||
|
||||
Подтверждает выбор. Ничего не делает, если `selectedEntry` пуст. Иначе испускает
|
||||
`runtimeChosen()`, а затем — если сборка не установлена, но доступна для скачивания, — вызывает
|
||||
`installJavaRuntime()` у бэкенда и закрывает окно. Именно поэтому кнопка подтверждения называется
|
||||
«Скачать» для отсутствующей сборки и «Выбрать» для уже скачанной.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
**Со стороны родителя.** Вызывающий код задаёт `backend`, открывает окно вызовом `openFor()` с
|
||||
текущей сборкой и требуемой мажорной версией Java (её отдаёт `requiredJavaMajor()` бэкенда) и
|
||||
подписывается на `runtimeChosen()`.
|
||||
|
||||
**Со стороны бэкенда.** `javaCatalog` и `javaCatalogLoading` — привязки, пересчитывающие модель и
|
||||
текст пустого состояния при каждом обновлении каталога. `refreshJavaCatalog()` вызывается при
|
||||
открытии окна, `installJavaRuntime()` — при подтверждении отсутствующей сборки,
|
||||
`removeJavaRuntime()` — по щелчку на корзине в строке. Ход самой загрузки окно не показывает: за
|
||||
это отвечает плашка [ProgressPanel](ProgressPanel.md) в главном окне.
|
||||
|
||||
**Удаление.** Корзина в строке доступна только у скачанных сборок; сборки весят по двести
|
||||
мегабайт, и удалять их нужно прямо здесь, иначе папка лаунчера растёт молча. Если удалена была
|
||||
выбранная сборка, выбор снимается.
|
||||
|
||||
**Слишком старые сборки.** Сборка ниже требования игры остаётся доступной для выбора, но
|
||||
помечается в строке подробностей: она может пригодиться для другой сборки Minecraft.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
JavaPickerDialog {
|
||||
id: javaPicker
|
||||
x: (window.width - width) / 2
|
||||
y: (window.height - height) / 2
|
||||
backend: launcherBackend
|
||||
onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: javaField
|
||||
onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId,
|
||||
launcherBackend.requiredJavaMajor(launcherBackend.activeBuildIndex))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,76 +0,0 @@
|
||||
# LabelledField
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick с полностью самостоятельно
|
||||
оформленным тёмным интерфейсом. В диалоге настроек и в редакторе сборок много однотипных полей
|
||||
ввода: подпись сверху, поле под ней. `LabelledField` собирает эту пару в один компонент, чтобы
|
||||
отступы, цвета и подсветка фокуса не переписывались в каждом месте заново.
|
||||
|
||||
Компонент нужен там, где пользователь вводит короткое значение: имя профиля, объём памяти, путь,
|
||||
аргументы запуска.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы не используются.
|
||||
|
||||
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`) и доступен по имени внутри
|
||||
модуля без импорта. Применяется в диалоге настроек и в карточке сборки — см. [Main](Main.md) и
|
||||
[BuildsDialog](BuildsDialog.md).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Column` с расстоянием 3 px между элементами. Колонка содержит два потомка:
|
||||
`Text` с подписью (серый, 11 px) и `TextField` фиксированной высоты 32 px с тёмным фоном,
|
||||
скруглением 6 px и рамкой, которая при фокусе поля меняет цвет на акцентный.
|
||||
|
||||
Ширина поля привязана к ширине самой колонки, поэтому размер задаётся снаружи одним свойством
|
||||
`width` корневого элемента. Высоту `Column` вычисляет сам.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `text` | `string` (алиас на `text` внутреннего поля) | `""` | Нет | Содержимое поля ввода. Работает в обе стороны: чтение возвращает введённое значение, запись подставляет новое. |
|
||||
| `validator` | `var` (алиас на `validator` внутреннего поля) | `null` | Нет | Валидатор ввода — например `IntValidator` для числовых полей. Ограничивает то, что пользователь может набрать. |
|
||||
| `label` | `string` | `""` | Нет | Текст подписи над полем. |
|
||||
| `placeholder` | `string` | `""` | Нет | Подсказка, показываемая в пустом поле приглушённым цветом. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### editingFinished()
|
||||
|
||||
Проброшен из внутреннего `TextField`: срабатывает, когда правка закончена — поле потеряло фокус
|
||||
или пользователь нажал Enter. Промежуточные нажатия клавиш сигнала не вызывают.
|
||||
|
||||
Обработчик обычно сохраняет введённое значение: читает `text` и передаёт его в бэкенд или в
|
||||
модель родительского диалога. Именно из-за этой семантики поля настроек сохраняются по завершении
|
||||
правки, а не на каждый символ.
|
||||
|
||||
## Методы
|
||||
|
||||
Собственных функций нет.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
Компонент не знает ни о бэкенде, ни о содержащем его диалоге. Родитель задаёт `label`,
|
||||
`placeholder`, начальный `text` и при необходимости `validator`, а затем подписывается на
|
||||
`editingFinished`, чтобы записать значение. Двусторонней привязки к бэкенду внутри компонента нет
|
||||
— решение о том, когда и куда сохранять, целиком за родителем.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
LabelledField {
|
||||
width: parent.width
|
||||
label: "Оперативная память, МБ"
|
||||
placeholder: "2048"
|
||||
text: String(settings.memoryMb)
|
||||
validator: IntValidator { bottom: 512; top: 32768 }
|
||||
onEditingFinished: settings.memoryMb = parseInt(text)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,150 +0,0 @@
|
||||
# LoaderRow
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме чистой игры он умеет
|
||||
ставить модлоадеры: Forge, Fabric, NeoForge и Quilt. В карточке сборки лоадер выбирается
|
||||
чекбоксом, а под ним — конкретная версия лоадера.
|
||||
|
||||
`LoaderRow` — одна такая строка: чекбокс с названием лоадера и, когда он отмечен, выпадающий
|
||||
список версий именно под выбранную версию Minecraft. Компонент берёт на себя всю возню со
|
||||
списком версий: подтягивает его из кэша, обновляет по сети, следит, чтобы выбранная версия
|
||||
всегда существовала под текущую версию игры, и словами объясняет случай «лоадер эту версию игры
|
||||
не поддерживает» вместо показа пустого списка.
|
||||
|
||||
Совместимость компонент не проверяет и проверять не должен: бэкенд отдаёт список, уже собранный
|
||||
под конкретную версию игры, поэтому несовместимой строки в нём не бывает. Пустой список — это и
|
||||
есть отсутствие поддержки.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Импортирует `QtQuick` и `QtQuick.Controls 2.15`.
|
||||
|
||||
Использует компонент [DarkCombo](DarkCombo.md) из того же QML-модуля — выпадающий список версий в
|
||||
тёмном стиле окна.
|
||||
|
||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, зарегистрирован через `QML_ELEMENT`
|
||||
в модуле `Minecraft_launcher`), который передаётся снаружи в свойство `backend`. Из него
|
||||
компонент вызывает `loaderVersions()`, `refreshLoaderVersions()` и `loaderVersionsLoading()`, а
|
||||
также слушает сигнал `loaderVersionsChanged`.
|
||||
|
||||
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`). Инстанцируется в
|
||||
[BuildsDialog](BuildsDialog.md) — по одной строке на каждый поддерживаемый лоадер.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Column` с расстоянием 4 px. Внутри три потомка, видимость которых
|
||||
взаимоисключающая по нижней части:
|
||||
|
||||
- `CheckBox` с полностью переопределённым индикатором (квадрат со скруглением и галочкой) и
|
||||
подписью. Выключен, пока не выбрана версия игры.
|
||||
- [DarkCombo](DarkCombo.md) со списком версий лоадера — виден, только когда чекбокс отмечен и
|
||||
список непустой.
|
||||
- Текстовая строка на месте списка — видна, когда чекбокс отмечен, а список пуст. Пока идёт
|
||||
запрос, она серая и говорит о загрузке; когда запрос закончен, она красноватая и сообщает, что
|
||||
лоадер не поддерживает выбранную версию Minecraft.
|
||||
|
||||
Ширина внутренних элементов считается от ширины колонки, поэтому снаружи достаточно задать
|
||||
`width`.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend`. Через него запрашиваются и обновляются списки версий лоадера. |
|
||||
| `loaderKey` | `string` | — | **Да** | Ключ лоадера, которым он опознаётся в бэкенде и в сохранённой сборке: `forge`, `fabric`, `neoforge`, `quilt`. |
|
||||
| `title` | `string` | — | **Да** | Человекочитаемое название лоадера рядом с чекбоксом; оно же подставляется в сообщения о загрузке и об отсутствии поддержки. |
|
||||
| `gameVersion` | `string` | `""` | Нет | Версия Minecraft, выбранная в карточке. Пустая строка выключает чекбокс. Смена значения сбрасывает выбранную версию лоадера и перезапрашивает список. |
|
||||
| `checked` | `bool` (алиас на чекбокс) | `false` | Нет | Отмечен ли лоадер. Чтение даёт текущее состояние, запись переключает чекбокс программно — без сигнала `userChecked()`. |
|
||||
| `selectedVersion` | `string` | `""` | Нет | Выбранная версия лоадера. Устанавливается только через `applyEntries()`, поэтому всегда либо пуста, либо присутствует в текущем списке. |
|
||||
| `entries` | `var` | `[]` | Нет | Текущий список версий лоадера — массив записей, у каждой есть поля `version` (значение) и `label` (подпись для списка). Заполняется из бэкенда. |
|
||||
| `applying` | `bool` | `false` | Нет | Признак того, что строку сейчас заполняет карточка данными сохранённой сборки. Пока он выставлен, изменения не считаются пользовательскими и сигнал `changed()` не испускается. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### userChecked()
|
||||
|
||||
Пользователь сам отметил чекбокс (не программная установка `checked`). Карточка сборки в ответ
|
||||
снимает отметки с остальных строк лоадеров: одновременно в `.minecraft` может жить только один
|
||||
лоадер.
|
||||
|
||||
#### changed()
|
||||
|
||||
Отметка или версия лоадера изменились и это изменение пользовательское. Обработчик — карточка
|
||||
сборки — сохраняет сборку с новыми значениями.
|
||||
|
||||
Сигнал сознательно не испускается, пока выставлен `applying`, то есть при заполнении строки из
|
||||
уже сохранённой сборки: иначе загрузка карточки сразу же приводила бы к её перезаписи.
|
||||
|
||||
## Методы
|
||||
|
||||
#### versionIndex(string version) : int
|
||||
|
||||
Возвращает позицию версии в текущем массиве `entries` или `-1`, если такой версии в списке нет.
|
||||
Вспомогательная функция для синхронизации выбранного значения с выпадающим списком.
|
||||
|
||||
#### applyEntries(var list) : void
|
||||
|
||||
Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути
|
||||
получения списка — кэш, ответ сети и заполнение из сохранённой сборки, — потому что выбранная
|
||||
версия обязана существовать в списке под текущую версию игры.
|
||||
|
||||
Записывает новый список, а затем проверяет выбранную версию: если её в списке нет, подставляет
|
||||
первую строку (список отсортирован новыми вперёд, поэтому первая — максимально доступная под эту
|
||||
версию игры) или пустую строку для пустого списка. Если подстановка изменила значение и строка не
|
||||
находится в режиме `applying`, испускает `changed()`. В конце синхронизирует `currentIndex`
|
||||
выпадающего списка.
|
||||
|
||||
#### reload() : void
|
||||
|
||||
Перезапрашивает список версий. Если лоадер не отмечен или версия игры не выбрана, очищает список
|
||||
через `applyEntries([])`. Иначе сначала берёт список из кэша бэкенда (`loaderVersions()`) — он
|
||||
появляется мгновенно, — а затем просит обновление по сети (`refreshLoaderVersions()`), результат
|
||||
которого придёт позже сигналом.
|
||||
|
||||
#### applyBuild(string loader, string loaderVersion) : void
|
||||
|
||||
Заполняет строку данными сохранённой сборки, не испуская `changed()`: на время работы выставляет
|
||||
`applying`. Отмечает чекбокс, если ключ лоадера сборки совпадает с `loaderKey`, подставляет версию
|
||||
из сборки как пожелание и вызывает `reload()`. Если под выбранную версию игры такой версии
|
||||
лоадера нет, `applyEntries()` заменит её на максимально доступную.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
**Что приходит извне.** Карточка сборки в [BuildsDialog](BuildsDialog.md) задаёт `backend`,
|
||||
`loaderKey`, `title` и привязывает `gameVersion` к версии Minecraft, выбранной в карточке.
|
||||
Заполнение сохранённой сборкой идёт вызовом `applyBuild()` снаружи.
|
||||
|
||||
**Что уходит наружу.** По `userChecked()` карточка снимает отметки с остальных строк — набор
|
||||
строк она держит в собственном списке. По `changed()` карточка сохраняет сборку, читая `checked` и
|
||||
`selectedVersion`.
|
||||
|
||||
**Бэкенд.** Компонент сам подписан на сигнал `loaderVersionsChanged(key, game)` через
|
||||
`Connections`: пришедшее обновление принимается, только если ключ и версия игры совпадают с
|
||||
текущими и чекбокс отмечен, — иначе ответ относится к другой строке или устарел. Текст в пустом
|
||||
состоянии опрашивает `loaderVersionsLoading()`, чтобы отличать «ещё грузим» от «не поддерживается».
|
||||
|
||||
**Реакция на смену версии игры.** Обработчик `onGameVersionChanged` сбрасывает `selectedVersion` и
|
||||
вызывает `reload()`: сборка лоадера привязана к версии игры, поэтому под новой версией прежний
|
||||
выбор недействителен.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
LoaderRow {
|
||||
id: forgeRow
|
||||
width: parent.width
|
||||
|
||||
backend: launcherBackend
|
||||
loaderKey: "forge"
|
||||
title: "Forge"
|
||||
gameVersion: buildCard.gameVersion
|
||||
|
||||
onUserChecked: buildCard.keepOnly(forgeRow)
|
||||
onChanged: buildCard.commitSelection()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,212 +0,0 @@
|
||||
# Main
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. `Main.qml` — его главное и
|
||||
единственное настоящее окно: точка входа приложения, которую загружает `main.cpp` вызовом
|
||||
`engine.loadFromModule("Minecraft_launcher", "Main")`.
|
||||
|
||||
Окно совмещает четыре роли. Оно держит единственный экземпляр `LauncherBackend` — весь остальной
|
||||
интерфейс получает его от главного окна. Оно рисует сам экран запуска: фоновая картинка, большая
|
||||
кнопка игры по центру, выпадающий список профилей, кнопка активной сборки, кнопки папки модов,
|
||||
настроек и сезонных сборок. Оно показывает обратную связь — всплывающую плашку сообщений и две
|
||||
панели хода долгих операций. И наконец, оно объявляет диалоги, которые не вынесены в отдельные
|
||||
файлы: создание и редактирование профиля, ввод кода двухфакторной аутентификации и настройки
|
||||
запуска.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Импортирует `QtQuick`, `QtQuick.Layouts 2.15`, `QtQuick.Controls 2.15` и сам QML-модуль проекта
|
||||
`Minecraft_launcher`, из которого приходит тип `LauncherBackend`.
|
||||
|
||||
Инстанцирует четыре компонента модуля: [ProgressPanel](ProgressPanel.md) (дважды),
|
||||
[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [BuildsDialog](BuildsDialog.md),
|
||||
[JavaPickerDialog](JavaPickerDialog.md), а также [DarkCombo](DarkCombo.md) и
|
||||
[LabelledField](LabelledField.md) внутри своих диалогов.
|
||||
[MicrosoftLoginDialog](MicrosoftLoginDialog.md) создаётся динамически — см. ниже.
|
||||
|
||||
Стиль Qt Quick Controls принудительно выставлен в `Basic` в `main.cpp`, потому что нативные стили
|
||||
игнорируют пользовательские `contentItem` и `background`; поэтому в этом файле почти каждый
|
||||
элемент управления переопределяет своё оформление вручную.
|
||||
|
||||
Использует ресурсы из `RESOURCES` QML-модуля: фоновую картинку, три состояния кнопки запуска, по
|
||||
три состояния кнопок папки и настроек, стрелки выпадающих списков, `images/Trash.svg` и
|
||||
`images/Pencil.svg`.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Window` размером 1280×720 px, видимое при старте. Это не переиспользуемый
|
||||
компонент, а точка входа приложения, поэтому раздел с примером использования здесь неприменим.
|
||||
|
||||
Раскладка держится на якорях относительно центральной кнопки запуска: список профилей — слева
|
||||
сверху от неё, кнопка активной сборки — справа сверху, кнопки папки и настроек — под списком
|
||||
профилей. Кнопка сезонных сборок стоит в правом нижнем углу: это единственная свободная часть
|
||||
окна, потому что панели хода работ висят слева, а всё остальное собрано вокруг кнопки запуска.
|
||||
|
||||
Панели загрузки и смены сборки имеют одни и те же якоря — они взаимоисключающи по построению:
|
||||
признак занятости бэкенда не даёт начать переключение во время установки и наоборот. Панель смены
|
||||
сборки объявлена неотменяемой: отступать после очистки `.minecraft` некуда, операцию нужно довести
|
||||
до конца.
|
||||
|
||||
Заголовки и подвалы всех диалогов сделаны на `Item` с явным `implicitHeight`, а не на
|
||||
`Rectangle`: у прямоугольника `implicitHeight` равен нулю независимо от заданной высоты, и
|
||||
`Dialog` не смог бы вычислить свою полную высоту.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `microsoftLoginDialog` | `var` | `null` | Нет | Созданный по требованию экземпляр окна входа Microsoft либо `null`, пока вход ни разу не запускался. Хранится в свойстве, чтобы окно создавалось один раз за сеанс. |
|
||||
|
||||
### Внутренние диалоги и их состояние
|
||||
|
||||
`Main.qml` объявляет четыре диалога прямо в файле. Их свойства — часть состояния главного окна.
|
||||
|
||||
**Диалог редактирования профиля** (`editProfileDialog`):
|
||||
|
||||
| Свойство | Тип | По умолчанию | Описание |
|
||||
|----------|-----|--------------|----------|
|
||||
| `editIndex` | `int` | `-1` | Индекс редактируемого профиля; `-1` — диалог не открыт ни для кого. |
|
||||
| `msProfile` | `bool` | `false` | Открытый профиль имеет тип «Microsoft». |
|
||||
| `msLinked` | `bool` | `false` | У профиля есть действующая сессия Microsoft — от этого зависит строка статуса и подпись кнопки входа. |
|
||||
| `msName` | `string` | `""` | Ник, полученный при официальной авторизации. Показывается отдельным полем только для чтения, а не подменой поля логина: привязка сломалась бы первым же вводом в поле логина обычного профиля. |
|
||||
|
||||
**Диалог двухфакторной аутентификации** (`twoFactorDialog`):
|
||||
|
||||
| Свойство | Тип | По умолчанию | Описание |
|
||||
|----------|-----|--------------|----------|
|
||||
| `profileName` | `string` | `""` | Имя профиля, для которого запрошен код; подставляется в текст просьбы. |
|
||||
|
||||
**Диалог настроек** (`settingsDialog`):
|
||||
|
||||
| Свойство | Тип | По умолчанию | Описание |
|
||||
|----------|-----|--------------|----------|
|
||||
| `javaRuntimeId` | `string` | `""` | Выбранная сборка Java из папки лаунчера. Живёт в свойстве, а не в поле ввода: её выбирают в отдельном окне, а записывается она только по «Сохранить». Пустая строка означает «искать Java в системе». |
|
||||
| `javaRuntimeInfo` | `var` | `null` | Подробности выбранной сборки для строки поля. Не привязка: `javaRuntimeInfo()` — обычный вызов, и сам он не пересчитается, когда сборка докачается, поэтому значение обновляется по событиям. |
|
||||
|
||||
Типы профилей во всех выпадающих списках кодируются одинаково: позиция 0 — `offline`
|
||||
(офлайн, без пароля), 1 — `elyby` (Ely.by, с логином и паролем), 2 — `microsoft` (лицензия).
|
||||
Третья позиция показывается, только когда лаунчер собран с Qt WebEngine; в диалоге редактирования
|
||||
она показывается ещё и тогда, когда профиль уже сохранён как лицензионный — иначе в сборке без
|
||||
WebEngine он молча стал бы офлайновым.
|
||||
|
||||
## Сигналы
|
||||
|
||||
Собственных сигналов главное окно не объявляет.
|
||||
|
||||
## Методы
|
||||
|
||||
#### showToast(string text, color color, int timeout) : void
|
||||
|
||||
Показывает единую всплывающую плашку сообщений: через неё проходят сообщения о ходе запуска,
|
||||
ошибки и статус игры. Задаёт текст и цвет фона и перезапускает таймер скрытия.
|
||||
|
||||
Параметр `timeout` — время показа в миллисекундах. Пропущенное значение означает четыре секунды;
|
||||
`0` означает «держать до следующего сообщения» — так показываются промежуточные шаги запуска, чтобы
|
||||
сообщение не исчезало посреди долгой операции.
|
||||
|
||||
#### openMicrosoftLogin(url) : void
|
||||
|
||||
Открывает окно входа в аккаунт Microsoft на переданном адресе, создавая его при первом вызове.
|
||||
|
||||
Окно создаётся по требованию, а не вместе с главным: `MicrosoftLoginDialog.qml` попадает в модуль
|
||||
только в сборках с Qt WebEngine, и обычная декларация сломала бы всё главное окно в остальных.
|
||||
Поэтому компонент загружается через `Qt.createComponent()`, и если он не готов — сборка собрана без
|
||||
WebEngine, — вход отменяется у бэкенда, а пользователю показывается сообщение о том, что окно
|
||||
недоступно. При успешном создании окну сразу передаётся бэкенд, а его сигнал `failed`
|
||||
подключается к плашке сообщений.
|
||||
|
||||
#### formatMb(bytes) : string
|
||||
|
||||
Переводит байты в мегабайты с одним знаком после запятой. Используется в строке подробностей
|
||||
панели загрузки.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
### Бэкенд
|
||||
|
||||
Единственный экземпляр `LauncherBackend` объявлен прямо в окне и передаётся всем вложенным
|
||||
диалогам через их свойство `backend`. Главное окно — единственное место, где обрабатываются его
|
||||
сигналы:
|
||||
|
||||
| Сигнал бэкенда | Что делает главное окно |
|
||||
|----------------|-------------------------|
|
||||
| `launched(profileName, buildName, serverUrl)` | показывает зелёное сообщение о запуске |
|
||||
| `launchProgress(message)` | показывает сообщение без таймаута — до следующего шага |
|
||||
| `launchError(message)` | показывает ошибку на восемь секунд |
|
||||
| `twoFactorRequired(profileName)` | открывает диалог ввода кода: Ely.by отклонил пароль с пометкой two factor, и код добирается здесь, чтобы продолжить прерванный запуск |
|
||||
| `gameFinished(exitCode, crashed)` | сообщает о закрытии игры; аварийное завершение показывается красным вместе с кодом выхода |
|
||||
| `microsoftLoginUrlReady(url)` | вызывает `openMicrosoftLogin()` |
|
||||
| `microsoftLoginSucceeded(playerName)` | сообщает об успешном входе. Выбор в списке профилей при этом не трогается: новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль |
|
||||
| `microsoftLoginFailed(message)` | показывает ошибку на восемь секунд |
|
||||
| `microsoftReloginRequired(profileIndex)` | сразу начинает вход заново для этого профиля |
|
||||
| `gameOutput(line)` | пишет строку в консоль |
|
||||
| `seasonalInstallFinished(seasonalId, buildName)` | сообщает, что сезонная сборка установлена и её можно запускать |
|
||||
| `javaRuntimeInstalled(runtimeId)` | обновляет подробности выбранной сборки Java в настройках |
|
||||
|
||||
Привязки к свойствам бэкенда управляют доступностью интерфейса: кнопка запуска выключена, пока
|
||||
лаунчер занят или игра уже идёт; кнопка активной сборки — пока идёт игра или переключение сборок;
|
||||
подпись на ней берётся из `activeBuildName`, а список профилей — из `profileNames`.
|
||||
|
||||
### Профили
|
||||
|
||||
Выпадающий список профилей переопределён целиком: кнопка «+ Добавить профиль» закреплена сверху
|
||||
всплывающей панели, под ней список, где у строки при наведении появляются карандаш и корзина.
|
||||
Карандаш открывает диалог редактирования (`openFor()` заполняет его через `profileAt()`), корзина
|
||||
вызывает `removeProfile()`.
|
||||
|
||||
Создание профиля вызывает `addProfile()`, выбирает новый профиль в списке и, если тип —
|
||||
«Microsoft», сразу начинает вход: такой профиль без входа бесполезен. Скрытые поля при сохранении
|
||||
не читаются — в них мог остаться текст, набранный до переключения типа профиля.
|
||||
|
||||
В диалоге редактирования кнопка входа перед вызовом `startMicrosoftLogin()` сначала сохраняет
|
||||
профиль вызовом `updateProfile()` с типом `microsoft`: тип мог быть только что переключён, и без
|
||||
этого бэкенд приписал бы токены профилю другого типа.
|
||||
|
||||
### Запуск игры
|
||||
|
||||
Кнопка запуска вызывает `launchGame()` с индексом выбранного профиля и индексом активной сборки.
|
||||
Дальше всё идёт через сигналы бэкенда: промежуточные шаги — в плашку сообщений, запрос кода
|
||||
двухфакторной аутентификации — в отдельный диалог, где подтверждение вызывает
|
||||
`submitTwoFactorCode()`, а отмена — `cancelPendingLaunch()`.
|
||||
|
||||
### Настройки
|
||||
|
||||
Диалог настроек открывается методом `load()`, который читает `settings()` бэкенда и раскладывает
|
||||
значения по полям, а также подставляет разрешённый путь папки игры и список найденных в системе
|
||||
сборок Java (`detectedJava()`). Сохранение собирает все поля в один `QVariantMap` и передаёт его
|
||||
в `updateSettings()`.
|
||||
|
||||
Первым пунктом диалога идёт выбор языка интерфейса — настройка уровня приложения, поэтому она
|
||||
стоит над параметрами запуска. Подписи в модели переводятся, а коды (`system`, `ru`, `en`) лежат
|
||||
рядом отдельным списком `codes` и не переводятся. Применяется язык по кнопке «Сохранить», как и
|
||||
всё остальное в этом диалоге, и сразу же, без перезапуска: см. [Localization](../cpp/Localization.md).
|
||||
|
||||
Разрешённый путь папки игры хранится свойством `resolvedGameDir` диалога, а не присваивается
|
||||
тексту напрямую — иначе подпись не пережила бы смену языка.
|
||||
|
||||
Поле выбора сборки Java открывает [JavaPickerDialog](JavaPickerDialog.md), передавая текущий выбор
|
||||
и требование активной сборки (`requiredJavaMajor()`); крестик справа сбрасывает выбор обратно на
|
||||
поиск Java в системе. Само окно выбора объявлено рядом с настройками, а не внутри них: оно шире и
|
||||
центрируется по окну лаунчера.
|
||||
|
||||
Выбранная сборка Java — общая настройка лаунчера: когда она задана, запуск идёт ею, а путь к Java
|
||||
из соседнего поля остаётся запасным вариантом.
|
||||
|
||||
### Тексты
|
||||
|
||||
Все подписи, сообщения и подсказки окна берутся из синглтона `Loc`: `Loc.t.домен.вид.имя`.
|
||||
Ни одного текстового литерала в разметке не осталось, `qsTr` не используется. Модель типов входа
|
||||
в диалогах профиля — тоже ключ каталога (`Loc.t.profile.authTypes`), причём порядок значений
|
||||
в нём значим: код сравнивает `currentIndex` с 1 и 2, а вариант без Microsoft получается из той же
|
||||
модели через `.slice(0, 2)`. Подробности — в [Localization](../cpp/Localization.md).
|
||||
|
||||
### Прочие кнопки
|
||||
|
||||
Кнопка папки вызывает `openMinecraftFolder()`, кнопка настроек открывает диалог настроек, кнопка
|
||||
сезонных сборок — [SeasonalBuildsDialog](SeasonalBuildsDialog.md) методом `openCatalog()`, кнопка
|
||||
активной сборки — [BuildsDialog](BuildsDialog.md).
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,121 +0,0 @@
|
||||
# MicrosoftLoginDialog
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Он поддерживает несколько
|
||||
способов входа: офлайн-профиль, сервер Ely.by и учётную запись Microsoft. Последний путь требует
|
||||
показать пользователю настоящую страницу входа Microsoft и дождаться, пока браузер уйдёт на
|
||||
`redirect_uri` с кодом авторизации в адресе — ровно так же поступает официальный лаунчер.
|
||||
|
||||
`MicrosoftLoginDialog` — окно с этой страницей. Внутри него живёт `WebEngineView`; диалог следит
|
||||
за сменой адреса, отдаёт перехваченный код бэкенду и закрывается. Собственной логики разбора
|
||||
адреса у него нет — она в C++, чтобы правила совпадения совпадали с теми, по которым сервис сам
|
||||
строит `redirect_uri`.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Импортирует `QtQuick`, `QtQuick.Controls 2.15` и `QtWebEngine`. В начале файла объявлена
|
||||
`pragma ComponentBehavior: Bound`.
|
||||
|
||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT`), который передаётся
|
||||
снаружи в свойство `backend`. Вызывает у него `inspectMicrosoftRedirect()`,
|
||||
`finishMicrosoftLogin()` и `cancelMicrosoftLogin()`.
|
||||
|
||||
**Особенность сборки.** Это единственный QML-файл проекта, который попадает в модуль условно.
|
||||
`CMakeLists.txt` ищет `Qt6WebEngineQuick` через `find_package(... QUIET)`; модуль объявлен
|
||||
необязательным сознательно — он ставится отдельной галочкой в установщике Qt и тянет за собой
|
||||
WebChannel с Positioning, которых в типовой установке нет. Если модуль найден, файл добавляется в
|
||||
`QML_FILES` и определяется макрос `LAUNCHER_HAS_WEBENGINE`; если нет — лаунчер собирается и
|
||||
работает как прежде, только без входа через Microsoft. В QML это различие видно через свойство
|
||||
`backend.microsoftAvailable`, и интерфейс не должен предлагать этот путь, когда оно ложно.
|
||||
|
||||
Инстанцируется динамически из [Main](Main.md) — главное окно создаёт диалог по требованию, потому
|
||||
что при сборке без WebEngine самого типа в модуле не существует.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Dialog` из Qt Quick Controls: модальный, 560×680 px, по центру родителя, с нулевым
|
||||
внутренним отступом и `closePolicy: Popup.NoAutoClose` — окно нельзя закрыть щелчком мимо или
|
||||
клавишей Escape, выход только через кнопку отмены или успешный вход.
|
||||
|
||||
Оформление задано вручную: тёмный фон со скруглением и акцентной рамкой, заголовок с
|
||||
разделительной линией, подвал с кнопкой «Отмена».
|
||||
|
||||
Содержимое — `WebEngineView` во всю площадь с отступом 12 px и индикатор занятости по центру,
|
||||
видимый на время загрузки страницы. Рядом объявлен `WebEngineProfilePrototype` без `storageName`:
|
||||
профиль без имени хранилища означает профиль без диска, поэтому куки живут только пока работает
|
||||
лаунчер и в общий браузер не попадают. За выбор аккаунта в пределах сессии отвечает параметр
|
||||
`prompt=select_account` в адресе входа, который формирует бэкенд.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend`. Разбирает перехваченный адрес и завершает или отменяет вход. |
|
||||
| `codeTaken` | `bool` | `false` | Нет | Код авторизации уже отдан бэкенду. Защита от повторной обработки: `WebEngineView` успевает сообщить об изменении адреса несколько раз, и без этого признака код ушёл бы дважды. Сбрасывается в `openAt()` и принудительно выставляется при отмене. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### failed(string message)
|
||||
|
||||
Вход не завершён: адрес совпал с `redirect_uri`, но кода в нём нет — например, пользователь
|
||||
отказался выдать разрешение, или Microsoft вернула ошибку. В параметре приходит текст ошибки от
|
||||
бэкенда, а если его нет — сообщение по умолчанию о незавершённом входе.
|
||||
|
||||
Окно ничего не знает про тосты главного окна, поэтому о неудаче сообщает сигналом. Обработчик в
|
||||
[Main](Main.md) показывает это сообщение пользователю. К моменту испускания сигнала диалог уже
|
||||
закрыт, а вход у бэкенда отменён — обработчику остаётся только уведомить.
|
||||
|
||||
Отмена по кнопке сигнала не испускает: пользователь и так знает, что закрыл окно.
|
||||
|
||||
## Методы
|
||||
|
||||
#### openAt(string url) : void
|
||||
|
||||
Открывает диалог на переданном адресе страницы входа. Сбрасывает `codeTaken`, загружает адрес в
|
||||
`WebEngineView` и показывает окно. Адрес формирует бэкенд — в нём уже присутствуют `redirect_uri`,
|
||||
идентификатор клиента и `prompt=select_account`.
|
||||
|
||||
#### handleUrl(url) : void
|
||||
|
||||
Обработчик смены адреса в `WebEngineView`; вызывать снаружи не нужно. Ничего не делает, если код
|
||||
уже перехвачен. Иначе передаёт адрес в `backend.inspectMicrosoftRedirect()` и смотрит на поле
|
||||
`matched` ответа: если адрес не является `redirect_uri`, обработка на этом заканчивается — это
|
||||
обычная навигация по страницам входа.
|
||||
|
||||
При совпадении выставляет `codeTaken`, закрывает окно и дальше расходится по двум путям: непустое
|
||||
поле `code` уходит в `backend.finishMicrosoftLogin()`, иначе вход отменяется через
|
||||
`backend.cancelMicrosoftLogin()` и испускается сигнал `failed()` с текстом из поля `error`.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
**Со стороны главного окна.** [Main](Main.md) создаёт диалог динамически (функция
|
||||
`openMicrosoftLogin()`), задаёт `backend`, вызывает `openAt()` с адресом от бэкенда и
|
||||
подписывается на `failed()`, чтобы показать тост с ошибкой. Показывать ли кнопку входа через
|
||||
Microsoft вообще, главное окно решает по `backend.microsoftAvailable`.
|
||||
|
||||
**Со стороны бэкенда.** Диалог только доставляет код: `inspectMicrosoftRedirect()` — разбор
|
||||
адреса, `finishMicrosoftLogin()` — продолжение обмена кода на токены, `cancelMicrosoftLogin()` —
|
||||
сброс начатой сессии входа. Результат входа диалогу не возвращается: об успехе главное окно
|
||||
узнаёт от бэкенда по его собственным сигналам, а диалог к этому моменту уже закрыт.
|
||||
|
||||
**Кнопка отмены.** Выставляет `codeTaken`, закрывает окно и отменяет вход у бэкенда. Признак
|
||||
ставится до закрытия, чтобы последний сигнал об изменении адреса при закрытии не был обработан.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
MicrosoftLoginDialog {
|
||||
id: msLogin
|
||||
parent: Overlay.overlay
|
||||
backend: launcherBackend
|
||||
onFailed: (message) => showToast(message, "#cc6666", 4000)
|
||||
}
|
||||
|
||||
// открывать только в сборке с Qt WebEngine
|
||||
Component.onCompleted: if (launcherBackend.microsoftAvailable) msLogin.openAt(loginUrl)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,90 +0,0 @@
|
||||
# ProgressPanel
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Почти каждое действие в нём
|
||||
долгое: скачивание версии игры, установка Java, распаковка и архивация папки `.minecraft` при
|
||||
смене сборки, загрузка сезонной сборки. Лаунчер не блокирует окно на это время, поэтому ход
|
||||
операции нужно показывать неотрывно от остального интерфейса.
|
||||
|
||||
`ProgressPanel` — та самая плашка прогресса. Она размещается в левом нижнем углу главного окна,
|
||||
где не перекрывает кнопку запуска, сообщение по центру и кнопки папки и настроек. Компонент
|
||||
только отображает переданное состояние; сам он ничего не считает и ни за чем не следит.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы напрямую не использует.
|
||||
|
||||
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`), поэтому доступен по имени
|
||||
внутри модуля без импорта. Инстанцируется в [Main](Main.md) — по одной плашке на вид долгой
|
||||
операции.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Rectangle` фиксированного размера 320×72 px со скруглением 8 px, тёмной заливкой,
|
||||
акцентной рамкой и лёгкой полупрозрачностью, чтобы плашка читалась поверх фонового изображения
|
||||
окна.
|
||||
|
||||
Внутри — пять элементов без внешних зависимостей: заголовок слева сверху, проценты справа сверху,
|
||||
полоса прогресса (дорожка и заполнение с плавной анимацией ширины на 120 мс), строка подробностей
|
||||
снизу и крестик отмены в правом нижнем углу с увеличенной областью нажатия.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `title` | `string` | `""` | Нет | Заголовок операции в левом верхнем углу, полужирным. Длинный текст обрезается справа многоточием. |
|
||||
| `status` | `string` | `""` | Нет | Строка состояния внизу плашки. Показывается только когда `detail` пуст. |
|
||||
| `fraction` | `double` | `-1` | Нет | Доля выполнения от `0` до `1`. Значение `-1` означает «итог ещё неизвестен»: вместо процентов выводится многоточие, а полоса остаётся пустой. |
|
||||
| `detail` | `string` | `""` | Нет | Необязательная вторая строка подробностей — мегабайты у загрузки, путь у архивации. Если задана, вытесняет `status`. Длинный текст обрезается посередине, чтобы у пути были видны и начало, и конец. |
|
||||
| `cancellable` | `bool` | `true` | Нет | Показывать ли крестик отмены. Ставится в `false` для операций, которые прерывать нельзя. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### cancelRequested()
|
||||
|
||||
Пользователь нажал крестик в правом нижнем углу. Сигнал сообщает только о намерении: плашка не
|
||||
скрывает себя и не меняет своё состояние.
|
||||
|
||||
Обработчик должен сам остановить операцию в бэкенде и убрать плашку с экрана — как правило, вызвав
|
||||
соответствующий метод отмены у `LauncherBackend`; видимость плашки при этом снимется сама, потому
|
||||
что она привязана к свойству занятости бэкенда.
|
||||
|
||||
Сигнал не испускается при `cancellable: false` — в этом случае крестик скрыт.
|
||||
|
||||
## Методы
|
||||
|
||||
Собственных функций нет.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
Все пять свойств плашки — точки внешней привязки. В [Main](Main.md) они связаны со свойствами
|
||||
`LauncherBackend`: у загрузки версии это группа `downloading` / `downloadProgress` /
|
||||
`downloadVersion` / `downloadStatus` / `downloadBytesDone` / `downloadBytesTotal`, у смены сборки —
|
||||
`switching` / `switchProgress` / `switchStage` / `switchStatus`. Байты в мегабайты переводит
|
||||
функция `formatMb()` главного окна, а не сама плашка.
|
||||
|
||||
Видимостью плашки управляет родитель, обычно привязывая её к тому же признаку занятости, который
|
||||
питает `fraction`. Сигнал `cancelRequested` родитель замыкает на метод отмены бэкенда.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
ProgressPanel {
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 16
|
||||
visible: backend.downloading
|
||||
|
||||
title: qsTr("Загрузка Minecraft %1").arg(backend.downloadVersion)
|
||||
status: backend.downloadStatus
|
||||
fraction: backend.downloadProgress
|
||||
detail: formatMb(backend.downloadBytesDone) + " / " + formatMb(backend.downloadBytesTotal)
|
||||
|
||||
onCancelRequested: backend.cancelDownload()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,135 +0,0 @@
|
||||
# SeasonalBuildsDialog
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме сборок, которые
|
||||
пользователь собирает сам, он умеет ставить готовые сезонные сборки с сервера лаунчера: набор
|
||||
модов под конкретную версию игры и модлоадер, подготовленный заранее и выдаваемый целиком.
|
||||
|
||||
`SeasonalBuildsDialog` — окно этого каталога: таблица со всем, что нужно знать, чтобы решить,
|
||||
ставить сборку или нет, и одна кнопка, которая делает всё остальное — заводит сборку, ставит
|
||||
версию игры, модлоадер, Java и раскладывает файлы. Окно также показывает, что установленная
|
||||
сборка устарела, и предлагает обновить её до свежей ревизии.
|
||||
|
||||
Таблица собрана из строк `Row` с фиксированными колонками, а не из `TableView`: в проекте нет ни
|
||||
одной модели `QAbstractItemModel`, а строки приходят готовыми `QVariantMap` — заводить ради семи
|
||||
колонок отдельную модель незачем.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick` и
|
||||
`QtQuick.Controls 2.15` — слои `QtQuick.Layouts` здесь не нужны, вся раскладка на якорях и
|
||||
фиксированных ширинах колонок.
|
||||
|
||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства
|
||||
`seasonalCatalog`, `seasonalCatalogLoading`, `seasonalCatalogError`, `seasonalInstalling` и `busy`,
|
||||
вызывает `refreshSeasonalCatalog()` и `installSeasonalBuild()`.
|
||||
|
||||
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`). Инстанцируется в [Main](Main.md).
|
||||
|
||||
Строки каталога приходят из C++ уже отсортированными и сведёнными с локальными записями — окно
|
||||
показывает статус, не считая ничего само. Запись содержит поля `id`, `name`, `description`,
|
||||
`minecraftVersion`, `loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd`,
|
||||
`status`, `revision`, `installedRevision`, `updateAvailable`, `sizeBytes` и `serverUrl`.
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Dialog`: модальный, 960×560 px, нулевой внутренний отступ, тёмный фон со
|
||||
скруглением и акцентной рамкой.
|
||||
|
||||
Политика закрытия зависит от состояния: пока идёт установка, окно закрывается только кнопкой
|
||||
(`Popup.NoAutoClose`), потому что случайный щелчок мимо не должен спрятать единственную видимую
|
||||
отмену; в остальное время работают Escape и щелчок мимо.
|
||||
|
||||
Содержимое — шапка таблицы (`Row` с `Repeater` по `columns`), разделительная линия, список строк и
|
||||
сообщение по центру для пустого состояния. Строка списка — `Rectangle` высотой 36 px с вложенным
|
||||
`Row`, который повторяет тот же набор колонок; ширины берутся из общего описания `columns`,
|
||||
поэтому шапка и строки не могут разъехаться.
|
||||
|
||||
Подвал высотой 76 px несёт описание и размер выбранной сборки (они длинные и в таблицу не
|
||||
помещаются, а решение принимается именно по ним) и две кнопки — обновления списка и установки.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога сезонных сборок и исполнитель установки. |
|
||||
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной строки. Хранится по `id`, а не по индексу: список обновляется под руками, и индекс после обновления указывал бы на другую сборку. |
|
||||
| `columns` | `var` (только чтение) | список из семи колонок | Нет | Описание таблицы: массив объектов с полями `key` (поле записи), `title` (заголовок), `width` (ширина в пикселях) и `align` (выравнивание). Колонки: название, версия, загрузчик, число модов, начало и конец сезона, статус. Ширины собраны в одном месте, потому что их повторяют и шапка, и делегат строки. |
|
||||
| `entries` | `var` (только чтение) | `backend.seasonalCatalog` | Нет | Строки каталога напрямую из бэкенда. Отбора и сортировки в окне нет. |
|
||||
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки или `null`, если ничего не выбрано. От неё зависят подвал и доступность кнопки установки. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
Собственных сигналов компонент не объявляет. Результат работы окна виден через состояние бэкенда:
|
||||
установка меняет список сборок и запускает загрузку, за ходом которой следит главное окно.
|
||||
|
||||
## Методы
|
||||
|
||||
#### openCatalog() : void
|
||||
|
||||
Открывает окно. Обновление каталога происходит само в обработчике `onAboutToShow`, который просит
|
||||
у бэкенда `refreshSeasonalCatalog(false)` — без принудительного обхода кэша: свежий кэш отвечает
|
||||
без сети, поэтому вызов при каждом открытии ничего не стоит.
|
||||
|
||||
#### formatMb(bytes) : string
|
||||
|
||||
Переводит размер в байтах в строку с мегабайтами и одним знаком после запятой. Для нулевого или
|
||||
отсутствующего значения возвращает пустую строку, чтобы размер просто не попал в строку подвала.
|
||||
|
||||
#### cellText(entry, string key) : string
|
||||
|
||||
Возвращает текст ячейки для записи и ключа колонки. Для всех колонок это значение одноимённого
|
||||
поля записи, приведённое к строке; исключение — колонка загрузчика, где название и версия
|
||||
склеиваются в одну подпись, а при пустой версии остаётся только название.
|
||||
|
||||
#### installSelected() : void
|
||||
|
||||
Ставит выбранную сборку. Ничего не делает, если строка не выбрана или лаунчер занят другой
|
||||
операцией: установка занимает и панель загрузки, и `.minecraft` целиком, поэтому вторую начинать
|
||||
нельзя. Иначе вызывает `installSeasonalBuild()` у бэкенда.
|
||||
|
||||
Вызывается кнопкой установки и двойным щелчком по строке.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
**Со стороны родителя.** [Main](Main.md) задаёт `backend` и открывает окно вызовом
|
||||
`openCatalog()`. Обратной связи наружу через сигналы нет.
|
||||
|
||||
**Со стороны бэкенда.** Всё содержимое таблицы — привязка к `seasonalCatalog`, поэтому обновление
|
||||
каталога и изменение статуса установленной сборки перерисовывают окно сами. Пустое состояние
|
||||
различает три случая по `seasonalCatalogLoading` и `seasonalCatalogError`: идёт загрузка, сборок
|
||||
пока нет, произошла ошибка — её текст показывается прямо на месте строк, потому что пустой список
|
||||
и ошибка выглядят одинаково пустыми.
|
||||
|
||||
**Занятость.** Кнопка установки выключается по общему признаку `busy`, кнопка обновления списка —
|
||||
по `seasonalCatalogLoading` (её подпись при этом меняется на «Обновление…»). Политика закрытия
|
||||
окна завязана на `seasonalInstalling`.
|
||||
|
||||
**Обновление ревизии.** Если у записи выставлен `updateAvailable`, статус в таблице подсвечивается
|
||||
акцентным цветом, в подвале дописывается установленная ревизия, а кнопка установки называется
|
||||
«Обновить». Отдельного пути обновления нет — это тот же вызов `installSeasonalBuild()`.
|
||||
|
||||
**Ход установки.** Окно не показывает прогресс: за это отвечает плашка
|
||||
[ProgressPanel](ProgressPanel.md) в главном окне, а отмена — метод `cancelSeasonalInstall()`
|
||||
бэкенда.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
SeasonalBuildsDialog {
|
||||
id: seasonalDialog
|
||||
parent: Overlay.overlay
|
||||
backend: launcherBackend
|
||||
}
|
||||
|
||||
Button {
|
||||
text: qsTr("Сезонные сборки")
|
||||
onClicked: seasonalDialog.openCatalog()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,158 +0,0 @@
|
||||
# VersionPickerDialog
|
||||
|
||||
## Обзор компонента
|
||||
|
||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Каталог версий Mojang — это около
|
||||
тысячи записей: релизы, снапшоты, старые беты и альфы. Раньше выбор версии был обычным выпадающим
|
||||
списком на всю эту тысячу, и найти в нём, скажем, бету 1.7 можно было только поиском по точному
|
||||
номеру.
|
||||
|
||||
`VersionPickerDialog` заменил тот список отдельным окном: слева категории, справа сами версии с
|
||||
поиском сверху. Категории делят каталог на обозримые части, а поиск работает внутри выбранной.
|
||||
Помимо выбора окно умеет удалять уже скачанные версии — с предупреждением о последствиях, потому
|
||||
что версия весит десятки мегабайт, а поверх неё могут стоять профили модлоадеров.
|
||||
|
||||
Окно открывается из карточки сборки, когда пользователь выбирает, на какой версии Minecraft
|
||||
собирается играть.
|
||||
|
||||
## Место в проекте и зависимости
|
||||
|
||||
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
||||
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
||||
|
||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает у него свойства
|
||||
`versionCatalog` и `catalogLoading`, вызывает `refreshVersionCatalog()`, `versionRemovalInfo()` и
|
||||
`removeVersion()`.
|
||||
|
||||
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg` из
|
||||
`RESOURCES` того же модуля. Инстанцируется в [BuildsDialog](BuildsDialog.md).
|
||||
|
||||
Каталог приходит из C++ уже отсортированным (новые сверху), поэтому окно только отбирает записи и
|
||||
порядок не трогает. Каждая запись каталога — объект с полями `id` (идентификатор версии), `label`
|
||||
(подпись строки), `category` (ключ категории), `installed` (скачана ли) и `search`
|
||||
(предвычисленная строка для поиска в нижнем регистре).
|
||||
|
||||
## Иерархия и роль
|
||||
|
||||
Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
|
||||
щелчку мимо. Оформление задано вручную — тёмный фон со скруглением и акцентной рамкой.
|
||||
|
||||
Содержимое — `RowLayout` из двух частей, разделённых вертикальной линией: колонка категорий
|
||||
шириной 150 px (`Repeater` по `categories`) и область версий. В области версий сверху поле поиска,
|
||||
внизу флажок «Только установленные», между ними `ListView` со строками версий. Строка показывает
|
||||
подпись, галочку для установленной версии и корзину удаления; галочка рядом со строкой —
|
||||
единственное место, где видно, что именно скачано.
|
||||
|
||||
Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой. Он
|
||||
привязан к тому же родителю, что и само окно, и центрируется в нём вручную, чтобы не оказаться
|
||||
внутри области выбора.
|
||||
|
||||
Пустое состояние списка объясняется текстом по центру, который различает три случая: каталог ещё
|
||||
грузится, каталог пуст (нет соединения) и по фильтру ничего не найдено.
|
||||
|
||||
## Свойства
|
||||
|
||||
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||
|----------|-----|--------------|--------------|----------|
|
||||
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога версий и исполнитель удаления. |
|
||||
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной версии. Окно открывается с текущей версией, и по «Отмене» выбор возвращается к ней, потому что результат уходит наружу только через сигнал. Пустая строка — версия не выбрана, кнопка подтверждения выключена. |
|
||||
| `category` | `string` | `"release"` | Нет | Ключ активной категории. Допустимые значения: `release` (релизы), `snapshot` (снапшоты), `old_beta` (беты), `old_alpha` (альфы), `other` (прочие — сюда попадают в том числе установленные профили модлоадеров). |
|
||||
| `filterText` | `string` | `""` | Нет | Текст поиска. Сравнивается в нижнем регистре без учёта регистра с полем `search` записи каталога; поиск идёт внутри активной категории. |
|
||||
| `installedOnly` | `bool` | `false` | Нет | Показывать только скачанные версии. |
|
||||
| `categories` | `var` (только чтение) | список из пяти записей | Нет | Описание колонки категорий: массив объектов с полями `key` и переведённым `title`. Задаёт и порядок пунктов, и набор допустимых значений `category`. |
|
||||
| `visibleEntries` | `var` (только чтение) | вычисляется | Нет | Отобранные строки каталога: записи активной категории, прошедшие флажок «только установленные» и текст поиска. Порядок наследуется от каталога. Служит моделью списка. |
|
||||
|
||||
## Сигналы
|
||||
|
||||
#### versionChosen(string versionId)
|
||||
|
||||
Версия подтверждена — кнопкой «Выбрать», двойным щелчком по строке или клавишей Enter в поле
|
||||
поиска. В параметре приходит идентификатор версии.
|
||||
|
||||
Имя не `accepted()` сознательно: такой сигнал у `Dialog` уже есть и переопределить его нельзя.
|
||||
|
||||
Обработчик — карточка сборки — записывает выбранную версию в сборку. К моменту вызова обработчика
|
||||
окно уже закрыто. При отмене сигнал не испускается, поэтому снаружи ничего откатывать не нужно.
|
||||
|
||||
## Методы
|
||||
|
||||
#### openFor(string versionId) : void
|
||||
|
||||
Открывает окно на переданной версии. Запоминает её в `selectedId`, очищает поиск, переключается на
|
||||
категорию именно этой версии (а не всегда на релизы), просит бэкенд обновить каталог и
|
||||
прокручивает список к выбранной строке.
|
||||
|
||||
#### categoryOf(string versionId) : string
|
||||
|
||||
Возвращает ключ категории версии по каталогу; для неизвестной версии — `release`.
|
||||
|
||||
#### indexOfSelected() : int
|
||||
|
||||
Позиция выбранной версии в `visibleEntries` или `-1`, если под текущим фильтром её не видно.
|
||||
|
||||
#### revealSelected() : void
|
||||
|
||||
Выставляет текущий индекс списка на выбранную версию и прокручивает список так, чтобы строка
|
||||
оказалась по центру. Вызывается после каждой смены фильтра, категории или удаления.
|
||||
|
||||
#### acceptSelection() : void
|
||||
|
||||
Подтверждает выбор: испускает `versionChosen()` и закрывает окно. При пустом `selectedId` не
|
||||
делает ничего.
|
||||
|
||||
#### catalogHas(string versionId) : bool
|
||||
|
||||
Есть ли версия в каталоге. Нужен после удаления: профиль модлоадера присутствовал в каталоге
|
||||
только потому, что был установлен, и после удаления строка исчезает совсем.
|
||||
|
||||
#### askRemove(string versionId) : void
|
||||
|
||||
Спрашивает подтверждение перед удалением. Запрашивает у бэкенда `versionRemovalInfo()` и, если
|
||||
версия действительно установлена, наполняет окно подтверждения: занимаемый объём, список
|
||||
зависящих профилей модлоадеров и список сборок, которые эту версию используют. Идентификатор
|
||||
запоминается в самом окне подтверждения, потому что к моменту ответа строка под курсором может
|
||||
быть уже другой.
|
||||
|
||||
Окно подтверждения объясняет три вещи: файлы удалятся из `versions/` и освободится столько-то
|
||||
мегабайт; профили модлоадеров поверх этой версии без неё не запустятся; библиотеки и ресурсы в
|
||||
`libraries/` и `assets/` общие для всех версий и остаются на месте.
|
||||
|
||||
#### performRemove(string versionId) : void
|
||||
|
||||
Удаляет версию через бэкенд. Если удалена была именно выбранная версия и её больше нет в каталоге,
|
||||
снимает выбор. В конце обновляет позицию списка.
|
||||
|
||||
## Взаимодействие с другими компонентами
|
||||
|
||||
**Со стороны родителя.** [BuildsDialog](BuildsDialog.md) задаёт `backend`, открывает окно вызовом
|
||||
`openFor()` с текущей версией сборки и подписывается на `versionChosen()`, чтобы записать выбор.
|
||||
Никаких других точек входа у окна нет — прямая запись `selectedId` снаружи не предполагается.
|
||||
|
||||
**Со стороны бэкенда.** `versionCatalog` и `catalogLoading` — привязки, от которых зависят и
|
||||
модель списка, и текст пустого состояния: пришедшее обновление каталога пересчитывает
|
||||
`visibleEntries` само. `refreshVersionCatalog()` вызывается при открытии окна, `removeVersion()` —
|
||||
после подтверждения удаления.
|
||||
|
||||
**Клавиатура.** Фокус при открытии уходит в поле поиска. Стрелки вверх и вниз двигают выбор по
|
||||
списку функцией `step()`, Enter подтверждает выбор, Escape закрывает окно.
|
||||
|
||||
## Пример использования
|
||||
|
||||
```qml
|
||||
VersionPickerDialog {
|
||||
id: versionPicker
|
||||
parent: Overlay.overlay
|
||||
backend: launcherBackend
|
||||
onVersionChosen: (versionId) => buildCard.gameVersion = versionId
|
||||
}
|
||||
|
||||
Button {
|
||||
text: buildCard.gameVersion || qsTr("Выбрать версию")
|
||||
onClicked: versionPicker.openFor(buildCard.gameVersion)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,372 +0,0 @@
|
||||
#include "gamelauncher.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "javalocator.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QProcess>
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(Q_OS_WIN)
|
||||
constexpr QChar kClasspathSeparator = u';';
|
||||
#else
|
||||
constexpr QChar kClasspathSeparator = u':';
|
||||
#endif
|
||||
|
||||
QString libraryPath(const QString &gameDir, const MinecraftLibrary &library)
|
||||
{
|
||||
return gameDir + QStringLiteral("/libraries/") + library.path;
|
||||
}
|
||||
|
||||
QString nativeToken(const QString &path)
|
||||
{
|
||||
return QDir::toNativeSeparators(path);
|
||||
}
|
||||
|
||||
// Подставляет ${...}. Аргументы, в которых после подстановки остались
|
||||
// неизвестные плейсхолдеры, вызывающий выбрасывает.
|
||||
QString substitute(QString argument, const QHash<QString, QString> &variables)
|
||||
{
|
||||
for (auto it = variables.cbegin(); it != variables.cend(); ++it)
|
||||
argument.replace(QStringLiteral("${") + it.key() + u'}', it.value());
|
||||
return argument;
|
||||
}
|
||||
|
||||
bool isNativeLibraryFile(const QString &fileName)
|
||||
{
|
||||
static const QStringList suffixes = {QStringLiteral(".dll"),
|
||||
QStringLiteral(".so"),
|
||||
QStringLiteral(".dylib"),
|
||||
QStringLiteral(".jnilib")};
|
||||
for (const QString &suffix : suffixes)
|
||||
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
|
||||
return true;
|
||||
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GameLauncher::GameLauncher(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
bool GameLauncher::isRunning() const
|
||||
{
|
||||
return m_process && m_process->state() != QProcess::NotRunning;
|
||||
}
|
||||
|
||||
QStringList GameLauncher::missingFiles(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
int limit)
|
||||
{
|
||||
QStringList missing;
|
||||
const auto add = [&missing, limit](const QString &path) {
|
||||
if (missing.size() < limit)
|
||||
missing << QDir::toNativeSeparators(path);
|
||||
};
|
||||
|
||||
if (!QDir(options.gameDir).exists())
|
||||
return {QDir::toNativeSeparators(options.gameDir)};
|
||||
|
||||
if (!QFile::exists(version.clientJarPath))
|
||||
add(version.clientJarPath);
|
||||
|
||||
for (const MinecraftLibrary &library : version.libraries) {
|
||||
const QString path = libraryPath(options.gameDir, library);
|
||||
if (!QFile::exists(path))
|
||||
add(path);
|
||||
}
|
||||
|
||||
if (!version.assetIndexId.isEmpty()) {
|
||||
const QString index = options.gameDir + QStringLiteral("/assets/indexes/")
|
||||
+ version.assetIndexId + QStringLiteral(".json");
|
||||
if (!QFile::exists(index))
|
||||
add(index);
|
||||
else if (!QDir(options.gameDir + QStringLiteral("/assets/objects")).exists())
|
||||
add(options.gameDir + QStringLiteral("/assets/objects"));
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// -DignoreList у Forge 1.17+ — список префиксов имён файлов, которые
|
||||
// BootstrapLauncher не берёт в слой модулей. Сравнение идёт по началу имени.
|
||||
bool ignoreListCovers(const QString &ignoreListArgument, const QString &fileName)
|
||||
{
|
||||
const QString value = ignoreListArgument.mid(QStringLiteral("-DignoreList=").size());
|
||||
const QStringList prefixes = value.split(u',', Qt::SkipEmptyParts);
|
||||
for (const QString &prefix : prefixes) {
|
||||
if (!prefix.isEmpty() && fileName.startsWith(prefix))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList GameLauncher::buildArguments(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
const QString &nativesDir)
|
||||
{
|
||||
QStringList classpath;
|
||||
for (const MinecraftLibrary &library : version.libraries) {
|
||||
if (library.native)
|
||||
continue; // натив-библиотеки только распаковываются
|
||||
classpath << nativeToken(libraryPath(options.gameDir, library));
|
||||
}
|
||||
classpath << nativeToken(version.clientJarPath);
|
||||
|
||||
const QString assetsRoot = options.gameDir + QStringLiteral("/assets");
|
||||
const bool legacyAssets = version.assetsKind == QLatin1String("legacy")
|
||||
|| version.assetsKind == QLatin1String("pre-1.6");
|
||||
|
||||
QHash<QString, QString> variables;
|
||||
variables[QStringLiteral("auth_player_name")] = options.playerName;
|
||||
variables[QStringLiteral("auth_uuid")] = options.uuid;
|
||||
variables[QStringLiteral("auth_access_token")] = options.accessToken;
|
||||
variables[QStringLiteral("auth_session")] =
|
||||
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid);
|
||||
variables[QStringLiteral("auth_xuid")] =
|
||||
options.xuid.isEmpty() ? QStringLiteral("0") : options.xuid;
|
||||
variables[QStringLiteral("clientid")] =
|
||||
options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken;
|
||||
variables[QStringLiteral("user_type")] = options.userType;
|
||||
variables[QStringLiteral("user_properties")] = QStringLiteral("{}");
|
||||
variables[QStringLiteral("version_name")] = version.id;
|
||||
variables[QStringLiteral("version_type")] = version.type;
|
||||
variables[QStringLiteral("game_directory")] = nativeToken(options.gameDir);
|
||||
variables[QStringLiteral("assets_root")] = nativeToken(assetsRoot);
|
||||
variables[QStringLiteral("assets_index_name")] = version.assetIndexId;
|
||||
variables[QStringLiteral("game_assets")] =
|
||||
nativeToken(legacyAssets ? assetsRoot + QStringLiteral("/virtual/legacy") : assetsRoot);
|
||||
variables[QStringLiteral("natives_directory")] = nativeToken(nativesDir);
|
||||
variables[QStringLiteral("library_directory")] =
|
||||
nativeToken(options.gameDir + QStringLiteral("/libraries"));
|
||||
variables[QStringLiteral("classpath")] = classpath.join(kClasspathSeparator);
|
||||
variables[QStringLiteral("classpath_separator")] = QString(kClasspathSeparator);
|
||||
variables[QStringLiteral("primary_jar")] = nativeToken(version.clientJarPath);
|
||||
variables[QStringLiteral("launcher_name")] = options.launcherName;
|
||||
variables[QStringLiteral("launcher_version")] = options.launcherVersion;
|
||||
variables[QStringLiteral("resolution_width")] = QString::number(options.windowWidth);
|
||||
variables[QStringLiteral("resolution_height")] = QString::number(options.windowHeight);
|
||||
|
||||
QStringList arguments;
|
||||
|
||||
// Свои jvm-аргументы идут первыми — как в строке запуска из примера.
|
||||
if (!options.authlibInjectorPath.isEmpty()) {
|
||||
arguments << QStringLiteral("-javaagent:%1=%2")
|
||||
.arg(nativeToken(options.authlibInjectorPath), options.authlibInjectorApi)
|
||||
<< QStringLiteral("-Dauthlibinjector.side=client");
|
||||
}
|
||||
if (options.minMemoryMb > 0)
|
||||
arguments << QStringLiteral("-Xms%1M").arg(options.minMemoryMb);
|
||||
if (options.maxMemoryMb > 0)
|
||||
arguments << QStringLiteral("-Xmx%1M").arg(options.maxMemoryMb);
|
||||
arguments += options.extraJvmArgs;
|
||||
|
||||
// Forge 1.17+ собирает слой модулей из classpath, пропуская всё, чьё имя
|
||||
// начинается с записи в -DignoreList. Ванильный клиентский jar записан там
|
||||
// как ${version_name}.jar — с расчётом на лаунчер Mojang, который кладёт в
|
||||
// classpath jar самого профиля. Своего jar у профиля Forge нет, мы кладём
|
||||
// родительский, и без его имени в списке он становится вторым модулем с
|
||||
// классами Minecraft: «Modules minecraft and _1._20._1 export package ...».
|
||||
const QString clientJarName = QFileInfo(version.clientJarPath).fileName();
|
||||
|
||||
for (const QString &argument : version.jvmArgs) {
|
||||
QString resolved = substitute(argument, variables);
|
||||
if (resolved.contains(QStringLiteral("${")))
|
||||
continue;
|
||||
if (!clientJarName.isEmpty() && resolved.startsWith(QLatin1String("-DignoreList="))
|
||||
&& !ignoreListCovers(resolved, clientJarName))
|
||||
resolved += u',' + clientJarName;
|
||||
arguments << resolved;
|
||||
}
|
||||
|
||||
if (!version.loggingArgument.isEmpty() && QFile::exists(version.loggingConfigPath)) {
|
||||
QHash<QString, QString> loggingVariable;
|
||||
loggingVariable[QStringLiteral("path")] = nativeToken(version.loggingConfigPath);
|
||||
arguments << substitute(version.loggingArgument, loggingVariable);
|
||||
}
|
||||
|
||||
arguments << version.mainClass;
|
||||
|
||||
for (const QString &argument : version.gameArgs) {
|
||||
const QString resolved = substitute(argument, variables);
|
||||
if (!resolved.contains(QStringLiteral("${")))
|
||||
arguments << resolved;
|
||||
}
|
||||
|
||||
// Разрешение окна: у старых версий фич-флага нет, аргументы всё равно понимаются.
|
||||
if (options.windowWidth > 0 && options.windowHeight > 0
|
||||
&& !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) {
|
||||
arguments << QStringLiteral("--width") << QString::number(options.windowWidth)
|
||||
<< QStringLiteral("--height") << QString::number(options.windowHeight);
|
||||
}
|
||||
if (options.fullscreen && !arguments.contains(QStringLiteral("--fullscreen")))
|
||||
arguments << QStringLiteral("--fullscreen");
|
||||
|
||||
if (!options.serverAddress.isEmpty()) {
|
||||
if (version.supportsQuickPlay) {
|
||||
arguments << QStringLiteral("--quickPlayMultiplayer") << options.serverAddress;
|
||||
} else {
|
||||
const int colon = options.serverAddress.lastIndexOf(u':');
|
||||
const QString host = colon > 0 ? options.serverAddress.left(colon) : options.serverAddress;
|
||||
const QString port = colon > 0 ? options.serverAddress.mid(colon + 1)
|
||||
: QStringLiteral("25565");
|
||||
arguments << QStringLiteral("--server") << host << QStringLiteral("--port") << port;
|
||||
}
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
bool GameLauncher::extractNatives(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
const QString &nativesDir,
|
||||
QString *error)
|
||||
{
|
||||
if (!QDir().mkpath(nativesDir)) {
|
||||
if (error)
|
||||
*error = Loc::text("launch.error.createFolderFailed").arg(QDir::toNativeSeparators(nativesDir));
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const MinecraftLibrary &library : version.libraries) {
|
||||
if (!library.native)
|
||||
continue;
|
||||
|
||||
const QString jar = libraryPath(options.gameDir, library);
|
||||
QZipReader reader(jar);
|
||||
if (!reader.isReadable()) {
|
||||
if (error)
|
||||
*error = Loc::text("launch.error.nativeReadFailed")
|
||||
.arg(QDir::toNativeSeparators(jar));
|
||||
return false;
|
||||
}
|
||||
|
||||
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||
for (const QZipReader::FileInfo &entry : entries) {
|
||||
if (!entry.isFile)
|
||||
continue;
|
||||
if (entry.filePath.startsWith(QStringLiteral("META-INF/")))
|
||||
continue;
|
||||
|
||||
bool excluded = false;
|
||||
for (const QString &prefix : library.extractExclude) {
|
||||
if (entry.filePath.startsWith(prefix)) {
|
||||
excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (excluded)
|
||||
continue;
|
||||
|
||||
// java.library.path ищет библиотеки только в корне папки, поэтому
|
||||
// складываем их плоско, без структуры каталогов внутри jar.
|
||||
const QString fileName = QFileInfo(entry.filePath).fileName();
|
||||
if (!isNativeLibraryFile(fileName))
|
||||
continue;
|
||||
|
||||
const QString target = nativesDir + u'/' + fileName;
|
||||
const QByteArray data = reader.fileData(entry.filePath);
|
||||
if (QFileInfo(target).size() == data.size() && QFile::exists(target))
|
||||
continue; // уже распаковано этим же лаунчером
|
||||
|
||||
QFile file(target);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||
if (error)
|
||||
*error = Loc::text("launch.error.extractFailed")
|
||||
.arg(fileName, QDir::toNativeSeparators(nativesDir));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GameLauncher::launch(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
QString *error)
|
||||
{
|
||||
if (isRunning()) {
|
||||
if (error)
|
||||
*error = Loc::text("common.error.gameRunning");
|
||||
return false;
|
||||
}
|
||||
|
||||
emit progress(Loc::text("launch.progress.checkingFiles"));
|
||||
const QStringList missing = missingFiles(options, version);
|
||||
if (!missing.isEmpty()) {
|
||||
if (error)
|
||||
*error = Loc::text("launch.error.filesMissing")
|
||||
.arg(missing.size())
|
||||
.arg(missing.join(u'\n'));
|
||||
return false;
|
||||
}
|
||||
|
||||
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(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;
|
||||
|
||||
const QStringList arguments = buildArguments(options, version, nativesDir);
|
||||
const QString executable = JavaLocator::windowlessVariant(java);
|
||||
|
||||
m_process = new QProcess(this);
|
||||
m_process->setProgram(executable);
|
||||
m_process->setArguments(arguments);
|
||||
m_process->setWorkingDirectory(options.gameDir);
|
||||
m_process->setProcessChannelMode(QProcess::MergedChannels);
|
||||
|
||||
connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() {
|
||||
const QString chunk = QString::fromLocal8Bit(m_process->readAllStandardOutput());
|
||||
const QStringList lines = chunk.split(u'\n', Qt::SkipEmptyParts);
|
||||
for (const QString &line : lines)
|
||||
emit output(line.trimmed());
|
||||
});
|
||||
connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
|
||||
emit gameFinished(exitCode, status == QProcess::CrashExit);
|
||||
m_process->deleteLater();
|
||||
m_process = nullptr;
|
||||
});
|
||||
// Токен в лог не попадает — маскируем его в отображаемой строке запуска.
|
||||
QStringList shown = arguments;
|
||||
const int tokenIndex = shown.indexOf(QStringLiteral("--accessToken"));
|
||||
if (tokenIndex >= 0 && tokenIndex + 1 < shown.size())
|
||||
shown[tokenIndex + 1] = QStringLiteral("<скрыт>");
|
||||
|
||||
m_process->start();
|
||||
if (!m_process->waitForStarted(15000)) {
|
||||
if (error)
|
||||
*error = Loc::text("launch.error.startFailed")
|
||||
.arg(QDir::toNativeSeparators(executable), m_process->errorString());
|
||||
m_process->deleteLater();
|
||||
m_process = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
emit gameStarted(QDir::toNativeSeparators(executable) + QStringLiteral(" ")
|
||||
+ shown.join(QStringLiteral(" ")));
|
||||
return true;
|
||||
}
|
||||
|
||||
void GameLauncher::terminate()
|
||||
{
|
||||
if (isRunning())
|
||||
m_process->kill();
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
class QProcess;
|
||||
|
||||
// Всё, что лаунчер знает к моменту нажатия PLAY.
|
||||
struct LaunchOptions
|
||||
{
|
||||
QString gameDir; // .minecraft
|
||||
QString versionId; // Папка в versions
|
||||
|
||||
QString playerName;
|
||||
QString uuid;
|
||||
QString accessToken;
|
||||
QString userType;
|
||||
QString clientToken;
|
||||
QString xuid; // Пусто — подставится "0", как в офлайне
|
||||
|
||||
QString javaPath; // Пусто — ищем сами
|
||||
int minMemoryMb = 512;
|
||||
int maxMemoryMb = 4096;
|
||||
QStringList extraJvmArgs;
|
||||
|
||||
int windowWidth = 0; // 0 — не передавать --width/--height
|
||||
int windowHeight = 0;
|
||||
bool fullscreen = false;
|
||||
|
||||
QString serverAddress; // host[:port] — автозаход на сервер
|
||||
QString authlibInjectorPath; // Пусто — не подключаем
|
||||
QString authlibInjectorApi = QStringLiteral("ely.by");
|
||||
|
||||
QString launcherName = QStringLiteral("KishkaLauncher");
|
||||
QString launcherVersion = QStringLiteral("1.0");
|
||||
};
|
||||
|
||||
// Готовит и запускает JVM. Один экземпляр — одна игра одновременно.
|
||||
class GameLauncher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit GameLauncher(QObject *parent = nullptr);
|
||||
|
||||
bool isRunning() const;
|
||||
|
||||
// Проверяет .minecraft на комплектность. Пустой список — всё на месте.
|
||||
// Возвращает описания недостающих файлов, но не больше limit штук.
|
||||
static QStringList missingFiles(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
int limit = 12);
|
||||
|
||||
// Собирает аргументы ровно в том порядке, в котором их ждёт JVM:
|
||||
// <jvm-аргументы> <mainClass> <аргументы игры>.
|
||||
static QStringList buildArguments(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
const QString &nativesDir);
|
||||
|
||||
// Распаковывает .dll/.so/.dylib из натив-библиотек в <версия>/natives.
|
||||
static bool extractNatives(const LaunchOptions &options,
|
||||
const MinecraftVersion &version,
|
||||
const QString &nativesDir,
|
||||
QString *error);
|
||||
|
||||
// Полный цикл: проверка, натив-библиотеки, поиск java, старт процесса.
|
||||
bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString *error);
|
||||
|
||||
void terminate();
|
||||
|
||||
signals:
|
||||
void progress(const QString &message);
|
||||
void output(const QString &line);
|
||||
void gameStarted(const QString &commandLine);
|
||||
void gameFinished(int exitCode, bool crashed);
|
||||
|
||||
private:
|
||||
QProcess *m_process = nullptr;
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #637a10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-2" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||
<path class="cls-1" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 743 B |
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #232323;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-2" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||
<path class="cls-1" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 743 B |
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #232323;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||
<path class="cls-2" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 743 B |
|
Before Width: | Height: | Size: 2.2 MiB |
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #232323;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-2" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||
<g>
|
||||
<path class="cls-1" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
<path class="cls-1" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
<path class="cls-1" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 978 B |
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #637a10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-2" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||
<g>
|
||||
<path class="cls-1" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
<path class="cls-1" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
<path class="cls-1" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 978 B |
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #232323;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||
<g>
|
||||
<path class="cls-2" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
<path class="cls-2" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
<path class="cls-2" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 978 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 255 B |
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 170">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #637a10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-2" width="335" height="170" rx="10.7" ry="10.7"/>
|
||||
<g>
|
||||
<path class="cls-1" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
|
||||
<path class="cls-1" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
|
||||
<path class="cls-1" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
|
||||
<path class="cls-1" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 170">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #91b315;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-2" width="335" height="170" rx="10.7" ry="10.7"/>
|
||||
<g>
|
||||
<path class="cls-1" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
|
||||
<path class="cls-1" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
|
||||
<path class="cls-1" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
|
||||
<path class="cls-1" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 170">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #91b315;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="335" height="170" rx="10.7" ry="10.7"/>
|
||||
<g>
|
||||
<path class="cls-2" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
|
||||
<path class="cls-2" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
|
||||
<path class="cls-2" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
|
||||
<path class="cls-2" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 443 B |
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11.29 6.05">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<polyline class="cls-1" points="10.7 .59 5.65 5.46 .59 .59"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 463 B |
|
Before Width: | Height: | Size: 455 B |
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11.29 6.05">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<polyline class="cls-1" points=".59 5.46 5.65 .59 10.7 5.46"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 464 B |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.83 45">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #637a10;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="118.83" height="45" rx="5.27" ry="5.27"/>
|
||||
<polyline class="cls-2" points="15.36 24.93 20.41 20.07 25.47 24.93"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 618 B |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.83 45">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #232323;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="118.83" height="45" rx="5.27" ry="5.27"/>
|
||||
<polyline class="cls-2" points="15.36 24.93 20.41 20.07 25.47 24.93"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 618 B |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.83 45">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #232323;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="118.83" height="45" rx="5.27" ry="5.27"/>
|
||||
<polyline class="cls-2" points="15.36 20.07 20.41 24.93 25.47 20.07"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 618 B |
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3.41 39.95">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e6e6e6;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" x="0" y="0" width="3.41" height="39.95" rx="1.56" ry="1.56"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 395 B |
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3.41 161.66">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #637a10;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" x="0" y="0" width="3.41" height="161.66" rx="1.56" ry="1.56"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 374 B |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 45">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #232323;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="210" height="45" rx="5.24" ry="5.24"/>
|
||||
<polyline class="cls-2" points="16.58 20.07 21.63 24.93 26.69 20.07"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 612 B |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 45">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #637a10;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="210" height="45" rx="5.24" ry="5.24"/>
|
||||
<polyline class="cls-2" points="16.58 24.93 21.63 20.07 26.69 24.93"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 612 B |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 45">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #232323;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #e6e6e6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.18px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect class="cls-1" width="210" height="45" rx="5.24" ry="5.24"/>
|
||||
<polyline class="cls-2" points="16.58 24.93 21.63 20.07 26.69 24.93"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 612 B |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,784 +0,0 @@
|
||||
#include "javainstaller.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QProcess>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
// Читатель архива вместе с разобранным оглавлением: fileInfoList() каждый раз
|
||||
// заново разбирает центральный каталог zip, а записей в JDK десятки тысяч.
|
||||
struct ZipExtraction
|
||||
{
|
||||
explicit ZipExtraction(const QString &path)
|
||||
: reader(path)
|
||||
{
|
||||
}
|
||||
|
||||
QZipReader reader;
|
||||
QList<QZipReader::FileInfo> entries;
|
||||
int index = 0;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kTransferTimeoutMs = 30000;
|
||||
constexpr int kMaxParallel = 4;
|
||||
constexpr int kMaxAttempts = 3;
|
||||
constexpr int kProgressIntervalMs = 100;
|
||||
|
||||
// Записей zip за один проход таймера. Сотня файлов по паре мегабайт — это
|
||||
// заметная, но ещё не видимая глазом задержка кадра.
|
||||
constexpr int kZipChunk = 24;
|
||||
|
||||
bool isTransient(QNetworkReply::NetworkError error, int status)
|
||||
{
|
||||
if (status >= 500)
|
||||
return true;
|
||||
switch (error) {
|
||||
case QNetworkReply::TimeoutError:
|
||||
case QNetworkReply::TemporaryNetworkFailureError:
|
||||
case QNetworkReply::NetworkSessionFailedError:
|
||||
case QNetworkReply::ProxyTimeoutError:
|
||||
case QNetworkReply::UnknownNetworkError:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Путь из манифеста или архива не должен уводить за пределы папки сборки:
|
||||
// «../../» в имени записи — известный способ подменить чужой файл.
|
||||
bool isInside(const QString &root, const QString &path)
|
||||
{
|
||||
const QString normalizedRoot = QDir::cleanPath(root) + u'/';
|
||||
const QString normalized = QDir::cleanPath(path);
|
||||
return normalized.startsWith(normalizedRoot);
|
||||
}
|
||||
|
||||
void makeExecutable(const QString &path)
|
||||
{
|
||||
const QFileDevice::Permissions current = QFile::permissions(path);
|
||||
QFile::setPermissions(path,
|
||||
current | QFileDevice::ExeOwner | QFileDevice::ExeGroup
|
||||
| QFileDevice::ExeOther);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JavaInstaller::JavaInstaller(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
, m_extractTimer(new QTimer(this))
|
||||
, m_progressTimer(new QTimer(this))
|
||||
{
|
||||
m_extractTimer->setInterval(0);
|
||||
connect(m_extractTimer, &QTimer::timeout, this, &JavaInstaller::extractZipChunk);
|
||||
|
||||
m_progressTimer->setInterval(kProgressIntervalMs);
|
||||
connect(m_progressTimer, &QTimer::timeout, this, &JavaInstaller::flushProgress);
|
||||
}
|
||||
|
||||
JavaInstaller::~JavaInstaller() = default;
|
||||
|
||||
qint64 JavaInstaller::bytesDone() const
|
||||
{
|
||||
qint64 total = m_bytesSettled;
|
||||
for (const auto &context : m_active)
|
||||
total += context->received;
|
||||
return total;
|
||||
}
|
||||
|
||||
double JavaInstaller::fraction() const
|
||||
{
|
||||
if (m_bytesTotal > 0)
|
||||
return qBound(0.0, double(bytesDone()) / double(m_bytesTotal), 1.0);
|
||||
if (m_stepsTotal > 0)
|
||||
return qBound(0.0, double(m_stepsDone) / double(m_stepsTotal), 1.0);
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
void JavaInstaller::install(const JavaRuntimeEntry &entry)
|
||||
{
|
||||
if (m_running)
|
||||
return;
|
||||
if (!entry.isValid()) {
|
||||
emit failed(entry.version, Loc::text("java.error.buildUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString error;
|
||||
if (!LauncherPaths::ensureRootExists(&error)) {
|
||||
emit failed(entry.version, Loc::text("java.error.launcherDirUnavailable").arg(error));
|
||||
return;
|
||||
}
|
||||
|
||||
m_entry = entry;
|
||||
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, Loc::text("java.error.invalidBuildId"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Переустановка поверх недокачанной прошлой попытки: старое дерево мешает
|
||||
// и размером, и тем, что locateBinary нашла бы в нём чужой java.
|
||||
if (!JavaRuntimeStore::remove(entry.id, &error)) {
|
||||
emit failed(m_label, error);
|
||||
return;
|
||||
}
|
||||
if (!QDir().mkpath(m_targetDir)) {
|
||||
emit failed(m_label, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(m_targetDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
m_running = true;
|
||||
m_canceling = false;
|
||||
m_bytesSettled = 0;
|
||||
m_bytesTotal = 0;
|
||||
m_stepsDone = 0;
|
||||
m_stepsTotal = 0;
|
||||
m_currentFile.clear();
|
||||
m_links.clear();
|
||||
m_queue.clear();
|
||||
|
||||
emit started(m_label);
|
||||
if (entry.archive == QLatin1String("mojang"))
|
||||
beginMojang();
|
||||
else
|
||||
beginArchive();
|
||||
}
|
||||
|
||||
void JavaInstaller::cancel()
|
||||
{
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
m_canceling = true;
|
||||
m_extractTimer->stop();
|
||||
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
for (const auto &context : std::as_const(m_active))
|
||||
context->reply->abort();
|
||||
if (m_tar)
|
||||
m_tar->kill();
|
||||
|
||||
// Прерванная установка оставляет дерево наполовину собранным: запускать из
|
||||
// него нечего, а locateBinary могла бы найти в нём java.
|
||||
if (m_active.isEmpty() && !m_reply && !m_tar) {
|
||||
const QString label = m_label;
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit canceled(label);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Temurin: один архив ─────────────────────────────────────────────────────
|
||||
|
||||
void JavaInstaller::beginArchive()
|
||||
{
|
||||
if (m_entry.archive != QLatin1String("zip")
|
||||
&& m_entry.archive != QLatin1String("tar.gz")) {
|
||||
fail(Loc::text("java.error.unknownArchiveFormat").arg(m_entry.archive));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString name = m_entry.url.fileName();
|
||||
m_archivePath = LauncherPaths::javaDownloadDir() + u'/'
|
||||
+ (name.isEmpty() ? m_entry.id + QStringLiteral(".archive") : name);
|
||||
m_bytesTotal = m_entry.size;
|
||||
setStage(Loc::text("java.progress.downloadingBuild").arg(m_entry.version));
|
||||
downloadArchive();
|
||||
}
|
||||
|
||||
void JavaInstaller::downloadArchive()
|
||||
{
|
||||
m_archiveFile = std::make_unique<QSaveFile>(m_archivePath);
|
||||
if (!m_archiveFile->open(QIODevice::WriteOnly)) {
|
||||
fail(Loc::text("common.error.openForWriteFailed").arg(QDir::toNativeSeparators(m_archivePath)));
|
||||
return;
|
||||
}
|
||||
m_archiveHash.reset();
|
||||
m_currentFile = QFileInfo(m_archivePath).fileName();
|
||||
|
||||
QNetworkRequest request{m_entry.url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
m_reply = m_network->get(request);
|
||||
connect(m_reply, &QNetworkReply::readyRead, this, [this] {
|
||||
const QByteArray chunk = m_reply->readAll();
|
||||
if (chunk.isEmpty())
|
||||
return;
|
||||
m_archiveHash.addData(chunk);
|
||||
if (m_archiveFile->write(chunk) != chunk.size())
|
||||
m_reply->abort();
|
||||
});
|
||||
connect(m_reply, &QNetworkReply::downloadProgress, this,
|
||||
[this](qint64 received, qint64 total) {
|
||||
m_bytesSettled = received;
|
||||
if (m_bytesTotal <= 0 && total > 0)
|
||||
m_bytesTotal = total;
|
||||
scheduleProgress();
|
||||
});
|
||||
connect(m_reply, &QNetworkReply::finished, this,
|
||||
[this] { handleArchiveFinished(m_reply); });
|
||||
|
||||
scheduleProgress();
|
||||
}
|
||||
|
||||
void JavaInstaller::handleArchiveFinished(QNetworkReply *reply)
|
||||
{
|
||||
reply->deleteLater();
|
||||
m_reply = nullptr;
|
||||
|
||||
if (m_canceling) {
|
||||
m_archiveFile->cancelWriting();
|
||||
m_archiveFile.reset();
|
||||
const QString label = m_label;
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit canceled(label);
|
||||
return;
|
||||
}
|
||||
if (!m_running)
|
||||
return;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
m_archiveFile->cancelWriting();
|
||||
m_archiveFile.reset();
|
||||
fail(Loc::text("java.error.downloadFailed").arg(m_entry.version, reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_entry.checksum.isEmpty()) {
|
||||
const QString actual = QString::fromLatin1(m_archiveHash.result().toHex());
|
||||
if (actual.compare(m_entry.checksum, Qt::CaseInsensitive) != 0) {
|
||||
m_archiveFile->cancelWriting();
|
||||
m_archiveFile.reset();
|
||||
fail(Loc::text("java.error.archiveChecksum"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_archiveFile->commit()) {
|
||||
m_archiveFile.reset();
|
||||
fail(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(m_archivePath)));
|
||||
return;
|
||||
}
|
||||
m_archiveFile.reset();
|
||||
|
||||
startExtraction();
|
||||
}
|
||||
|
||||
void JavaInstaller::startExtraction()
|
||||
{
|
||||
// Байты кончились, дальше считаем шаги: панель по нулевому итогу перестанет
|
||||
// показывать мегабайты, а проценты продолжит.
|
||||
m_bytesTotal = 0;
|
||||
m_bytesSettled = 0;
|
||||
setStage(Loc::text("java.progress.extracting").arg(m_entry.version));
|
||||
|
||||
if (m_entry.archive == QLatin1String("tar.gz")) {
|
||||
startTarExtraction();
|
||||
return;
|
||||
}
|
||||
|
||||
m_zip = std::make_unique<ZipExtraction>(m_archivePath);
|
||||
if (!m_zip->reader.isReadable()) {
|
||||
m_zip.reset();
|
||||
fail(Loc::text("java.error.archiveReadFailed").arg(QDir::toNativeSeparators(m_archivePath)));
|
||||
return;
|
||||
}
|
||||
|
||||
m_zip->entries = m_zip->reader.fileInfoList();
|
||||
m_stepsDone = 0;
|
||||
m_stepsTotal = m_zip->entries.size();
|
||||
m_extractTimer->start();
|
||||
}
|
||||
|
||||
void JavaInstaller::extractZipChunk()
|
||||
{
|
||||
if (m_canceling || !m_running || !m_zip) {
|
||||
m_extractTimer->stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<QZipReader::FileInfo> &entries = m_zip->entries;
|
||||
const int end = qMin(m_zip->index + kZipChunk, entries.size());
|
||||
for (; m_zip->index < end; ++m_zip->index) {
|
||||
const QZipReader::FileInfo &info = entries.at(m_zip->index);
|
||||
const QString target = m_targetDir + u'/' + info.filePath;
|
||||
if (!isInside(m_targetDir, target)) {
|
||||
m_extractTimer->stop();
|
||||
fail(Loc::text("java.error.archiveBadPath").arg(info.filePath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.isDir) {
|
||||
QDir().mkpath(target);
|
||||
continue;
|
||||
}
|
||||
// Ссылок в zip-сборках Temurin не бывает — под Unix они отдают tar.gz.
|
||||
if (!info.isFile)
|
||||
continue;
|
||||
|
||||
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||
m_extractTimer->stop();
|
||||
fail(Loc::text("common.error.createFailed")
|
||||
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray data = m_zip->reader.fileData(info.filePath);
|
||||
QFile file(target);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||
m_extractTimer->stop();
|
||||
fail(Loc::text("java.error.extractEntryFailed").arg(info.filePath));
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
if (info.permissions != QFileDevice::Permissions())
|
||||
file.setPermissions(info.permissions);
|
||||
}
|
||||
|
||||
m_stepsDone = m_zip->index;
|
||||
m_currentFile = m_zip->index > 0 ? entries.at(m_zip->index - 1).filePath : QString();
|
||||
scheduleProgress();
|
||||
|
||||
if (m_zip->index >= entries.size()) {
|
||||
m_extractTimer->stop();
|
||||
m_zip.reset();
|
||||
complete();
|
||||
}
|
||||
}
|
||||
|
||||
void JavaInstaller::startTarExtraction()
|
||||
{
|
||||
// tar есть в любой macOS и практически в любом дистрибутиве Linux; своего
|
||||
// распаковщика gzip-tar в Qt нет, а тянуть ради него зависимость незачем.
|
||||
m_tar = new QProcess(this);
|
||||
m_tar->setWorkingDirectory(m_targetDir);
|
||||
m_tar->setProgram(QStringLiteral("tar"));
|
||||
m_tar->setArguments({QStringLiteral("-xzf"), m_archivePath,
|
||||
QStringLiteral("-C"), m_targetDir});
|
||||
|
||||
connect(m_tar, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
|
||||
if (!m_tar)
|
||||
return;
|
||||
const QString message = m_tar->errorString();
|
||||
m_tar->deleteLater();
|
||||
m_tar = nullptr;
|
||||
if (m_canceling) {
|
||||
const QString label = m_label;
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit canceled(label);
|
||||
return;
|
||||
}
|
||||
fail(Loc::text("java.error.extractFailed").arg(message));
|
||||
});
|
||||
|
||||
connect(m_tar, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
|
||||
if (!m_tar)
|
||||
return;
|
||||
const QByteArray errorOutput = m_tar->readAllStandardError();
|
||||
m_tar->deleteLater();
|
||||
m_tar = nullptr;
|
||||
|
||||
if (m_canceling) {
|
||||
const QString label = m_label;
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit canceled(label);
|
||||
return;
|
||||
}
|
||||
if (status != QProcess::NormalExit || exitCode != 0) {
|
||||
fail(Loc::text("java.error.extractCrashed")
|
||||
.arg(QString::fromLocal8Bit(errorOutput).trimmed()));
|
||||
return;
|
||||
}
|
||||
complete();
|
||||
});
|
||||
|
||||
m_tar->start();
|
||||
}
|
||||
|
||||
// ── Mojang: дерево файлов ───────────────────────────────────────────────────
|
||||
|
||||
void JavaInstaller::beginMojang()
|
||||
{
|
||||
setStage(Loc::text("java.progress.fileList").arg(m_entry.version));
|
||||
m_currentFile = QStringLiteral("manifest.json");
|
||||
|
||||
QNetworkRequest request{m_entry.url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
m_reply = m_network->get(request);
|
||||
connect(m_reply, &QNetworkReply::finished, this, [this] {
|
||||
QNetworkReply *reply = m_reply;
|
||||
reply->deleteLater();
|
||||
m_reply = nullptr;
|
||||
|
||||
if (m_canceling) {
|
||||
const QString label = m_label;
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit canceled(label);
|
||||
return;
|
||||
}
|
||||
if (!m_running)
|
||||
return;
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
fail(Loc::text("java.error.manifestFetchFailed").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
handleManifest(reply->readAll());
|
||||
});
|
||||
}
|
||||
|
||||
void JavaInstaller::handleManifest(const QByteArray &payload)
|
||||
{
|
||||
const QJsonObject files = QJsonDocument::fromJson(payload)
|
||||
.object()
|
||||
.value(QStringLiteral("files"))
|
||||
.toObject();
|
||||
if (files.isEmpty()) {
|
||||
fail(Loc::text("java.error.manifestUnparsable"));
|
||||
return;
|
||||
}
|
||||
if (!prepareTree(files))
|
||||
return;
|
||||
|
||||
setStage(Loc::text("java.progress.downloadingBuild").arg(m_entry.version));
|
||||
if (m_queue.isEmpty()) {
|
||||
if (applyLinks())
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
pump();
|
||||
}
|
||||
|
||||
bool JavaInstaller::prepareTree(const QJsonObject &files)
|
||||
{
|
||||
m_bytesTotal = 0;
|
||||
|
||||
for (auto it = files.constBegin(); it != files.constEnd(); ++it) {
|
||||
const QString target = m_targetDir + u'/' + it.key();
|
||||
if (!isInside(m_targetDir, target)) {
|
||||
fail(Loc::text("java.error.manifestBadPath").arg(it.key()));
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject node = it.value().toObject();
|
||||
const QString type = node.value(QStringLiteral("type")).toString();
|
||||
|
||||
if (type == QLatin1String("directory")) {
|
||||
if (!QDir().mkpath(target)) {
|
||||
fail(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(target)));
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == QLatin1String("link")) {
|
||||
// Ссылки создаём в конце: их цели ещё не скачаны.
|
||||
m_links << qMakePair(target, node.value(QStringLiteral("target")).toString());
|
||||
continue;
|
||||
}
|
||||
if (type != QLatin1String("file"))
|
||||
continue;
|
||||
|
||||
const QJsonObject raw = node.value(QStringLiteral("downloads"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("raw"))
|
||||
.toObject();
|
||||
const QUrl url(raw.value(QStringLiteral("url")).toString());
|
||||
if (url.scheme() != QLatin1String("https"))
|
||||
continue;
|
||||
|
||||
JavaFileTask task;
|
||||
task.url = url;
|
||||
task.path = target;
|
||||
task.sha1 = raw.value(QStringLiteral("sha1")).toString();
|
||||
task.size = static_cast<qint64>(raw.value(QStringLiteral("size")).toDouble());
|
||||
task.executable = node.value(QStringLiteral("executable")).toBool();
|
||||
|
||||
m_bytesTotal += task.size;
|
||||
m_queue.enqueue(task);
|
||||
}
|
||||
|
||||
m_stepsTotal = m_queue.size();
|
||||
m_stepsDone = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void JavaInstaller::pump()
|
||||
{
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
while (m_active.size() < kMaxParallel && !m_queue.isEmpty())
|
||||
startTask(m_queue.dequeue());
|
||||
|
||||
if (m_active.isEmpty() && m_queue.isEmpty()) {
|
||||
if (applyLinks())
|
||||
complete();
|
||||
}
|
||||
}
|
||||
|
||||
void JavaInstaller::startTask(JavaFileTask task)
|
||||
{
|
||||
const QFileInfo info(task.path);
|
||||
if (!QDir().mkpath(info.absolutePath())) {
|
||||
fail(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(info.absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
auto context = std::make_shared<JavaActiveDownload>();
|
||||
context->task = task;
|
||||
context->file = std::make_unique<QSaveFile>(task.path);
|
||||
if (!context->file->open(QIODevice::WriteOnly)) {
|
||||
fail(Loc::text("common.error.openForWriteFailed").arg(QDir::toNativeSeparators(task.path)));
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest request{task.url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
context->reply = m_network->get(request);
|
||||
m_active.append(context);
|
||||
m_currentFile = QDir(m_targetDir).relativeFilePath(task.path);
|
||||
|
||||
connect(context->reply, &QNetworkReply::readyRead, this, [this, context] {
|
||||
const QByteArray chunk = context->reply->readAll();
|
||||
if (chunk.isEmpty())
|
||||
return;
|
||||
context->hash.addData(chunk);
|
||||
if (context->file->write(chunk) != chunk.size())
|
||||
context->reply->abort();
|
||||
});
|
||||
connect(context->reply, &QNetworkReply::downloadProgress, this,
|
||||
[this, context](qint64 received, qint64) {
|
||||
context->received = received;
|
||||
scheduleProgress();
|
||||
});
|
||||
connect(context->reply, &QNetworkReply::finished, this,
|
||||
[this, context] { handleFileFinished(context); });
|
||||
|
||||
scheduleProgress();
|
||||
}
|
||||
|
||||
void JavaInstaller::handleFileFinished(const std::shared_ptr<JavaActiveDownload> &context)
|
||||
{
|
||||
context->reply->deleteLater();
|
||||
m_active.removeOne(context);
|
||||
|
||||
if (m_canceling) {
|
||||
context->file->cancelWriting();
|
||||
if (m_active.isEmpty()) {
|
||||
const QString label = m_label;
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit canceled(label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!m_running)
|
||||
return;
|
||||
|
||||
const int status =
|
||||
context->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
|
||||
if (context->reply->error() != QNetworkReply::NoError) {
|
||||
context->file->cancelWriting();
|
||||
const QString reason = context->reply->errorString();
|
||||
if (isTransient(context->reply->error(), status)) {
|
||||
if (!retryOrFail(context->task, reason))
|
||||
return;
|
||||
} else {
|
||||
fail(Loc::text("common.error.downloadFailed")
|
||||
.arg(QDir(m_targetDir).relativeFilePath(context->task.path), reason));
|
||||
return;
|
||||
}
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context->task.sha1.isEmpty()) {
|
||||
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, Loc::text("common.error.checksumMismatch")))
|
||||
return;
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context->file->commit()) {
|
||||
fail(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(context->task.path)));
|
||||
return;
|
||||
}
|
||||
if (context->task.executable)
|
||||
makeExecutable(context->task.path);
|
||||
|
||||
m_bytesSettled += context->received;
|
||||
++m_stepsDone;
|
||||
scheduleProgress();
|
||||
pump();
|
||||
}
|
||||
|
||||
bool JavaInstaller::retryOrFail(JavaFileTask task, const QString &reason)
|
||||
{
|
||||
if (++task.attempts >= kMaxAttempts) {
|
||||
fail(Loc::text("common.error.downloadFailed")
|
||||
.arg(QDir(m_targetDir).relativeFilePath(task.path), reason));
|
||||
return false;
|
||||
}
|
||||
m_queue.enqueue(task);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JavaInstaller::applyLinks()
|
||||
{
|
||||
for (const auto &link : std::as_const(m_links)) {
|
||||
const QString linkPath = link.first;
|
||||
if (QFileInfo::exists(linkPath))
|
||||
continue;
|
||||
if (!QDir().mkpath(QFileInfo(linkPath).absolutePath()))
|
||||
continue;
|
||||
|
||||
// Цель относительна самой ссылке — QFile::link кладёт её как есть.
|
||||
if (!QFile::link(link.second, linkPath)) {
|
||||
// Под Windows символических ссылок в рантаймах Mojang нет, но если
|
||||
// они появятся, копия работает не хуже.
|
||||
const QString source =
|
||||
QDir::cleanPath(QFileInfo(linkPath).absolutePath() + u'/' + link.second);
|
||||
if (!QFile::copy(source, linkPath)) {
|
||||
fail(Loc::text("java.error.linkFailed")
|
||||
.arg(QDir(m_targetDir).relativeFilePath(linkPath)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Завершение ──────────────────────────────────────────────────────────────
|
||||
|
||||
void JavaInstaller::complete()
|
||||
{
|
||||
setStage(Loc::text("java.progress.verifying"));
|
||||
|
||||
const QString javaPath = JavaRuntimeStore::locateBinary(m_targetDir);
|
||||
if (javaPath.isEmpty()) {
|
||||
fail(Loc::text("java.error.noExecutable"));
|
||||
return;
|
||||
}
|
||||
#if !defined(Q_OS_WIN)
|
||||
// tar сохраняет права, а вот zip и файлы Mojang без флага executable — нет.
|
||||
makeExecutable(javaPath);
|
||||
#endif
|
||||
|
||||
InstalledJavaRuntime runtime;
|
||||
runtime.id = m_entry.id;
|
||||
runtime.kind = javaKindKey(m_entry.kind);
|
||||
runtime.major = m_entry.major;
|
||||
runtime.version = m_entry.version;
|
||||
runtime.javaPath = javaPath;
|
||||
runtime.size = m_entry.size > 0 ? m_entry.size : m_bytesTotal;
|
||||
|
||||
QString error;
|
||||
if (!JavaRuntimeStore::writeMeta(runtime, &error)) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Архив нужен был только на распаковку — двести мегабайт в кэше ни к чему.
|
||||
if (!m_archivePath.isEmpty())
|
||||
QFile::remove(m_archivePath);
|
||||
|
||||
const QString id = m_entry.id;
|
||||
reset();
|
||||
emit finished(id, javaPath);
|
||||
}
|
||||
|
||||
void JavaInstaller::fail(const QString &message)
|
||||
{
|
||||
const QString label = m_label;
|
||||
m_extractTimer->stop();
|
||||
m_zip.reset();
|
||||
if (!m_archivePath.isEmpty())
|
||||
QFile::remove(m_archivePath);
|
||||
// Половина рантайма хуже, чем его отсутствие: следующая попытка начнётся с чистой папки.
|
||||
JavaRuntimeStore::remove(m_entry.id, nullptr);
|
||||
reset();
|
||||
emit failed(label, message);
|
||||
}
|
||||
|
||||
void JavaInstaller::reset()
|
||||
{
|
||||
m_running = false;
|
||||
m_canceling = false;
|
||||
m_progressTimer->stop();
|
||||
m_extractTimer->stop();
|
||||
m_zip.reset();
|
||||
m_queue.clear();
|
||||
m_active.clear();
|
||||
m_links.clear();
|
||||
m_archiveFile.reset();
|
||||
m_archivePath.clear();
|
||||
m_bytesSettled = 0;
|
||||
m_bytesTotal = 0;
|
||||
m_stepsDone = 0;
|
||||
m_stepsTotal = 0;
|
||||
m_stage.clear();
|
||||
m_currentFile.clear();
|
||||
m_entry = {};
|
||||
m_label.clear();
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void JavaInstaller::setStage(const QString &stage)
|
||||
{
|
||||
if (m_stage == stage)
|
||||
return;
|
||||
m_stage = stage;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void JavaInstaller::scheduleProgress()
|
||||
{
|
||||
m_progressDirty = true;
|
||||
if (!m_progressTimer->isActive())
|
||||
m_progressTimer->start();
|
||||
}
|
||||
|
||||
void JavaInstaller::flushProgress()
|
||||
{
|
||||
if (!m_progressDirty) {
|
||||
m_progressTimer->stop();
|
||||
return;
|
||||
}
|
||||
m_progressDirty = false;
|
||||
emit progressChanged();
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "javaruntime.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QQueue>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <memory>
|
||||
|
||||
class QJsonObject;
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class QProcess;
|
||||
class QSaveFile;
|
||||
class QTimer;
|
||||
|
||||
// Состояние распаковки zip. Спрятано в .cpp, чтобы приватный заголовок
|
||||
// QZipReader не расходился по проекту вместе с этим.
|
||||
struct ZipExtraction;
|
||||
|
||||
// Один файл рантайма Mojang.
|
||||
struct JavaFileTask
|
||||
{
|
||||
QUrl url;
|
||||
QString path; // абсолютный путь назначения
|
||||
QString sha1;
|
||||
qint64 size = 0;
|
||||
bool executable = false;
|
||||
int attempts = 0;
|
||||
};
|
||||
|
||||
// Файл в процессе скачивания. Пишем потоком: рантайм — это две сотни мегабайт.
|
||||
struct JavaActiveDownload
|
||||
{
|
||||
JavaFileTask task;
|
||||
QNetworkReply *reply = nullptr;
|
||||
std::unique_ptr<QSaveFile> file;
|
||||
QCryptographicHash hash{QCryptographicHash::Sha1};
|
||||
qint64 received = 0;
|
||||
};
|
||||
|
||||
// Установка сборки Java в <root>/java/<id>. Два разных пути под одним фасадом:
|
||||
//
|
||||
// * Temurin отдаёт один архив — качаем, сверяем sha256 и распаковываем;
|
||||
//
|
||||
// * Mojang отдаёт манифест с деревом файлов — качаем файлы по отдельности,
|
||||
// как это делает официальный лаунчер.
|
||||
//
|
||||
// Набор геттеров повторяет VersionInstaller: панель загрузки в интерфейсе
|
||||
// читает их одинаково, независимо от того, кто сейчас работает.
|
||||
class JavaInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit JavaInstaller(QObject *parent = nullptr);
|
||||
~JavaInstaller() override;
|
||||
|
||||
bool isRunning() const { return m_running; }
|
||||
QString runtimeId() const { return m_entry.id; }
|
||||
QString label() const { return m_label; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString currentFile() const { return m_currentFile; }
|
||||
|
||||
// Байты считаются только на загрузке: на распаковке считать нечего, и панель
|
||||
// по нулевому итогу сама прячет мегабайты.
|
||||
qint64 bytesDone() const;
|
||||
qint64 bytesTotal() const { return m_bytesTotal; }
|
||||
double fraction() const; // 0..1, либо -1 пока итог неизвестен
|
||||
|
||||
void install(const JavaRuntimeEntry &entry);
|
||||
void cancel();
|
||||
|
||||
signals:
|
||||
void started(const QString &label);
|
||||
void progressChanged();
|
||||
void finished(const QString &runtimeId, const QString &javaPath);
|
||||
void failed(const QString &label, const QString &message);
|
||||
void canceled(const QString &label);
|
||||
|
||||
private:
|
||||
// ── Общее ───────────────────────────────────────────────────────────
|
||||
void beginArchive();
|
||||
void beginMojang();
|
||||
void complete();
|
||||
void fail(const QString &message);
|
||||
void reset();
|
||||
void setStage(const QString &stage);
|
||||
void scheduleProgress();
|
||||
void flushProgress();
|
||||
|
||||
// ── Temurin ─────────────────────────────────────────────────────────
|
||||
void downloadArchive();
|
||||
void handleArchiveFinished(QNetworkReply *reply);
|
||||
void startExtraction();
|
||||
void extractZipChunk();
|
||||
void startTarExtraction();
|
||||
|
||||
// ── Mojang ──────────────────────────────────────────────────────────
|
||||
void handleManifest(const QByteArray &payload);
|
||||
bool prepareTree(const QJsonObject &files);
|
||||
void pump();
|
||||
void startTask(JavaFileTask task);
|
||||
void handleFileFinished(const std::shared_ptr<JavaActiveDownload> &context);
|
||||
bool retryOrFail(JavaFileTask task, const QString &reason);
|
||||
bool applyLinks();
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
|
||||
JavaRuntimeEntry m_entry;
|
||||
QString m_label;
|
||||
QString m_targetDir;
|
||||
QString m_archivePath;
|
||||
|
||||
QString m_stage;
|
||||
QString m_currentFile;
|
||||
qint64 m_bytesSettled = 0;
|
||||
qint64 m_bytesTotal = 0;
|
||||
|
||||
// Шаги распаковки: у архива — записи внутри него, у Mojang — файлы дерева.
|
||||
int m_stepsDone = 0;
|
||||
int m_stepsTotal = 0;
|
||||
|
||||
QNetworkReply *m_reply = nullptr; // архив или манифест
|
||||
std::unique_ptr<QSaveFile> m_archiveFile;
|
||||
QCryptographicHash m_archiveHash{QCryptographicHash::Sha256};
|
||||
|
||||
QProcess *m_tar = nullptr;
|
||||
QTimer *m_extractTimer;
|
||||
QTimer *m_progressTimer;
|
||||
bool m_progressDirty = false;
|
||||
|
||||
// Распаковка zip по кускам: держать GUI-поток занятым на всю сотню
|
||||
// мегабайт нельзя, а поток ради одной операции заводить незачем.
|
||||
std::unique_ptr<ZipExtraction> m_zip;
|
||||
|
||||
QQueue<JavaFileTask> m_queue;
|
||||
QList<std::shared_ptr<JavaActiveDownload>> m_active;
|
||||
QList<QPair<QString, QString>> m_links; // {абсолютный путь ссылки, цель}
|
||||
|
||||
bool m_running = false;
|
||||
bool m_canceling = false;
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
#include "javalocator.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "javaruntime.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QStandardPaths>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(Q_OS_WIN)
|
||||
constexpr auto kExecutable = "java.exe";
|
||||
constexpr auto kWindowless = "javaw.exe";
|
||||
#else
|
||||
constexpr auto kExecutable = "java";
|
||||
constexpr auto kWindowless = "java";
|
||||
#endif
|
||||
|
||||
// Корни, в которых Windows/Linux/macOS обычно держат JDK и JRE.
|
||||
QStringList installationRoots()
|
||||
{
|
||||
QStringList roots;
|
||||
#if defined(Q_OS_WIN)
|
||||
const QStringList programFiles = {qEnvironmentVariable("ProgramFiles"),
|
||||
qEnvironmentVariable("ProgramFiles(x86)"),
|
||||
qEnvironmentVariable("ProgramW6432")};
|
||||
const QStringList vendors = {QStringLiteral("Java"),
|
||||
QStringLiteral("Eclipse Adoptium"),
|
||||
QStringLiteral("Eclipse Foundation"),
|
||||
QStringLiteral("AdoptOpenJDK"),
|
||||
QStringLiteral("Amazon Corretto"),
|
||||
QStringLiteral("Zulu"),
|
||||
QStringLiteral("BellSoft"),
|
||||
QStringLiteral("Microsoft"),
|
||||
QStringLiteral("Semeru"),
|
||||
QStringLiteral("Android/Android Studio/jbr")};
|
||||
for (const QString &base : programFiles) {
|
||||
if (base.isEmpty())
|
||||
continue;
|
||||
for (const QString &vendor : vendors)
|
||||
roots << base + u'/' + vendor;
|
||||
}
|
||||
#elif defined(Q_OS_MACOS)
|
||||
roots << QStringLiteral("/Library/Java/JavaVirtualMachines")
|
||||
<< QDir::homePath() + QStringLiteral("/Library/Java/JavaVirtualMachines");
|
||||
#else
|
||||
roots << QStringLiteral("/usr/lib/jvm") << QStringLiteral("/usr/java")
|
||||
<< QStringLiteral("/opt/java") << QStringLiteral("/opt/jdk");
|
||||
#endif
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Ищем именно java.exe: у javaw.exe нет вывода, а версию мы определяем запуском.
|
||||
void appendIfExecutable(QStringList &target, const QString &binDir)
|
||||
{
|
||||
const QFileInfo info(binDir + u'/' + QString::fromLatin1(kExecutable));
|
||||
if (!info.isFile() || !info.isExecutable())
|
||||
return;
|
||||
const QString path = info.absoluteFilePath();
|
||||
if (!target.contains(path))
|
||||
target << path;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList JavaLocator::findAll(const QString &gameDir)
|
||||
{
|
||||
QStringList result;
|
||||
|
||||
// 0. Сборки, скачанные самим лаунчером. Идут первыми: пользователь выбирал
|
||||
// их осознанно, в отличие от того, что нашлось в системе.
|
||||
const QList<InstalledJavaRuntime> managed = JavaRuntimeStore::installed();
|
||||
for (const InstalledJavaRuntime &entry : managed) {
|
||||
if (!result.contains(entry.javaPath))
|
||||
result << entry.javaPath;
|
||||
}
|
||||
|
||||
// 1. Рантайм, скачанный самим Minecraft: .minecraft/runtime/<component>/<os>/<component>/bin
|
||||
const QString runtime = gameDir + QStringLiteral("/runtime");
|
||||
if (QDir(runtime).exists()) {
|
||||
QDirIterator it(runtime, QStringList{QString::fromLatin1(kExecutable)},
|
||||
QDir::Files | QDir::Executable, QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
appendIfExecutable(result, it.fileInfo().absolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. JAVA_HOME
|
||||
const QString javaHome = qEnvironmentVariable("JAVA_HOME");
|
||||
if (!javaHome.isEmpty())
|
||||
appendIfExecutable(result, javaHome + QStringLiteral("/bin"));
|
||||
|
||||
// 3. PATH
|
||||
const QString onPath = QStandardPaths::findExecutable(QString::fromLatin1(kExecutable));
|
||||
if (!onPath.isEmpty())
|
||||
appendIfExecutable(result, QFileInfo(onPath).absolutePath());
|
||||
|
||||
// 4. Типовые каталоги установки
|
||||
const QStringList roots = installationRoots();
|
||||
for (const QString &root : roots) {
|
||||
const QDir dir(root);
|
||||
if (!dir.exists())
|
||||
continue;
|
||||
const QStringList entries = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
for (const QString &entry : entries) {
|
||||
const QString home = dir.absoluteFilePath(entry);
|
||||
appendIfExecutable(result, home + QStringLiteral("/bin"));
|
||||
appendIfExecutable(result, home + QStringLiteral("/Contents/Home/bin")); // macOS
|
||||
appendIfExecutable(result, home + QStringLiteral("/jre/bin"));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int JavaLocator::majorVersion(const QString &javaPath)
|
||||
{
|
||||
static QHash<QString, int> cache;
|
||||
const auto cached = cache.constFind(javaPath);
|
||||
if (cached != cache.constEnd())
|
||||
return cached.value();
|
||||
|
||||
int major = 0;
|
||||
QProcess process;
|
||||
process.start(javaPath, {QStringLiteral("-version")});
|
||||
if (process.waitForFinished(5000)) {
|
||||
// java печатает версию в stderr: openjdk version "17.0.2" 2022-01-18
|
||||
const QString output = QString::fromLocal8Bit(process.readAllStandardError())
|
||||
+ QString::fromLocal8Bit(process.readAllStandardOutput());
|
||||
static const QRegularExpression re(QStringLiteral("version\\s+\"(\\d+)(?:\\.(\\d+))?"));
|
||||
const QRegularExpressionMatch match = re.match(output);
|
||||
if (match.hasMatch()) {
|
||||
const int first = match.captured(1).toInt();
|
||||
// До Java 9 версия выглядела как 1.8.0_301 — мажорная тут вторая цифра.
|
||||
major = (first == 1) ? match.captured(2).toInt() : first;
|
||||
}
|
||||
}
|
||||
|
||||
cache.insert(javaPath, major);
|
||||
return major;
|
||||
}
|
||||
|
||||
QString JavaLocator::windowlessVariant(const QString &javaPath)
|
||||
{
|
||||
const QFileInfo info(javaPath);
|
||||
if (info.fileName() != QLatin1String(kExecutable))
|
||||
return javaPath;
|
||||
const QFileInfo windowless(info.absolutePath() + u'/' + QString::fromLatin1(kWindowless));
|
||||
return windowless.isFile() ? windowless.absoluteFilePath() : javaPath;
|
||||
}
|
||||
|
||||
QString JavaLocator::select(const QString &gameDir,
|
||||
int requiredMajor,
|
||||
const QString &preferred,
|
||||
QString *error)
|
||||
{
|
||||
QStringList candidates;
|
||||
if (!preferred.isEmpty()) {
|
||||
QFileInfo info(preferred);
|
||||
// В настройках можно указать как сам java.exe, так и корень JDK.
|
||||
if (info.isDir())
|
||||
appendIfExecutable(candidates, info.absoluteFilePath() + QStringLiteral("/bin"));
|
||||
else if (info.isFile())
|
||||
candidates << info.absoluteFilePath();
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
if (error)
|
||||
*error = Loc::text("java.error.notAtPath")
|
||||
.arg(QDir::toNativeSeparators(preferred));
|
||||
return {};
|
||||
}
|
||||
}
|
||||
candidates += findAll(gameDir);
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
if (error)
|
||||
*error = Loc::text("java.error.notFound")
|
||||
.arg(requiredMajor);
|
||||
return {};
|
||||
}
|
||||
|
||||
QString bestBelow;
|
||||
int bestBelowMajor = 0;
|
||||
for (const QString &candidate : std::as_const(candidates)) {
|
||||
const int major = majorVersion(candidate);
|
||||
if (major >= requiredMajor)
|
||||
return candidate;
|
||||
if (major > bestBelowMajor) {
|
||||
bestBelowMajor = major;
|
||||
bestBelow = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
*error = bestBelowMajor > 0
|
||||
? Loc::text("java.error.tooOld")
|
||||
.arg(requiredMajor)
|
||||
.arg(bestBelowMajor)
|
||||
.arg(QDir::toNativeSeparators(bestBelow))
|
||||
: Loc::text("java.error.versionUnknown")
|
||||
.arg(requiredMajor);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace JavaLocator {
|
||||
|
||||
// Все java, которые удалось найти: рантайм самого Minecraft, JAVA_HOME, PATH,
|
||||
// стандартные каталоги установки JDK/JRE. Без дублей, в порядке приоритета.
|
||||
QStringList findAll(const QString &gameDir);
|
||||
|
||||
// Мажорная версия (8, 17, 21...) или 0, если запустить не удалось. Результат кешируется.
|
||||
int majorVersion(const QString &javaPath);
|
||||
|
||||
// java.exe -> javaw.exe, чтобы игра не открывала окно консоли. На Unix возвращает вход.
|
||||
QString windowlessVariant(const QString &javaPath);
|
||||
|
||||
// Выбирает java не ниже requiredMajor. preferred, если задан, проверяется первым.
|
||||
// При неудаче возвращает пустую строку и заполняет error.
|
||||
QString select(const QString &gameDir,
|
||||
int requiredMajor,
|
||||
const QString &preferred,
|
||||
QString *error);
|
||||
|
||||
} // namespace JavaLocator
|
||||
@@ -1,265 +0,0 @@
|
||||
#include "javaruntime.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(Q_OS_WIN)
|
||||
constexpr auto kExecutable = "java.exe";
|
||||
#else
|
||||
constexpr auto kExecutable = "java";
|
||||
#endif
|
||||
|
||||
const QString kMetaFile = QStringLiteral("runtime.json");
|
||||
|
||||
} // namespace
|
||||
|
||||
QString javaKindKey(JavaRuntimeKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case JavaRuntimeKind::Mojang: return QStringLiteral("java");
|
||||
case JavaRuntimeKind::Jdk: return QStringLiteral("jdk");
|
||||
case JavaRuntimeKind::Jre: return QStringLiteral("jre");
|
||||
}
|
||||
return QStringLiteral("jdk");
|
||||
}
|
||||
|
||||
std::optional<JavaRuntimeKind> javaKindFromKey(const QString &key)
|
||||
{
|
||||
if (key == QLatin1String("java"))
|
||||
return JavaRuntimeKind::Mojang;
|
||||
if (key == QLatin1String("jdk"))
|
||||
return JavaRuntimeKind::Jdk;
|
||||
if (key == QLatin1String("jre"))
|
||||
return JavaRuntimeKind::Jre;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QString javaKindTitle(JavaRuntimeKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
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 {};
|
||||
}
|
||||
|
||||
QString JavaRuntimeStore::sanitizeId(const QString &id)
|
||||
{
|
||||
QString out;
|
||||
out.reserve(id.size());
|
||||
for (const QChar ch : id) {
|
||||
const bool safe = ch.isLetterOrNumber() || ch == u'.' || ch == u'-' || ch == u'_';
|
||||
out += safe ? ch : u'_';
|
||||
}
|
||||
// Точки в начале превратили бы папку в скрытую, а «..» — в путь наружу.
|
||||
while (out.startsWith(u'.'))
|
||||
out.remove(0, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
QString JavaRuntimeStore::dirFor(const QString &id)
|
||||
{
|
||||
const QString safe = sanitizeId(id);
|
||||
if (safe.isEmpty())
|
||||
return {};
|
||||
return LauncherPaths::javaDir() + u'/' + safe;
|
||||
}
|
||||
|
||||
QString JavaRuntimeStore::locateBinary(const QString &rootDir)
|
||||
{
|
||||
if (rootDir.isEmpty() || !QDir(rootDir).exists())
|
||||
return {};
|
||||
|
||||
// Кандидатов бывает несколько: в JDK рядом с bin/java лежит ещё и
|
||||
// jre/bin/java. Берём самый короткий путь — это корневой интерпретатор.
|
||||
QString best;
|
||||
QDirIterator it(rootDir, QStringList{QString::fromLatin1(kExecutable)},
|
||||
QDir::Files, QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
const QFileInfo info = it.fileInfo();
|
||||
if (info.absolutePath().section(u'/', -1) != QLatin1String("bin"))
|
||||
continue;
|
||||
const QString path = info.absoluteFilePath();
|
||||
if (best.isEmpty() || path.length() < best.length())
|
||||
best = path;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::optional<InstalledJavaRuntime> JavaRuntimeStore::find(const QString &id)
|
||||
{
|
||||
const QString dir = dirFor(id);
|
||||
if (dir.isEmpty())
|
||||
return std::nullopt;
|
||||
|
||||
QFile file(dir + u'/' + kMetaFile);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return std::nullopt;
|
||||
|
||||
const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object();
|
||||
InstalledJavaRuntime runtime;
|
||||
runtime.id = object.value(QStringLiteral("id")).toString();
|
||||
runtime.kind = object.value(QStringLiteral("kind")).toString();
|
||||
runtime.major = object.value(QStringLiteral("major")).toInt();
|
||||
runtime.version = object.value(QStringLiteral("version")).toString();
|
||||
runtime.size = static_cast<qint64>(object.value(QStringLiteral("size")).toDouble());
|
||||
|
||||
// Путь хранится относительным: папку лаунчера могли перенести вместе с
|
||||
// профилем пользователя, а абсолютный путь этого не переживает.
|
||||
const QString relative = object.value(QStringLiteral("javaPath")).toString();
|
||||
runtime.javaPath = relative.isEmpty() ? QString() : QDir(dir).absoluteFilePath(relative);
|
||||
|
||||
if (runtime.id != id || runtime.javaPath.isEmpty() || !QFileInfo::exists(runtime.javaPath))
|
||||
return std::nullopt;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
QList<InstalledJavaRuntime> JavaRuntimeStore::installed()
|
||||
{
|
||||
QList<InstalledJavaRuntime> result;
|
||||
const QDir root(LauncherPaths::javaDir());
|
||||
const QStringList entries = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
for (const QString &entry : entries) {
|
||||
const auto runtime = find(entry);
|
||||
if (runtime)
|
||||
result << *runtime;
|
||||
}
|
||||
|
||||
std::stable_sort(result.begin(), result.end(),
|
||||
[](const InstalledJavaRuntime &a, const InstalledJavaRuntime &b) {
|
||||
if (a.major != b.major)
|
||||
return a.major > b.major;
|
||||
return a.version > b.version;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
bool JavaRuntimeStore::writeMeta(const InstalledJavaRuntime &runtime, QString *error)
|
||||
{
|
||||
const QString dir = dirFor(runtime.id);
|
||||
if (dir.isEmpty()) {
|
||||
if (error)
|
||||
*error = Loc::text("java.error.invalidRuntimeId");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject object{
|
||||
{QStringLiteral("id"), runtime.id},
|
||||
{QStringLiteral("kind"), runtime.kind},
|
||||
{QStringLiteral("major"), runtime.major},
|
||||
{QStringLiteral("version"), runtime.version},
|
||||
{QStringLiteral("javaPath"), QDir(dir).relativeFilePath(runtime.javaPath)},
|
||||
{QStringLiteral("size"), static_cast<double>(runtime.size)}};
|
||||
|
||||
QSaveFile file(dir + u'/' + kMetaFile);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
if (error)
|
||||
*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 = Loc::text("java.error.writeFailed").arg(QDir::toNativeSeparators(file.fileName()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JavaRuntimeStore::remove(const QString &id, QString *error)
|
||||
{
|
||||
const QString dir = dirFor(id);
|
||||
// Удаляем только внутри <root>/java: sanitizeId не пропустит «..», но
|
||||
// проверка стоит одной строки, а цена ошибки — чужая папка.
|
||||
if (dir.isEmpty() || !dir.startsWith(LauncherPaths::javaDir() + u'/')) {
|
||||
if (error)
|
||||
*error = Loc::text("java.error.invalidRuntimeId");
|
||||
return false;
|
||||
}
|
||||
if (!QDir(dir).exists())
|
||||
return true;
|
||||
if (!QDir(dir).removeRecursively()) {
|
||||
if (error)
|
||||
*error = Loc::text("common.error.removeFailed").arg(QDir::toNativeSeparators(dir));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int JavaRequirement::minimumFor(const QString &minecraftVersionId)
|
||||
{
|
||||
const QString id = minecraftVersionId.trimmed();
|
||||
if (id.isEmpty())
|
||||
return 0;
|
||||
|
||||
// Снапшоты — YYwWWa. Ровных границ по неделям нет, но год отвечает на
|
||||
// вопрос «какая Java» точнее, чем ничего: 1.17 (Java 16) вышла в 21-м году,
|
||||
// 1.18 (Java 17) в конце того же года, 1.20.5 (Java 21) — в 24-м.
|
||||
static const QRegularExpression snapshot(QStringLiteral("^(\\d{2})w\\d{2}[a-z]$"));
|
||||
const QRegularExpressionMatch snapshotMatch = snapshot.match(id);
|
||||
if (snapshotMatch.hasMatch()) {
|
||||
const int year = snapshotMatch.captured(1).toInt();
|
||||
if (year >= 24)
|
||||
return 21;
|
||||
if (year >= 22)
|
||||
return 17;
|
||||
if (year == 21)
|
||||
return 16;
|
||||
return 8;
|
||||
}
|
||||
|
||||
// Релизы и их пре-релизы: 1.20.5, 1.21, 1.21.4-pre1, 26.2.
|
||||
static const QRegularExpression release(
|
||||
QStringLiteral("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?"));
|
||||
const QRegularExpressionMatch releaseMatch = release.match(id);
|
||||
if (!releaseMatch.hasMatch())
|
||||
return 8; // a1.2.6, b1.7.3, rd-132211 и прочая доисторическая ваниль
|
||||
|
||||
const int epoch = releaseMatch.captured(1).toInt();
|
||||
const int major = releaseMatch.captured(2).toInt();
|
||||
const int patch = releaseMatch.captured(3).toInt();
|
||||
|
||||
// Нумерация после 1.21.x пошла по годам (26.2) — это заведомо новее 1.20.5.
|
||||
if (epoch != 1)
|
||||
return 21;
|
||||
if (major >= 21)
|
||||
return 21;
|
||||
if (major == 20)
|
||||
return patch >= 5 ? 21 : 17;
|
||||
if (major >= 18)
|
||||
return 17;
|
||||
if (major == 17)
|
||||
return 16;
|
||||
return 8;
|
||||
}
|
||||
|
||||
QString JavaRequirement::coverage(int javaMajor)
|
||||
{
|
||||
if (javaMajor >= 21)
|
||||
return Loc::text("java.range.modern");
|
||||
if (javaMajor >= 17)
|
||||
return Loc::text("java.range.mid");
|
||||
if (javaMajor == 16)
|
||||
return Loc::text("java.range.seventeen");
|
||||
if (javaMajor >= 9)
|
||||
return Loc::text("java.range.unsuitable");
|
||||
if (javaMajor > 0)
|
||||
return Loc::text("java.range.legacy");
|
||||
return {};
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
#include <optional>
|
||||
|
||||
// Откуда взялась сборка Java и что именно в ней лежит.
|
||||
enum class JavaRuntimeKind {
|
||||
Mojang, // «Java» — тот же рантайм, которым игру запускает официальный лаунчер
|
||||
Jdk, // Eclipse Temurin JDK: компилятор и инструменты в комплекте
|
||||
Jre // Eclipse Temurin JRE: только то, что нужно для запуска
|
||||
};
|
||||
|
||||
QString javaKindKey(JavaRuntimeKind kind); // java | jdk | jre
|
||||
std::optional<JavaRuntimeKind> javaKindFromKey(const QString &key);
|
||||
QString javaKindTitle(JavaRuntimeKind kind);
|
||||
|
||||
// Строка каталога — то, что можно скачать.
|
||||
struct JavaRuntimeEntry
|
||||
{
|
||||
QString id; // temurin-jdk-21.0.12.1_1 | mojang-java-runtime-delta
|
||||
JavaRuntimeKind kind = JavaRuntimeKind::Jdk;
|
||||
int major = 0; // 8, 17, 21…
|
||||
QString version; // 21.0.12.1+1 | 21.0.7
|
||||
QString component; // java-runtime-delta — только у сборок Mojang
|
||||
QDateTime released;
|
||||
QUrl url; // архив Temurin либо manifest.json компонента
|
||||
QString checksum; // sha256 архива Temurin, sha1 манифеста Mojang
|
||||
qint64 size = 0; // размер загрузки, 0 — неизвестен
|
||||
QString archive; // zip | tar.gz | mojang
|
||||
QString architecture; // x64 | aarch64 — на macOS бывает и не родная
|
||||
bool lts = false;
|
||||
|
||||
bool isValid() const { return !id.isEmpty() && major > 0 && url.isValid(); }
|
||||
};
|
||||
|
||||
// Сборка, уже распакованная в папке лаунчера.
|
||||
struct InstalledJavaRuntime
|
||||
{
|
||||
QString id;
|
||||
QString kind; // java | jdk | jre
|
||||
int major = 0;
|
||||
QString version;
|
||||
QString javaPath; // абсолютный путь к java/java.exe
|
||||
qint64 size = 0; // сколько заняло на диске по итогам установки
|
||||
};
|
||||
|
||||
// Папка <root>/java: одна подпапка на сборку плюс её описание внутри. Отдельного
|
||||
// индекса нет намеренно — удалённую вручную папку не пришлось бы вычищать ещё и
|
||||
// из общего файла.
|
||||
namespace JavaRuntimeStore {
|
||||
|
||||
QString dirFor(const QString &id);
|
||||
|
||||
// Всё, что лежит в <root>/java и на что нашлась java. Новые версии сверху.
|
||||
QList<InstalledJavaRuntime> installed();
|
||||
std::optional<InstalledJavaRuntime> find(const QString &id);
|
||||
|
||||
// Ищет bin/java в распакованном дереве: у Temurin на macOS это
|
||||
// Contents/Home/bin, у Mojang — jre.bundle/Contents/Home/bin.
|
||||
QString locateBinary(const QString &rootDir);
|
||||
|
||||
bool writeMeta(const InstalledJavaRuntime &runtime, QString *error);
|
||||
bool remove(const QString &id, QString *error);
|
||||
|
||||
// Имя папки из идентификатора каталога: всё, кроме букв, цифр, точки, дефиса и
|
||||
// подчёркивания, заменяется — идентификатор приходит из сети.
|
||||
QString sanitizeId(const QString &id);
|
||||
|
||||
} // namespace JavaRuntimeStore
|
||||
|
||||
// Какая Java нужна какой версии игры. Точный ответ лежит в client.json
|
||||
// (javaVersion.majorVersion) и берётся оттуда при запуске; эта таблица нужна
|
||||
// раньше — когда версия ещё не скачана, а подсказку в каталоге показать надо.
|
||||
namespace JavaRequirement {
|
||||
|
||||
// Минимальная мажорная версия Java для версии игры. 0 — разобрать не удалось.
|
||||
int minimumFor(const QString &minecraftVersionId);
|
||||
|
||||
// «Minecraft 1.20.5 и новее» — подпись к строке каталога.
|
||||
QString coverage(int javaMajor);
|
||||
|
||||
} // namespace JavaRequirement
|
||||
@@ -1,583 +0,0 @@
|
||||
#include "javaruntimeservice.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QSysInfo>
|
||||
#include <QTimer>
|
||||
#include <QUrlQuery>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kAdoptiumApi = QStringLiteral("https://api.adoptium.net/v3");
|
||||
|
||||
// Список компонентов рантайма Mojang. Ссылка зашита в официальный лаунчер и
|
||||
// меняется вместе с ним; сами компоненты обновляются по этому же адресу.
|
||||
const QString kMojangRuntimesUrl = QStringLiteral(
|
||||
"https://piston-meta.mojang.com/v1/products/java-runtime/"
|
||||
"2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json");
|
||||
|
||||
// Сборки Java выходят раз в квартал, а не раз в неделю: суток кэша достаточно,
|
||||
// чтобы новая появилась в тот же день, и хватает на десяток открытий окна.
|
||||
constexpr qint64 kCacheTtlSeconds = 24 * 60 * 60;
|
||||
|
||||
constexpr int kTransferTimeoutMs = 20000;
|
||||
|
||||
// Сколько последних сборок каждой мажорной версии показывать. Больше трёх
|
||||
// смысла нет: старые патчи отличаются только исправлениями безопасности.
|
||||
constexpr int kReleasesPerMajor = 3;
|
||||
|
||||
QString adoptiumOs()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
return QStringLiteral("windows");
|
||||
#elif defined(Q_OS_MACOS)
|
||||
return QStringLiteral("mac");
|
||||
#else
|
||||
return QStringLiteral("linux");
|
||||
#endif
|
||||
}
|
||||
|
||||
QString adoptiumArch()
|
||||
{
|
||||
const QString arch = QSysInfo::currentCpuArchitecture();
|
||||
if (arch == QLatin1String("arm64"))
|
||||
return QStringLiteral("aarch64");
|
||||
if (arch == QLatin1String("x86_64"))
|
||||
return QStringLiteral("x64");
|
||||
if (arch == QLatin1String("i386"))
|
||||
return QStringLiteral("x86");
|
||||
if (arch == QLatin1String("arm"))
|
||||
return QStringLiteral("arm");
|
||||
return arch;
|
||||
}
|
||||
|
||||
// Ключ платформы в манифесте Mojang.
|
||||
QString mojangPlatform()
|
||||
{
|
||||
const QString arch = QSysInfo::currentCpuArchitecture();
|
||||
#if defined(Q_OS_WIN)
|
||||
if (arch == QLatin1String("arm64"))
|
||||
return QStringLiteral("windows-arm64");
|
||||
return arch == QLatin1String("i386") ? QStringLiteral("windows-x86")
|
||||
: QStringLiteral("windows-x64");
|
||||
#elif defined(Q_OS_MACOS)
|
||||
return arch == QLatin1String("arm64") ? QStringLiteral("mac-os-arm64")
|
||||
: QStringLiteral("mac-os");
|
||||
#else
|
||||
return arch == QLatin1String("i386") ? QStringLiteral("linux-i386")
|
||||
: QStringLiteral("linux");
|
||||
#endif
|
||||
}
|
||||
|
||||
QNetworkRequest makeRequest(const QUrl &url)
|
||||
{
|
||||
QNetworkRequest request{url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
return request;
|
||||
}
|
||||
|
||||
// «1.8.0_504-b01» никто не узнаёт, а «8u504» — общепринятое имя той же сборки.
|
||||
QString displayVersion(const QJsonObject &versionData)
|
||||
{
|
||||
const int major = versionData.value(QStringLiteral("major")).toInt();
|
||||
if (major == 8) {
|
||||
return QStringLiteral("8u%1").arg(versionData.value(QStringLiteral("security")).toInt());
|
||||
}
|
||||
|
||||
QString openjdk = versionData.value(QStringLiteral("openjdk_version")).toString();
|
||||
if (openjdk.endsWith(QLatin1String("-LTS")))
|
||||
openjdk.chop(4);
|
||||
if (!openjdk.isEmpty())
|
||||
return openjdk;
|
||||
return versionData.value(QStringLiteral("semver")).toString();
|
||||
}
|
||||
|
||||
QString archiveKind(const QString &fileName)
|
||||
{
|
||||
if (fileName.endsWith(QLatin1String(".zip"), Qt::CaseInsensitive))
|
||||
return QStringLiteral("zip");
|
||||
if (fileName.endsWith(QLatin1String(".tar.gz"), Qt::CaseInsensitive))
|
||||
return QStringLiteral("tar.gz");
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JavaRuntimeService::JavaRuntimeService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
QList<JavaRuntimeEntry> JavaRuntimeService::entries(JavaRuntimeKind kind) const
|
||||
{
|
||||
QList<JavaRuntimeEntry> result;
|
||||
for (const JavaRuntimeEntry &entry : m_entries) {
|
||||
if (entry.kind == kind)
|
||||
result << entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<JavaRuntimeEntry> JavaRuntimeService::bestFor(int major) const
|
||||
{
|
||||
if (major <= 0)
|
||||
return std::nullopt;
|
||||
|
||||
// Каталог уже отфильтрован по текущей ОС и архитектуре, поэтому здесь
|
||||
// остаётся выбрать из заведомо запускаемого.
|
||||
const auto rank = [](JavaRuntimeKind kind) {
|
||||
switch (kind) {
|
||||
case JavaRuntimeKind::Jdk:
|
||||
return 0;
|
||||
case JavaRuntimeKind::Mojang:
|
||||
return 1;
|
||||
case JavaRuntimeKind::Jre:
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
|
||||
const JavaRuntimeEntry *best = nullptr;
|
||||
for (const JavaRuntimeEntry &entry : m_entries) {
|
||||
if (!entry.isValid() || entry.major < major)
|
||||
continue;
|
||||
if (!best) {
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
// Чем ближе мажорная версия к нужной, тем меньше шансов на сюрприз:
|
||||
// игра на Java 8 под Java 21 просто не стартует.
|
||||
if (entry.major != best->major) {
|
||||
if (entry.major < best->major)
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
if (rank(entry.kind) != rank(best->kind)) {
|
||||
if (rank(entry.kind) < rank(best->kind))
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
if (entry.released.isValid() && best->released.isValid()
|
||||
&& entry.released != best->released) {
|
||||
if (entry.released > best->released)
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
if (entry.version > best->version)
|
||||
best = &entry;
|
||||
}
|
||||
|
||||
if (!best)
|
||||
return std::nullopt;
|
||||
return *best;
|
||||
}
|
||||
|
||||
std::optional<JavaRuntimeEntry> JavaRuntimeService::find(const QString &id) const
|
||||
{
|
||||
const auto it = m_indexById.constFind(id);
|
||||
if (it == m_indexById.constEnd())
|
||||
return std::nullopt;
|
||||
return m_entries.at(it.value());
|
||||
}
|
||||
|
||||
void JavaRuntimeService::ensureLoaded(Callback callback, bool forceRefresh)
|
||||
{
|
||||
if (!m_cacheRead) {
|
||||
m_cacheRead = true;
|
||||
if (readCache())
|
||||
emit catalogChanged();
|
||||
}
|
||||
|
||||
const bool fresh = hasData() && m_fetchedAt.isValid()
|
||||
&& m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
|
||||
if (!forceRefresh && fresh) {
|
||||
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
|
||||
// на попадании в кэш отвечаем через событийный цикл.
|
||||
if (callback)
|
||||
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback)
|
||||
m_pending << std::move(callback);
|
||||
if (isRefreshing())
|
||||
return;
|
||||
startRefresh();
|
||||
}
|
||||
|
||||
void JavaRuntimeService::startRefresh()
|
||||
{
|
||||
m_incoming.clear();
|
||||
m_warnings.clear();
|
||||
m_ltsVersions.clear();
|
||||
|
||||
// Счётчик поднимаем на оба стартовых запроса разом: иначе ответ первого
|
||||
// успел бы обнулить его и завершить обновление раньше времени.
|
||||
m_outstanding = 2;
|
||||
emit refreshingChanged();
|
||||
|
||||
requestAdoptiumReleases();
|
||||
requestMojangRuntimes();
|
||||
}
|
||||
|
||||
void JavaRuntimeService::requestAdoptiumReleases()
|
||||
{
|
||||
QNetworkReply *reply = m_network->get(
|
||||
makeRequest(QUrl(kAdoptiumApi + QStringLiteral("/info/available_releases"))));
|
||||
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
||||
reply->deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
requestFinished(Loc::text("java.error.versionsFetchFailed")
|
||||
.arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(reply->readAll()).object();
|
||||
const QJsonArray lts = root.value(QStringLiteral("available_lts_releases")).toArray();
|
||||
for (const QJsonValue &value : lts)
|
||||
m_ltsVersions.insert(value.toInt());
|
||||
|
||||
const QJsonArray available = root.value(QStringLiteral("available_releases")).toArray();
|
||||
QList<int> majors;
|
||||
for (const QJsonValue &value : available) {
|
||||
const int major = value.toInt();
|
||||
if (major > 0)
|
||||
majors << major;
|
||||
}
|
||||
if (majors.isEmpty()) {
|
||||
requestFinished(Loc::text("java.error.versionsUnparsable"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Запросы добавляем до того, как отчитается этот: иначе счётчик уйдёт
|
||||
// в ноль между двумя строчками.
|
||||
m_outstanding += majors.size();
|
||||
const QString arch = adoptiumArch();
|
||||
for (const int major : std::as_const(majors))
|
||||
requestFeatureRelease(major, arch, arch == QLatin1String("aarch64"));
|
||||
|
||||
requestFinished({});
|
||||
});
|
||||
}
|
||||
|
||||
void JavaRuntimeService::requestFeatureRelease(int major,
|
||||
const QString &architecture,
|
||||
bool allowFallback)
|
||||
{
|
||||
QUrl url(kAdoptiumApi + QStringLiteral("/assets/feature_releases/%1/ga").arg(major));
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("architecture"), architecture);
|
||||
query.addQueryItem(QStringLiteral("heap_size"), QStringLiteral("normal"));
|
||||
query.addQueryItem(QStringLiteral("jvm_impl"), QStringLiteral("hotspot"));
|
||||
query.addQueryItem(QStringLiteral("os"), adoptiumOs());
|
||||
query.addQueryItem(QStringLiteral("page"), QStringLiteral("0"));
|
||||
query.addQueryItem(QStringLiteral("page_size"), QString::number(kReleasesPerMajor));
|
||||
query.addQueryItem(QStringLiteral("project"), QStringLiteral("jdk"));
|
||||
query.addQueryItem(QStringLiteral("sort_method"), QStringLiteral("DEFAULT"));
|
||||
query.addQueryItem(QStringLiteral("sort_order"), QStringLiteral("DESC"));
|
||||
query.addQueryItem(QStringLiteral("vendor"), QStringLiteral("eclipse"));
|
||||
url.setQuery(query);
|
||||
|
||||
QNetworkReply *reply = m_network->get(makeRequest(url));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, major, allowFallback] {
|
||||
reply->deleteLater();
|
||||
|
||||
// 404 значит «под эту платформу такой сборки нет» — это не ошибка сети,
|
||||
// а нормальный ответ: Java 8 и 16 под Apple Silicon просто не собирали.
|
||||
const int status =
|
||||
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
QList<JavaRuntimeEntry> parsed;
|
||||
if (reply->error() == QNetworkReply::NoError)
|
||||
parsed = parseAdoptium(reply->readAll(), m_ltsVersions);
|
||||
|
||||
if (parsed.isEmpty() && allowFallback && (status == 404 || status == 200)) {
|
||||
// На Apple Silicon старые сборки берём под x64: Rosetta их запускает.
|
||||
m_outstanding += 1;
|
||||
requestFeatureRelease(major, QStringLiteral("x64"), false);
|
||||
requestFinished({});
|
||||
return;
|
||||
}
|
||||
|
||||
QString warning;
|
||||
if (reply->error() != QNetworkReply::NoError && status != 404) {
|
||||
warning = Loc::text("java.error.buildsFetchFailed")
|
||||
.arg(major)
|
||||
.arg(reply->errorString());
|
||||
}
|
||||
m_incoming += parsed;
|
||||
requestFinished(warning);
|
||||
});
|
||||
}
|
||||
|
||||
void JavaRuntimeService::requestMojangRuntimes()
|
||||
{
|
||||
QNetworkReply *reply = m_network->get(makeRequest(QUrl(kMojangRuntimesUrl)));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
||||
reply->deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
requestFinished(Loc::text("java.error.mojangRuntimesFailed")
|
||||
.arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
m_incoming += parseMojang(reply->readAll());
|
||||
requestFinished({});
|
||||
});
|
||||
}
|
||||
|
||||
void JavaRuntimeService::requestFinished(const QString &warning)
|
||||
{
|
||||
if (!warning.isEmpty())
|
||||
m_warnings << warning;
|
||||
if (--m_outstanding > 0)
|
||||
return;
|
||||
finishRefresh();
|
||||
}
|
||||
|
||||
void JavaRuntimeService::finishRefresh()
|
||||
{
|
||||
m_outstanding = 0;
|
||||
const QString warning = m_warnings.join(QStringLiteral("; "));
|
||||
// Пустой разбор — испорченные ответы; хороший кэш ими не затираем.
|
||||
const bool ok = !m_incoming.isEmpty();
|
||||
if (ok) {
|
||||
applyEntries(std::exchange(m_incoming, {}));
|
||||
m_fetchedAt = QDateTime::currentDateTimeUtc();
|
||||
writeCache();
|
||||
emit catalogChanged();
|
||||
}
|
||||
m_incoming.clear();
|
||||
m_warnings.clear();
|
||||
emit refreshingChanged();
|
||||
|
||||
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
|
||||
const QList<Callback> pending = std::exchange(m_pending, {});
|
||||
for (const Callback &callback : pending) {
|
||||
// Сеть подвела, но старые данные есть — работаем на них.
|
||||
if (!ok && hasData())
|
||||
callback(true, warning.isEmpty() ? Loc::text("java.status.catalogFromCache") : warning);
|
||||
else
|
||||
callback(ok, warning);
|
||||
}
|
||||
}
|
||||
|
||||
QList<JavaRuntimeEntry> JavaRuntimeService::parseAdoptium(const QByteArray &payload,
|
||||
const QSet<int> <sVersions)
|
||||
{
|
||||
QList<JavaRuntimeEntry> result;
|
||||
const QJsonArray releases = QJsonDocument::fromJson(payload).array();
|
||||
|
||||
for (const QJsonValue &releaseValue : releases) {
|
||||
const QJsonObject release = releaseValue.toObject();
|
||||
const QJsonObject versionData = release.value(QStringLiteral("version_data")).toObject();
|
||||
const int major = versionData.value(QStringLiteral("major")).toInt();
|
||||
const QString releaseName = release.value(QStringLiteral("release_name")).toString();
|
||||
if (major <= 0 || releaseName.isEmpty())
|
||||
continue;
|
||||
|
||||
const QString version = displayVersion(versionData);
|
||||
const QDateTime released = QDateTime::fromString(
|
||||
release.value(QStringLiteral("timestamp")).toString(), Qt::ISODate);
|
||||
|
||||
const QJsonArray binaries = release.value(QStringLiteral("binaries")).toArray();
|
||||
for (const QJsonValue &binaryValue : binaries) {
|
||||
const QJsonObject binary = binaryValue.toObject();
|
||||
const auto kind = javaKindFromKey(binary.value(QStringLiteral("image_type")).toString());
|
||||
if (!kind || *kind == JavaRuntimeKind::Mojang)
|
||||
continue; // testimage, debugimage, sources и прочее нам не нужно
|
||||
|
||||
const QJsonObject package = binary.value(QStringLiteral("package")).toObject();
|
||||
const QUrl url(package.value(QStringLiteral("link")).toString());
|
||||
const QString archive = archiveKind(package.value(QStringLiteral("name")).toString());
|
||||
// Распаковать умеем только zip и tar.gz; https — потому что ссылку
|
||||
// мы получили из сети и открываем её же.
|
||||
if (archive.isEmpty() || url.scheme() != QLatin1String("https"))
|
||||
continue;
|
||||
|
||||
JavaRuntimeEntry entry;
|
||||
entry.kind = *kind;
|
||||
entry.major = major;
|
||||
entry.version = version;
|
||||
entry.released = released;
|
||||
entry.url = url;
|
||||
entry.checksum = package.value(QStringLiteral("checksum")).toString();
|
||||
entry.size = static_cast<qint64>(package.value(QStringLiteral("size")).toDouble());
|
||||
entry.archive = archive;
|
||||
entry.architecture = binary.value(QStringLiteral("architecture")).toString();
|
||||
entry.lts = ltsVersions.contains(major);
|
||||
entry.id = JavaRuntimeStore::sanitizeId(QStringLiteral("temurin-%1-%2-%3")
|
||||
.arg(javaKindKey(*kind),
|
||||
version,
|
||||
entry.architecture));
|
||||
result << entry;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QList<JavaRuntimeEntry> JavaRuntimeService::parseMojang(const QByteArray &payload)
|
||||
{
|
||||
QList<JavaRuntimeEntry> result;
|
||||
const QJsonObject root = QJsonDocument::fromJson(payload).object();
|
||||
const QJsonObject platform = root.value(mojangPlatform()).toObject();
|
||||
|
||||
for (auto it = platform.constBegin(); it != platform.constEnd(); ++it) {
|
||||
// minecraft-java-exe — это лаунчер игры, а не Java.
|
||||
if (it.key() == QLatin1String("minecraft-java-exe"))
|
||||
continue;
|
||||
|
||||
const QJsonArray variants = it.value().toArray();
|
||||
if (variants.isEmpty())
|
||||
continue; // компонента под эту платформу нет
|
||||
|
||||
const QJsonObject variant = variants.first().toObject();
|
||||
const QJsonObject manifest = variant.value(QStringLiteral("manifest")).toObject();
|
||||
const QUrl url(manifest.value(QStringLiteral("url")).toString());
|
||||
if (url.scheme() != QLatin1String("https"))
|
||||
continue;
|
||||
|
||||
const QJsonObject versionObject = variant.value(QStringLiteral("version")).toObject();
|
||||
const QString version = versionObject.value(QStringLiteral("name")).toString();
|
||||
if (version.isEmpty())
|
||||
continue;
|
||||
|
||||
JavaRuntimeEntry entry;
|
||||
entry.kind = JavaRuntimeKind::Mojang;
|
||||
entry.component = it.key();
|
||||
entry.version = version;
|
||||
// «8u51-cacert462b08» у jre-legacy, «21.0.7» у остальных.
|
||||
entry.major = version.startsWith(QLatin1String("8u"))
|
||||
? 8
|
||||
: version.section(u'.', 0, 0).toInt();
|
||||
entry.released = QDateTime::fromString(
|
||||
versionObject.value(QStringLiteral("released")).toString(), Qt::ISODate);
|
||||
entry.url = url;
|
||||
entry.checksum = manifest.value(QStringLiteral("sha1")).toString();
|
||||
entry.size = 0; // манифест знает только свой размер, не размер рантайма
|
||||
entry.archive = QStringLiteral("mojang");
|
||||
entry.architecture = QSysInfo::currentCpuArchitecture();
|
||||
entry.lts = false;
|
||||
entry.id = JavaRuntimeStore::sanitizeId(QStringLiteral("mojang-") + it.key());
|
||||
|
||||
if (entry.major > 0)
|
||||
result << entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void JavaRuntimeService::applyEntries(QList<JavaRuntimeEntry> list)
|
||||
{
|
||||
std::stable_sort(list.begin(), list.end(),
|
||||
[](const JavaRuntimeEntry &a, const JavaRuntimeEntry &b) {
|
||||
if (a.major != b.major)
|
||||
return a.major > b.major;
|
||||
if (a.released != b.released)
|
||||
return a.released > b.released;
|
||||
return a.version > b.version;
|
||||
});
|
||||
|
||||
m_entries = std::move(list);
|
||||
m_indexById.clear();
|
||||
m_indexById.reserve(m_entries.size());
|
||||
for (int i = 0; i < m_entries.size(); ++i)
|
||||
m_indexById.insert(m_entries.at(i).id, i);
|
||||
}
|
||||
|
||||
bool JavaRuntimeService::readCache()
|
||||
{
|
||||
QFile file(LauncherPaths::javaCatalogFile());
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
// Кэш платформозависим: папку лаунчера могли принести с другой машины.
|
||||
if (root.value(QStringLiteral("platform")).toString() != mojangPlatform())
|
||||
return false;
|
||||
|
||||
QList<JavaRuntimeEntry> parsed;
|
||||
const QJsonArray entries = root.value(QStringLiteral("entries")).toArray();
|
||||
for (const QJsonValue &value : entries) {
|
||||
const QJsonObject object = value.toObject();
|
||||
const auto kind = javaKindFromKey(object.value(QStringLiteral("kind")).toString());
|
||||
if (!kind)
|
||||
continue;
|
||||
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = object.value(QStringLiteral("id")).toString();
|
||||
entry.kind = *kind;
|
||||
entry.major = object.value(QStringLiteral("major")).toInt();
|
||||
entry.version = object.value(QStringLiteral("version")).toString();
|
||||
entry.component = object.value(QStringLiteral("component")).toString();
|
||||
entry.released = QDateTime::fromString(
|
||||
object.value(QStringLiteral("released")).toString(), Qt::ISODate);
|
||||
entry.url = QUrl(object.value(QStringLiteral("url")).toString());
|
||||
entry.checksum = object.value(QStringLiteral("checksum")).toString();
|
||||
entry.size = static_cast<qint64>(object.value(QStringLiteral("size")).toDouble());
|
||||
entry.archive = object.value(QStringLiteral("archive")).toString();
|
||||
entry.architecture = object.value(QStringLiteral("architecture")).toString();
|
||||
entry.lts = object.value(QStringLiteral("lts")).toBool();
|
||||
|
||||
if (entry.isValid() && entry.url.scheme() == QLatin1String("https"))
|
||||
parsed << entry;
|
||||
}
|
||||
if (parsed.isEmpty())
|
||||
return false;
|
||||
|
||||
applyEntries(std::move(parsed));
|
||||
m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
|
||||
Qt::ISODate);
|
||||
return true;
|
||||
}
|
||||
|
||||
void JavaRuntimeService::writeCache()
|
||||
{
|
||||
// Папку кэша могли удалить уже после запуска лаунчера.
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
|
||||
QJsonArray entries;
|
||||
for (const JavaRuntimeEntry &entry : std::as_const(m_entries)) {
|
||||
entries << QJsonObject{{QStringLiteral("id"), entry.id},
|
||||
{QStringLiteral("kind"), javaKindKey(entry.kind)},
|
||||
{QStringLiteral("major"), entry.major},
|
||||
{QStringLiteral("version"), entry.version},
|
||||
{QStringLiteral("component"), entry.component},
|
||||
{QStringLiteral("released"), entry.released.toString(Qt::ISODate)},
|
||||
{QStringLiteral("url"), entry.url.toString()},
|
||||
{QStringLiteral("checksum"), entry.checksum},
|
||||
{QStringLiteral("size"), static_cast<double>(entry.size)},
|
||||
{QStringLiteral("archive"), entry.archive},
|
||||
{QStringLiteral("architecture"), entry.architecture},
|
||||
{QStringLiteral("lts"), entry.lts}};
|
||||
}
|
||||
|
||||
const QJsonObject root{{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
|
||||
{QStringLiteral("platform"), mojangPlatform()},
|
||||
{QStringLiteral("entries"), entries}};
|
||||
|
||||
QSaveFile file(LauncherPaths::javaCatalogFile());
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "javaruntime.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в папке
|
||||
// лаунчера и отдаёт из кэша, пока тот не устарел. Устроен как
|
||||
// VersionManifestService и работает в GUI-потоке.
|
||||
//
|
||||
// Источников два и они дополняют друг друга:
|
||||
//
|
||||
// * Mojang (категория «Java») — ровно тот рантайм, которым запускает игру
|
||||
// официальный лаунчер. Версий немного, зато они заведомо совместимы.
|
||||
//
|
||||
// * Eclipse Temurin (категории JDK и JRE) — свежие сборки всех мажорных
|
||||
// версий, включая те, до которых Mojang ещё не дошёл.
|
||||
//
|
||||
// Отдаются только сборки под текущие ОС и архитектуру: выбрать заведомо
|
||||
// неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16
|
||||
// сборок aarch64 нет вовсе и приходится брать x64 (работает через Rosetta).
|
||||
class JavaRuntimeService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// ok = true и непустой warning — данные отдали из устаревшего кэша.
|
||||
using Callback = std::function<void(bool ok, const QString &warning)>;
|
||||
|
||||
explicit JavaRuntimeService(QObject *parent = nullptr);
|
||||
|
||||
void ensureLoaded(Callback callback, bool forceRefresh = false);
|
||||
|
||||
// Новые версии сверху.
|
||||
QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const;
|
||||
std::optional<JavaRuntimeEntry> find(const QString &id) const;
|
||||
|
||||
// Что скачать, если для версии игры нужна Java major, а подходящей в
|
||||
// системе нет. Точное совпадение мажорной версии предпочтительнее более
|
||||
// новой, JDK — предпочтительнее JRE: установщики Forge и NeoForge иногда
|
||||
// требуют инструментов из полного комплекта. Пусто — предложить нечего.
|
||||
std::optional<JavaRuntimeEntry> bestFor(int major) const;
|
||||
|
||||
bool isRefreshing() const { return m_outstanding > 0; }
|
||||
bool hasData() const { return !m_entries.isEmpty(); }
|
||||
|
||||
signals:
|
||||
void catalogChanged();
|
||||
void refreshingChanged();
|
||||
|
||||
private:
|
||||
void startRefresh();
|
||||
void requestAdoptiumReleases();
|
||||
// allowFallback — на macOS/aarch64 разрешено переспросить сборку под x64.
|
||||
void requestFeatureRelease(int major, const QString &architecture, bool allowFallback);
|
||||
void requestMojangRuntimes();
|
||||
|
||||
// Общий хвост любого запроса: считает незавершённые и, когда их не
|
||||
// осталось, применяет накопленное.
|
||||
void requestFinished(const QString &warning);
|
||||
void finishRefresh();
|
||||
|
||||
static QList<JavaRuntimeEntry> parseAdoptium(const QByteArray &payload,
|
||||
const QSet<int> <sVersions);
|
||||
static QList<JavaRuntimeEntry> parseMojang(const QByteArray &payload);
|
||||
|
||||
void applyEntries(QList<JavaRuntimeEntry> list);
|
||||
bool readCache();
|
||||
void writeCache();
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
|
||||
QList<JavaRuntimeEntry> m_entries;
|
||||
QHash<QString, int> m_indexById;
|
||||
QDateTime m_fetchedAt;
|
||||
bool m_cacheRead = false;
|
||||
|
||||
// Накопитель одного обновления: ответы приходят вразнобой, каталог
|
||||
// подменяется целиком и только когда пришли все.
|
||||
QList<JavaRuntimeEntry> m_incoming;
|
||||
QStringList m_warnings;
|
||||
QSet<int> m_ltsVersions;
|
||||
int m_outstanding = 0;
|
||||
|
||||
QList<Callback> m_pending;
|
||||
};
|
||||
@@ -1,410 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "minecraftversion.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QStringList>
|
||||
#include <QVariantMap>
|
||||
|
||||
struct AuthResult;
|
||||
class AuthService;
|
||||
class MsaAuthService;
|
||||
class GameLauncher;
|
||||
class VersionManifestService;
|
||||
class VersionInstaller;
|
||||
class ModLoaderVersionService;
|
||||
class ModLoaderInstaller;
|
||||
class BuildSwitcher;
|
||||
class JavaRuntimeService;
|
||||
class JavaInstaller;
|
||||
class SeasonalBuildService;
|
||||
class SeasonalPackDownloader;
|
||||
|
||||
class LauncherBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(QStringList profileNames READ profileNames NOTIFY profilesChanged)
|
||||
Q_PROPERTY(QStringList customBuildNames READ customBuildNames NOTIFY customBuildsChanged)
|
||||
|
||||
// Сборка, которую запускает кнопка игры. Хранится по id сборки, а не по
|
||||
// индексу: удаление соседней записи не должно переназначать активную.
|
||||
Q_PROPERTY(int activeBuildIndex READ activeBuildIndex WRITE setActiveBuildIndex
|
||||
NOTIFY activeBuildChanged)
|
||||
Q_PROPERTY(QString activeBuildName READ activeBuildName NOTIFY activeBuildChanged)
|
||||
Q_PROPERTY(QStringList installedVersions READ installedVersions NOTIFY installedVersionsChanged)
|
||||
Q_PROPERTY(bool gameRunning READ gameRunning NOTIFY gameRunningChanged)
|
||||
|
||||
// Идёт архивация/распаковка .minecraft при смене сборки. Отдельно от busy,
|
||||
// потому что на это время блокируется ещё и список сборок.
|
||||
Q_PROPERTY(bool switching READ switching NOTIFY switchChanged)
|
||||
Q_PROPERTY(double switchProgress READ switchProgress NOTIFY switchChanged)
|
||||
Q_PROPERTY(QString switchStage READ switchStage NOTIFY switchChanged)
|
||||
Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
|
||||
// Собран ли лаунчер с Qt WebEngine. Без него окно входа Microsoft
|
||||
// показать нечем, и интерфейс не должен предлагать этот путь.
|
||||
Q_PROPERTY(bool microsoftAvailable READ microsoftAvailable CONSTANT)
|
||||
|
||||
// Объединённый список для выпадающего меню версий: установленные, разделитель,
|
||||
// затем весь каталог Mojang.
|
||||
Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged)
|
||||
Q_PROPERTY(bool catalogLoading READ catalogLoading NOTIFY catalogLoadingChanged)
|
||||
|
||||
// Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат
|
||||
// в папке лаунчера.
|
||||
Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged)
|
||||
Q_PROPERTY(bool javaCatalogLoading READ javaCatalogLoading NOTIFY javaCatalogLoadingChanged)
|
||||
|
||||
// Готовые сборки с сервера. Строки таблицы уже сведены с локальными
|
||||
// записями: окно показывает статус, не считая ничего само.
|
||||
Q_PROPERTY(QVariantList seasonalCatalog READ seasonalCatalog NOTIFY seasonalCatalogChanged)
|
||||
Q_PROPERTY(bool seasonalCatalogLoading READ seasonalCatalogLoading
|
||||
NOTIFY seasonalCatalogLoadingChanged)
|
||||
Q_PROPERTY(QString seasonalCatalogError READ seasonalCatalogError NOTIFY seasonalCatalogChanged)
|
||||
// Идёт установка сезонной сборки: окно не даёт начать вторую.
|
||||
Q_PROPERTY(bool seasonalInstalling READ seasonalInstalling NOTIFY seasonalInstallingChanged)
|
||||
|
||||
// Одного сигнала на все свойства загрузки достаточно: установщик уже
|
||||
// ограничивает частоту, а QML всё равно перечитывает их разом.
|
||||
Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged)
|
||||
Q_PROPERTY(double downloadProgress READ downloadProgress NOTIFY downloadChanged)
|
||||
Q_PROPERTY(QString downloadVersion READ downloadVersion NOTIFY downloadChanged)
|
||||
Q_PROPERTY(QString downloadStatus READ downloadStatus NOTIFY downloadChanged)
|
||||
Q_PROPERTY(qint64 downloadBytesDone READ downloadBytesDone NOTIFY downloadChanged)
|
||||
Q_PROPERTY(qint64 downloadBytesTotal READ downloadBytesTotal NOTIFY downloadChanged)
|
||||
|
||||
public:
|
||||
explicit LauncherBackend(QObject *parent = nullptr);
|
||||
~LauncherBackend() override;
|
||||
|
||||
QStringList profileNames() const;
|
||||
QStringList customBuildNames() const;
|
||||
int activeBuildIndex() const;
|
||||
void setActiveBuildIndex(int index);
|
||||
QString activeBuildName() const;
|
||||
QStringList installedVersions() const;
|
||||
bool gameRunning() const;
|
||||
bool switching() const;
|
||||
double switchProgress() const;
|
||||
QString switchStage() const;
|
||||
QString switchStatus() const;
|
||||
// Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет.
|
||||
bool busy() const;
|
||||
bool microsoftAvailable() const;
|
||||
|
||||
QVariantList versionCatalog() const;
|
||||
bool catalogLoading() const;
|
||||
|
||||
QVariantList javaCatalog() const;
|
||||
bool javaCatalogLoading() const;
|
||||
|
||||
QVariantList seasonalCatalog() const;
|
||||
bool seasonalCatalogLoading() const;
|
||||
QString seasonalCatalogError() const;
|
||||
bool seasonalInstalling() const;
|
||||
|
||||
bool downloading() const;
|
||||
double downloadProgress() const;
|
||||
QString downloadVersion() const;
|
||||
QString downloadStatus() const;
|
||||
qint64 downloadBytesDone() const;
|
||||
qint64 downloadBytesTotal() const;
|
||||
|
||||
Q_INVOKABLE void addProfile(const QString &name,
|
||||
const QString &login,
|
||||
const QString &password,
|
||||
const QString &authType = QStringLiteral("offline"));
|
||||
Q_INVOKABLE void addCustomBuild(const QString &name,
|
||||
const QString &serverUrl,
|
||||
const QString &minecraftVersion = QString());
|
||||
Q_INVOKABLE void updateProfile(int index,
|
||||
const QString &name,
|
||||
const QString &login,
|
||||
const QString &password,
|
||||
const QString &authType = QStringLiteral("offline"));
|
||||
// Мержит только присланные ключи: name, serverUrl, minecraftVersion,
|
||||
// loader, loaderVersion, resolvedVersionId.
|
||||
Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields);
|
||||
Q_INVOKABLE QVariantMap profileAt(int index) const;
|
||||
|
||||
// Вход в аккаунт Microsoft. profileIndex = -1 — вход ещё не привязан к
|
||||
// профилю: он создастся по нику, который вернут Minecraft Services.
|
||||
Q_INVOKABLE void startMicrosoftLogin(int profileIndex);
|
||||
Q_INVOKABLE void finishMicrosoftLogin(const QString &code);
|
||||
Q_INVOKABLE void cancelMicrosoftLogin();
|
||||
// Разбор адреса, на который встроенное окно возвращается после входа.
|
||||
// Живёт в C++, чтобы правила совпадения не разъезжались с сервисом.
|
||||
Q_INVOKABLE QVariantMap inspectMicrosoftRedirect(const QString &url) const;
|
||||
Q_INVOKABLE QVariantMap customBuildAt(int index) const;
|
||||
Q_INVOKABLE void removeProfile(int index);
|
||||
// Что именно потеряется при удалении — для текста предупреждения.
|
||||
Q_INVOKABLE QVariantMap customBuildRemovalInfo(int index) const;
|
||||
Q_INVOKABLE void removeCustomBuild(int index);
|
||||
|
||||
// Докачать то, чего не хватает выбранной сборке. Отдельной кнопкой, потому
|
||||
// что карточка сборки сохраняет правки по ходу редактирования.
|
||||
Q_INVOKABLE void installCustomBuild(int index);
|
||||
|
||||
// Главная кнопка. Проверяет выбор профиля и версии, комплектность .minecraft,
|
||||
// при необходимости авторизуется и стартует игру.
|
||||
Q_INVOKABLE void launchGame(int profileIndex, int buildIndex);
|
||||
Q_INVOKABLE void submitTwoFactorCode(const QString &code);
|
||||
Q_INVOKABLE void cancelPendingLaunch();
|
||||
Q_INVOKABLE void stopGame();
|
||||
|
||||
// Проверка без запуска — для подсказки в интерфейсе.
|
||||
Q_INVOKABLE QStringList checkInstallation(int buildIndex) const;
|
||||
|
||||
// Вызывается при открытии выпадающего списка версий: свежий кэш — без сети.
|
||||
Q_INVOKABLE void refreshVersionCatalog(bool force = false);
|
||||
Q_INVOKABLE bool isVersionInstalled(const QString &versionId) const;
|
||||
Q_INVOKABLE void installVersion(const QString &versionId);
|
||||
// Что потеряется при удалении версии — для текста предупреждения.
|
||||
Q_INVOKABLE QVariantMap versionRemovalInfo(const QString &versionId) const;
|
||||
Q_INVOKABLE void removeVersion(const QString &versionId);
|
||||
Q_INVOKABLE void cancelDownload();
|
||||
|
||||
// Версии модлоадеров для выбранной версии игры. Возвращает список карт
|
||||
// {version, label, recommended, stable}; несовместимых строк в нём нет —
|
||||
// отбор заложен в сам источник данных.
|
||||
Q_INVOKABLE QVariantList loaderVersions(const QString &loaderKey,
|
||||
const QString &gameVersion) const;
|
||||
Q_INVOKABLE void refreshLoaderVersions(const QString &loaderKey,
|
||||
const QString &gameVersion,
|
||||
bool force = false);
|
||||
Q_INVOKABLE bool loaderVersionsLoading(const QString &loaderKey,
|
||||
const QString &gameVersion) const;
|
||||
// Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль
|
||||
// в resolvedVersionId.
|
||||
Q_INVOKABLE void installLoaderForBuild(int index);
|
||||
|
||||
Q_INVOKABLE QVariantMap settings() const;
|
||||
Q_INVOKABLE void updateSettings(const QVariantMap &values);
|
||||
Q_INVOKABLE QStringList detectedJava() const;
|
||||
|
||||
// ── Сборки Java ─────────────────────────────────────────────────────
|
||||
// Вызывается при открытии окна выбора: свежий кэш — без сети.
|
||||
Q_INVOKABLE void refreshJavaCatalog(bool force = false);
|
||||
Q_INVOKABLE void installJavaRuntime(const QString &runtimeId);
|
||||
Q_INVOKABLE void removeJavaRuntime(const QString &runtimeId);
|
||||
// Пусто — сборки с таким id в папке лаунчера нет.
|
||||
Q_INVOKABLE QVariantMap javaRuntimeInfo(const QString &runtimeId) const;
|
||||
// Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана.
|
||||
Q_INVOKABLE int requiredJavaMajor(int buildIndex) const;
|
||||
|
||||
// ── Сезонные сборки ─────────────────────────────────────────────────
|
||||
Q_INVOKABLE void refreshSeasonalCatalog(bool force = false);
|
||||
// Ставит или обновляет сборку целиком: запись, версия, модлоадер, Java и
|
||||
// файлы — одной цепочкой.
|
||||
Q_INVOKABLE void installSeasonalBuild(const QString &seasonalId);
|
||||
Q_INVOKABLE void cancelSeasonalInstall();
|
||||
|
||||
Q_INVOKABLE void openMinecraftFolder();
|
||||
Q_INVOKABLE void openGameFolder();
|
||||
|
||||
signals:
|
||||
void profilesChanged();
|
||||
void customBuildsChanged();
|
||||
void activeBuildChanged();
|
||||
void switchChanged();
|
||||
void loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion);
|
||||
void installedVersionsChanged();
|
||||
void settingsChanged();
|
||||
void gameRunningChanged();
|
||||
void busyChanged();
|
||||
void versionCatalogChanged();
|
||||
void catalogLoadingChanged();
|
||||
void javaCatalogChanged();
|
||||
void javaCatalogLoadingChanged();
|
||||
void seasonalCatalogChanged();
|
||||
void seasonalCatalogLoadingChanged();
|
||||
void seasonalInstallingChanged();
|
||||
void downloadChanged();
|
||||
|
||||
// Сезонная сборка установлена и активна — можно запускать игру.
|
||||
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
||||
|
||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||
void javaRuntimeInstalled(const QString &runtimeId);
|
||||
|
||||
void launchProgress(const QString &message);
|
||||
void launched(const QString &profileName, const QString &buildName, const QString &serverUrl);
|
||||
void launchError(const QString &message);
|
||||
void twoFactorRequired(const QString &profileName);
|
||||
// Окну входа Microsoft: открыться на этом адресе.
|
||||
void microsoftLoginUrlReady(const QString &url);
|
||||
void microsoftLoginSucceeded(const QString &playerName);
|
||||
void microsoftLoginFailed(const QString &message);
|
||||
// Сессия профиля протухла настолько, что нужен повторный вход руками.
|
||||
void microsoftReloginRequired(int profileIndex);
|
||||
void gameOutput(const QString &line);
|
||||
void gameFinished(int exitCode, bool crashed);
|
||||
|
||||
private:
|
||||
struct Profile
|
||||
{
|
||||
int id = 0;
|
||||
QString name, login, password;
|
||||
QString authType = QStringLiteral("offline"); // offline | elyby | microsoft
|
||||
QString clientToken, accessToken, uuid, resolvedName, userType;
|
||||
// Только для microsoft: продление сессии, ${auth_xuid} и срок жизни
|
||||
// accessToken в ISO-8601 (чтобы класть в JSON без конвертеров).
|
||||
QString refreshToken, xuid, tokenExpiry;
|
||||
};
|
||||
// Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со
|
||||
// второго этапа, выбранный модлоадер.
|
||||
struct CustomBuild
|
||||
{
|
||||
int id = 0;
|
||||
QString name, serverUrl;
|
||||
QString minecraftVersion; // 1.20.1
|
||||
QString loader; // "" | forge | fabric | neoforge | quilt
|
||||
QString loaderVersion; // 47.4.0 | 0.19.3
|
||||
QString resolvedVersionId; // versions/<id>, который реально запускается
|
||||
// Сборка приехала с сервера сезонных сборок: id её строки в каталоге и
|
||||
// установленная ревизия. Пусто — обычная пользовательская сборка.
|
||||
QString seasonalId;
|
||||
int seasonalRevision = 0;
|
||||
};
|
||||
|
||||
// Шаг установки сезонной сборки. Порядок жёсткий: Java нужна установщику
|
||||
// Forge, а раскатывать файлы поверх имеет смысл только когда всё остальное
|
||||
// на месте.
|
||||
enum class SeasonStage { Idle, Switching, Java, Loader, Downloading, Applying };
|
||||
struct SeasonalInstall
|
||||
{
|
||||
SeasonStage stage = SeasonStage::Idle;
|
||||
int buildId = -1;
|
||||
QString packPath;
|
||||
// Копия строки каталога на момент старта: обновление списка посреди
|
||||
// установки не должно менять то, что ставится.
|
||||
SeasonalBuildEntry entry;
|
||||
};
|
||||
|
||||
void loadData();
|
||||
void loadSettings();
|
||||
void ensureDataFiles();
|
||||
void saveProfiles();
|
||||
void saveCustomBuilds();
|
||||
void saveSettings();
|
||||
|
||||
// Чтение файлов лаунчера. Отсутствие файла — норма (первый запуск), а вот
|
||||
// повреждённое содержимое отводится в .bak, чтобы файл создался заново.
|
||||
QJsonArray readArrayFile(const QString &path);
|
||||
QJsonObject readObjectFile(const QString &path);
|
||||
bool moveAsideBroken(const QString &path, const QString &reason);
|
||||
|
||||
// Проверяет папку лаунчера и пишет документ. Об ошибке сообщает через launchError.
|
||||
bool writeJsonFile(const QString &path, const QJsonDocument &document);
|
||||
|
||||
QString gameDir() const;
|
||||
// id профиля в <gameDir>/versions, который запускает эта сборка.
|
||||
QString launchVersionIdOf(const CustomBuild &build) const;
|
||||
int indexOfBuildId(int buildId) const;
|
||||
|
||||
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
||||
// после переименования. true — миграция прошла, читать нужно новый файл.
|
||||
bool migrateLegacyBuildsFile();
|
||||
|
||||
void invalidateCatalog();
|
||||
void rebuildCatalog() const;
|
||||
void invalidateJavaCatalog();
|
||||
void rebuildJavaCatalog() const;
|
||||
void invalidateSeasonalCatalog();
|
||||
void rebuildSeasonalCatalog() const;
|
||||
// Панель загрузки занята скачиванием пака сезонной сборки.
|
||||
bool packDownloadShown() const;
|
||||
|
||||
// ── Установка сезонной сборки ───────────────────────────────────────
|
||||
// Каждый шаг либо запускает асинхронную операцию и возвращает true, либо
|
||||
// решает, что делать нечего, и уступает следующему.
|
||||
void seasonAdvance();
|
||||
bool seasonBeginSwitch();
|
||||
bool seasonBeginJava();
|
||||
bool seasonBeginLoader();
|
||||
bool seasonBeginDownload();
|
||||
bool seasonBeginApply();
|
||||
// message пусто — о причине уже сообщил тот, кто её обнаружил.
|
||||
void seasonFail(const QString &message);
|
||||
void seasonFinish();
|
||||
int indexOfSeasonalBuild(const QString &seasonalId) const;
|
||||
// Что принесла установленная ревизия сборки; пусто — сезонных файлов нет.
|
||||
QStringList seasonalPackEntries(int buildId) const;
|
||||
// Запоминает раскатанную ревизию: и в builds/<id>/season.json, и в записи
|
||||
// сборки. Вызывается и после доигрывания прерванной установки.
|
||||
void recordSeasonalPack(int buildId, const QJsonObject ¬e, const QStringList &entries);
|
||||
QUrl seasonalBaseUrl() const;
|
||||
// Панель загрузки занята установкой Java, а не версии или модлоадера.
|
||||
bool javaDownloadShown() const;
|
||||
// Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана
|
||||
// или ещё не скачана; тогда в дело идёт javaPath и автопоиск.
|
||||
QString managedJavaPath() const;
|
||||
// Версия выбрана, но её нет в .minecraft — ставим в фоне.
|
||||
void maybeInstall(const QString &versionId);
|
||||
|
||||
void setBusy(bool busy);
|
||||
void failLaunch(const QString &message);
|
||||
void beginAuthentication();
|
||||
void beginMicrosoftAuthentication();
|
||||
void applyMicrosoftResult(Profile &profile, const AuthResult &auth);
|
||||
void continueLaunch(const AuthResult &auth);
|
||||
|
||||
QList<Profile> m_profiles;
|
||||
QList<CustomBuild> m_builds;
|
||||
QVariantMap m_settings;
|
||||
// Проблемы хранилища, замеченные на старте: показываются одним сообщением,
|
||||
// когда интерфейс уже подключился к сигналам.
|
||||
QStringList m_storageIssues;
|
||||
int m_nextProfileId = 1;
|
||||
int m_nextBuildId = 1;
|
||||
|
||||
AuthService *m_auth;
|
||||
MsaAuthService *m_msa;
|
||||
GameLauncher *m_launcher;
|
||||
VersionManifestService *m_manifest;
|
||||
VersionInstaller *m_installer;
|
||||
ModLoaderVersionService *m_loaderMeta;
|
||||
ModLoaderInstaller *m_loaderInstaller;
|
||||
BuildSwitcher *m_switcher;
|
||||
JavaRuntimeService *m_javaMeta;
|
||||
JavaInstaller *m_javaInstaller;
|
||||
SeasonalBuildService *m_seasonalMeta;
|
||||
SeasonalPackDownloader *m_packDownloader;
|
||||
|
||||
// Сборка, для которой сейчас ставится модлоадер: по её id находим запись,
|
||||
// когда установщик отчитается.
|
||||
int m_loaderInstallBuildId = -1;
|
||||
|
||||
// Профиль, для которого открыто окно входа Microsoft. -1 — окно закрыто,
|
||||
// -2 — вход для профиля, которого ещё нет.
|
||||
int m_msaLoginProfile = -1;
|
||||
|
||||
// Каталог версий пересобирается только по изменениям: QML читает свойство
|
||||
// помногу раз за кадр, пока открыт список.
|
||||
mutable QVariantList m_catalog;
|
||||
mutable bool m_catalogDirty = true;
|
||||
|
||||
mutable QVariantList m_javaCatalog;
|
||||
mutable bool m_javaCatalogDirty = true;
|
||||
|
||||
mutable QVariantList m_seasonalCatalog;
|
||||
mutable bool m_seasonalCatalogDirty = true;
|
||||
|
||||
SeasonalInstall m_season;
|
||||
|
||||
bool m_busy = false;
|
||||
|
||||
// Состояние запуска, начатого до асинхронной авторизации.
|
||||
int m_pendingProfile = -1;
|
||||
int m_pendingBuild = -1;
|
||||
MinecraftVersion m_pendingVersionData;
|
||||
QString m_pendingInjectorPath;
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kFolderName = QStringLiteral("galeonLauncher");
|
||||
|
||||
} // namespace
|
||||
|
||||
QString LauncherPaths::containerDir()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
return QDir::homePath() + QStringLiteral("/AppData/Roaming");
|
||||
#elif defined(Q_OS_MACOS)
|
||||
return QDir::homePath() + QStringLiteral("/Library/Application Support");
|
||||
#else // Linux и прочие Unix-системы
|
||||
return QDir::homePath();
|
||||
#endif
|
||||
}
|
||||
|
||||
QString LauncherPaths::rootDir()
|
||||
{
|
||||
return containerDir() + u'/' + kFolderName;
|
||||
}
|
||||
|
||||
QString LauncherPaths::defaultMinecraftDir()
|
||||
{
|
||||
#if defined(Q_OS_MACOS)
|
||||
// На macOS папка игры исторически без точки.
|
||||
return containerDir() + QStringLiteral("/minecraft");
|
||||
#else
|
||||
return containerDir() + QStringLiteral("/.minecraft");
|
||||
#endif
|
||||
}
|
||||
|
||||
QString LauncherPaths::settingsFile()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/settings.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::profilesFile()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/profiles.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::customBuildsFile()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/customBuilds.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::legacyCustomBuildsFile()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/versions.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::buildStorageDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/builds");
|
||||
}
|
||||
|
||||
QString LauncherPaths::buildDir(int buildId)
|
||||
{
|
||||
return buildStorageDir() + u'/' + QString::number(buildId);
|
||||
}
|
||||
|
||||
QString LauncherPaths::seasonalStateFile(int buildId)
|
||||
{
|
||||
return buildDir(buildId) + QStringLiteral("/season.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::runtimeDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/runtime");
|
||||
}
|
||||
|
||||
QString LauncherPaths::javaDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/java");
|
||||
}
|
||||
|
||||
QString LauncherPaths::javaCatalogFile()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/java_runtimes.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::javaDownloadDir()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/java");
|
||||
}
|
||||
|
||||
QString LauncherPaths::cacheDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/cache");
|
||||
}
|
||||
|
||||
QString LauncherPaths::versionManifestFile()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/version_manifest.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::seasonalCatalogFile()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/seasonal_builds.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::loaderCacheFile(const QString &loaderKey)
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::loaderDownloadDir()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/loaders");
|
||||
}
|
||||
|
||||
bool LauncherPaths::ensureRootExists(QString *error)
|
||||
{
|
||||
const QStringList required{rootDir(), runtimeDir(), cacheDir(),
|
||||
loaderDownloadDir(), buildStorageDir(), javaDir(),
|
||||
javaDownloadDir()};
|
||||
for (const QString &path : required) {
|
||||
if (!QDir().mkpath(path)) {
|
||||
if (error)
|
||||
*error = Loc::text("storage.error.createFailed").arg(QDir::toNativeSeparators(path));
|
||||
return false;
|
||||
}
|
||||
|
||||
// mkpath отвечает true и для существующего каталога, в который нельзя писать,
|
||||
// и для подсунутого вместо папки файла — тогда все сохранения провалились бы молча.
|
||||
const QFileInfo info(path);
|
||||
if (!info.isDir() || !info.isWritable()) {
|
||||
if (error)
|
||||
*error = Loc::text("storage.error.notWritable").arg(QDir::toNativeSeparators(path));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
// Пути к данным лаунчера. Собственная папка galeonLauncher лежит рядом со
|
||||
// стандартной .minecraft, а не в системном каталоге приложения.
|
||||
namespace LauncherPaths {
|
||||
|
||||
// Родительская папка, в которой лежит .minecraft (и наша galeonLauncher).
|
||||
QString containerDir();
|
||||
|
||||
// <containerDir>/galeonLauncher — корень данных лаунчера.
|
||||
QString rootDir();
|
||||
|
||||
// Стандартная папка игры.
|
||||
QString defaultMinecraftDir();
|
||||
|
||||
QString settingsFile();
|
||||
QString profilesFile();
|
||||
|
||||
// <root>/customBuilds.json — пользовательские сборки.
|
||||
QString customBuildsFile();
|
||||
|
||||
// <root>/versions.json — как сборки назывались до переименования. Читается
|
||||
// один раз при миграции и больше ни для чего не нужен.
|
||||
QString legacyCustomBuildsFile();
|
||||
|
||||
// <root>/builds — архивы содержимого .minecraft по одному на сборку.
|
||||
QString buildStorageDir();
|
||||
|
||||
// <root>/builds/<id> — папка одной сборки: её архив и, у сезонных, скачанный
|
||||
// пак с описанием установленной ревизии.
|
||||
QString buildDir(int buildId);
|
||||
|
||||
// <root>/builds/<id>/season.json — какая ревизия сезонной сборки установлена и
|
||||
// какие файлы она принесла. Список нужен, чтобы при обновлении убрать те, что
|
||||
// из сборки ушли.
|
||||
QString seasonalStateFile(int buildId);
|
||||
|
||||
// Сюда качается authlib-injector.
|
||||
QString runtimeDir();
|
||||
|
||||
// <root>/java — сборки Java, скачанные лаунчером. Каждая в своей подпапке,
|
||||
// имя подпапки — идентификатор сборки из каталога.
|
||||
QString javaDir();
|
||||
|
||||
// Слепок каталога доступных сборок Java с отметкой времени.
|
||||
QString javaCatalogFile();
|
||||
|
||||
// Сюда качаются архивы Temurin до распаковки.
|
||||
QString javaDownloadDir();
|
||||
|
||||
// <root>/cache — данные, которые можно удалить без потерь.
|
||||
QString cacheDir();
|
||||
|
||||
// Слепок манифеста версий Mojang с отметкой времени.
|
||||
QString versionManifestFile();
|
||||
|
||||
// Слепок каталога сезонных сборок с отметкой времени.
|
||||
QString seasonalCatalogFile();
|
||||
|
||||
// Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt.
|
||||
QString loaderCacheFile(const QString &loaderKey);
|
||||
|
||||
// Сюда качаются installer.jar модлоадеров.
|
||||
QString loaderDownloadDir();
|
||||
|
||||
// Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед
|
||||
// каждой записью. false и текст в error, если папки нет или в неё не пишется.
|
||||
bool ensureRootExists(QString *error = nullptr);
|
||||
|
||||
} // namespace LauncherPaths
|
||||
@@ -1,291 +0,0 @@
|
||||
#include "localization.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QJSEngine>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QLocale>
|
||||
#include <QThread>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <utility>
|
||||
|
||||
// ── Про потоки ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Каталог (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<QStringList> 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
|
||||
@@ -1,108 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAtomicInt>
|
||||
#include <QHash>
|
||||
#include <QJSValue>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantList>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
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<QString, QStringList> m_singles; // ключ -> значения по языкам
|
||||
QHash<QString, QList<QStringList>> m_lists; // ключ -> списки по языкам
|
||||
QStringList m_codes; // ["ru", "en"] — порядок из _meta
|
||||
QHash<QString, QString> 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
|
||||
@@ -1,43 +0,0 @@
|
||||
#include "localization.h"
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQuickStyle>
|
||||
|
||||
#ifdef LAUNCHER_HAS_WEBENGINE
|
||||
#include <QtWebEngineQuick>
|
||||
#endif
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
#ifdef LAUNCHER_HAS_WEBENGINE
|
||||
// Вход в аккаунт Microsoft показывается встроенным окном браузера.
|
||||
// Инициализация обязана идти до QGuiApplication: она выставляет общий
|
||||
// контекст OpenGL, а после создания приложения это уже не действует.
|
||||
QtWebEngineQuick::initialize();
|
||||
#endif
|
||||
|
||||
QGuiApplication app(argc, argv);
|
||||
|
||||
// Язык интерфейса нужно знать до того, как QML вычислит первую привязку,
|
||||
// поэтому каталог читается здесь, а не в LauncherBackend: тот появляется
|
||||
// только вместе с движком. Каталог лежит в ресурсах — не прочитался, значит
|
||||
// сломана сборка, и запускаться дальше незачем.
|
||||
if (!Localization::instance().load())
|
||||
return -1;
|
||||
|
||||
// Нативный стиль Windows игнорирует пользовательские contentItem/background
|
||||
// и сыплет предупреждениями, а всё окно стилизовано вручную — берём Basic.
|
||||
QQuickStyle::setStyle("Basic");
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
QObject::connect(
|
||||
&engine,
|
||||
&QQmlApplicationEngine::objectCreationFailed,
|
||||
&app,
|
||||
[]() { QCoreApplication::exit(-1); },
|
||||
Qt::QueuedConnection);
|
||||
engine.loadFromModule("Minecraft_launcher", "Main");
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdlib> // Для system()
|
||||
#include <filesystem> // C++17 для работы с файловой системой
|
||||
#include <fstream> // Для работы с файлами конфигурации
|
||||
|
||||
// Пространство имен для удобства работы с файловой системой
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Имя файла конфигурации для хранения URL репозитория
|
||||
const std::string CONFIG_FILE = "launcher_config.txt";
|
||||
|
||||
// --- Функции для работы с конфигурацией ---
|
||||
|
||||
// Функция для сохранения URL Git-репозитория в файл
|
||||
void saveGitRepository(const std::string& repoUrl) {
|
||||
std::ofstream configFile(CONFIG_FILE);
|
||||
if (configFile.is_open()) {
|
||||
configFile << repoUrl;
|
||||
configFile.close();
|
||||
} else {
|
||||
std::cerr << "Ошибка: Не удалось открыть файл конфигурации для записи." << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для загрузки URL Git-репозитория из файла
|
||||
std::string loadGitRepository() {
|
||||
std::ifstream configFile(CONFIG_FILE);
|
||||
std::string repoUrl;
|
||||
if (configFile.is_open()) {
|
||||
std::getline(configFile, repoUrl);
|
||||
configFile.close();
|
||||
}
|
||||
return repoUrl;
|
||||
}
|
||||
|
||||
|
||||
// --- Кроссплатформенные функции ---
|
||||
|
||||
// Функция для получения пути к папке .minecraft в зависимости от ОС
|
||||
fs::path getMinecraftPath() {
|
||||
fs::path minecraftPath;
|
||||
|
||||
#if defined(_WIN32)
|
||||
// Для Windows путь обычно находится в %APPDATA%\.minecraft
|
||||
char* appdata = nullptr;
|
||||
size_t len;
|
||||
_dupenv_s(&appdata, &len, "APPDATA");
|
||||
if (appdata != nullptr) {
|
||||
minecraftPath = fs::path(appdata) / ".minecraft";
|
||||
free(appdata);
|
||||
}
|
||||
#elif defined(__linux__)
|
||||
// Для Linux путь обычно находится в ~/.minecraft
|
||||
char* home = getenv("HOME");
|
||||
if (home != nullptr) {
|
||||
minecraftPath = fs::path(home) / ".minecraft";
|
||||
}
|
||||
#elif defined(__APPLE__)
|
||||
// Для macOS путь находится в ~/Library/Application Support/minecraft
|
||||
char* home = getenv("HOME");
|
||||
if (home != nullptr) {
|
||||
minecraftPath = fs::path(home) / "Library" / "Application Support" / "minecraft";
|
||||
}
|
||||
#else
|
||||
std::cerr << "Неизвестная операционная система!" << std::endl;
|
||||
#endif
|
||||
|
||||
return minecraftPath;
|
||||
}
|
||||
|
||||
// Функция для открытия папки в файловом менеджере по умолчанию
|
||||
void openFolder(const fs::path& folderPath) {
|
||||
if (!fs::exists(folderPath)) {
|
||||
std::cout << "Папка не существует. Создание папки: " << folderPath.string() << std::endl;
|
||||
try {
|
||||
fs::create_directories(folderPath);
|
||||
} catch (const fs::filesystem_error& e) {
|
||||
std::cerr << "Ошибка при создании папки: " << e.what() << std::endl;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string command;
|
||||
#if defined(_WIN32)
|
||||
command = "explorer \"" + folderPath.string() + "\"";
|
||||
#elif defined(__linux__)
|
||||
command = "xdg-open \"" + folderPath.string() + "\"";
|
||||
#elif defined(__APPLE__)
|
||||
command = "open \"" + folderPath.string() + "\"";
|
||||
#else
|
||||
std::cerr << "Команда для открытия папки на данной ОС не поддерживается." << std::endl;
|
||||
return;
|
||||
#endif
|
||||
|
||||
// Выполнение системной команды
|
||||
system(command.c_str());
|
||||
}
|
||||
|
||||
// Функция для очистки содержимого папки
|
||||
void clearFolder(const fs::path& folderPath) {
|
||||
if (!fs::exists(folderPath) || !fs::is_directory(folderPath)) {
|
||||
return; // Если папки нет, ничего не делаем
|
||||
}
|
||||
for (const auto& entry : fs::directory_iterator(folderPath)) {
|
||||
try {
|
||||
fs::remove_all(entry.path());
|
||||
} catch (const fs::filesystem_error& e) {
|
||||
std::cerr << "Не удалось удалить " << entry.path() << ": " << e.what() << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Функции меню ---
|
||||
|
||||
// Функция для открытия папки с модами
|
||||
void openModsFolderAction() {
|
||||
fs::path minecraftPath = getMinecraftPath();
|
||||
if (minecraftPath.empty()) {
|
||||
std::cerr << "Не удалось определить путь к папке Minecraft." << std::endl;
|
||||
return;
|
||||
}
|
||||
fs::path modsPath = minecraftPath / "mods";
|
||||
std::cout << "Путь к папке модов: " << modsPath.string() << std::endl;
|
||||
openFolder(modsPath);
|
||||
}
|
||||
|
||||
// Функция для установки URL репозитория
|
||||
void setGitRepositoryAction() {
|
||||
std::string repoUrl;
|
||||
std::cout << "\nВведите URL Git-репозитория (например, https://github.com/user/repo.git):" << std::endl;
|
||||
std::getline(std::cin >> std::ws, repoUrl); // std::ws для очистки буфера от пробельных символов
|
||||
|
||||
if (repoUrl.empty()) {
|
||||
std::cout << "URL не может быть пустым." << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
saveGitRepository(repoUrl);
|
||||
std::cout << "URL репозитория успешно сохранен." << std::endl;
|
||||
}
|
||||
|
||||
// Функция для загрузки модов из Git
|
||||
void downloadModsAction() {
|
||||
std::string repoUrl = loadGitRepository();
|
||||
if (repoUrl.empty()) {
|
||||
std::cout << "URL Git-репозитория не настроен. Пожалуйста, сначала установите его (пункт 2)." << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
fs::path modsPath = getMinecraftPath() / "mods";
|
||||
std::cout << "Папка для модов: " << modsPath.string() << std::endl;
|
||||
|
||||
// Убедимся, что папка существует
|
||||
if (!fs::exists(modsPath)) {
|
||||
try {
|
||||
fs::create_directories(modsPath);
|
||||
} catch (const fs::filesystem_error& e) {
|
||||
std::cerr << "Ошибка при создании папки mods: " << e.what() << std::endl;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Запрос подтверждения перед очисткой
|
||||
char confirmation;
|
||||
std::cout << "\nВНИМАНИЕ! Все существующие файлы в папке 'mods' будут удалены." << std::endl;
|
||||
std::cout << "Продолжить? (y/n): ";
|
||||
std::cin >> confirmation;
|
||||
|
||||
// Проверка, что пользователь ввел 'y' или 'Y'
|
||||
if (confirmation != 'y' && confirmation != 'Y') {
|
||||
std::cout << "Загрузка отменена." << std::endl;
|
||||
// Очистка буфера ввода после чтения символа
|
||||
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
||||
return;
|
||||
}
|
||||
// Очистка буфера ввода
|
||||
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
||||
|
||||
|
||||
std::cout << "Очистка папки mods..." << std::endl;
|
||||
clearFolder(modsPath);
|
||||
std::cout << "Папка очищена." << std::endl;
|
||||
|
||||
// Формирование команды для клонирования репозитория
|
||||
// Клонируем содержимое репозитория прямо в папку mods
|
||||
std::string command = "git clone " + repoUrl + " \"" + modsPath.string() + "\"";
|
||||
|
||||
std::cout << "\nВыполнение команды: " << command << std::endl;
|
||||
std::cout << "Начинается загрузка модов. Это может занять некоторое время..." << std::endl;
|
||||
|
||||
int result = system(command.c_str());
|
||||
|
||||
if (result == 0) {
|
||||
std::cout << "Моды успешно загружены!" << std::endl;
|
||||
} else {
|
||||
std::cerr << "Произошла ошибка при загрузке модов. Убедитесь, что Git установлен и URL репозитория верный." << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение главного меню
|
||||
void printMenu() {
|
||||
std::cout << "\n--- Minecraft Mod Launcher ---" << std::endl;
|
||||
std::cout << "1. Открыть папку с модами (.minecraft/mods)" << std::endl;
|
||||
std::cout << "2. Указать/изменить URL Git-репозитория с модами" << std::endl;
|
||||
std::cout << "3. Загрузить/обновить моды из Git-репозитория" << std::endl;
|
||||
std::cout << "4. Выход" << std::endl;
|
||||
std::cout << "-----------------------------" << std::endl;
|
||||
|
||||
std::string currentRepo = loadGitRepository();
|
||||
if (currentRepo.empty()) {
|
||||
std::cout << "Текущий репозиторий: не задан" << std::endl;
|
||||
} else {
|
||||
std::cout << "Текущий репозиторий: " << currentRepo << std::endl;
|
||||
}
|
||||
|
||||
std::cout << "\nВведите ваш выбор: ";
|
||||
}
|
||||
|
||||
|
||||
// --- Главная функция программы ---
|
||||
|
||||
int main() {
|
||||
// Установка кодировки консоли для корректного отображения кириллицы в Windows
|
||||
#if defined(_WIN32)
|
||||
system("chcp 65001 > nul");
|
||||
#endif
|
||||
|
||||
int choice;
|
||||
while (true) {
|
||||
printMenu();
|
||||
std::cin >> choice;
|
||||
|
||||
// Проверка на корректность ввода
|
||||
if (std::cin.fail()) {
|
||||
std::cout << "Ошибка: введите число." << std::endl;
|
||||
std::cin.clear(); // Сброс флага ошибки
|
||||
// Очистка буфера ввода до следующей новой строки
|
||||
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case 1:
|
||||
openModsFolderAction();
|
||||
break;
|
||||
case 2:
|
||||
setGitRepositoryAction();
|
||||
break;
|
||||
case 3:
|
||||
downloadModsAction();
|
||||
break;
|
||||
case 4:
|
||||
std::cout << "Выход из программы." << std::endl;
|
||||
return 0;
|
||||
default:
|
||||
std::cout << "Неверный выбор. Пожалуйста, попробуйте снова." << std::endl;
|
||||
break;
|
||||
}
|
||||
std::cout << "\nНажмите Enter для продолжения...";
|
||||
// Очистка буфера перед ожиданием Enter
|
||||
if (choice != 3 && choice !=2) {
|
||||
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
||||
}
|
||||
std::cin.get();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||