Add support for mode field, with CurseForge metadata lookup

Now always asks the user before proceeding past the point where optional mods could be selected and configured
When updating files, the hash is checked so an update isn't redownloaded if it already exists
Added DevMain file for running in a dev environment
This commit is contained in:
comp500 2022-05-22 21:20:52 +01:00
parent 92d6f68f1d
commit c6e304bc7f
12 changed files with 388 additions and 25 deletions

View File

@ -0,0 +1,5 @@
package link.infra.packwiz.installer
fun main(args: Array<String>) {
Main(args)
}

View File

@ -10,14 +10,14 @@ import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.util.Log
import okio.Buffer
import okio.HashingSink
import okio.buffer
import okio.*
import okio.Path.Companion.toOkioPath
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.io.use
internal class DownloadTask private constructor(val metadata: IndexFile.File, defaultFormat: String, private val downloadSide: Side) : IOptionDetails {
var cachedFile: ManifestFile.File? = null
@ -27,7 +27,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
fun failed() = err != null
private var alreadyUpToDate = false
var alreadyUpToDate = false
private var metadataRequired = true
private var invalidated = false
// If file is new or isOptional changed to true, the option needs to be presented again
@ -124,11 +124,63 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
}
}
/**
* Check if the file in the destination location is already valid
* Must be done after metadata retrieval
*/
fun validateExistingFile(packFolder: String) {
if (!alreadyUpToDate) {
try {
// TODO: only do this for files that didn't exist before or have been modified since last full update?
val destPath = Paths.get(packFolder, metadata.destURI.toString())
FileSystem.SYSTEM.source(destPath.toOkioPath()).buffer().use { src ->
val hash: Hash
val fileHashFormat: String
val linkedFile = metadata.linkedFile
if (linkedFile != null) {
hash = linkedFile.hash
fileHashFormat = linkedFile.download!!.hashFormat!!
} else {
hash = metadata.getHashObj()
fileHashFormat = metadata.hashFormat!!
}
val fileSource = getHasher(fileHashFormat).getHashingSource(src)
fileSource.buffer().readAll(blackholeSink())
if (fileSource.hashIsEqual(hash)) {
alreadyUpToDate = true
// Update the manifest file
cachedFile = (cachedFile ?: ManifestFile.File()).also {
try {
it.hash = metadata.getHashObj()
} catch (e: Exception) {
err = e
return
}
it.isOptional = isOptional
it.cachedLocation = metadata.destURI.toString()
metadata.linkedFile?.let { linked ->
try {
it.linkedFileHash = linked.hash
} catch (e: Exception) {
err = e
}
}
}
}
}
} catch (e: IOException) {
// Ignore exceptions; if the file doesn't exist we'll be downloading it
}
}
}
fun download(packFolder: String, indexUri: SpaceSafeURI) {
if (err != null) return
// TODO: is this necessary if we overwrite?
// Ensure it is removed
// Ensure wrong-side or optional false files are removed
cachedFile?.let {
if (!it.optionValue || !correctSide()) {
if (it.cachedLocation == null) return
@ -143,8 +195,6 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
}
if (alreadyUpToDate) return
// TODO: should I be validating JSON properly, or this fine!!!!!!!??
assert(metadata.destURI != null)
val destPath = Paths.get(packFolder, metadata.destURI.toString())
// Don't update files marked with preserve if they already exist on disk
@ -164,10 +214,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
if (linkedFile != null) {
hash = linkedFile.hash
fileHashFormat = Objects.requireNonNull(Objects.requireNonNull(linkedFile.download)!!.hashFormat)!!
fileHashFormat = linkedFile.download!!.hashFormat!!
} else {
hash = metadata.getHashObj()
fileHashFormat = Objects.requireNonNull(metadata.hashFormat)!!
fileHashFormat = metadata.hashFormat!!
}
val src = metadata.getSource(indexUri)
@ -197,7 +247,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
println("Calculated: " + fileSource.hash)
println("Expected: $hash")
// Attempt to get the SHA256 hash
val sha256 = HashingSink.sha256(okio.blackholeSink())
val sha256 = HashingSink.sha256(blackholeSink())
data.readAll(sha256)
println("SHA256 hash value: " + sha256.hash)
err = Exception("Hash invalid!")

View File

@ -9,6 +9,7 @@ import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile
import link.infra.packwiz.installer.metadata.PackFile
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.metadata.curseforge.resolveCfMetadata
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
@ -125,8 +126,9 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
Log.info("Modpack is already up to date!")
// todo: --force?
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1))
ui.awaitOptionalButton(false)
if (!ui.optionsButtonPressed) {
return
}
@ -183,10 +185,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
Log.info("Modpack files are already up to date!")
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
ui.awaitOptionalButton(false)
if (!ui.optionsButtonPressed) {
return
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
manifest.indexFileHash = indexHash
@ -305,8 +312,20 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: task failed function?
val nonFailedFirstTasks = tasks.filter { t -> !t.failed() }.toList()
val optionTasks = nonFailedFirstTasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
val optionsChanged = optionTasks.any(DownloadTask::isNewOptional)
if (optionTasks.isNotEmpty() && !optionsChanged) {
if (!ui.optionsButtonPressed) {
// TODO: this is so ugly
ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1))
ui.awaitOptionalButton(true)
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
}
// If options changed, present all options again
if (ui.optionsButtonPressed || optionTasks.any(DownloadTask::isNewOptional)) {
if (ui.optionsButtonPressed || optionsChanged) {
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
if (ui.showOptions(ArrayList(optionTasks))) {
cancelled = true
@ -316,6 +335,38 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: keep this enabled? then apply changes after download process?
ui.disableOptionsButton(optionTasks.isNotEmpty())
ui.submitProgress(InstallProgress("Validating existing files..."))
// Validate existing files
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
downloadTask.validateExistingFile(opts.packFolder)
}
// Resolve CurseForge metadata
val cfFiles = nonFailedFirstTasks.asSequence().filter { !it.alreadyUpToDate }
.filter(DownloadTask::correctSide)
.map { it.metadata }
.filter { it.linkedFile != null }
.filter { it.linkedFile?.download?.mode == "metadata:curseforge" }.toList()
if (cfFiles.isNotEmpty()) {
ui.submitProgress(InstallProgress("Resolving CurseForge metadata..."))
val resolveFailures = resolveCfMetadata(cfFiles)
if (resolveFailures.isNotEmpty()) {
errorsOccurred = true
when (ui.showExceptions(resolveFailures, cfFiles.size, true)) {
ExceptionListResult.CONTINUE -> {}
ExceptionListResult.CANCEL -> {
cancelled = true
return
}
ExceptionListResult.IGNORE -> {
cancelledStartGame = true
return
}
}
}
}
// TODO: different thread pool type?
val threadPool = Executors.newFixedThreadPool(10)
val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool)

View File

@ -1,6 +1,9 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import link.infra.packwiz.installer.metadata.curseforge.UpdateData
import link.infra.packwiz.installer.metadata.curseforge.UpdateDeserializer
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
@ -19,11 +22,16 @@ class ModFile {
@SerializedName("hash-format")
var hashFormat: String? = null
var hash: String? = null
var mode: String? = null
}
var update: Map<String, Any>? = null
@JsonAdapter(UpdateDeserializer::class)
var update: Map<String, UpdateData>? = null
var option: Option? = null
@Transient
val resolvedUpdateData = mutableMapOf<String, SpaceSafeURI>()
class Option {
var optional = false
var description: String? = null
@ -34,11 +42,20 @@ class ModFile {
@Throws(Exception::class)
fun getSource(baseLoc: SpaceSafeURI?): Source {
download?.let {
if (it.url == null) {
throw Exception("Metadata file doesn't have a download URI")
if (it.mode == null || it.mode == "" || it.mode == "url") {
if (it.url == null) {
throw Exception("Metadata file doesn't have a download URI")
}
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid")
return getFileSource(newLoc)
} else if (it.mode == "metadata:curseforge") {
if (!resolvedUpdateData.contains("curseforge")) {
throw Exception("Metadata file specifies CurseForge mode, but is missing metadata")
}
return getFileSource(resolvedUpdateData["curseforge"]!!)
} else {
throw Exception("Unsupported download mode " + it.mode)
}
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid")
return getFileSource(newLoc)
} ?: throw Exception("Metadata file doesn't have download")
}

View File

@ -0,0 +1,132 @@
package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.Gson
import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.closeQuietly
import okio.ByteString.Companion.decodeBase64
import java.nio.charset.StandardCharsets
private class GetFilesRequest(val fileIds: List<Int>)
private class GetModsRequest(val modIds: List<Int>)
private class GetFilesResponse {
class CfFile {
var id = 0
var modId = 0
var downloadUrl: SpaceSafeURI? = null
}
val data = mutableListOf<CfFile>()
}
private class GetModsResponse {
class CfMod {
var id = 0
var name = ""
var links: CfLinks? = null
}
class CfLinks {
var websiteUrl = ""
}
val data = mutableListOf<CfMod>()
}
private const val APIServer = "api.curseforge.com"
// If you fork/derive from packwiz, I request that you obtain your own API key.
private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh".decodeBase64()!!
.string(StandardCharsets.UTF_8)
private val clientHolder = ClientHolder()
// TODO: switch to PackwizPath stuff and OkHttp in old code
@Throws(JsonSyntaxException::class, JsonIOException::class)
fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
val failures = mutableListOf<ExceptionDetails>()
val fileIdMap = mutableMapOf<Int, IndexFile.File>()
for (mod in mods) {
if (mod.linkedFile!!.update == null) {
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no update section")))
continue
}
if (!mod.linkedFile!!.update!!.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue
}
fileIdMap[(mod.linkedFile!!.update!!["curseforge"] as CurseForgeUpdateData).fileId] = mod
}
val reqData = GetFilesRequest(fileIdMap.keys.toList())
val req = Request.Builder()
.url("https://${APIServer}/v1/mods/files")
.header("Accept", "application/json")
.header("User-Agent", "packwiz-installer")
.header("X-API-Key", APIKey)
.post(Gson().toJson(reqData, GetFilesRequest::class.java).toRequestBody("application/json".toMediaType()))
.build()
val res = clientHolder.okHttpClient.newCall(req).execute()
if (!res.isSuccessful || res.body == null) {
res.closeQuietly()
failures.add(ExceptionDetails("Other", Exception("Failed to resolve CurseForge metadata for file data: error code ${res.code}")))
return failures
}
val resData = Gson().fromJson(res.body!!.charStream(), GetFilesResponse::class.java)
res.closeQuietly()
val manualDownloadMods = mutableMapOf<Int, Pair<IndexFile.File, Int>>()
for (file in resData.data) {
if (!fileIdMap.contains(file.id)) {
failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to find file from result: ID ${file.id}, Project ID ${file.modId}")))
continue
}
if (file.downloadUrl == null) {
manualDownloadMods[file.modId] = Pair(fileIdMap[file.id]!!, file.id)
continue
}
fileIdMap[file.id]!!.linkedFile!!.resolvedUpdateData["curseforge"] = file.downloadUrl!!
}
if (manualDownloadMods.isNotEmpty()) {
val reqModsData = GetModsRequest(manualDownloadMods.keys.toList())
val reqMods = Request.Builder()
.url("https://${APIServer}/v1/mods")
.header("Accept", "application/json")
.header("User-Agent", "packwiz-installer")
.header("X-API-Key", APIKey)
.post(Gson().toJson(reqModsData, GetModsRequest::class.java).toRequestBody("application/json".toMediaType()))
.build()
val resMods = clientHolder.okHttpClient.newCall(reqMods).execute()
if (!resMods.isSuccessful || resMods.body == null) {
resMods.closeQuietly()
failures.add(ExceptionDetails("Other", Exception("Failed to resolve CurseForge metadata for mod data: error code ${resMods.code}")))
return failures
}
val resModsData = Gson().fromJson(resMods.body!!.charStream(), GetModsResponse::class.java)
resMods.closeQuietly()
for (mod in resModsData.data) {
if (!manualDownloadMods.contains(mod.id)) {
failures.add(ExceptionDetails(mod.name,
Exception("Failed to find project from result: ID ${mod.id}")))
continue
}
val modFile = manualDownloadMods[mod.id]!!
failures.add(ExceptionDetails(mod.name, Exception("This mod is excluded from the CurseForge API and must be downloaded manually.\n" +
"Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI}")))
}
}
return failures
}

View File

@ -0,0 +1,10 @@
package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.annotations.SerializedName
class CurseForgeUpdateData: UpdateData {
@SerializedName("file-id")
var fileId = 0
@SerializedName("project-id")
var projectId = 0
}

View File

@ -0,0 +1,3 @@
package link.infra.packwiz.installer.metadata.curseforge
interface UpdateData

View File

@ -0,0 +1,22 @@
package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import java.lang.reflect.Type
class UpdateDeserializer: JsonDeserializer<Map<String, UpdateData>> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): Map<String, UpdateData> {
val out = mutableMapOf<String, UpdateData>()
for ((k, v) in json!!.asJsonObject.entrySet()) {
if (k == "curseforge") {
out[k] = context!!.deserialize(v, CurseForgeUpdateData::class.java)
}
}
return out
}
}

View File

@ -23,6 +23,8 @@ interface IUserInterface {
fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT
fun awaitOptionalButton(showCancel: Boolean)
enum class ExceptionListResult {
CONTINUE, CANCEL, IGNORE
}

View File

@ -59,4 +59,8 @@ class CLIHandler : IUserInterface {
}
return ExceptionListResult.CANCEL
}
override fun awaitOptionalButton(showCancel: Boolean) {
// Do nothing
}
}

