1
0
mirror of https://github.com/packwiz/packwiz-installer.git synced 2025-04-27 08:46:29 +02:00

Compare commits

..

No commits in common. "main" and "v0.3.3" have entirely different histories.
main ... v0.3.3

71 changed files with 1236 additions and 2529 deletions

@ -1,27 +0,0 @@
name: Java Gradle Build
on:
pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up JDK 8
uses: actions/setup-java@v2
with:
java-version: '8'
distribution: 'temurin'
cache: gradle
- name: Build with Gradle
run: ./gradlew build
- name: Cleanup Gradle Cache
# Remove some files from the Gradle cache, so they aren't cached by GitHub Actions.
# Restoring these files from a GitHub Actions cache might cause problems for future builds.
run: |
rm -f ~/.gradle/caches/modules-2/modules-2.lock
rm -f ~/.gradle/caches/modules-2/gc.properties

@ -1,29 +0,0 @@
name: Java Gradle Release
on:
push:
tags:
- 'v\d+.\d+.\d+'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up JDK 8
uses: actions/setup-java@v2
with:
java-version: '8'
distribution: 'temurin'
cache: gradle
- name: Publish with Gradle
run: ./gradlew publish -Pgithub.token="${{ secrets.GITHUB_TOKEN }}" -Pbunnycdn.token="${{ secrets.BUNNYCDN_TOKEN }}" -Prelease=true
- name: Cleanup Gradle Cache
# Remove some files from the Gradle cache, so they aren't cached by GitHub Actions.
# Restoring these files from a GitHub Actions cache might cause problems for future builds.
run: |
rm -f ~/.gradle/caches/modules-2/modules-2.lock
rm -f ~/.gradle/caches/modules-2/gc.properties

@ -1,29 +0,0 @@
name: Java Gradle Snapshot
on:
push:
branches:
- 'main'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up JDK 8
uses: actions/setup-java@v2
with:
java-version: '8'
distribution: 'temurin'
cache: gradle
- name: Publish with Gradle
run: ./gradlew publish -Pgithub.token="${{ secrets.GITHUB_TOKEN }}" -Pbunnycdn.token="${{ secrets.BUNNYCDN_TOKEN }}"
- name: Cleanup Gradle Cache
# Remove some files from the Gradle cache, so they aren't cached by GitHub Actions.
# Restoring these files from a GitHub Actions cache might cause problems for future builds.
run: |
rm -f ~/.gradle/caches/modules-2/modules-2.lock
rm -f ~/.gradle/caches/modules-2/gc.properties

@ -1,50 +1,45 @@
buildscript {
repositories {
jcenter()
}
dependencies {
classpath("com.guardsquare:proguard-gradle:7.0.0") {
exclude("com.android.tools.build")
}
}
}
plugins { plugins {
java java
application application
id("com.github.johnrengelman.shadow") version "7.1.2" id("com.github.johnrengelman.shadow") version "6.1.0"
id("com.palantir.git-version") version "0.13.0" id("com.palantir.git-version") version "0.12.3"
id("com.github.breadmoirai.github-release") version "2.4.1" id("com.github.breadmoirai.github-release") version "2.2.12"
kotlin("jvm") version "1.7.10" kotlin("jvm") version "1.4.21"
id("com.github.jk1.dependency-license-report") version "2.0" id("com.github.jk1.dependency-license-report") version "1.16"
`maven-publish`
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
} }
repositories { val shrinkClasspath: Configuration by configurations.creating
mavenCentral()
google()
maven {
url = uri("https://jitpack.io")
}
}
val r8 by configurations.creating
val distJarOutput by configurations.creating {
isCanBeResolved = false
isCanBeConsumed = true
attributes {
attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, Usage.JAVA_RUNTIME))
attribute(Bundling.BUNDLING_ATTRIBUTE, project.objects.named(Bundling::class.java, Bundling.EMBEDDED))
}
}
dependencies { dependencies {
implementation("commons-cli:commons-cli:1.5.0") implementation("commons-cli:commons-cli:1.4")
implementation("com.google.code.gson:gson:2.9.0") shrinkClasspath("commons-cli:commons-cli:1.4")
implementation("com.squareup.okio:okio:3.1.0") implementation("com.moandjiezana.toml:toml4j:0.7.2")
implementation("com.google.code.gson:gson:2.8.1")
implementation("com.squareup.okio:okio:2.9.0")
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
implementation("com.squareup.okhttp3:okhttp:4.10.0") }
implementation("cc.ekblad:4koma:1.1.0")
r8("com.android.tools:r8:3.3.28") repositories {
jcenter()
} }
application { application {
mainClass.set("link.infra.packwiz.installer.RequiresBootstrap") mainClassName = "link.infra.packwiz.installer.RequiresBootstrap"
} }
val gitVersion: groovy.lang.Closure<*> by extra val gitVersion: groovy.lang.Closure<*> by extra
@ -64,143 +59,79 @@ licenseReport {
filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer()) filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer())
} }
// TODO: build relocated jar for minecraft launcher lib, non-relocated jar for packwiz-installer
//tasks.register<com.github.jengelman.gradle.plugins.shadow.tasks.ConfigureShadowRelocation>("relocateShadowJar") {
// target = tasks.shadowJar.get()
// prefix = "link.infra.packwiz.deps"
//}
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
tasks.shadowJar { tasks.shadowJar {
// 4koma uses kotlin-reflect; requires Kotlin metadata dependencies {
//exclude("**/*.kotlin_metadata") exclude(dependency("commons-cli:commons-cli:1.4"))
//exclude("**/*.kotlin_builtins") exclude(dependency("com.eclipsesource.minimal-json:minimal-json:0.9.5"))
// TODO: exclude meta inf files
}
exclude("**/*.kotlin_metadata")
exclude("**/*.kotlin_builtins")
exclude("META-INF/maven/**/*") exclude("META-INF/maven/**/*")
exclude("META-INF/proguard/**/*") exclude("META-INF/proguard/**/*")
//dependsOn(tasks.named("relocateShadowJar"))
// Relocate Commons CLI, so that it doesn't clash with old packwiz-installer-bootstrap (that shades it)
relocate("org.apache.commons.cli", "link.infra.packwiz.installer.deps.commons-cli")
// from Commons CLI
exclude("META-INF/LICENSE.txt")
exclude("META-INF/NOTICE.txt")
} }
val shrinkJar by tasks.registering(JavaExec::class) { tasks.register<proguard.gradle.ProGuardTask>("shrinkJar") {
val rules = file("src/main/proguard.txt") injars(tasks.shadowJar)
val r8File = base.libsDirectory.file(provider { libraryjars(files(shrinkClasspath.files))
base.archivesName.get() + "-" + project.version + "-all-shrink.jar" outjars("build/libs/" + tasks.shadowJar.get().outputs.files.first().name.removeSuffix(".jar") + "-shrink.jar")
}) if (System.getProperty("java.version").startsWith("1.")) {
dependsOn(configurations.named("runtimeClasspath")) libraryjars("${System.getProperty("java.home")}/lib/rt.jar")
inputs.files(tasks.shadowJar, rules) libraryjars("${System.getProperty("java.home")}/lib/jce.jar")
outputs.file(r8File) } else {
throw RuntimeException("Compiling with Java 9+ not supported!")
classpath(r8)
mainClass.set("com.android.tools.r8.R8")
args = mutableListOf(
"--release",
"--classfile",
"--output", r8File.get().toString(),
"--pg-conf", rules.toString(),
"--lib", System.getProperty("java.home"),
*(if (System.getProperty("java.version").startsWith("1.")) {
// javax.crypto, necessary on <1.9 for compiling Okio
arrayOf("--lib", System.getProperty("java.home") + "/lib/jce.jar")
} else { arrayOf() }),
tasks.shadowJar.get().archiveFile.get().asFile.toString()
)
}
// MANIFEST.MF must be one of the first 2 entries in the zip for JarInputStream to see it
// Gradle's JAR creation handles this whereas R8 doesn't, so the dist JAR is repacked
val distJar by tasks.registering(Jar::class) {
from(shrinkJar.map { zipTree(it.outputs.files.singleFile) })
archiveClassifier.set("all-repacked")
manifest {
from(shrinkJar.map { zipTree(it.outputs.files.singleFile).matching {
include("META-INF/MANIFEST.MF")
}.singleFile })
} }
}
artifacts { keep("class link.infra.packwiz.installer.** { *; }")
add("distJarOutput", distJar) { dontoptimize()
classifier = "dist" dontobfuscate()
} dontwarn("org.codehaus.mojo.animal_sniffer.*")
} }
// Used for vscode launch.json // Used for vscode launch.json
val copyJar by tasks.registering(Copy::class) { tasks.register<Copy>("copyJar") {
from(distJar) from(tasks.named("shrinkJar"))
rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar") rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar")
into(layout.buildDirectory.dir("dist")) into("build/libs/")
outputs.file(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") })
} }
tasks.build { tasks.build {
dependsOn(copyJar) dependsOn("copyJar")
} }
githubRelease { if (project.hasProperty("github.token")) {
owner("comp500") githubRelease {
repo("packwiz-installer") owner("comp500")
tagName("${project.version}") repo("packwiz-installer")
releaseName("Release ${project.version}") tagName("${project.version}")
draft(true) releaseName("Release ${project.version}")
token(findProperty("github.token") as String?) draft(true)
releaseAssets(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") }.get()) token(findProperty("github.token") as String? ?: "")
} releaseAssets(tasks.jar.get().destinationDirectory.file("packwiz-installer.jar").get())
}
tasks.githubRelease { tasks.githubRelease {
dependsOn(copyJar) dependsOn(tasks.build)
enabled = project.hasProperty("github.token") && project.findProperty("release") == "true" }
}
tasks.publish {
dependsOn(tasks.githubRelease)
} }
tasks.compileKotlin { tasks.compileKotlin {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all", "-Xallow-result-return-type", "-opt-in=kotlin.io.path.ExperimentalPathApi", "-Xlambdas=indy") freeCompilerArgs = listOf("-Xjvm-default=enable")
} }
} }
tasks.compileTestKotlin { tasks.compileTestKotlin {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all", "-Xallow-result-return-type", "-opt-in=kotlin.io.path.ExperimentalPathApi", "-Xlambdas=indy") freeCompilerArgs = listOf("-Xjvm-default=enable")
} }
} }
val javaComponent = components["java"] as AdhocComponentWithVariants
javaComponent.addVariantsFromConfiguration(distJarOutput) {
mapToMavenScope("runtime")
mapToOptional()
}
javaComponent.withVariantsFromConfiguration(configurations["shadowRuntimeElements"]) {
skip()
}
if (project.hasProperty("bunnycdn.token")) {
publishing {
publications {
create<MavenPublication>("maven") {
groupId = "link.infra.packwiz"
artifactId = "packwiz-installer"
from(components["java"])
}
}
repositories {
maven {
url = if (project.findProperty("release") == "true") {
uri("https://storage.bunnycdn.com/comp-maven/repository/release")
} else {
uri("https://storage.bunnycdn.com/comp-maven/repository/snapshot")
}
credentials(HttpHeaderCredentials::class) {
name = "AccessKey"
value = findProperty("bunnycdn.token") as String?
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
}

Binary file not shown.

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

272
gradlew vendored Executable file → Normal file

@ -1,13 +1,13 @@
#!/bin/sh #!/usr/bin/env sh
# #
# Copyright © 2015-2021 the original authors. # Copyright 2015 the original author or authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
@ -17,113 +17,78 @@
# #
############################################################################## ##############################################################################
# ##
# Gradle start up script for POSIX generated by Gradle. ## Gradle start up script for UN*X
# ##
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
app_path=$0 PRG="$0"
# Need this for relative symlinks.
# Need this for daisy-chained symlinks. while [ -h "$PRG" ] ; do
while ls=`ls -ld "$PRG"`
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path link=`expr "$ls" : '.*-> \(.*\)$'`
[ -h "$app_path" ] if expr "$link" : '/.*' > /dev/null; then
do PRG="$link"
ls=$( ls -ld "$app_path" ) else
link=${ls#*' -> '} PRG=`dirname "$PRG"`"/$link"
case $link in #( fi
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" APP_NAME="Gradle"
APP_BASE_NAME=${0##*/} APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD="maximum"
warn () { warn () {
echo "$*" echo "$*"
} >&2 }
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} >&2 }
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "$( uname )" in #( case "`uname`" in
CYGWIN* ) cygwin=true ;; #( CYGWIN* )
Darwin* ) darwin=true ;; #( cygwin=true
MSYS* | MINGW* ) msys=true ;; #( ;;
NONSTOP* ) nonstop=true ;; Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java JAVACMD="$JAVA_HOME/jre/sh/java"
else else
JAVACMD=$JAVA_HOME/bin/java JAVACMD="$JAVA_HOME/bin/java"
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@ -132,7 +97,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi fi
else else
JAVACMD=java JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
@ -140,95 +105,84 @@ location of your Java installation."
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
case $MAX_FD in #( MAX_FD_LIMIT=`ulimit -H -n`
max*) if [ $? -eq 0 ] ; then
MAX_FD=$( ulimit -H -n ) || if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
warn "Could not query maximum file descriptor limit" MAX_FD="$MAX_FD_LIMIT"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi fi
# Roll the args list around exactly as many times as the number of ulimit -n $MAX_FD
# args, so each arg winds up back in the position where it started, but if [ $? -ne 0 ] ; then
# possibly modified. warn "Could not set maximum file descriptor limit: $MAX_FD"
# fi
# NB: a `for` loop captures its iteration list before it begins, so else
# changing the positional parameters here affects neither the number of warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
# iterations, nor the values presented in `arg`. fi
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi fi
# Collect all arguments for the java command; # For Darwin, add options to specify how the application appears in the dock
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of if $darwin; then
# shell script including quotes and variable substitutions, so put them in GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
# double quotes to make sure that they get re-expanded; and fi
# * put everything else in single quotes, so that it's not re-expanded.
set -- \ # For Cygwin, switch paths to Windows format before running java
"-Dorg.gradle.appname=$APP_BASE_NAME" \ if $cygwin ; then
-classpath "$CLASSPATH" \ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
org.gradle.wrapper.GradleWrapperMain \ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
"$@" JAVACMD=`cygpath --unix "$JAVACMD"`
# Use "xargs" to parse quoted args. # We build the pattern for arguments to be converted via cygpath
# ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. SEP=""
# for dir in $ROOTDIRSRAW ; do
# In Bash we could simply go: ROOTDIRS="$ROOTDIRS$SEP$dir"
# SEP="|"
# readarray ARGS < <( xargs -n1 <<<"$var" ) && done
# set -- "${ARGS[@]}" "$@" OURCYGPATTERN="(^($ROOTDIRS))"
# # Add a user-defined pattern to the cygpath arguments
# but POSIX shell has neither arrays nor command substitution, so instead we if [ "$GRADLE_CYGPATTERN" != "" ] ; then
# post-process each arg (as a line of input to sed) to backslash-escape any OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
# character that might be a shell metacharacter, then use eval to reverse fi
# that process (while maintaining the separation between arguments), and wrap # Now convert the arguments - kludge to limit ourselves to /bin/sh
# the whole thing up as a single "set" statement. i=0
# for arg in "$@" ; do
# This will of course break if any of these variables contains a newline or CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
# an unmatched quote. CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
#
eval "set -- $( if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
xargs -n1 | else
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | eval `echo args$i`="\"$arg\""
tr '\n' ' ' fi
)" '"$@"' i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"

