Compare commits

..

17 Commits

Author SHA1 Message Date
comp500
73d21a475a Fix phantom state of opted-out files made non-optional 2022-05-23 00:55:58 +01:00
comp500
7568770078 Fix optional button waiting when there are no optional mods 2022-05-23 00:38:57 +01:00
comp500
3d1d6db9b4 Update licenses 2022-05-22 21:44:22 +01:00
comp500
c6e304bc7f Add support for mode field, with CurseForge metadata lookup
Now always asks the user before proceeding past the point where optional mods could be selected and configured
When updating files, the hash is checked so an update isn't redownloaded if it already exists
Added DevMain file for running in a dev environment
2022-05-22 21:20:52 +01:00
comp500
92d6f68f1d Always use UTF-8 for reading TOML files (fixes #22) 2022-05-11 17:45:39 +01:00
comp500
07af6046c1 Rework target into interface; add overwrite mode and validity/identity tokens 2022-03-06 21:28:27 +00:00
comp500
89bdfd9c98 WIP task system with lazy evaluation 2022-02-21 22:15:10 +00:00
comp500
f4dd4fa866 Implement new abstraction for file paths
Once integrated with the rest of the installer, this should fix many directory traversal and path encoding issues
2022-02-21 22:15:10 +00:00
comp500
6db8422c87 Add source link, update report issue link 2022-02-21 22:15:10 +00:00
comp500
7d6346c088 Shade and relocate Commons CLI, update to 1.5.0 2022-02-21 22:15:10 +00:00
comp500
aff921f67e Update dependencies, fix build with Java 9+ 2022-02-21 22:15:10 +00:00
comp500
afb574d82d Remove unused client/server fields 2022-02-21 22:15:10 +00:00
comp500
8635906b1c Add maven publishing target 2022-02-21 22:15:10 +00:00
comp500
bf95f03a18 Start internal rewrite of file download system 2022-02-21 22:15:10 +00:00
comp500
bca2d758e1 Fix SpaceSafeURI nullability issues 2022-02-21 22:03:56 +00:00
comp500
46771ce870 Clarify error message for missing index file 2021-07-16 04:07:45 +01:00
comp500
b143f67acd Fix symlink check by catching the correct exception 2021-06-22 13:54:06 +01:00
42 changed files with 1189 additions and 161 deletions

View File

@@ -1,9 +1,9 @@
buildscript {
repositories {
jcenter()
mavenCentral()
}
dependencies {
classpath("com.guardsquare:proguard-gradle:7.0.0") {
classpath("com.guardsquare:proguard-gradle:7.1.0") {
exclude("com.android.tools.build")
}
}
@@ -12,30 +12,30 @@ buildscript {
plugins {
java
application
id("com.github.johnrengelman.shadow") version "6.1.0"
id("com.palantir.git-version") version "0.12.3"
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.4.21"
id("com.github.jk1.dependency-license-report") version "1.16"
kotlin("jvm") version "1.6.10"
id("com.github.jk1.dependency-license-report") version "2.0"
`maven-publish`
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
}
val shrinkClasspath: Configuration by configurations.creating
dependencies {
implementation("commons-cli:commons-cli:1.4")
shrinkClasspath("commons-cli:commons-cli:1.4")
implementation("com.moandjiezana.toml:toml4j:0.7.2")
implementation("com.google.code.gson:gson:2.8.1")
implementation("com.squareup.okio:okio:2.9.0")
implementation(kotlin("stdlib-jdk8"))
repositories {
mavenCentral()
}
repositories {
jcenter()
dependencies {
implementation("commons-cli:commons-cli:1.5.0")
implementation("com.moandjiezana.toml:toml4j:0.7.2")
implementation("com.google.code.gson:gson:2.8.9")
implementation("com.squareup.okio:okio:3.0.0")
implementation(kotlin("stdlib-jdk8"))
implementation("com.squareup.okhttp3:okhttp:4.9.3")
implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.14")
}
application {
@@ -59,41 +59,47 @@ licenseReport {
filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer())
}
// TODO: build relocated jar for minecraft launcher lib, non-relocated jar for packwiz-installer
//tasks.register<com.github.jengelman.gradle.plugins.shadow.tasks.ConfigureShadowRelocation>("relocateShadowJar") {
// target = tasks.shadowJar.get()
// prefix = "link.infra.packwiz.deps"
//}
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
tasks.shadowJar {
dependencies {
exclude(dependency("commons-cli:commons-cli:1.4"))
exclude(dependency("com.eclipsesource.minimal-json:minimal-json:0.9.5"))
// TODO: exclude meta inf files
}
exclude("**/*.kotlin_metadata")
exclude("**/*.kotlin_builtins")
exclude("META-INF/maven/**/*")
exclude("META-INF/proguard/**/*")
//dependsOn(tasks.named("relocateShadowJar"))
// Relocate Commons CLI, so that it doesn't clash with old packwiz-installer-bootstrap (that shades it)
relocate("org.apache.commons.cli", "link.infra.packwiz.installer.deps.commons-cli")
// from Commons CLI
exclude("META-INF/LICENSE.txt")
exclude("META-INF/NOTICE.txt")
}
tasks.register<proguard.gradle.ProGuardTask>("shrinkJar") {
injars(tasks.shadowJar)
libraryjars(files(shrinkClasspath.files))
outjars("build/libs/" + tasks.shadowJar.get().outputs.files.first().name.removeSuffix(".jar") + "-shrink.jar")
if (System.getProperty("java.version").startsWith("1.")) {
libraryjars("${System.getProperty("java.home")}/lib/rt.jar")
libraryjars("${System.getProperty("java.home")}/lib/jce.jar")
} else {
throw RuntimeException("Compiling with Java 9+ not supported!")
// Use jmods for Java 9+
val mods = listOf("java.base", "java.logging", "java.desktop", "java.sql")
for (mod in mods) {
libraryjars(mapOf(
"jarfilter" to "!**.jar",
"filter" to "!module-info.class"
), "${System.getProperty("java.home")}/jmods/$mod.jmod")
}
}
keep("class link.infra.packwiz.installer.** { *; }")
dontoptimize()
dontobfuscate()
// Used by Okio and OkHttp
dontwarn("org.codehaus.mojo.animal_sniffer.*")
dontwarn("okhttp3.internal.platform.**")
dontwarn("org.conscrypt.**")
dontwarn("org.bouncycastle.**")
dontwarn("org.openjsse.**")
}
// Used for vscode launch.json
@@ -126,12 +132,38 @@ if (project.hasProperty("github.token")) {
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=enable")
freeCompilerArgs = listOf("-Xjvm-default=enable", "-Xallow-result-return-type", "-Xopt-in=kotlin.io.path.ExperimentalPathApi")
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=enable")
freeCompilerArgs = listOf("-Xjvm-default=enable", "-Xallow-result-return-type", "-Xopt-in=kotlin.io.path.ExperimentalPathApi")
}
}
if (project.hasProperty("bunnycdn.token")) {
publishing {
publications {
create<MavenPublication>("maven") {
groupId = "link.infra.packwiz"
artifactId = "packwiz-installer"
from(components["java"])
}
}
repositories {
maven {
url = uri("https://storage.bunnycdn.com/comp-maven")
credentials(HttpHeaderCredentials::class) {
name = "AccessKey"
value = findProperty("bunnycdn.token") as String?
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -8,8 +8,6 @@ public class RequiresBootstrap {
public static void main(String[] args) {
// Very small CLI implementation, because Commons CLI complains on unexpected
// options
// Also so that Commons CLI can be excluded from the shaded JAR, as it is
// included in the bootstrap
if (Arrays.stream(args).map(str -> {
if (str == null) return "";
if (str.startsWith("--")) {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
package link.infra.packwiz.installer
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.ui.cli.CLIHandler
import link.infra.packwiz.installer.ui.gui.GUIHandler
import link.infra.packwiz.installer.util.Log
@@ -68,7 +69,7 @@ class Main(args: Array<String>) {
val uOptions = try {
UpdateManager.Options.construct(
downloadURI = SpaceSafeURI(unparsedArgs[0]),
side = cmd.getOptionValue("side")?.let((UpdateManager.Options.Side)::from),
side = cmd.getOptionValue("side")?.let((Side)::from),
packFolder = cmd.getOptionValue("pack-folder"),
manifestFile = cmd.getOptionValue("meta-file")
)

View File

@@ -3,33 +3,29 @@ package link.infra.packwiz.installer
import com.google.gson.GsonBuilder
import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException
import com.google.gson.annotations.SerializedName
import com.moandjiezana.toml.Toml
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex
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.target.Side
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 link.infra.packwiz.installer.util.ifletOrWarn
import okio.buffer
import java.io.FileNotFoundException
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
import java.io.*
import java.nio.file.Files
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.CompletionService
import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorCompletionService
@@ -57,56 +53,6 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
Options(downloadURI, manifestFile ?: "packwiz.json", packFolder ?: ".", side ?: Side.CLIENT)
}
enum class Side {
@SerializedName("client")
CLIENT("client"),
@SerializedName("server")
SERVER("server"),
@SerializedName("both")
@Suppress("unused")
BOTH("both", arrayOf(CLIENT, SERVER));
private val sideName: String
private val depSides: Array<Side>?
constructor(sideName: String) {
this.sideName = sideName.toLowerCase()
depSides = null
}
constructor(sideName: String, depSides: Array<Side>) {
this.sideName = sideName.toLowerCase()
this.depSides = depSides
}
override fun toString() = sideName
fun hasSide(tSide: Side): Boolean {
if (this == tSide) {
return true
}
if (depSides != null) {
for (depSide in depSides) {
if (depSide == tSide) {
return true
}
}
}
return false
}
companion object {
fun from(name: String): Side? {
val lowerName = name.toLowerCase()
for (side in values()) {
if (side.sideName == lowerName) {
return side
}
}
return null
}
}
}
}
private fun start() {
@@ -140,7 +86,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
val pf = packFileSource.buffer().use {
try {
Toml().read(it.inputStream()).to(PackFile::class.java)
Toml().read(InputStreamReader(it.inputStream(), "UTF-8")).to(PackFile::class.java)
} catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse pack.toml", e)
}
@@ -180,8 +126,11 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
Log.info("Modpack is already up to date!")
// todo: --force?
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false)
}
if (!ui.optionsButtonPressed) {
return
}
@@ -194,7 +143,8 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
handleCancellation()
}
try {
ifletOrWarn(pf.index, "No index file found") { index ->
// 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(
@@ -237,10 +187,17 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
Log.info("Modpack files are already up to date!")
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false)
}
if (!ui.optionsButtonPressed) {
return
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
manifest.indexFileHash = indexHash
@@ -252,7 +209,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
val indexFile = try {
Toml().read(indexFileSource.buffer().inputStream()).to(IndexFile::class.java)
Toml().read(InputStreamReader(indexFileSource.buffer().inputStream(), "UTF-8")).to(IndexFile::class.java)
} catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse index file", e)
}
@@ -359,8 +316,20 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: task failed function?
val nonFailedFirstTasks = tasks.filter { t -> !t.failed() }.toList()
val optionTasks = nonFailedFirstTasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
val optionsChanged = optionTasks.any(DownloadTask::isNewOptional)
if (optionTasks.isNotEmpty() && !optionsChanged) {
if (!ui.optionsButtonPressed) {
// TODO: this is so ugly
ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1))
ui.awaitOptionalButton(true)
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
}
// If options changed, present all options again
if (ui.optionsButtonPressed || optionTasks.any(DownloadTask::isNewOptional)) {
if (ui.optionsButtonPressed || optionsChanged) {
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
if (ui.showOptions(ArrayList(optionTasks))) {
cancelled = true
@@ -370,6 +339,38 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: keep this enabled? then apply changes after download process?
ui.disableOptionsButton(optionTasks.isNotEmpty())
ui.submitProgress(InstallProgress("Validating existing files..."))
// Validate existing files
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
downloadTask.validateExistingFile(opts.packFolder)
}
// Resolve CurseForge metadata
val cfFiles = nonFailedFirstTasks.asSequence().filter { !it.alreadyUpToDate }
.filter(DownloadTask::correctSide)
.map { it.metadata }
.filter { it.linkedFile != null }
.filter { it.linkedFile?.download?.mode == "metadata:curseforge" }.toList()
if (cfFiles.isNotEmpty()) {
ui.submitProgress(InstallProgress("Resolving CurseForge metadata..."))
val resolveFailures = resolveCfMetadata(cfFiles)
if (resolveFailures.isNotEmpty()) {
errorsOccurred = true
when (ui.showExceptions(resolveFailures, cfFiles.size, true)) {
ExceptionListResult.CONTINUE -> {}
ExceptionListResult.CANCEL -> {
cancelled = true
return
}
ExceptionListResult.IGNORE -> {
cancelledStartGame = true
return
}
}
}
}
// TODO: different thread pool type?
val threadPool = Executors.newFixedThreadPool(10)
val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool)
@@ -445,4 +446,5 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
exitProcess(0)
}
}
}

View File

@@ -9,6 +9,7 @@ import link.infra.packwiz.installer.request.HandlerManager.getFileSource
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
import okio.Source
import okio.buffer
import java.io.InputStreamReader
import java.nio.file.Paths
class IndexFile {
@@ -43,7 +44,7 @@ class IndexFile {
linkedFileURI = getNewLoc(indexUri, file)
val src = getFileSource(linkedFileURI!!)
val fileStream = getHasher(hashFormat!!).getHashingSource(src)
linkedFile = Toml().read(fileStream.buffer().inputStream()).to(ModFile::class.java)
linkedFile = Toml().read(InputStreamReader(fileStream.buffer().inputStream(), "UTF-8")).to(ModFile::class.java)
if (!fileStream.hashIsEqual(fileHash)) {
throw Exception("Invalid mod file hash")
}
@@ -78,9 +79,9 @@ class IndexFile {
get() {
if (metafile) {
return linkedFile?.name ?: linkedFile?.filename ?:
file?.run { Paths.get(path).fileName.toString() } ?: "Invalid file"
file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
}
return file?.run { Paths.get(path).fileName.toString() } ?: "Invalid file"
return file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
}
// TODO: URIs are bad

View File

@@ -1,15 +1,15 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import link.infra.packwiz.installer.UpdateManager
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.target.Side
class ManifestFile {
var packFileHash: Hash? = null
var indexFileHash: Hash? = null
var cachedFiles: MutableMap<SpaceSafeURI, File> = HashMap()
// If the side changes, EVERYTHING invalidates. FUN!!!
var cachedSide = UpdateManager.Options.Side.CLIENT
var cachedSide = Side.CLIENT
// TODO: switch to Kotlin-friendly JSON/TOML libs?
class File {

View File

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

View File

@@ -14,6 +14,4 @@ class PackFile {
}
var versions: Map<String, String>? = null
var client: Map<String, Any>? = null
var server: Map<String, Any>? = null
}

View File

@@ -32,7 +32,7 @@ class SpaceSafeURI : Comparable<SpaceSafeURI>, Serializable {
)
}
val path: String get() = u.path.replace("%20", " ")
val path: String? get() = u.path?.replace("%20", " ")
override fun toString(): String = u.toString().replace("%20", " ")
@@ -52,9 +52,9 @@ class SpaceSafeURI : Comparable<SpaceSafeURI>, Serializable {
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
val scheme: String? get() = u.scheme
val authority: String? get() = u.authority
val host: String? get() = u.host
@Throws(MalformedURLException::class)
fun toURL(): URL = u.toURL()

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
package link.infra.packwiz.installer.request
import okhttp3.Response
import okio.IOException
sealed class RequestException: Exception {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
/**
* Internal errors that should not be shown to the user when the code is correct
*/
sealed class Internal: RequestException {
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 IllegalState(cause: IllegalStateException): HTTP("Internal fatal HTTP request error", cause)
}
}
/**
* Errors indicating that the request is malformed
*/
sealed class Validation: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
// TODO: move out of RequestException?
class PathContainsNUL(path: String): Validation("Invalid path; contains NUL bytes: ${path.replace("\u0000", "")}")
class PathContainsVolumeLetter(path: String): Validation("Invalid path; contains volume letter: $path")
}
/**
* Errors relating to the response from the server
*/
sealed class Response: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
// TODO: fancier way of displaying this?
sealed class HTTP: Response {
val response: okhttp3.Response
constructor(response: okhttp3.Response, message: String, cause: Throwable) : super(message, cause) {
this.response = response
}
constructor(response: okhttp3.Response, message: String) : super(message) {
this.response = response
}
class ErrorCode(res: okhttp3.Response): HTTP(res, "Non-successful error code from HTTP request: ${res.code}")
}
sealed class File: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
class FileNotFound(file: String): File("File path not found: $file")
class Other(cause: Throwable): File("Failed to read file", cause)
}
}
}

View File

@@ -1,7 +1,6 @@
package link.infra.packwiz.installer.request.handlers
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import java.util.*
import java.util.concurrent.locks.ReentrantReadWriteLock
import java.util.regex.Pattern
import kotlin.concurrent.read
@@ -21,7 +20,7 @@ class RequestHandlerGithub : RequestHandlerZip(true) {
private val zipUriMap: MutableMap<String, SpaceSafeURI> = HashMap()
private val zipUriLock = ReentrantReadWriteLock()
private fun getRepoName(loc: SpaceSafeURI): String? {
val matcher = repoMatcherPattern.matcher(loc.path)
val matcher = repoMatcherPattern.matcher(loc.path ?: return null)
return if (matcher.matches()) {
matcher.group(1)
} else {
@@ -47,7 +46,7 @@ class RequestHandlerGithub : RequestHandlerZip(true) {
}
private fun getBranch(loc: SpaceSafeURI): String? {
val matcher = branchMatcherPattern.matcher(loc.path)
val matcher = branchMatcherPattern.matcher(loc.path ?: return null)
return if (matcher.matches()) {
matcher.group(1)
} else {
@@ -66,6 +65,6 @@ class RequestHandlerGithub : RequestHandlerZip(true) {
return false
}
// TODO: more match testing?
return "github.com" == loc.host && branchMatcherPattern.matcher(loc.path).matches()
return "github.com" == loc.host && branchMatcherPattern.matcher(loc.path ?: return false).matches()
}
}

View File

@@ -0,0 +1,23 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.metadata.hash.Hash
import java.nio.file.Path
data class CachedTarget(
/**
* @see Target.name
*/
val name: String,
/**
* The location where the target was last downloaded to.
* This is used for removing old files when the destination path changes.
* This shouldn't be set to the .disabled path (that is manually appended and checked)
*/
val cachedLocation: Path,
val enabled: Boolean,
val hash: Hash,
/**
* For detecting when a target transitions non-optional -> optional and showing the option selection screen
*/
val isOptional: Boolean
)

View File

@@ -0,0 +1,95 @@
package link.infra.packwiz.installer.target
import java.io.IOException
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import kotlin.io.path.relativeTo
data class CachedTargetStatus(val target: CachedTarget, var isValid: Boolean, var markDisabled: Boolean)
fun validate(targets: List<CachedTarget>, baseDir: Path) = runCatching {
val results = targets.map {
CachedTargetStatus(it, isValid = false, markDisabled = false)
}
val tree = buildTree(results, baseDir)
// Efficient file exists checking using directory listing, several orders of magnitude faster than Files.exists calls
Files.walkFileTree(baseDir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, object : FileVisitor<Path> {
var currentNode: PathNode<CachedTargetStatus> = tree
override fun preVisitDirectory(dir: Path?, attrs: BasicFileAttributes?): FileVisitResult {
if (dir == null) {
return FileVisitResult.SKIP_SUBTREE
}
val subdirNode = currentNode.subdirs[dir.getName(dir.nameCount - 1)]
return if (subdirNode != null) {
currentNode = subdirNode
FileVisitResult.CONTINUE
} else {
FileVisitResult.SKIP_SUBTREE
}
}
override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult {
if (file == null) {
return FileVisitResult.CONTINUE
}
// TODO: these are relative paths to baseDir
// TODO: strip the .disabled for lookup
val target = currentNode.files[file.getName(file.nameCount - 1)]
if (target != null) {
val disabledFile = file.endsWith(".disabled")
// If a .disabled file and the actual file both exist, mark as invalid if the target is disabled
if ((disabledFile )) {
}
}
return FileVisitResult.CONTINUE
}
@Throws(IOException::class)
override fun visitFileFailed(file: Path?, exc: IOException?): FileVisitResult {
if (exc != null) {
throw exc
}
throw IOException("visitFileFailed called with no exception")
}
@Throws(IOException::class)
override fun postVisitDirectory(dir: Path?, exc: IOException?): FileVisitResult {
if (exc != null) {
throw exc
} else {
val parent = currentNode.parent
if (parent != null) {
currentNode = parent
} else {
throw IOException("Invalid visitor tree structure")
}
return FileVisitResult.CONTINUE
}
}
})
results
}
fun buildTree(targets: List<CachedTargetStatus>, baseDir: Path): PathNode<CachedTargetStatus> {
val root = PathNode<CachedTargetStatus>()
for (target in targets) {
val relPath = target.target.cachedLocation.relativeTo(baseDir)
var node = root
// Traverse all the directory components, except for the last one
for (i in 0 until (relPath.nameCount - 1)) {
node = node.createSubdir(relPath.getName(i))
}
node.files[relPath.getName(relPath.nameCount - 1)] = target
}
return root
}
data class PathNode<T>(val subdirs: MutableMap<Path, PathNode<T>>, val files: MutableMap<Path, T>, val parent: PathNode<T>?) {
constructor() : this(mutableMapOf(), mutableMapOf(), null)
fun createSubdir(nextComponent: Path) = subdirs.getOrPut(nextComponent, { PathNode(mutableMapOf(), mutableMapOf(), this) })
}

View File

@@ -0,0 +1,12 @@
package link.infra.packwiz.installer.target
import okhttp3.OkHttpClient
import okio.FileSystem
class ClientHolder {
// TODO: timeouts?
// TODO: a button to increase timeouts temporarily when retrying?
val okHttpClient by lazy { OkHttpClient.Builder().build() }
val fileSystem = FileSystem.SYSTEM
}

View File

@@ -0,0 +1,13 @@
package link.infra.packwiz.installer.target
enum class OverwriteMode {
/**
* Overwrite the destination with the source file, if the source file has changed.
*/
IF_SRC_CHANGED,
/**
* Never overwrite the destination; if it exists, it should not be written to.
*/
NEVER
}

View File

@@ -0,0 +1,54 @@
package link.infra.packwiz.installer.target
import com.google.gson.annotations.SerializedName
enum class Side {
@SerializedName("client")
CLIENT("client"),
@SerializedName("server")
SERVER("server"),
@SerializedName("both")
@Suppress("unused")
BOTH("both", arrayOf(CLIENT, SERVER));
private val sideName: String
private val depSides: Array<Side>?
constructor(sideName: String) {
this.sideName = sideName.toLowerCase()
depSides = null
}
constructor(sideName: String, depSides: Array<Side>) {
this.sideName = sideName.toLowerCase()
this.depSides = depSides
}
override fun toString() = sideName
fun hasSide(tSide: Side): Boolean {
if (this == tSide) {
return true
}
if (depSides != null) {
for (depSide in depSides) {
if (depSide == tSide) {
return true
}
}
}
return false
}
companion object {
fun from(name: String): Side? {
val lowerName = name.toLowerCase()
for (side in values()) {
if (side.sideName == lowerName) {
return side
}
}
return null
}
}
}

View File

@@ -0,0 +1,45 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.target.path.PackwizPath
// TODO: rename to avoid conflicting with @Target
interface Target {
val src: PackwizPath
val dest: PackwizPath
val validityToken: ValidityToken
/**
* Token interface for types used to compare Target identity. Implementations should use equals to indicate that
* these tokens represent the same file; used to preserve optional target choices between file renames.
*/
interface IdentityToken
/**
* Default implementation of IdentityToken that assumes files are not renamed; so optional choices do not need to
* be preserved across renames.
*/
@JvmInline
value class PathIdentityToken(val path: String): IdentityToken
val ident: IdentityToken
get() = PathIdentityToken(dest.path)
/**
* A user-friendly name; defaults to the destination path of the file.
*/
val name: String
get() = dest.path
val side: Side
get() = Side.BOTH
val overwriteMode: OverwriteMode
get() = OverwriteMode.IF_SRC_CHANGED
interface Optional: Target {
val optional: Boolean
val optionalDefaultValue: Boolean
}
interface OptionalDescribed: Optional {
val optionalDescription: String
}
}

View File

@@ -0,0 +1,16 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.metadata.hash.Hash
/**
* A token used to determine if the source or destination file is changed, and ensure that the destination file is valid.
*/
interface ValidityToken {
// TODO: functions to allow validating this from file metadata, from file, or during the download process
/**
* Default implementation of ValidityToken based on a single hash.
*/
@JvmInline
value class HashValidityToken(val hash: Hash): ValidityToken
}

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
package link.infra.packwiz.installer.target.path
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
init {
this.base = base
// 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
}
}
// 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("/")
}
val folder: Boolean get() = path.endsWith("/")
fun resolve(path: String): PackwizPath {
return if (path.startsWith('/') || path.startsWith('\\')) {
// Absolute (but still relative to base of pack)
PackwizPath(path, base)
} else if (folder) {
// File in folder; append
PackwizPath(this.path + path, base)
} else {
// File in parent folder; append with parent component
PackwizPath(this.path + "/../" + path, base)
}
}
/**
* 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
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
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 toString(): String {
return "base=$base; $path"
}
}

View File

@@ -0,0 +1,3 @@
package link.infra.packwiz.installer.task
data class CacheKey<T>(val key: String, val version: Int)

View File

@@ -0,0 +1,22 @@
package link.infra.packwiz.installer.task
import kotlin.reflect.KProperty
class CacheManager {
class CacheValue<T> {
operator fun getValue(thisVal: Any?, property: KProperty<*>): T {
TODO("Not yet implemented")
}
operator fun setValue(thisVal: Any?, property: KProperty<*>, value: T) {
TODO("Not yet implemented")
}
}
operator fun <T> get(cacheKey: CacheKey<T>): CacheValue<T> {
return CacheValue()
}
}

View File

@@ -0,0 +1,20 @@
package link.infra.packwiz.installer.task
import kotlin.reflect.KMutableProperty0
// TODO: task processing on 1 background thread; actual resolving of values calls out to a thread group
// TODO: progress bar is updated from each of these tasks
// TODO: have everything be lazy so there's no need to determine task ordering upfront? a bit like rust async - task results must be queried to occur
abstract class Task<T>(protected val ctx: TaskContext): TaskInput<T> {
// TODO: lazy wrapper for fallible results
// TODO: multithreaded fanout subclass/helper
protected fun <T> wasUpdated(value: KMutableProperty0<T>, newValue: T): Boolean {
if (value.get() == newValue) {
return false
}
value.set(newValue)
return true
}
}

View File

@@ -0,0 +1,6 @@
package link.infra.packwiz.installer.task
/**
* An object for storing results where result and upToDate are calculated simultaneously
*/
data class TaskCombinedResult<T>(val result: T, val upToDate: Boolean)

View File

@@ -0,0 +1,12 @@
package link.infra.packwiz.installer.task
import link.infra.packwiz.installer.target.ClientHolder
class TaskContext {
// TODO: thread pools, protocol roots
// TODO: cache management
val cache = CacheManager()
val clients = ClientHolder()
}

View File

@@ -0,0 +1,29 @@
package link.infra.packwiz.installer.task
import kotlin.reflect.KProperty
interface TaskInput<T> {
/**
* The value of this task input. May be lazily evaluated; must be threadsafe.
*/
val value: T
/**
* True if the effective value of this input has changed since the task was last run.
* Doesn't require evaluation of the input value; should use cached data if possible.
* May be lazily evaluated; must be threadsafe.
*/
val upToDate: Boolean
operator fun getValue(thisVal: Any?, property: KProperty<*>): T = value
companion object {
fun <T> raw(value: T): TaskInput<T> {
return object: TaskInput<T> {
override val value = value
override val upToDate: Boolean
get() = false
}
}
}
}

View File

@@ -0,0 +1,6 @@
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)

View File

@@ -0,0 +1,48 @@
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.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) {
// 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 class PackFile {
var name: String? = null
var index: IndexFileLoc? = null
class IndexFileLoc {
var file: String? = null
@SerializedName("hash-format")
var hashFormat: String? = null
var hash: String? = null
}
var versions: Map<String, String>? = null
}
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 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"))
)
val hash = HashUtils.getHash("sha256", "whatever was just read")
TaskCombinedResult(resolved, wasUpdated(::cache, hash))
}
override val value by internalResult::result
override val upToDate by internalResult::upToDate
}

View File

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

View File

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

View File

@@ -125,7 +125,7 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
addActionListener {
try {
Desktop.getDesktop().browse(URI("https://github.com/comp500/packwiz-installer/issues/new"))
Desktop.getDesktop().browse(URI("https://github.com/packwiz/packwiz-installer/issues/new"))
} catch (e: IOException) {
// lol the button just won't work i guess
} catch (e: URISyntaxException) {}

View File

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

View File

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

View File

@@ -1,14 +1,22 @@
# Licenses
packwiz-installer itself is under the MIT license, except for Murmur2Lib and bundled dependencies as follows:
packwiz-installer itself is under the MIT license ([Source](https://github.com/packwiz/packwiz-installer)), except for Murmur2Lib and bundled dependencies as follows:
- Murmur2Lib: Apache 2.0 ([Source](https://github.com/prasanthj/hasher/blob/master/src/main/java/hasher/Murmur2.java))
- Google Gson 2.8.1: Apache 2.0 ([Source](https://github.com/google/gson))
- Okio 2.9.0: Apache 2.0 ([Source](https://github.com/square/okio/))
- Commons CLI 1.4: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/))
- Copyright 2014 Prasanth Jayachandran
- Google Gson 2.8.9: 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/))
- 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))
- Kotlin Standard Library 1.4.21: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin))
- Copyright 2000-2016 JetBrains s.r.o.
- Kotlin Standard Library 1.6.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)
## Associated notices
@@ -22,10 +30,6 @@ The Apache Software Foundation (http://www.apache.org/).
## Full license texts
### MIT
MIT License
Copyright (c)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
@@ -248,3 +252,15 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
### ISC
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.