View File

@ -8,6 +8,7 @@ import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log
import java.awt.EventQueue
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CountDownLatch
import javax.swing.JDialog
import javax.swing.JOptionPane
import javax.swing.UIManager
@ -18,8 +19,21 @@ class GUIHandler : IUserInterface {
@Volatile
override var optionsButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
@Volatile
override var cancelButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
var okButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
@Volatile
override var firstInstall = false
@ -42,8 +56,12 @@ class GUIHandler : IUserInterface {
}
}
private val visibleCountdownLatch = CountDownLatch(1)
private val optionalSelectedLatch = CountDownLatch(1)
override fun show() = EventQueue.invokeLater {
frmPackwizlauncher.isVisible = true
visibleCountdownLatch.countDown()
}
override fun dispose() = EventQueue.invokeAndWait {
@ -147,4 +165,15 @@ class GUIHandler : IUserInterface {
}
return future.get()
}
override fun awaitOptionalButton(showCancel: Boolean) {
EventQueue.invokeAndWait {
frmPackwizlauncher.showOk(!showCancel)
}
visibleCountdownLatch.await()
optionalSelectedLatch.await()
EventQueue.invokeLater {
frmPackwizlauncher.hideOk()
}
}
}

View File

@ -12,6 +12,9 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
private var lblProgresslabel: JLabel
private var progressBar: JProgressBar
private var btnOptions: JButton
private val btnCancel: JButton
private val btnOk: JButton
private val buttonsPanel: JPanel
init {
setBounds(100, 100, 493, 95)
@ -35,7 +38,7 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
}, BorderLayout.CENTER)
// Buttons
add(JPanel().apply {
buttonsPanel = JPanel().apply {
border = EmptyBorder(0, 5, 0, 5)
layout = GridBagLayout()
@ -49,20 +52,28 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
}
}
add(btnOptions, GridBagConstraints().apply {
gridx = 0
gridx = 1
gridy = 0
})
add(JButton("Cancel").apply {
btnCancel = JButton("Cancel").apply {
addActionListener {
isEnabled = false
handler.cancelButtonPressed = true
}
}, GridBagConstraints().apply {
gridx = 0
}
add(btnCancel, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
}, BorderLayout.EAST)
}
btnOk = JButton("Continue").apply {
addActionListener {
handler.okButtonPressed = true
}
}
add(buttonsPanel, BorderLayout.EAST)
}
fun displayProgress(progress: InstallProgress) {
@ -83,4 +94,31 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
isEnabled = false
}
}
fun showOk(hideCancel: Boolean) {
if (hideCancel) {
buttonsPanel.add(btnOk, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
buttonsPanel.remove(btnCancel)
} else {
buttonsPanel.add(btnOk, GridBagConstraints().apply {
gridx = 0
gridy = 1
})
}
buttonsPanel.revalidate()
}
fun hideOk() {
buttonsPanel.remove(btnOk)
if (!buttonsPanel.components.contains(btnCancel)) {
buttonsPanel.add(btnCancel, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
}
buttonsPanel.revalidate()
}
}