27
gradlew.bat vendored

@ -5,7 +5,7 @@
@rem you may not use this file except in compliance with the License. @rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem http://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS, @rem distributed under the License is distributed on an "AS IS" BASIS,
@ -29,9 +29,6 @@ if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@ -40,7 +37,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if "%ERRORLEVEL%" == "0" goto init
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@ -54,7 +51,7 @@ goto fail
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto init
echo. echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
@ -64,14 +61,28 @@ echo location of your Java installation.
goto fail goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell

@ -8,6 +8,8 @@ public class RequiresBootstrap {
public static void main(String[] args) { public static void main(String[] args) {
// Very small CLI implementation, because Commons CLI complains on unexpected // Very small CLI implementation, because Commons CLI complains on unexpected
// options // 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 (Arrays.stream(args).map(str -> {
if (str == null) return ""; if (str == null) return "";
if (str.startsWith("--")) { if (str.startsWith("--")) {

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

@ -2,57 +2,41 @@ package link.infra.packwiz.installer
import link.infra.packwiz.installer.metadata.IndexFile import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile import link.infra.packwiz.installer.metadata.ManifestFile
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
import link.infra.packwiz.installer.request.RequestException import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import okio.Buffer import okio.Buffer
import okio.HashingSink import okio.HashingSink
import okio.blackholeSink
import okio.buffer import okio.buffer
import java.io.IOException import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import java.util.*
internal class DownloadTask private constructor(val metadata: IndexFile.File, val index: IndexFile, private val downloadSide: Side) : IOptionDetails { internal class DownloadTask private constructor(val metadata: IndexFile.File, defaultFormat: String, private val downloadSide: UpdateManager.Options.Side) : IOptionDetails {
var cachedFile: ManifestFile.File? = null var cachedFile: ManifestFile.File? = null
private set
private var err: Exception? = null private var err: Exception? = null
val exceptionDetails get() = err?.let { e -> ExceptionDetails(name, e) } val exceptionDetails get() = err?.let { e -> ExceptionDetails(name, e) }
fun failed() = err != null fun failed() = err != null
var alreadyUpToDate = false private var alreadyUpToDate = false
private set
private var metadataRequired = true private var metadataRequired = true
private var invalidated = false private var invalidated = false
// If file is new or isOptional changed to true, the option needs to be presented again // If file is new or isOptional changed to true, the option needs to be presented again
private var newOptional = true private var newOptional = true
var completionStatus = CompletionStatus.INCOMPLETE
private set
enum class CompletionStatus { val isOptional get() = metadata.linkedFile?.isOptional ?: false
INCOMPLETE,
DOWNLOADED,
ALREADY_EXISTS_CACHED,
ALREADY_EXISTS_VALIDATED,
SKIPPED_DISABLED,
SKIPPED_WRONG_SIDE,
DELETED_DISABLED,
DELETED_WRONG_SIDE;
}
val isOptional get() = metadata.linkedFile?.option?.optional ?: false
fun isNewOptional() = isOptional && newOptional fun isNewOptional() = isOptional && newOptional
fun correctSide() = metadata.linkedFile?.side?.let { downloadSide.hasSide(it) } ?: true fun correctSide() = metadata.linkedFile?.side?.hasSide(downloadSide) ?: true
override val name get() = metadata.name override val name get() = metadata.name
@ -68,6 +52,12 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
override val optionDescription get() = metadata.linkedFile?.option?.description ?: "" override val optionDescription get() = metadata.linkedFile?.option?.description ?: ""
init {
if (metadata.hashFormat?.isEmpty() != false) {
metadata.hashFormat = defaultFormat
}
}
fun invalidate() { fun invalidate() {
invalidated = true invalidated = true
alreadyUpToDate = false alreadyUpToDate = false
@ -83,7 +73,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
this.cachedFile = cachedFile this.cachedFile = cachedFile
if (!invalidated) { if (!invalidated) {
val currHash = try { val currHash = try {
metadata.getHashObj(index) getHash(metadata.hashFormat!!, metadata.hash!!)
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return return
@ -91,7 +81,6 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
if (currHash == cachedFile.hash) { // Already up to date if (currHash == cachedFile.hash) { // Already up to date
alreadyUpToDate = true alreadyUpToDate = true
metadataRequired = false metadataRequired = false
completionStatus = CompletionStatus.ALREADY_EXISTS_CACHED
} }
} }
if (cachedFile.isOptional) { if (cachedFile.isOptional) {
@ -101,13 +90,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
} }
} }
fun downloadMetadata(clientHolder: ClientHolder) { fun downloadMetadata(parentIndexFile: IndexFile, indexUri: SpaceSafeURI) {
if (err != null) return if (err != null) return
if (metadataRequired) { if (metadataRequired) {
try { try {
// Retrieve the linked metadata file // Retrieve the linked metadata file
metadata.downloadMeta(index, clientHolder) metadata.downloadMeta(parentIndexFile, indexUri)
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return return
@ -115,134 +104,73 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
cachedFile?.let { cachedFile -> cachedFile?.let { cachedFile ->
val linkedFile = metadata.linkedFile val linkedFile = metadata.linkedFile
if (linkedFile != null) { if (linkedFile != null) {
if (linkedFile.option.optional) { linkedFile.option?.let { opt ->
if (cachedFile.isOptional) { if (opt.optional) {
// isOptional didn't change if (cachedFile.isOptional) {
newOptional = false // isOptional didn't change
} else { newOptional = false
// isOptional false -> true, set option to it's default value } else {
// TODO: preserve previous option value, somehow?? // isOptional false -> true, set option to it's default value
cachedFile.optionValue = linkedFile.option.defaultValue // TODO: preserve previous option value, somehow??
cachedFile.optionValue = opt.defaultValue
}
} }
} }
cachedFile.isOptional = isOptional
cachedFile.onlyOtherSide = !correctSide()
} }
cachedFile.isOptional = isOptional
cachedFile.onlyOtherSide = !correctSide()
} }
} }
} }
/** fun download(packFolder: String, indexUri: SpaceSafeURI) {
* Check if the file in the destination location is already valid
* Must be done after metadata retrieval
*/
fun validateExistingFile(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
if (!alreadyUpToDate) {
try {
// TODO: only do this for files that didn't exist before or have been modified since last full update?
val destPath = metadata.destURI.rebase(packFolder)
destPath.source(clientHolder).use { src ->
// TODO: clean up duplicated code
val hash: Hash<*>
val fileHashFormat: HashFormat<*>
val linkedFile = metadata.linkedFile
if (linkedFile != null) {
hash = linkedFile.hash
fileHashFormat = linkedFile.download.hashFormat
} else {
hash = metadata.getHashObj(index)
fileHashFormat = metadata.hashFormat(index)
}
val fileSource = fileHashFormat.source(src)
fileSource.buffer().readAll(blackholeSink())
if (hash == fileSource.hash) {
alreadyUpToDate = true
completionStatus = CompletionStatus.ALREADY_EXISTS_VALIDATED
// Update the manifest file
cachedFile = (cachedFile ?: ManifestFile.File()).also {
try {
it.hash = metadata.getHashObj(index)
} catch (e: Exception) {
err = e
return
}
it.isOptional = isOptional
it.cachedLocation = metadata.destURI.rebase(packFolder)
metadata.linkedFile?.let { linked ->
try {
it.linkedFileHash = linked.hash
} catch (e: Exception) {
err = e
}
}
}
}
}
} catch (e: RequestException) {
// Ignore exceptions; if the file doesn't exist we'll be downloading it
} catch (e: IOException) {
// Ignore exceptions; if the file doesn't exist we'll be downloading it
}
}
}
fun download(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
if (err != null) return if (err != null) return
// Exclude wrong-side and optional false files // TODO: is this necessary if we overwrite?
// Ensure it is removed
cachedFile?.let { cachedFile?.let {
if ((it.isOptional && !it.optionValue) || !correctSide()) { if (!it.optionValue || !correctSide()) {
if (it.cachedLocation != null) { if (it.cachedLocation == null) return
// Ensure wrong-side or optional false files are removed
try { try {
completionStatus = if (Files.deleteIfExists(it.cachedLocation!!.nioPath)) { Files.deleteIfExists(Paths.get(packFolder, it.cachedLocation))
if (correctSide()) { CompletionStatus.DELETED_DISABLED } else { CompletionStatus.DELETED_WRONG_SIDE } } catch (e: IOException) {
} else { Log.warn("Failed to delete file before downloading", e)
if (correctSide()) { CompletionStatus.SKIPPED_DISABLED } else { CompletionStatus.SKIPPED_WRONG_SIDE }
}
} catch (e: IOException) {
Log.warn("Failed to delete file", e)
}
} else {
completionStatus =
if (correctSide()) { CompletionStatus.SKIPPED_DISABLED }
else { CompletionStatus.SKIPPED_WRONG_SIDE }
} }
it.cachedLocation = null it.cachedLocation = null
return
} }
} }
if (alreadyUpToDate) return if (alreadyUpToDate) return
val destPath = metadata.destURI.rebase(packFolder) // 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 // Don't update files marked with preserve if they already exist on disk
if (metadata.preserve) { if (metadata.preserve) {
if (destPath.nioPath.toFile().exists()) { if (destPath.toFile().exists()) {
return return
} }
} }
// TODO: if already exists and has correct hash, ignore?
// TODO: add .disabled support? // TODO: add .disabled support?
try { try {
val hash: Hash<*> val hash: Hash
val fileHashFormat: HashFormat<*> val fileHashFormat: String
val linkedFile = metadata.linkedFile val linkedFile = metadata.linkedFile
if (linkedFile != null) { if (linkedFile != null) {
hash = linkedFile.hash hash = linkedFile.hash
fileHashFormat = linkedFile.download.hashFormat fileHashFormat = Objects.requireNonNull(Objects.requireNonNull(linkedFile.download)!!.hashFormat)!!
} else { } else {
hash = metadata.getHashObj(index) hash = metadata.getHashObj()
fileHashFormat = metadata.hashFormat(index) fileHashFormat = Objects.requireNonNull(metadata.hashFormat)!!
} }
val src = metadata.getSource(clientHolder) val src = metadata.getSource(indexUri)
val fileSource = fileHashFormat.source(src) val fileSource = getHasher(fileHashFormat).getHashingSource(src)
val data = Buffer() val data = Buffer()
// Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails) // Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails)
@ -251,16 +179,16 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
it.readAll(data) it.readAll(data)
} }
if (hash == fileSource.hash) { if (fileSource.hashIsEqual(hash)) {
// isDirectory follows symlinks, but createDirectories doesn't // isDirectory follows symlinks, but createDirectories doesn't
try { try {
Files.createDirectories(destPath.parent.nioPath) Files.createDirectories(destPath.parent)
} catch (e: java.nio.file.FileAlreadyExistsException) { } catch (e: java.nio.file.FileAlreadyExistsException) {
if (!Files.isDirectory(destPath.parent.nioPath)) { if (!Files.isDirectory(destPath.parent)) {
throw e throw e
} }
} }
Files.copy(data.inputStream(), destPath.nioPath, StandardCopyOption.REPLACE_EXISTING) Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING)
data.clear() data.clear()
} else { } else {
// TODO: move println to something visible in the error window // TODO: move println to something visible in the error window
@ -268,7 +196,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
println("Calculated: " + fileSource.hash) println("Calculated: " + fileSource.hash)
println("Expected: $hash") println("Expected: $hash")
// Attempt to get the SHA256 hash // Attempt to get the SHA256 hash
val sha256 = HashingSink.sha256(blackholeSink()) val sha256 = HashingSink.sha256(okio.blackholeSink())
data.readAll(sha256) data.readAll(sha256)
println("SHA256 hash value: " + sha256.hash) println("SHA256 hash value: " + sha256.hash)
err = Exception("Hash invalid!") err = Exception("Hash invalid!")
@ -276,10 +204,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
return return
} }
cachedFile?.cachedLocation?.let { cachedFile?.cachedLocation?.let {
if (destPath != it) { if (destPath != Paths.get(packFolder, it)) {
// Delete old file if location changes // Delete old file if location changes
try { try {
Files.delete(cachedFile!!.cachedLocation!!.nioPath) Files.delete(Paths.get(packFolder, cachedFile!!.cachedLocation))
} catch (e: IOException) { } catch (e: IOException) {
// Continue, as it was probably already deleted? // Continue, as it was probably already deleted?
// TODO: log it // TODO: log it
@ -294,13 +222,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
// Update the manifest file // Update the manifest file
cachedFile = (cachedFile ?: ManifestFile.File()).also { cachedFile = (cachedFile ?: ManifestFile.File()).also {
try { try {
it.hash = metadata.getHashObj(index) it.hash = metadata.getHashObj()
} catch (e: Exception) { } catch (e: Exception) {
err = e err = e
return return
} }
it.isOptional = isOptional it.isOptional = isOptional
it.cachedLocation = metadata.destURI.rebase(packFolder) it.cachedLocation = metadata.destURI.toString()
metadata.linkedFile?.let { linked -> metadata.linkedFile?.let { linked ->
try { try {
it.linkedFileHash = linked.hash it.linkedFileHash = linked.hash
@ -309,15 +237,14 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
} }
} }
} }
completionStatus = CompletionStatus.DOWNLOADED
} }
companion object { companion object {
fun createTasksFromIndex(index: IndexFile, downloadSide: Side): MutableList<DownloadTask> { @JvmStatic
fun createTasksFromIndex(index: IndexFile, defaultFormat: String, downloadSide: UpdateManager.Options.Side): List<DownloadTask> {
val tasks = ArrayList<DownloadTask>() val tasks = ArrayList<DownloadTask>()
for (file in index.files) { for (file in Objects.requireNonNull(index.files)) {
tasks.add(DownloadTask(file, index, downloadSide)) tasks.add(DownloadTask(file, defaultFormat, downloadSide))
} }
return tasks return tasks
} }

@ -1,142 +0,0 @@
package link.infra.packwiz.installer
import com.google.gson.Gson
import com.google.gson.JsonIOException
import com.google.gson.JsonParser
import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.metadata.PackFile
import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.util.Log
import kotlin.io.path.reader
import kotlin.io.path.writeText
class LauncherUtils internal constructor(private val opts: UpdateManager.Options, val ui: IUserInterface) {
enum class LauncherStatus {
SUCCESSFUL,
NO_CHANGES,
CANCELLED,
NOT_FOUND, // When there is no mmc-pack.json file found (i.e. MultiMC is not being used)
}
fun handleMultiMC(pf: PackFile, gson: Gson): LauncherStatus {
// MultiMC MC and loader version checker
val manifestPath = opts.multimcFolder / "mmc-pack.json"
if (!manifestPath.nioPath.toFile().exists()) {
return LauncherStatus.NOT_FOUND
}
val multimcManifest = manifestPath.nioPath.reader().use {
try {
JsonParser.parseReader(it)
} catch (e: JsonIOException) {
throw Exception("Cannot read the MultiMC pack file", e)
} catch (e: JsonSyntaxException) {
throw Exception("Invalid MultiMC pack file", e)
}.asJsonObject
}
Log.info("Loaded MultiMC config")
// We only support format 1, if it gets updated in the future we'll have to handle that
// There's only version 1 for now tho, so that's good
if (multimcManifest["formatVersion"]?.asInt != 1) {
throw Exception("Unsupported MultiMC format version ${multimcManifest["formatVersion"]}")
}
var manifestModified = false
val modLoaders = hashMapOf(
"net.minecraft" to "minecraft",
"net.minecraftforge" to "forge",
"net.neoforged" to "neoforge",
"net.fabricmc.fabric-loader" to "fabric",
"org.quiltmc.quilt-loader" to "quilt",
"com.mumfrey.liteloader" to "liteloader"
)
// MultiMC requires components to be sorted; this is defined in the MultiMC meta repo, but they seem to
// be the same for every version so they are just used directly here
val componentOrders = mapOf(
"net.minecraft" to -2,
"org.lwjgl" to -1,
"org.lwjgl3" to -1,
"net.minecraftforge" to 5,
"net.neoforged" to 5,
"net.fabricmc.fabric-loader" to 10,
"org.quiltmc.quilt-loader" to 10,
"com.mumfrey.liteloader" to 10,
"net.fabricmc.intermediary" to 11
)
val modLoadersClasses = modLoaders.entries.associate{(k,v)-> v to k}
val loaderVersionsFound = HashMap<String, String?>()
val outdatedLoaders = mutableSetOf<String>()
val components = multimcManifest["components"]?.asJsonArray ?: throw Exception("Invalid mmc-pack.json: no components key")
components.removeAll {
val component = it.asJsonObject
val version = component["version"]?.asString
// If we find any of the modloaders we support, we save it and check the version
if (modLoaders.containsKey(component["uid"]?.asString)) {
val modLoader = modLoaders.getValue(component["uid"]!!.asString)
loaderVersionsFound[modLoader] = version
if (version != pf.versions[modLoader]) {
outdatedLoaders.add(modLoader)
true // Delete component; cached metadata is invalid and will be re-added
} else {
false // Already up to date; cached metadata is valid
}
} else { false } // Not a known loader / MC
}
for ((_, loader) in modLoaders
.filter {
(!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) && pf.versions.containsKey(it.value)
}
) {
manifestModified = true
components.add(gson.toJsonTree(
hashMapOf("uid" to modLoadersClasses[loader], "version" to pf.versions[loader]))
)
}
// If inconsistent Intermediary mappings version is found, delete it - MultiMC will add and re-dl the correct one
components.find { it.isJsonObject && it.asJsonObject["uid"]?.asString == "net.fabricmc.intermediary" }?.let {
if (it.asJsonObject["version"]?.asString != pf.versions["minecraft"]) {
components.remove(it)
manifestModified = true
}
}
if (manifestModified) {
// Sort manifest by component order
val sortedComponents = components.sortedWith(nullsLast(compareBy {
if (it.isJsonObject) {
componentOrders[it.asJsonObject["uid"]?.asString]
} else { null }
}))
components.removeAll { true }
sortedComponents.forEach { components.add(it) }
// The manifest has been modified, so before saving it we'll ask the user
// if they wanna update it, continue without updating it, or exit
val oldVers = loaderVersionsFound.map { Pair(it.key, it.value) }
val newVers = pf.versions.map { Pair(it.key, it.value) }
when (ui.showUpdateConfirmationDialog(oldVers, newVers)) {
IUserInterface.UpdateConfirmationResult.CANCELLED -> {
return LauncherStatus.CANCELLED
}
IUserInterface.UpdateConfirmationResult.CONTINUE -> {
return LauncherStatus.SUCCESSFUL
}
else -> {}
}
manifestPath.nioPath.writeText(gson.toJson(multimcManifest))
Log.info("Successfully updated mmc-pack.json based on version metadata")
return LauncherStatus.SUCCESSFUL
}
return LauncherStatus.NO_CHANGES
}
}

@ -2,23 +2,16 @@
package link.infra.packwiz.installer package link.infra.packwiz.installer
import link.infra.packwiz.installer.target.Side import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.cli.CLIHandler import link.infra.packwiz.installer.ui.cli.CLIHandler
import link.infra.packwiz.installer.ui.gui.GUIHandler import link.infra.packwiz.installer.ui.gui.GUIHandler
import link.infra.packwiz.installer.ui.wrap
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.Path.Companion.toOkioPath
import okio.Path.Companion.toPath
import org.apache.commons.cli.DefaultParser import org.apache.commons.cli.DefaultParser
import org.apache.commons.cli.Options import org.apache.commons.cli.Options
import org.apache.commons.cli.ParseException import org.apache.commons.cli.ParseException
import java.awt.EventQueue import java.awt.EventQueue
import java.awt.GraphicsEnvironment import java.awt.GraphicsEnvironment
import java.net.URI import java.net.URISyntaxException
import java.nio.file.Paths
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -72,44 +65,20 @@ class Main(args: Array<String>) {
ui.show() ui.show()
val packFileRaw = unparsedArgs[0] val uOptions = try {
UpdateManager.Options.construct(
val packFile = when { downloadURI = SpaceSafeURI(unparsedArgs[0]),
// HTTP(s) URLs side = cmd.getOptionValue("side")?.let((UpdateManager.Options.Side)::from),
Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.wrap("Invalid HTTP/HTTPS URL for pack file: $packFileRaw") { packFolder = cmd.getOptionValue("pack-folder"),
HttpUrlPath(packFileRaw.toHttpUrl().resolve(".")!!, packFileRaw.toHttpUrl().pathSegments.last()) manifestFile = cmd.getOptionValue("meta-file")
} )
// File URIs (uses same logic as old packwiz-installer, for backwards compat) } catch (e: URISyntaxException) {
Regex("^file:", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> { ui.showErrorAndExit("Failed to read pack.toml URI", e)
ui.wrap("Failed to parse file path for pack file: $packFileRaw") {
val path = Paths.get(URI(packFileRaw)).toOkioPath()
PackwizFilePath(path.parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), path.name)
}
}
// Other URIs (unsupported)
Regex("^[a-z][a-z\\d+\\-.]*://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.showErrorAndExit("Unsupported scheme for pack file: $packFileRaw")
// None of the above matches -> interpret as file path
else -> PackwizFilePath(packFileRaw.toPath().parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), packFileRaw.toPath().name)
}
val side = cmd.getOptionValue("side")?.let {
Side.from(it) ?: ui.showErrorAndExit("Unknown side name: $it")
} ?: Side.CLIENT
val packFolder = ui.wrap("Invalid pack folder path") {
cmd.getOptionValue("pack-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath(".".toPath())
}
val multimcFolder = ui.wrap("Invalid MultiMC folder path") {
cmd.getOptionValue("multimc-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath("..".toPath())
}
val manifestFile = ui.wrap("Invalid manifest file path") {
packFolder / (cmd.getOptionValue("meta-file") ?: "packwiz.json")
}
val timeout = ui.wrap("Invalid timeout value") {
cmd.getOptionValue("timeout")?.toLong() ?: 10
} }
// Start update process! // Start update process!
try { try {
UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side, timeout), ui) UpdateManager(uOptions, ui)
} catch (e: Exception) { } catch (e: Exception) {
ui.showErrorAndExit("Update process failed", e) ui.showErrorAndExit("Update process failed", e)
} }
@ -124,9 +93,7 @@ class Main(args: Array<String>) {
options.addOption("s", "side", true, "Side to install mods from (client/server, defaults to client)") 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") options.addOption(null, "title", true, "Title of the installer window")
options.addOption(null, "pack-folder", true, "Folder to install the pack to (defaults to the JAR directory)") options.addOption(null, "pack-folder", true, "Folder to install the pack to (defaults to the JAR directory)")
options.addOption(null, "multimc-folder", true, "The MultiMC pack folder (defaults to the parent of the pack directory)")
options.addOption(null, "meta-file", true, "JSON file to store pack metadata, relative to the pack folder (defaults to packwiz.json)") options.addOption(null, "meta-file", true, "JSON file to store pack metadata, relative to the pack folder (defaults to packwiz.json)")
options.addOption("t", "timeout", true, "Seconds to wait before automatically launching when asking about optional mods (defaults to 10)")
} }
// TODO: link these somehow so they're only defined once? // TODO: link these somehow so they're only defined once?
@ -139,12 +106,6 @@ class Main(args: Array<String>) {
options.addOption("g", "no-gui", false, "Don't display a GUI to show update progress") options.addOption("g", "no-gui", false, "Don't display a GUI to show update progress")
options.addOption("h", "help", false, "Display this message") // Implemented in packwiz-installer-bootstrap! options.addOption("h", "help", false, "Display this message") // Implemented in packwiz-installer-bootstrap!
} }
@JvmStatic
fun main(args: Array<String>) {
Log.info("packwiz-installer was started without packwiz-installer-bootstrap. Use the bootstrapper for automatic updates! (Disregard this message if you have your own update mechanism)")
Main(args)
}
} }
// Actual main() is in RequiresBootstrap! // Actual main() is in RequiresBootstrap!

@ -1,32 +1,33 @@
package link.infra.packwiz.installer package link.infra.packwiz.installer
import cc.ekblad.toml.decode
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonIOException import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import com.google.gson.annotations.SerializedName
import com.moandjiezana.toml.Toml
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex
import link.infra.packwiz.installer.metadata.DownloadMode
import link.infra.packwiz.installer.metadata.IndexFile import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile import link.infra.packwiz.installer.metadata.ManifestFile
import link.infra.packwiz.installer.metadata.PackFile import link.infra.packwiz.installer.metadata.PackFile
import link.infra.packwiz.installer.metadata.curseforge.resolveCfMetadata import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.metadata.hash.Hash import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
import link.infra.packwiz.installer.request.RequestException import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
import link.infra.packwiz.installer.target.ClientHolder import link.infra.packwiz.installer.request.HandlerManager.getFileSource
import link.infra.packwiz.installer.target.Side import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.ui.IUserInterface import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.InstallProgress import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import link.infra.packwiz.installer.util.ifletOrErr
import okio.buffer import okio.buffer
import java.io.FileNotFoundException
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Paths
import java.util.concurrent.CompletionService import java.util.concurrent.CompletionService
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorCompletionService import java.util.concurrent.ExecutorCompletionService
@ -43,33 +44,78 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
data class Options( data class Options(
val packFile: PackwizPath<*>, val downloadURI: SpaceSafeURI,
val manifestFile: PackwizFilePath, val manifestFile: String,
val packFolder: PackwizFilePath, val packFolder: String,
val multimcFolder: PackwizFilePath, val side: Side
val side: Side, ) {
val timeout: Long, // Horrible workaround for default params not working cleanly with nullable values
) companion object {
fun construct(downloadURI: SpaceSafeURI, manifestFile: String?, packFolder: String?, side: Side?) =
// TODO: make this return a value based on results? Options(downloadURI, manifestFile ?: "packwiz.json", packFolder ?: ".", side ?: Side.CLIENT)
private fun start() {
val clientHolder = ClientHolder()
ui.cancelCallback = {
clientHolder.close()
} }
ui.submitProgress(InstallProgress("Loading manifest file...")) enum class Side {
val gson = GsonBuilder() @SerializedName("client")
.registerTypeAdapter(Hash::class.java, Hash.TypeHandler()) CLIENT("client"),
.registerTypeAdapter(PackwizFilePath::class.java, PackwizPath.adapterRelativeTo(opts.packFolder)) @SerializedName("server")
.enableComplexMapKeySerialization() SERVER("server"),
.create() @SerializedName("both")
val manifest = try { @Suppress("unused")
// TODO: kotlinx.serialisation? BOTH("both", arrayOf(CLIENT, SERVER));
InputStreamReader(opts.manifestFile.source(clientHolder).inputStream(), StandardCharsets.UTF_8).use { reader ->
gson.fromJson(reader, ManifestFile::class.java) private val sideName: String
private val depSides: Array<Side>?
constructor(sideName: String) {
this.sideName = sideName.toLowerCase()
depSides = null
} }
} catch (e: RequestException.Response.File.FileNotFound) {
constructor(sideName: String, depSides: Array<Side>) {
this.sideName = sideName.toLowerCase()
this.depSides = depSides
}
override fun toString() = sideName
fun hasSide(tSide: Side): Boolean {
if (this == tSide) {
return true
}
if (depSides != null) {
for (depSide in depSides) {
if (depSide == tSide) {
return true
}
}
}
return false
}
companion object {
fun from(name: String): Side? {
val lowerName = name.toLowerCase()
for (side in values()) {
if (side.sideName == lowerName) {
return side
}
}
return null
}
}
}
}
private fun start() {
checkOptions()
ui.submitProgress(InstallProgress("Loading manifest file..."))
val gson = GsonBuilder().registerTypeAdapter(Hash::class.java, Hash.TypeHandler()).create()
val manifest = try {
gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
ManifestFile::class.java)
} catch (e: FileNotFoundException) {
ui.firstInstall = true ui.firstInstall = true
ManifestFile() ManifestFile()
} catch (e: JsonSyntaxException) { } catch (e: JsonSyntaxException) {
@ -85,15 +131,14 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
ui.submitProgress(InstallProgress("Loading pack file...")) ui.submitProgress(InstallProgress("Loading pack file..."))
val packFileSource = try { val packFileSource = try {
val src = opts.packFile.source(clientHolder) val src = getFileSource(opts.downloadURI)
HashFormat.SHA256.source(src) getHasher("sha256").getHashingSource(src)
} catch (e: Exception) { } catch (e: Exception) {
// TODO: ensure suppressed/caused exceptions are shown?
ui.showErrorAndExit("Failed to download pack.toml", e) ui.showErrorAndExit("Failed to download pack.toml", e)
} }
val pf = packFileSource.buffer().use { val pf = packFileSource.buffer().use {
try { try {
PackFile.mapper(opts.packFile).decode<PackFile>(it.inputStream()) Toml().read(it.inputStream()).to(PackFile::class.java)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse pack.toml", e) ui.showErrorAndExit("Failed to parse pack.toml", e)
} }
@ -104,68 +149,40 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
handleCancellation() handleCancellation()
} }
// Launcher checks
val lu = LauncherUtils(opts, ui)
// MultiMC MC and loader version checker
ui.submitProgress(InstallProgress("Loading MultiMC pack file..."))
try {
when (lu.handleMultiMC(pf, gson)) {
LauncherUtils.LauncherStatus.CANCELLED -> cancelled = true
LauncherUtils.LauncherStatus.NOT_FOUND -> Log.info("MultiMC not detected")
else -> {}
}
handleCancellation()
} catch (e: Exception) {
ui.showErrorAndExit(e.message!!, e)
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
handleCancellation()
}
ui.submitProgress(InstallProgress("Checking local files...")) ui.submitProgress(InstallProgress("Checking local files..."))
// If the side changes, invalidate EVERYTHING (even when the index hasn't changed) // Invalidation checking must be done here, as it must happen before pack/index hashes are checked
val invalidateAll = opts.side != manifest.cachedSide val invalidatedUris: MutableList<SpaceSafeURI> = ArrayList()
val invalidatedUris: MutableList<PackwizFilePath> = ArrayList() for ((fileUri, file) in manifest.cachedFiles) {
if (!invalidateAll) { // ignore onlyOtherSide files
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked if (file.onlyOtherSide) {
for ((fileUri, file) in manifest.cachedFiles) { continue
// ignore onlyOtherSide files
if (file.onlyOtherSide) {
continue
}
var invalid = false
// if isn't optional, or is optional but optionValue == true
if (!file.isOptional || file.optionValue) {
if (file.cachedLocation != null) {
if (!file.cachedLocation!!.nioPath.toFile().exists()) {
invalid = true
}
} else {
// if cachedLocation == null, should probably be installed!!
invalid = true
}
}
if (invalid) {
Log.info("File ${fileUri.filename} invalidated, marked for redownloading")
invalidatedUris.add(fileUri)
}
} }
if (manifest.packFileHash?.let { it == packFileSource.hash } == true && invalidatedUris.isEmpty()) { var invalid = false
// todo: --force? // if isn't optional, or is optional but optionValue == true
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1)) if (!file.isOptional || file.optionValue) {
if (manifest.cachedFiles.any { it.value.isOptional }) { if (file.cachedLocation != null) {
ui.awaitOptionalButton(false, opts.timeout) if (!Paths.get(opts.packFolder, file.cachedLocation).toFile().exists()) {
} invalid = true
if (!ui.optionsButtonPressed) { }
return } else {
// if cachedLocation == null, should probably be installed!!
invalid = true
} }
} }
if (invalid) {
Log.info("File $fileUri invalidated, marked for redownloading")
invalidatedUris.add(fileUri)
}
}
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
Log.info("Modpack is already up to date!")
// todo: --force?
if (!ui.optionsButtonPressed) {
return
}
} }
Log.info("Modpack name: ${pf.name}") Log.info("Modpack name: ${pf.name}")
@ -175,21 +192,27 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
handleCancellation() handleCancellation()
} }
try { try {
processIndex( // TODO: switch to OkHttp for better redirect handling
pf.index.file, 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 ->
pf.index.hashFormat.fromString(pf.index.hash), ui.ifletOrErr(index.hashFormat, index.hash, "Pack has no hash or hashFormat for index") { hashFormat, hash ->
pf.index.hashFormat, ui.ifletOrErr(getNewLoc(opts.downloadURI, index.file), "Pack has invalid index file: " + index.file) { newLoc ->
manifest, processIndex(
invalidatedUris, newLoc,
invalidateAll, getHash(hashFormat, hash),
clientHolder hashFormat,
) manifest,
invalidatedUris
)
}
}
}
} catch (e1: Exception) { } catch (e1: Exception) {
ui.showErrorAndExit("Failed to process index file", e1) ui.showErrorAndExit("Failed to process index file", e1)
} }
handleCancellation() handleCancellation()
// TODO: update MMC params, java args etc
// If there were errors, don't write the manifest/index hashes, to ensure they are rechecked later // If there were errors, don't write the manifest/index hashes, to ensure they are rechecked later
if (errorsOccurred) { if (errorsOccurred) {
@ -201,43 +224,38 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
manifest.cachedSide = opts.side manifest.cachedSide = opts.side
try { try {
Files.newBufferedWriter(opts.manifestFile.nioPath, StandardCharsets.UTF_8).use { writer -> gson.toJson(manifest, writer) } FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString()).use { writer -> gson.toJson(manifest, writer) }
} catch (e: IOException) { } catch (e: IOException) {
ui.showErrorAndExit("Failed to save local manifest file", e) ui.showErrorAndExit("Failed to save local manifest file", e)
} }
} }
private fun processIndex(indexUri: PackwizPath<*>, indexHash: Hash<*>, hashFormat: HashFormat<*>, manifest: ManifestFile, invalidatedFiles: List<PackwizFilePath>, invalidateAll: Boolean, clientHolder: ClientHolder) { private fun checkOptions() {
if (!invalidateAll) { // TODO: implement
if (manifest.indexFileHash == indexHash && invalidatedFiles.isEmpty()) { }
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) { private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
ui.awaitOptionalButton(false, opts.timeout) if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
} Log.info("Modpack files are already up to date!")
if (!ui.optionsButtonPressed) { if (!ui.optionsButtonPressed) {
return return
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
} }
} }
manifest.indexFileHash = indexHash manifest.indexFileHash = indexHash
val indexFileSource = try { val indexFileSource = try {
val src = indexUri.source(clientHolder) val src = getFileSource(indexUri)
hashFormat.source(src) getHasher(hashFormat).getHashingSource(src)
} catch (e: Exception) { } catch (e: Exception) {
ui.showErrorAndExit("Failed to download index file", e) ui.showErrorAndExit("Failed to download index file", e)
} }
val indexFile = try { val indexFile = try {
IndexFile.mapper(indexUri).decode<IndexFile>(indexFileSource.buffer().inputStream()) Toml().read(indexFileSource.buffer().inputStream()).to(IndexFile::class.java)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse index file", e) ui.showErrorAndExit("Failed to parse index file", e)
} }
if (indexHash != indexFileSource.hash) { if (!indexFileSource.hashIsEqual(indexHash)) {
ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again") ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again")
} }
@ -247,17 +265,31 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
ui.submitProgress(InstallProgress("Checking local files...")) ui.submitProgress(InstallProgress("Checking local files..."))
val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator() // TODO: use kotlin filtering/FP rather than an iterator?
val it: MutableIterator<Map.Entry<SpaceSafeURI, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
while (it.hasNext()) { while (it.hasNext()) {
val (uri, file) = it.next() val (uri, file) = it.next()
if (file.cachedLocation != null) { if (file.cachedLocation != null) {
if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index var alreadyDeleted = false
// Delete if option value has been set to false
if (file.isOptional && !file.optionValue) {
try { try {
Files.deleteIfExists(file.cachedLocation!!.nioPath) Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
} catch (e: IOException) { } catch (e: IOException) {
Log.warn("Failed to delete file removed from index", e) Log.warn("Failed to delete optional disabled file", e)
}
// Set to null, as it doesn't exist anymore
file.cachedLocation = null
alreadyDeleted = true
}
if (indexFile.files.none { it.file == uri }) { // File has been removed from the index
if (!alreadyDeleted) {
try {
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
} catch (e: IOException) {
Log.warn("Failed to delete file removed from index", e)
}
} }
Log.info("Deleted ${file.cachedLocation!!.filename} (removed from pack)")
it.remove() it.remove()
} }
} }
@ -273,7 +305,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
if (indexFile.files.isEmpty()) { if (indexFile.files.isEmpty()) {
Log.warn("Index is empty!") Log.warn("Index is empty!")
} }
val tasks = createTasksFromIndex(indexFile, opts.side) 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) { if (invalidateAll) {
Log.info("Side changed, invalidating all mods") Log.info("Side changed, invalidating all mods")
} }
@ -281,10 +316,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: should linkedfile be checked as well? should this be done in the download section? // TODO: should linkedfile be checked as well? should this be done in the download section?
if (invalidateAll) { if (invalidateAll) {
f.invalidate() f.invalidate()
} else if (invalidatedFiles.contains(f.metadata.file.rebase(opts.packFolder))) { } else if (invalidatedUris.contains(f.metadata.file)) {
f.invalidate() f.invalidate()
} }
val file = manifest.cachedFiles[f.metadata.file.rebase(opts.packFolder)] val file = manifest.cachedFiles[f.metadata.file]
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back // Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
file?.backup() file?.backup()
// If it is null, the DownloadTask will make a new empty cachedFile // If it is null, the DownloadTask will make a new empty cachedFile
@ -297,7 +332,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
// Let's hope downloadMetadata is a pure function!!! // Let's hope downloadMetadata is a pure function!!!
tasks.parallelStream().forEach { f -> f.downloadMetadata(clientHolder) } tasks.parallelStream().forEach { f -> f.downloadMetadata(indexFile, indexUri) }
val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList() val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTaskDetails.isNotEmpty()) { if (failedTaskDetails.isNotEmpty()) {
@ -321,22 +356,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
// TODO: task failed function? // TODO: task failed function?
tasks.removeAll { it.failed() } val nonFailedFirstTasks = tasks.filter { t -> !t.failed() }.toList()
val optionTasks = tasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList() val optionTasks = nonFailedFirstTasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
val optionsChanged = optionTasks.any(DownloadTask::isNewOptional)
if (optionTasks.isNotEmpty() && !optionsChanged) {
if (!ui.optionsButtonPressed) {
// TODO: this is so ugly
ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1))
ui.awaitOptionalButton(true, opts.timeout)
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
}
// If options changed, present all options again // If options changed, present all options again
if (ui.optionsButtonPressed || optionsChanged) { if (ui.optionsButtonPressed || optionTasks.any(DownloadTask::isNewOptional)) {
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list // new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
if (ui.showOptions(ArrayList(optionTasks))) { if (ui.showOptions(ArrayList(optionTasks))) {
cancelled = true cancelled = true
@ -346,20 +369,12 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// TODO: keep this enabled? then apply changes after download process? // TODO: keep this enabled? then apply changes after download process?
ui.disableOptionsButton(optionTasks.isNotEmpty()) ui.disableOptionsButton(optionTasks.isNotEmpty())
while (true) {
when (validateAndResolve(tasks, clientHolder)) {
ResolveResult.RETRY -> {}
ResolveResult.QUIT -> return
ResolveResult.SUCCESS -> break
}
}
// TODO: different thread pool type? // TODO: different thread pool type?
val threadPool = Executors.newFixedThreadPool(10) val threadPool = Executors.newFixedThreadPool(10)
val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool) val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool)
tasks.forEach { t -> tasks.forEach { t ->
completionService.submit { completionService.submit {
t.download(opts.packFolder, clientHolder) t.download(opts.packFolder, indexUri)
t t
} }
} }
@ -376,10 +391,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
if (task.failed()) { if (task.failed()) {
val oldFile = file.revert val oldFile = file.revert
if (oldFile != null) { if (oldFile != null) {
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), oldFile) task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, oldFile) }
} else { null } } else { null }
} else { } else {
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), file) task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, file) }
} }
} }
@ -387,22 +402,11 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
val progress = if (exDetails != null) { val progress = if (exDetails != null) {
"Failed to download ${exDetails.name}: ${exDetails.exception.message}" "Failed to download ${exDetails.name}: ${exDetails.exception.message}"
} else { } else {
when (task.completionStatus) { "Downloaded ${task.name}"
DownloadTask.CompletionStatus.INCOMPLETE -> "${task.name} pending (you should never see this...)"
DownloadTask.CompletionStatus.DOWNLOADED -> "Downloaded ${task.name}"
DownloadTask.CompletionStatus.ALREADY_EXISTS_CACHED -> "${task.name} already exists (cached)"
DownloadTask.CompletionStatus.ALREADY_EXISTS_VALIDATED -> "${task.name} already exists (validated)"
DownloadTask.CompletionStatus.SKIPPED_DISABLED -> "Skipped ${task.name} (disabled)"
DownloadTask.CompletionStatus.SKIPPED_WRONG_SIDE -> "Skipped ${task.name} (wrong side)"
DownloadTask.CompletionStatus.DELETED_DISABLED -> "Deleted ${task.name} (disabled)"
DownloadTask.CompletionStatus.DELETED_WRONG_SIDE -> "Deleted ${task.name} (wrong side)"
}
} }
ui.submitProgress(InstallProgress(progress, i + 1, tasks.size)) ui.submitProgress(InstallProgress(progress, i + 1, tasks.size))
if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!) if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!)
// TODO: close client holder in more places?
clientHolder.close()
threadPool.shutdown() threadPool.shutdown()
cancelled = true cancelled = true
return return
@ -412,7 +416,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// Shut down the thread pool when the update is done // Shut down the thread pool when the update is done
threadPool.shutdown() threadPool.shutdown()
val failedTasks2ElectricBoogaloo = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList() val failedTasks2ElectricBoogaloo = nonFailedFirstTasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTasks2ElectricBoogaloo.isNotEmpty()) { if (failedTasks2ElectricBoogaloo.isNotEmpty()) {
errorsOccurred = true errorsOccurred = true
when (ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false)) { when (ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false)) {
@ -423,49 +427,6 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
} }
enum class ResolveResult {
RETRY,
QUIT,
SUCCESS;
}
private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>, clientHolder: ClientHolder): ResolveResult {
ui.submitProgress(InstallProgress("Validating existing files..."))
// Validate existing files
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
downloadTask.validateExistingFile(opts.packFolder, clientHolder)
}
// 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 == DownloadMode.CURSEFORGE }.toList()
if (cfFiles.isNotEmpty()) {
ui.submitProgress(InstallProgress("Resolving CurseForge metadata..."))
val resolveFailures = resolveCfMetadata(cfFiles, opts.packFolder, clientHolder)
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() { private fun showCancellationDialog() {
when (ui.showCancellationDialog()) { when (ui.showCancellationDialog()) {
CancellationResult.QUIT -> cancelled = true CancellationResult.QUIT -> cancelled = true
@ -483,5 +444,4 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
exitProcess(0) exitProcess(0)
} }
} }
} }

