diff --git a/build.gradle.kts b/build.gradle.kts index 343571d..ca8dda1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,8 +3,8 @@ plugins { application id("com.github.johnrengelman.shadow") version "7.1.2" id("com.palantir.git-version") version "0.13.0" - id("com.github.breadmoirai.github-release") version "2.2.12" - kotlin("jvm") version "1.6.10" + id("com.github.breadmoirai.github-release") version "2.4.1" + kotlin("jvm") version "1.7.10" id("com.github.jk1.dependency-license-report") version "2.0" `maven-publish` } @@ -16,18 +16,20 @@ java { repositories { mavenCentral() google() + maven { + url = uri("https://jitpack.io") + } } val r8 by configurations.creating dependencies { implementation("commons-cli:commons-cli:1.5.0") - implementation("com.moandjiezana.toml:toml4j:0.7.2") implementation("com.google.code.gson:gson:2.9.0") - implementation("com.squareup.okio:okio:3.0.0") + implementation("com.squareup.okio:okio:3.1.0") implementation(kotlin("stdlib-jdk8")) - implementation("com.squareup.okhttp3:okhttp:4.9.3") - implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.14") + implementation("com.squareup.okhttp3:okhttp:4.10.0") + implementation("cc.ekblad:4koma:1.1.0") r8("com.android.tools:r8:3.3.28") } @@ -54,8 +56,9 @@ licenseReport { } tasks.shadowJar { - exclude("**/*.kotlin_metadata") - exclude("**/*.kotlin_builtins") + // 4koma uses kotlin-reflect; requires Kotlin metadata + //exclude("**/*.kotlin_metadata") + //exclude("**/*.kotlin_builtins") exclude("META-INF/maven/**/*") exclude("META-INF/proguard/**/*") diff --git a/src/main/kotlin/link/infra/packwiz/installer/DownloadTask.kt b/src/main/kotlin/link/infra/packwiz/installer/DownloadTask.kt index 002961a..609b8cb 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/DownloadTask.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/DownloadTask.kt @@ -2,24 +2,24 @@ package link.infra.packwiz.installer import link.infra.packwiz.installer.metadata.IndexFile import link.infra.packwiz.installer.metadata.ManifestFile -import link.infra.packwiz.installer.metadata.SpaceSafeURI 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 +import link.infra.packwiz.installer.metadata.hash.HashFormat +import link.infra.packwiz.installer.request.RequestException +import link.infra.packwiz.installer.target.ClientHolder import link.infra.packwiz.installer.target.Side +import link.infra.packwiz.installer.target.path.PackwizFilePath 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.* -import okio.Path.Companion.toOkioPath +import okio.Buffer +import okio.HashingSink +import okio.blackholeSink +import okio.buffer 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 { +internal class DownloadTask private constructor(val metadata: IndexFile.File, val index: IndexFile, private val downloadSide: Side) : IOptionDetails { var cachedFile: ManifestFile.File? = null private var err: Exception? = null @@ -33,7 +33,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de // If file is new or isOptional changed to true, the option needs to be presented again private var newOptional = true - val isOptional get() = metadata.linkedFile?.isOptional ?: false + val isOptional get() = metadata.linkedFile?.option?.optional ?: false fun isNewOptional() = isOptional && newOptional @@ -53,12 +53,6 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de override val optionDescription get() = metadata.linkedFile?.option?.description ?: "" - init { - if (metadata.hashFormat?.isEmpty() != false) { - metadata.hashFormat = defaultFormat - } - } - fun invalidate() { invalidated = true alreadyUpToDate = false @@ -74,7 +68,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de this.cachedFile = cachedFile if (!invalidated) { val currHash = try { - getHash(metadata.hashFormat!!, metadata.hash!!) + metadata.getHashObj(index) } catch (e: Exception) { err = e return @@ -91,13 +85,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de } } - fun downloadMetadata(parentIndexFile: IndexFile, indexUri: SpaceSafeURI) { + fun downloadMetadata(clientHolder: ClientHolder) { if (err != null) return if (metadataRequired) { try { // Retrieve the linked metadata file - metadata.downloadMeta(parentIndexFile, indexUri) + metadata.downloadMeta(index, clientHolder) } catch (e: Exception) { err = e return @@ -105,16 +99,14 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de cachedFile?.let { cachedFile -> val linkedFile = metadata.linkedFile if (linkedFile != null) { - linkedFile.option?.let { opt -> - if (opt.optional) { - if (cachedFile.isOptional) { - // isOptional didn't change - newOptional = false - } else { - // isOptional false -> true, set option to it's default value - // TODO: preserve previous option value, somehow?? - cachedFile.optionValue = opt.defaultValue - } + if (linkedFile.option.optional) { + if (cachedFile.isOptional) { + // isOptional didn't change + newOptional = false + } else { + // isOptional false -> true, set option to it's default value + // TODO: preserve previous option value, somehow?? + cachedFile.optionValue = linkedFile.option.defaultValue } } cachedFile.isOptional = isOptional @@ -128,39 +120,40 @@ 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) { + fun validateExistingFile(packFolder: PackwizFilePath, clientHolder: ClientHolder) { 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 destPath = metadata.destURI.rebase(packFolder) + destPath.source(clientHolder).use { src -> + // TODO: clean up duplicated code + val hash: Hash<*> + val fileHashFormat: HashFormat<*> val linkedFile = metadata.linkedFile if (linkedFile != null) { hash = linkedFile.hash - fileHashFormat = linkedFile.download!!.hashFormat!! + fileHashFormat = linkedFile.download.hashFormat } else { - hash = metadata.getHashObj() - fileHashFormat = metadata.hashFormat!! + hash = metadata.getHashObj(index) + fileHashFormat = metadata.hashFormat(index) } - val fileSource = getHasher(fileHashFormat).getHashingSource(src) + val fileSource = fileHashFormat.source(src) fileSource.buffer().readAll(blackholeSink()) - if (fileSource.hashIsEqual(hash)) { + if (hash == fileSource.hash) { alreadyUpToDate = true // Update the manifest file cachedFile = (cachedFile ?: ManifestFile.File()).also { try { - it.hash = metadata.getHashObj() + it.hash = metadata.getHashObj(index) } catch (e: Exception) { err = e return } it.isOptional = isOptional - it.cachedLocation = metadata.destURI.toString() + it.cachedLocation = metadata.destURI.rebase(packFolder) metadata.linkedFile?.let { linked -> try { it.linkedFileHash = linked.hash @@ -171,13 +164,15 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de } } } + } catch (e: RequestException) { + // Ignore exceptions; if the file doesn't exist we'll be downloading it } catch (e: IOException) { // Ignore exceptions; if the file doesn't exist we'll be downloading it } } } - fun download(packFolder: String, indexUri: SpaceSafeURI) { + fun download(packFolder: PackwizFilePath, clientHolder: ClientHolder) { if (err != null) return // Exclude wrong-side and optional false files @@ -186,7 +181,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de if (it.cachedLocation != null) { // Ensure wrong-side or optional false files are removed try { - Files.deleteIfExists(Paths.get(packFolder, it.cachedLocation)) + Files.deleteIfExists(it.cachedLocation!!.nioPath) } catch (e: IOException) { Log.warn("Failed to delete file", e) } @@ -197,33 +192,32 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de } if (alreadyUpToDate) return - val destPath = Paths.get(packFolder, metadata.destURI.toString()) + val destPath = metadata.destURI.rebase(packFolder) // Don't update files marked with preserve if they already exist on disk if (metadata.preserve) { - if (destPath.toFile().exists()) { + if (destPath.nioPath.toFile().exists()) { return } } - // TODO: if already exists and has correct hash, ignore? // TODO: add .disabled support? try { - val hash: Hash - val fileHashFormat: String + val hash: Hash<*> + val fileHashFormat: HashFormat<*> val linkedFile = metadata.linkedFile if (linkedFile != null) { hash = linkedFile.hash - fileHashFormat = linkedFile.download!!.hashFormat!! + fileHashFormat = linkedFile.download.hashFormat } else { - hash = metadata.getHashObj() - fileHashFormat = metadata.hashFormat!! + hash = metadata.getHashObj(index) + fileHashFormat = metadata.hashFormat(index) } - val src = metadata.getSource(indexUri) - val fileSource = getHasher(fileHashFormat).getHashingSource(src) + val src = metadata.getSource(clientHolder) + val fileSource = fileHashFormat.source(src) val data = Buffer() // Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails) @@ -232,16 +226,16 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de it.readAll(data) } - if (fileSource.hashIsEqual(hash)) { + if (hash == fileSource.hash) { // isDirectory follows symlinks, but createDirectories doesn't try { - Files.createDirectories(destPath.parent) + Files.createDirectories(destPath.parent.nioPath) } catch (e: java.nio.file.FileAlreadyExistsException) { - if (!Files.isDirectory(destPath.parent)) { + if (!Files.isDirectory(destPath.parent.nioPath)) { throw e } } - Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING) + Files.copy(data.inputStream(), destPath.nioPath, StandardCopyOption.REPLACE_EXISTING) data.clear() } else { // TODO: move println to something visible in the error window @@ -257,10 +251,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de return } cachedFile?.cachedLocation?.let { - if (destPath != Paths.get(packFolder, it)) { + if (destPath != it) { // Delete old file if location changes try { - Files.delete(Paths.get(packFolder, cachedFile!!.cachedLocation)) + Files.delete(cachedFile!!.cachedLocation!!.nioPath) } catch (e: IOException) { // Continue, as it was probably already deleted? // TODO: log it @@ -275,13 +269,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de // Update the manifest file cachedFile = (cachedFile ?: ManifestFile.File()).also { try { - it.hash = metadata.getHashObj() + it.hash = metadata.getHashObj(index) } catch (e: Exception) { err = e return } it.isOptional = isOptional - it.cachedLocation = metadata.destURI.toString() + it.cachedLocation = metadata.destURI.rebase(packFolder) metadata.linkedFile?.let { linked -> try { it.linkedFileHash = linked.hash @@ -293,11 +287,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de } companion object { - @JvmStatic - fun createTasksFromIndex(index: IndexFile, defaultFormat: String, downloadSide: Side): MutableList { + fun createTasksFromIndex(index: IndexFile, downloadSide: Side): MutableList { val tasks = ArrayList() - for (file in Objects.requireNonNull(index.files)) { - tasks.add(DownloadTask(file, defaultFormat, downloadSide)) + for (file in index.files) { + tasks.add(DownloadTask(file, index, downloadSide)) } return tasks } diff --git a/src/main/kotlin/link/infra/packwiz/installer/LauncherUtils.kt b/src/main/kotlin/link/infra/packwiz/installer/LauncherUtils.kt index fb0c43b..e299057 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/LauncherUtils.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/LauncherUtils.kt @@ -7,8 +7,8 @@ import com.google.gson.JsonSyntaxException import link.infra.packwiz.installer.metadata.PackFile import link.infra.packwiz.installer.ui.IUserInterface import link.infra.packwiz.installer.util.Log -import java.io.File -import java.nio.file.Paths +import kotlin.io.path.reader +import kotlin.io.path.writeText class LauncherUtils internal constructor(private val opts: UpdateManager.Options, val ui: IUserInterface) { enum class LauncherStatus { @@ -20,14 +20,13 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options fun handleMultiMC(pf: PackFile, gson: Gson): LauncherStatus { // MultiMC MC and loader version checker - val manifestPath = Paths.get(opts.multimcFolder, "mmc-pack.json").toString() - val manifestFile = File(manifestPath) + val manifestPath = opts.multimcFolder / "mmc-pack.json" - if (!manifestFile.exists()) { + if (!manifestPath.nioPath.toFile().exists()) { return LauncherStatus.NOT_FOUND } - val multimcManifest = manifestFile.reader().use { + val multimcManifest = manifestPath.nioPath.reader().use { try { JsonParser.parseReader(it) } catch (e: JsonIOException) { @@ -64,7 +63,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options if (modLoaders.containsKey(component["uid"]?.asString)) { val modLoader = modLoaders.getValue(component["uid"]!!.asString) loaderVersionsFound[modLoader] = version - if (version != pf.versions?.get(modLoader)) { + if (version != pf.versions[modLoader]) { outdatedLoaders.add(modLoader) true // Delete component; cached metadata is invalid and will be re-added } else { @@ -75,18 +74,18 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options for ((_, loader) in modLoaders .filter { - (!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) - && pf.versions?.containsKey(it.value) == true } + (!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) && pf.versions.containsKey(it.value) + } ) { manifestModified = true components.add(gson.toJsonTree( - hashMapOf("uid" to modLoadersClasses[loader], "version" to pf.versions?.get(loader))) + hashMapOf("uid" to modLoadersClasses[loader], "version" to pf.versions[loader])) ) } // If inconsistent Intermediary mappings version is found, delete it - MultiMC will add and re-dl the correct one components.find { it.isJsonObject && it.asJsonObject["uid"]?.asString == "net.fabricmc.intermediary" }?.let { - if (it.asJsonObject["version"]?.asString != pf.versions?.get("minecraft")) { + if (it.asJsonObject["version"]?.asString != pf.versions["minecraft"]) { components.remove(it) manifestModified = true } @@ -96,7 +95,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options // The manifest has been modified, so before saving it we'll ask the user // if they wanna update it, continue without updating it, or exit val oldVers = loaderVersionsFound.map { Pair(it.key, it.value) } - val newVers = pf.versions!!.map { Pair(it.key, it.value) } + val newVers = pf.versions.map { Pair(it.key, it.value) } when (ui.showUpdateConfirmationDialog(oldVers, newVers)) { IUserInterface.UpdateConfirmationResult.CANCELLED -> { @@ -108,7 +107,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options else -> {} } - manifestFile.writeText(gson.toJson(multimcManifest)) + manifestPath.nioPath.writeText(gson.toJson(multimcManifest)) Log.info("Successfully updated mmc-pack.json based on version metadata") return LauncherStatus.SUCCESSFUL diff --git a/src/main/kotlin/link/infra/packwiz/installer/Main.kt b/src/main/kotlin/link/infra/packwiz/installer/Main.kt index 2991205..ae74649 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/Main.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/Main.kt @@ -2,17 +2,23 @@ package link.infra.packwiz.installer -import link.infra.packwiz.installer.metadata.SpaceSafeURI import link.infra.packwiz.installer.target.Side +import link.infra.packwiz.installer.target.path.HttpUrlPath +import link.infra.packwiz.installer.target.path.PackwizFilePath import link.infra.packwiz.installer.ui.cli.CLIHandler import link.infra.packwiz.installer.ui.gui.GUIHandler +import link.infra.packwiz.installer.ui.wrap import link.infra.packwiz.installer.util.Log +import okhttp3.HttpUrl.Companion.toHttpUrl +import okio.Path.Companion.toOkioPath +import okio.Path.Companion.toPath import org.apache.commons.cli.DefaultParser import org.apache.commons.cli.Options import org.apache.commons.cli.ParseException import java.awt.EventQueue import java.awt.GraphicsEnvironment -import java.net.URISyntaxException +import java.net.URI +import java.nio.file.Paths import javax.swing.JOptionPane import javax.swing.UIManager import kotlin.system.exitProcess @@ -66,21 +72,41 @@ class Main(args: Array) { ui.show() - val uOptions = try { - UpdateManager.Options.construct( - downloadURI = SpaceSafeURI(unparsedArgs[0]), - side = cmd.getOptionValue("side")?.let((Side)::from), - packFolder = cmd.getOptionValue("pack-folder"), - multimcFolder = cmd.getOptionValue("multimc-folder"), - manifestFile = cmd.getOptionValue("meta-file") - ) - } catch (e: URISyntaxException) { - ui.showErrorAndExit("Failed to read pack.toml URI", e) + val packFileRaw = unparsedArgs[0] + + val packFile = when { + // HTTP(s) URLs + Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.wrap("Invalid HTTP/HTTPS URL for pack file: $packFileRaw") { + HttpUrlPath(packFileRaw.toHttpUrl().resolve(".")!!, packFileRaw.toHttpUrl().pathSegments.last()) + } + // File URIs (uses same logic as old packwiz-installer, for backwards compat) + Regex("^file:", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> { + ui.wrap("Failed to parse file path for pack file: $packFileRaw") { + val path = Paths.get(URI(packFileRaw)).toOkioPath() + PackwizFilePath(path.parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), path.name) + } + } + // Other URIs (unsupported) + Regex("^[a-z][a-z\\d+\\-.]*://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.showErrorAndExit("Unsupported scheme for pack file: $packFileRaw") + // None of the above matches -> interpret as file path + else -> PackwizFilePath(packFileRaw.toPath().parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), packFileRaw.toPath().name) + } + val side = cmd.getOptionValue("side")?.let { + Side.from(it) ?: ui.showErrorAndExit("Unknown side name: $it") + } ?: Side.CLIENT + val packFolder = ui.wrap("Invalid pack folder path") { + cmd.getOptionValue("pack-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath(".".toPath()) + } + val multimcFolder = ui.wrap("Invalid MultiMC folder path") { + cmd.getOptionValue("multimc-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath("..".toPath()) + } + val manifestFile = ui.wrap("Invalid manifest file path") { + packFolder / (cmd.getOptionValue("meta-file") ?: "manifest.json") } // Start update process! try { - UpdateManager(uOptions, ui) + UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side), ui) } catch (e: Exception) { ui.showErrorAndExit("Update process failed", e) } diff --git a/src/main/kotlin/link/infra/packwiz/installer/UpdateManager.kt b/src/main/kotlin/link/infra/packwiz/installer/UpdateManager.kt index 4bf9812..687f3e4 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/UpdateManager.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/UpdateManager.kt @@ -1,31 +1,33 @@ package link.infra.packwiz.installer +import cc.ekblad.toml.decode import com.google.gson.GsonBuilder import com.google.gson.JsonIOException import com.google.gson.JsonSyntaxException -import com.moandjiezana.toml.Toml import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex +import link.infra.packwiz.installer.metadata.DownloadMode 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 -import link.infra.packwiz.installer.request.HandlerManager.getFileSource -import link.infra.packwiz.installer.request.HandlerManager.getNewLoc +import link.infra.packwiz.installer.metadata.hash.HashFormat +import link.infra.packwiz.installer.request.RequestException +import link.infra.packwiz.installer.target.ClientHolder import link.infra.packwiz.installer.target.Side +import link.infra.packwiz.installer.target.path.PackwizFilePath +import link.infra.packwiz.installer.target.path.PackwizPath import link.infra.packwiz.installer.ui.IUserInterface import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult import link.infra.packwiz.installer.ui.data.InstallProgress import link.infra.packwiz.installer.util.Log -import link.infra.packwiz.installer.util.ifletOrErr import okio.buffer -import java.io.* +import java.io.FileWriter +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets import java.nio.file.Files -import java.nio.file.Paths import java.util.concurrent.CompletionService import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutorCompletionService @@ -42,29 +44,32 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse } data class Options( - val downloadURI: SpaceSafeURI, - val manifestFile: String, - val packFolder: String, - val multimcFolder: String, + val packFile: PackwizPath<*>, + val manifestFile: PackwizFilePath, + val packFolder: PackwizFilePath, + val multimcFolder: PackwizFilePath, val side: Side - ) { - // Horrible workaround for default params not working cleanly with nullable values - companion object { - fun construct(downloadURI: SpaceSafeURI, manifestFile: String?, packFolder: String?, multimcFolder: String?, side: Side?) = - Options(downloadURI, manifestFile ?: "packwiz.json", packFolder ?: ".", multimcFolder ?: "..", side ?: Side.CLIENT) + ) + + // TODO: make this return a value based on results? + private fun start() { + val clientHolder = ClientHolder() + ui.cancelCallback = { + clientHolder.close() } - } - - private fun start() { - checkOptions() - ui.submitProgress(InstallProgress("Loading manifest file...")) - val gson = GsonBuilder().registerTypeAdapter(Hash::class.java, Hash.TypeHandler()).create() + val gson = GsonBuilder() + .registerTypeAdapter(Hash::class.java, Hash.TypeHandler()) + .registerTypeAdapter(PackwizFilePath::class.java, PackwizPath.adapterRelativeTo(opts.packFolder)) + .enableComplexMapKeySerialization() + .create() val manifest = try { - gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()), - ManifestFile::class.java) - } catch (e: FileNotFoundException) { + // TODO: kotlinx.serialisation? + InputStreamReader(opts.manifestFile.source(clientHolder).inputStream(), StandardCharsets.UTF_8).use { reader -> + gson.fromJson(reader, ManifestFile::class.java) + } + } catch (e: RequestException.Response.File.FileNotFound) { ui.firstInstall = true ManifestFile() } catch (e: JsonSyntaxException) { @@ -80,14 +85,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse ui.submitProgress(InstallProgress("Loading pack file...")) val packFileSource = try { - val src = getFileSource(opts.downloadURI) - getHasher("sha256").getHashingSource(src) + val src = opts.packFile.source(clientHolder) + HashFormat.SHA256.source(src) } catch (e: Exception) { + // TODO: ensure suppressed/caused exceptions are shown? ui.showErrorAndExit("Failed to download pack.toml", e) } val pf = packFileSource.buffer().use { try { - Toml().read(InputStreamReader(it.inputStream(), "UTF-8")).to(PackFile::class.java) + PackFile.mapper(opts.packFile).decode(it.inputStream()) } catch (e: IllegalStateException) { ui.showErrorAndExit("Failed to parse pack.toml", e) } @@ -122,7 +128,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse ui.submitProgress(InstallProgress("Checking local files...")) // Invalidation checking must be done here, as it must happen before pack/index hashes are checked - val invalidatedUris: MutableList = ArrayList() + val invalidatedUris: MutableList = ArrayList() for ((fileUri, file) in manifest.cachedFiles) { // ignore onlyOtherSide files if (file.onlyOtherSide) { @@ -133,7 +139,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse // if isn't optional, or is optional but optionValue == true if (!file.isOptional || file.optionValue) { if (file.cachedLocation != null) { - if (!Paths.get(opts.packFolder, file.cachedLocation).toFile().exists()) { + if (!file.cachedLocation!!.nioPath.toFile().exists()) { invalid = true } } else { @@ -142,12 +148,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse } } if (invalid) { - Log.info("File $fileUri invalidated, marked for redownloading") + Log.info("File ${fileUri.filename} invalidated, marked for redownloading") invalidatedUris.add(fileUri) } } - if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) { + if (manifest.packFileHash?.let { it == packFileSource.hash } == true && invalidatedUris.isEmpty()) { // todo: --force? ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1)) if (manifest.cachedFiles.any { it.value.isOptional }) { @@ -165,20 +171,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse handleCancellation() } try { - // TODO: switch to OkHttp for better redirect handling - ui.ifletOrErr(pf.index, "No index file found, or the pack file is empty; note that Java doesn't automatically follow redirects from HTTP to HTTPS (and may cause this error)") { index -> - ui.ifletOrErr(index.hashFormat, index.hash, "Pack has no hash or hashFormat for index") { hashFormat, hash -> - ui.ifletOrErr(getNewLoc(opts.downloadURI, index.file), "Pack has invalid index file: " + index.file) { newLoc -> - processIndex( - newLoc, - getHash(hashFormat, hash), - hashFormat, - manifest, - invalidatedUris - ) - } - } - } + processIndex( + pf.index.file, + pf.index.hashFormat.fromString(pf.index.hash), + pf.index.hashFormat, + manifest, + invalidatedUris, + clientHolder + ) } catch (e1: Exception) { ui.showErrorAndExit("Failed to process index file", e1) } @@ -196,18 +196,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse manifest.cachedSide = opts.side try { - FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString()).use { writer -> gson.toJson(manifest, writer) } + FileWriter(opts.manifestFile.nioPath.toFile()).use { writer -> gson.toJson(manifest, writer) } } catch (e: IOException) { ui.showErrorAndExit("Failed to save local manifest file", e) } } - private fun checkOptions() { - // TODO: implement - } - - private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List) { - if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) { + private fun processIndex(indexUri: PackwizPath<*>, indexHash: Hash<*>, hashFormat: HashFormat<*>, manifest: ManifestFile, invalidatedFiles: List, clientHolder: ClientHolder) { + if (manifest.indexFileHash == indexHash && invalidatedFiles.isEmpty()) { ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1)) if (manifest.cachedFiles.any { it.value.isOptional }) { ui.awaitOptionalButton(false) @@ -223,18 +219,18 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse manifest.indexFileHash = indexHash val indexFileSource = try { - val src = getFileSource(indexUri) - getHasher(hashFormat).getHashingSource(src) + val src = indexUri.source(clientHolder) + hashFormat.source(src) } catch (e: Exception) { ui.showErrorAndExit("Failed to download index file", e) } val indexFile = try { - Toml().read(InputStreamReader(indexFileSource.buffer().inputStream(), "UTF-8")).to(IndexFile::class.java) + IndexFile.mapper(indexUri).decode(indexFileSource.buffer().inputStream()) } catch (e: IllegalStateException) { ui.showErrorAndExit("Failed to parse index file", e) } - if (!indexFileSource.hashIsEqual(indexHash)) { + if (indexHash != indexFileSource.hash) { ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again") } @@ -245,7 +241,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse ui.submitProgress(InstallProgress("Checking local files...")) // TODO: use kotlin filtering/FP rather than an iterator? - val it: MutableIterator> = manifest.cachedFiles.entries.iterator() + val it: MutableIterator> = manifest.cachedFiles.entries.iterator() while (it.hasNext()) { val (uri, file) = it.next() if (file.cachedLocation != null) { @@ -253,7 +249,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse // Delete if option value has been set to false if (file.isOptional && !file.optionValue) { try { - Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation)) + Files.deleteIfExists(file.cachedLocation!!.nioPath) } catch (e: IOException) { Log.warn("Failed to delete optional disabled file", e) } @@ -261,10 +257,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse file.cachedLocation = null alreadyDeleted = true } - if (indexFile.files.none { it.file == uri }) { // File has been removed from the index + if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index if (!alreadyDeleted) { try { - Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation)) + Files.deleteIfExists(file.cachedLocation!!.nioPath) } catch (e: IOException) { Log.warn("Failed to delete file removed from index", e) } @@ -284,7 +280,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse if (indexFile.files.isEmpty()) { Log.warn("Index is empty!") } - val tasks = createTasksFromIndex(indexFile, indexFile.hashFormat, opts.side) + val tasks = createTasksFromIndex(indexFile, opts.side) // If the side changes, invalidate EVERYTHING just in case // Might not be needed, but done just to be safe val invalidateAll = opts.side != manifest.cachedSide @@ -295,10 +291,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse // TODO: should linkedfile be checked as well? should this be done in the download section? if (invalidateAll) { f.invalidate() - } else if (invalidatedUris.contains(f.metadata.file)) { + } else if (invalidatedFiles.contains(f.metadata.file.rebase(opts.packFolder))) { f.invalidate() } - val file = manifest.cachedFiles[f.metadata.file] + val file = manifest.cachedFiles[f.metadata.file.rebase(opts.packFolder)] // Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back file?.backup() // If it is null, the DownloadTask will make a new empty cachedFile @@ -311,7 +307,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse } // Let's hope downloadMetadata is a pure function!!! - tasks.parallelStream().forEach { f -> f.downloadMetadata(indexFile, indexUri) } + tasks.parallelStream().forEach { f -> f.downloadMetadata(clientHolder) } val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList() if (failedTaskDetails.isNotEmpty()) { @@ -361,7 +357,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse ui.disableOptionsButton(optionTasks.isNotEmpty()) while (true) { - when (validateAndResolve(tasks)) { + when (validateAndResolve(tasks, clientHolder)) { ResolveResult.RETRY -> {} ResolveResult.QUIT -> return ResolveResult.SUCCESS -> break @@ -373,7 +369,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse val completionService: CompletionService = ExecutorCompletionService(threadPool) tasks.forEach { t -> completionService.submit { - t.download(opts.packFolder, indexUri) + t.download(opts.packFolder, clientHolder) t } } @@ -390,10 +386,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse if (task.failed()) { val oldFile = file.revert if (oldFile != null) { - task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, oldFile) } + manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), oldFile) } else { null } } else { - task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, file) } + manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), file) } } @@ -406,6 +402,8 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse ui.submitProgress(InstallProgress(progress, i + 1, tasks.size)) if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!) + // TODO: close client holder in more places? + clientHolder.close() threadPool.shutdown() cancelled = true return @@ -432,12 +430,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse SUCCESS; } - private fun validateAndResolve(nonFailedFirstTasks: List): ResolveResult { + private fun validateAndResolve(nonFailedFirstTasks: List, clientHolder: ClientHolder): ResolveResult { ui.submitProgress(InstallProgress("Validating existing files...")) // Validate existing files for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) { - downloadTask.validateExistingFile(opts.packFolder) + downloadTask.validateExistingFile(opts.packFolder, clientHolder) } // Resolve CurseForge metadata @@ -445,10 +443,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse .filter(DownloadTask::correctSide) .map { it.metadata } .filter { it.linkedFile != null } - .filter { it.linkedFile?.download?.mode == "metadata:curseforge" }.toList() + .filter { it.linkedFile!!.download.mode == DownloadMode.CURSEFORGE }.toList() if (cfFiles.isNotEmpty()) { ui.submitProgress(InstallProgress("Resolving CurseForge metadata...")) - val resolveFailures = resolveCfMetadata(cfFiles) + val resolveFailures = resolveCfMetadata(cfFiles, opts.packFolder, clientHolder) if (resolveFailures.isNotEmpty()) { errorsOccurred = true return when (ui.showExceptions(resolveFailures, cfFiles.size, true)) { diff --git a/src/main/kotlin/link/infra/packwiz/installer/metadata/DownloadMode.kt b/src/main/kotlin/link/infra/packwiz/installer/metadata/DownloadMode.kt new file mode 100644 index 0000000..f9db794 --- /dev/null +++ b/src/main/kotlin/link/infra/packwiz/installer/metadata/DownloadMode.kt @@ -0,0 +1,19 @@ +package link.infra.packwiz.installer.metadata + +import cc.ekblad.toml.model.TomlValue +import cc.ekblad.toml.tomlMapper + +enum class DownloadMode { + URL, + CURSEFORGE; + + companion object { + fun mapper() = tomlMapper { + decoder { it: TomlValue.String -> when (it.value) { + "", "url" -> URL + "metadata:curseforge" -> CURSEFORGE + else -> throw Exception("Unsupported download mode ${it.value}") + } } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/link/infra/packwiz/installer/metadata/EfficientBooleanAdapter.kt b/src/main/kotlin/link/infra/packwiz/installer/metadata/EfficientBooleanAdapter.kt index a0bba6d..9393aa8 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/metadata/EfficientBooleanAdapter.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/metadata/EfficientBooleanAdapter.kt @@ -17,7 +17,7 @@ class EfficientBooleanAdapter : TypeAdapter() { } @Throws(IOException::class) - override fun read(reader: JsonReader): Boolean? { + override fun read(reader: JsonReader): Boolean { if (reader.peek() == JsonToken.NULL) { reader.nextNull() return false diff --git a/src/main/kotlin/link/infra/packwiz/installer/metadata/IndexFile.kt b/src/main/kotlin/link/infra/packwiz/installer/metadata/IndexFile.kt index 3c76f9b..0858d50 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/metadata/IndexFile.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/metadata/IndexFile.kt @@ -1,100 +1,97 @@ package link.infra.packwiz.installer.metadata -import com.google.gson.annotations.SerializedName -import com.moandjiezana.toml.Toml +import cc.ekblad.toml.decode +import cc.ekblad.toml.tomlMapper 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 -import link.infra.packwiz.installer.request.HandlerManager.getFileSource -import link.infra.packwiz.installer.request.HandlerManager.getNewLoc +import link.infra.packwiz.installer.metadata.hash.HashFormat +import link.infra.packwiz.installer.target.ClientHolder +import link.infra.packwiz.installer.target.path.PackwizPath +import link.infra.packwiz.installer.util.delegateTransitive import okio.Source import okio.buffer -import java.io.InputStreamReader -import java.nio.file.Paths -class IndexFile { - @SerializedName("hash-format") - var hashFormat: String = "sha-256" - var files: MutableList = ArrayList() - - class File { - var file: SpaceSafeURI? = null - @SerializedName("hash-format") - var hashFormat: String? = null - var hash: String? = null - var alias: SpaceSafeURI? = null - var metafile = false - var preserve = false - - @Transient +data class IndexFile( + val hashFormat: HashFormat<*>, + val files: List = listOf() +) { + data class File( + val file: PackwizPath<*>, + private val hashFormat: HashFormat<*>? = null, + val hash: String, + val alias: PackwizPath<*>?, + val metafile: Boolean = false, + val preserve: Boolean = false, + ) { var linkedFile: ModFile? = null - @Transient - var linkedFileURI: SpaceSafeURI? = null + + fun hashFormat(index: IndexFile) = hashFormat ?: index.hashFormat + @Throws(Exception::class) + fun getHashObj(index: IndexFile): Hash<*> { + // TODO: more specific exceptions? + return hashFormat(index).fromString(hash) + } @Throws(Exception::class) - fun downloadMeta(parentIndexFile: IndexFile, indexUri: SpaceSafeURI?) { + fun downloadMeta(index: IndexFile, clientHolder: ClientHolder) { if (!metafile) { return } - if (hashFormat?.length ?: 0 == 0) { - hashFormat = parentIndexFile.hashFormat - } - // TODO: throw a proper exception instead of allowing NPE? - val fileHash = getHash(hashFormat!!, hash!!) - linkedFileURI = getNewLoc(indexUri, file) - val src = getFileSource(linkedFileURI!!) - val fileStream = getHasher(hashFormat!!).getHashingSource(src) - linkedFile = Toml().read(InputStreamReader(fileStream.buffer().inputStream(), "UTF-8")).to(ModFile::class.java) - if (!fileStream.hashIsEqual(fileHash)) { + val fileHash = getHashObj(index) + val src = file.source(clientHolder) + val fileStream = hashFormat(index).source(src) + linkedFile = ModFile.mapper(file).decode(fileStream.buffer().inputStream()) + if (fileHash != fileStream.hash) { + // TODO: propagate details about hash, and show better error! throw Exception("Invalid mod file hash") } } @Throws(Exception::class) - fun getSource(indexUri: SpaceSafeURI?): Source { + fun getSource(clientHolder: ClientHolder): Source { return if (metafile) { if (linkedFile == null) { throw Exception("Linked file doesn't exist!") } - linkedFile!!.getSource(linkedFileURI) + linkedFile!!.getSource(clientHolder) } else { - val newLoc = getNewLoc(indexUri, file) ?: throw Exception("Index file URI is invalid") - getFileSource(newLoc) + file.source(clientHolder) } } - @Throws(Exception::class) - fun getHashObj(): Hash { - if (hash == null) { // TODO: should these be more specific exceptions (e.g. IndexFileException?!) - throw Exception("Index file doesn't have a hash") - } - if (hashFormat == null) { - throw Exception("Index file doesn't have a hash format") - } - return getHash(hashFormat!!, hash!!) - } - - // TODO: throw some kind of exception? val name: String get() { if (metafile) { - return linkedFile?.name ?: linkedFile?.filename ?: - file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file" + return linkedFile?.name ?: file.filename } - return file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file" + return file.filename } - // TODO: URIs are bad - val destURI: SpaceSafeURI? + val destURI: PackwizPath<*> get() { if (alias != null) { return alias } - return if (metafile && linkedFile != null) { - linkedFile?.filename?.let { file?.resolve(it) } + return if (metafile) { + linkedFile!!.filename } else { file } } + + companion object { + fun mapper(base: PackwizPath<*>) = tomlMapper { + mapping("hash-format" to "hashFormat") + delegateTransitive>(HashFormat.mapper()) + delegateTransitive>(PackwizPath.mapperRelativeTo(base)) + } + } + } + + companion object { + fun mapper(base: PackwizPath<*>) = tomlMapper { + mapping("hash-format" to "hashFormat") + delegateTransitive>(HashFormat.mapper()) + delegateTransitive(File.mapper(base)) + } } } \ No newline at end of file diff --git a/src/main/kotlin/link/infra/packwiz/installer/metadata/ManifestFile.kt b/src/main/kotlin/link/infra/packwiz/installer/metadata/ManifestFile.kt index e87ac36..f0fa5e4 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/metadata/ManifestFile.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/metadata/ManifestFile.kt @@ -3,23 +3,23 @@ package link.infra.packwiz.installer.metadata import com.google.gson.annotations.JsonAdapter import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.target.Side +import link.infra.packwiz.installer.target.path.PackwizFilePath class ManifestFile { - var packFileHash: Hash? = null - var indexFileHash: Hash? = null - var cachedFiles: MutableMap = HashMap() + var packFileHash: Hash<*>? = null + var indexFileHash: Hash<*>? = null + var cachedFiles: MutableMap = HashMap() // If the side changes, EVERYTHING invalidates. FUN!!! var cachedSide = Side.CLIENT - // TODO: switch to Kotlin-friendly JSON/TOML libs? class File { @Transient var revert: File? = null private set - var hash: Hash? = null - var linkedFileHash: Hash? = null - var cachedLocation: String? = null + var hash: Hash<*>? = null + var linkedFileHash: Hash<*>? = null + var cachedLocation: PackwizFilePath? = null @JsonAdapter(EfficientBooleanAdapter::class) var isOptional = false diff --git a/src/main/kotlin/link/infra/packwiz/installer/metadata/ModFile.kt b/src/main/kotlin/link/infra/packwiz/installer/metadata/ModFile.kt index 0a02be2..9f063f1 100644 --- a/src/main/kotlin/link/infra/packwiz/installer/metadata/ModFile.kt +++ b/src/main/kotlin/link/infra/packwiz/installer/metadata/ModFile.kt @@ -1,74 +1,96 @@ package link.infra.packwiz.installer.metadata -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName +import cc.ekblad.toml.delegate +import cc.ekblad.toml.model.TomlValue +import cc.ekblad.toml.tomlMapper 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 -import link.infra.packwiz.installer.request.HandlerManager.getNewLoc +import link.infra.packwiz.installer.metadata.hash.HashFormat +import link.infra.packwiz.installer.target.ClientHolder import link.infra.packwiz.installer.target.Side +import link.infra.packwiz.installer.target.path.HttpUrlPath +import link.infra.packwiz.installer.target.path.PackwizPath +import link.infra.packwiz.installer.util.delegateTransitive +import okhttp3.HttpUrl.Companion.toHttpUrl import okio.Source +import kotlin.reflect.KType -class ModFile { - var name: String? = null - var filename: String? = null - var side: Side? = null - var download: Download? = null +data class ModFile( + val name: String, + val filename: PackwizPath<*>, + val side: Side = Side.BOTH, + val download: Download, + val update: Map = mapOf(), + val option: Option = Option(false) +) { + data class Download( + val url: PackwizPath<*>?, + val hashFormat: HashFormat<*>, + val hash: String, + val mode: DownloadMode = DownloadMode.URL + ) { + companion object { + fun mapper() = tomlMapper { + decoder> { it -> HttpUrlPath(it.value.toHttpUrl()) } + mapping("hash-format" to "hashFormat") - class Download { - var url: SpaceSafeURI? = null - @SerializedName("hash-format") - var hashFormat: String? = null - var hash: String? = null - var mode: String? = null + delegateTransitive>(HashFormat.mapper()) + delegate(DownloadMode.mapper()) + } + } } - @JsonAdapter(UpdateDeserializer::class) - var update: Map? = null - var option: Option? = null - @Transient - val resolvedUpdateData = mutableMapOf() + val resolvedUpdateData = mutableMapOf>() - class Option { - var optional = false - var description: String? = null - @SerializedName("default") - var defaultValue = false + data class Option( + val optional: Boolean, + val description: String = "", + val defaultValue: Boolean = false + ) { + companion object { + fun mapper() = tomlMapper { + mapping