mirror of
https://github.com/packwiz/packwiz-installer.git
synced 2025-04-18 20:46:30 +02:00
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:
parent
02b01b90d7
commit
66bc4c3e29
@ -3,8 +3,8 @@ plugins {
|
||||
application
|
||||
id("com.github.johnrengelman.shadow") version "7.1.2"
|
||||
id("com.palantir.git-version") version "0.13.0"
|
||||
id("com.github.breadmoirai.github-release") version "2.2.12"
|
||||
kotlin("jvm") version "1.6.10"
|
||||
id("com.github.breadmoirai.github-release") version "2.4.1"
|
||||
kotlin("jvm") version "1.7.10"
|
||||
id("com.github.jk1.dependency-license-report") version "2.0"
|
||||
`maven-publish`
|
||||
}
|
||||
@ -16,18 +16,20 @@ java {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
maven {
|
||||
url = uri("https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
val r8 by configurations.creating
|
||||
|
||||
dependencies {
|
||||
implementation("commons-cli:commons-cli:1.5.0")
|
||||
implementation("com.moandjiezana.toml:toml4j:0.7.2")
|
||||
implementation("com.google.code.gson:gson:2.9.0")
|
||||
implementation("com.squareup.okio:okio:3.0.0")
|
||||
implementation("com.squareup.okio:okio:3.1.0")
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
implementation("com.squareup.okhttp3:okhttp:4.9.3")
|
||||
implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.14")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.10.0")
|
||||
implementation("cc.ekblad:4koma:1.1.0")
|
||||
|
||||
r8("com.android.tools:r8:3.3.28")
|
||||
}
|
||||
@ -54,8 +56,9 @@ licenseReport {
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
exclude("**/*.kotlin_metadata")
|
||||
exclude("**/*.kotlin_builtins")
|
||||
// 4koma uses kotlin-reflect; requires Kotlin metadata
|
||||
//exclude("**/*.kotlin_metadata")
|
||||
//exclude("**/*.kotlin_builtins")
|
||||
exclude("META-INF/maven/**/*")
|
||||
exclude("META-INF/proguard/**/*")
|
||||
|
||||
|
@ -2,24 +2,24 @@ package link.infra.packwiz.installer
|
||||
|
||||
import link.infra.packwiz.installer.metadata.IndexFile
|
||||
import link.infra.packwiz.installer.metadata.ManifestFile
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
import link.infra.packwiz.installer.ui.data.ExceptionDetails
|
||||
import link.infra.packwiz.installer.ui.data.IOptionDetails
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import okio.*
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Buffer
|
||||
import okio.HashingSink
|
||||
import okio.blackholeSink
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.*
|
||||
import kotlin.io.use
|
||||
|
||||
internal class DownloadTask private constructor(val metadata: IndexFile.File, defaultFormat: String, private val downloadSide: Side) : IOptionDetails {
|
||||
internal class DownloadTask private constructor(val metadata: IndexFile.File, val index: IndexFile, private val downloadSide: Side) : IOptionDetails {
|
||||
var cachedFile: ManifestFile.File? = null
|
||||
|
||||
private var err: Exception? = null
|
||||
@ -33,7 +33,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
// If file is new or isOptional changed to true, the option needs to be presented again
|
||||
private var newOptional = true
|
||||
|
||||
val isOptional get() = metadata.linkedFile?.isOptional ?: false
|
||||
val isOptional get() = metadata.linkedFile?.option?.optional ?: false
|
||||
|
||||
fun isNewOptional() = isOptional && newOptional
|
||||
|
||||
@ -53,12 +53,6 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
|
||||
override val optionDescription get() = metadata.linkedFile?.option?.description ?: ""
|
||||
|
||||
init {
|
||||
if (metadata.hashFormat?.isEmpty() != false) {
|
||||
metadata.hashFormat = defaultFormat
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
invalidated = true
|
||||
alreadyUpToDate = false
|
||||
@ -74,7 +68,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
this.cachedFile = cachedFile
|
||||
if (!invalidated) {
|
||||
val currHash = try {
|
||||
getHash(metadata.hashFormat!!, metadata.hash!!)
|
||||
metadata.getHashObj(index)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
@ -91,13 +85,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadMetadata(parentIndexFile: IndexFile, indexUri: SpaceSafeURI) {
|
||||
fun downloadMetadata(clientHolder: ClientHolder) {
|
||||
if (err != null) return
|
||||
|
||||
if (metadataRequired) {
|
||||
try {
|
||||
// Retrieve the linked metadata file
|
||||
metadata.downloadMeta(parentIndexFile, indexUri)
|
||||
metadata.downloadMeta(index, clientHolder)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
@ -105,16 +99,14 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
cachedFile?.let { cachedFile ->
|
||||
val linkedFile = metadata.linkedFile
|
||||
if (linkedFile != null) {
|
||||
linkedFile.option?.let { opt ->
|
||||
if (opt.optional) {
|
||||
if (cachedFile.isOptional) {
|
||||
// isOptional didn't change
|
||||
newOptional = false
|
||||
} else {
|
||||
// isOptional false -> true, set option to it's default value
|
||||
// TODO: preserve previous option value, somehow??
|
||||
cachedFile.optionValue = opt.defaultValue
|
||||
}
|
||||
if (linkedFile.option.optional) {
|
||||
if (cachedFile.isOptional) {
|
||||
// isOptional didn't change
|
||||
newOptional = false
|
||||
} else {
|
||||
// isOptional false -> true, set option to it's default value
|
||||
// TODO: preserve previous option value, somehow??
|
||||
cachedFile.optionValue = linkedFile.option.defaultValue
|
||||
}
|
||||
}
|
||||
cachedFile.isOptional = isOptional
|
||||
@ -128,39 +120,40 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
* Check if the file in the destination location is already valid
|
||||
* Must be done after metadata retrieval
|
||||
*/
|
||||
fun validateExistingFile(packFolder: String) {
|
||||
fun validateExistingFile(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
|
||||
if (!alreadyUpToDate) {
|
||||
try {
|
||||
// TODO: only do this for files that didn't exist before or have been modified since last full update?
|
||||
val destPath = Paths.get(packFolder, metadata.destURI.toString())
|
||||
FileSystem.SYSTEM.source(destPath.toOkioPath()).buffer().use { src ->
|
||||
val hash: Hash
|
||||
val fileHashFormat: String
|
||||
val destPath = metadata.destURI.rebase(packFolder)
|
||||
destPath.source(clientHolder).use { src ->
|
||||
// TODO: clean up duplicated code
|
||||
val hash: Hash<*>
|
||||
val fileHashFormat: HashFormat<*>
|
||||
val linkedFile = metadata.linkedFile
|
||||
|
||||
if (linkedFile != null) {
|
||||
hash = linkedFile.hash
|
||||
fileHashFormat = linkedFile.download!!.hashFormat!!
|
||||
fileHashFormat = linkedFile.download.hashFormat
|
||||
} else {
|
||||
hash = metadata.getHashObj()
|
||||
fileHashFormat = metadata.hashFormat!!
|
||||
hash = metadata.getHashObj(index)
|
||||
fileHashFormat = metadata.hashFormat(index)
|
||||
}
|
||||
|
||||
val fileSource = getHasher(fileHashFormat).getHashingSource(src)
|
||||
val fileSource = fileHashFormat.source(src)
|
||||
fileSource.buffer().readAll(blackholeSink())
|
||||
if (fileSource.hashIsEqual(hash)) {
|
||||
if (hash == fileSource.hash) {
|
||||
alreadyUpToDate = true
|
||||
|
||||
// Update the manifest file
|
||||
cachedFile = (cachedFile ?: ManifestFile.File()).also {
|
||||
try {
|
||||
it.hash = metadata.getHashObj()
|
||||
it.hash = metadata.getHashObj(index)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
it.isOptional = isOptional
|
||||
it.cachedLocation = metadata.destURI.toString()
|
||||
it.cachedLocation = metadata.destURI.rebase(packFolder)
|
||||
metadata.linkedFile?.let { linked ->
|
||||
try {
|
||||
it.linkedFileHash = linked.hash
|
||||
@ -171,13 +164,15 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: RequestException) {
|
||||
// Ignore exceptions; if the file doesn't exist we'll be downloading it
|
||||
} catch (e: IOException) {
|
||||
// Ignore exceptions; if the file doesn't exist we'll be downloading it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun download(packFolder: String, indexUri: SpaceSafeURI) {
|
||||
fun download(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
|
||||
if (err != null) return
|
||||
|
||||
// Exclude wrong-side and optional false files
|
||||
@ -186,7 +181,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
if (it.cachedLocation != null) {
|
||||
// Ensure wrong-side or optional false files are removed
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(packFolder, it.cachedLocation))
|
||||
Files.deleteIfExists(it.cachedLocation!!.nioPath)
|
||||
} catch (e: IOException) {
|
||||
Log.warn("Failed to delete file", e)
|
||||
}
|
||||
@ -197,33 +192,32 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
}
|
||||
if (alreadyUpToDate) return
|
||||
|
||||
val destPath = Paths.get(packFolder, metadata.destURI.toString())
|
||||
val destPath = metadata.destURI.rebase(packFolder)
|
||||
|
||||
// Don't update files marked with preserve if they already exist on disk
|
||||
if (metadata.preserve) {
|
||||
if (destPath.toFile().exists()) {
|
||||
if (destPath.nioPath.toFile().exists()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: if already exists and has correct hash, ignore?
|
||||
// TODO: add .disabled support?
|
||||
|
||||
try {
|
||||
val hash: Hash
|
||||
val fileHashFormat: String
|
||||
val hash: Hash<*>
|
||||
val fileHashFormat: HashFormat<*>
|
||||
val linkedFile = metadata.linkedFile
|
||||
|
||||
if (linkedFile != null) {
|
||||
hash = linkedFile.hash
|
||||
fileHashFormat = linkedFile.download!!.hashFormat!!
|
||||
fileHashFormat = linkedFile.download.hashFormat
|
||||
} else {
|
||||
hash = metadata.getHashObj()
|
||||
fileHashFormat = metadata.hashFormat!!
|
||||
hash = metadata.getHashObj(index)
|
||||
fileHashFormat = metadata.hashFormat(index)
|
||||
}
|
||||
|
||||
val src = metadata.getSource(indexUri)
|
||||
val fileSource = getHasher(fileHashFormat).getHashingSource(src)
|
||||
val src = metadata.getSource(clientHolder)
|
||||
val fileSource = fileHashFormat.source(src)
|
||||
val data = Buffer()
|
||||
|
||||
// Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails)
|
||||
@ -232,16 +226,16 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
it.readAll(data)
|
||||
}
|
||||
|
||||
if (fileSource.hashIsEqual(hash)) {
|
||||
if (hash == fileSource.hash) {
|
||||
// isDirectory follows symlinks, but createDirectories doesn't
|
||||
try {
|
||||
Files.createDirectories(destPath.parent)
|
||||
Files.createDirectories(destPath.parent.nioPath)
|
||||
} catch (e: java.nio.file.FileAlreadyExistsException) {
|
||||
if (!Files.isDirectory(destPath.parent)) {
|
||||
if (!Files.isDirectory(destPath.parent.nioPath)) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING)
|
||||
Files.copy(data.inputStream(), destPath.nioPath, StandardCopyOption.REPLACE_EXISTING)
|
||||
data.clear()
|
||||
} else {
|
||||
// TODO: move println to something visible in the error window
|
||||
@ -257,10 +251,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
return
|
||||
}
|
||||
cachedFile?.cachedLocation?.let {
|
||||
if (destPath != Paths.get(packFolder, it)) {
|
||||
if (destPath != it) {
|
||||
// Delete old file if location changes
|
||||
try {
|
||||
Files.delete(Paths.get(packFolder, cachedFile!!.cachedLocation))
|
||||
Files.delete(cachedFile!!.cachedLocation!!.nioPath)
|
||||
} catch (e: IOException) {
|
||||
// Continue, as it was probably already deleted?
|
||||
// TODO: log it
|
||||
@ -275,13 +269,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
// Update the manifest file
|
||||
cachedFile = (cachedFile ?: ManifestFile.File()).also {
|
||||
try {
|
||||
it.hash = metadata.getHashObj()
|
||||
it.hash = metadata.getHashObj(index)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
it.isOptional = isOptional
|
||||
it.cachedLocation = metadata.destURI.toString()
|
||||
it.cachedLocation = metadata.destURI.rebase(packFolder)
|
||||
metadata.linkedFile?.let { linked ->
|
||||
try {
|
||||
it.linkedFileHash = linked.hash
|
||||
@ -293,11 +287,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createTasksFromIndex(index: IndexFile, defaultFormat: String, downloadSide: Side): MutableList<DownloadTask> {
|
||||
fun createTasksFromIndex(index: IndexFile, downloadSide: Side): MutableList<DownloadTask> {
|
||||
val tasks = ArrayList<DownloadTask>()
|
||||
for (file in Objects.requireNonNull(index.files)) {
|
||||
tasks.add(DownloadTask(file, defaultFormat, downloadSide))
|
||||
for (file in index.files) {
|
||||
tasks.add(DownloadTask(file, index, downloadSide))
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
@ -7,8 +7,8 @@ import com.google.gson.JsonSyntaxException
|
||||
import link.infra.packwiz.installer.metadata.PackFile
|
||||
import link.infra.packwiz.installer.ui.IUserInterface
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import java.io.File
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.reader
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
class LauncherUtils internal constructor(private val opts: UpdateManager.Options, val ui: IUserInterface) {
|
||||
enum class LauncherStatus {
|
||||
@ -20,14 +20,13 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
|
||||
|
||||
fun handleMultiMC(pf: PackFile, gson: Gson): LauncherStatus {
|
||||
// MultiMC MC and loader version checker
|
||||
val manifestPath = Paths.get(opts.multimcFolder, "mmc-pack.json").toString()
|
||||
val manifestFile = File(manifestPath)
|
||||
val manifestPath = opts.multimcFolder / "mmc-pack.json"
|
||||
|
||||
if (!manifestFile.exists()) {
|
||||
if (!manifestPath.nioPath.toFile().exists()) {
|
||||
return LauncherStatus.NOT_FOUND
|
||||
}
|
||||
|
||||
val multimcManifest = manifestFile.reader().use {
|
||||
val multimcManifest = manifestPath.nioPath.reader().use {
|
||||
try {
|
||||
JsonParser.parseReader(it)
|
||||
} catch (e: JsonIOException) {
|
||||
@ -64,7 +63,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
|
||||
if (modLoaders.containsKey(component["uid"]?.asString)) {
|
||||
val modLoader = modLoaders.getValue(component["uid"]!!.asString)
|
||||
loaderVersionsFound[modLoader] = version
|
||||
if (version != pf.versions?.get(modLoader)) {
|
||||
if (version != pf.versions[modLoader]) {
|
||||
outdatedLoaders.add(modLoader)
|
||||
true // Delete component; cached metadata is invalid and will be re-added
|
||||
} else {
|
||||
@ -75,18 +74,18 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
|
||||
|
||||
for ((_, loader) in modLoaders
|
||||
.filter {
|
||||
(!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value))
|
||||
&& pf.versions?.containsKey(it.value) == true }
|
||||
(!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) && pf.versions.containsKey(it.value)
|
||||
}
|
||||
) {
|
||||
manifestModified = true
|
||||
components.add(gson.toJsonTree(
|
||||
hashMapOf("uid" to modLoadersClasses[loader], "version" to pf.versions?.get(loader)))
|
||||
hashMapOf("uid" to modLoadersClasses[loader], "version" to pf.versions[loader]))
|
||||
)
|
||||
}
|
||||
|
||||
// If inconsistent Intermediary mappings version is found, delete it - MultiMC will add and re-dl the correct one
|
||||
components.find { it.isJsonObject && it.asJsonObject["uid"]?.asString == "net.fabricmc.intermediary" }?.let {
|
||||
if (it.asJsonObject["version"]?.asString != pf.versions?.get("minecraft")) {
|
||||
if (it.asJsonObject["version"]?.asString != pf.versions["minecraft"]) {
|
||||
components.remove(it)
|
||||
manifestModified = true
|
||||
}
|
||||
@ -96,7 +95,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
|
||||
// The manifest has been modified, so before saving it we'll ask the user
|
||||
// if they wanna update it, continue without updating it, or exit
|
||||
val oldVers = loaderVersionsFound.map { Pair(it.key, it.value) }
|
||||
val newVers = pf.versions!!.map { Pair(it.key, it.value) }
|
||||
val newVers = pf.versions.map { Pair(it.key, it.value) }
|
||||
|
||||
when (ui.showUpdateConfirmationDialog(oldVers, newVers)) {
|
||||
IUserInterface.UpdateConfirmationResult.CANCELLED -> {
|
||||
@ -108,7 +107,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
|
||||
else -> {}
|
||||
}
|
||||
|
||||
manifestFile.writeText(gson.toJson(multimcManifest))
|
||||
manifestPath.nioPath.writeText(gson.toJson(multimcManifest))
|
||||
Log.info("Successfully updated mmc-pack.json based on version metadata")
|
||||
|
||||
return LauncherStatus.SUCCESSFUL
|
||||
|
@ -2,17 +2,23 @@
|
||||
|
||||
package link.infra.packwiz.installer
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.HttpUrlPath
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
import link.infra.packwiz.installer.ui.cli.CLIHandler
|
||||
import link.infra.packwiz.installer.ui.gui.GUIHandler
|
||||
import link.infra.packwiz.installer.ui.wrap
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Path.Companion.toPath
|
||||
import org.apache.commons.cli.DefaultParser
|
||||
import org.apache.commons.cli.Options
|
||||
import org.apache.commons.cli.ParseException
|
||||
import java.awt.EventQueue
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URI
|
||||
import java.nio.file.Paths
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.UIManager
|
||||
import kotlin.system.exitProcess
|
||||
@ -66,21 +72,41 @@ class Main(args: Array<String>) {
|
||||
|
||||
ui.show()
|
||||
|
||||
val uOptions = try {
|
||||
UpdateManager.Options.construct(
|
||||
downloadURI = SpaceSafeURI(unparsedArgs[0]),
|
||||
side = cmd.getOptionValue("side")?.let((Side)::from),
|
||||
packFolder = cmd.getOptionValue("pack-folder"),
|
||||
multimcFolder = cmd.getOptionValue("multimc-folder"),
|
||||
manifestFile = cmd.getOptionValue("meta-file")
|
||||
)
|
||||
} catch (e: URISyntaxException) {
|
||||
ui.showErrorAndExit("Failed to read pack.toml URI", e)
|
||||
val packFileRaw = unparsedArgs[0]
|
||||
|
||||
val packFile = when {
|
||||
// HTTP(s) URLs
|
||||
Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.wrap("Invalid HTTP/HTTPS URL for pack file: $packFileRaw") {
|
||||
HttpUrlPath(packFileRaw.toHttpUrl().resolve(".")!!, packFileRaw.toHttpUrl().pathSegments.last())
|
||||
}
|
||||
// File URIs (uses same logic as old packwiz-installer, for backwards compat)
|
||||
Regex("^file:", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> {
|
||||
ui.wrap("Failed to parse file path for pack file: $packFileRaw") {
|
||||
val path = Paths.get(URI(packFileRaw)).toOkioPath()
|
||||
PackwizFilePath(path.parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), path.name)
|
||||
}
|
||||
}
|
||||
// Other URIs (unsupported)
|
||||
Regex("^[a-z][a-z\\d+\\-.]*://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.showErrorAndExit("Unsupported scheme for pack file: $packFileRaw")
|
||||
// None of the above matches -> interpret as file path
|
||||
else -> PackwizFilePath(packFileRaw.toPath().parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), packFileRaw.toPath().name)
|
||||
}
|
||||
val side = cmd.getOptionValue("side")?.let {
|
||||
Side.from(it) ?: ui.showErrorAndExit("Unknown side name: $it")
|
||||
} ?: Side.CLIENT
|
||||
val packFolder = ui.wrap("Invalid pack folder path") {
|
||||
cmd.getOptionValue("pack-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath(".".toPath())
|
||||
}
|
||||
val multimcFolder = ui.wrap("Invalid MultiMC folder path") {
|
||||
cmd.getOptionValue("multimc-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath("..".toPath())
|
||||
}
|
||||
val manifestFile = ui.wrap("Invalid manifest file path") {
|
||||
packFolder / (cmd.getOptionValue("meta-file") ?: "manifest.json")
|
||||
}
|
||||
|
||||
// Start update process!
|
||||
try {
|
||||
UpdateManager(uOptions, ui)
|
||||
UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side), ui)
|
||||
} catch (e: Exception) {
|
||||
ui.showErrorAndExit("Update process failed", e)
|
||||
}
|
||||
|
@ -1,31 +1,33 @@
|
||||
package link.infra.packwiz.installer
|
||||
|
||||
import cc.ekblad.toml.decode
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonIOException
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.moandjiezana.toml.Toml
|
||||
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex
|
||||
import link.infra.packwiz.installer.metadata.DownloadMode
|
||||
import link.infra.packwiz.installer.metadata.IndexFile
|
||||
import link.infra.packwiz.installer.metadata.ManifestFile
|
||||
import link.infra.packwiz.installer.metadata.PackFile
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.metadata.curseforge.resolveCfMetadata
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.ui.IUserInterface
|
||||
import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult
|
||||
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
|
||||
import link.infra.packwiz.installer.ui.data.InstallProgress
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import link.infra.packwiz.installer.util.ifletOrErr
|
||||
import okio.buffer
|
||||
import java.io.*
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.CompletionService
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ExecutorCompletionService
|
||||
@ -42,29 +44,32 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
}
|
||||
|
||||
data class Options(
|
||||
val downloadURI: SpaceSafeURI,
|
||||
val manifestFile: String,
|
||||
val packFolder: String,
|
||||
val multimcFolder: String,
|
||||
val packFile: PackwizPath<*>,
|
||||
val manifestFile: PackwizFilePath,
|
||||
val packFolder: PackwizFilePath,
|
||||
val multimcFolder: PackwizFilePath,
|
||||
val side: Side
|
||||
) {
|
||||
// Horrible workaround for default params not working cleanly with nullable values
|
||||
companion object {
|
||||
fun construct(downloadURI: SpaceSafeURI, manifestFile: String?, packFolder: String?, multimcFolder: String?, side: Side?) =
|
||||
Options(downloadURI, manifestFile ?: "packwiz.json", packFolder ?: ".", multimcFolder ?: "..", side ?: Side.CLIENT)
|
||||
)
|
||||
|
||||
// TODO: make this return a value based on results?
|
||||
private fun start() {
|
||||
val clientHolder = ClientHolder()
|
||||
ui.cancelCallback = {
|
||||
clientHolder.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
checkOptions()
|
||||
|
||||
ui.submitProgress(InstallProgress("Loading manifest file..."))
|
||||
val gson = GsonBuilder().registerTypeAdapter(Hash::class.java, Hash.TypeHandler()).create()
|
||||
val gson = GsonBuilder()
|
||||
.registerTypeAdapter(Hash::class.java, Hash.TypeHandler())
|
||||
.registerTypeAdapter(PackwizFilePath::class.java, PackwizPath.adapterRelativeTo(opts.packFolder))
|
||||
.enableComplexMapKeySerialization()
|
||||
.create()
|
||||
val manifest = try {
|
||||
gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
|
||||
ManifestFile::class.java)
|
||||
} catch (e: FileNotFoundException) {
|
||||
// TODO: kotlinx.serialisation?
|
||||
InputStreamReader(opts.manifestFile.source(clientHolder).inputStream(), StandardCharsets.UTF_8).use { reader ->
|
||||
gson.fromJson(reader, ManifestFile::class.java)
|
||||
}
|
||||
} catch (e: RequestException.Response.File.FileNotFound) {
|
||||
ui.firstInstall = true
|
||||
ManifestFile()
|
||||
} catch (e: JsonSyntaxException) {
|
||||
@ -80,14 +85,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
|
||||
ui.submitProgress(InstallProgress("Loading pack file..."))
|
||||
val packFileSource = try {
|
||||
val src = getFileSource(opts.downloadURI)
|
||||
getHasher("sha256").getHashingSource(src)
|
||||
val src = opts.packFile.source(clientHolder)
|
||||
HashFormat.SHA256.source(src)
|
||||
} catch (e: Exception) {
|
||||
// TODO: ensure suppressed/caused exceptions are shown?
|
||||
ui.showErrorAndExit("Failed to download pack.toml", e)
|
||||
}
|
||||
val pf = packFileSource.buffer().use {
|
||||
try {
|
||||
Toml().read(InputStreamReader(it.inputStream(), "UTF-8")).to(PackFile::class.java)
|
||||
PackFile.mapper(opts.packFile).decode<PackFile>(it.inputStream())
|
||||
} catch (e: IllegalStateException) {
|
||||
ui.showErrorAndExit("Failed to parse pack.toml", e)
|
||||
}
|
||||
@ -122,7 +128,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
ui.submitProgress(InstallProgress("Checking local files..."))
|
||||
|
||||
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked
|
||||
val invalidatedUris: MutableList<SpaceSafeURI> = ArrayList()
|
||||
val invalidatedUris: MutableList<PackwizFilePath> = ArrayList()
|
||||
for ((fileUri, file) in manifest.cachedFiles) {
|
||||
// ignore onlyOtherSide files
|
||||
if (file.onlyOtherSide) {
|
||||
@ -133,7 +139,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
// if isn't optional, or is optional but optionValue == true
|
||||
if (!file.isOptional || file.optionValue) {
|
||||
if (file.cachedLocation != null) {
|
||||
if (!Paths.get(opts.packFolder, file.cachedLocation).toFile().exists()) {
|
||||
if (!file.cachedLocation!!.nioPath.toFile().exists()) {
|
||||
invalid = true
|
||||
}
|
||||
} else {
|
||||
@ -142,12 +148,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
}
|
||||
}
|
||||
if (invalid) {
|
||||
Log.info("File $fileUri invalidated, marked for redownloading")
|
||||
Log.info("File ${fileUri.filename} invalidated, marked for redownloading")
|
||||
invalidatedUris.add(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
|
||||
if (manifest.packFileHash?.let { it == packFileSource.hash } == true && invalidatedUris.isEmpty()) {
|
||||
// todo: --force?
|
||||
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1))
|
||||
if (manifest.cachedFiles.any { it.value.isOptional }) {
|
||||
@ -165,20 +171,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
handleCancellation()
|
||||
}
|
||||
try {
|
||||
// TODO: switch to OkHttp for better redirect handling
|
||||
ui.ifletOrErr(pf.index, "No index file found, or the pack file is empty; note that Java doesn't automatically follow redirects from HTTP to HTTPS (and may cause this error)") { index ->
|
||||
ui.ifletOrErr(index.hashFormat, index.hash, "Pack has no hash or hashFormat for index") { hashFormat, hash ->
|
||||
ui.ifletOrErr(getNewLoc(opts.downloadURI, index.file), "Pack has invalid index file: " + index.file) { newLoc ->
|
||||
processIndex(
|
||||
newLoc,
|
||||
getHash(hashFormat, hash),
|
||||
hashFormat,
|
||||
manifest,
|
||||
invalidatedUris
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
processIndex(
|
||||
pf.index.file,
|
||||
pf.index.hashFormat.fromString(pf.index.hash),
|
||||
pf.index.hashFormat,
|
||||
manifest,
|
||||
invalidatedUris,
|
||||
clientHolder
|
||||
)
|
||||
} catch (e1: Exception) {
|
||||
ui.showErrorAndExit("Failed to process index file", e1)
|
||||
}
|
||||
@ -196,18 +196,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
|
||||
manifest.cachedSide = opts.side
|
||||
try {
|
||||
FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString()).use { writer -> gson.toJson(manifest, writer) }
|
||||
FileWriter(opts.manifestFile.nioPath.toFile()).use { writer -> gson.toJson(manifest, writer) }
|
||||
} catch (e: IOException) {
|
||||
ui.showErrorAndExit("Failed to save local manifest file", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkOptions() {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
|
||||
if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
|
||||
private fun processIndex(indexUri: PackwizPath<*>, indexHash: Hash<*>, hashFormat: HashFormat<*>, manifest: ManifestFile, invalidatedFiles: List<PackwizFilePath>, clientHolder: ClientHolder) {
|
||||
if (manifest.indexFileHash == indexHash && invalidatedFiles.isEmpty()) {
|
||||
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
|
||||
if (manifest.cachedFiles.any { it.value.isOptional }) {
|
||||
ui.awaitOptionalButton(false)
|
||||
@ -223,18 +219,18 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
manifest.indexFileHash = indexHash
|
||||
|
||||
val indexFileSource = try {
|
||||
val src = getFileSource(indexUri)
|
||||
getHasher(hashFormat).getHashingSource(src)
|
||||
val src = indexUri.source(clientHolder)
|
||||
hashFormat.source(src)
|
||||
} catch (e: Exception) {
|
||||
ui.showErrorAndExit("Failed to download index file", e)
|
||||
}
|
||||
|
||||
val indexFile = try {
|
||||
Toml().read(InputStreamReader(indexFileSource.buffer().inputStream(), "UTF-8")).to(IndexFile::class.java)
|
||||
IndexFile.mapper(indexUri).decode<IndexFile>(indexFileSource.buffer().inputStream())
|
||||
} catch (e: IllegalStateException) {
|
||||
ui.showErrorAndExit("Failed to parse index file", e)
|
||||
}
|
||||
if (!indexFileSource.hashIsEqual(indexHash)) {
|
||||
if (indexHash != indexFileSource.hash) {
|
||||
ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again")
|
||||
}
|
||||
|
||||
@ -245,7 +241,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
|
||||
ui.submitProgress(InstallProgress("Checking local files..."))
|
||||
// TODO: use kotlin filtering/FP rather than an iterator?
|
||||
val it: MutableIterator<Map.Entry<SpaceSafeURI, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
|
||||
val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val (uri, file) = it.next()
|
||||
if (file.cachedLocation != null) {
|
||||
@ -253,7 +249,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
// Delete if option value has been set to false
|
||||
if (file.isOptional && !file.optionValue) {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
|
||||
Files.deleteIfExists(file.cachedLocation!!.nioPath)
|
||||
} catch (e: IOException) {
|
||||
Log.warn("Failed to delete optional disabled file", e)
|
||||
}
|
||||
@ -261,10 +257,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
file.cachedLocation = null
|
||||
alreadyDeleted = true
|
||||
}
|
||||
if (indexFile.files.none { it.file == uri }) { // File has been removed from the index
|
||||
if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index
|
||||
if (!alreadyDeleted) {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
|
||||
Files.deleteIfExists(file.cachedLocation!!.nioPath)
|
||||
} catch (e: IOException) {
|
||||
Log.warn("Failed to delete file removed from index", e)
|
||||
}
|
||||
@ -284,7 +280,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
if (indexFile.files.isEmpty()) {
|
||||
Log.warn("Index is empty!")
|
||||
}
|
||||
val tasks = createTasksFromIndex(indexFile, indexFile.hashFormat, opts.side)
|
||||
val tasks = createTasksFromIndex(indexFile, opts.side)
|
||||
// If the side changes, invalidate EVERYTHING just in case
|
||||
// Might not be needed, but done just to be safe
|
||||
val invalidateAll = opts.side != manifest.cachedSide
|
||||
@ -295,10 +291,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
// TODO: should linkedfile be checked as well? should this be done in the download section?
|
||||
if (invalidateAll) {
|
||||
f.invalidate()
|
||||
} else if (invalidatedUris.contains(f.metadata.file)) {
|
||||
} else if (invalidatedFiles.contains(f.metadata.file.rebase(opts.packFolder))) {
|
||||
f.invalidate()
|
||||
}
|
||||
val file = manifest.cachedFiles[f.metadata.file]
|
||||
val file = manifest.cachedFiles[f.metadata.file.rebase(opts.packFolder)]
|
||||
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
|
||||
file?.backup()
|
||||
// If it is null, the DownloadTask will make a new empty cachedFile
|
||||
@ -311,7 +307,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
}
|
||||
|
||||
// Let's hope downloadMetadata is a pure function!!!
|
||||
tasks.parallelStream().forEach { f -> f.downloadMetadata(indexFile, indexUri) }
|
||||
tasks.parallelStream().forEach { f -> f.downloadMetadata(clientHolder) }
|
||||
|
||||
val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
|
||||
if (failedTaskDetails.isNotEmpty()) {
|
||||
@ -361,7 +357,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
ui.disableOptionsButton(optionTasks.isNotEmpty())
|
||||
|
||||
while (true) {
|
||||
when (validateAndResolve(tasks)) {
|
||||
when (validateAndResolve(tasks, clientHolder)) {
|
||||
ResolveResult.RETRY -> {}
|
||||
ResolveResult.QUIT -> return
|
||||
ResolveResult.SUCCESS -> break
|
||||
@ -373,7 +369,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool)
|
||||
tasks.forEach { t ->
|
||||
completionService.submit {
|
||||
t.download(opts.packFolder, indexUri)
|
||||
t.download(opts.packFolder, clientHolder)
|
||||
t
|
||||
}
|
||||
}
|
||||
@ -390,10 +386,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
if (task.failed()) {
|
||||
val oldFile = file.revert
|
||||
if (oldFile != null) {
|
||||
task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, oldFile) }
|
||||
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), oldFile)
|
||||
} else { null }
|
||||
} else {
|
||||
task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, file) }
|
||||
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), file)
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,6 +402,8 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
ui.submitProgress(InstallProgress(progress, i + 1, tasks.size))
|
||||
|
||||
if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!)
|
||||
// TODO: close client holder in more places?
|
||||
clientHolder.close()
|
||||
threadPool.shutdown()
|
||||
cancelled = true
|
||||
return
|
||||
@ -432,12 +430,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
SUCCESS;
|
||||
}
|
||||
|
||||
private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>): ResolveResult {
|
||||
private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>, clientHolder: ClientHolder): ResolveResult {
|
||||
ui.submitProgress(InstallProgress("Validating existing files..."))
|
||||
|
||||
// Validate existing files
|
||||
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
|
||||
downloadTask.validateExistingFile(opts.packFolder)
|
||||
downloadTask.validateExistingFile(opts.packFolder, clientHolder)
|
||||
}
|
||||
|
||||
// Resolve CurseForge metadata
|
||||
@ -445,10 +443,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
.filter(DownloadTask::correctSide)
|
||||
.map { it.metadata }
|
||||
.filter { it.linkedFile != null }
|
||||
.filter { it.linkedFile?.download?.mode == "metadata:curseforge" }.toList()
|
||||
.filter { it.linkedFile!!.download.mode == DownloadMode.CURSEFORGE }.toList()
|
||||
if (cfFiles.isNotEmpty()) {
|
||||
ui.submitProgress(InstallProgress("Resolving CurseForge metadata..."))
|
||||
val resolveFailures = resolveCfMetadata(cfFiles)
|
||||
val resolveFailures = resolveCfMetadata(cfFiles, opts.packFolder, clientHolder)
|
||||
if (resolveFailures.isNotEmpty()) {
|
||||
errorsOccurred = true
|
||||
return when (ui.showExceptions(resolveFailures, cfFiles.size, true)) {
|
||||
|
@ -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}")
|
||||
} }
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ class EfficientBooleanAdapter : TypeAdapter<Boolean?>() {
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): Boolean? {
|
||||
override fun read(reader: JsonReader): Boolean {
|
||||
if (reader.peek() == JsonToken.NULL) {
|
||||
reader.nextNull()
|
||||
return false
|
||||
|
@ -1,100 +1,97 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.moandjiezana.toml.Toml
|
||||
import cc.ekblad.toml.decode
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.util.delegateTransitive
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.file.Paths
|
||||
|
||||
class IndexFile {
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String = "sha-256"
|
||||
var files: MutableList<File> = ArrayList()
|
||||
|
||||
class File {
|
||||
var file: SpaceSafeURI? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hash: String? = null
|
||||
var alias: SpaceSafeURI? = null
|
||||
var metafile = false
|
||||
var preserve = false
|
||||
|
||||
@Transient
|
||||
data class IndexFile(
|
||||
val hashFormat: HashFormat<*>,
|
||||
val files: List<File> = listOf()
|
||||
) {
|
||||
data class File(
|
||||
val file: PackwizPath<*>,
|
||||
private val hashFormat: HashFormat<*>? = null,
|
||||
val hash: String,
|
||||
val alias: PackwizPath<*>?,
|
||||
val metafile: Boolean = false,
|
||||
val preserve: Boolean = false,
|
||||
) {
|
||||
var linkedFile: ModFile? = null
|
||||
@Transient
|
||||
var linkedFileURI: SpaceSafeURI? = null
|
||||
|
||||
fun hashFormat(index: IndexFile) = hashFormat ?: index.hashFormat
|
||||
@Throws(Exception::class)
|
||||
fun getHashObj(index: IndexFile): Hash<*> {
|
||||
// TODO: more specific exceptions?
|
||||
return hashFormat(index).fromString(hash)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun downloadMeta(parentIndexFile: IndexFile, indexUri: SpaceSafeURI?) {
|
||||
fun downloadMeta(index: IndexFile, clientHolder: ClientHolder) {
|
||||
if (!metafile) {
|
||||
return
|
||||
}
|
||||
if (hashFormat?.length ?: 0 == 0) {
|
||||
hashFormat = parentIndexFile.hashFormat
|
||||
}
|
||||
// TODO: throw a proper exception instead of allowing NPE?
|
||||
val fileHash = getHash(hashFormat!!, hash!!)
|
||||
linkedFileURI = getNewLoc(indexUri, file)
|
||||
val src = getFileSource(linkedFileURI!!)
|
||||
val fileStream = getHasher(hashFormat!!).getHashingSource(src)
|
||||
linkedFile = Toml().read(InputStreamReader(fileStream.buffer().inputStream(), "UTF-8")).to(ModFile::class.java)
|
||||
if (!fileStream.hashIsEqual(fileHash)) {
|
||||
val fileHash = getHashObj(index)
|
||||
val src = file.source(clientHolder)
|
||||
val fileStream = hashFormat(index).source(src)
|
||||
linkedFile = ModFile.mapper(file).decode<ModFile>(fileStream.buffer().inputStream())
|
||||
if (fileHash != fileStream.hash) {
|
||||
// TODO: propagate details about hash, and show better error!
|
||||
throw Exception("Invalid mod file hash")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getSource(indexUri: SpaceSafeURI?): Source {
|
||||
fun getSource(clientHolder: ClientHolder): Source {
|
||||
return if (metafile) {
|
||||
if (linkedFile == null) {
|
||||
throw Exception("Linked file doesn't exist!")
|
||||
}
|
||||
linkedFile!!.getSource(linkedFileURI)
|
||||
linkedFile!!.getSource(clientHolder)
|
||||
} else {
|
||||
val newLoc = getNewLoc(indexUri, file) ?: throw Exception("Index file URI is invalid")
|
||||
getFileSource(newLoc)
|
||||
file.source(clientHolder)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getHashObj(): Hash {
|
||||
if (hash == null) { // TODO: should these be more specific exceptions (e.g. IndexFileException?!)
|
||||
throw Exception("Index file doesn't have a hash")
|
||||
}
|
||||
if (hashFormat == null) {
|
||||
throw Exception("Index file doesn't have a hash format")
|
||||
}
|
||||
return getHash(hashFormat!!, hash!!)
|
||||
}
|
||||
|
||||
// TODO: throw some kind of exception?
|
||||
val name: String
|
||||
get() {
|
||||
if (metafile) {
|
||||
return linkedFile?.name ?: linkedFile?.filename ?:
|
||||
file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
|
||||
return linkedFile?.name ?: file.filename
|
||||
}
|
||||
return file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
|
||||
return file.filename
|
||||
}
|
||||
|
||||
// TODO: URIs are bad
|
||||
val destURI: SpaceSafeURI?
|
||||
val destURI: PackwizPath<*>
|
||||
get() {
|
||||
if (alias != null) {
|
||||
return alias
|
||||
}
|
||||
return if (metafile && linkedFile != null) {
|
||||
linkedFile?.filename?.let { file?.resolve(it) }
|
||||
return if (metafile) {
|
||||
linkedFile!!.filename
|
||||
} else {
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun mapper(base: PackwizPath<*>) = tomlMapper {
|
||||
mapping<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))
|
||||
}
|
||||
}
|
||||
}
|
@ -3,23 +3,23 @@ package link.infra.packwiz.installer.metadata
|
||||
import com.google.gson.annotations.JsonAdapter
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
|
||||
class ManifestFile {
|
||||
var packFileHash: Hash? = null
|
||||
var indexFileHash: Hash? = null
|
||||
var cachedFiles: MutableMap<SpaceSafeURI, File> = HashMap()
|
||||
var packFileHash: Hash<*>? = null
|
||||
var indexFileHash: Hash<*>? = null
|
||||
var cachedFiles: MutableMap<PackwizFilePath, File> = HashMap()
|
||||
// If the side changes, EVERYTHING invalidates. FUN!!!
|
||||
var cachedSide = Side.CLIENT
|
||||
|
||||
// TODO: switch to Kotlin-friendly JSON/TOML libs?
|
||||
class File {
|
||||
@Transient
|
||||
var revert: File? = null
|
||||
private set
|
||||
|
||||
var hash: Hash? = null
|
||||
var linkedFileHash: Hash? = null
|
||||
var cachedLocation: String? = null
|
||||
var hash: Hash<*>? = null
|
||||
var linkedFileHash: Hash<*>? = null
|
||||
var cachedLocation: PackwizFilePath? = null
|
||||
|
||||
@JsonAdapter(EfficientBooleanAdapter::class)
|
||||
var isOptional = false
|
||||
|
@ -1,74 +1,96 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import cc.ekblad.toml.delegate
|
||||
import cc.ekblad.toml.model.TomlValue
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import link.infra.packwiz.installer.metadata.curseforge.UpdateData
|
||||
import link.infra.packwiz.installer.metadata.curseforge.UpdateDeserializer
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.HttpUrlPath
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.util.delegateTransitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.Source
|
||||
import kotlin.reflect.KType
|
||||
|
||||
class ModFile {
|
||||
var name: String? = null
|
||||
var filename: String? = null
|
||||
var side: Side? = null
|
||||
var download: Download? = null
|
||||
data class ModFile(
|
||||
val name: String,
|
||||
val filename: PackwizPath<*>,
|
||||
val side: Side = Side.BOTH,
|
||||
val download: Download,
|
||||
val update: Map<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 {
|
||||
var url: SpaceSafeURI? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hash: String? = null
|
||||
var mode: String? = null
|
||||
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
|
||||
delegate<DownloadMode>(DownloadMode.mapper())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonAdapter(UpdateDeserializer::class)
|
||||
var update: Map<String, UpdateData>? = null
|
||||
var option: Option? = null
|
||||
|
||||
@Transient
|
||||
val resolvedUpdateData = mutableMapOf<String, SpaceSafeURI>()
|
||||
val resolvedUpdateData = mutableMapOf<String, PackwizPath<*>>()
|
||||
|
||||
class Option {
|
||||
var optional = false
|
||||
var description: String? = null
|
||||
@SerializedName("default")
|
||||
var defaultValue = false
|
||||
data class Option(
|
||||
val optional: Boolean,
|
||||
val description: String = "",
|
||||
val defaultValue: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun mapper() = tomlMapper {
|
||||
mapping<Option>("default" to "defaultValue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getSource(baseLoc: SpaceSafeURI?): Source {
|
||||
download?.let {
|
||||
if (it.mode == null || it.mode == "" || it.mode == "url") {
|
||||
if (it.url == null) {
|
||||
throw Exception("Metadata file doesn't have a download URI")
|
||||
}
|
||||
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid")
|
||||
return getFileSource(newLoc)
|
||||
} else if (it.mode == "metadata:curseforge") {
|
||||
fun getSource(clientHolder: ClientHolder): Source {
|
||||
return when (download.mode) {
|
||||
DownloadMode.URL -> {
|
||||
(download.url ?: throw Exception("No download URL provided")).source(clientHolder)
|
||||
}
|
||||
DownloadMode.CURSEFORGE -> {
|
||||
if (!resolvedUpdateData.contains("curseforge")) {
|
||||
throw Exception("Metadata file specifies CurseForge mode, but is missing metadata")
|
||||
}
|
||||
return getFileSource(resolvedUpdateData["curseforge"]!!)
|
||||
} else {
|
||||
throw Exception("Unsupported download mode " + it.mode)
|
||||
return resolvedUpdateData["curseforge"]!!.source(clientHolder)
|
||||
}
|
||||
} ?: throw Exception("Metadata file doesn't have download")
|
||||
}
|
||||
}
|
||||
|
||||
@get:Throws(Exception::class)
|
||||
val hash: Hash
|
||||
get() {
|
||||
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 hash: Hash<*>
|
||||
get() = download.hashFormat.fromString(download.hash)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +1,37 @@
|
||||
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 {
|
||||
var name: String? = null
|
||||
var index: IndexFileLoc? = null
|
||||
|
||||
class IndexFileLoc {
|
||||
var file: SpaceSafeURI? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hash: String? = null
|
||||
data class PackFile(
|
||||
val name: String,
|
||||
val packFormat: PackFormat = PackFormat.DEFAULT,
|
||||
val index: IndexFileLoc,
|
||||
val versions: Map<String, String> = mapOf()
|
||||
) {
|
||||
data class IndexFileLoc(
|
||||
val file: PackwizPath<*>,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
}
|
@ -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?
|
||||
}
|
@ -4,15 +4,18 @@ import com.google.gson.Gson
|
||||
import com.google.gson.JsonIOException
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import link.infra.packwiz.installer.metadata.IndexFile
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.path.HttpUrlPath
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
import link.infra.packwiz.installer.ui.data.ExceptionDetails
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.io.path.absolute
|
||||
|
||||
private class GetFilesRequest(val fileIds: List<Int>)
|
||||
private class GetModsRequest(val modIds: List<Int>)
|
||||
@ -21,7 +24,7 @@ private class GetFilesResponse {
|
||||
class CfFile {
|
||||
var id = 0
|
||||
var modId = 0
|
||||
var downloadUrl: SpaceSafeURI? = null
|
||||
var downloadUrl: String? = null
|
||||
}
|
||||
val data = mutableListOf<CfFile>()
|
||||
}
|
||||
@ -43,25 +46,17 @@ private const val APIServer = "api.curseforge.com"
|
||||
private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh".decodeBase64()!!
|
||||
.string(StandardCharsets.UTF_8)
|
||||
|
||||
private val clientHolder = ClientHolder()
|
||||
|
||||
// TODO: switch to PackwizPath stuff and OkHttp in old code
|
||||
|
||||
@Throws(JsonSyntaxException::class, JsonIOException::class)
|
||||
fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
|
||||
fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> {
|
||||
val failures = mutableListOf<ExceptionDetails>()
|
||||
val fileIdMap = mutableMapOf<Int, IndexFile.File>()
|
||||
|
||||
for (mod in mods) {
|
||||
if (mod.linkedFile!!.update == null) {
|
||||
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no update section")))
|
||||
if (!mod.linkedFile!!.update.contains("curseforge")) {
|
||||
failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
|
||||
continue
|
||||
}
|
||||
if (!mod.linkedFile!!.update!!.contains("curseforge")) {
|
||||
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
|
||||
continue
|
||||
}
|
||||
fileIdMap[(mod.linkedFile!!.update!!["curseforge"] as CurseForgeUpdateData).fileId] = mod
|
||||
fileIdMap[(mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId] = mod
|
||||
}
|
||||
|
||||
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)
|
||||
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()) {
|
||||
@ -124,7 +125,7 @@ fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
|
||||
|
||||
val modFile = manualDownloadMods[mod.id]!!
|
||||
failures.add(ExceptionDetails(mod.name, Exception("This mod is excluded from the CurseForge API and must be downloaded manually.\n" +
|
||||
"Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI}")))
|
||||
"Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI.rebase(packFolder).nioPath.absolute()}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,14 @@
|
||||
package link.infra.packwiz.installer.metadata.curseforge
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
|
||||
class CurseForgeUpdateData: UpdateData {
|
||||
@SerializedName("file-id")
|
||||
var fileId = 0
|
||||
@SerializedName("project-id")
|
||||
var projectId = 0
|
||||
data class CurseForgeUpdateData(
|
||||
val fileId: Int,
|
||||
val projectId: Int,
|
||||
): UpdateData {
|
||||
companion object {
|
||||
fun mapper() = tomlMapper {
|
||||
mapping<CurseForgeUpdateData>("file-id" to "fileId", "project-id" to "projectId")
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,17 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -1,20 +1,55 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
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
|
||||
|
||||
abstract class Hash {
|
||||
protected abstract val stringValue: String
|
||||
protected abstract val type: String
|
||||
data class Hash<T>(val type: HashFormat<T>, val value: T) {
|
||||
interface Encoding<T> {
|
||||
fun encodeToString(value: T): String
|
||||
fun decodeFromString(str: String): T
|
||||
|
||||
class TypeHandler : JsonDeserializer<Hash>, JsonSerializer<Hash> {
|
||||
override fun serialize(src: Hash, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = JsonObject().apply {
|
||||
add("type", JsonPrimitive(src.type))
|
||||
add("value", JsonPrimitive(src.stringValue))
|
||||
object Hex: Encoding<ByteString> {
|
||||
override fun encodeToString(value: ByteString) = value.hex()
|
||||
override fun decodeFromString(str: String) = str.decodeHex()
|
||||
}
|
||||
|
||||
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)
|
||||
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 type: String
|
||||
val value: String
|
||||
@ -25,7 +60,7 @@ abstract class Hash {
|
||||
throw JsonParseException("Invalid hash JSON data")
|
||||
}
|
||||
return try {
|
||||
HashUtils.getHash(type, value)
|
||||
(HashFormat.fromName(type) ?: throw JsonParseException("Unknown hash type $type")).fromString(value)
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Failed to create hash object", e)
|
||||
}
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.Source
|
||||
|
||||
interface HasherSource<T>: Source {
|
||||
val hash: Hash<T>
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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?
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package link.infra.packwiz.installer.request
|
||||
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
|
||||
sealed class RequestException: Exception {
|
||||
@ -14,14 +13,12 @@ sealed class RequestException: Exception {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
class UnsinkableBase: Internal("Base associated with this path is not a SinkableBase")
|
||||
|
||||
sealed class HTTP: Internal {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -47,16 +44,16 @@ sealed class RequestException: Exception {
|
||||
|
||||
// TODO: fancier way of displaying this?
|
||||
sealed class HTTP: Response {
|
||||
val response: okhttp3.Response
|
||||
val res: okhttp3.Response
|
||||
|
||||
constructor(response: okhttp3.Response, message: String, cause: Throwable) : super(message, cause) {
|
||||
this.response = response
|
||||
constructor(req: okhttp3.Request, res: okhttp3.Response, message: String, cause: Throwable) : super("Failed to make HTTP request to ${req.url}: $message", cause) {
|
||||
this.res = res
|
||||
}
|
||||
constructor(response: okhttp3.Response, message: String) : super(message) {
|
||||
this.response = response
|
||||
constructor(req: okhttp3.Request, res: okhttp3.Response, message: String) : super("Failed to make HTTP request to ${req.url}: $message") {
|
||||
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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -15,7 +15,7 @@ data class CachedTarget(
|
||||
*/
|
||||
val cachedLocation: Path,
|
||||
val enabled: Boolean,
|
||||
val hash: Hash,
|
||||
val hash: Hash<*>,
|
||||
/**
|
||||
* For detecting when a target transitions non-optional -> optional and showing the option selection screen
|
||||
*/
|
||||
|
@ -1,12 +1,54 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okio.FileSystem
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ClientHolder {
|
||||
// TODO: timeouts?
|
||||
// TODO: a button to increase timeouts temporarily when retrying?
|
||||
val okHttpClient by lazy { OkHttpClient.Builder().build() }
|
||||
// TODO: a button to increase timeouts temporarily when retrying? manual retry button?
|
||||
val okHttpClient by lazy { OkHttpClient.Builder()
|
||||
// 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
|
||||
|
||||
fun close() {
|
||||
okHttpClient.dispatcher.cancelAll()
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
okHttpClient.connectionPool.evictAll()
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import cc.ekblad.toml.model.TomlValue
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
enum class Side {
|
||||
@ -50,5 +52,10 @@ enum class Side {
|
||||
}
|
||||
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}") }
|
||||
}
|
||||
}
|
||||
}
|
@ -4,8 +4,8 @@ import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
|
||||
// TODO: rename to avoid conflicting with @Target
|
||||
interface Target {
|
||||
val src: PackwizPath
|
||||
val dest: PackwizPath
|
||||
val src: PackwizPath<*>
|
||||
val dest: PackwizPath<*>
|
||||
val validityToken: ValidityToken
|
||||
|
||||
/**
|
||||
@ -19,16 +19,16 @@ interface Target {
|
||||
* be preserved across renames.
|
||||
*/
|
||||
@JvmInline
|
||||
value class PathIdentityToken(val path: String): IdentityToken
|
||||
value class PathIdentityToken(val path: PackwizPath<*>): 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.
|
||||
*/
|
||||
val name: String
|
||||
get() = dest.path
|
||||
get() = dest.filename
|
||||
val side: Side
|
||||
get() = Side.BOTH
|
||||
val overwriteMode: OverwriteMode
|
||||
|
@ -12,5 +12,5 @@ interface ValidityToken {
|
||||
* Default implementation of ValidityToken based on a single hash.
|
||||
*/
|
||||
@JvmInline
|
||||
value class HashValidityToken(val hash: Hash): ValidityToken
|
||||
value class HashValidityToken(val hash: Hash<*>): ValidityToken
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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()
|
||||
}
|
@ -1,124 +1,126 @@
|
||||
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.target.ClientHolder
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
|
||||
class PackwizPath(path: String, base: Base) {
|
||||
val path: String
|
||||
val base: Base
|
||||
abstract class PackwizPath<T: PackwizPath<T>>(path: String? = null) {
|
||||
protected val path: String?
|
||||
|
||||
init {
|
||||
this.base = base
|
||||
if (path != null) {
|
||||
// Check for NUL bytes
|
||||
if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) }
|
||||
// Normalise separator, to prevent differences between Unix/Windows
|
||||
val pathNorm = path.replace('\\', '/')
|
||||
// Split, create new lists for output
|
||||
val split = pathNorm.split('/')
|
||||
val canonicalised = mutableListOf<String>()
|
||||
|
||||
// Check for NUL bytes
|
||||
if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) }
|
||||
// Normalise separator, to prevent differences between Unix/Windows
|
||||
val pathNorm = path.replace('\\', '/')
|
||||
// Split, create new lists for output
|
||||
val split = pathNorm.split('/')
|
||||
val canonicalised = mutableListOf<String>()
|
||||
|
||||
// Backward pass: collapse ".." components, remove "." components and empty components (except an empty component at the end; indicating a folder)
|
||||
var parentComponentCount = 0
|
||||
var first = true
|
||||
for (component in split.asReversed()) {
|
||||
if (first) {
|
||||
first = false
|
||||
if (component == "") {
|
||||
canonicalised += component
|
||||
// Backward pass: collapse ".." components, remove "." components and empty components (except an empty component at the end; indicating a folder)
|
||||
var parentComponentCount = 0
|
||||
var first = true
|
||||
for (component in split.asReversed()) {
|
||||
if (first) {
|
||||
first = false
|
||||
if (component == "") {
|
||||
canonicalised += component
|
||||
}
|
||||
}
|
||||
}
|
||||
// URL-encoded . is normalised
|
||||
val componentNorm = component.replace("%2e", ".")
|
||||
if (componentNorm == "." || componentNorm == "") {
|
||||
// Do nothing
|
||||
} else if (componentNorm == "..") {
|
||||
parentComponentCount++
|
||||
} else if (parentComponentCount > 0) {
|
||||
parentComponentCount--
|
||||
} else {
|
||||
canonicalised += componentNorm
|
||||
// Don't allow volume letters (allows traversal to the root on Windows)
|
||||
if (componentNorm[0] in 'a'..'z' || componentNorm[0] in 'A'..'Z') {
|
||||
if (componentNorm[1] == ':') {
|
||||
throw RequestException.Validation.PathContainsVolumeLetter(path)
|
||||
// URL-encoded . is normalised
|
||||
val componentNorm = component.replace("%2e", ".")
|
||||
if (componentNorm == "." || componentNorm == "") {
|
||||
// Do nothing
|
||||
} else if (componentNorm == "..") {
|
||||
parentComponentCount++
|
||||
} else if (parentComponentCount > 0) {
|
||||
parentComponentCount--
|
||||
} else {
|
||||
canonicalised += componentNorm
|
||||
// Don't allow volume letters (allows traversal to the root on Windows)
|
||||
if (componentNorm[0] in 'a'..'z' || componentNorm[0] in 'A'..'Z') {
|
||||
if (componentNorm[1] == ':') {
|
||||
throw RequestException.Validation.PathContainsVolumeLetter(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join path
|
||||
this.path = canonicalised.asReversed().joinToString("/")
|
||||
if (canonicalised.isEmpty()) {
|
||||
this.path = null
|
||||
} else {
|
||||
// Join path
|
||||
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('\\')) {
|
||||
// Absolute (but still relative to base of pack)
|
||||
PackwizPath(path, base)
|
||||
construct(path)
|
||||
} else if (folder) {
|
||||
// File in folder; append
|
||||
PackwizPath(this.path + path, base)
|
||||
construct((this.path ?: "") + path)
|
||||
} else {
|
||||
// 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
|
||||
* @throws RequestException When resolving the file failed
|
||||
*/
|
||||
fun source(clientHolder: ClientHolder): BufferedSource = base.source(path, clientHolder)
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@Throws(RequestException::class)
|
||||
abstract fun source(clientHolder: ClientHolder): BufferedSource
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PackwizPath
|
||||
other as PackwizPath<*>
|
||||
|
||||
if (path != other.path) return false
|
||||
if (base != other.base) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = path.hashCode()
|
||||
result = 31 * result + base.hashCode()
|
||||
return result
|
||||
override fun hashCode() = path.hashCode()
|
||||
|
||||
companion object {
|
||||
fun mapperRelativeTo(base: PackwizPath<*>) = tomlMapper {
|
||||
encoder { it: PackwizPath<*> -> TomlValue.String(it.path ?: "") }
|
||||
decoder { it: TomlValue.String -> base.resolve(it.value) }
|
||||
}
|
||||
|
||||
fun <T: PackwizPath<T>> adapterRelativeTo(base: T) = object : TypeAdapter<T>() {
|
||||
override fun write(writer: JsonWriter, value: T?) {
|
||||
writer.value(value?.path)
|
||||
}
|
||||
override fun read(reader: JsonReader) = base.resolve(reader.nextString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "base=$base; $path"
|
||||
}
|
||||
override fun toString() = "(Unknown base) $path"
|
||||
}
|
@ -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.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<*>)
|
||||
|
@ -1,19 +1,17 @@
|
||||
package link.infra.packwiz.installer.task.formats.packwizv1
|
||||
|
||||
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.HashUtils
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.task.CacheKey
|
||||
import link.infra.packwiz.installer.task.Task
|
||||
import link.infra.packwiz.installer.task.TaskCombinedResult
|
||||
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
|
||||
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 {
|
||||
var name: String? = null
|
||||
@ -22,7 +20,7 @@ class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath): Task<Packw
|
||||
class IndexFileLoc {
|
||||
var file: String? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hashFormat: HashFormat<*>? = null
|
||||
var hash: String? = null
|
||||
}
|
||||
|
||||
@ -31,14 +29,16 @@ class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath): Task<Packw
|
||||
|
||||
private val internalResult by lazy {
|
||||
// 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")),
|
||||
HashUtils.getHash(packFile.index?.hashFormat ?: throw RuntimeException("Hash format required"),
|
||||
packFile.index?.hash ?: throw RuntimeException("Hash required"))
|
||||
hashFormat.fromString(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))
|
||||
}
|
||||
|
@ -41,6 +41,16 @@ interface IUserInterface {
|
||||
|
||||
var optionsButtonPressed: Boolean
|
||||
var cancelButtonPressed: Boolean
|
||||
var cancelCallback: (() -> Unit)?
|
||||
|
||||
var firstInstall: Boolean
|
||||
|
||||
}
|
||||
|
||||
inline fun <T> IUserInterface.wrap(message: String, inner: () -> T): T {
|
||||
return try {
|
||||
inner.invoke()
|
||||
} catch (e: Exception) {
|
||||
showErrorAndExit(message, e)
|
||||
}
|
||||
}
|
@ -11,9 +11,12 @@ import kotlin.system.exitProcess
|
||||
class CLIHandler : IUserInterface {
|
||||
@Volatile
|
||||
override var optionsButtonPressed = false
|
||||
// TODO: treat ctrl+c as cancel?
|
||||
@Volatile
|
||||
override var cancelButtonPressed = false
|
||||
@Volatile
|
||||
override var cancelCallback: (() -> Unit)? = null
|
||||
@Volatile
|
||||
override var firstInstall = false
|
||||
|
||||
override var title: String = ""
|
||||
|
@ -28,7 +28,10 @@ class GUIHandler : IUserInterface {
|
||||
set(value) {
|
||||
optionalSelectedLatch.countDown()
|
||||
field = value
|
||||
cancelCallback?.invoke()
|
||||
}
|
||||
@Volatile
|
||||
override var cancelCallback: (() -> Unit)? = null
|
||||
var okButtonPressed = false
|
||||
set(value) {
|
||||
optionalSelectedLatch.countDown()
|
||||
|
@ -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) }
|
||||
}
|
@ -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()
|
||||
}
|
@ -13,5 +13,7 @@
|
||||
-keep class com.google.gson.reflect.TypeToken { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
|
||||
-keep @interface kotlin.Metadata { *; }
|
||||
|
||||
-renamesourcefileattribute SourceFile
|
||||
-keepattributes *Annotation*,SourceFile,LineNumberTable,Signature
|
||||
|
@ -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))
|
||||
- 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.
|
||||
- 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.
|
||||
- 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))
|
||||
- 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
|
||||
- toml4j 0.7.2: MIT ([Source](https://github.com/mwanji/toml4j))
|
||||
- Copyright (c) 2013-2015 Moandji Ezana
|
||||
- kotlin-result 1.1.14: ISC ([Source](https://github.com/michaelbull/kotlin-result))
|
||||
- Copyright (c) 2017-2022 Michael Bull (https://www.michael-bull.com)
|
||||
- 4koma 1.1.0: MIT ([Source](https://github.com/valderman/4koma))
|
||||
- Copyright (c) 2021 Anton Ekblad
|
||||
|
||||
## Associated notices
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user