@ -1,19 +0,0 @@
package link.infra.packwiz.installer.metadata
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
enum class DownloadMode {
URL,
CURSEFORGE;
companion object {
fun mapper() = tomlMapper {
decoder { it: TomlValue.String -> when (it.value) {
"", "url" -> URL
"metadata:curseforge" -> CURSEFORGE
else -> throw Exception("Unsupported download mode ${it.value}")
} }
}
}
}

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

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

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

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

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

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

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

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

@ -1,159 +0,0 @@
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.target.ClientHolder
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.closeQuietly
import okio.ByteString.Companion.decodeBase64
import java.nio.charset.StandardCharsets
import kotlin.io.path.absolute
private class GetFilesRequest(val fileIds: List<Int>)
private class GetModsRequest(val modIds: List<Int>)
private class GetFilesResponse {
class CfFile {
var id = 0
var modId = 0
var downloadUrl: String? = 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)
@Throws(JsonSyntaxException::class, JsonIOException::class)
fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> {
val failures = mutableListOf<ExceptionDetails>()
val fileIdMap = mutableMapOf<Int, List<IndexFile.File>>()
for (mod in mods) {
if (!mod.linkedFile!!.update.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue
}
val fileId = (mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId
fileIdMap[fileId] = (fileIdMap[fileId] ?: listOf()) + 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, List<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] = (manualDownloadMods[file.modId] ?: listOf()) + file.id
continue
}
try {
for (indexFile in fileIdMap[file.id]!!) {
indexFile.linkedFile!!.resolvedUpdateData["curseforge"] =
HttpUrlPath(file.downloadUrl!!.toHttpUrl())
}
} catch (e: IllegalArgumentException) {
failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to parse URL: ${file.downloadUrl} for ID ${file.id}, Project ID ${file.modId}", e)))
}
}
// Some file types don't show up in the API at all! (e.g. shaderpacks)
// Add unresolved files to manualDownloadMods
for ((fileId, indexFiles) in fileIdMap) {
for (file in indexFiles) {
if (file.linkedFile != null) {
if (file.linkedFile!!.resolvedUpdateData["curseforge"] == null) {
val projectId = (file.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).projectId
manualDownloadMods[projectId] = (manualDownloadMods[projectId] ?: listOf()) + fileId
}
}
}
}
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
}
for (fileId in manualDownloadMods[mod.id]!!) {
if (!fileIdMap.contains(fileId)) {
failures.add(ExceptionDetails(mod.name,
Exception("Failed to find file from result: file ID $fileId")))
continue
}
for (indexFile in fileIdMap[fileId]!!) {
var modUrl = "${mod.links?.websiteUrl}/files/${fileId}"
failures.add(ExceptionDetails(indexFile.name, Exception("This mod is excluded from the CurseForge API and must be downloaded manually.\n" +
"Please go to ${modUrl} and save this file to ${indexFile.destURI.rebase(packFolder).nioPath.absolute()}"), modUrl))
}
}
}
}
return failures
}

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

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

