Significant rewrite to use 4koma, OkHttp, PackwizPath; fixing several issues

Github release plugin, Kotlin, Okio and OkHttp updated
Toml4j removed and replaced with 4koma - improves null safety, immutability, TOML compliance
kotlin-result removed (I don't think it was used anyway)
SpaceSafeURI replaced with PackwizPath which handles special characters much better (fixes #5)
Fixed directory traversal issues
Hashing system rewritten for better type safety and cleaner code
Download mode changed to use an enum
Request system completely rewritten; now uses OkHttp for HTTP requests (fixes #36, fixes #5)
Added request interceptor which retries requests (fixes #4)
Removed: support for extracting packs from zip files (and Github zipballs)
Cleaner exceptions; more improvements pending (in particular showing request failure causes)
Improved speed of cancelling in-progress downloads
Better support for installing from local files (no file: URI required; though it is still supported)
Various code cleanup changes
This commit is contained in:
comp500 2022-07-10 01:44:35 +01:00
parent 02b01b90d7
commit 66bc4c3e29
52 changed files with 877 additions and 1140 deletions

View File

@ -3,8 +3,8 @@ plugins {
application application
id("com.github.johnrengelman.shadow") version "7.1.2" id("com.github.johnrengelman.shadow") version "7.1.2"
id("com.palantir.git-version") version "0.13.0" id("com.palantir.git-version") version "0.13.0"
id("com.github.breadmoirai.github-release") version "2.2.12" id("com.github.breadmoirai.github-release") version "2.4.1"
kotlin("jvm") version "1.6.10" kotlin("jvm") version "1.7.10"
id("com.github.jk1.dependency-license-report") version "2.0" id("com.github.jk1.dependency-license-report") version "2.0"
`maven-publish` `maven-publish`
} }
@ -16,18 +16,20 @@ java {
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
maven {
url = uri("https://jitpack.io")
}
} }
val r8 by configurations.creating val r8 by configurations.creating
dependencies { dependencies {
implementation("commons-cli:commons-cli:1.5.0") 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.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(kotlin("stdlib-jdk8"))
implementation("com.squareup.okhttp3:okhttp:4.9.3") implementation("com.squareup.okhttp3:okhttp:4.10.0")
implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.14") implementation("cc.ekblad:4koma:1.1.0")
r8("com.android.tools:r8:3.3.28") r8("com.android.tools:r8:3.3.28")
} }
@ -54,8 +56,9 @@ licenseReport {
} }
tasks.shadowJar { tasks.shadowJar {
exclude("**/*.kotlin_metadata") // 4koma uses kotlin-reflect; requires Kotlin metadata
exclude("**/*.kotlin_builtins") //exclude("**/*.kotlin_metadata")
//exclude("**/*.kotlin_builtins")
exclude("META-INF/maven/**/*") exclude("META-INF/maven/**/*")
exclude("META-INF/proguard/**/*") exclude("META-INF/proguard/**/*")

View File

@ -2,24 +2,24 @@ package link.infra.packwiz.installer
import link.infra.packwiz.installer.metadata.IndexFile import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile 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.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher 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.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import okio.* import okio.Buffer
import okio.Path.Companion.toOkioPath import okio.HashingSink
import okio.blackholeSink
import okio.buffer
import java.io.IOException import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption 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 var cachedFile: ManifestFile.File? = null
private var err: Exception? = 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 // If file is new or isOptional changed to true, the option needs to be presented again
private var newOptional = true private var newOptional = true
val isOptional get() = metadata.linkedFile?.isOptional ?: false val isOptional get() = metadata.linkedFile?.option?.optional ?: false
fun isNewOptional() = isOptional && newOptional 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 ?: "" override val optionDescription get() = metadata.linkedFile?.option?.description ?: ""
init {
if (metadata.hashFormat?.isEmpty() != false) {
metadata.hashFormat = defaultFormat
}
}
fun invalidate() { fun invalidate() {
invalidated = true invalidated = true
alreadyUpToDate = false alreadyUpToDate = false
@ -74,7 +68,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
this.cachedFile = cachedFile this.cachedFile = cachedFile
if (!invalidated) { if (!invalidated) {
val currHash = try { val currHash = try {
getHash(metadata.hashFormat!!, metadata.hash!!) metadata.getHashObj(index)
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return 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 (err != null) return
if (metadataRequired) { if (metadataRequired) {
try { try {
// Retrieve the linked metadata file // Retrieve the linked metadata file
metadata.downloadMeta(parentIndexFile, indexUri) metadata.downloadMeta(index, clientHolder)
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return return
@ -105,16 +99,14 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
cachedFile?.let { cachedFile -> cachedFile?.let { cachedFile ->
val linkedFile = metadata.linkedFile val linkedFile = metadata.linkedFile
if (linkedFile != null) { if (linkedFile != null) {
linkedFile.option?.let { opt -> if (linkedFile.option.optional) {
if (opt.optional) {
if (cachedFile.isOptional) { if (cachedFile.isOptional) {
// isOptional didn't change // isOptional didn't change
newOptional = false newOptional = false
} else { } else {
// isOptional false -> true, set option to it's default value // isOptional false -> true, set option to it's default value
// TODO: preserve previous option value, somehow?? // TODO: preserve previous option value, somehow??
cachedFile.optionValue = opt.defaultValue cachedFile.optionValue = linkedFile.option.defaultValue
}
} }
} }
cachedFile.isOptional = isOptional 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 * Check if the file in the destination location is already valid
* Must be done after metadata retrieval * Must be done after metadata retrieval
*/ */
fun validateExistingFile(packFolder: String) { fun validateExistingFile(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
if (!alreadyUpToDate) { if (!alreadyUpToDate) {
try { try {
// TODO: only do this for files that didn't exist before or have been modified since last full update? // 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()) val destPath = metadata.destURI.rebase(packFolder)
FileSystem.SYSTEM.source(destPath.toOkioPath()).buffer().use { src -> destPath.source(clientHolder).use { src ->
val hash: Hash // TODO: clean up duplicated code
val fileHashFormat: String val hash: Hash<*>
val fileHashFormat: HashFormat<*>
val linkedFile = metadata.linkedFile val linkedFile = metadata.linkedFile
if (linkedFile != null) { if (linkedFile != null) {
hash = linkedFile.hash hash = linkedFile.hash
fileHashFormat = linkedFile.download!!.hashFormat!! fileHashFormat = linkedFile.download.hashFormat
} else { } else {
hash = metadata.getHashObj() hash = metadata.getHashObj(index)
fileHashFormat = metadata.hashFormat!! fileHashFormat = metadata.hashFormat(index)
} }
val fileSource = getHasher(fileHashFormat).getHashingSource(src) val fileSource = fileHashFormat.source(src)
fileSource.buffer().readAll(blackholeSink()) fileSource.buffer().readAll(blackholeSink())
if (fileSource.hashIsEqual(hash)) { if (hash == fileSource.hash) {
alreadyUpToDate = true alreadyUpToDate = true
// Update the manifest file // Update the manifest file
cachedFile = (cachedFile ?: ManifestFile.File()).also { cachedFile = (cachedFile ?: ManifestFile.File()).also {
try { try {
it.hash = metadata.getHashObj() it.hash = metadata.getHashObj(index)
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return return
} }
it.isOptional = isOptional it.isOptional = isOptional
it.cachedLocation = metadata.destURI.toString() it.cachedLocation = metadata.destURI.rebase(packFolder)
metadata.linkedFile?.let { linked -> metadata.linkedFile?.let { linked ->
try { try {
it.linkedFileHash = linked.hash 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) { } catch (e: IOException) {
// Ignore exceptions; if the file doesn't exist we'll be downloading it // 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 if (err != null) return
// Exclude wrong-side and optional false files // 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) { if (it.cachedLocation != null) {
// Ensure wrong-side or optional false files are removed // Ensure wrong-side or optional false files are removed
try { try {
Files.deleteIfExists(Paths.get(packFolder, it.cachedLocation)) Files.deleteIfExists(it.cachedLocation!!.nioPath)
} catch (e: IOException) { } catch (e: IOException) {
Log.warn("Failed to delete file", e) Log.warn("Failed to delete file", e)
} }
@ -197,33 +192,32 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
} }
if (alreadyUpToDate) return 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 // Don't update files marked with preserve if they already exist on disk
if (metadata.preserve) { if (metadata.preserve) {
if (destPath.toFile().exists()) { if (destPath.nioPath.toFile().exists()) {
return return
} }
} }
// TODO: if already exists and has correct hash, ignore?
// TODO: add .disabled support? // TODO: add .disabled support?
try { try {
val hash: Hash val hash: Hash<*>
val fileHashFormat: String val fileHashFormat: HashFormat<*>
val linkedFile = metadata.linkedFile val linkedFile = metadata.linkedFile
if (linkedFile != null) { if (linkedFile != null) {
hash = linkedFile.hash hash = linkedFile.hash
fileHashFormat = linkedFile.download!!.hashFormat!! fileHashFormat = linkedFile.download.hashFormat
} else { } else {
hash = metadata.getHashObj() hash = metadata.getHashObj(index)
fileHashFormat = metadata.hashFormat!! fileHashFormat = metadata.hashFormat(index)
} }
val src = metadata.getSource(indexUri) val src = metadata.getSource(clientHolder)
val fileSource = getHasher(fileHashFormat).getHashingSource(src) val fileSource = fileHashFormat.source(src)
val data = Buffer() val data = Buffer()
// Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails) // 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) it.readAll(data)
} }
if (fileSource.hashIsEqual(hash)) { if (hash == fileSource.hash) {
// isDirectory follows symlinks, but createDirectories doesn't // isDirectory follows symlinks, but createDirectories doesn't
try { try {
Files.createDirectories(destPath.parent) Files.createDirectories(destPath.parent.nioPath)
} catch (e: java.nio.file.FileAlreadyExistsException) { } catch (e: java.nio.file.FileAlreadyExistsException) {
if (!Files.isDirectory(destPath.parent)) { if (!Files.isDirectory(destPath.parent.nioPath)) {
throw e throw e
} }
} }
Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING) Files.copy(data.inputStream(), destPath.nioPath, StandardCopyOption.REPLACE_EXISTING)
data.clear() data.clear()
} else { } else {
// TODO: move println to something visible in the error window // 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 return
} }
cachedFile?.cachedLocation?.let { cachedFile?.cachedLocation?.let {
if (destPath != Paths.get(packFolder, it)) { if (destPath != it) {
// Delete old file if location changes // Delete old file if location changes
try { try {
Files.delete(Paths.get(packFolder, cachedFile!!.cachedLocation)) Files.delete(cachedFile!!.cachedLocation!!.nioPath)
} catch (e: IOException) { } catch (e: IOException) {
// Continue, as it was probably already deleted? // Continue, as it was probably already deleted?
// TODO: log it // TODO: log it
@ -275,13 +269,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
// Update the manifest file // Update the manifest file
cachedFile = (cachedFile ?: ManifestFile.File()).also { cachedFile = (cachedFile ?: ManifestFile.File()).also {
try { try {
it.hash = metadata.getHashObj() it.hash = metadata.getHashObj(index)
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return return
} }
it.isOptional = isOptional it.isOptional = isOptional
it.cachedLocation = metadata.destURI.toString() it.cachedLocation = metadata.destURI.rebase(packFolder)
metadata.linkedFile?.let { linked -> metadata.linkedFile?.let { linked ->
try { try {
it.linkedFileHash = linked.hash it.linkedFileHash = linked.hash
@ -293,11 +287,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
} }
companion object { companion object {
@JvmStatic fun createTasksFromIndex(index: IndexFile, downloadSide: Side): MutableList<DownloadTask> {
fun createTasksFromIndex(index: IndexFile, defaultFormat: String, downloadSide: Side): MutableList<DownloadTask> {
val tasks = ArrayList<DownloadTask>() val tasks = ArrayList<DownloadTask>()
for (file in Objects.requireNonNull(index.files)) { for (file in index.files) {
tasks.add(DownloadTask(file, defaultFormat, downloadSide)) tasks.add(DownloadTask(file, index, downloadSide))
} }
return tasks return tasks
} }

View File

@ -7,8 +7,8 @@ import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.metadata.PackFile import link.infra.packwiz.installer.metadata.PackFile
import link.infra.packwiz.installer.ui.IUserInterface import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import java.io.File import kotlin.io.path.reader
import java.nio.file.Paths import kotlin.io.path.writeText
class LauncherUtils internal constructor(private val opts: UpdateManager.Options, val ui: IUserInterface) { class LauncherUtils internal constructor(private val opts: UpdateManager.Options, val ui: IUserInterface) {
enum class LauncherStatus { enum class LauncherStatus {
@ -20,14 +20,13 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
fun handleMultiMC(pf: PackFile, gson: Gson): LauncherStatus { fun handleMultiMC(pf: PackFile, gson: Gson): LauncherStatus {
// MultiMC MC and loader version checker // MultiMC MC and loader version checker
val manifestPath = Paths.get(opts.multimcFolder, "mmc-pack.json").toString() val manifestPath = opts.multimcFolder / "mmc-pack.json"
val manifestFile = File(manifestPath)
if (!manifestFile.exists()) { if (!manifestPath.nioPath.toFile().exists()) {
return LauncherStatus.NOT_FOUND return LauncherStatus.NOT_FOUND
} }
val multimcManifest = manifestFile.reader().use { val multimcManifest = manifestPath.nioPath.reader().use {
try { try {
JsonParser.parseReader(it) JsonParser.parseReader(it)
} catch (e: JsonIOException) { } catch (e: JsonIOException) {
@ -64,7 +63,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
if (modLoaders.containsKey(component["uid"]?.asString)) { if (modLoaders.containsKey(component["uid"]?.asString)) {
val modLoader = modLoaders.getValue(component["uid"]!!.asString) val modLoader = modLoaders.getValue(component["uid"]!!.asString)
loaderVersionsFound[modLoader] = version loaderVersionsFound[modLoader] = version
if (version != pf.versions?.get(modLoader)) { if (version != pf.versions[modLoader]) {
outdatedLoaders.add(modLoader) outdatedLoaders.add(modLoader)
true // Delete component; cached metadata is invalid and will be re-added true // Delete component; cached metadata is invalid and will be re-added
} else { } else {
@ -75,18 +74,18 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
for ((_, loader) in modLoaders for ((_, loader) in modLoaders
.filter { .filter {
(!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) (!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) && pf.versions.containsKey(it.value)
&& pf.versions?.containsKey(it.value) == true } }
) { ) {
manifestModified = true manifestModified = true
components.add(gson.toJsonTree( 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 // 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 { 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) components.remove(it)
manifestModified = true 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 // 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 // if they wanna update it, continue without updating it, or exit
val oldVers = loaderVersionsFound.map { Pair(it.key, it.value) } 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)) { when (ui.showUpdateConfirmationDialog(oldVers, newVers)) {
IUserInterface.UpdateConfirmationResult.CANCELLED -> { IUserInterface.UpdateConfirmationResult.CANCELLED -> {
@ -108,7 +107,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
else -> {} else -> {}
} }
manifestFile.writeText(gson.toJson(multimcManifest)) manifestPath.nioPath.writeText(gson.toJson(multimcManifest))
Log.info("Successfully updated mmc-pack.json based on version metadata") Log.info("Successfully updated mmc-pack.json based on version metadata")
return LauncherStatus.SUCCESSFUL return LauncherStatus.SUCCESSFUL

View File

@ -2,17 +2,23 @@
package link.infra.packwiz.installer 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.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.cli.CLIHandler
import link.infra.packwiz.installer.ui.gui.GUIHandler import link.infra.packwiz.installer.ui.gui.GUIHandler
import link.infra.packwiz.installer.ui.wrap
import link.infra.packwiz.installer.util.Log 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.DefaultParser
import org.apache.commons.cli.Options import org.apache.commons.cli.Options
import org.apache.commons.cli.ParseException import org.apache.commons.cli.ParseException
import java.awt.EventQueue import java.awt.EventQueue
import java.awt.GraphicsEnvironment import java.awt.GraphicsEnvironment
import java.net.URISyntaxException import java.net.URI
import java.nio.file.Paths
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -66,21 +72,41 @@ class Main(args: Array<String>) {
ui.show() ui.show()
val uOptions = try { val packFileRaw = unparsedArgs[0]
UpdateManager.Options.construct(
downloadURI = SpaceSafeURI(unparsedArgs[0]), val packFile = when {
side = cmd.getOptionValue("side")?.let((Side)::from), // HTTP(s) URLs
packFolder = cmd.getOptionValue("pack-folder"), Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.wrap("Invalid HTTP/HTTPS URL for pack file: $packFileRaw") {
multimcFolder = cmd.getOptionValue("multimc-folder"), HttpUrlPath(packFileRaw.toHttpUrl().resolve(".")!!, packFileRaw.toHttpUrl().pathSegments.last())
manifestFile = cmd.getOptionValue("meta-file") }
) // File URIs (uses same logic as old packwiz-installer, for backwards compat)
} catch (e: URISyntaxException) { Regex("^file:", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> {
ui.showErrorAndExit("Failed to read pack.toml URI", e) 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! // Start update process!
try { try {
UpdateManager(uOptions, ui) UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side), ui)
} catch (e: Exception) { } catch (e: Exception) {
ui.showErrorAndExit("Update process failed", e) ui.showErrorAndExit("Update process failed", e)
} }

View File

@ -1,31 +1,33 @@
package link.infra.packwiz.installer package link.infra.packwiz.installer
import cc.ekblad.toml.decode
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonIOException import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import com.moandjiezana.toml.Toml
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex 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.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile import link.infra.packwiz.installer.metadata.ManifestFile
import link.infra.packwiz.installer.metadata.PackFile 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.curseforge.resolveCfMetadata
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.request.HandlerManager.getFileSource import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
import link.infra.packwiz.installer.target.Side 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
import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.InstallProgress import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import link.infra.packwiz.installer.util.ifletOrErr
import okio.buffer 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.Files
import java.nio.file.Paths
import java.util.concurrent.CompletionService import java.util.concurrent.CompletionService
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorCompletionService import java.util.concurrent.ExecutorCompletionService
@ -42,29 +44,32 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
data class Options( data class Options(
val downloadURI: SpaceSafeURI, val packFile: PackwizPath<*>,
val manifestFile: String, val manifestFile: PackwizFilePath,
val packFolder: String, val packFolder: PackwizFilePath,
val multimcFolder: String, val multimcFolder: PackwizFilePath,
val side: Side 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() { private fun start() {
checkOptions() val clientHolder = ClientHolder()
ui.cancelCallback = {
clientHolder.close()
}
ui.submitProgress(InstallProgress("Loading manifest file...")) 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 { val manifest = try {
gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()), // TODO: kotlinx.serialisation?
ManifestFile::class.java) InputStreamReader(opts.manifestFile.source(clientHolder).inputStream(), StandardCharsets.UTF_8).use { reader ->
} catch (e: FileNotFoundException) { gson.fromJson(reader, ManifestFile::class.java)
}
} catch (e: RequestException.Response.File.FileNotFound) {
ui.firstInstall = true ui.firstInstall = true
ManifestFile() ManifestFile()
} catch (e: JsonSyntaxException) { } catch (e: JsonSyntaxException) {
@ -80,14 +85,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
ui.submitProgress(InstallProgress("Loading pack file...")) ui.submitProgress(InstallProgress("Loading pack file..."))
val packFileSource = try { val packFileSource = try {
val src = getFileSource(opts.downloadURI) val src = opts.packFile.source(clientHolder)
getHasher("sha256").getHashingSource(src) HashFormat.SHA256.source(src)
} catch (e: Exception) { } catch (e: Exception) {
// TODO: ensure suppressed/caused exceptions are shown?
ui.showErrorAndExit("Failed to download pack.toml", e) ui.showErrorAndExit("Failed to download pack.toml", e)
} }
val pf = packFileSource.buffer().use { val pf = packFileSource.buffer().use {
try { try {
Toml().read(InputStreamReader(it.inputStream(), "UTF-8")).to(PackFile::class.java) PackFile.mapper(opts.packFile).decode<PackFile>(it.inputStream())
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse pack.toml", e) 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...")) ui.submitProgress(InstallProgress("Checking local files..."))
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked // Invalidation checking must be done here, as it must happen before pack/index hashes are checked
val invalidatedUris: MutableList<SpaceSafeURI> = ArrayList() val invalidatedUris: MutableList<PackwizFilePath> = ArrayList()
for ((fileUri, file) in manifest.cachedFiles) { for ((fileUri, file) in manifest.cachedFiles) {
// ignore onlyOtherSide files // ignore onlyOtherSide files
if (file.onlyOtherSide) { 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 isn't optional, or is optional but optionValue == true
if (!file.isOptional || file.optionValue) { if (!file.isOptional || file.optionValue) {
if (file.cachedLocation != null) { if (file.cachedLocation != null) {
if (!Paths.get(opts.packFolder, file.cachedLocation).toFile().exists()) { if (!file.cachedLocation!!.nioPath.toFile().exists()) {
invalid = true invalid = true
} }
} else { } else {
@ -142,12 +148,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
} }
if (invalid) { if (invalid) {
Log.info("File $fileUri invalidated, marked for redownloading") Log.info("File ${fileUri.filename} invalidated, marked for redownloading")
invalidatedUris.add(fileUri) 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? // todo: --force?
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1)) ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) { if (manifest.cachedFiles.any { it.value.isOptional }) {
@ -165,20 +171,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
handleCancellation() handleCancellation()
} }
try { 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( processIndex(
newLoc, pf.index.file,
getHash(hashFormat, hash), pf.index.hashFormat.fromString(pf.index.hash),
hashFormat, pf.index.hashFormat,
manifest, manifest,
invalidatedUris invalidatedUris,
clientHolder
) )
}
}
}
} catch (e1: Exception) { } catch (e1: Exception) {
ui.showErrorAndExit("Failed to process index file", e1) 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 manifest.cachedSide = opts.side
try { 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) { } catch (e: IOException) {
ui.showErrorAndExit("Failed to save local manifest file", e) ui.showErrorAndExit("Failed to save local manifest file", e)
} }
} }
private fun checkOptions() { private fun processIndex(indexUri: PackwizPath<*>, indexHash: Hash<*>, hashFormat: HashFormat<*>, manifest: ManifestFile, invalidatedFiles: List<PackwizFilePath>, clientHolder: ClientHolder) {
// TODO: implement if (manifest.indexFileHash == indexHash && invalidatedFiles.isEmpty()) {
}
private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1)) ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) { if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false) ui.awaitOptionalButton(false)
@ -223,18 +219,18 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
manifest.indexFileHash = indexHash manifest.indexFileHash = indexHash
val indexFileSource = try { val indexFileSource = try {
val src = getFileSource(indexUri) val src = indexUri.source(clientHolder)
getHasher(hashFormat).getHashingSource(src) hashFormat.source(src)
} catch (e: Exception) { } catch (e: Exception) {
ui.showErrorAndExit("Failed to download index file", e) ui.showErrorAndExit("Failed to download index file", e)
} }
val indexFile = try { val indexFile = try {
Toml().read(InputStreamReader(indexFileSource.buffer().inputStream(), "UTF-8")).to(IndexFile::class.java) IndexFile.mapper(indexUri).decode<IndexFile>(indexFileSource.buffer().inputStream())
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse index file", e) 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") 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...")) ui.submitProgress(InstallProgress("Checking local files..."))
// TODO: use kotlin filtering/FP rather than an iterator? // TODO: use kotlin filtering/FP rather than an iterator?
val it: MutableIterator<Map.Entry<SpaceSafeURI, ManifestFile.File>> = manifest.cachedFiles.entries.iterator() val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
while (it.hasNext()) { while (it.hasNext()) {
val (uri, file) = it.next() val (uri, file) = it.next()
if (file.cachedLocation != null) { 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 // Delete if option value has been set to false
if (file.isOptional && !file.optionValue) { if (file.isOptional && !file.optionValue) {
try { try {
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation)) Files.deleteIfExists(file.cachedLocation!!.nioPath)
} catch (e: IOException) { } catch (e: IOException) {
Log.warn("Failed to delete optional disabled file", e) 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 file.cachedLocation = null
alreadyDeleted = true 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) { if (!alreadyDeleted) {
try { try {
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation)) Files.deleteIfExists(file.cachedLocation!!.nioPath)
} catch (e: IOException) { } catch (e: IOException) {
Log.warn("Failed to delete file removed from index", e) 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()) { if (indexFile.files.isEmpty()) {
Log.warn("Index is empty!") 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 // If the side changes, invalidate EVERYTHING just in case
// Might not be needed, but done just to be safe // Might not be needed, but done just to be safe
val invalidateAll = opts.side != manifest.cachedSide 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? // TODO: should linkedfile be checked as well? should this be done in the download section?
if (invalidateAll) { if (invalidateAll) {
f.invalidate() f.invalidate()
} else if (invalidatedUris.contains(f.metadata.file)) { } else if (invalidatedFiles.contains(f.metadata.file.rebase(opts.packFolder))) {
f.invalidate() 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 // 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() file?.backup()
// If it is null, the DownloadTask will make a new empty cachedFile // 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!!! // 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() val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTaskDetails.isNotEmpty()) { if (failedTaskDetails.isNotEmpty()) {
@ -361,7 +357,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
ui.disableOptionsButton(optionTasks.isNotEmpty()) ui.disableOptionsButton(optionTasks.isNotEmpty())
while (true) { while (true) {
when (validateAndResolve(tasks)) { when (validateAndResolve(tasks, clientHolder)) {
ResolveResult.RETRY -> {} ResolveResult.RETRY -> {}
ResolveResult.QUIT -> return ResolveResult.QUIT -> return
ResolveResult.SUCCESS -> break ResolveResult.SUCCESS -> break
@ -373,7 +369,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool) val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool)
tasks.forEach { t -> tasks.forEach { t ->
completionService.submit { completionService.submit {
t.download(opts.packFolder, indexUri) t.download(opts.packFolder, clientHolder)
t t
} }
} }
@ -390,10 +386,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
if (task.failed()) { if (task.failed()) {
val oldFile = file.revert val oldFile = file.revert
if (oldFile != null) { 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 { null }
} else { } 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)) 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!) 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() threadPool.shutdown()
cancelled = true cancelled = true
return return
@ -432,12 +430,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
SUCCESS; SUCCESS;
} }
private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>): ResolveResult { private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>, clientHolder: ClientHolder): ResolveResult {
ui.submitProgress(InstallProgress("Validating existing files...")) ui.submitProgress(InstallProgress("Validating existing files..."))
// Validate existing files // Validate existing files
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) { for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
downloadTask.validateExistingFile(opts.packFolder) downloadTask.validateExistingFile(opts.packFolder, clientHolder)
} }
// Resolve CurseForge metadata // Resolve CurseForge metadata
@ -445,10 +443,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
.filter(DownloadTask::correctSide) .filter(DownloadTask::correctSide)
.map { it.metadata } .map { it.metadata }
.filter { it.linkedFile != null } .filter { it.linkedFile != null }
.filter { it.linkedFile?.download?.mode == "metadata:curseforge" }.toList() .filter { it.linkedFile!!.download.mode == DownloadMode.CURSEFORGE }.toList()
if (cfFiles.isNotEmpty()) { if (cfFiles.isNotEmpty()) {
ui.submitProgress(InstallProgress("Resolving CurseForge metadata...")) ui.submitProgress(InstallProgress("Resolving CurseForge metadata..."))
val resolveFailures = resolveCfMetadata(cfFiles) val resolveFailures = resolveCfMetadata(cfFiles, opts.packFolder, clientHolder)
if (resolveFailures.isNotEmpty()) { if (resolveFailures.isNotEmpty()) {
errorsOccurred = true errorsOccurred = true
return when (ui.showExceptions(resolveFailures, cfFiles.size, true)) { return when (ui.showExceptions(resolveFailures, cfFiles.size, true)) {

View File

@ -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}")
} }
}
}
}

View File

@ -17,7 +17,7 @@ class EfficientBooleanAdapter : TypeAdapter<Boolean?>() {
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun read(reader: JsonReader): Boolean? { override fun read(reader: JsonReader): Boolean {
if (reader.peek() == JsonToken.NULL) { if (reader.peek() == JsonToken.NULL) {
reader.nextNull() reader.nextNull()
return false return false

View File

@ -1,100 +1,97 @@
package link.infra.packwiz.installer.metadata package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.SerializedName import cc.ekblad.toml.decode
import com.moandjiezana.toml.Toml import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.request.HandlerManager.getFileSource import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc import link.infra.packwiz.installer.util.delegateTransitive
import okio.Source import okio.Source
import okio.buffer import okio.buffer
import java.io.InputStreamReader
import java.nio.file.Paths
class IndexFile { data class IndexFile(
@SerializedName("hash-format") val hashFormat: HashFormat<*>,
var hashFormat: String = "sha-256" val files: List<File> = listOf()
var files: MutableList<File> = ArrayList() ) {
data class File(
class File { val file: PackwizPath<*>,
var file: SpaceSafeURI? = null private val hashFormat: HashFormat<*>? = null,
@SerializedName("hash-format") val hash: String,
var hashFormat: String? = null val alias: PackwizPath<*>?,
var hash: String? = null val metafile: Boolean = false,
var alias: SpaceSafeURI? = null val preserve: Boolean = false,
var metafile = false ) {
var preserve = false
@Transient
var linkedFile: ModFile? = null 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) @Throws(Exception::class)
fun downloadMeta(parentIndexFile: IndexFile, indexUri: SpaceSafeURI?) { fun downloadMeta(index: IndexFile, clientHolder: ClientHolder) {
if (!metafile) { if (!metafile) {
return return
} }
if (hashFormat?.length ?: 0 == 0) { val fileHash = getHashObj(index)
hashFormat = parentIndexFile.hashFormat val src = file.source(clientHolder)
} val fileStream = hashFormat(index).source(src)
// TODO: throw a proper exception instead of allowing NPE? linkedFile = ModFile.mapper(file).decode<ModFile>(fileStream.buffer().inputStream())
val fileHash = getHash(hashFormat!!, hash!!) if (fileHash != fileStream.hash) {
linkedFileURI = getNewLoc(indexUri, file) // TODO: propagate details about hash, and show better error!
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)) {
throw Exception("Invalid mod file hash") throw Exception("Invalid mod file hash")
} }
} }
@Throws(Exception::class) @Throws(Exception::class)
fun getSource(indexUri: SpaceSafeURI?): Source { fun getSource(clientHolder: ClientHolder): Source {
return if (metafile) { return if (metafile) {
if (linkedFile == null) { if (linkedFile == null) {
throw Exception("Linked file doesn't exist!") throw Exception("Linked file doesn't exist!")
} }
linkedFile!!.getSource(linkedFileURI) linkedFile!!.getSource(clientHolder)
} else { } else {
val newLoc = getNewLoc(indexUri, file) ?: throw Exception("Index file URI is invalid") file.source(clientHolder)
getFileSource(newLoc)
} }
} }
@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 val name: String
get() { get() {
if (metafile) { if (metafile) {
return linkedFile?.name ?: linkedFile?.filename ?: return linkedFile?.name ?: file.filename
file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
} }
return file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file" return file.filename
} }
// TODO: URIs are bad val destURI: PackwizPath<*>
val destURI: SpaceSafeURI?
get() { get() {
if (alias != null) { if (alias != null) {
return alias return alias
} }
return if (metafile && linkedFile != null) { return if (metafile) {
linkedFile?.filename?.let { file?.resolve(it) } linkedFile!!.filename
} else { } else {
file file
} }
} }
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<File>("hash-format" to "hashFormat")
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
}
}
}
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<IndexFile>("hash-format" to "hashFormat")
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegateTransitive<File>(File.mapper(base))
}
} }
} }

View File

@ -3,23 +3,23 @@ package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.JsonAdapter
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.target.Side import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
class ManifestFile { class ManifestFile {
var packFileHash: Hash? = null var packFileHash: Hash<*>? = null
var indexFileHash: Hash? = null var indexFileHash: Hash<*>? = null
var cachedFiles: MutableMap<SpaceSafeURI, File> = HashMap() var cachedFiles: MutableMap<PackwizFilePath, File> = HashMap()
// If the side changes, EVERYTHING invalidates. FUN!!! // If the side changes, EVERYTHING invalidates. FUN!!!
var cachedSide = Side.CLIENT var cachedSide = Side.CLIENT
// TODO: switch to Kotlin-friendly JSON/TOML libs?
class File { class File {
@Transient @Transient
var revert: File? = null var revert: File? = null
private set private set
var hash: Hash? = null var hash: Hash<*>? = null
var linkedFileHash: Hash? = null var linkedFileHash: Hash<*>? = null
var cachedLocation: String? = null var cachedLocation: PackwizFilePath? = null
@JsonAdapter(EfficientBooleanAdapter::class) @JsonAdapter(EfficientBooleanAdapter::class)
var isOptional = false var isOptional = false

View File

@ -1,74 +1,96 @@
package link.infra.packwiz.installer.metadata package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter import cc.ekblad.toml.delegate
import com.google.gson.annotations.SerializedName 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.UpdateData
import link.infra.packwiz.installer.metadata.curseforge.UpdateDeserializer
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.request.HandlerManager.getFileSource import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
import link.infra.packwiz.installer.target.Side 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 okio.Source
import kotlin.reflect.KType
class ModFile { data class ModFile(
var name: String? = null val name: String,
var filename: String? = null val filename: PackwizPath<*>,
var side: Side? = null val side: Side = Side.BOTH,
var download: Download? = null val download: Download,
val update: Map<String, UpdateData> = 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<TomlValue.String, PackwizPath<*>> { it -> HttpUrlPath(it.value.toHttpUrl()) }
mapping<Download>("hash-format" to "hashFormat")
class Download { delegateTransitive<HashFormat<*>>(HashFormat.mapper())
var url: SpaceSafeURI? = null delegate<DownloadMode>(DownloadMode.mapper())
@SerializedName("hash-format") }
var hashFormat: String? = null }
var hash: String? = null
var mode: String? = null
} }
@JsonAdapter(UpdateDeserializer::class)
var update: Map<String, UpdateData>? = null
var option: Option? = null
@Transient @Transient
val resolvedUpdateData = mutableMapOf<String, SpaceSafeURI>() val resolvedUpdateData = mutableMapOf<String, PackwizPath<*>>()
class Option { data class Option(
var optional = false val optional: Boolean,
var description: String? = null val description: String = "",
@SerializedName("default") val defaultValue: Boolean = false
var defaultValue = false ) {
companion object {
fun mapper() = tomlMapper {
mapping<Option>("default" to "defaultValue")
}
}
} }
@Throws(Exception::class) @Throws(Exception::class)
fun getSource(baseLoc: SpaceSafeURI?): Source { fun getSource(clientHolder: ClientHolder): Source {
download?.let { return when (download.mode) {
if (it.mode == null || it.mode == "" || it.mode == "url") { DownloadMode.URL -> {
if (it.url == null) { (download.url ?: throw Exception("No download URL provided")).source(clientHolder)
throw Exception("Metadata file doesn't have a download URI")
} }
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid") DownloadMode.CURSEFORGE -> {
return getFileSource(newLoc)
} else if (it.mode == "metadata:curseforge") {
if (!resolvedUpdateData.contains("curseforge")) { if (!resolvedUpdateData.contains("curseforge")) {
throw Exception("Metadata file specifies CurseForge mode, but is missing metadata") throw Exception("Metadata file specifies CurseForge mode, but is missing metadata")
} }
return getFileSource(resolvedUpdateData["curseforge"]!!) return resolvedUpdateData["curseforge"]!!.source(clientHolder)
} else { }
throw Exception("Unsupported download mode " + it.mode)
} }
} ?: throw Exception("Metadata file doesn't have download")
} }
@get:Throws(Exception::class) @get:Throws(Exception::class)
val hash: Hash val hash: Hash<*>
get() { get() = download.hashFormat.fromString(download.hash)
download?.let {
return getHash(
it.hashFormat ?: throw Exception("Metadata file doesn't have a hash format"),
it.hash ?: throw Exception("Metadata file doesn't have a hash")
)
} ?: throw Exception("Metadata file doesn't have download")
}
val isOptional: Boolean get() = option?.optional ?: false companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
delegateTransitive<Option>(Option.mapper())
delegateTransitive<Download>(Download.mapper())
delegateTransitive<Side>(Side.mapper())
val updateDataMapper = UpdateData.mapper()
decoder { type: KType, it: TomlValue.Map ->
if (type.arguments[1].type?.classifier == UpdateData::class) {
updateDataMapper.decode<Map<String, UpdateData>>(it)
} else {
pass()
}
}
}
}
} }

View File

@ -1,17 +1,37 @@
package link.infra.packwiz.installer.metadata package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.SerializedName import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.util.delegateTransitive
class PackFile { data class PackFile(
var name: String? = null val name: String,
var index: IndexFileLoc? = null val packFormat: PackFormat = PackFormat.DEFAULT,
val index: IndexFileLoc,
class IndexFileLoc { val versions: Map<String, String> = mapOf()
var file: SpaceSafeURI? = null ) {
@SerializedName("hash-format") data class IndexFileLoc(
var hashFormat: String? = null val file: PackwizPath<*>,
var hash: String? = null val hashFormat: HashFormat<*>,
val hash: String,
) {
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<IndexFileLoc>("hash-format" to "hashFormat")
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
}
}
} }
var versions: Map<String, String>? = null companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<PackFile>("pack-format" to "packFormat")
decoder { it: TomlValue.String -> PackFormat(it.value) }
encoder { it: PackFormat -> TomlValue.String(it.format) }
delegateTransitive<IndexFileLoc>(IndexFileLoc.mapper(base))
}
}
} }

View File

@ -0,0 +1,10 @@
package link.infra.packwiz.installer.metadata
@JvmInline
value class PackFormat(val format: String) {
companion object {
val DEFAULT = PackFormat("packwiz:1.0.0")
}
// TODO: implement validation, errors for too new / invalid versions
}

View File

@ -1,61 +0,0 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import java.io.Serializable
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
import java.net.URL
// The world's worst URI wrapper
@JsonAdapter(SpaceSafeURIParser::class)
class SpaceSafeURI : Comparable<SpaceSafeURI>, Serializable {
private val u: URI
@Throws(URISyntaxException::class)
constructor(str: String) {
u = URI(str.replace(" ", "%20"))
}
constructor(uri: URI) {
u = uri
}
@Throws(URISyntaxException::class)
constructor(scheme: String?, authority: String?, path: String?, query: String?, fragment: String?) { // TODO: do all components need to be replaced?
u = URI(
scheme?.replace(" ", "%20"),
authority?.replace(" ", "%20"),
path?.replace(" ", "%20"),
query?.replace(" ", "%20"),
fragment?.replace(" ", "%20")
)
}
val path: String? get() = u.path?.replace("%20", " ")
override fun toString(): String = u.toString().replace("%20", " ")
fun resolve(path: String): SpaceSafeURI = SpaceSafeURI(u.resolve(path.replace(" ", "%20")))
fun resolve(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.resolve(loc.u))
fun relativize(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.relativize(loc.u))
override fun equals(other: Any?): Boolean {
return if (other is SpaceSafeURI) {
u == other.u
} else false
}
override fun hashCode() = u.hashCode()
override fun compareTo(other: SpaceSafeURI): Int = u.compareTo(other.u)
val scheme: String? get() = u.scheme
val authority: String? get() = u.authority
val host: String? get() = u.host
@Throws(MalformedURLException::class)
fun toURL(): URL = u.toURL()
}

View File

@ -1,25 +0,0 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
import java.net.URISyntaxException
/**
* This class encodes spaces before parsing the URI, so the URI can actually be
* parsed.
*/
internal class SpaceSafeURIParser : JsonDeserializer<SpaceSafeURI> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): SpaceSafeURI {
return try {
SpaceSafeURI(json.asString)
} catch (e: URISyntaxException) {
throw JsonParseException("Failed to parse URI", e)
}
}
// TODO: replace this with a better solution?
}

View File

@ -4,15 +4,18 @@ import com.google.gson.Gson
import com.google.gson.JsonIOException import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.metadata.IndexFile 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.target.ClientHolder
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails import link.infra.packwiz.installer.ui.data.ExceptionDetails
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.ByteString.Companion.decodeBase64 import okio.ByteString.Companion.decodeBase64
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import kotlin.io.path.absolute
private class GetFilesRequest(val fileIds: List<Int>) private class GetFilesRequest(val fileIds: List<Int>)
private class GetModsRequest(val modIds: List<Int>) private class GetModsRequest(val modIds: List<Int>)
@ -21,7 +24,7 @@ private class GetFilesResponse {
class CfFile { class CfFile {
var id = 0 var id = 0
var modId = 0 var modId = 0
var downloadUrl: SpaceSafeURI? = null var downloadUrl: String? = null
} }
val data = mutableListOf<CfFile>() val data = mutableListOf<CfFile>()
} }
@ -43,25 +46,17 @@ private const val APIServer = "api.curseforge.com"
private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh".decodeBase64()!! private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh".decodeBase64()!!
.string(StandardCharsets.UTF_8) .string(StandardCharsets.UTF_8)
private val clientHolder = ClientHolder()
// TODO: switch to PackwizPath stuff and OkHttp in old code
@Throws(JsonSyntaxException::class, JsonIOException::class) @Throws(JsonSyntaxException::class, JsonIOException::class)
fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> { fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> {
val failures = mutableListOf<ExceptionDetails>() val failures = mutableListOf<ExceptionDetails>()
val fileIdMap = mutableMapOf<Int, IndexFile.File>() val fileIdMap = mutableMapOf<Int, IndexFile.File>()
for (mod in mods) { for (mod in mods) {
if (mod.linkedFile!!.update == null) { if (!mod.linkedFile!!.update.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no update section"))) failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue continue
} }
if (!mod.linkedFile!!.update!!.contains("curseforge")) { fileIdMap[(mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId] = mod
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 reqData = GetFilesRequest(fileIdMap.keys.toList())
@ -93,7 +88,13 @@ fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
manualDownloadMods[file.modId] = Pair(fileIdMap[file.id]!!, file.id) manualDownloadMods[file.modId] = Pair(fileIdMap[file.id]!!, file.id)
continue continue
} }
fileIdMap[file.id]!!.linkedFile!!.resolvedUpdateData["curseforge"] = file.downloadUrl!! try {
fileIdMap[file.id]!!.linkedFile!!.resolvedUpdateData["curseforge"] =
HttpUrlPath(file.downloadUrl!!.toHttpUrl())
} catch (e: IllegalArgumentException) {
failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to parse URL: ${file.downloadUrl} for ID ${file.id}, Project ID ${file.modId}", e)))
}
} }
if (manualDownloadMods.isNotEmpty()) { if (manualDownloadMods.isNotEmpty()) {
@ -124,7 +125,7 @@ fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
val modFile = manualDownloadMods[mod.id]!! 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" + 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}"))) "Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI.rebase(packFolder).nioPath.absolute()}")))
} }
} }

View File

@ -1,10 +1,14 @@
package link.infra.packwiz.installer.metadata.curseforge package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.annotations.SerializedName import cc.ekblad.toml.tomlMapper
class CurseForgeUpdateData: UpdateData { data class CurseForgeUpdateData(
@SerializedName("file-id") val fileId: Int,
var fileId = 0 val projectId: Int,
@SerializedName("project-id") ): UpdateData {
var projectId = 0 companion object {
fun mapper() = tomlMapper {
mapping<CurseForgeUpdateData>("file-id" to "fileId", "project-id" to "projectId")
}
}
} }

View File

@ -1,3 +1,17 @@
package link.infra.packwiz.installer.metadata.curseforge package link.infra.packwiz.installer.metadata.curseforge
interface UpdateData import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
interface UpdateData {
companion object {
fun mapper() = tomlMapper {
val cfMapper = CurseForgeUpdateData.mapper()
decoder { it: TomlValue.Map ->
if (it.properties.contains("curseforge")) {
mapOf("curseforge" to cfMapper.decode<CurseForgeUpdateData>(it.properties["curseforge"]!!))
} else { mapOf() }
}
}
}
}

View File

@ -1,10 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.ForwardingSource
import okio.Source
abstract class GeneralHashingSource(delegate: Source) : ForwardingSource(delegate) {
abstract val hash: Hash
fun hashIsEqual(compareTo: Any) = compareTo == hash
}

View File

@ -1,20 +1,55 @@
package link.infra.packwiz.installer.metadata.hash package link.infra.packwiz.installer.metadata.hash
import com.google.gson.* import com.google.gson.*
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ForwardingSource
import okio.HashingSource
import okio.Source
import java.lang.reflect.Type import java.lang.reflect.Type
abstract class Hash { data class Hash<T>(val type: HashFormat<T>, val value: T) {
protected abstract val stringValue: String interface Encoding<T> {
protected abstract val type: String fun encodeToString(value: T): String
fun decodeFromString(str: String): T
class TypeHandler : JsonDeserializer<Hash>, JsonSerializer<Hash> { object Hex: Encoding<ByteString> {
override fun serialize(src: Hash, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = JsonObject().apply { override fun encodeToString(value: ByteString) = value.hex()
add("type", JsonPrimitive(src.type)) override fun decodeFromString(str: String) = str.decodeHex()
add("value", JsonPrimitive(src.stringValue)) }
object UInt: Encoding<kotlin.UInt> {
override fun encodeToString(value: kotlin.UInt) = value.toString()
override fun decodeFromString(str: String) = str.toUInt()
}
}
fun interface SourceProvider<T> {
fun source(type: HashFormat<T>, delegate: Source): HasherSource<T>
companion object {
fun fromOkio(provider: ((Source) -> HashingSource)): SourceProvider<ByteString> {
return SourceProvider { type, delegate ->
val delegateHashing = provider.invoke(delegate)
object : ForwardingSource(delegateHashing), HasherSource<ByteString> {
override val hash: Hash<ByteString> by lazy(LazyThreadSafetyMode.NONE) { Hash(type, delegateHashing.hash) }
}
}
}
}
}
class TypeHandler : JsonDeserializer<Hash<*>>, JsonSerializer<Hash<*>> {
override fun serialize(src: Hash<*>, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = JsonObject().apply {
add("type", JsonPrimitive(src.type.formatName))
// Local function for generics
fun <T> addValue(src: Hash<T>) = add("value", JsonPrimitive(src.type.encodeToString(src.value)))
addValue(src)
} }
@Throws(JsonParseException::class) @Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash { override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash<*> {
val obj = json.asJsonObject val obj = json.asJsonObject
val type: String val type: String
val value: String val value: String
@ -25,7 +60,7 @@ abstract class Hash {
throw JsonParseException("Invalid hash JSON data") throw JsonParseException("Invalid hash JSON data")
} }
return try { return try {
HashUtils.getHash(type, value) (HashFormat.fromName(type) ?: throw JsonParseException("Unknown hash type $type")).fromString(value)
} catch (e: Exception) { } catch (e: Exception) {
throw JsonParseException("Failed to create hash object", e) throw JsonParseException("Failed to create hash object", e)
} }

View File

@ -0,0 +1,39 @@
package link.infra.packwiz.installer.metadata.hash
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.Hash.Encoding
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider.Companion.fromOkio
import okio.ByteString
import okio.Source
import okio.HashingSource.Companion as OkHashes
sealed class HashFormat<T>(val formatName: String): Encoding<T>, SourceProvider<T> {
object SHA1: HashFormat<ByteString>("sha1"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha1)
object SHA256: HashFormat<ByteString>("sha256"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha256)
object SHA512: HashFormat<ByteString>("sha512"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha512)
object MD5: HashFormat<ByteString>("md5"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::md5)
object MURMUR2: HashFormat<UInt>("murmur2"),
Encoding<UInt> by Encoding.UInt, SourceProvider<UInt> by SourceProvider(::Murmur2HasherSource)
fun source(delegate: Source): HasherSource<T> = source(this, delegate)
fun fromString(str: String) = Hash(this, decodeFromString(str))
override fun toString() = formatName
companion object {
// lazy used to prevent initialisation issues!
private val values by lazy { listOf(SHA1, SHA256, SHA512, MD5, MURMUR2) }
fun fromName(formatName: String) = values.find { formatName == it.formatName }
fun mapper() = tomlMapper {
// TODO: better exception?
decoder { it: TomlValue.String -> fromName(it.value) ?: throw Exception("Hash format ${it.value} not supported") }
encoder { it: HashFormat<*> -> TomlValue.String(it.formatName) }
}
}
}

View File

@ -1,22 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
object HashUtils {
private val hashTypeConversion: Map<String, IHasher> = mapOf(
"sha256" to HashingSourceHasher("sha256"),
"sha512" to HashingSourceHasher("sha512"),
"murmur2" to Murmur2Hasher(),
"sha1" to HashingSourceHasher("sha1")
)
@JvmStatic
@Throws(Exception::class)
fun getHasher(type: String): IHasher {
return hashTypeConversion[type] ?: throw Exception("Hash type not supported: $type")
}
@JvmStatic
@Throws(Exception::class)
fun getHash(type: String, value: String): Hash {
return hashTypeConversion[type]?.getHash(value) ?: throw Exception("Hash type not supported: $type")
}
}

View File

@ -0,0 +1,7 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Source
interface HasherSource<T>: Source {
val hash: Hash<T>
}

View File

@ -1,46 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.HashingSource
import okio.Source
class HashingSourceHasher internal constructor(private val type: String) : IHasher {
// i love naming things
private inner class HashingSourceGeneralHashingSource(val delegateHashing: HashingSource) : GeneralHashingSource(delegateHashing) {
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
HashingSourceHash(delegateHashing.hash.hex())
}
}
// this some funky inner class stuff
// each of these classes is specific to the instance of the HasherHashingSource
// therefore HashingSourceHashes from different parent instances will be not instanceof each other
private inner class HashingSourceHash(val value: String) : Hash() {
override val stringValue get() = value
override fun equals(other: Any?): Boolean {
if (other !is HashingSourceHash) {
return false
}
return stringValue.equals(other.stringValue, ignoreCase = true)
}
override fun toString(): String = "$type: $stringValue"
override fun hashCode(): Int = value.hashCode()
override val type: String get() = this@HashingSourceHasher.type
}
override fun getHashingSource(delegate: Source): GeneralHashingSource {
when (type) {
"md5" -> return HashingSourceGeneralHashingSource(HashingSource.md5(delegate))
"sha256" -> return HashingSourceGeneralHashingSource(HashingSource.sha256(delegate))
"sha512" -> return HashingSourceGeneralHashingSource(HashingSource.sha512(delegate))
"sha1" -> return HashingSourceGeneralHashingSource(HashingSource.sha1(delegate))
}
throw RuntimeException("Invalid hash type provided")
}
override fun getHash(value: String): Hash {
return HashingSourceHash(value)
}
}

View File

@ -1,8 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Source
interface IHasher {
fun getHashingSource(delegate: Source): GeneralHashingSource
fun getHash(value: String): Hash
}

View File

@ -1,91 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Buffer
import okio.Source
import java.io.IOException
class Murmur2Hasher : IHasher {
private inner class Murmur2GeneralHashingSource(delegate: Source) : GeneralHashingSource(delegate) {
val internalBuffer = Buffer()
val tempBuffer = Buffer()
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
val data = internalBuffer.readByteArray()
Murmur2Hash(Murmur2Lib.hash32(data, data.size, 1))
}
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val out = delegate.read(tempBuffer, byteCount)
if (out > -1) {
sink.write(tempBuffer.clone(), out)
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
}
return out
}
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
// private fun computeNormalizedArray(input: ByteArray): ByteArray {
// val output = ByteArray(input.size)
// var index = 0
// for (b in input) {
// when (b) {
// 9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
// else -> {
// output[index] = b
// index++
// }
// }
// }
// val outputTrimmed = ByteArray(index)
// System.arraycopy(output, 0, outputTrimmed, 0, index)
// return outputTrimmed
// }
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
var index = 0
val arr = input.readByteArray()
for (b in arr) {
when (b) {
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
else -> {
arr[index] = b
index++
}
}
}
output.write(arr, 0, index)
}
}
private class Murmur2Hash : Hash {
val value: Int
constructor(value: String) {
// Parsing as long then casting to int converts values gt int max value but lt uint max value
// into negatives. I presume this is how the murmur2 code handles this.
this.value = value.toLong().toInt()
}
constructor(value: Int) {
this.value = value
}
override val stringValue get() = value.toString()
override val type get() = "murmur2"
override fun equals(other: Any?): Boolean {
if (other !is Murmur2Hash) {
return false
}
return value == other.value
}
override fun toString(): String = "murmur2: $value"
override fun hashCode(): Int = value
}
override fun getHashingSource(delegate: Source): GeneralHashingSource = Murmur2GeneralHashingSource(delegate)
override fun getHash(value: String): Hash = Murmur2Hash(value)
}

View File

@ -0,0 +1,43 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Buffer
import okio.ForwardingSource
import okio.Source
import java.io.IOException
class Murmur2HasherSource(type: HashFormat<UInt>, delegate: Source) : ForwardingSource(delegate), HasherSource<UInt> {
private val internalBuffer = Buffer()
private val tempBuffer = Buffer()
override val hash: Hash<UInt> by lazy(LazyThreadSafetyMode.NONE) {
// TODO: remove internal buffering?
val data = internalBuffer.readByteArray()
Hash(type, Murmur2Lib.hash32(data, data.size, 1).toUInt())
}
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val out = delegate.read(tempBuffer, byteCount)
if (out > -1) {
sink.write(tempBuffer.clone(), out)
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
}
return out
}
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
var index = 0
val arr = input.readByteArray()
for (b in arr) {
when (b) {
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
else -> {
arr[index] = b
index++
}
}
}
output.write(arr, 0, index)
}
}

View File

@ -1,52 +0,0 @@
package link.infra.packwiz.installer.request
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.request.handlers.RequestHandlerFile
import link.infra.packwiz.installer.request.handlers.RequestHandlerGithub
import link.infra.packwiz.installer.request.handlers.RequestHandlerHTTP
import okio.Source
object HandlerManager {
private val handlers: List<IRequestHandler> = listOf(
RequestHandlerGithub(),
RequestHandlerHTTP(),
RequestHandlerFile()
)
// TODO: get rid of nullable stuff here
@JvmStatic
fun getNewLoc(base: SpaceSafeURI?, loc: SpaceSafeURI?): SpaceSafeURI? {
if (loc == null) {
return null
}
val dest = base?.run { resolve(loc) } ?: loc
for (handler in handlers) with (handler) {
if (matchesHandler(dest)) {
return getNewLoc(dest)
}
}
return dest
}
// TODO: What if files are read multiple times??
// Zip handler discards once read, requesting multiple times on other handlers would cause multiple downloads
// Caching system? Copy from already downloaded files?
// TODO: change to use something more idiomatic than exceptions?
@JvmStatic
@Throws(Exception::class)
fun getFileSource(loc: SpaceSafeURI): Source {
for (handler in handlers) {
if (handler.matchesHandler(loc)) {
return handler.getFileSource(loc) ?: throw Exception("Couldn't find URI: $loc")
}
}
throw Exception("No handler available for URI: $loc")
}
// TODO: github toml resolution?
// e.g. https://github.com/comp500/Demagnetize -> demagnetize.toml
// https://github.com/comp500/Demagnetize/blob/master/demagnetize.toml
}

View File

@ -1,24 +0,0 @@
package link.infra.packwiz.installer.request
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import okio.Source
/**
* IRequestHandler handles requests for locations specified in modpack metadata.
*/
interface IRequestHandler {
fun matchesHandler(loc: SpaceSafeURI): Boolean
fun getNewLoc(loc: SpaceSafeURI): SpaceSafeURI {
return loc
}
/**
* Gets the Source for a location. Must be threadsafe.
* It is assumed that each location is read only once for the duration of an IRequestHandler.
* @param loc The location to be read
* @return The Source containing the data of the file
* @throws Exception Exception if it failed to download a file!!!
*/
fun getFileSource(loc: SpaceSafeURI): Source?
}

View File

@ -1,6 +1,5 @@
package link.infra.packwiz.installer.request package link.infra.packwiz.installer.request
import okhttp3.Response
import okio.IOException import okio.IOException
sealed class RequestException: Exception { sealed class RequestException: Exception {
@ -14,14 +13,12 @@ sealed class RequestException: Exception {
constructor(message: String, cause: Throwable) : super(message, cause) constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message) constructor(message: String) : super(message)
class UnsinkableBase: Internal("Base associated with this path is not a SinkableBase")
sealed class HTTP: Internal { sealed class HTTP: Internal {
constructor(message: String, cause: Throwable) : super(message, cause) constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message) constructor(message: String) : super(message)
class NoResponseBody: HTTP("HTTP response in onResponse must have a response body") class NoResponseBody: HTTP("HTTP response in onResponse must have a response body")
class RequestFailed(cause: IOException): HTTP("HTTP request failed; may have been cancelled", cause) class RequestFailed(cause: IOException): HTTP("HTTP request failed", cause)
class IllegalState(cause: IllegalStateException): HTTP("Internal fatal HTTP request error", cause) class IllegalState(cause: IllegalStateException): HTTP("Internal fatal HTTP request error", cause)
} }
} }
@ -47,16 +44,16 @@ sealed class RequestException: Exception {
// TODO: fancier way of displaying this? // TODO: fancier way of displaying this?
sealed class HTTP: Response { sealed class HTTP: Response {
val response: okhttp3.Response val res: okhttp3.Response
constructor(response: okhttp3.Response, message: String, cause: Throwable) : super(message, cause) { constructor(req: okhttp3.Request, res: okhttp3.Response, message: String, cause: Throwable) : super("Failed to make HTTP request to ${req.url}: $message", cause) {
this.response = response this.res = res
} }
constructor(response: okhttp3.Response, message: String) : super(message) { constructor(req: okhttp3.Request, res: okhttp3.Response, message: String) : super("Failed to make HTTP request to ${req.url}: $message") {
this.response = response this.res = res
} }
class ErrorCode(res: okhttp3.Response): HTTP(res, "Non-successful error code from HTTP request: ${res.code}") class ErrorCode(req: okhttp3.Request, res: okhttp3.Response): HTTP(req, res, "Non-successful error code from HTTP request: ${res.code}")
} }
sealed class File: RequestException { sealed class File: RequestException {

View File

@ -1,18 +0,0 @@
package link.infra.packwiz.installer.request.handlers
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.request.IRequestHandler
import okio.Source
import okio.source
import java.nio.file.Paths
open class RequestHandlerFile : IRequestHandler {
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
return "file" == loc.scheme
}
override fun getFileSource(loc: SpaceSafeURI): Source? {
val path = Paths.get(loc.toURL().toURI())
return path.source()
}
}

View File

@ -1,70 +0,0 @@
package link.infra.packwiz.installer.request.handlers
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import java.util.concurrent.locks.ReentrantReadWriteLock
import java.util.regex.Pattern
import kotlin.concurrent.read
import kotlin.concurrent.write
class RequestHandlerGithub : RequestHandlerZip(true) {
override fun getNewLoc(loc: SpaceSafeURI): SpaceSafeURI {
return loc
}
companion object {
private val repoMatcherPattern = Pattern.compile("/([\\w.-]+/[\\w.-]+).*")
private val branchMatcherPattern = Pattern.compile("/[\\w.-]+/[\\w.-]+/blob/([\\w.-]+).*")
}
// TODO: is caching really needed, if HTTPURLConnection follows redirects correctly?
private val zipUriMap: MutableMap<String, SpaceSafeURI> = HashMap()
private val zipUriLock = ReentrantReadWriteLock()
private fun getRepoName(loc: SpaceSafeURI): String? {
val matcher = repoMatcherPattern.matcher(loc.path ?: return null)
return if (matcher.matches()) {
matcher.group(1)
} else {
null
}
}
override fun getZipUri(loc: SpaceSafeURI): SpaceSafeURI {
val repoName = getRepoName(loc)
val branchName = getBranch(loc)
zipUriLock.read {
zipUriMap["$repoName/$branchName"]
}?.let { return it }
var zipUri = SpaceSafeURI("https://api.github.com/repos/$repoName/zipball/$branchName")
zipUriLock.write {
// If another thread sets the value concurrently, use the existing value from the
// thread that first acquired the lock.
zipUri = zipUriMap.putIfAbsent("$repoName/$branchName", zipUri) ?: zipUri
}
return zipUri
}
private fun getBranch(loc: SpaceSafeURI): String? {
val matcher = branchMatcherPattern.matcher(loc.path ?: return null)
return if (matcher.matches()) {
matcher.group(1)
} else {
null
}
}
override fun getLocationInZip(loc: SpaceSafeURI): SpaceSafeURI {
val path = "/" + getRepoName(loc) + "/blob/" + getBranch(loc)
return SpaceSafeURI(loc.scheme, loc.authority, path, null, null).relativize(loc)
}
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
val scheme = loc.scheme
if (!("http" == scheme || "https" == scheme)) {
return false
}
// TODO: more match testing?
return "github.com" == loc.host && branchMatcherPattern.matcher(loc.path ?: return false).matches()
}
}

View File

@ -1,29 +0,0 @@
package link.infra.packwiz.installer.request.handlers
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.request.IRequestHandler
import okio.Source
import okio.source
import java.net.HttpURLConnection
open class RequestHandlerHTTP : IRequestHandler {
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
val scheme = loc.scheme
return "http" == scheme || "https" == scheme
}
override fun getFileSource(loc: SpaceSafeURI): Source? {
val conn = loc.toURL().openConnection() as HttpURLConnection
// TODO: when do we send specific headers??? should there be a way to signal this?
conn.addRequestProperty("Accept", "application/octet-stream")
// TODO: include version?
conn.addRequestProperty("User-Agent", "packwiz-installer")
conn.apply {
// 30 second read timeout
readTimeout = 30 * 1000
requestMethod = "GET"
}
return conn.inputStream.source()
}
}

View File

@ -1,123 +0,0 @@
package link.infra.packwiz.installer.request.handlers
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import okio.Buffer
import okio.Source
import okio.buffer
import okio.source
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import java.util.concurrent.locks.ReentrantReadWriteLock
import java.util.function.Predicate
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import kotlin.concurrent.read
import kotlin.concurrent.withLock
import kotlin.concurrent.write
abstract class RequestHandlerZip(private val modeHasFolder: Boolean) : RequestHandlerHTTP() {
private fun removeFolder(name: String): String {
return if (modeHasFolder) {
// TODO: replace with proper path checks once switched to Path??
name.substring(name.indexOf("/") + 1)
} else {
name
}
}
private inner class ZipReader(zip: Source) {
private val zis = ZipInputStream(zip.buffer().inputStream())
private val readFiles: MutableMap<SpaceSafeURI, Buffer> = HashMap()
// Write lock implies access to ZipInputStream - only 1 thread must read at a time!
val filesLock = ReentrantLock()
private var entry: ZipEntry? = null
private val zipSource = zis.source().buffer()
// File lock must be obtained before calling this function
private fun readCurrFile(): Buffer {
val fileBuffer = Buffer()
zipSource.readFully(fileBuffer, entry!!.size)
return fileBuffer
}
// File lock must be obtained before calling this function
private fun findFile(loc: SpaceSafeURI): Buffer? {
while (true) {
entry = zis.nextEntry
entry?.also {
val data = readCurrFile()
val fileLoc = SpaceSafeURI(removeFolder(it.name))
if (loc == fileLoc) {
return data
} else {
readFiles[fileLoc] = data
}
} ?: return null
}
}
fun getFileSource(loc: SpaceSafeURI): Source? {
filesLock.withLock {
// Assume files are only read once, allow GC by removing
readFiles.remove(loc)?.also { return it }
return findFile(loc)
}
}
fun findInZip(matches: Predicate<SpaceSafeURI>): SpaceSafeURI? {
filesLock.withLock {
readFiles.keys.find { matches.test(it) }?.let { return it }
do {
val entry = zis.nextEntry?.also {
val data = readCurrFile()
val fileLoc = SpaceSafeURI(removeFolder(it.name))
readFiles[fileLoc] = data
if (matches.test(fileLoc)) {
return fileLoc
}
}
} while (entry != null)
return null
}
}
}
private val cache: MutableMap<SpaceSafeURI, ZipReader> = HashMap()
private val cacheLock = ReentrantReadWriteLock()
protected abstract fun getZipUri(loc: SpaceSafeURI): SpaceSafeURI
protected abstract fun getLocationInZip(loc: SpaceSafeURI): SpaceSafeURI
abstract override fun matchesHandler(loc: SpaceSafeURI): Boolean
override fun getFileSource(loc: SpaceSafeURI): Source? {
val zipUri = getZipUri(loc)
var zr = cacheLock.read { cache[zipUri] }
if (zr == null) {
cacheLock.write {
// Recheck, because unlocking read lock allows another thread to modify it
zr = cache[zipUri]
if (zr == null) {
val src = super.getFileSource(zipUri) ?: return null
zr = ZipReader(src).also { cache[zipUri] = it }
}
}
}
return zr?.getFileSource(getLocationInZip(loc))
}
protected fun findInZip(loc: SpaceSafeURI, matches: Predicate<SpaceSafeURI>): SpaceSafeURI? {
val zipUri = getZipUri(loc)
return (cacheLock.read { cache[zipUri] } ?: cacheLock.write {
// Recheck, because unlocking read lock allows another thread to modify it
cache[zipUri] ?: run {
// Create the ZipReader if it doesn't exist, return null if getFileSource returns null
super.getFileSource(zipUri)?.let { ZipReader(it) }
?.also { cache[zipUri] = it }
}
})?.findInZip(matches)
}
}

View File

@ -15,7 +15,7 @@ data class CachedTarget(
*/ */
val cachedLocation: Path, val cachedLocation: Path,
val enabled: Boolean, val enabled: Boolean,
val hash: Hash, val hash: Hash<*>,
/** /**
* For detecting when a target transitions non-optional -> optional and showing the option selection screen * For detecting when a target transitions non-optional -> optional and showing the option selection screen
*/ */

View File

@ -1,12 +1,54 @@
package link.infra.packwiz.installer.target package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.util.Log
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response
import okio.FileSystem import okio.FileSystem
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
class ClientHolder { class ClientHolder {
// TODO: timeouts? // TODO: a button to increase timeouts temporarily when retrying? manual retry button?
// TODO: a button to increase timeouts temporarily when retrying? val okHttpClient by lazy { OkHttpClient.Builder()
val okHttpClient by lazy { OkHttpClient.Builder().build() } // Retry requests up to 3 times, increasing the timeouts slightly if it failed
.addInterceptor {
val req = it.request()
var lastException: SocketTimeoutException? = null
var res: Response? = null
try {
res = it.proceed(req)
} catch (e: SocketTimeoutException) {
lastException = e
}
var tryCount = 0
while (res == null && tryCount < 3) {
tryCount++
Log.info("OkHttp connection to ${req.url} timed out; retrying... ($tryCount/3)")
val longerTimeoutChain = it
.withConnectTimeout(10 * tryCount, TimeUnit.SECONDS)
.withReadTimeout(10 * tryCount, TimeUnit.SECONDS)
.withWriteTimeout(10 * tryCount, TimeUnit.SECONDS)
try {
res = longerTimeoutChain.proceed(req)
} catch (e: SocketTimeoutException) {
lastException = e
}
}
res ?: throw lastException!!
}
.build() }
val fileSystem = FileSystem.SYSTEM val fileSystem = FileSystem.SYSTEM
fun close() {
okHttpClient.dispatcher.cancelAll()
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
}
} }

View File

@ -1,5 +1,7 @@
package link.infra.packwiz.installer.target package link.infra.packwiz.installer.target
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
enum class Side { enum class Side {
@ -50,5 +52,10 @@ enum class Side {
} }
return null return null
} }
fun mapper() = tomlMapper {
encoder { it: Side -> TomlValue.String(it.sideName) }
decoder { it: TomlValue.String -> from(it.value) ?: throw Exception("Invalid side name ${it.value}") }
}
} }
} }

View File

@ -4,8 +4,8 @@ import link.infra.packwiz.installer.target.path.PackwizPath
// TODO: rename to avoid conflicting with @Target // TODO: rename to avoid conflicting with @Target
interface Target { interface Target {
val src: PackwizPath val src: PackwizPath<*>
val dest: PackwizPath val dest: PackwizPath<*>
val validityToken: ValidityToken val validityToken: ValidityToken
/** /**
@ -19,16 +19,16 @@ interface Target {
* be preserved across renames. * be preserved across renames.
*/ */
@JvmInline @JvmInline
value class PathIdentityToken(val path: String): IdentityToken value class PathIdentityToken(val path: PackwizPath<*>): IdentityToken
val ident: IdentityToken val ident: IdentityToken
get() = PathIdentityToken(dest.path) get() = PathIdentityToken(dest) // TODO: should use local-rebased path?
/** /**
* A user-friendly name; defaults to the destination path of the file. * A user-friendly name; defaults to the destination path of the file.
*/ */
val name: String val name: String
get() = dest.path get() = dest.filename
val side: Side val side: Side
get() = Side.BOTH get() = Side.BOTH
val overwriteMode: OverwriteMode val overwriteMode: OverwriteMode

View File

@ -12,5 +12,5 @@ interface ValidityToken {
* Default implementation of ValidityToken based on a single hash. * Default implementation of ValidityToken based on a single hash.
*/ */
@JvmInline @JvmInline
value class HashValidityToken(val hash: Hash): ValidityToken value class HashValidityToken(val hash: Hash<*>): ValidityToken
} }

View File

@ -1,29 +0,0 @@
package link.infra.packwiz.installer.target.path
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import okio.*
data class FilePathBase(private val path: Path): PackwizPath.Base, PackwizPath.SinkableBase {
override fun source(path: String, clientHolder: ClientHolder): BufferedSource {
val resolved = this.path.resolve(path, true)
try {
return clientHolder.fileSystem.source(resolved).buffer()
} catch (e: FileNotFoundException) {
throw RequestException.Response.File.FileNotFound(resolved.toString())
} catch (e: IOException) {
throw RequestException.Response.File.Other(e)
}
}
override fun sink(path: String, clientHolder: ClientHolder): BufferedSink {
val resolved = this.path.resolve(path, true)
try {
return clientHolder.fileSystem.sink(resolved).buffer()
} catch (e: FileNotFoundException) {
throw RequestException.Response.File.FileNotFound(resolved.toString())
} catch (e: IOException) {
throw RequestException.Response.File.Other(e)
}
}
}

View File

@ -1,41 +0,0 @@
package link.infra.packwiz.installer.target.path
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import okhttp3.HttpUrl
import okhttp3.Request
import okio.BufferedSource
import okio.IOException
data class HttpUrlBase(private val url: HttpUrl): PackwizPath.Base {
private fun resolve(path: String) = url.newBuilder().addPathSegments(path).build()
override fun source(path: String, clientHolder: ClientHolder): BufferedSource {
val req = Request.Builder()
.url(resolve(path))
.header("Accept", "application/octet-stream")
.header("User-Agent", "packwiz-installer")
.get()
.build()
try {
val res = clientHolder.okHttpClient.newCall(req).execute()
// Can't use .use since it would close the response body before returning it to the caller
try {
if (!res.isSuccessful) {
throw RequestException.Response.HTTP.ErrorCode(res)
}
val body = res.body ?: throw RequestException.Internal.HTTP.NoResponseBody()
return body.source()
} catch (e: Exception) {
// If an exception is thrown, close the response and rethrow
res.close()
throw e
}
} catch (e: IOException) {
throw RequestException.Internal.HTTP.RequestFailed(e)
} catch (e: IllegalStateException) {
throw RequestException.Internal.HTTP.IllegalState(e)
}
}
}

View File

@ -0,0 +1,69 @@
package link.infra.packwiz.installer.target.path
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import okhttp3.HttpUrl
import okhttp3.Request
import okio.BufferedSource
import okio.IOException
class HttpUrlPath(private val url: HttpUrl, path: String? = null): PackwizPath<HttpUrlPath>(path) {
private fun build() = if (path == null) { url } else { url.newBuilder().addPathSegments(path).build() }
@Throws(RequestException::class)
override fun source(clientHolder: ClientHolder): BufferedSource {
val req = Request.Builder()
.url(build())
.header("Accept", "application/octet-stream")
.header("User-Agent", "packwiz-installer")
.get()
.build()
try {
val res = clientHolder.okHttpClient.newCall(req).execute()
// Can't use .use since it would close the response body before returning it to the caller
try {
if (!res.isSuccessful) {
throw RequestException.Response.HTTP.ErrorCode(req, res)
}
val body = res.body ?: throw RequestException.Internal.HTTP.NoResponseBody()
return body.source()
} catch (e: Exception) {
// If an exception is thrown, close the response and rethrow
res.close()
throw e
}
} catch (e: IOException) {
throw RequestException.Internal.HTTP.RequestFailed(e)
} catch (e: IllegalStateException) {
throw RequestException.Internal.HTTP.IllegalState(e)
}
}
override fun construct(path: String): HttpUrlPath = HttpUrlPath(url, path)
override val folder: Boolean
get() = pathFolder ?: (url.pathSegments.last() == "")
override val filename: String
get() = pathFilename ?: url.pathSegments.last()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as HttpUrlPath
if (url != other.url) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + url.hashCode()
return result
}
override fun toString() = build().toString()
}

View File

@ -0,0 +1,51 @@
package link.infra.packwiz.installer.target.path
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import okio.*
class PackwizFilePath(private val base: Path, path: String? = null): PackwizPath<PackwizFilePath>(path) {
@Throws(RequestException::class)
override fun source(clientHolder: ClientHolder): BufferedSource {
val resolved = if (path == null) { base } else { this.base.resolve(path, true) }
try {
return clientHolder.fileSystem.source(resolved).buffer()
} catch (e: FileNotFoundException) {
throw RequestException.Response.File.FileNotFound(resolved.toString())
} catch (e: IOException) {
throw RequestException.Response.File.Other(e)
}
}
val nioPath: java.nio.file.Path get() {
val resolved = if (path == null) { base } else { this.base.resolve(path, true) }
return resolved.toNioPath()
}
override fun construct(path: String): PackwizFilePath = PackwizFilePath(base, path)
override val folder: Boolean
get() = pathFolder ?: (base.segments.last() == "")
override val filename: String
get() = pathFilename ?: base.segments.last()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as PackwizFilePath
if (base != other.base) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + base.hashCode()
return result
}
override fun toString() = nioPath.toString()
}

View File

@ -1,17 +1,19 @@
package link.infra.packwiz.installer.target.path package link.infra.packwiz.installer.target.path
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import link.infra.packwiz.installer.request.RequestException import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder import link.infra.packwiz.installer.target.ClientHolder
import okio.BufferedSink
import okio.BufferedSource import okio.BufferedSource
class PackwizPath(path: String, base: Base) { abstract class PackwizPath<T: PackwizPath<T>>(path: String? = null) {
val path: String protected val path: String?
val base: Base
init { init {
this.base = base if (path != null) {
// Check for NUL bytes // Check for NUL bytes
if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) } if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) }
// Normalise separator, to prevent differences between Unix/Windows // Normalise separator, to prevent differences between Unix/Windows
@ -49,76 +51,76 @@ class PackwizPath(path: String, base: Base) {
} }
} }
if (canonicalised.isEmpty()) {
this.path = null
} else {
// Join path // Join path
this.path = canonicalised.asReversed().joinToString("/") this.path = canonicalised.asReversed().joinToString("/")
} }
} else {
this.path = null
}
}
val folder: Boolean get() = path.endsWith("/") protected abstract fun construct(path: String): T
fun resolve(path: String): PackwizPath { protected val pathFolder: Boolean? get() = path?.endsWith("/")
abstract val folder: Boolean
protected val pathFilename: String? get() = path?.split("/")?.last()
abstract val filename: String
fun resolve(path: String): T {
return if (path.startsWith('/') || path.startsWith('\\')) { return if (path.startsWith('/') || path.startsWith('\\')) {
// Absolute (but still relative to base of pack) // Absolute (but still relative to base of pack)
PackwizPath(path, base) construct(path)
} else if (folder) { } else if (folder) {
// File in folder; append // File in folder; append
PackwizPath(this.path + path, base) construct((this.path ?: "") + path)
} else { } else {
// File in parent folder; append with parent component // File in parent folder; append with parent component
PackwizPath(this.path + "/../" + path, base) construct((this.path ?: "") + "/../" + path)
} }
} }
operator fun div(path: String) = resolve(path)
fun <U: PackwizPath<U>> rebase(path: U) = path.resolve(this.path ?: "")
val parent: T get() = resolve(if (folder) { ".." } else { "." })
/** /**
* Obtain a BufferedSource for this path * Obtain a BufferedSource for this path
* @throws RequestException When resolving the file failed * @throws RequestException When resolving the file failed
*/ */
fun source(clientHolder: ClientHolder): BufferedSource = base.source(path, clientHolder) @Throws(RequestException::class)
abstract fun source(clientHolder: ClientHolder): BufferedSource
/**
* Obtain a BufferedSink for this path
* @throws RequestException.Internal.UnsinkableBase When the base of this path does not have a sink
* @throws RequestException When resolving the file failed
*/
fun sink(clientHolder: ClientHolder): BufferedSink =
if (base is SinkableBase) { base.sink(path, clientHolder) } else { throw RequestException.Internal.UnsinkableBase() }
interface Base {
/**
* Resolve the given (canonical) path against the base, and get a BufferedSource for this file.
* @throws RequestException
*/
fun source(path: String, clientHolder: ClientHolder): BufferedSource
operator fun div(path: String) = PackwizPath(path, this)
}
interface SinkableBase: Base {
/**
* Resolve the given (canonical) path against the base, and get a BufferedSink for this file.
* @throws RequestException
*/
fun sink(path: String, clientHolder: ClientHolder): BufferedSink
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
other as PackwizPath other as PackwizPath<*>
if (path != other.path) return false if (path != other.path) return false
if (base != other.base) return false
return true return true
} }
override fun hashCode(): Int { override fun hashCode() = path.hashCode()
var result = path.hashCode()
result = 31 * result + base.hashCode() companion object {
return result fun mapperRelativeTo(base: PackwizPath<*>) = tomlMapper {
encoder { it: PackwizPath<*> -> TomlValue.String(it.path ?: "") }
decoder { it: TomlValue.String -> base.resolve(it.value) }
} }
override fun toString(): String { fun <T: PackwizPath<T>> adapterRelativeTo(base: T) = object : TypeAdapter<T>() {
return "base=$base; $path" override fun write(writer: JsonWriter, value: T?) {
writer.value(value?.path)
}
override fun read(reader: JsonReader) = base.resolve(reader.nextString())
} }
} }
override fun toString() = "(Unknown base) $path"
}

View File

@ -3,4 +3,4 @@ package link.infra.packwiz.installer.task.formats.packwizv1
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.target.path.PackwizPath import link.infra.packwiz.installer.target.path.PackwizPath
data class PackwizV1PackFile(val name: String, val indexPath: PackwizPath, val indexHash: Hash) data class PackwizV1PackFile(val name: String, val indexPath: PackwizPath<*>, val indexHash: Hash<*>)

View File

@ -1,19 +1,17 @@
package link.infra.packwiz.installer.task.formats.packwizv1 package link.infra.packwiz.installer.task.formats.packwizv1
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.moandjiezana.toml.Toml
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.path.PackwizPath import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.task.CacheKey import link.infra.packwiz.installer.task.CacheKey
import link.infra.packwiz.installer.task.Task import link.infra.packwiz.installer.task.Task
import link.infra.packwiz.installer.task.TaskCombinedResult import link.infra.packwiz.installer.task.TaskCombinedResult
import link.infra.packwiz.installer.task.TaskContext import link.infra.packwiz.installer.task.TaskContext
import java.io.InputStreamReader
class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath): Task<PackwizV1PackFile>(ctx) { class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath<*>): Task<PackwizV1PackFile>(ctx) {
// TODO: make hierarchically defined by caller? - then changing the pack format type doesn't leave junk in the cache // TODO: make hierarchically defined by caller? - then changing the pack format type doesn't leave junk in the cache
private var cache by ctx.cache[CacheKey<Hash>("packwiz.v1.packtoml.hash", 1)] private var cache by ctx.cache[CacheKey<Hash<*>>("packwiz.v1.packtoml.hash", 1)]
private class PackFile { private class PackFile {
var name: String? = null var name: String? = null
@ -22,7 +20,7 @@ class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath): Task<Packw
class IndexFileLoc { class IndexFileLoc {
var file: String? = null var file: String? = null
@SerializedName("hash-format") @SerializedName("hash-format")
var hashFormat: String? = null var hashFormat: HashFormat<*>? = null
var hash: String? = null var hash: String? = null
} }
@ -31,14 +29,16 @@ class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath): Task<Packw
private val internalResult by lazy { private val internalResult by lazy {
// TODO: query, parse JSON // TODO: query, parse JSON
val packFile = Toml().read(InputStreamReader(path.source(ctx.clients).inputStream(), "UTF-8")).to(PackFile::class.java) val packFile = PackFile()
//Toml().read(InputStreamReader(path.source(ctx.clients).inputStream(), "UTF-8")).to(PackFile::class.java)
val resolved = PackwizV1PackFile(packFile.name ?: throw RuntimeException("Name required"), // TODO: better exception handling val hashFormat = (packFile.index?.hashFormat ?: throw RuntimeException("Hash format required"))
val resolved = PackwizV1PackFile(
packFile.name ?: throw RuntimeException("Name required"), // TODO: better exception handling
path.resolve(packFile.index?.file ?: throw RuntimeException("File required")), path.resolve(packFile.index?.file ?: throw RuntimeException("File required")),
HashUtils.getHash(packFile.index?.hashFormat ?: throw RuntimeException("Hash format required"), hashFormat.fromString(packFile.index?.hash ?: throw RuntimeException("Hash required"))
packFile.index?.hash ?: throw RuntimeException("Hash required"))
) )
val hash = HashUtils.getHash("sha256", "whatever was just read") val hash = hashFormat.fromString("whatever was just read")
TaskCombinedResult(resolved, wasUpdated(::cache, hash)) TaskCombinedResult(resolved, wasUpdated(::cache, hash))
} }

View File

@ -41,6 +41,16 @@ interface IUserInterface {
var optionsButtonPressed: Boolean var optionsButtonPressed: Boolean
var cancelButtonPressed: Boolean var cancelButtonPressed: Boolean
var cancelCallback: (() -> Unit)?
var firstInstall: Boolean var firstInstall: Boolean
}
inline fun <T> IUserInterface.wrap(message: String, inner: () -> T): T {
return try {
inner.invoke()
} catch (e: Exception) {
showErrorAndExit(message, e)
}
} }

View File

@ -11,9 +11,12 @@ import kotlin.system.exitProcess
class CLIHandler : IUserInterface { class CLIHandler : IUserInterface {
@Volatile @Volatile
override var optionsButtonPressed = false override var optionsButtonPressed = false
// TODO: treat ctrl+c as cancel?
@Volatile @Volatile
override var cancelButtonPressed = false override var cancelButtonPressed = false
@Volatile @Volatile
override var cancelCallback: (() -> Unit)? = null
@Volatile
override var firstInstall = false override var firstInstall = false
override var title: String = "" override var title: String = ""

View File

@ -28,7 +28,10 @@ class GUIHandler : IUserInterface {
set(value) { set(value) {
optionalSelectedLatch.countDown() optionalSelectedLatch.countDown()
field = value field = value
cancelCallback?.invoke()
} }
@Volatile
override var cancelCallback: (() -> Unit)? = null
var okButtonPressed = false var okButtonPressed = false
set(value) { set(value) {
optionalSelectedLatch.countDown() optionalSelectedLatch.countDown()

View File

@ -0,0 +1,10 @@
package link.infra.packwiz.installer.util
import cc.ekblad.toml.TomlMapper
import cc.ekblad.toml.configuration.TomlMapperConfigurator
import cc.ekblad.toml.model.TomlValue
inline fun <reified T: Any> TomlMapperConfigurator.delegateTransitive(mapper: TomlMapper) {
decoder { it: TomlValue -> mapper.decode<T>(it) }
encoder { it: T -> mapper.encode(it) }
}

View File

@ -1,38 +0,0 @@
package link.infra.packwiz.installer.util
import link.infra.packwiz.installer.ui.IUserInterface
inline fun <T> iflet(value: T?, whenNotNull: (T) -> Unit) {
if (value != null) {
whenNotNull(value)
}
}
inline fun <T, U> IUserInterface.ifletOrErr(value: T?, message: String, whenNotNull: (T) -> U): U =
if (value != null) {
whenNotNull(value)
} else {
this.showErrorAndExit(message)
}
inline fun <T, U, V> IUserInterface.ifletOrErr(value: T?, value2: U?, message: String, whenNotNull: (T, U) -> V): V =
if (value != null && value2 != null) {
whenNotNull(value, value2)
} else {
this.showErrorAndExit(message)
}
inline fun <T> ifletOrWarn(value: T?, message: String, whenNotNull: (T) -> Unit) {
if (value != null) {
whenNotNull(value)
} else {
Log.warn(message)
}
}
inline fun <T, U> iflet(value: T?, whenNotNull: (T) -> U, whenNull: () -> U): U =
if (value != null) {
whenNotNull(value)
} else {
whenNull()
}

View File

@ -13,5 +13,7 @@
-keep class com.google.gson.reflect.TypeToken { *; } -keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken -keep class * extends com.google.gson.reflect.TypeToken
-keep @interface kotlin.Metadata { *; }
-renamesourcefileattribute SourceFile -renamesourcefileattribute SourceFile
-keepattributes *Annotation*,SourceFile,LineNumberTable,Signature -keepattributes *Annotation*,SourceFile,LineNumberTable,Signature

View File

@ -4,19 +4,17 @@ packwiz-installer itself is under the MIT license ([Source](https://github.com/p
- Murmur2Lib: Apache 2.0 ([Source](https://github.com/prasanthj/hasher/blob/master/src/main/java/hasher/Murmur2.java)) - Murmur2Lib: Apache 2.0 ([Source](https://github.com/prasanthj/hasher/blob/master/src/main/java/hasher/Murmur2.java))
- Copyright 2014 Prasanth Jayachandran - Copyright 2014 Prasanth Jayachandran
- Google Gson 2.8.9: Apache 2.0 ([Source](https://github.com/google/gson)) - Google Gson 2.9.0: Apache 2.0 ([Source](https://github.com/google/gson))
- Copyright 2008 Google Inc. - Copyright 2008 Google Inc.
- Okio 3.0.0: Apache 2.0 ([Source](https://github.com/square/okio/)) - Okio 3.1.0: Apache 2.0 ([Source](https://github.com/square/okio/))
- Copyright 2013 Square, Inc. - Copyright 2013 Square, Inc.
- Commons CLI 1.5: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/)) - Commons CLI 1.5: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/))
- Jetbrains Annotations 13.0: Apache 2.0 ([Source](https://github.com/JetBrains/java-annotations)) - Jetbrains Annotations 13.0: Apache 2.0 ([Source](https://github.com/JetBrains/java-annotations))
- Copyright 2000-2016 JetBrains s.r.o. - Copyright 2000-2016 JetBrains s.r.o.
- Kotlin Standard Library 1.6.10: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin)) - Kotlin Standard Library 1.7.10: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin))
- Copyright 2010-2020 JetBrains s.r.o and respective authors and developers - Copyright 2010-2020 JetBrains s.r.o and respective authors and developers
- toml4j 0.7.2: MIT ([Source](https://github.com/mwanji/toml4j)) - 4koma 1.1.0: MIT ([Source](https://github.com/valderman/4koma))
- Copyright (c) 2013-2015 Moandji Ezana - Copyright (c) 2021 Anton Ekblad
- kotlin-result 1.1.14: ISC ([Source](https://github.com/michaelbull/kotlin-result))
- Copyright (c) 2017-2022 Michael Bull (https://www.michael-bull.com)
## Associated notices ## Associated notices