Compare commits

..

44 Commits

Author SHA1 Message Date
comp500
c2ee6fca8b Improve handling of manual downloads; "Continue" now rechecks files 2022-05-23 17:38:01 +01:00
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
comp500
03b0f1b09b Fix disable options button always changing to No optional mods... 2021-02-16 16:26:23 +00:00
comp500
6c6a0100fd Bundle licenses, disable relocation for now 2021-02-16 16:19:16 +00:00
comp500
6d47c0d61f Relocate shadow deps 2021-02-07 14:02:10 +00:00
comp500
226e754547 Add SHA1 support (see https://github.com/comp500/packwiz/pull/11) 2021-01-29 06:50:50 +00:00
comp500
2c02703101 Ask the user if they want to continue launching, if update/install fails 2020-12-15 20:24:49 +00:00
comp500
81a60cc759 Remove kotlinx.serialization gradle plugin as I didn't end up using it 2020-12-15 19:43:30 +00:00
comp500
92afa93fd7 Update deps, shrink fatjar (no obf) with proguard 2020-12-15 19:40:18 +00:00
comp500
0858c90079 Rework error handling to be more robust 2020-12-15 17:28:23 +00:00
comp500
1d4c94f5b6 Make the main window unresizable, to work better with tiling window managers 2020-12-13 17:00:35 +00:00
comp500
74ddca5d54 Remove unnecessary Futures from IUserInterface API 2020-12-13 16:57:12 +00:00
comp500
0df48d19a9 Separate IUserInterface logic out into GUIHandler 2020-12-13 16:34:00 +00:00
comp500
f5b22f37a4 Refactor GUI code, remove bad SwingWorker junk 2020-12-13 16:12:44 +00:00
comp500
f52cd19ad4 Show download exceptions properly in CLI 2020-12-11 18:18:10 +00:00
comp500
60887a4312 Whoops 2020-12-07 17:42:52 +00:00
comp500
a368268038 Fix support for symlinked directories 2020-12-07 17:38:21 +00:00
comp500
8beded7b41 Improve UX when there are no optional mods 2020-12-06 19:05:56 +00:00
comp500
91060dcd54 Put an error message there. Later is now! 2020-11-30 00:24:47 +00:00
comp500
e06ee21f3b Add User-Agent to download requests 2020-10-22 20:53:36 +01:00
comp500
b3370739a5 Fix Swing multithreading issue, clean up slightly 2020-09-29 02:14:56 +01:00
comp500
ecc6f0440a Remove IntelliJ metadata from repo 2020-09-29 02:14:05 +01:00
comp500
92b44352b3 Fix RequestHandlerGithub heuristics, so that Github Releases files work properly 2020-06-20 03:15:18 +01:00
comp500
1d5a787b02 Add JvmStatic to fix --help command (bootstrapper calls these) 2020-06-16 04:05:02 +01:00
comp500
b5983800e8 Update README.md 2020-05-11 17:48:41 +01:00
comp500
4b3c279e71 Add support for loading from file:// URIs 2020-05-08 22:57:03 +01:00
comp500
b413371306 Fix --help command 2020-05-08 18:08:53 +01:00
comp500
1d2ec61232 Fix disgusting getNewLoc call (!! already checks null!!) 2020-02-07 03:12:52 +00:00
67 changed files with 2063 additions and 729 deletions

30
.gitignore vendored
View File

@@ -1,9 +1,9 @@
# Created by https://www.gitignore.io/api/java,gradle,intellij
# Edit at https://www.gitignore.io/?templates=java,gradle,intellij
# Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij+all
# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij+all
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
@@ -33,6 +33,9 @@
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
@@ -72,13 +75,18 @@ fabric.properties
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
### Intellij+all Patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
@@ -127,4 +135,4 @@ gradle-app.setting
### Gradle Patch ###
**/build/
# End of https://www.gitignore.io/api/java,gradle,intellij
# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij+all

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@@ -1,36 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="JavaDoc" enabled="true" level="WARNING" enabled_by_default="true">
<option name="TOP_LEVEL_CLASS_OPTIONS">
<value>
<option name="ACCESS_JAVADOC_REQUIRED_FOR" value="none" />
<option name="REQUIRED_TAGS" value="" />
</value>
</option>
<option name="INNER_CLASS_OPTIONS">
<value>
<option name="ACCESS_JAVADOC_REQUIRED_FOR" value="none" />
<option name="REQUIRED_TAGS" value="" />
</value>
</option>
<option name="METHOD_OPTIONS">
<value>
<option name="ACCESS_JAVADOC_REQUIRED_FOR" value="none" />
<option name="REQUIRED_TAGS" value="@return@param@throws or @exception" />
</value>
</option>
<option name="FIELD_OPTIONS">
<value>
<option name="ACCESS_JAVADOC_REQUIRED_FOR" value="none" />
<option name="REQUIRED_TAGS" value="" />
</value>
</option>
<option name="IGNORE_DEPRECATED" value="false" />
<option name="IGNORE_JAVADOC_PERIOD" value="true" />
<option name="IGNORE_DUPLICATED_THROWS" value="false" />
<option name="IGNORE_POINT_TO_ITSELF" value="false" />
<option name="myAdditionalJavadocTags" value="wbp.parser.entryPoint" />
</inspection_tool>
</profile>
</component>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
</component>
</project>

10
.idea/misc.xml generated
View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EntryPointsManager">
<list size="1">
<item index="0" class="java.lang.String" itemvalue="com.google.gson.annotations.SerializedName" />
</list>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="false" project-jdk-name="11" project-jdk-type="JavaSDK" />
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019
Copyright (c) 2021 comp500
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,2 +1,2 @@
# packwiz-installer
An installer for launching packwiz modpacks with MultiMC.
An installer for launching packwiz modpacks with MultiMC. You'll need [the bootstrapper](https://github.com/comp500/packwiz-installer-bootstrap/releases) to actually use this.

View File

@@ -1,28 +1,41 @@
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("com.guardsquare:proguard-gradle:7.1.0") {
exclude("com.android.tools.build")
}
}
}
plugins {
java
application
id("com.github.johnrengelman.shadow") version "5.0.0"
id("com.palantir.git-version") version "0.11.0"
id("com.github.breadmoirai.github-release") version "2.2.9"
kotlin("jvm") version "1.3.61"
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.jk1.dependency-license-report") version "2.0"
`maven-publish`
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
implementation("commons-cli:commons-cli:1.4")
implementation("com.moandjiezana.toml:toml4j:0.7.2")
// TODO: Implement tests
//testImplementation "junit:junit:4.12"
implementation("com.google.code.gson:gson:2.8.1")
implementation("com.squareup.okio:okio:2.2.2")
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 {
@@ -39,17 +52,59 @@ tasks.jar {
}
}
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
licenseReport {
renderers = arrayOf<com.github.jk1.license.render.ReportRenderer>(
com.github.jk1.license.render.InventoryMarkdownReportRenderer("licenses.md", "packwiz-installer")
)
filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer())
}
tasks.shadowJar {
dependencies {
exclude(dependency("commons-cli:commons-cli:1.4"))
exclude(dependency("com.eclipsesource.minimal-json:minimal-json:0.9.5"))
exclude("**/*.kotlin_metadata")
exclude("**/*.kotlin_builtins")
exclude("META-INF/maven/**/*")
exclude("META-INF/proguard/**/*")
// 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)
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 {
// 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
tasks.register<Copy>("copyJar") {
from(tasks.shadowJar)
from(tasks.named("shrinkJar"))
rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar")
into("build/libs/")
}
@@ -77,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-5.4.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,18 +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.ui.ExceptionDetails
import link.infra.packwiz.installer.ui.IOptionDetails
import okio.Buffer
import okio.HashingSink
import okio.buffer
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.*
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
@@ -25,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
@@ -122,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
// 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) {
// TODO: how much of a problem is this? use log4j/other log library to show warning?
e.printStackTrace()
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
@@ -152,6 +204,9 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
}
}
// TODO: if already exists and has correct hash, ignore?
// TODO: add .disabled support?
try {
val hash: Hash
val fileHashFormat: String
@@ -159,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)
@@ -176,16 +231,23 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
}
if (fileSource.hashIsEqual(hash)) {
Files.createDirectories(destPath.parent)
// isDirectory follows symlinks, but createDirectories doesn't
try {
Files.createDirectories(destPath.parent)
} catch (e: java.nio.file.FileAlreadyExistsException) {
if (!Files.isDirectory(destPath.parent)) {
throw e
}
}
Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING)
data.clear()
} else {
// TODO: no more PRINTLN!!!!!!!!!
// TODO: move println to something visible in the error window
println("Invalid hash for " + metadata.destURI.toString())
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!")
@@ -230,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): MutableList<DownloadTask> {
val tasks = ArrayList<DownloadTask>()
for (file in Objects.requireNonNull(index.files)) {
tasks.add(DownloadTask(file, defaultFormat, downloadSide))

View File

@@ -1,9 +1,12 @@
@file:JvmName("Main")
package link.infra.packwiz.installer
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.ui.CLIHandler
import link.infra.packwiz.installer.ui.InputStateHandler
import link.infra.packwiz.installer.ui.InstallWindow
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
import org.apache.commons.cli.DefaultParser
import org.apache.commons.cli.Options
import org.apache.commons.cli.ParseException
@@ -16,6 +19,9 @@ import kotlin.system.exitProcess
@Suppress("unused")
class Main(args: Array<String>) {
// Don't attempt to start a GUI if we are headless
private var guiEnabled = !GraphicsEnvironment.isHeadless()
private fun startup(args: Array<String>) {
val options = Options()
addNonBootstrapOptions(options)
@@ -25,65 +31,65 @@ class Main(args: Array<String>) {
val cmd = try {
parser.parse(options, args)
} catch (e: ParseException) {
e.printStackTrace()
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (e1: Exception) {
// Ignore the exceptions, just continue using the ugly L&F
Log.fatal("Failed to parse command line arguments", e)
if (guiEnabled) {
EventQueue.invokeAndWait {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (ignored: Exception) {
// Ignore the exceptions, just continue using the ugly L&F
}
JOptionPane.showMessageDialog(null, "Failed to parse command line arguments: $e",
"packwiz-installer", JOptionPane.ERROR_MESSAGE)
}
}
JOptionPane.showMessageDialog(null, e.message, "packwiz-installer", JOptionPane.ERROR_MESSAGE)
exitProcess(1)
}
// if "headless", GUI creation will fail anyway!
val ui = if (cmd.hasOption("no-gui") || GraphicsEnvironment.isHeadless()) {
CLIHandler()
} else InstallWindow()
if (guiEnabled && cmd.hasOption("no-gui")) {
guiEnabled = false
}
val ui = if (guiEnabled) GUIHandler() else CLIHandler()
val unparsedArgs = cmd.args
if (unparsedArgs.size > 1) {
ui.handleExceptionAndExit(RuntimeException("Too many arguments specified!"))
ui.showErrorAndExit("Too many arguments specified!")
} else if (unparsedArgs.isEmpty()) {
ui.handleExceptionAndExit(RuntimeException("URI to install from must be specified!"))
ui.showErrorAndExit("pack.toml URI to install from must be specified!")
}
cmd.getOptionValue("title")?.also {
ui.setTitle(it)
val title = cmd.getOptionValue("title")
if (title != null) {
ui.title = title
}
val inputStateHandler = InputStateHandler()
ui.show(inputStateHandler)
ui.show()
val uOptions = UpdateManager.Options().apply {
side = cmd.getOptionValue("side")?.let { UpdateManager.Options.Side.from(it) } ?: side
packFolder = cmd.getOptionValue("pack-folder") ?: packFolder
manifestFile = cmd.getOptionValue("meta-file") ?: manifestFile
}
try {
uOptions.downloadURI = SpaceSafeURI(unparsedArgs[0])
val uOptions = try {
UpdateManager.Options.construct(
downloadURI = SpaceSafeURI(unparsedArgs[0]),
side = cmd.getOptionValue("side")?.let((Side)::from),
packFolder = cmd.getOptionValue("pack-folder"),
manifestFile = cmd.getOptionValue("meta-file")
)
} catch (e: URISyntaxException) {
// TODO: better error message?
ui.handleExceptionAndExit(e)
ui.showErrorAndExit("Failed to read pack.toml URI", e)
}
// Start update process!
// TODO: start in SwingWorker?
try {
ui.executeManager {
try {
UpdateManager(uOptions, ui, inputStateHandler)
} catch (e: Exception) { // TODO: better error message?
ui.handleExceptionAndExit(e)
}
}
} catch (e: Exception) { // TODO: better error message?
ui.handleExceptionAndExit(e)
UpdateManager(uOptions, ui)
} catch (e: Exception) {
ui.showErrorAndExit("Update process failed", e)
}
println("Finished successfully!")
ui.dispose()
}
companion object {
// Called by packwiz-installer-bootstrap to set up the help command
@JvmStatic
fun addNonBootstrapOptions(options: Options) {
options.addOption("s", "side", true, "Side to install mods from (client/server, defaults to client)")
options.addOption(null, "title", true, "Title of the installer window")
@@ -92,6 +98,7 @@ class Main(args: Array<String>) {
}
// TODO: link these somehow so they're only defined once?
@JvmStatic
private fun addBootstrapOptions(options: Options) {
options.addOption(null, "bootstrap-update-url", true, "Github API URL for checking for updates")
options.addOption(null, "bootstrap-update-token", true, "Github API Access Token, for private repositories")
@@ -108,15 +115,17 @@ class Main(args: Array<String>) {
try {
startup(args)
} catch (e: Exception) {
e.printStackTrace()
EventQueue.invokeLater {
JOptionPane.showMessageDialog(null,
"A fatal error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
Log.fatal("Error from main", e)
if (guiEnabled) {
EventQueue.invokeLater {
JOptionPane.showMessageDialog(null,
"A fatal error occurred: \n$e",
"packwiz-installer", JOptionPane.ERROR_MESSAGE)
exitProcess(1)
exitProcess(1)
}
// In case the EventQueue is broken, exit after 1 minute
Thread.sleep(60 * 1000.toLong())
}
// In case the EventQueue is broken, exit after 1 minute
Thread.sleep(60 * 1000.toLong())
exitProcess(1)
}
}

View File

@@ -3,38 +3,36 @@ 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.InputStateHandler
import link.infra.packwiz.installer.ui.InstallProgress
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.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
import java.util.concurrent.Executors
import kotlin.system.exitProcess
class UpdateManager internal constructor(private val opts: Options, val ui: IUserInterface, private val stateHandler: InputStateHandler) {
class UpdateManager internal constructor(private val opts: Options, val ui: IUserInterface) {
private var cancelled = false
private var cancelledStartGame = false
private var errorsOccurred = false
@@ -44,61 +42,17 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
data class Options(
var downloadURI: SpaceSafeURI? = null,
var manifestFile: String = "packwiz.json", // TODO: make configurable
var packFolder: String = ".",
var side: Side = Side.CLIENT
val downloadURI: SpaceSafeURI,
val manifestFile: String,
val packFolder: String,
val side: Side
) {
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
}
}
// Horrible workaround for default params not working cleanly with nullable values
companion object {
fun construct(downloadURI: SpaceSafeURI, manifestFile: String?, packFolder: String?, side: Side?) =
Options(downloadURI, manifestFile ?: "packwiz.json", packFolder ?: ".", side ?: Side.CLIENT)
}
}
private fun start() {
@@ -110,40 +64,35 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
ManifestFile::class.java)
} catch (e: FileNotFoundException) {
ui.firstInstall = true
ManifestFile()
} catch (e: JsonSyntaxException) {
ui.handleExceptionAndExit(e)
return
ui.showErrorAndExit("Invalid local manifest file, try deleting ${opts.manifestFile}", e)
} catch (e: JsonIOException) {
ui.handleExceptionAndExit(e)
return
ui.showErrorAndExit("Failed to read local manifest file, try deleting ${opts.manifestFile}", e)
}
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
handleCancellation()
}
ui.submitProgress(InstallProgress("Loading pack file..."))
val packFileSource = try {
Objects.requireNonNull(opts.downloadURI)
val src = getFileSource(opts.downloadURI!!)
val src = getFileSource(opts.downloadURI)
getHasher("sha256").getHashingSource(src)
} catch (e: Exception) {
// TODO: run cancellation window?
ui.handleExceptionAndExit(e)
return
ui.showErrorAndExit("Failed to download pack.toml", e)
}
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.handleExceptionAndExit(e)
return
ui.showErrorAndExit("Failed to parse pack.toml", e)
}
}
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
handleCancellation()
}
@@ -171,36 +120,45 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
}
if (invalid) {
println("File $fileUri invalidated, marked for redownloading")
Log.info("File $fileUri invalidated, marked for redownloading")
invalidatedUris.add(fileUri)
}
}
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
println("Modpack is already up to date!")
// todo: --force?
if (!stateHandler.optionsButton) {
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
}
}
println("Modpack name: " + pf.name)
Log.info("Modpack name: ${pf.name}")
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
handleCancellation()
}
try {
// This is badly written, I'll probably heavily refactor it at some point
// The port to Kotlin made this REALLY messy!!!!
getNewLoc(opts.downloadURI, Objects.requireNonNull(pf.index)!!.file)?.let {
pf.index!!.hashFormat?.let { it1 ->
processIndex(it,
getHash(Objects.requireNonNull(pf.index!!.hashFormat)!!, Objects.requireNonNull(pf.index!!.hash)!!), it1, manifest, invalidatedUris)
// 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
)
}
}
}
} catch (e1: Exception) {
ui.handleExceptionAndExit(e1)
ui.showErrorAndExit("Failed to process index file", e1)
}
handleCancellation()
@@ -219,8 +177,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
try {
FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString()).use { writer -> gson.toJson(manifest, writer) }
} catch (e: IOException) {
// TODO: add message?
ui.handleException(e)
ui.showErrorAndExit("Failed to save local manifest file", e)
}
}
@@ -230,8 +187,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
println("Modpack files are already up to date!")
if (!stateHandler.optionsButton) {
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
}
}
@@ -241,22 +205,19 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
val src = getFileSource(indexUri)
getHasher(hashFormat).getHashingSource(src)
} catch (e: Exception) {
// TODO: run cancellation window?
ui.handleExceptionAndExit(e)
return
ui.showErrorAndExit("Failed to download index file", e)
}
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.handleExceptionAndExit(e)
return
ui.showErrorAndExit("Failed to parse index file", e)
}
if (!indexFileSource.hashIsEqual(indexHash)) {
// TODO: throw exception
println("I was meant to put an error message here but I'll do that later")
return
ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again")
}
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
@@ -273,8 +234,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
try {
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
} catch (e: IOException) {
// TODO: should this be shown to the user in some way?
e.printStackTrace()
Log.warn("Failed to delete optional disabled file", e)
}
// Set to null, as it doesn't exist anymore
file.cachedLocation = null
@@ -284,8 +244,8 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
if (!alreadyDeleted) {
try {
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
} catch (e: IOException) { // TODO: should this be shown to the user in some way?
e.printStackTrace()
} catch (e: IOException) {
Log.warn("Failed to delete file removed from index", e)
}
}
it.remove()
@@ -293,7 +253,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
}
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
@@ -301,14 +261,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: progress bar?
if (indexFile.files.isEmpty()) {
println("Warning: Index is empty!")
Log.warn("Index is empty!")
}
val tasks = createTasksFromIndex(indexFile, indexFile.hashFormat, 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
if (invalidateAll) {
println("Side changed, invalidating all mods")
Log.info("Side changed, invalidating all mods")
}
tasks.forEach{ f ->
// TODO: should linkedfile be checked as well? should this be done in the download section?
@@ -324,7 +284,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
f.updateFromCache(file)
}
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
@@ -335,17 +295,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTaskDetails.isNotEmpty()) {
errorsOccurred = true
val exceptionListResult: ExceptionListResult
exceptionListResult = try {
ui.showExceptions(failedTaskDetails, tasks.size, true).get()
} catch (e: InterruptedException) { // Interrupted means cancelled???
ui.handleExceptionAndExit(e)
return
} catch (e: ExecutionException) {
ui.handleExceptionAndExit(e)
return
}
when (exceptionListResult) {
when (ui.showExceptions(failedTaskDetails, tasks.size, true)) {
ExceptionListResult.CONTINUE -> {}
ExceptionListResult.CANCEL -> {
cancelled = true
@@ -358,32 +308,44 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
}
if (stateHandler.cancelButton) {
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
// TODO: task failed function?
val nonFailedFirstTasks = tasks.filter { t -> !t.failed() }.toList()
val optionTasks = nonFailedFirstTasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
// If options changed, present all options again
if (stateHandler.optionsButton || optionTasks.any(DownloadTask::isNewOptional)) {
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
val cancelledResult = ui.showOptions(ArrayList(optionTasks))
try {
if (cancelledResult.get()) {
cancelled = true
// TODO: Should the UI be closed somehow??
tasks.removeAll { it.failed() }
val optionTasks = tasks.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
}
} catch (e: InterruptedException) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e)
} catch (e: ExecutionException) {
ui.handleExceptionAndExit(e)
}
}
ui.disableOptionsButton()
// If options changed, present all options again
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
handleCancellation()
}
}
// TODO: keep this enabled? then apply changes after download process?
ui.disableOptionsButton(optionTasks.isNotEmpty())
while (true) {
when (validateAndResolve(tasks)) {
ResolveResult.RETRY -> {}
ResolveResult.QUIT -> return
ResolveResult.SUCCESS -> break
}
}
// TODO: different thread pool type?
val threadPool = Executors.newFixedThreadPool(10)
@@ -395,18 +357,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
}
for (i in tasks.indices) {
var task: DownloadTask?
task = try {
val task: DownloadTask = try {
completionService.take().get()
} catch (e: InterruptedException) {
ui.handleException(e)
null
ui.showErrorAndExit("Interrupted when consuming download tasks", e)
} catch (e: ExecutionException) {
ui.handleException(e)
null
ui.showErrorAndExit("Failed to execute download task", e)
}
// Update manifest - If there were no errors cachedFile has already been modified in place (good old pass by reference)
task?.cachedFile?.let { file ->
task.cachedFile?.let { file ->
if (task.failed()) {
val oldFile = file.revert
if (oldFile != null) {
@@ -417,21 +376,15 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
}
var progress: String
if (task != null) {
val exDetails = task.exceptionDetails
if (exDetails != null) {
progress = "Failed to download ${exDetails.name}: ${exDetails.exception.message}"
exDetails.exception.printStackTrace()
} else {
progress = "Downloaded ${task.name}"
}
val exDetails = task.exceptionDetails
val progress = if (exDetails != null) {
"Failed to download ${exDetails.name}: ${exDetails.exception.message}"
} else {
progress = "Failed to download, unknown reason"
"Downloaded ${task.name}"
}
ui.submitProgress(InstallProgress(progress, i + 1, tasks.size))
if (stateHandler.cancelButton) { // Stop all tasks, don't launch the game (it's in an invalid state!)
if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!)
threadPool.shutdown()
cancelled = true
return
@@ -441,21 +394,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// Shut down the thread pool when the update is done
threadPool.shutdown()
val failedTasks2ElectricBoogaloo = nonFailedFirstTasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
val failedTasks2ElectricBoogaloo = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTasks2ElectricBoogaloo.isNotEmpty()) {
errorsOccurred = true
val exceptionListResult: ExceptionListResult
exceptionListResult = try {
ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false).get()
} catch (e: InterruptedException) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e)
return
} catch (e: ExecutionException) {
ui.handleExceptionAndExit(e)
return
}
when (exceptionListResult) {
when (ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false)) {
ExceptionListResult.CONTINUE -> {}
ExceptionListResult.CANCEL -> cancelled = true
ExceptionListResult.IGNORE -> cancelledStartGame = true
@@ -463,24 +405,57 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
}
}
private fun showCancellationDialog() {
val cancellationResult: CancellationResult
cancellationResult = try {
ui.showCancellationDialog().get()
} catch (e: InterruptedException) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e)
return
} catch (e: ExecutionException) {
ui.handleExceptionAndExit(e)
return
enum class ResolveResult {
RETRY,
QUIT,
SUCCESS;
}
private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>): ResolveResult {
ui.submitProgress(InstallProgress("Validating existing files..."))
// Validate existing files
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
downloadTask.validateExistingFile(opts.packFolder)
}
when (cancellationResult) {
// 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
return when (ui.showExceptions(resolveFailures, cfFiles.size, true)) {
ExceptionListResult.CONTINUE -> {
ResolveResult.RETRY
}
ExceptionListResult.CANCEL -> {
cancelled = true
ResolveResult.QUIT
}
ExceptionListResult.IGNORE -> {
cancelledStartGame = true
ResolveResult.QUIT
}
}
}
}
return ResolveResult.SUCCESS
}
private fun showCancellationDialog() {
when (ui.showCancellationDialog()) {
CancellationResult.QUIT -> cancelled = true
CancellationResult.CONTINUE -> cancelledStartGame = true
}
}
// TODO: move to UI?
private fun handleCancellation() {
if (cancelled) {
println("Update cancelled by user!")
@@ -490,4 +465,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

@@ -4,7 +4,8 @@ object HashUtils {
private val hashTypeConversion: Map<String, IHasher> = mapOf(
"sha256" to HashingSourceHasher("sha256"),
"sha512" to HashingSourceHasher("sha512"),
"murmur2" to Murmur2Hasher()
"murmur2" to Murmur2Hasher(),
"sha1" to HashingSourceHasher("sha1")
)
@JvmStatic

View File

@@ -5,7 +5,7 @@ import okio.Source
class HashingSourceHasher internal constructor(private val type: String) : IHasher {
// i love naming things
private inner class HashingSourceGeneralHashingSource internal constructor(val delegateHashing: HashingSource) : GeneralHashingSource(delegateHashing) {
private inner class HashingSourceGeneralHashingSource(val delegateHashing: HashingSource) : GeneralHashingSource(delegateHashing) {
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
HashingSourceHash(delegateHashing.hash.hex())
}
@@ -35,6 +35,7 @@ class HashingSourceHasher internal constructor(private val type: String) : IHash
"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")
}

View File

@@ -1,6 +1,7 @@
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
@@ -9,9 +10,11 @@ object HandlerManager {
private val handlers: List<IRequestHandler> = listOf(
RequestHandlerGithub(),
RequestHandlerHTTP()
RequestHandlerHTTP(),
RequestHandlerFile()
)
// TODO: get rid of nullable stuff here
@JvmStatic
fun getNewLoc(base: SpaceSafeURI?, loc: SpaceSafeURI?): SpaceSafeURI? {
if (loc == null) {
@@ -30,6 +33,8 @@ object HandlerManager {
// 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 {

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

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

View File

@@ -1,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 {
@@ -65,7 +64,7 @@ class RequestHandlerGithub : RequestHandlerZip(true) {
if (!("http" == scheme || "https" == scheme)) {
return false
}
return "github.com" == loc.host
// TODO: sanity checks, support for more github urls
// TODO: more match testing?
return "github.com" == loc.host && branchMatcherPattern.matcher(loc.path ?: return false).matches()
}
}

View File

@@ -4,6 +4,7 @@ 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 {
@@ -12,14 +13,17 @@ open class RequestHandlerHTTP : IRequestHandler {
}
override fun getFileSource(loc: SpaceSafeURI): Source? {
val conn = loc.toURL().openConnection()
val conn = loc.toURL().openConnection() as HttpURLConnection
// TODO: when do we send specific headers??? should there be a way to signal this?
// github *sometimes* requires it, sometimes not!
//conn.addRequestProperty("Accept", "application/octet-stream");
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.getInputStream().source()
return conn.inputStream.source()
}
}

View File

@@ -25,7 +25,7 @@ abstract class RequestHandlerZip(private val modeHasFolder: Boolean) : RequestHa
}
}
private inner class ZipReader internal constructor(zip: Source) {
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!

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

@@ -1,47 +0,0 @@
package link.infra.packwiz.installer.ui
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Future
class CLIHandler : IUserInterface {
override fun handleException(e: Exception) {
e.printStackTrace()
}
override fun show(handler: InputStateHandler) {}
override fun submitProgress(progress: InstallProgress) {
val sb = StringBuilder()
if (progress.hasProgress) {
sb.append('(')
sb.append(progress.progress)
sb.append('/')
sb.append(progress.progressTotal)
sb.append(") ")
}
sb.append(progress.message)
println(sb.toString())
}
override fun executeManager(task: () -> Unit) {
task()
println("Finished successfully!")
}
override fun showOptions(options: List<IOptionDetails>): Future<Boolean> {
for (opt in options) {
opt.optionValue = true
// TODO: implement option choice in the CLI?
println("Warning: accepting option " + opt.name + " as option choosing is not implemented in the CLI")
}
return CompletableFuture<Boolean>().apply {
complete(false) // Can't be cancelled!
}
}
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult> {
val future = CompletableFuture<ExceptionListResult>()
future.complete(ExceptionListResult.CANCEL)
return future
}
}

View File

@@ -1,35 +1,29 @@
package link.infra.packwiz.installer.ui
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Future
import kotlin.system.exitProcess
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.ui.data.InstallProgress
interface IUserInterface {
fun show(handler: InputStateHandler)
fun handleException(e: Exception)
@JvmDefault
fun handleExceptionAndExit(e: Exception) {
handleException(e)
exitProcess(1)
}
fun show()
fun dispose()
@JvmDefault
fun setTitle(title: String) {}
fun showErrorAndExit(message: String): Nothing {
showErrorAndExit(message, null)
}
fun showErrorAndExit(message: String, e: Exception?): Nothing
var title: String
fun submitProgress(progress: InstallProgress)
fun executeManager(task: () -> Unit)
// Return true if the installation was cancelled!
fun showOptions(options: List<IOptionDetails>): Future<Boolean>
fun showOptions(options: List<IOptionDetails>): Boolean
fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult>
@JvmDefault
fun disableOptionsButton() {}
fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): ExceptionListResult
fun disableOptionsButton(hasOptions: Boolean) {}
@JvmDefault
fun showCancellationDialog(): Future<CancellationResult> {
return CompletableFuture<CancellationResult>().apply {
complete(CancellationResult.QUIT)
}
}
fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT
fun awaitOptionalButton(showCancel: Boolean)
enum class ExceptionListResult {
CONTINUE, CANCEL, IGNORE
@@ -38,4 +32,9 @@ interface IUserInterface {
enum class CancellationResult {
QUIT, CONTINUE
}
var optionsButtonPressed: Boolean
var cancelButtonPressed: Boolean
var firstInstall: Boolean
}

View File

@@ -1,21 +0,0 @@
package link.infra.packwiz.installer.ui
class InputStateHandler {
// TODO: convert to coroutines/locks?
@get:Synchronized
var optionsButton = false
private set
@get:Synchronized
var cancelButton = false
private set
@Synchronized
fun pressCancelButton() {
cancelButton = true
}
@Synchronized
fun pressOptionsButton() {
optionsButton = true
}
}

View File

@@ -1,219 +0,0 @@
package link.infra.packwiz.installer.ui
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import java.awt.*
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Future
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
import javax.swing.border.EmptyBorder
import kotlin.system.exitProcess
class InstallWindow : IUserInterface {
private val frmPackwizlauncher: JFrame
private val lblProgresslabel: JLabel
private val progressBar: JProgressBar
private val btnOptions: JButton
private var inputStateHandler: InputStateHandler? = null
private var title = "Updating modpack..."
private var worker: SwingWorkerButWithPublicPublish<Unit, InstallProgress>? = null
private val aboutToCrash = AtomicBoolean()
// TODO: separate JFrame junk from IUserInterface junk?
init {
frmPackwizlauncher = JFrame().apply {
title = this@InstallWindow.title
setBounds(100, 100, 493, 95)
defaultCloseOperation = JFrame.EXIT_ON_CLOSE
setLocationRelativeTo(null)
// Progress bar and loading text
add(JPanel().apply {
border = EmptyBorder(10, 10, 10, 10)
layout = BorderLayout(0, 0)
progressBar = JProgressBar().apply {
isIndeterminate = true
}
add(progressBar, BorderLayout.CENTER)
lblProgresslabel = JLabel("Loading...")
add(lblProgresslabel, BorderLayout.SOUTH)
}, BorderLayout.CENTER)
// Buttons
add(JPanel().apply {
border = EmptyBorder(0, 5, 0, 5)
layout = GridBagLayout()
btnOptions = JButton("Optional mods...").apply {
alignmentX = Component.CENTER_ALIGNMENT
addActionListener {
text = "Loading..."
isEnabled = false
inputStateHandler?.pressOptionsButton()
}
}
add(btnOptions, GridBagConstraints().apply {
gridx = 0
gridy = 0
})
add(JButton("Cancel").apply {
addActionListener {
isEnabled = false
inputStateHandler?.pressCancelButton()
}
}, GridBagConstraints().apply {
gridx = 0
gridy = 1
})
}, BorderLayout.EAST)
}
}
override fun show(handler: InputStateHandler) {
inputStateHandler = handler
EventQueue.invokeLater {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
frmPackwizlauncher.isVisible = true
} catch (e: Exception) {
e.printStackTrace()
}
}
}
override fun handleException(e: Exception) {
e.printStackTrace()
EventQueue.invokeLater {
JOptionPane.showMessageDialog(null,
"An error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
title, JOptionPane.ERROR_MESSAGE)
}
}
override fun handleExceptionAndExit(e: Exception) {
e.printStackTrace()
// TODO: Fix this mess
// Used to prevent the done() handler of SwingWorker executing if the invokeLater hasn't happened yet
aboutToCrash.set(true)
EventQueue.invokeLater {
JOptionPane.showMessageDialog(null,
"A fatal error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
title, JOptionPane.ERROR_MESSAGE)
exitProcess(1)
}
// Pause forever, so it blocks while we wait for System.exit to take effect
try {
Thread.currentThread().join()
} catch (ex: InterruptedException) { // no u
}
}
override fun setTitle(title: String) {
this.title = title
frmPackwizlauncher.let { frame ->
EventQueue.invokeLater { frame.title = title }
}
}
override fun submitProgress(progress: InstallProgress) {
val sb = StringBuilder()
if (progress.hasProgress) {
sb.append('(')
sb.append(progress.progress)
sb.append('/')
sb.append(progress.progressTotal)
sb.append(") ")
}
sb.append(progress.message)
// TODO: better logging library?
println(sb.toString())
worker?.publishPublic(progress)
}
override fun executeManager(task: Function0<Unit>) {
EventQueue.invokeLater {
// TODO: rewrite this stupidity to use channels??!!!
worker = object : SwingWorkerButWithPublicPublish<Unit, InstallProgress>() {
override fun doInBackground() {
task.invoke()
}
override fun process(chunks: List<InstallProgress>) {
// Only process last chunk
if (chunks.isNotEmpty()) {
val (message, hasProgress, progress, progressTotal) = chunks[chunks.size - 1]
if (hasProgress) {
progressBar.isIndeterminate = false
progressBar.value = progress
progressBar.maximum = progressTotal
} else {
progressBar.isIndeterminate = true
progressBar.value = 0
}
lblProgresslabel.text = message
}
}
override fun done() {
if (aboutToCrash.get()) {
return
}
// TODO: a better way to do this?
frmPackwizlauncher.dispose()
println("Finished successfully!")
exitProcess(0)
}
}.also {
it.execute()
}
}
}
override fun showOptions(options: List<IOptionDetails>): Future<Boolean> {
val future = CompletableFuture<Boolean>()
EventQueue.invokeLater {
OptionsSelectWindow(options, future, frmPackwizlauncher).apply {
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
isVisible = true
}
}
return future
}
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult> {
val future = CompletableFuture<ExceptionListResult>()
EventQueue.invokeLater {
ExceptionListWindow(exceptions, future, numTotal, allowsIgnore, frmPackwizlauncher).apply {
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
isVisible = true
}
}
return future
}
override fun disableOptionsButton() {
btnOptions.apply {
text = "Optional mods..."
isEnabled = false
}
}
override fun showCancellationDialog(): Future<IUserInterface.CancellationResult> {
val future = CompletableFuture<IUserInterface.CancellationResult>()
EventQueue.invokeLater {
val buttons = arrayOf("Quit", "Ignore")
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
"The installation was cancelled. Would you like to quit the game, or ignore the update and start the game?",
"Cancelled installation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, buttons, buttons[0])
future.complete(if (result == 0) IUserInterface.CancellationResult.QUIT else IUserInterface.CancellationResult.CONTINUE)
}
return future
}
}

View File

@@ -1,13 +0,0 @@
package link.infra.packwiz.installer.ui
import javax.swing.SwingWorker
// Q: AAA WHAT HAVE YOU DONE THIS IS DISGUSTING
// A: it just makes things easier, so i can easily have one interface for CLI/GUI
// if someone has a better way to do this please PR it
abstract class SwingWorkerButWithPublicPublish<T, V> : SwingWorker<T, V>() {
@SafeVarargs
fun publishPublic(vararg chunks: V) {
publish(*chunks)
}
}

View File

@@ -0,0 +1,66 @@
package link.infra.packwiz.installer.ui.cli
import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log
import kotlin.system.exitProcess
class CLIHandler : IUserInterface {
@Volatile
override var optionsButtonPressed = false
@Volatile
override var cancelButtonPressed = false
@Volatile
override var firstInstall = false
override var title: String = ""
override fun showErrorAndExit(message: String, e: Exception?): Nothing {
if (e != null) {
Log.fatal(message, e)
} else {
Log.fatal(message)
}
exitProcess(1)
}
override fun show() {}
override fun dispose() {}
override fun submitProgress(progress: InstallProgress) {
val sb = StringBuilder()
if (progress.hasProgress) {
sb.append('(')
sb.append(progress.progress)
sb.append('/')
sb.append(progress.progressTotal)
sb.append(") ")
}
sb.append(progress.message)
println(sb.toString())
}
override fun showOptions(options: List<IOptionDetails>): Boolean {
for (opt in options) {
opt.optionValue = true
// TODO: implement option choice in the CLI?
Log.warn("Accepting option ${opt.name} as option choosing is not implemented in the CLI")
}
return false // Can't be cancelled!
}
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): ExceptionListResult {
println("Failed to download modpack, the following errors were encountered:")
for (ex in exceptions) {
print(ex.name + ": ")
ex.exception.printStackTrace()
}
return ExceptionListResult.CANCEL
}
override fun awaitOptionalButton(showCancel: Boolean) {
// Do nothing
}
}

View File

@@ -1,4 +1,4 @@
package link.infra.packwiz.installer.ui
package link.infra.packwiz.installer.ui.data
data class ExceptionDetails(
val name: String,

View File

@@ -1,4 +1,4 @@
package link.infra.packwiz.installer.ui
package link.infra.packwiz.installer.ui.data
interface IOptionDetails {
val name: String

View File

@@ -1,4 +1,4 @@
package link.infra.packwiz.installer.ui
package link.infra.packwiz.installer.ui.data
data class InstallProgress(
val message: String,

View File

@@ -1,5 +1,7 @@
package link.infra.packwiz.installer.ui
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import java.awt.BorderLayout
import java.awt.Desktop
import java.awt.event.WindowAdapter
@@ -16,7 +18,7 @@ import javax.swing.border.EmptyBorder
class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFuture<IUserInterface.ExceptionListResult>, numTotal: Int, allowsIgnore: Boolean, parentWindow: JFrame?) : JDialog(parentWindow, "Failed file downloads", true) {
private val lblExceptionStacktrace: JTextArea
private class ExceptionListModel internal constructor(private val details: List<ExceptionDetails>) : AbstractListModel<String>() {
private class ExceptionListModel(private val details: List<ExceptionDetails>) : AbstractListModel<String>() {
override fun getSize() = details.size
override fun getElementAt(index: Int) = details[index].name
fun getExceptionAt(index: Int) = details[index].exception
@@ -123,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

@@ -0,0 +1,179 @@
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
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
import kotlin.system.exitProcess
class GUIHandler : IUserInterface {
private lateinit var frmPackwizlauncher: InstallWindow
@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
override var title = "packwiz-installer"
set(value) {
field = value
EventQueue.invokeLater { frmPackwizlauncher.title = value }
}
init {
EventQueue.invokeAndWait {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (e: Exception) {
Log.warn("Failed to set look and feel", e)
}
frmPackwizlauncher = InstallWindow(this).apply {
title = this@GUIHandler.title
}
}
}
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 {
frmPackwizlauncher.dispose()
}
override fun showErrorAndExit(message: String, e: Exception?): Nothing {
val buttons = arrayOf("Quit", if (firstInstall) "Continue without installing" else "Continue without updating")
if (e != null) {
Log.fatal(message, e)
EventQueue.invokeAndWait {
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
"$message: $e",
title,
JOptionPane.YES_NO_OPTION, JOptionPane.ERROR_MESSAGE, null, buttons, buttons[0])
if (result == 1) {
Log.info("User selected to continue without installing/updating, exiting with code 0...")
exitProcess(0)
} else {
Log.info("User selected to quit, exiting with code 1...")
exitProcess(1)
}
}
} else {
Log.fatal(message)
EventQueue.invokeAndWait {
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
message,
title,
JOptionPane.YES_NO_OPTION, JOptionPane.ERROR_MESSAGE, null, buttons, buttons[0])
if (result == 1) {
Log.info("User selected to continue without installing/updating, exiting with code 0...")
exitProcess(0)
} else {
Log.info("User selected to quit, exiting with code 1...")
exitProcess(1)
}
}
}
exitProcess(1)
}
override fun submitProgress(progress: InstallProgress) {
val sb = StringBuilder()
if (progress.hasProgress) {
sb.append('(')
sb.append(progress.progress)
sb.append('/')
sb.append(progress.progressTotal)
sb.append(") ")
}
sb.append(progress.message)
Log.info(sb.toString())
EventQueue.invokeLater {
frmPackwizlauncher.displayProgress(progress)
}
}
override fun showOptions(options: List<IOptionDetails>): Boolean {
val future = CompletableFuture<Boolean>()
EventQueue.invokeAndWait {
if (options.isEmpty()) {
JOptionPane.showMessageDialog(null,
"This modpack has no optional mods!",
"Optional mods", JOptionPane.INFORMATION_MESSAGE)
future.complete(false)
} else {
OptionsSelectWindow(options, future, frmPackwizlauncher).apply {
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
isVisible = true
}
}
}
return future.get()
}
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): ExceptionListResult {
val future = CompletableFuture<ExceptionListResult>()
EventQueue.invokeLater {
ExceptionListWindow(exceptions, future, numTotal, allowsIgnore, frmPackwizlauncher).apply {
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
isVisible = true
}
}
return future.get()
}
override fun disableOptionsButton(hasOptions: Boolean) = EventQueue.invokeLater {
frmPackwizlauncher.disableOptionsButton(hasOptions)
}
override fun showCancellationDialog(): IUserInterface.CancellationResult {
val future = CompletableFuture<IUserInterface.CancellationResult>()
EventQueue.invokeLater {
val buttons = arrayOf("Quit", "Ignore")
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
"The installation was cancelled. Would you like to quit the game, or ignore the update and start the game?",
"Cancelled installation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, buttons, buttons[0])
future.complete(if (result == 0) IUserInterface.CancellationResult.QUIT else IUserInterface.CancellationResult.CONTINUE)
}
return future.get()
}
override fun awaitOptionalButton(showCancel: Boolean) {
EventQueue.invokeAndWait {
frmPackwizlauncher.showOk(!showCancel)
}
visibleCountdownLatch.await()
optionalSelectedLatch.await()
EventQueue.invokeLater {
frmPackwizlauncher.hideOk()
}
}
}

View File

@@ -0,0 +1,124 @@
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.data.InstallProgress
import java.awt.BorderLayout
import java.awt.Component
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.*
import javax.swing.border.EmptyBorder
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)
// Works better with tiling window managers - there isn't any reason to change window size currently anyway
isResizable = false
defaultCloseOperation = EXIT_ON_CLOSE
setLocationRelativeTo(null)
// Progress bar and loading text
add(JPanel().apply {
border = EmptyBorder(10, 10, 10, 10)
layout = BorderLayout(0, 0)
progressBar = JProgressBar().apply {
isIndeterminate = true
}
add(progressBar, BorderLayout.CENTER)
lblProgresslabel = JLabel("Loading...")
add(lblProgresslabel, BorderLayout.SOUTH)
}, BorderLayout.CENTER)
// Buttons
buttonsPanel = JPanel().apply {
border = EmptyBorder(0, 5, 0, 5)
layout = GridBagLayout()
btnOptions = JButton("Optional mods...").apply {
alignmentX = Component.CENTER_ALIGNMENT
addActionListener {
text = "Loading..."
isEnabled = false
handler.optionsButtonPressed = true
}
}
add(btnOptions, GridBagConstraints().apply {
gridx = 1
gridy = 0
})
btnCancel = JButton("Cancel").apply {
addActionListener {
isEnabled = false
handler.cancelButtonPressed = true
}
}
add(btnCancel, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
}
btnOk = JButton("Continue").apply {
addActionListener {
handler.okButtonPressed = true
}
}
add(buttonsPanel, BorderLayout.EAST)
}
fun displayProgress(progress: InstallProgress) {
if (progress.hasProgress) {
progressBar.isIndeterminate = false
progressBar.value = progress.progress
progressBar.maximum = progress.progressTotal
} else {
progressBar.isIndeterminate = true
progressBar.value = 0
}
lblProgresslabel.text = progress.message
}
fun disableOptionsButton(hasOptions: Boolean) {
btnOptions.apply {
text = if (hasOptions) { "Optional mods..." } else { "No optional mods" }
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,4 +1,6 @@
package link.infra.packwiz.installer.ui
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.data.IOptionDetails
// Serves as a proxy for IOptionDetails, so that setOptionValue isn't called until OK is clicked
internal class OptionTempHandler(private val opt: IOptionDetails) : IOptionDetails {

View File

@@ -1,5 +1,6 @@
package link.infra.packwiz.installer.ui
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.data.IOptionDetails
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.event.ActionEvent
@@ -18,7 +19,7 @@ class OptionsSelectWindow internal constructor(optList: List<IOptionDetails>, fu
private val tableModel: OptionTableModel
private val future: CompletableFuture<Boolean>
private class OptionTableModel internal constructor(givenOpts: List<IOptionDetails>) : TableModel {
private class OptionTableModel(givenOpts: List<IOptionDetails>) : TableModel {
private val opts: List<OptionTempHandler>
init {

View File

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

View File

@@ -0,0 +1,16 @@
package link.infra.packwiz.installer.util
object Log {
fun info(message: String) = println(message)
fun warn(message: String) = println("[Warning] $message")
fun warn(message: String, exception: Exception) = println("[Warning] $message: $exception")
fun fatal(message: String) {
println("[FATAL] $message")
}
fun fatal(message: String, exception: Exception) {
println("[FATAL] $message: ")
exception.printStackTrace()
}
}

View File

@@ -0,0 +1,266 @@
# Licenses
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))
- 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))
- 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
### Commons CLI
Apache Commons CLI
Copyright 2001-2017 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
## Full license texts
### MIT
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
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### Apache 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
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.