@ -1,22 +0,0 @@
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
}
}

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

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

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

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

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

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

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

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

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

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

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

@ -1,67 +0,0 @@
package link.infra.packwiz.installer.request
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)
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", 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 res: okhttp3.Response
constructor(req: okhttp3.Request, res: okhttp3.Response, message: String, cause: Throwable) : super("Failed to make HTTP request to ${req.url}: $message", cause) {
this.res = res
}
constructor(req: okhttp3.Request, res: okhttp3.Response, message: String) : super("Failed to make HTTP request to ${req.url}: $message") {
this.res = res
}
class ErrorCode(req: okhttp3.Request, res: okhttp3.Response): HTTP(req, 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)
}
}
}

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

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

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

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

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

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

@ -1,57 +0,0 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.util.Log
import okhttp3.OkHttpClient
import okhttp3.Response
import okio.FileSystem
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
class ClientHolder {
// Tries 10s timeouts (default), then 15s timeouts, then 60s timeouts
private val retryTimes = arrayOf(15, 60)
// TODO: a button to increase timeouts temporarily when retrying? manual retry button?
val okHttpClient by lazy { OkHttpClient.Builder()
// Retry requests according to retryTimes list
.addInterceptor {
val req = it.request()
var lastException: SocketTimeoutException? = null
var res: Response? = null
try {
res = it.proceed(req)
} catch (e: SocketTimeoutException) {
lastException = e
}
var tryCount = 0
while (res == null && tryCount < retryTimes.size) {
Log.info("OkHttp connection to ${req.url} timed out; retrying... (${tryCount + 1}/${retryTimes.size})")
val longerTimeoutChain = it
.withConnectTimeout(retryTimes[tryCount], TimeUnit.SECONDS)
.withReadTimeout(retryTimes[tryCount], TimeUnit.SECONDS)
.withWriteTimeout(retryTimes[tryCount], TimeUnit.SECONDS)
try {
res = longerTimeoutChain.proceed(req)
} catch (e: SocketTimeoutException) {
lastException = e
}
tryCount++
}
res ?: throw lastException!!
}
.build() }
val fileSystem = FileSystem.SYSTEM
fun close() {
okHttpClient.dispatcher.cancelAll()
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
}
}

@ -1,13 +0,0 @@
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
}

@ -1,48 +0,0 @@
package link.infra.packwiz.installer.target
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import com.google.gson.annotations.SerializedName
enum class Side(sideName: String) {
@SerializedName("client")
CLIENT("client"),
@SerializedName("server")
SERVER("server"),
@SerializedName("both")
@Suppress("unused")
BOTH("both") {
override fun hasSide(tSide: Side): Boolean {
return true
}
};
private val sideName: String
init {
this.sideName = sideName.lowercase()
}
override fun toString() = sideName
open fun hasSide(tSide: Side): Boolean {
return this == tSide || tSide == BOTH
}
companion object {
fun from(name: String): Side? {
val lowerName = name.lowercase()
for (side in values()) {
if (side.sideName == lowerName) {
return side
}
}
return null
}
fun mapper() = tomlMapper {
encoder { it: Side -> TomlValue.String(it.sideName) }
decoder { it: TomlValue.String -> from(it.value) ?: throw Exception("Invalid side name ${it.value}") }
}
}
}

@ -1,45 +0,0 @@
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: PackwizPath<*>): IdentityToken
val ident: IdentityToken
get() = PathIdentityToken(dest) // TODO: should use local-rebased path?
/**
* A user-friendly name; defaults to the destination path of the file.
*/
val name: String
get() = dest.filename
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
}
}

@ -1,16 +0,0 @@
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
}

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

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

@ -1,128 +0,0 @@
package link.infra.packwiz.installer.target.path
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import okio.BufferedSource
abstract class PackwizPath<T: PackwizPath<T>>(path: String? = null) {
protected val path: String?
init {
if (path != null) {
// Check for NUL bytes
if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) }
// Normalise separator, to prevent differences between Unix/Windows
val pathNorm = path.replace('\\', '/')
// Split, create new lists for output
val split = pathNorm.split('/')
val canonicalised = mutableListOf<String>()
// 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.length == 2) {
if (componentNorm[0] in 'a'..'z' || componentNorm[0] in 'A'..'Z') {
if (componentNorm[1] == ':') {
throw RequestException.Validation.PathContainsVolumeLetter(path)
}
}
}
}
}
if (canonicalised.isEmpty()) {
this.path = null
} else {
// Join path
this.path = canonicalised.asReversed().joinToString("/")
}
} else {
this.path = null
}
}
protected abstract fun construct(path: String): T
protected val pathFolder: Boolean? get() = path?.endsWith("/")
abstract val folder: Boolean
protected val pathFilename: String? get() = path?.split("/")?.last()
abstract val filename: String
fun resolve(path: String): T {
return if (path.startsWith('/') || path.startsWith('\\')) {
// Absolute (but still relative to base of pack)
construct(path)
} else if (folder) {
// File in folder; append
construct((this.path ?: "") + path)
} else {
// File in parent folder; append with parent component
construct((this.path ?: "") + "/../" + path)
}
}
operator fun div(path: String) = resolve(path)
fun <U: PackwizPath<U>> rebase(path: U) = path.resolve(this.path ?: "")
val parent: T get() = resolve(if (folder) { ".." } else { "." })
/**
* Obtain a BufferedSource for this path
* @throws RequestException When resolving the file failed
*/
@Throws(RequestException::class)
abstract fun source(clientHolder: ClientHolder): BufferedSource
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PackwizPath<*>
if (path != other.path) return false
return true
}
override fun hashCode() = path.hashCode()
companion object {
fun mapperRelativeTo(base: PackwizPath<*>) = tomlMapper {
encoder { it: PackwizPath<*> -> TomlValue.String(it.path ?: "") }
decoder { it: TomlValue.String -> base.resolve(it.value) }
}
fun <T: PackwizPath<T>> adapterRelativeTo(base: T) = object : TypeAdapter<T>() {
override fun write(writer: JsonWriter, value: T?) {
writer.value(value?.path)
}
override fun read(reader: JsonReader) = base.resolve(reader.nextString())
}
}
override fun toString() = "(Unknown base) $path"
}

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

@ -1,22 +0,0 @@
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()
}
}

@ -1,20 +0,0 @@
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
}
}

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

@ -1,12 +0,0 @@
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()
}

@ -1,29 +0,0 @@
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
}
}
}
}

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

@ -1,48 +0,0 @@
package link.infra.packwiz.installer.task.formats.packwizv1
import com.google.gson.annotations.SerializedName
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.task.CacheKey
import link.infra.packwiz.installer.task.Task
import link.infra.packwiz.installer.task.TaskCombinedResult
import link.infra.packwiz.installer.task.TaskContext
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: HashFormat<*>? = null
var hash: String? = null
}
var versions: Map<String, String>? = null
}
private val internalResult by lazy {
// TODO: query, parse JSON
val packFile = PackFile()
//Toml().read(InputStreamReader(path.source(ctx.clients).inputStream(), "UTF-8")).to(PackFile::class.java)
val hashFormat = (packFile.index?.hashFormat ?: throw RuntimeException("Hash format required"))
val resolved = PackwizV1PackFile(
packFile.name ?: throw RuntimeException("Name required"), // TODO: better exception handling
path.resolve(packFile.index?.file ?: throw RuntimeException("File required")),
hashFormat.fromString(packFile.index?.hash ?: throw RuntimeException("Hash required"))
)
val hash = hashFormat.fromString("whatever was just read")
TaskCombinedResult(resolved, wasUpdated(::cache, hash))
}
override val value by internalResult::result
override val upToDate by internalResult::upToDate
}

@ -23,13 +23,6 @@ interface IUserInterface {
fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT
fun showUpdateConfirmationDialog(oldVersions: List<Pair<String, String?>>, newVersions: List<Pair<String, String?>>): UpdateConfirmationResult {
// Always update metadata when using the CLI
return UpdateConfirmationResult.UPDATE
}
fun awaitOptionalButton(showCancel: Boolean, timeout: Long)
enum class ExceptionListResult { enum class ExceptionListResult {
CONTINUE, CANCEL, IGNORE CONTINUE, CANCEL, IGNORE
} }
@ -38,22 +31,8 @@ interface IUserInterface {
QUIT, CONTINUE QUIT, CONTINUE
} }
enum class UpdateConfirmationResult {
CANCELLED, CONTINUE, UPDATE
}
var optionsButtonPressed: Boolean var optionsButtonPressed: Boolean
var cancelButtonPressed: Boolean var cancelButtonPressed: Boolean
var cancelCallback: (() -> Unit)?
var firstInstall: Boolean var firstInstall: Boolean
}
inline fun <T> IUserInterface.wrap(message: String, inner: () -> T): T {
return try {
inner.invoke()
} catch (e: Exception) {
showErrorAndExit(message, e)
}
} }

@ -11,12 +11,9 @@ import kotlin.system.exitProcess
class CLIHandler : IUserInterface { class CLIHandler : IUserInterface {
@Volatile @Volatile
override var optionsButtonPressed = false override var optionsButtonPressed = false
// TODO: treat ctrl+c as cancel?
@Volatile @Volatile
override var cancelButtonPressed = false override var cancelButtonPressed = false
@Volatile @Volatile
override var cancelCallback: (() -> Unit)? = null
@Volatile
override var firstInstall = false override var firstInstall = false
override var title: String = "" override var title: String = ""
@ -62,8 +59,4 @@ class CLIHandler : IUserInterface {
} }
return ExceptionListResult.CANCEL return ExceptionListResult.CANCEL
} }
override fun awaitOptionalButton(showCancel: Boolean, timeout: Long) {
// Do nothing
}
} }

@ -2,6 +2,5 @@ package link.infra.packwiz.installer.ui.data
data class ExceptionDetails( data class ExceptionDetails(
val name: String, val name: String,
val exception: Exception, val exception: Exception
val modUrl: String? = null )
)

@ -1,6 +1,5 @@
package link.infra.packwiz.installer.ui.gui package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.util.Log
import link.infra.packwiz.installer.ui.IUserInterface import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.data.ExceptionDetails import link.infra.packwiz.installer.ui.data.ExceptionDetails
import java.awt.BorderLayout import java.awt.BorderLayout
@ -25,24 +24,6 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
fun getExceptionAt(index: Int) = details[index].exception fun getExceptionAt(index: Int) = details[index].exception
} }
private fun openUrl(url: String) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI(url))
} else {
val process = Runtime.getRuntime().exec(arrayOf("xdg-open", url));
val exitValue = process.waitFor()
if (exitValue > 0) {
Log.warn("Failed to open $url: xdg-open exited with code $exitValue")
}
}
} catch (e: IOException) {
Log.warn("Failed to open $url", e)
} catch (e: URISyntaxException) {
Log.warn("Failed to open $url", e)
}
}
/** /**
* Create the dialog. * Create the dialog.
*/ */
@ -131,19 +112,6 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
this@ExceptionListWindow.dispose() this@ExceptionListWindow.dispose()
} }
}) })
val missingMods = eList.filter { it.modUrl != null }.map { it.modUrl!! }.toSet()
if (!missingMods.isEmpty()) {
add(JButton("Open missing mods").apply {
toolTipText = "Open missing mods in your browser"
addActionListener {
missingMods.forEach {
openUrl(it)
}
}
})
}
}, BorderLayout.EAST) }, BorderLayout.EAST)
// Errored label // Errored label
@ -154,8 +122,16 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
// Left buttons // Left buttons
add(JPanel().apply { add(JPanel().apply {
add(JButton("Report issue").apply { add(JButton("Report issue").apply {
addActionListener { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
openUrl("https://github.com/packwiz/packwiz-installer/issues/new") addActionListener {
try {
Desktop.getDesktop().browse(URI("https://github.com/comp500/packwiz-installer/issues/new"))
} catch (e: IOException) {
// lol the button just won't work i guess
} catch (e: URISyntaxException) {}
}
} else {
isEnabled = false
} }
}) })
}, BorderLayout.WEST) }, BorderLayout.WEST)
@ -174,4 +150,4 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
} }
}) })
} }
} }

@ -7,13 +7,10 @@ import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.ui.data.InstallProgress import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import java.awt.EventQueue import java.awt.EventQueue
import java.util.Timer
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import java.util.concurrent.CountDownLatch
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.concurrent.timer
import kotlin.system.exitProcess import kotlin.system.exitProcess
class GUIHandler : IUserInterface { class GUIHandler : IUserInterface {
@ -21,24 +18,8 @@ class GUIHandler : IUserInterface {
@Volatile @Volatile
override var optionsButtonPressed = false override var optionsButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
@Volatile @Volatile
override var cancelButtonPressed = false override var cancelButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
cancelCallback?.invoke()
}
@Volatile
override var cancelCallback: (() -> Unit)? = null
var okButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
@Volatile @Volatile
override var firstInstall = false override var firstInstall = false
@ -61,12 +42,8 @@ class GUIHandler : IUserInterface {
} }
} }
private val visibleCountdownLatch = CountDownLatch(1)
private val optionalSelectedLatch = CountDownLatch(1)
override fun show() = EventQueue.invokeLater { override fun show() = EventQueue.invokeLater {
frmPackwizlauncher.isVisible = true frmPackwizlauncher.isVisible = true
visibleCountdownLatch.countDown()
} }
override fun dispose() = EventQueue.invokeAndWait { override fun dispose() = EventQueue.invokeAndWait {
@ -170,82 +147,4 @@ class GUIHandler : IUserInterface {
} }
return future.get() return future.get()
} }
override fun showUpdateConfirmationDialog(oldVersions: List<Pair<String, String?>>, newVersions: List<Pair<String, String?>>): IUserInterface.UpdateConfirmationResult {
assert(newVersions.isNotEmpty())
val future = CompletableFuture<IUserInterface.UpdateConfirmationResult>()
EventQueue.invokeLater {
val oldVersIndex = oldVersions.map { it.first to it.second }.toMap()
val newVersIndex = newVersions.map { it.first to it.second }.toMap()
val message = StringBuilder()
message.append("<html>" +
"This modpack uses newer versions of the following:<br>" +
"<ul>")
for (oldVer in oldVersions) {
val correspondingNewVer = newVersIndex[oldVer.first]
message.append("<li>")
message.append(oldVer.first.replaceFirstChar { it.uppercase() })
message.append(": <font color=${if (oldVer.second != correspondingNewVer) "#ff0000" else "#000000"}>")
message.append(oldVer.second ?: "Not found")
message.append("</font></li>")
}
message.append("</ul>")
message.append("New versions:" +
"<ul>")
for (newVer in newVersions) {
val correspondingOldVer = oldVersIndex[newVer.first]
message.append("<li>")
message.append(newVer.first.replaceFirstChar { it.uppercase() })
message.append(": <font color=${if (newVer.second != correspondingOldVer) "#00ff00" else "#000000"}>")
message.append(newVer.second ?: "Not found")
message.append("</font></li>")
}
message.append("</ul><br>" +
"Would you like to update the versions, launch without updating, or cancel the launch?")
val options = arrayOf("Cancel", "Continue anyways", "Update")
val result = JOptionPane.showOptionDialog(frmPackwizlauncher, message,
"Updating MultiMC versions",
JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, options[2])
future.complete(
when (result) {
JOptionPane.CLOSED_OPTION, 0 -> IUserInterface.UpdateConfirmationResult.CANCELLED
1 -> IUserInterface.UpdateConfirmationResult.CONTINUE
2 -> IUserInterface.UpdateConfirmationResult.UPDATE
else -> IUserInterface.UpdateConfirmationResult.CANCELLED
}
)
}
return future.get()
}
override fun awaitOptionalButton(showCancel: Boolean, timeout: Long) {
EventQueue.invokeAndWait {
frmPackwizlauncher.showOk(!showCancel)
}
visibleCountdownLatch.await()
var closeTimer: Timer? = null
if (timeout >= 0) {
var count = 0
closeTimer = timer("timeout", true, 0, 1000) {
if (count >= timeout) {
optionalSelectedLatch.countDown()
cancel()
} else {
frmPackwizlauncher.timeoutOk(timeout - count)
count += 1
}
};
}
optionalSelectedLatch.await()
closeTimer?.cancel()
EventQueue.invokeLater {
frmPackwizlauncher.hideOk()
}
}
} }

@ -12,9 +12,6 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
private var lblProgresslabel: JLabel private var lblProgresslabel: JLabel
private var progressBar: JProgressBar private var progressBar: JProgressBar
private var btnOptions: JButton private var btnOptions: JButton
private val btnCancel: JButton
private val btnOk: JButton
private val buttonsPanel: JPanel
init { init {
setBounds(100, 100, 493, 95) setBounds(100, 100, 493, 95)
@ -38,7 +35,7 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
}, BorderLayout.CENTER) }, BorderLayout.CENTER)
// Buttons // Buttons
buttonsPanel = JPanel().apply { add(JPanel().apply {
border = EmptyBorder(0, 5, 0, 5) border = EmptyBorder(0, 5, 0, 5)
layout = GridBagLayout() layout = GridBagLayout()
@ -52,28 +49,20 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
} }
} }
add(btnOptions, GridBagConstraints().apply { add(btnOptions, GridBagConstraints().apply {
gridx = 1 gridx = 0
gridy = 0 gridy = 0
}) })
btnCancel = JButton("Cancel").apply { add(JButton("Cancel").apply {
addActionListener { addActionListener {
isEnabled = false isEnabled = false
handler.cancelButtonPressed = true handler.cancelButtonPressed = true
} }
} }, GridBagConstraints().apply {
add(btnCancel, GridBagConstraints().apply { gridx = 0
gridx = 1
gridy = 1 gridy = 1
}) })
} }, BorderLayout.EAST)
btnOk = JButton("Continue").apply {
addActionListener {
handler.okButtonPressed = true
}
}
add(buttonsPanel, BorderLayout.EAST)
} }
fun displayProgress(progress: InstallProgress) { fun displayProgress(progress: InstallProgress) {
@ -94,35 +83,4 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
isEnabled = false 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()
}
fun timeoutOk(remaining: Long) {
btnOk.text = "Continue ($remaining)"
}
} }

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

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

@ -1,19 +0,0 @@
-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.**
-keep class !link.infra.packwiz.installer.deps.**,link.infra.packwiz.installer.** { *; }
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep @interface kotlin.Metadata { *; }
-renamesourcefileattribute SourceFile
-keepattributes *Annotation*,SourceFile,LineNumberTable,Signature

@ -1,20 +1,14 @@
# Licenses # Licenses
packwiz-installer itself is under the MIT license ([Source](https://github.com/packwiz/packwiz-installer)), except for Murmur2Lib and bundled dependencies as follows: packwiz-installer itself is under the MIT license, 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)) - 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.1: Apache 2.0 ([Source](https://github.com/google/gson))
- Google Gson 2.9.0: Apache 2.0 ([Source](https://github.com/google/gson)) - Okio 2.9.0: Apache 2.0 ([Source](https://github.com/square/okio/))
- Copyright 2008 Google Inc. - Commons CLI 1.4: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/))
- Okio 3.1.0: Apache 2.0 ([Source](https://github.com/square/okio/))
- Copyright 2013 Square, Inc.
- Commons CLI 1.5: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/))
- Jetbrains Annotations 13.0: Apache 2.0 ([Source](https://github.com/JetBrains/java-annotations)) - 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.4.21: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin))
- Kotlin Standard Library 1.7.10: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin)) - toml4j 0.7.2: MIT ([Source](https://github.com/mwanji/toml4j))
- Copyright 2010-2020 JetBrains s.r.o and respective authors and developers
- 4koma 1.1.0: MIT ([Source](https://github.com/valderman/4koma))
- Copyright (c) 2021 Anton Ekblad
## Associated notices ## Associated notices
@ -28,6 +22,10 @@ The Apache Software Foundation (http://www.apache.org/).
## Full license texts ## Full license texts
### MIT ### MIT
MIT License
Copyright (c)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
@ -250,15 +248,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. 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.