Compare commits

..

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

101 changed files with 2990 additions and 4279 deletions

View File

@ -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

View File

@ -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

View File

@ -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

30
.gitignore vendored
View File

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

View File

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

10
.idea/misc.xml generated Normal file
View File

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

6
.idea/vcs.xml generated Normal file
View File

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

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2021 comp500 Copyright (c) 2019
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

View File

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

70
build.gradle Normal file
View File

@ -0,0 +1,70 @@
plugins {
id 'java'
id 'application'
id 'com.github.johnrengelman.shadow' version '5.0.0'
id 'com.palantir.git-version' version '0.11.0'
id 'com.github.breadmoirai.github-release' version '2.2.9'
}
sourceCompatibility = 1.8
dependencies {
implementation 'commons-cli:commons-cli:1.4'
implementation 'com.moandjiezana.toml:toml4j:0.7.2'
// TODO: Implement tests
//testImplementation 'junit:junit:4.12'
implementation 'com.google.code.gson:gson:2.8.1'
implementation 'com.squareup.okio:okio:2.2.2'
}
repositories {
jcenter()
}
mainClassName = 'link.infra.packwiz.installer.RequiresBootstrap'
version gitVersion()
jar {
manifest {
attributes(
'Main-Class': 'link.infra.packwiz.installer.RequiresBootstrap',
'Implementation-Version': project.version
)
}
}
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
shadowJar {
dependencies {
exclude(dependency('commons-cli:commons-cli:1.4'))
exclude(dependency('com.eclipsesource.minimal-json:minimal-json:0.9.5'))
}
}
// Used for vscode launch.json
task copyJar(type: Copy) {
from shadowJar
rename "packwiz-installer-(.*)\\.jar", "packwiz-installer.jar"
into "build/libs/"
}
build.dependsOn copyJar
githubRelease {
// IntelliJ u ok?
//noinspection GroovyAssignabilityCheck
owner "comp500"
//noinspection GroovyAssignabilityCheck
repo "packwiz-installer"
//noinspection GroovyAssignabilityCheck
tagName "${project.version}"
//noinspection GroovyAssignabilityCheck
releaseName "Release ${project.version}"
//noinspection GroovyAssignabilityCheck
draft true
//noinspection GroovyAssignabilityCheck
token getProperty("github.token")
releaseAssets = [jar.destinationDirectory.file("packwiz-installer.jar").get()]
}
tasks.githubRelease.dependsOn(build)

View File

@ -1,206 +0,0 @@
plugins {
java
application
id("com.github.johnrengelman.shadow") version "7.1.2"
id("com.palantir.git-version") version "0.13.0"
id("com.github.breadmoirai.github-release") version "2.4.1"
kotlin("jvm") version "1.7.10"
id("com.github.jk1.dependency-license-report") version "2.0"
`maven-publish`
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
}
repositories {
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 {
implementation("commons-cli:commons-cli:1.5.0")
implementation("com.google.code.gson:gson:2.9.0")
implementation("com.squareup.okio:okio:3.1.0")
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")
}
application {
mainClass.set("link.infra.packwiz.installer.RequiresBootstrap")
}
val gitVersion: groovy.lang.Closure<*> by extra
version = gitVersion()
tasks.jar {
manifest {
attributes["Main-Class"] = "link.infra.packwiz.installer.RequiresBootstrap"
attributes["Implementation-Version"] = project.version
}
}
licenseReport {
renderers = arrayOf<com.github.jk1.license.render.ReportRenderer>(
com.github.jk1.license.render.InventoryMarkdownReportRenderer("licenses.md", "packwiz-installer")
)
filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer())
}
tasks.shadowJar {
// 4koma uses kotlin-reflect; requires Kotlin metadata
//exclude("**/*.kotlin_metadata")
//exclude("**/*.kotlin_builtins")
exclude("META-INF/maven/**/*")
exclude("META-INF/proguard/**/*")
// Relocate Commons CLI, so that it doesn't clash with old packwiz-installer-bootstrap (that shades it)
relocate("org.apache.commons.cli", "link.infra.packwiz.installer.deps.commons-cli")
// from Commons CLI
exclude("META-INF/LICENSE.txt")
exclude("META-INF/NOTICE.txt")
}
val shrinkJar by tasks.registering(JavaExec::class) {
val rules = file("src/main/proguard.txt")
val r8File = base.libsDirectory.file(provider {
base.archivesName.get() + "-" + project.version + "-all-shrink.jar"
})
dependsOn(configurations.named("runtimeClasspath"))
inputs.files(tasks.shadowJar, rules)
outputs.file(r8File)
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 {
add("distJarOutput", distJar) {
classifier = "dist"
}
}
// Used for vscode launch.json
val copyJar by tasks.registering(Copy::class) {
from(distJar)
rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar")
into(layout.buildDirectory.dir("dist"))
outputs.file(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") })
}
tasks.build {
dependsOn(copyJar)
}
githubRelease {
owner("comp500")
repo("packwiz-installer")
tagName("${project.version}")
releaseName("Release ${project.version}")
draft(true)
token(findProperty("github.token") as String?)
releaseAssets(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") }.get())
}
tasks.githubRelease {
dependsOn(copyJar)
enabled = project.hasProperty("github.token") && project.findProperty("release") == "true"
}
tasks.publish {
dependsOn(tasks.githubRelease)
}
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all", "-Xallow-result-return-type", "-opt-in=kotlin.io.path.ExperimentalPathApi", "-Xlambdas=indy")
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all", "-Xallow-result-return-type", "-opt-in=kotlin.io.path.ExperimentalPathApi", "-Xlambdas=indy")
}
}
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.

View File

@ -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-5.4.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

268
gradlew vendored Executable file → Normal file
View 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 fi
case $MAX_FD in #( ulimit -n $MAX_FD
'' | soft) :;; #( if [ $? -ne 0 ] ; then
*) warn "Could not set maximum file descriptor limit: $MAX_FD"
ulimit -n "$MAX_FD" || fi
warn "Could not set maximum file descriptor limit to $MAX_FD" else
esac warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi fi
# Collect all arguments for the java command, stacking in reverse order: # For Darwin, add options to specify how the application appears in the dock
# * args from the command line if $darwin; then
# * the main class name GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
# * -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
# args, so each arg winds up back in the position where it started, but # For Cygwin, switch paths to Windows format before running java
# possibly modified. if $cygwin ; then
# APP_HOME=`cygpath --path --mixed "$APP_HOME"`
# NB: a `for` loop captures its iteration list before it begins, so CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
# changing the positional parameters here affects neither the number of JAVACMD=`cygpath --unix "$JAVACMD"`
# iterations, nor the values presented in `arg`.
shift # remove old arg # We build the pattern for arguments to be converted via cygpath
set -- "$@" "$arg" # push replacement arg ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
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 fi
# Collect all arguments for the java command; # Escape application args
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of save () {
# shell script including quotes and variable substitutions, so put them in for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
# double quotes to make sure that they get re-expanded; and echo " "
# * put everything else in single quotes, so that it's not re-expanded. }
APP_ARGS=$(save "$@")
set -- \ # Collect all arguments for the java command, following the shell quoting and substitution rules
"-Dorg.gradle.appname=$APP_BASE_NAME" \ eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted 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
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. cd "$(dirname "$0")"
# fi
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"

27
gradlew.bat vendored
View File

@ -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

View File

@ -0,0 +1,244 @@
package link.infra.packwiz.installer;
import link.infra.packwiz.installer.metadata.IndexFile;
import link.infra.packwiz.installer.metadata.ManifestFile;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
import link.infra.packwiz.installer.metadata.hash.Hash;
import link.infra.packwiz.installer.metadata.hash.HashUtils;
import link.infra.packwiz.installer.ui.IExceptionDetails;
import link.infra.packwiz.installer.ui.IOptionDetails;
import okio.Buffer;
import okio.Okio;
import okio.Source;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
class DownloadTask implements IOptionDetails, IExceptionDetails {
final IndexFile.File metadata;
ManifestFile.File cachedFile = null;
private Exception failure = null;
private boolean alreadyUpToDate = false;
private boolean metadataRequired = true;
private boolean invalidated = false;
// If file is new or isOptional changed to true, the option needs to be presented again
private boolean newOptional = true;
private final UpdateManager.Options.Side downloadSide;
private DownloadTask(IndexFile.File metadata, String defaultFormat, UpdateManager.Options.Side downloadSide) {
this.metadata = metadata;
if (metadata.hashFormat == null || metadata.hashFormat.length() == 0) {
metadata.hashFormat = defaultFormat;
}
this.downloadSide = downloadSide;
}
void invalidate() {
invalidated = true;
alreadyUpToDate = false;
}
void updateFromCache(ManifestFile.File cachedFile) {
if (failure != null) return;
if (cachedFile == null) {
this.cachedFile = new ManifestFile.File();
return;
}
this.cachedFile = cachedFile;
if (!invalidated) {
Hash currHash;
try {
currHash = HashUtils.getHash(metadata.hashFormat, metadata.hash);
} catch (Exception e) {
failure = e;
return;
}
if (currHash != null && currHash.equals(cachedFile.hash)) {
// Already up to date
alreadyUpToDate = true;
metadataRequired = false;
}
}
if (cachedFile.isOptional) {
// Because option selection dialog might set this task to true/false, metadata is always needed to download
// the file, and to show the description and name
metadataRequired = true;
}
}
void downloadMetadata(IndexFile parentIndexFile, SpaceSafeURI indexUri) {
if (failure != null) return;
if (metadataRequired) {
try {
metadata.downloadMeta(parentIndexFile, indexUri);
} catch (Exception e) {
failure = e;
return;
}
if (metadata.linkedFile != null) {
if (metadata.linkedFile.option != null) {
if (metadata.linkedFile.option.optional) {
if (cachedFile.isOptional) {
// isOptional didn't change
newOptional = false;
} else {
// isOptional false -> true, set option to it's default value
// TODO: preserve previous option value, somehow??
cachedFile.optionValue = this.metadata.linkedFile.option.defaultValue;
}
}
}
cachedFile.isOptional = isOptional();
}
}
}
void download(String packFolder, SpaceSafeURI indexUri) {
if (failure != null) return;
// Ensure it is removed
if (!cachedFile.optionValue || !correctSide()) {
if (cachedFile.cachedLocation == null) return;
try {
Files.deleteIfExists(Paths.get(packFolder, cachedFile.cachedLocation));
} catch (IOException e) {
// TODO: how much of a problem is this? use log4j/other log library to show warning?
e.printStackTrace();
}
cachedFile.cachedLocation = null;
return;
}
if (alreadyUpToDate) return;
Path destPath = Paths.get(packFolder, metadata.getDestURI().toString());
// Don't update files marked with preserve if they already exist on disk
if (metadata.preserve) {
if (Files.exists(destPath)) {
return;
}
}
try {
Hash hash;
String fileHashFormat;
if (metadata.linkedFile != null) {
hash = metadata.linkedFile.getHash();
fileHashFormat = metadata.linkedFile.download.hashFormat;
} else {
hash = metadata.getHash();
fileHashFormat = metadata.hashFormat;
}
Source src = metadata.getSource(indexUri);
GeneralHashingSource fileSource = HashUtils.getHasher(fileHashFormat).getHashingSource(src);
Buffer data = new Buffer();
Okio.buffer(fileSource).readAll(data);
if (fileSource.hashIsEqual(hash)) {
Files.createDirectories(destPath.getParent());
Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING);
} else {
// TODO: no more SYSOUT!!!!!!!!!
System.out.println("Invalid hash for " + metadata.getDestURI().toString());
System.out.println("Calculated: " + fileSource.getHash());
System.out.println("Expected: " + hash);
failure = new Exception("Hash invalid!");
}
if (cachedFile.cachedLocation != null && !destPath.equals(Paths.get(packFolder, cachedFile.cachedLocation))) {
// Delete old file if location changes
Files.delete(Paths.get(packFolder, cachedFile.cachedLocation));
}
} catch (Exception e) {
failure = e;
}
if (failure == null) {
if (cachedFile == null) {
cachedFile = new ManifestFile.File();
}
// Update the manifest file
try {
cachedFile.hash = metadata.getHash();
} catch (Exception e) {
failure = e;
return;
}
cachedFile.isOptional = isOptional();
cachedFile.cachedLocation = metadata.getDestURI().toString();
if (metadata.linkedFile != null) {
try {
cachedFile.linkedFileHash = metadata.linkedFile.getHash();
} catch (Exception e) {
failure = e;
}
}
}
}
public Exception getException() {
return failure;
}
boolean isOptional() {
if (metadata.linkedFile != null) {
return metadata.linkedFile.isOptional();
}
return false;
}
boolean isNewOptional() {
return isOptional() && this.newOptional;
}
boolean correctSide() {
if (metadata.linkedFile != null) {
return metadata.linkedFile.side.hasSide(downloadSide);
}
return true;
}
public String getName() {
return metadata.getName();
}
@Override
public boolean getOptionValue() {
return cachedFile.optionValue;
}
@Override
public String getOptionDescription() {
if (metadata.linkedFile != null) {
return metadata.linkedFile.option.description;
}
return null;
}
public void setOptionValue(boolean value) {
if (value && !cachedFile.optionValue) {
// Ensure that an update is done if it changes from false to true, or from true to false
alreadyUpToDate = false;
}
cachedFile.optionValue = value;
}
static List<DownloadTask> createTasksFromIndex(IndexFile index, String defaultFormat, UpdateManager.Options.Side downloadSide) {
ArrayList<DownloadTask> tasks = new ArrayList<>();
for (IndexFile.File file : index.files) {
tasks.add(new DownloadTask(file, defaultFormat, downloadSide));
}
return tasks;
}
}

View File

@ -0,0 +1,148 @@
package link.infra.packwiz.installer;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import link.infra.packwiz.installer.ui.CLIHandler;
import link.infra.packwiz.installer.ui.IUserInterface;
import link.infra.packwiz.installer.ui.InputStateHandler;
import link.infra.packwiz.installer.ui.InstallWindow;
import org.apache.commons.cli.*;
import javax.swing.*;
import java.awt.*;
import java.net.URISyntaxException;
@SuppressWarnings("unused")
public class Main {
// Actual main() is in RequiresBootstrap!
@SuppressWarnings("unused")
public Main(String[] args) {
// Big overarching try/catch just in case everything breaks
try {
this.startup(args);
} catch (Exception e) {
e.printStackTrace();
EventQueue.invokeLater(() -> {
JOptionPane.showMessageDialog(null,
"A fatal error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(),
"packwiz-installer", JOptionPane.ERROR_MESSAGE);
System.exit(1);
});
// In case the eventqueue is broken, exit after 1 minute
try {
Thread.sleep(60 * 1000);
} catch (InterruptedException e1) {
// Good, it was already called?
return;
}
System.exit(1);
}
}
private void startup(String[] args) {
Options options = new Options();
addNonBootstrapOptions(options);
addBootstrapOptions(options);
CommandLineParser parser = new DefaultParser();
CommandLine cmd = null;
try {
cmd = parser.parse(options, args);
} catch (ParseException e) {
e.printStackTrace();
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception e1) {
// Ignore the exceptions, just continue using the ugly L&F
}
JOptionPane.showMessageDialog(null, e.getMessage(), "packwiz-installer", JOptionPane.ERROR_MESSAGE);
System.exit(1);
}
IUserInterface ui;
// if "headless", GUI creation will fail anyway!
if (cmd.hasOption("no-gui") || GraphicsEnvironment.isHeadless()) {
ui = new CLIHandler();
} else {
ui = new InstallWindow();
}
String[] unparsedArgs = cmd.getArgs();
if (unparsedArgs.length > 1) {
ui.handleExceptionAndExit(new RuntimeException("Too many arguments specified!"));
return;
} else if (unparsedArgs.length < 1) {
ui.handleExceptionAndExit(new RuntimeException("URI to install from must be specified!"));
return;
}
String title = cmd.getOptionValue("title");
if (title != null) {
ui.setTitle(title);
}
InputStateHandler inputStateHandler = new InputStateHandler();
ui.show(inputStateHandler);
UpdateManager.Options uOptions = new UpdateManager.Options();
String side = cmd.getOptionValue("side");
if (side != null) {
uOptions.side = UpdateManager.Options.Side.from(side);
}
String packFolder = cmd.getOptionValue("pack-folder");
if (packFolder != null) {
uOptions.packFolder = packFolder;
}
String metaFile = cmd.getOptionValue("meta-file");
if (metaFile != null) {
uOptions.manifestFile = metaFile;
}
try {
uOptions.downloadURI = new SpaceSafeURI(unparsedArgs[0]);
} catch (URISyntaxException e) {
// TODO: better error message?
ui.handleExceptionAndExit(e);
return;
}
// Start update process!
// TODO: start in SwingWorker?
try {
ui.executeManager(() -> {
try {
new UpdateManager(uOptions, ui, inputStateHandler);
} catch (Exception e) {
// TODO: better error message?
ui.handleExceptionAndExit(e);
}
});
} catch (Exception e) {
// TODO: better error message?
ui.handleExceptionAndExit(e);
}
}
// Called by packwiz-installer-bootstrap to set up the help command
@SuppressWarnings("WeakerAccess")
public static void addNonBootstrapOptions(Options options) {
options.addOption("s", "side", true, "Side to install mods from (client/server, defaults to client)");
options.addOption(null, "title", true, "Title of the installer window");
options.addOption(null, "pack-folder", true, "Folder to install the pack to (defaults to the JAR directory)");
options.addOption(null, "meta-file", true, "JSON file to store pack metadata, relative to the pack folder (defaults to packwiz.json)");
}
// TODO: link these somehow so they're only defined once?
private static void addBootstrapOptions(Options options) {
options.addOption(null, "bootstrap-update-url", true, "Github API URL for checking for updates");
options.addOption(null, "bootstrap-update-token", true, "Github API Access Token, for private repositories");
options.addOption(null, "bootstrap-no-update", false, "Don't update packwiz-installer");
options.addOption(null, "bootstrap-main-jar", true, "Location of the packwiz-installer JAR file");
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!
}
}

View File

@ -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("--")) {

View File

@ -0,0 +1,493 @@
package link.infra.packwiz.installer;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import com.moandjiezana.toml.Toml;
import link.infra.packwiz.installer.metadata.IndexFile;
import link.infra.packwiz.installer.metadata.ManifestFile;
import link.infra.packwiz.installer.metadata.PackFile;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
import link.infra.packwiz.installer.metadata.hash.Hash;
import link.infra.packwiz.installer.metadata.hash.HashUtils;
import link.infra.packwiz.installer.request.HandlerManager;
import link.infra.packwiz.installer.ui.IExceptionDetails;
import link.infra.packwiz.installer.ui.IUserInterface;
import link.infra.packwiz.installer.ui.InputStateHandler;
import link.infra.packwiz.installer.ui.InstallProgress;
import okio.Okio;
import okio.Source;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
public class UpdateManager {
private final Options opts;
public final IUserInterface ui;
private boolean cancelled;
private boolean cancelledStartGame = false;
private InputStateHandler stateHandler;
private boolean errorsOccurred = false;
public static class Options {
SpaceSafeURI downloadURI = null;
String manifestFile = "packwiz.json"; // TODO: make configurable
String packFolder = ".";
Side side = Side.CLIENT;
public enum Side {
@SerializedName("client")
CLIENT("client"),
@SerializedName("server")
SERVER("server"),
@SerializedName("both")
BOTH("both", new Side[] { CLIENT, SERVER });
private final String sideName;
private final Side[] depSides;
Side(String sideName) {
this.sideName = sideName.toLowerCase();
this.depSides = null;
}
Side(String sideName, Side[] depSides) {
this.sideName = sideName.toLowerCase();
this.depSides = depSides;
}
@Override
public String toString() {
return this.sideName;
}
public boolean hasSide(Side tSide) {
if (this.equals(tSide)) {
return true;
}
if (this.depSides != null) {
for (Side depSide : this.depSides) {
if (depSide.equals(tSide)) {
return true;
}
}
}
return false;
}
public static Side from(String name) {
String lowerName = name.toLowerCase();
for (Side side : Side.values()) {
if (side.sideName.equals(lowerName)) {
return side;
}
}
return null;
}
}
}
UpdateManager(Options opts, IUserInterface ui, InputStateHandler inputStateHandler) {
this.opts = opts;
this.ui = ui;
this.stateHandler = inputStateHandler;
this.start();
}
private void start() {
this.checkOptions();
ui.submitProgress(new InstallProgress("Loading manifest file..."));
Gson gson = new GsonBuilder().registerTypeAdapter(Hash.class, new Hash.TypeHandler()).create();
ManifestFile manifest;
try {
manifest = gson.fromJson(new FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
ManifestFile.class);
} catch (FileNotFoundException e) {
manifest = new ManifestFile();
} catch (JsonSyntaxException | JsonIOException e) {
ui.handleExceptionAndExit(e);
return;
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
handleCancellation();
}
ui.submitProgress(new InstallProgress("Loading pack file..."));
GeneralHashingSource packFileSource;
try {
Source src = HandlerManager.getFileSource(opts.downloadURI);
packFileSource = HashUtils.getHasher("sha256").getHashingSource(src);
} catch (Exception e) {
// TODO: still launch the game if updating doesn't work?
// TODO: ask user if they want to launch the game, exit(1) if they don't
ui.handleExceptionAndExit(e);
return;
}
PackFile pf;
try {
pf = new Toml().read(Okio.buffer(packFileSource).inputStream()).to(PackFile.class);
} catch (IllegalStateException e) {
ui.handleExceptionAndExit(e);
return;
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
handleCancellation();
}
ui.submitProgress(new InstallProgress("Checking local files..."));
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked
List<SpaceSafeURI> invalidatedUris = new ArrayList<>();
if (manifest.cachedFiles != null) {
for (Map.Entry<SpaceSafeURI, ManifestFile.File> entry : manifest.cachedFiles.entrySet()) {
boolean invalid = false;
// if isn't optional, or is optional but optionValue == true
if (!entry.getValue().isOptional || entry.getValue().optionValue) {
if (entry.getValue().cachedLocation != null) {
if (!Files.exists(Paths.get(opts.packFolder, entry.getValue().cachedLocation))) {
invalid = true;
}
} else {
// if cachedLocation == null, should probably be installed!!
invalid = true;
}
}
if (invalid) {
SpaceSafeURI fileUri = entry.getKey();
System.out.println("File " + fileUri.toString() + " invalidated, marked for redownloading");
invalidatedUris.add(fileUri);
}
}
}
if (manifest.packFileHash != null && packFileSource.hashIsEqual(manifest.packFileHash) && invalidatedUris.isEmpty()) {
System.out.println("Modpack is already up to date!");
// todo: --force?
if (!stateHandler.getOptionsButton()) {
return;
}
}
System.out.println("Modpack name: " + pf.name);
if (stateHandler.getCancelButton()) {
showCancellationDialog();
handleCancellation();
}
try {
// This is badly written, I'll probably heavily refactor it at some point
processIndex(HandlerManager.getNewLoc(opts.downloadURI, pf.index.file),
HashUtils.getHash(pf.index.hashFormat, pf.index.hash), pf.index.hashFormat, manifest, invalidatedUris);
} catch (Exception e1) {
ui.handleExceptionAndExit(e1);
}
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 (errorsOccurred) {
manifest.indexFileHash = null;
manifest.packFileHash = null;
} else {
manifest.packFileHash = packFileSource.getHash();
}
manifest.cachedSide = opts.side;
try (Writer writer = new FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString())) {
gson.toJson(manifest, writer);
} catch (IOException e) {
// TODO: add message?
ui.handleException(e);
}
}
private void checkOptions() {
// TODO: implement
}
private void processIndex(SpaceSafeURI indexUri, Hash indexHash, String hashFormat, ManifestFile manifest, List<SpaceSafeURI> invalidatedUris) {
if (manifest.indexFileHash != null && manifest.indexFileHash.equals(indexHash) && invalidatedUris.isEmpty()) {
System.out.println("Modpack files are already up to date!");
if (!stateHandler.getOptionsButton()) {
return;
}
}
manifest.indexFileHash = indexHash;
GeneralHashingSource indexFileSource;
try {
Source src = HandlerManager.getFileSource(indexUri);
indexFileSource = HashUtils.getHasher(hashFormat).getHashingSource(src);
} catch (Exception e) {
// TODO: still launch the game if updating doesn't work?
// TODO: ask user if they want to launch the game, exit(1) if they don't
ui.handleExceptionAndExit(e);
return;
}
IndexFile indexFile;
try {
indexFile = new Toml().read(Okio.buffer(indexFileSource).inputStream()).to(IndexFile.class);
} catch (IllegalStateException e) {
ui.handleExceptionAndExit(e);
return;
}
if (!indexFileSource.hashIsEqual(indexHash)) {
// TODO: throw exception
System.out.println("I was meant to put an error message here but I'll do that later");
return;
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
if (manifest.cachedFiles == null) {
manifest.cachedFiles = new HashMap<>();
}
ui.submitProgress(new InstallProgress("Checking local files..."));
Iterator<Map.Entry<SpaceSafeURI, ManifestFile.File>> it = manifest.cachedFiles.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<SpaceSafeURI, ManifestFile.File> entry = it.next();
if (entry.getValue().cachedLocation != null) {
boolean alreadyDeleted = false;
// Delete if option value has been set to false
if (entry.getValue().isOptional && !entry.getValue().optionValue) {
try {
Files.deleteIfExists(Paths.get(opts.packFolder, entry.getValue().cachedLocation));
} catch (IOException e) {
// TODO: should this be shown to the user in some way?
e.printStackTrace();
}
// Set to null, as it doesn't exist anymore
entry.getValue().cachedLocation = null;
alreadyDeleted = true;
}
if (indexFile.files.stream().noneMatch(f -> f.file.equals(entry.getKey()))) {
// File has been removed from the index
if (!alreadyDeleted) {
try {
Files.deleteIfExists(Paths.get(opts.packFolder, entry.getValue().cachedLocation));
} catch (IOException e) {
// TODO: should this be shown to the user in some way?
e.printStackTrace();
}
}
it.remove();
}
}
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
ui.submitProgress(new InstallProgress("Comparing new files..."));
// TODO: progress bar?
if (indexFile.files == null || indexFile.files.size() == 0) {
System.out.println("Warning: Index is empty!");
indexFile.files = new ArrayList<>();
}
List<DownloadTask> tasks = DownloadTask.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
boolean invalidateAll = !opts.side.equals(manifest.cachedSide);
if (invalidateAll) {
System.out.println("Side changed, invalidating all mods");
}
tasks.forEach(f -> {
// TODO: should linkedfile be checked as well? should this be done in the download section?
if (invalidateAll) {
f.invalidate();
} else if (invalidatedUris.contains(f.metadata.file)) {
f.invalidate();
}
ManifestFile.File file = manifest.cachedFiles.get(f.metadata.file);
if (file != null) {
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
file.backup();
}
// If it is null, the DownloadTask will make a new empty cachedFile
f.updateFromCache(file);
});
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
// Let's hope downloadMetadata is a pure function!!!
tasks.parallelStream().forEach(f -> f.downloadMetadata(indexFile, indexUri));
List<IExceptionDetails> failedTasks = tasks.stream().filter(t -> t.getException() != null).collect(Collectors.toList());
if (!failedTasks.isEmpty()) {
errorsOccurred = true;
IExceptionDetails.ExceptionListResult exceptionListResult;
try {
exceptionListResult = ui.showExceptions(failedTasks, tasks.size(), true).get();
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
return;
}
switch (exceptionListResult) {
case CONTINUE:
break;
case CANCEL:
cancelled = true;
return;
case IGNORE:
cancelledStartGame = true;
return;
}
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
List<DownloadTask> nonFailedFirstTasks = tasks.stream().filter(t -> t.getException() == null).collect(Collectors.toList());
List<DownloadTask> optionTasks = nonFailedFirstTasks.stream().filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).collect(Collectors.toList());
// If options changed, present all options again
if (stateHandler.getOptionsButton() || optionTasks.stream().anyMatch(DownloadTask::isNewOptional)) {
// new ArrayList is requires so it's an IOptionDetails rather than a DownloadTask list
Future<Boolean> cancelledResult = ui.showOptions(new ArrayList<>(optionTasks));
try {
if (cancelledResult.get()) {
cancelled = true;
// TODO: Should the UI be closed somehow??
return;
}
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
}
}
ui.disableOptionsButton();
// TODO: different thread pool type?
ExecutorService threadPool = Executors.newFixedThreadPool(10);
CompletionService<DownloadTask> completionService = new ExecutorCompletionService<>(threadPool);
tasks.forEach(t -> completionService.submit(() -> {
t.download(opts.packFolder, indexUri);
return t;
}));
for (int i = 0; i < tasks.size(); i++) {
DownloadTask task;
try {
task = completionService.take().get();
} catch (InterruptedException | ExecutionException e) {
ui.handleException(e);
task = null;
}
// Update manifest - If there were no errors cachedFile has already been modified in place (good old pass by reference)
if (task != null) {
if (task.getException() != null) {
ManifestFile.File file = task.cachedFile.getRevert();
if (file != null) {
manifest.cachedFiles.putIfAbsent(task.metadata.file, file);
}
} else {
// idiot, if it wasn't there in the first place it won't magically appear there
manifest.cachedFiles.putIfAbsent(task.metadata.file, task.cachedFile);
}
}
String progress;
if (task != null) {
if (task.getException() != null) {
progress = "Failed to download " + task.metadata.getName() + ": " + task.getException().getMessage();
task.getException().printStackTrace();
} else {
// TODO: should this be revised for tasks that didn't actually download it?
progress = "Downloaded " + task.metadata.getName();
}
} else {
progress = "Failed to download, unknown reason";
}
ui.submitProgress(new InstallProgress(progress, i + 1, tasks.size()));
if (stateHandler.getCancelButton()) {
// Stop all tasks, don't launch the game (it's in an invalid state!)
threadPool.shutdown();
cancelled = true;
return;
}
}
List<IExceptionDetails> failedTasks2ElectricBoogaloo = nonFailedFirstTasks.stream().filter(t -> t.getException() != null).collect(Collectors.toList());
if (!failedTasks2ElectricBoogaloo.isEmpty()) {
errorsOccurred = true;
IExceptionDetails.ExceptionListResult exceptionListResult;
try {
exceptionListResult = ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size(), false).get();
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
return;
}
switch (exceptionListResult) {
case CONTINUE:
break;
case CANCEL:
cancelled = true;
return;
case IGNORE:
cancelledStartGame = true;
}
}
}
private void showCancellationDialog() {
IExceptionDetails.ExceptionListResult exceptionListResult;
try {
exceptionListResult = ui.showCancellationDialog().get();
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
return;
}
switch (exceptionListResult) {
case CONTINUE:
throw new RuntimeException("Continuation not allowed here!");
case CANCEL:
cancelled = true;
return;
case IGNORE:
cancelledStartGame = true;
}
}
private void handleCancellation() {
if (cancelled) {
System.out.println("Update cancelled by user!");
System.exit(1);
} else if (cancelledStartGame) {
System.out.println("Update cancelled by user! Continuing to start game...");
System.exit(0);
}
}
}

View File

@ -0,0 +1,105 @@
package link.infra.packwiz.installer.metadata;
import com.google.gson.annotations.SerializedName;
import com.moandjiezana.toml.Toml;
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
import link.infra.packwiz.installer.metadata.hash.Hash;
import link.infra.packwiz.installer.metadata.hash.HashUtils;
import link.infra.packwiz.installer.request.HandlerManager;
import okio.Okio;
import okio.Source;
import java.nio.file.Paths;
import java.util.List;
public class IndexFile {
@SerializedName("hash-format")
public String hashFormat;
public List<File> files;
public static class File {
public SpaceSafeURI file;
@SerializedName("hash-format")
public String hashFormat;
public String hash;
public SpaceSafeURI alias;
public boolean metafile;
public boolean preserve;
public transient ModFile linkedFile;
public transient SpaceSafeURI linkedFileURI;
public void downloadMeta(IndexFile parentIndexFile, SpaceSafeURI indexUri) throws Exception {
if (!metafile) {
return;
}
if (hashFormat == null || hashFormat.length() == 0) {
hashFormat = parentIndexFile.hashFormat;
}
Hash fileHash = HashUtils.getHash(hashFormat, hash);
linkedFileURI = HandlerManager.getNewLoc(indexUri, file);
Source src = HandlerManager.getFileSource(linkedFileURI);
GeneralHashingSource fileStream = HashUtils.getHasher(hashFormat).getHashingSource(src);
linkedFile = new Toml().read(Okio.buffer(fileStream).inputStream()).to(ModFile.class);
if (!fileStream.hashIsEqual(fileHash)) {
throw new Exception("Invalid mod file hash");
}
}
public Source getSource(SpaceSafeURI indexUri) throws Exception {
if (metafile) {
if (linkedFile == null) {
throw new Exception("Linked file doesn't exist!");
}
return linkedFile.getSource(linkedFileURI);
} else {
SpaceSafeURI newLoc = HandlerManager.getNewLoc(indexUri, file);
if (newLoc == null) {
throw new Exception("Index file URI is invalid");
}
return HandlerManager.getFileSource(newLoc);
}
}
public Hash getHash() throws Exception {
if (hash == null) {
// TODO: should these be more specific exceptions (e.g. IndexFileException?!)
throw new Exception("Index file doesn't have a hash");
}
if (hashFormat == null) {
throw new Exception("Index file doesn't have a hash format");
}
return HashUtils.getHash(hashFormat, hash);
}
public String getName() {
if (metafile) {
if (linkedFile != null) {
if (linkedFile.name != null) {
return linkedFile.name;
} else if (linkedFile.filename != null) {
return linkedFile.filename;
}
}
}
if (file != null) {
return Paths.get(file.getPath()).getFileName().toString();
}
// TODO: throw some kind of exception?
return "Invalid file";
}
public SpaceSafeURI getDestURI() {
if (alias != null) {
return alias;
}
if (metafile && linkedFile != null) {
// TODO: URIs are bad
return file.resolve(linkedFile.filename);
} else {
return file;
}
}
}
}

View File

@ -0,0 +1,39 @@
package link.infra.packwiz.installer.metadata;
import link.infra.packwiz.installer.UpdateManager;
import link.infra.packwiz.installer.metadata.hash.Hash;
import java.util.Map;
public class ManifestFile {
public Hash packFileHash = null;
public Hash indexFileHash = null;
public Map<SpaceSafeURI, File> cachedFiles;
// If the side changes, EVERYTHING invalidates. FUN!!!
public UpdateManager.Options.Side cachedSide = UpdateManager.Options.Side.CLIENT;
public static class File {
private transient File revert;
public Hash hash = null;
public Hash linkedFileHash = null;
public String cachedLocation = null;
public boolean isOptional = false;
public boolean optionValue = true;
// When an error occurs, the state needs to be reverted. To do this, I have a crude revert system.
public void backup() {
revert = new File();
revert.hash = hash;
revert.linkedFileHash = linkedFileHash;
revert.cachedLocation = cachedLocation;
revert.isOptional = isOptional;
revert.optionValue = optionValue;
}
public File getRevert() {
return revert;
}
}
}

View File

@ -0,0 +1,70 @@
package link.infra.packwiz.installer.metadata;
import com.google.gson.annotations.SerializedName;
import link.infra.packwiz.installer.UpdateManager.Options.Side;
import link.infra.packwiz.installer.metadata.hash.Hash;
import link.infra.packwiz.installer.metadata.hash.HashUtils;
import link.infra.packwiz.installer.request.HandlerManager;
import okio.Source;
import java.util.Map;
public class ModFile {
public String name;
public String filename;
public Side side;
public Download download;
public static class Download {
public SpaceSafeURI url;
@SerializedName("hash-format")
public String hashFormat;
public String hash;
}
public Map<String, Object> update;
public Option option;
public static class Option {
public boolean optional;
public String description;
@SerializedName("default")
public boolean defaultValue;
}
public Source getSource(SpaceSafeURI baseLoc) throws Exception {
if (download == null) {
throw new Exception("Metadata file doesn't have download");
}
if (download.url == null) {
throw new Exception("Metadata file doesn't have a download URI");
}
SpaceSafeURI newLoc = HandlerManager.getNewLoc(baseLoc, download.url);
if (newLoc == null) {
throw new Exception("Metadata file URI is invalid");
}
return HandlerManager.getFileSource(newLoc);
}
public Hash getHash() throws Exception {
if (download == null) {
throw new Exception("Metadata file doesn't have download");
}
if (download.hash == null) {
throw new Exception("Metadata file doesn't have a hash");
}
if (download.hashFormat == null) {
throw new Exception("Metadata file doesn't have a hash format");
}
return HashUtils.getHash(download.hashFormat, download.hash);
}
public boolean isOptional() {
if (option != null) {
return option.optional;
}
return false;
}
}

View File

@ -0,0 +1,21 @@
package link.infra.packwiz.installer.metadata;
import com.google.gson.annotations.SerializedName;
import java.util.Map;
public class PackFile {
public String name;
public IndexFileLoc index;
public static class IndexFileLoc {
public SpaceSafeURI file;
@SerializedName("hash-format")
public String hashFormat;
public String hash;
}
public Map<String, String> versions;
public Map<String, Object> client;
public Map<String, Object> server;
}

View File

@ -0,0 +1,88 @@
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)
public class SpaceSafeURI implements Comparable<SpaceSafeURI>, Serializable {
private final URI u;
public SpaceSafeURI(String str) throws URISyntaxException {
u = new URI(str.replace(" ", "%20"));
}
public SpaceSafeURI(URI uri) {
this.u = uri;
}
public SpaceSafeURI(String scheme, String authority, String path, String query, String fragment) throws URISyntaxException {
// TODO: do all components need to be replaced?
scheme = scheme.replace(" ", "%20");
authority = authority.replace(" ", "%20");
path = path.replace(" ", "%20");
query = query.replace(" ", "%20");
fragment = fragment.replace(" ", "%20");
u = new URI(scheme, authority, path, query, fragment);
}
public String getPath() {
return u.getPath().replace("%20", " ");
}
public String toString() {
return u.toString().replace("%20", " ");
}
@SuppressWarnings("WeakerAccess")
public SpaceSafeURI resolve(String path) {
return new SpaceSafeURI(u.resolve(path.replace(" ", "%20")));
}
public SpaceSafeURI resolve(SpaceSafeURI loc) {
return new SpaceSafeURI(u.resolve(loc.u));
}
public SpaceSafeURI relativize(SpaceSafeURI loc) {
return new SpaceSafeURI(u.relativize(loc.u));
}
@Override
public boolean equals(Object obj) {
if (obj instanceof SpaceSafeURI) {
return u.equals(((SpaceSafeURI) obj).u);
}
return false;
}
@Override
public int hashCode() {
return u.hashCode();
}
@Override
public int compareTo(SpaceSafeURI uri) {
return u.compareTo(uri.u);
}
public String getScheme() {
return u.getScheme();
}
public String getAuthority() {
return u.getAuthority();
}
public String getHost() {
return u.getHost();
}
public URL toURL() throws MalformedURLException {
return u.toURL();
}
}

View File

@ -0,0 +1,29 @@
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.
*/
class SpaceSafeURIParser implements JsonDeserializer<SpaceSafeURI> {
@Override
public SpaceSafeURI deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
try {
return new SpaceSafeURI(json.getAsString());
} catch (URISyntaxException e) {
throw new JsonParseException("Failed to parse URI", e);
}
}
// TODO: replace this with a better solution?
}

View File

@ -0,0 +1,18 @@
package link.infra.packwiz.installer.metadata.hash;
import okio.ForwardingSource;
import okio.Source;
public abstract class GeneralHashingSource extends ForwardingSource {
public GeneralHashingSource(Source delegate) {
super(delegate);
}
public abstract Hash getHash();
public boolean hashIsEqual(Object compareTo) {
return compareTo.equals(getHash());
}
}

View File

@ -0,0 +1,48 @@
package link.infra.packwiz.installer.metadata.hash;
import java.lang.reflect.Type;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
public abstract class Hash {
protected abstract String getStringValue();
protected abstract String getType();
public static class TypeHandler implements JsonDeserializer<Hash>, JsonSerializer<Hash> {
@Override
public JsonElement serialize(Hash src, Type typeOfSrc, JsonSerializationContext context) {
JsonObject out = new JsonObject();
out.add("type", new JsonPrimitive(src.getType()));
out.add("value", new JsonPrimitive(src.getStringValue()));
return out;
}
@Override
public Hash deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
JsonObject obj = json.getAsJsonObject();
String type, value;
try {
type = obj.get("type").getAsString();
value = obj.get("value").getAsString();
} catch (NullPointerException e) {
throw new JsonParseException("Invalid hash JSON data");
}
try {
return HashUtils.getHash(type, value);
} catch (Exception e) {
throw new JsonParseException("Failed to create hash object", e);
}
}
}
}

View File

@ -0,0 +1,29 @@
package link.infra.packwiz.installer.metadata.hash;
import java.util.HashMap;
import java.util.Map;
public class HashUtils {
private static final Map<String, IHasher> hashTypeConversion = new HashMap<String, IHasher>();
static {
hashTypeConversion.put("sha256", new HashingSourceHasher("sha256"));
hashTypeConversion.put("murmur2", new Murmur2Hasher());
}
public static IHasher getHasher(String type) throws Exception {
IHasher hasher = hashTypeConversion.get(type);
if (hasher == null) {
throw new Exception("Hash type not supported: " + type);
}
return hasher;
}
public static Hash getHash(String type, String value) throws Exception {
if (hashTypeConversion.containsKey(type)) {
return hashTypeConversion.get(type).getHash(value);
}
throw new Exception("Hash type not supported: " + type);
}
}

View File

@ -0,0 +1,89 @@
package link.infra.packwiz.installer.metadata.hash;
import okio.HashingSource;
import okio.Source;
public class HashingSourceHasher implements IHasher {
private String type;
HashingSourceHasher(String type) {
this.type = type;
}
// i love naming things
private class HashingSourceGeneralHashingSource extends GeneralHashingSource {
HashingSource delegateHashing;
HashingSourceHash value;
HashingSourceGeneralHashingSource(HashingSource delegate) {
super(delegate);
delegateHashing = delegate;
}
@Override
public Hash getHash() {
if (value == null) {
value = new HashingSourceHash(delegateHashing.hash().hex());
}
return value;
}
}
// 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 class HashingSourceHash extends Hash {
String value;
private HashingSourceHash(String value) {
this.value = value;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof HashingSourceHash)) {
return false;
}
HashingSourceHash objHash = (HashingSourceHash) obj;
if (value != null) {
return value.equalsIgnoreCase(objHash.value);
} else {
return objHash.value == null;
}
}
@Override
public String toString() {
return type + ": " + value;
}
@Override
protected String getStringValue() {
return value;
}
@Override
protected String getType() {
return type;
}
}
@Override
public GeneralHashingSource getHashingSource(Source delegate) {
switch (type) {
case "md5":
return new HashingSourceGeneralHashingSource(HashingSource.md5(delegate));
case "sha256":
return new HashingSourceGeneralHashingSource(HashingSource.sha256(delegate));
case "sha512":
return new HashingSourceGeneralHashingSource(HashingSource.sha512(delegate));
}
throw new RuntimeException("Invalid hash type provided");
}
@Override
public Hash getHash(String value) {
return new HashingSourceHash(value);
}
}

View File

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

View File

@ -0,0 +1,103 @@
package link.infra.packwiz.installer.metadata.hash;
import okio.Buffer;
import okio.Source;
import java.io.IOException;
public class Murmur2Hasher implements IHasher {
private class Murmur2GeneralHashingSource extends GeneralHashingSource {
Murmur2Hash value;
Buffer internalBuffer = new Buffer();
Buffer tempBuffer = new Buffer();
Source delegate;
public Murmur2GeneralHashingSource(Source delegate) {
super(delegate);
this.delegate = delegate;
}
@Override
public long read(Buffer sink, long byteCount) throws IOException {
long out = delegate.read(tempBuffer, byteCount);
if (out > -1) {
sink.write(tempBuffer.clone(), out);
internalBuffer.write(tempBuffer, out);
}
return out;
}
@Override
public Hash getHash() {
if (value == null) {
byte[] data = computeNormalizedArray(internalBuffer.readByteArray());
value = new Murmur2Hash(Murmur2Lib.hash32(data, data.length, 1));
}
return value;
}
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
private byte[] computeNormalizedArray(byte[] input) {
byte[] output = new byte[input.length];
int num = 0;
for (byte b : input) {
if (!(b == 9 || b == 10 || b == 13 || b == 32)) {
output[num] = b;
num++;
}
}
byte[] outputTrimmed = new byte[num];
System.arraycopy(output, 0, outputTrimmed, 0, num);
return outputTrimmed;
}
}
private static class Murmur2Hash extends Hash {
int value;
private Murmur2Hash(String value) {
// 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 = (int)Long.parseLong(value);
}
private Murmur2Hash(int value) {
this.value = value;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Murmur2Hash)) {
return false;
}
Murmur2Hash objHash = (Murmur2Hash) obj;
return value == objHash.value;
}
@Override
public String toString() {
return "murmur2: " + value;
}
@Override
protected String getStringValue() {
return Integer.toString(value);
}
@Override
protected String getType() {
return "murmur2";
}
}
@Override
public GeneralHashingSource getHashingSource(Source delegate) {
return new Murmur2GeneralHashingSource(delegate);
}
@Override
public Hash getHash(String value) {
return new Murmur2Hash(value);
}
}

View File

@ -74,13 +74,13 @@ public class Murmur2Lib {
int left = length - len_m; int left = length - len_m;
if (left != 0) { if (left != 0) {
if (left >= 3) { if (left >= 3) {
h ^= (int) data[length - (left - 2)] << 16; h ^= (int) data[length - 3] << 16;
} }
if (left >= 2) { if (left >= 2) {
h ^= (int) data[length - (left - 1)] << 8; h ^= (int) data[length - 2] << 8;
} }
if (left >= 1) { if (left >= 1) {
h ^= data[length - left]; h ^= (int) data[length - 1];
} }
h *= M_32; h *= M_32;
@ -152,7 +152,7 @@ public class Murmur2Lib {
case 2: case 2:
h ^= (long) (data[tailStart + 1] & 0xff) << 8; h ^= (long) (data[tailStart + 1] & 0xff) << 8;
case 1: case 1:
h ^= data[tailStart] & 0xff; h ^= (long) (data[tailStart] & 0xff);
h *= M_64; h *= M_64;
} }

View File

@ -0,0 +1,59 @@
package link.infra.packwiz.installer.request;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import link.infra.packwiz.installer.request.handlers.RequestHandlerGithub;
import link.infra.packwiz.installer.request.handlers.RequestHandlerHTTP;
import okio.Source;
import java.util.ArrayList;
import java.util.List;
public abstract class HandlerManager {
private static List<IRequestHandler> handlers = new ArrayList<>();
static {
handlers.add(new RequestHandlerGithub());
handlers.add(new RequestHandlerHTTP());
}
public static SpaceSafeURI getNewLoc(SpaceSafeURI base, SpaceSafeURI loc) {
if (loc == null) return null;
if (base != null) {
loc = base.resolve(loc);
}
for (IRequestHandler handler : handlers) {
if (handler.matchesHandler(loc)) {
return handler.getNewLoc(loc);
}
}
return loc;
}
// 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?
public static Source getFileSource(SpaceSafeURI loc) throws Exception {
for (IRequestHandler handler : handlers) {
if (handler.matchesHandler(loc)) {
Source src = handler.getFileSource(loc);
if (src == null) {
throw new Exception("Couldn't find URI: " + loc.toString());
} else {
return src;
}
}
}
// TODO: specialised exception classes??
throw new Exception("No handler available for URI: " + loc.toString());
}
// github toml resolution
// e.g. https://github.com/comp500/Demagnetize -> demagnetize.toml
// https://github.com/comp500/Demagnetize/blob/master/demagnetize.toml
// To handle "progress", just count tasks, rather than individual progress
// It'll look bad, especially for zip-based things, but it should work fine
}

View File

@ -0,0 +1,26 @@
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.
*/
public interface IRequestHandler {
boolean matchesHandler(SpaceSafeURI loc);
default SpaceSafeURI getNewLoc(SpaceSafeURI loc) {
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!!!
*/
Source getFileSource(SpaceSafeURI loc) throws Exception;
}

View File

@ -0,0 +1,90 @@
package link.infra.packwiz.installer.request.handlers;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class RequestHandlerGithub extends RequestHandlerZip {
public RequestHandlerGithub() {
super(true);
}
@Override
public SpaceSafeURI getNewLoc(SpaceSafeURI loc) {
return loc;
}
// TODO: is caching really needed, if HTTPURLConnection follows redirects correctly?
private Map<String, SpaceSafeURI> zipUriMap = new HashMap<>();
private final ReentrantReadWriteLock zipUriLock = new ReentrantReadWriteLock();
private static Pattern repoMatcherPattern = Pattern.compile("/([\\w.-]+/[\\w.-]+).*");
private String getRepoName(SpaceSafeURI loc) {
Matcher matcher = repoMatcherPattern.matcher(loc.getPath());
if (matcher.matches()) {
return matcher.group(1);
} else {
return null;
}
}
@Override
protected SpaceSafeURI getZipUri(SpaceSafeURI loc) throws Exception {
String repoName = getRepoName(loc);
String branchName = getBranch(loc);
zipUriLock.readLock().lock();
SpaceSafeURI zipUri = zipUriMap.get(repoName + "/" + branchName);
zipUriLock.readLock().unlock();
if (zipUri != null) {
return zipUri;
}
zipUri = new SpaceSafeURI("https://api.github.com/repos/" + repoName + "/zipball/" + branchName);
zipUriLock.writeLock().lock();
// If another thread sets the value concurrently, use the value of the
// thread that first acquired the lock.
SpaceSafeURI zipUriInserted = zipUriMap.putIfAbsent(repoName + "/" + branchName, zipUri);
if (zipUriInserted != null) {
zipUri = zipUriInserted;
}
zipUriLock.writeLock().unlock();
return zipUri;
}
private static Pattern branchMatcherPattern = Pattern.compile("/[\\w.-]+/[\\w.-]+/blob/([\\w.-]+).*");
private String getBranch(SpaceSafeURI loc) {
Matcher matcher = branchMatcherPattern.matcher(loc.getPath());
if (matcher.matches()) {
return matcher.group(1);
} else {
return null;
}
}
@Override
protected SpaceSafeURI getLocationInZip(SpaceSafeURI loc) throws Exception {
String path = "/" + getRepoName(loc) + "/blob/" + getBranch(loc);
return new SpaceSafeURI(loc.getScheme(), loc.getAuthority(), path, null, null).relativize(loc);
}
@Override
public boolean matchesHandler(SpaceSafeURI loc) {
String scheme = loc.getScheme();
if (!("http".equals(scheme) || "https".equals(scheme))) {
return false;
}
if (!"github.com".equals(loc.getHost())) {
return false;
}
// TODO: sanity checks, support for more github urls
return true;
}
}

View File

@ -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.Okio;
import okio.Source;
import java.net.URLConnection;
public class RequestHandlerHTTP implements IRequestHandler {
@Override
public boolean matchesHandler(SpaceSafeURI loc) {
String scheme = loc.getScheme();
return "http".equals(scheme) || "https".equals(scheme);
}
@Override
public Source getFileSource(SpaceSafeURI loc) throws Exception {
URLConnection conn = loc.toURL().openConnection();
// TODO: when do we send specific headers??? should there be a way to signal this?
// github *sometimes* requires it, sometimes not!
//conn.addRequestProperty("Accept", "application/octet-stream");
// 30 second read timeout
conn.setReadTimeout(30 * 1000);
return Okio.source(conn.getInputStream());
}
}

View File

@ -0,0 +1,169 @@
package link.infra.packwiz.installer.request.handlers;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import okio.Buffer;
import okio.BufferedSource;
import okio.Okio;
import okio.Source;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
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;
public abstract class RequestHandlerZip extends RequestHandlerHTTP {
private final boolean modeHasFolder;
public RequestHandlerZip(boolean modeHasFolder) {
this.modeHasFolder = modeHasFolder;
}
private String removeFolder(String name) {
if (modeHasFolder) {
return name.substring(name.indexOf("/")+1);
} else {
return name;
}
}
private class ZipReader {
private final ZipInputStream zis;
private final Map<SpaceSafeURI, Buffer> readFiles = new HashMap<>();
// Write lock implies access to ZipInputStream - only 1 thread must read at a time!
final ReentrantLock filesLock = new ReentrantLock();
private ZipEntry entry;
private final BufferedSource zipSource;
ZipReader(Source zip) {
zis = new ZipInputStream(Okio.buffer(zip).inputStream());
zipSource = Okio.buffer(Okio.source(zis));
}
// File lock must be obtained before calling this function
private Buffer readCurrFile() throws IOException {
Buffer fileBuffer = new Buffer();
zipSource.readFully(fileBuffer, entry.getSize());
return fileBuffer;
}
// File lock must be obtained before calling this function
private Buffer findFile(SpaceSafeURI loc) throws IOException, URISyntaxException {
while (true) {
entry = zis.getNextEntry();
if (entry == null) {
return null;
}
Buffer data = readCurrFile();
SpaceSafeURI fileLoc = new SpaceSafeURI(removeFolder(entry.getName()));
if (loc.equals(fileLoc)) {
return data;
} else {
readFiles.put(fileLoc, data);
}
}
}
Source getFileSource(SpaceSafeURI loc) throws Exception {
filesLock.lock();
// Assume files are only read once, allow GC by removing
Buffer file = readFiles.remove(loc);
if (file != null) {
filesLock.unlock();
return file;
}
file = findFile(loc);
filesLock.unlock();
return file;
}
SpaceSafeURI findInZip(Predicate<SpaceSafeURI> matches) throws Exception {
filesLock.lock();
for (SpaceSafeURI file : readFiles.keySet()) {
if (matches.test(file)) {
filesLock.unlock();
return file;
}
}
while (true) {
entry = zis.getNextEntry();
if (entry == null) {
filesLock.unlock();
return null;
}
Buffer data = readCurrFile();
SpaceSafeURI fileLoc = new SpaceSafeURI(removeFolder(entry.getName()));
readFiles.put(fileLoc, data);
if (matches.test(fileLoc)) {
filesLock.unlock();
return fileLoc;
}
}
}
}
private final Map<SpaceSafeURI, ZipReader> cache = new HashMap<>();
private final ReentrantReadWriteLock cacheLock = new ReentrantReadWriteLock();
protected abstract SpaceSafeURI getZipUri(SpaceSafeURI loc) throws Exception;
protected abstract SpaceSafeURI getLocationInZip(SpaceSafeURI loc) throws Exception;
@Override
public abstract boolean matchesHandler(SpaceSafeURI loc);
@Override
public Source getFileSource(SpaceSafeURI loc) throws Exception {
SpaceSafeURI zipUri = getZipUri(loc);
cacheLock.readLock().lock();
ZipReader zr = cache.get(zipUri);
cacheLock.readLock().unlock();
if (zr == null) {
cacheLock.writeLock().lock();
// Recheck, because unlocking read lock allows another thread to modify it
zr = cache.get(zipUri);
if (zr == null) {
Source src = super.getFileSource(zipUri);
if (src == null) {
cacheLock.writeLock().unlock();
return null;
}
zr = new ZipReader(src);
cache.put(zipUri, zr);
}
cacheLock.writeLock().unlock();
}
return zr.getFileSource(getLocationInZip(loc));
}
protected SpaceSafeURI findInZip(SpaceSafeURI loc, Predicate<SpaceSafeURI> matches) throws Exception {
SpaceSafeURI zipUri = getZipUri(loc);
cacheLock.readLock().lock();
ZipReader zr = cache.get(zipUri);
cacheLock.readLock().unlock();
if (zr == null) {
cacheLock.writeLock().lock();
// Recheck, because unlocking read lock allows another thread to modify it
zr = cache.get(zipUri);
if (zr == null) {
zr = new ZipReader(super.getFileSource(zipUri));
cache.put(zipUri, zr);
}
cacheLock.writeLock().unlock();
}
return zr.findInZip(matches);
}
}

View File

@ -0,0 +1,49 @@
package link.infra.packwiz.installer.ui;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
public class CLIHandler implements IUserInterface {
@Override
public void handleException(Exception e) {
e.printStackTrace();
}
@Override
public void show(InputStateHandler h) {}
@Override
public void submitProgress(InstallProgress progress) {
StringBuilder sb = new StringBuilder();
if (progress.hasProgress) {
sb.append('(');
sb.append(progress.progress);
sb.append('/');
sb.append(progress.progressTotal);
sb.append(") ");
}
sb.append(progress.message);
System.out.println(sb.toString());
}
@Override
public void executeManager(Runnable task) {
task.run();
System.out.println("Finished successfully!");
}
@Override
public Future<Boolean> showOptions(List<IOptionDetails> option) {
throw new RuntimeException("Optional mods not implemented for CLI! Make sure your optional mods are only on the client side!");
}
@Override
public Future<IExceptionDetails.ExceptionListResult> showExceptions(List<IExceptionDetails> opts, int numTotal, boolean allowsIgnore) {
CompletableFuture<IExceptionDetails.ExceptionListResult> future = new CompletableFuture<>();
future.complete(IExceptionDetails.ExceptionListResult.CANCEL);
return future;
}
}

View File

@ -0,0 +1,183 @@
package link.infra.packwiz.installer.ui;
import link.infra.packwiz.installer.ui.IExceptionDetails.ExceptionListResult;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
class ExceptionListWindow extends JDialog {
private static final long serialVersionUID = 1L;
private final JTextArea lblExceptionStacktrace;
/**
* Create the dialog.
*/
ExceptionListWindow(List<IExceptionDetails> eList, CompletableFuture<ExceptionListResult> future, int numTotal, boolean allowsIgnore, JFrame parentWindow) {
super(parentWindow, "Failed file downloads", true);
setBounds(100, 100, 540, 340);
setLocationRelativeTo(parentWindow);
getContentPane().setLayout(new BorderLayout());
{
JPanel errorPanel = new JPanel();
getContentPane().add(errorPanel, BorderLayout.NORTH);
{
JLabel lblWarning = new JLabel("One or more errors were encountered while installing the modpack!");
lblWarning.setIcon(UIManager.getIcon("OptionPane.warningIcon"));
errorPanel.add(lblWarning);
}
}
JPanel contentPanel = new JPanel();
contentPanel.setBorder(new EmptyBorder(5, 5, 5, 5));
getContentPane().add(contentPanel, BorderLayout.CENTER);
contentPanel.setLayout(new BorderLayout(0, 0));
{
JSplitPane splitPane = new JSplitPane();
splitPane.setResizeWeight(0.3);
contentPanel.add(splitPane);
{
lblExceptionStacktrace = new JTextArea("Select a file");
lblExceptionStacktrace.setBackground(UIManager.getColor("List.background"));
lblExceptionStacktrace.setOpaque(true);
lblExceptionStacktrace.setWrapStyleWord(true);
lblExceptionStacktrace.setLineWrap(true);
lblExceptionStacktrace.setEditable(false);
lblExceptionStacktrace.setFocusable(true);
lblExceptionStacktrace.setFont(UIManager.getFont("Label.font"));
lblExceptionStacktrace.setBorder(new EmptyBorder(5, 5, 5, 5));
JScrollPane scrollPane = new JScrollPane(lblExceptionStacktrace);
scrollPane.setBorder(new EmptyBorder(0, 0, 0, 0));
splitPane.setRightComponent(scrollPane);
}
{
JList<String> list = new JList<>();
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setBorder(new EmptyBorder(5, 5, 5, 5));
ExceptionListModel listModel = new ExceptionListModel(eList);
list.setModel(listModel);
list.addListSelectionListener(e -> {
int i = list.getSelectedIndex();
if (i > -1) {
StringWriter sw = new StringWriter();
listModel.getExceptionAt(i).printStackTrace(new PrintWriter(sw));
lblExceptionStacktrace.setText(sw.toString());
// Scroll to the top
lblExceptionStacktrace.setCaretPosition(0);
} else {
lblExceptionStacktrace.setText("Select a file");
}
});
JScrollPane scrollPane = new JScrollPane(list);
scrollPane.setBorder(new EmptyBorder(0, 0, 0, 0));
splitPane.setLeftComponent(scrollPane);
}
}
{
JPanel buttonPane = new JPanel();
getContentPane().add(buttonPane, BorderLayout.SOUTH);
buttonPane.setLayout(new BorderLayout(0, 0));
{
JPanel rightButtons = new JPanel();
buttonPane.add(rightButtons, BorderLayout.EAST);
{
JButton btnContinue = new JButton("Continue");
btnContinue.setToolTipText("Attempt to continue installing, excluding the failed downloads");
btnContinue.addActionListener(e -> {
future.complete(ExceptionListResult.CONTINUE);
ExceptionListWindow.this.dispose();
});
rightButtons.add(btnContinue);
}
{
JButton btnCancelLaunch = new JButton("Cancel launch");
btnCancelLaunch.setToolTipText("Stop launching the game");
btnCancelLaunch.addActionListener(e -> {
future.complete(ExceptionListResult.CANCEL);
ExceptionListWindow.this.dispose();
});
rightButtons.add(btnCancelLaunch);
}
{
JButton btnIgnoreUpdate = new JButton("Ignore update");
btnIgnoreUpdate.setEnabled(allowsIgnore);
btnIgnoreUpdate.setToolTipText("Start the game without attempting to update");
btnIgnoreUpdate.addActionListener(e -> {
future.complete(ExceptionListResult.IGNORE);
ExceptionListWindow.this.dispose();
});
rightButtons.add(btnIgnoreUpdate);
{
JLabel lblErrored = new JLabel(eList.size() + "/" + numTotal + " errored");
lblErrored.setHorizontalAlignment(SwingConstants.CENTER);
buttonPane.add(lblErrored, BorderLayout.CENTER);
}
{
JPanel leftButtons = new JPanel();
buttonPane.add(leftButtons, BorderLayout.WEST);
{
JButton btnReportIssue = new JButton("Report issue");
boolean supported = Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE);
btnReportIssue.setEnabled(supported);
if (supported) {
btnReportIssue.addActionListener(e -> {
try {
Desktop.getDesktop().browse(new URI("https://github.com/comp500/packwiz-installer/issues/new"));
} catch (IOException | URISyntaxException e1) {
// lol the button just won't work i guess
}
});
}
leftButtons.add(btnReportIssue);
}
}
}
}
}
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
future.complete(ExceptionListResult.CANCEL);
}
@Override
public void windowClosed(WindowEvent e) {
// Just in case closing didn't get triggered - if something else called dispose() the
// future will have already completed
future.complete(ExceptionListResult.CANCEL);
}
});
}
private static class ExceptionListModel extends AbstractListModel<String> {
private static final long serialVersionUID = 1L;
private final List<IExceptionDetails> details;
ExceptionListModel(List<IExceptionDetails> details) {
this.details = details;
}
public int getSize() {
return details.size();
}
public String getElementAt(int index) {
return details.get(index).getName();
}
Exception getExceptionAt(int index) {
return details.get(index).getException();
}
}
}

View File

@ -0,0 +1,10 @@
package link.infra.packwiz.installer.ui;
public interface IExceptionDetails {
Exception getException();
String getName();
enum ExceptionListResult {
CONTINUE, CANCEL, IGNORE
}
}

View File

@ -0,0 +1,8 @@
package link.infra.packwiz.installer.ui;
public interface IOptionDetails {
String getName();
boolean getOptionValue();
String getOptionDescription();
void setOptionValue(boolean value);
}

View File

@ -0,0 +1,38 @@
package link.infra.packwiz.installer.ui;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
public interface IUserInterface {
void show(InputStateHandler handler);
void handleException(Exception e);
default void handleExceptionAndExit(Exception e) {
handleException(e);
System.exit(1);
}
default void setTitle(String title) {}
void submitProgress(InstallProgress progress);
void executeManager(Runnable task);
// Return true if the installation was cancelled!
Future<Boolean> showOptions(List<IOptionDetails> option);
Future<IExceptionDetails.ExceptionListResult> showExceptions(List<IExceptionDetails> opts, int numTotal, boolean allowsIgnore);
default void disableOptionsButton() {}
// Should not return CONTINUE
default Future<IExceptionDetails.ExceptionListResult> showCancellationDialog() {
CompletableFuture<IExceptionDetails.ExceptionListResult> future = new CompletableFuture<>();
future.complete(IExceptionDetails.ExceptionListResult.CANCEL);
return future;
}
}

View File

@ -0,0 +1,22 @@
package link.infra.packwiz.installer.ui;
public class InputStateHandler {
private boolean optionsButtonPressed = false;
private boolean cancelButtonPressed = false;
synchronized void pressCancelButton() {
this.cancelButtonPressed = true;
}
synchronized void pressOptionsButton() {
this.optionsButtonPressed = true;
}
public synchronized boolean getCancelButton() {
return cancelButtonPressed;
}
public synchronized boolean getOptionsButton() {
return optionsButtonPressed;
}
}

View File

@ -0,0 +1,22 @@
package link.infra.packwiz.installer.ui;
public class InstallProgress {
public final String message;
public final boolean hasProgress;
public final int progress;
public final int progressTotal;
public InstallProgress(String message) {
this.message = message;
hasProgress = false;
progress = 0;
progressTotal = 0;
}
public InstallProgress(String message, int progress, int progressTotal) {
this.message = message;
hasProgress = true;
this.progress = progress;
this.progressTotal = progressTotal;
}
}

View File

@ -0,0 +1,228 @@
package link.infra.packwiz.installer.ui;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
public class InstallWindow implements IUserInterface {
private JFrame frmPackwizlauncher;
private JLabel lblProgresslabel;
private JProgressBar progressBar;
private InputStateHandler inputStateHandler;
private String title = "Updating modpack...";
private SwingWorkerButWithPublicPublish<Void, InstallProgress> worker;
private AtomicBoolean aboutToCrash = new AtomicBoolean();
private JButton btnOptions;
@Override
public void show(InputStateHandler handler) {
this.inputStateHandler = handler;
EventQueue.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
initialize();
frmPackwizlauncher.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
});
}
/**
* Initialize the contents of the frame.
* @wbp.parser.entryPoint
*/
private void initialize() {
frmPackwizlauncher = new JFrame();
frmPackwizlauncher.setTitle(title);
frmPackwizlauncher.setBounds(100, 100, 493, 95);
frmPackwizlauncher.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frmPackwizlauncher.setLocationRelativeTo(null);
JPanel panel = new JPanel();
panel.setBorder(new EmptyBorder(10, 10, 10, 10));
frmPackwizlauncher.getContentPane().add(panel, BorderLayout.CENTER);
panel.setLayout(new BorderLayout(0, 0));
progressBar = new JProgressBar();
progressBar.setIndeterminate(true);
panel.add(progressBar, BorderLayout.CENTER);
lblProgresslabel = new JLabel("Loading...");
panel.add(lblProgresslabel, BorderLayout.SOUTH);
JPanel panel_1 = new JPanel();
panel_1.setBorder(new EmptyBorder(0, 5, 0, 5));
frmPackwizlauncher.getContentPane().add(panel_1, BorderLayout.EAST);
GridBagLayout gbl_panel_1 = new GridBagLayout();
panel_1.setLayout(gbl_panel_1);
btnOptions = new JButton("Optional mods...");
btnOptions.addActionListener(e -> {
btnOptions.setText("Loading...");
btnOptions.setEnabled(false);
inputStateHandler.pressOptionsButton();
});
btnOptions.setAlignmentX(Component.CENTER_ALIGNMENT);
GridBagConstraints gbc_btnOptions = new GridBagConstraints();
gbc_btnOptions.gridx = 0;
gbc_btnOptions.gridy = 0;
panel_1.add(btnOptions, gbc_btnOptions);
JButton btnCancel = new JButton("Cancel");
btnCancel.addActionListener(e -> {
btnCancel.setEnabled(false);
inputStateHandler.pressCancelButton();
});
btnCancel.setAlignmentX(Component.CENTER_ALIGNMENT);
GridBagConstraints gbc_btnCancel = new GridBagConstraints();
gbc_btnCancel.gridx = 0;
gbc_btnCancel.gridy = 1;
panel_1.add(btnCancel, gbc_btnCancel);
}
@Override
public void handleException(Exception e) {
e.printStackTrace();
EventQueue.invokeLater(() -> {
JOptionPane.showMessageDialog(null, "An error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(), title, JOptionPane.ERROR_MESSAGE);
});
}
@Override
public void handleExceptionAndExit(Exception e) {
e.printStackTrace();
// Used to prevent the done() handler of SwingWorker executing if the invokeLater hasn't happened yet
aboutToCrash.set(true);
EventQueue.invokeLater(() -> {
JOptionPane.showMessageDialog(null, "A fatal error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(), title, JOptionPane.ERROR_MESSAGE);
System.exit(1);
});
// Pause forever, so it blocks while we wait for System.exit to take effect
try {
Thread.currentThread().join();
} catch (InterruptedException ex) {
// no u
}
}
@Override
public void setTitle(String title) {
this.title = title;
if (frmPackwizlauncher != null) {
EventQueue.invokeLater(() -> frmPackwizlauncher.setTitle(title));
}
}
@Override
public void submitProgress(InstallProgress progress) {
StringBuilder sb = new StringBuilder();
if (progress.hasProgress) {
sb.append('(');
sb.append(progress.progress);
sb.append('/');
sb.append(progress.progressTotal);
sb.append(") ");
}
sb.append(progress.message);
// TODO: better logging library?
System.out.println(sb.toString());
if (worker != null) {
worker.publishPublic(progress);
}
}
@Override
public void executeManager(Runnable task) {
EventQueue.invokeLater(() -> {
worker = new SwingWorkerButWithPublicPublish<Void, InstallProgress>() {
@Override
protected Void doInBackground() {
task.run();
return null;
}
@Override
protected void process(List<InstallProgress> chunks) {
// Only process last chunk
if (chunks.size() > 0) {
InstallProgress prog = chunks.get(chunks.size() - 1);
if (prog.hasProgress) {
progressBar.setIndeterminate(false);
progressBar.setValue(prog.progress);
progressBar.setMaximum(prog.progressTotal);
} else {
progressBar.setIndeterminate(true);
progressBar.setValue(0);
}
lblProgresslabel.setText(prog.message);
}
}
@Override
protected void done() {
if (aboutToCrash.get()) {
return;
}
// TODO: a better way to do this?
frmPackwizlauncher.dispose();
System.out.println("Finished successfully!");
System.exit(0);
}
};
worker.execute();
});
}
@Override
public Future<Boolean> showOptions(List<IOptionDetails> opts) {
CompletableFuture<Boolean> future = new CompletableFuture<>();
EventQueue.invokeLater(() -> {
OptionsSelectWindow dialog = new OptionsSelectWindow(opts, future, frmPackwizlauncher);
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
dialog.setVisible(true);
});
return future;
}
@Override
public Future<IExceptionDetails.ExceptionListResult> showExceptions(List<IExceptionDetails> opts, int numTotal, boolean allowsIgnore) {
CompletableFuture<IExceptionDetails.ExceptionListResult> future = new CompletableFuture<>();
EventQueue.invokeLater(() -> {
ExceptionListWindow dialog = new ExceptionListWindow(opts, future, numTotal, allowsIgnore, frmPackwizlauncher);
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
dialog.setVisible(true);
});
return future;
}
@Override
public void disableOptionsButton() {
if (btnOptions != null) {
btnOptions.setText("Optional mods...");
btnOptions.setEnabled(false);
}
}
@Override
public Future<IExceptionDetails.ExceptionListResult> showCancellationDialog() {
CompletableFuture<IExceptionDetails.ExceptionListResult> future = new CompletableFuture<>();
EventQueue.invokeLater(() -> {
Object[] buttons = {"Quit", "Ignore"};
int result = JOptionPane.showOptionDialog(frmPackwizlauncher,
"The installation was cancelled. Would you like to quit the game, or ignore the update and start the game?",
"Cancelled installation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, buttons, buttons[0]);
future.complete(result == 0 ? IExceptionDetails.ExceptionListResult.CANCEL : IExceptionDetails.ExceptionListResult.IGNORE);
});
return future;
}
}

View File

@ -0,0 +1,33 @@
package link.infra.packwiz.installer.ui;
// Serves as a proxy for IOptionDetails, so that setOptionValue isn't called until OK is clicked
class OptionTempHandler implements IOptionDetails {
private final IOptionDetails opt;
private boolean tempValue;
OptionTempHandler(IOptionDetails opt) {
this.opt = opt;
tempValue = opt.getOptionValue();
}
public String getName() {
return opt.getName();
}
public String getOptionDescription() {
return opt.getOptionDescription();
}
public boolean getOptionValue() {
return tempValue;
}
public void setOptionValue(boolean value) {
tempValue = value;
}
void finalise() {
opt.setOptionValue(tempValue);
}
}

View File

@ -0,0 +1,205 @@
package link.infra.packwiz.installer.ui;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.event.TableModelListener;
import javax.swing.table.TableModel;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class OptionsSelectWindow extends JDialog implements ActionListener {
private static final long serialVersionUID = 1L;
private final JTextArea lblOptionDescription;
private final OptionTableModel tableModel;
private final CompletableFuture<Boolean> future;
/**
* Create the dialog.
*/
OptionsSelectWindow(List<IOptionDetails> optList, CompletableFuture<Boolean> future, JFrame parentWindow) {
super(parentWindow, "Select optional mods...", true);
tableModel = new OptionTableModel(optList);
this.future = future;
setBounds(100, 100, 450, 300);
setLocationRelativeTo(parentWindow);
getContentPane().setLayout(new BorderLayout());
JPanel contentPanel = new JPanel();
contentPanel.setBorder(new EmptyBorder(5, 5, 5, 5));
getContentPane().add(contentPanel, BorderLayout.CENTER);
contentPanel.setLayout(new BorderLayout(0, 0));
{
JSplitPane splitPane = new JSplitPane();
splitPane.setResizeWeight(0.5);
contentPanel.add(splitPane);
{
JTable table = new JTable();
table.setShowVerticalLines(false);
table.setShowHorizontalLines(false);
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
table.setShowGrid(false);
table.setModel(tableModel);
table.getColumnModel().getColumn(0).setResizable(false);
table.getColumnModel().getColumn(0).setPreferredWidth(15);
table.getColumnModel().getColumn(0).setMaxWidth(15);
table.getColumnModel().getColumn(1).setResizable(false);
table.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
@Override
public void valueChanged(ListSelectionEvent e) {
int i = table.getSelectedRow();
if (i > -1) {
lblOptionDescription.setText(tableModel.getDescription(i));
} else {
lblOptionDescription.setText("Select an option...");
}
}
});
table.setTableHeader(null);
JScrollPane scrollPane = new JScrollPane(table);
scrollPane.getViewport().setBackground(UIManager.getColor("List.background"));
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
scrollPane.setBorder(new EmptyBorder(0, 0, 0, 0));
splitPane.setLeftComponent(scrollPane);
}
{
lblOptionDescription = new JTextArea("Select an option...");
lblOptionDescription.setBackground(UIManager.getColor("List.background"));
lblOptionDescription.setOpaque(true);
lblOptionDescription.setWrapStyleWord(true);
lblOptionDescription.setLineWrap(true);
lblOptionDescription.setEditable(false);
lblOptionDescription.setFocusable(false);
lblOptionDescription.setFont(UIManager.getFont("Label.font"));
lblOptionDescription.setBorder(new EmptyBorder(10, 10, 10, 10));
JScrollPane scrollPane = new JScrollPane(lblOptionDescription);
scrollPane.setBorder(new EmptyBorder(0, 0, 0, 0));
splitPane.setRightComponent(scrollPane);
}
}
{
JPanel buttonPane = new JPanel();
buttonPane.setLayout(new FlowLayout(FlowLayout.RIGHT));
getContentPane().add(buttonPane, BorderLayout.SOUTH);
{
JButton okButton = new JButton("OK");
okButton.setActionCommand("OK");
okButton.addActionListener(this);
buttonPane.add(okButton);
getRootPane().setDefaultButton(okButton);
}
{
JButton cancelButton = new JButton("Cancel");
cancelButton.setActionCommand("Cancel");
cancelButton.addActionListener(this);
buttonPane.add(cancelButton);
}
}
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
future.complete(true);
}
@Override
public void windowClosed(WindowEvent e) {
// Just in case closing didn't get triggered - if something else called dispose() the
// future will have already completed
future.complete(true);
}
});
}
private static class OptionTableModel implements TableModel {
private List<OptionTempHandler> opts = new ArrayList<>();
OptionTableModel(List<IOptionDetails> givenOpts) {
for (IOptionDetails opt : givenOpts) {
opts.add(new OptionTempHandler(opt));
}
}
@Override
public int getRowCount() {
return opts.size();
}
@Override
public int getColumnCount() {
return 2;
}
private final String[] columnNames = {"Enabled", "Mod name"};
private final Class<?>[] columnTypes = {Boolean.class, String.class};
private final boolean[] columnEditables = {true, false};
@Override
public String getColumnName(int columnIndex) {
return columnNames[columnIndex];
}
@Override
public Class<?> getColumnClass(int columnIndex) {
return columnTypes[columnIndex];
}
@Override
public boolean isCellEditable(int rowIndex, int columnIndex) {
return columnEditables[columnIndex];
}
@Override
public Object getValueAt(int rowIndex, int columnIndex) {
OptionTempHandler opt = opts.get(rowIndex);
return columnIndex == 0 ? opt.getOptionValue() : opt.getName();
}
@Override
public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
if (columnIndex == 0) {
OptionTempHandler opt = opts.get(rowIndex);
opt.setOptionValue((boolean) aValue);
}
}
// Noop, the table model doesn't change!
@Override
public void addTableModelListener(TableModelListener l) {}
@Override
public void removeTableModelListener(TableModelListener l) {}
String getDescription(int rowIndex) {
return opts.get(rowIndex).getOptionDescription();
}
void finalise() {
for (OptionTempHandler opt : opts) {
opt.finalise();
}
}
}
@Override
public void actionPerformed(ActionEvent e) {
if (e.getActionCommand().equals("OK")) {
tableModel.finalise();
future.complete(false);
dispose();
} else if (e.getActionCommand().equals("Cancel")) {
future.complete(true);
dispose();
}
}
}

View File

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

View File

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

View File

@ -1,325 +0,0 @@
package link.infra.packwiz.installer
import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.util.Log
import okio.Buffer
import okio.HashingSink
import okio.blackholeSink
import okio.buffer
import java.io.IOException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
internal class DownloadTask private constructor(val metadata: IndexFile.File, val index: IndexFile, private val downloadSide: Side) : IOptionDetails {
var cachedFile: ManifestFile.File? = null
private set
private var err: Exception? = null
val exceptionDetails get() = err?.let { e -> ExceptionDetails(name, e) }
fun failed() = err != null
var alreadyUpToDate = false
private set
private var metadataRequired = true
private var invalidated = false
// If file is new or isOptional changed to true, the option needs to be presented again
private var newOptional = true
var completionStatus = CompletionStatus.INCOMPLETE
private set
enum class CompletionStatus {
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 correctSide() = metadata.linkedFile?.side?.let { downloadSide.hasSide(it) } ?: true
override val name get() = metadata.name
// Ensure that an update is done if it changes from false to true, or from true to false
override var optionValue: Boolean
get() = cachedFile?.optionValue ?: true
set(value) {
if (value && !optionValue) { // Ensure that an update is done if it changes from false to true, or from true to false
alreadyUpToDate = false
}
cachedFile?.optionValue = value
}
override val optionDescription get() = metadata.linkedFile?.option?.description ?: ""
fun invalidate() {
invalidated = true
alreadyUpToDate = false
}
fun updateFromCache(cachedFile: ManifestFile.File?) {
if (err != null) return
if (cachedFile == null) {
this.cachedFile = ManifestFile.File()
return
}
this.cachedFile = cachedFile
if (!invalidated) {
val currHash = try {
metadata.getHashObj(index)
} catch (e: Exception) {
err = e
return
}
if (currHash == cachedFile.hash) { // Already up to date
alreadyUpToDate = true
metadataRequired = false
completionStatus = CompletionStatus.ALREADY_EXISTS_CACHED
}
}
if (cachedFile.isOptional) {
// Because option selection dialog might set this task to true/false, metadata is always needed to download
// the file, and to show the description and name
metadataRequired = true
}
}
fun downloadMetadata(clientHolder: ClientHolder) {
if (err != null) return
if (metadataRequired) {
try {
// Retrieve the linked metadata file
metadata.downloadMeta(index, clientHolder)
} catch (e: Exception) {
err = e
return
}
cachedFile?.let { cachedFile ->
val linkedFile = metadata.linkedFile
if (linkedFile != null) {
if (linkedFile.option.optional) {
if (cachedFile.isOptional) {
// isOptional didn't change
newOptional = false
} else {
// isOptional false -> true, set option to it's default value
// TODO: preserve previous option value, somehow??
cachedFile.optionValue = linkedFile.option.defaultValue
}
}
}
cachedFile.isOptional = isOptional
cachedFile.onlyOtherSide = !correctSide()
}
}
}
/**
* 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
// Exclude wrong-side and optional false files
cachedFile?.let {
if ((it.isOptional && !it.optionValue) || !correctSide()) {
if (it.cachedLocation != null) {
// Ensure wrong-side or optional false files are removed
try {
completionStatus = if (Files.deleteIfExists(it.cachedLocation!!.nioPath)) {
if (correctSide()) { CompletionStatus.DELETED_DISABLED } else { CompletionStatus.DELETED_WRONG_SIDE }
} else {
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
return
}
}
if (alreadyUpToDate) return
val destPath = metadata.destURI.rebase(packFolder)
// Don't update files marked with preserve if they already exist on disk
if (metadata.preserve) {
if (destPath.nioPath.toFile().exists()) {
return
}
}
// TODO: add .disabled support?
try {
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 src = metadata.getSource(clientHolder)
val fileSource = fileHashFormat.source(src)
val data = Buffer()
// Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails)
// TODO: should we instead rename the existing file, then stream straight to the file and rollback from the renamed file?
fileSource.buffer().use {
it.readAll(data)
}
if (hash == fileSource.hash) {
// isDirectory follows symlinks, but createDirectories doesn't
try {
Files.createDirectories(destPath.parent.nioPath)
} catch (e: java.nio.file.FileAlreadyExistsException) {
if (!Files.isDirectory(destPath.parent.nioPath)) {
throw e
}
}
Files.copy(data.inputStream(), destPath.nioPath, StandardCopyOption.REPLACE_EXISTING)
data.clear()
} else {
// TODO: move println to something visible in the error window
println("Invalid hash for " + metadata.destURI.toString())
println("Calculated: " + fileSource.hash)
println("Expected: $hash")
// Attempt to get the SHA256 hash
val sha256 = HashingSink.sha256(blackholeSink())
data.readAll(sha256)
println("SHA256 hash value: " + sha256.hash)
err = Exception("Hash invalid!")
data.clear()
return
}
cachedFile?.cachedLocation?.let {
if (destPath != it) {
// Delete old file if location changes
try {
Files.delete(cachedFile!!.cachedLocation!!.nioPath)
} catch (e: IOException) {
// Continue, as it was probably already deleted?
// TODO: log it
}
}
}
} catch (e: Exception) {
err = e
return
}
// 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
}
}
}
completionStatus = CompletionStatus.DOWNLOADED
}
companion object {
fun createTasksFromIndex(index: IndexFile, downloadSide: Side): MutableList<DownloadTask> {
val tasks = ArrayList<DownloadTask>()
for (file in index.files) {
tasks.add(DownloadTask(file, index, downloadSide))
}
return tasks
}
}
}

View File

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

View File

@ -1,170 +0,0 @@
@file:JvmName("Main")
package link.infra.packwiz.installer
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.cli.CLIHandler
import link.infra.packwiz.installer.ui.gui.GUIHandler
import link.infra.packwiz.installer.ui.wrap
import link.infra.packwiz.installer.util.Log
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.Path.Companion.toOkioPath
import okio.Path.Companion.toPath
import org.apache.commons.cli.DefaultParser
import org.apache.commons.cli.Options
import org.apache.commons.cli.ParseException
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.net.URI
import java.nio.file.Paths
import javax.swing.JOptionPane
import javax.swing.UIManager
import kotlin.system.exitProcess
@Suppress("unused")
class Main(args: Array<String>) {
// Don't attempt to start a GUI if we are headless
private var guiEnabled = !GraphicsEnvironment.isHeadless()
private fun startup(args: Array<String>) {
val options = Options()
addNonBootstrapOptions(options)
addBootstrapOptions(options)
val parser = DefaultParser()
val cmd = try {
parser.parse(options, args)
} catch (e: ParseException) {
Log.fatal("Failed to parse command line arguments", e)
if (guiEnabled) {
EventQueue.invokeAndWait {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (ignored: Exception) {
// Ignore the exceptions, just continue using the ugly L&F
}
JOptionPane.showMessageDialog(null, "Failed to parse command line arguments: $e",
"packwiz-installer", JOptionPane.ERROR_MESSAGE)
}
}
exitProcess(1)
}
if (guiEnabled && cmd.hasOption("no-gui")) {
guiEnabled = false
}
val ui = if (guiEnabled) GUIHandler() else CLIHandler()
val unparsedArgs = cmd.args
if (unparsedArgs.size > 1) {
ui.showErrorAndExit("Too many arguments specified!")
} else if (unparsedArgs.isEmpty()) {
ui.showErrorAndExit("pack.toml URI to install from must be specified!")
}
val title = cmd.getOptionValue("title")
if (title != null) {
ui.title = title
}
ui.show()
val packFileRaw = unparsedArgs[0]
val packFile = when {
// HTTP(s) URLs
Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.wrap("Invalid HTTP/HTTPS URL for pack file: $packFileRaw") {
HttpUrlPath(packFileRaw.toHttpUrl().resolve(".")!!, packFileRaw.toHttpUrl().pathSegments.last())
}
// File URIs (uses same logic as old packwiz-installer, for backwards compat)
Regex("^file:", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> {
ui.wrap("Failed to parse file path for pack file: $packFileRaw") {
val path = Paths.get(URI(packFileRaw)).toOkioPath()
PackwizFilePath(path.parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), path.name)
}
}
// Other URIs (unsupported)
Regex("^[a-z][a-z\\d+\\-.]*://", RegexOption.IGNORE_CASE).containsMatchIn(packFileRaw) -> ui.showErrorAndExit("Unsupported scheme for pack file: $packFileRaw")
// None of the above matches -> interpret as file path
else -> PackwizFilePath(packFileRaw.toPath().parent ?: ui.showErrorAndExit("Invalid pack file path: $packFileRaw"), packFileRaw.toPath().name)
}
val side = cmd.getOptionValue("side")?.let {
Side.from(it) ?: ui.showErrorAndExit("Unknown side name: $it")
} ?: Side.CLIENT
val packFolder = ui.wrap("Invalid pack folder path") {
cmd.getOptionValue("pack-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath(".".toPath())
}
val multimcFolder = ui.wrap("Invalid MultiMC folder path") {
cmd.getOptionValue("multimc-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath("..".toPath())
}
val manifestFile = ui.wrap("Invalid manifest file path") {
packFolder / (cmd.getOptionValue("meta-file") ?: "packwiz.json")
}
val timeout = ui.wrap("Invalid timeout value") {
cmd.getOptionValue("timeout")?.toLong() ?: 10
}
// Start update process!
try {
UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side, timeout), ui)
} catch (e: Exception) {
ui.showErrorAndExit("Update process failed", e)
}
println("Finished successfully!")
ui.dispose()
}
companion object {
// Called by packwiz-installer-bootstrap to set up the help command
@JvmStatic
fun addNonBootstrapOptions(options: Options) {
options.addOption("s", "side", true, "Side to install mods from (client/server, defaults to client)")
options.addOption(null, "title", true, "Title of the installer window")
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("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?
@JvmStatic
private fun addBootstrapOptions(options: Options) {
options.addOption(null, "bootstrap-update-url", true, "Github API URL for checking for updates")
options.addOption(null, "bootstrap-update-token", true, "Github API Access Token, for private repositories")
options.addOption(null, "bootstrap-no-update", false, "Don't update packwiz-installer")
options.addOption(null, "bootstrap-main-jar", true, "Location of the packwiz-installer JAR file")
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!
}
@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!
init {
// Big overarching try/catch just in case everything breaks
try {
startup(args)
} catch (e: Exception) {
Log.fatal("Error from main", e)
if (guiEnabled) {
EventQueue.invokeLater {
JOptionPane.showMessageDialog(null,
"A fatal error occurred: \n$e",
"packwiz-installer", JOptionPane.ERROR_MESSAGE)
exitProcess(1)
}
// In case the EventQueue is broken, exit after 1 minute
Thread.sleep(60 * 1000.toLong())
}
exitProcess(1)
}
}
}

View File

@ -1,487 +0,0 @@
package link.infra.packwiz.installer
import cc.ekblad.toml.decode
import com.google.gson.GsonBuilder
import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex
import link.infra.packwiz.installer.metadata.DownloadMode
import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.ManifestFile
import link.infra.packwiz.installer.metadata.PackFile
import link.infra.packwiz.installer.metadata.curseforge.resolveCfMetadata
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.request.RequestException
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log
import okio.buffer
import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.util.concurrent.CompletionService
import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorCompletionService
import java.util.concurrent.Executors
import kotlin.system.exitProcess
class UpdateManager internal constructor(private val opts: Options, val ui: IUserInterface) {
private var cancelled = false
private var cancelledStartGame = false
private var errorsOccurred = false
init {
start()
}
data class Options(
val packFile: PackwizPath<*>,
val manifestFile: PackwizFilePath,
val packFolder: PackwizFilePath,
val multimcFolder: PackwizFilePath,
val side: Side,
val timeout: Long,
)
// TODO: make this return a value based on results?
private fun start() {
val clientHolder = ClientHolder()
ui.cancelCallback = {
clientHolder.close()
}
ui.submitProgress(InstallProgress("Loading manifest file..."))
val gson = GsonBuilder()
.registerTypeAdapter(Hash::class.java, Hash.TypeHandler())
.registerTypeAdapter(PackwizFilePath::class.java, PackwizPath.adapterRelativeTo(opts.packFolder))
.enableComplexMapKeySerialization()
.create()
val manifest = try {
// TODO: kotlinx.serialisation?
InputStreamReader(opts.manifestFile.source(clientHolder).inputStream(), StandardCharsets.UTF_8).use { reader ->
gson.fromJson(reader, ManifestFile::class.java)
}
} catch (e: RequestException.Response.File.FileNotFound) {
ui.firstInstall = true
ManifestFile()
} catch (e: JsonSyntaxException) {
ui.showErrorAndExit("Invalid local manifest file, try deleting ${opts.manifestFile}", e)
} catch (e: JsonIOException) {
ui.showErrorAndExit("Failed to read local manifest file, try deleting ${opts.manifestFile}", e)
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
handleCancellation()
}
ui.submitProgress(InstallProgress("Loading pack file..."))
val packFileSource = try {
val src = opts.packFile.source(clientHolder)
HashFormat.SHA256.source(src)
} catch (e: Exception) {
// TODO: ensure suppressed/caused exceptions are shown?
ui.showErrorAndExit("Failed to download pack.toml", e)
}
val pf = packFileSource.buffer().use {
try {
PackFile.mapper(opts.packFile).decode<PackFile>(it.inputStream())
} catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse pack.toml", e)
}
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
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..."))
// If the side changes, invalidate EVERYTHING (even when the index hasn't changed)
val invalidateAll = opts.side != manifest.cachedSide
val invalidatedUris: MutableList<PackwizFilePath> = ArrayList()
if (!invalidateAll) {
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked
for ((fileUri, file) in manifest.cachedFiles) {
// 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()) {
// todo: --force?
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false, opts.timeout)
}
if (!ui.optionsButtonPressed) {
return
}
}
}
Log.info("Modpack name: ${pf.name}")
if (ui.cancelButtonPressed) {
showCancellationDialog()
handleCancellation()
}
try {
processIndex(
pf.index.file,
pf.index.hashFormat.fromString(pf.index.hash),
pf.index.hashFormat,
manifest,
invalidatedUris,
invalidateAll,
clientHolder
)
} catch (e1: Exception) {
ui.showErrorAndExit("Failed to process index file", e1)
}
handleCancellation()
// If there were errors, don't write the manifest/index hashes, to ensure they are rechecked later
if (errorsOccurred) {
manifest.indexFileHash = null
manifest.packFileHash = null
} else {
manifest.packFileHash = packFileSource.hash
}
manifest.cachedSide = opts.side
try {
Files.newBufferedWriter(opts.manifestFile.nioPath, StandardCharsets.UTF_8).use { writer -> gson.toJson(manifest, writer) }
} catch (e: IOException) {
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) {
if (!invalidateAll) {
if (manifest.indexFileHash == indexHash && invalidatedFiles.isEmpty()) {
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false, opts.timeout)
}
if (!ui.optionsButtonPressed) {
return
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
}
manifest.indexFileHash = indexHash
val indexFileSource = try {
val src = indexUri.source(clientHolder)
hashFormat.source(src)
} catch (e: Exception) {
ui.showErrorAndExit("Failed to download index file", e)
}
val indexFile = try {
IndexFile.mapper(indexUri).decode<IndexFile>(indexFileSource.buffer().inputStream())
} catch (e: IllegalStateException) {
ui.showErrorAndExit("Failed to parse index file", e)
}
if (indexHash != indexFileSource.hash) {
ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again")
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
ui.submitProgress(InstallProgress("Checking local files..."))
val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
while (it.hasNext()) {
val (uri, file) = it.next()
if (file.cachedLocation != null) {
if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index
try {
Files.deleteIfExists(file.cachedLocation!!.nioPath)
} catch (e: IOException) {
Log.warn("Failed to delete file removed from index", e)
}
Log.info("Deleted ${file.cachedLocation!!.filename} (removed from pack)")
it.remove()
}
}
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
ui.submitProgress(InstallProgress("Comparing new files..."))
// TODO: progress bar?
if (indexFile.files.isEmpty()) {
Log.warn("Index is empty!")
}
val tasks = createTasksFromIndex(indexFile, opts.side)
if (invalidateAll) {
Log.info("Side changed, invalidating all mods")
}
tasks.forEach{ f ->
// TODO: should linkedfile be checked as well? should this be done in the download section?
if (invalidateAll) {
f.invalidate()
} else if (invalidatedFiles.contains(f.metadata.file.rebase(opts.packFolder))) {
f.invalidate()
}
val file = manifest.cachedFiles[f.metadata.file.rebase(opts.packFolder)]
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
file?.backup()
// If it is null, the DownloadTask will make a new empty cachedFile
f.updateFromCache(file)
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
// Let's hope downloadMetadata is a pure function!!!
tasks.parallelStream().forEach { f -> f.downloadMetadata(clientHolder) }
val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTaskDetails.isNotEmpty()) {
errorsOccurred = true
when (ui.showExceptions(failedTaskDetails, tasks.size, true)) {
ExceptionListResult.CONTINUE -> {}
ExceptionListResult.CANCEL -> {
cancelled = true
return
}
ExceptionListResult.IGNORE -> {
cancelledStartGame = true
return
}
}
}
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
// TODO: task failed function?
tasks.removeAll { it.failed() }
val optionTasks = tasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
val optionsChanged = optionTasks.any(DownloadTask::isNewOptional)
if (optionTasks.isNotEmpty() && !optionsChanged) {
if (!ui.optionsButtonPressed) {
// TODO: this is so ugly
ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1))
ui.awaitOptionalButton(true, opts.timeout)
if (ui.cancelButtonPressed) {
showCancellationDialog()
return
}
}
}
// If options changed, present all options again
if (ui.optionsButtonPressed || optionsChanged) {
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
if (ui.showOptions(ArrayList(optionTasks))) {
cancelled = true
handleCancellation()
}
}
// TODO: keep this enabled? then apply changes after download process?
ui.disableOptionsButton(optionTasks.isNotEmpty())
while (true) {
when (validateAndResolve(tasks, clientHolder)) {
ResolveResult.RETRY -> {}
ResolveResult.QUIT -> return
ResolveResult.SUCCESS -> break
}
}
// TODO: different thread pool type?
val threadPool = Executors.newFixedThreadPool(10)
val completionService: CompletionService<DownloadTask> = ExecutorCompletionService(threadPool)
tasks.forEach { t ->
completionService.submit {
t.download(opts.packFolder, clientHolder)
t
}
}
for (i in tasks.indices) {
val task: DownloadTask = try {
completionService.take().get()
} catch (e: InterruptedException) {
ui.showErrorAndExit("Interrupted when consuming download tasks", e)
} catch (e: ExecutionException) {
ui.showErrorAndExit("Failed to execute download task", e)
}
// Update manifest - If there were no errors cachedFile has already been modified in place (good old pass by reference)
task.cachedFile?.let { file ->
if (task.failed()) {
val oldFile = file.revert
if (oldFile != null) {
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), oldFile)
} else { null }
} else {
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), file)
}
}
val exDetails = task.exceptionDetails
val progress = if (exDetails != null) {
"Failed to download ${exDetails.name}: ${exDetails.exception.message}"
} else {
when (task.completionStatus) {
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))
if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!)
// TODO: close client holder in more places?
clientHolder.close()
threadPool.shutdown()
cancelled = true
return
}
}
// Shut down the thread pool when the update is done
threadPool.shutdown()
val failedTasks2ElectricBoogaloo = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
if (failedTasks2ElectricBoogaloo.isNotEmpty()) {
errorsOccurred = true
when (ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false)) {
ExceptionListResult.CONTINUE -> {}
ExceptionListResult.CANCEL -> cancelled = true
ExceptionListResult.IGNORE -> cancelledStartGame = true
}
}
}
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() {
when (ui.showCancellationDialog()) {
CancellationResult.QUIT -> cancelled = true
CancellationResult.CONTINUE -> cancelledStartGame = true
}
}
// TODO: move to UI?
private fun handleCancellation() {
if (cancelled) {
println("Update cancelled by user!")
exitProcess(1)
} else if (cancelledStartGame) {
println("Update cancelled by user! Continuing to start game...")
exitProcess(0)
}
}
}

View File

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

View File

@ -1,27 +0,0 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import java.io.IOException
class EfficientBooleanAdapter : TypeAdapter<Boolean?>() {
@Throws(IOException::class)
override fun write(out: JsonWriter, value: Boolean?) {
if (value == null || !value) {
out.nullValue()
return
}
out.value(true)
}
@Throws(IOException::class)
override fun read(reader: JsonReader): Boolean {
if (reader.peek() == JsonToken.NULL) {
reader.nextNull()
return false
}
return reader.nextBoolean()
}
}

View File

@ -1,97 +0,0 @@
package link.infra.packwiz.installer.metadata
import cc.ekblad.toml.decode
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.util.delegateTransitive
import okio.Source
import okio.buffer
data class IndexFile(
val hashFormat: HashFormat<*>,
val files: List<File> = listOf()
) {
data class File(
val file: PackwizPath<*>,
private val hashFormat: HashFormat<*>? = null,
val hash: String,
val alias: PackwizPath<*>?,
val metafile: Boolean = false,
val preserve: Boolean = false,
) {
var linkedFile: ModFile? = null
fun hashFormat(index: IndexFile) = hashFormat ?: index.hashFormat
@Throws(Exception::class)
fun getHashObj(index: IndexFile): Hash<*> {
// TODO: more specific exceptions?
return hashFormat(index).fromString(hash)
}
@Throws(Exception::class)
fun downloadMeta(index: IndexFile, clientHolder: ClientHolder) {
if (!metafile) {
return
}
val fileHash = getHashObj(index)
val src = file.source(clientHolder)
val fileStream = hashFormat(index).source(src)
linkedFile = ModFile.mapper(file).decode<ModFile>(fileStream.buffer().inputStream())
if (fileHash != fileStream.hash) {
// TODO: propagate details about hash, and show better error!
throw Exception("Invalid mod file hash")
}
}
@Throws(Exception::class)
fun getSource(clientHolder: ClientHolder): Source {
return if (metafile) {
if (linkedFile == null) {
throw Exception("Linked file doesn't exist!")
}
linkedFile!!.getSource(clientHolder)
} else {
file.source(clientHolder)
}
}
val name: String
get() {
if (metafile) {
return linkedFile?.name ?: file.filename
}
return file.filename
}
val destURI: PackwizPath<*>
get() {
if (alias != null) {
return alias
}
return if (metafile) {
linkedFile!!.filename
} else {
file
}
}
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<File>("hash-format" to "hashFormat")
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
}
}
}
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<IndexFile>("hash-format" to "hashFormat")
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegateTransitive<File>(File.mapper(base))
}
}
}

View File

@ -1,44 +0,0 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
class ManifestFile {
var packFileHash: Hash<*>? = null
var indexFileHash: Hash<*>? = null
var cachedFiles: MutableMap<PackwizFilePath, File> = HashMap()
// If the side changes, EVERYTHING invalidates. FUN!!!
var cachedSide = Side.CLIENT
class File {
@Transient
var revert: File? = null
private set
var hash: Hash<*>? = null
var linkedFileHash: Hash<*>? = null
var cachedLocation: PackwizFilePath? = null
@JsonAdapter(EfficientBooleanAdapter::class)
var isOptional = false
var optionValue = true
@JsonAdapter(EfficientBooleanAdapter::class)
var onlyOtherSide = false
// When an error occurs, the state needs to be reverted. To do this, I have a crude revert system.
fun backup() {
revert = File().also {
it.hash = hash
it.linkedFileHash = linkedFileHash
it.cachedLocation = cachedLocation
it.isOptional = isOptional
it.optionValue = optionValue
it.onlyOtherSide = onlyOtherSide
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,76 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import com.google.gson.*
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ForwardingSource
import okio.HashingSource
import okio.Source
import java.lang.reflect.Type
data class Hash<T>(val type: HashFormat<T>, val value: T) {
interface Encoding<T> {
fun encodeToString(value: T): String
fun decodeFromString(str: String): T
object Hex: Encoding<ByteString> {
override fun encodeToString(value: ByteString) = value.hex()
override fun decodeFromString(str: String) = str.decodeHex()
}
object UInt: Encoding<kotlin.UInt> {
override fun encodeToString(value: kotlin.UInt) = value.toString()
override fun decodeFromString(str: String) =
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)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash<*> {
val obj = json.asJsonObject
val type: String
val value: String
try {
type = obj["type"].asString
value = obj["value"].asString
} catch (e: NullPointerException) {
throw JsonParseException("Invalid hash JSON data")
}
return try {
(HashFormat.fromName(type) ?: throw JsonParseException("Unknown hash type $type")).fromString(value)
} catch (e: Exception) {
throw JsonParseException("Failed to create hash object", e)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<*>)

View File

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

View File

@ -1,59 +0,0 @@
package link.infra.packwiz.installer.ui
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.ui.data.InstallProgress
interface IUserInterface {
fun show()
fun dispose()
fun showErrorAndExit(message: String): Nothing {
showErrorAndExit(message, null)
}
fun showErrorAndExit(message: String, e: Exception?): Nothing
var title: String
fun submitProgress(progress: InstallProgress)
// Return true if the installation was cancelled!
fun showOptions(options: List<IOptionDetails>): Boolean
fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): ExceptionListResult
fun disableOptionsButton(hasOptions: Boolean) {}
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 {
CONTINUE, CANCEL, IGNORE
}
enum class CancellationResult {
QUIT, CONTINUE
}
enum class UpdateConfirmationResult {
CANCELLED, CONTINUE, UPDATE
}
var optionsButtonPressed: Boolean
var cancelButtonPressed: Boolean
var cancelCallback: (() -> Unit)?
var firstInstall: Boolean
}
inline fun <T> IUserInterface.wrap(message: String, inner: () -> T): T {
return try {
inner.invoke()
} catch (e: Exception) {
showErrorAndExit(message, e)
}
}

View File

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

View File

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

View File

@ -1,7 +0,0 @@
package link.infra.packwiz.installer.ui.data
interface IOptionDetails {
val name: String
var optionValue: Boolean
val optionDescription: String
}

View File

@ -1,12 +0,0 @@
package link.infra.packwiz.installer.ui.data
data class InstallProgress(
val message: String,
val hasProgress: Boolean = false,
val progress: Int = 0,
val progressTotal: Int = 0
) {
constructor(message: String, progress: Int, progressTotal: Int) : this(message, true, progress, progressTotal)
constructor(message: String) : this(message, false)
}

View File

@ -1,177 +0,0 @@
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.data.ExceptionDetails
import java.awt.BorderLayout
import java.awt.Desktop
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.net.URI
import java.net.URISyntaxException
import java.util.concurrent.CompletableFuture
import javax.swing.*
import javax.swing.border.EmptyBorder
class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFuture<IUserInterface.ExceptionListResult>, numTotal: Int, allowsIgnore: Boolean, parentWindow: JFrame?) : JDialog(parentWindow, "Failed file downloads", true) {
private val lblExceptionStacktrace: JTextArea
private class ExceptionListModel(private val details: List<ExceptionDetails>) : AbstractListModel<String>() {
override fun getSize() = details.size
override fun getElementAt(index: Int) = details[index].name
fun getExceptionAt(index: Int) = details[index].exception
}
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.
*/
init {
setBounds(100, 100, 540, 340)
setLocationRelativeTo(parentWindow)
contentPane.apply {
layout = BorderLayout()
// Error panel
add(JPanel().apply {
add(JLabel("One or more errors were encountered while installing the modpack!").apply {
icon = UIManager.getIcon("OptionPane.warningIcon")
})
}, BorderLayout.NORTH)
// Content panel
add(JPanel().apply {
border = EmptyBorder(5, 5, 5, 5)
layout = BorderLayout(0, 0)
add(JSplitPane().apply {
resizeWeight = 0.3
lblExceptionStacktrace = JTextArea("Select a file")
lblExceptionStacktrace.background = UIManager.getColor("List.background")
lblExceptionStacktrace.isOpaque = true
lblExceptionStacktrace.wrapStyleWord = true
lblExceptionStacktrace.lineWrap = true
lblExceptionStacktrace.isEditable = false
lblExceptionStacktrace.isFocusable = true
lblExceptionStacktrace.font = UIManager.getFont("Label.font")
lblExceptionStacktrace.border = EmptyBorder(5, 5, 5, 5)
rightComponent = JScrollPane(lblExceptionStacktrace)
leftComponent = JScrollPane(JList<String>().apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
border = EmptyBorder(5, 5, 5, 5)
val listModel = ExceptionListModel(eList)
model = listModel
addListSelectionListener {
val i = selectedIndex
if (i > -1) {
val sw = StringWriter()
listModel.getExceptionAt(i).printStackTrace(PrintWriter(sw))
lblExceptionStacktrace.text = sw.toString()
// Scroll to the top
lblExceptionStacktrace.caretPosition = 0
} else {
lblExceptionStacktrace.text = "Select a file"
}
}
})
})
}, BorderLayout.CENTER)
// Button pane
add(JPanel().apply {
layout = BorderLayout(0, 0)
// Right buttons
add(JPanel().apply {
add(JButton("Continue").apply {
toolTipText = "Attempt to continue installing, excluding the failed downloads"
addActionListener {
future.complete(IUserInterface.ExceptionListResult.CONTINUE)
this@ExceptionListWindow.dispose()
}
})
add(JButton("Cancel launch").apply {
toolTipText = "Stop launching the game"
addActionListener {
future.complete(IUserInterface.ExceptionListResult.CANCEL)
this@ExceptionListWindow.dispose()
}
})
add(JButton("Ignore update").apply {
toolTipText = "Start the game without attempting to update"
isEnabled = allowsIgnore
addActionListener {
future.complete(IUserInterface.ExceptionListResult.IGNORE)
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)
// Errored label
add(JLabel(eList.size.toString() + "/" + numTotal + " errored").apply {
horizontalAlignment = SwingConstants.CENTER
}, BorderLayout.CENTER)
// Left buttons
add(JPanel().apply {
add(JButton("Report issue").apply {
addActionListener {
openUrl("https://github.com/packwiz/packwiz-installer/issues/new")
}
})
}, BorderLayout.WEST)
}, BorderLayout.SOUTH)
}
addWindowListener(object : WindowAdapter() {
override fun windowClosing(e: WindowEvent) {
future.complete(IUserInterface.ExceptionListResult.CANCEL)
}
override fun windowClosed(e: WindowEvent) {
// Just in case closing didn't get triggered - if something else called dispose() the
// future will have already completed
future.complete(IUserInterface.ExceptionListResult.CANCEL)
}
})
}
}

View File

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

View File

@ -1,128 +0,0 @@
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.data.InstallProgress
import java.awt.BorderLayout
import java.awt.Component
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.*
import javax.swing.border.EmptyBorder
class InstallWindow(private val handler: GUIHandler) : JFrame() {
private var lblProgresslabel: JLabel
private var progressBar: JProgressBar
private var btnOptions: JButton
private val btnCancel: JButton
private val btnOk: JButton
private val buttonsPanel: JPanel
init {
setBounds(100, 100, 493, 95)
// Works better with tiling window managers - there isn't any reason to change window size currently anyway
isResizable = false
defaultCloseOperation = EXIT_ON_CLOSE
setLocationRelativeTo(null)
// Progress bar and loading text
add(JPanel().apply {
border = EmptyBorder(10, 10, 10, 10)
layout = BorderLayout(0, 0)
progressBar = JProgressBar().apply {
isIndeterminate = true
}
add(progressBar, BorderLayout.CENTER)
lblProgresslabel = JLabel("Loading...")
add(lblProgresslabel, BorderLayout.SOUTH)
}, BorderLayout.CENTER)
// Buttons
buttonsPanel = JPanel().apply {
border = EmptyBorder(0, 5, 0, 5)
layout = GridBagLayout()
btnOptions = JButton("Optional mods...").apply {
alignmentX = Component.CENTER_ALIGNMENT
addActionListener {
text = "Loading..."
isEnabled = false
handler.optionsButtonPressed = true
}
}
add(btnOptions, GridBagConstraints().apply {
gridx = 1
gridy = 0
})
btnCancel = JButton("Cancel").apply {
addActionListener {
isEnabled = false
handler.cancelButtonPressed = true
}
}
add(btnCancel, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
}
btnOk = JButton("Continue").apply {
addActionListener {
handler.okButtonPressed = true
}
}
add(buttonsPanel, BorderLayout.EAST)
}
fun displayProgress(progress: InstallProgress) {
if (progress.hasProgress) {
progressBar.isIndeterminate = false
progressBar.value = progress.progress
progressBar.maximum = progress.progressTotal
} else {
progressBar.isIndeterminate = true
progressBar.value = 0
}
lblProgresslabel.text = progress.message
}
fun disableOptionsButton(hasOptions: Boolean) {
btnOptions.apply {
text = if (hasOptions) { "Optional mods..." } else { "No optional mods" }
isEnabled = false
}
}
fun showOk(hideCancel: Boolean) {
if (hideCancel) {
buttonsPanel.add(btnOk, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
buttonsPanel.remove(btnCancel)
} else {
buttonsPanel.add(btnOk, GridBagConstraints().apply {
gridx = 0
gridy = 1
})
}
buttonsPanel.revalidate()
}
fun hideOk() {
buttonsPanel.remove(btnOk)
if (!buttonsPanel.components.contains(btnCancel)) {
buttonsPanel.add(btnCancel, GridBagConstraints().apply {
gridx = 1
gridy = 1
})
}
buttonsPanel.revalidate()
}
fun timeoutOk(remaining: Long) {
btnOk.text = "Continue ($remaining)"
}
}

View File

@ -1,15 +0,0 @@
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.data.IOptionDetails
// Serves as a proxy for IOptionDetails, so that setOptionValue isn't called until OK is clicked
internal class OptionTempHandler(private val opt: IOptionDetails) : IOptionDetails {
override var optionValue = opt.optionValue
override val name get() = opt.name
override val optionDescription get() = opt.optionDescription
fun finalise() {
opt.optionValue = optionValue
}
}

View File

@ -1,167 +0,0 @@
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.data.IOptionDetails
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.util.*
import java.util.concurrent.CompletableFuture
import javax.swing.*
import javax.swing.border.EmptyBorder
import javax.swing.event.TableModelListener
import javax.swing.table.TableModel
class OptionsSelectWindow internal constructor(optList: List<IOptionDetails>, future: CompletableFuture<Boolean>, parentWindow: JFrame?) : JDialog(parentWindow, "Select optional mods...", true), ActionListener {
private val lblOptionDescription: JTextArea
private val tableModel: OptionTableModel
private val future: CompletableFuture<Boolean>
private class OptionTableModel(givenOpts: List<IOptionDetails>) : TableModel {
private val opts: List<OptionTempHandler>
init {
val mutOpts = ArrayList<OptionTempHandler>()
for (opt in givenOpts) {
mutOpts.add(OptionTempHandler(opt))
}
opts = mutOpts
}
override fun getRowCount() = opts.size
override fun getColumnCount() = 2
private val columnNames = arrayOf("Enabled", "Mod name")
private val columnTypes = arrayOf(Boolean::class.javaObjectType, String::class.java)
private val columnEditables = booleanArrayOf(true, false)
override fun getColumnName(columnIndex: Int) = columnNames[columnIndex]
override fun getColumnClass(columnIndex: Int) = columnTypes[columnIndex]
override fun isCellEditable(rowIndex: Int, columnIndex: Int) = columnEditables[columnIndex]
override fun getValueAt(rowIndex: Int, columnIndex: Int): Any {
val opt = opts[rowIndex]
return if (columnIndex == 0) opt.optionValue else opt.name
}
override fun setValueAt(aValue: Any, rowIndex: Int, columnIndex: Int) {
if (columnIndex == 0) {
val opt = opts[rowIndex]
opt.optionValue = aValue as Boolean
}
}
// Noop, the table model doesn't change!
override fun addTableModelListener(l: TableModelListener) {}
override fun removeTableModelListener(l: TableModelListener) {}
fun getDescription(rowIndex: Int) = opts[rowIndex].optionDescription
fun finalise() {
for (opt in opts) {
opt.finalise()
}
}
}
override fun actionPerformed(e: ActionEvent) {
if (e.actionCommand == "OK") {
tableModel.finalise()
future.complete(false)
dispose()
} else if (e.actionCommand == "Cancel") {
future.complete(true)
dispose()
}
}
/**
* Create the dialog.
*/
init {
tableModel = OptionTableModel(optList)
this.future = future
setBounds(100, 100, 450, 300)
setLocationRelativeTo(parentWindow)
contentPane.apply {
layout = BorderLayout()
add(JPanel().apply {
border = EmptyBorder(5, 5, 5, 5)
layout = BorderLayout(0, 0)
add(JSplitPane().apply {
resizeWeight = 0.5
lblOptionDescription = JTextArea("Select an option...").apply {
background = UIManager.getColor("List.background")
isOpaque = true
wrapStyleWord = true
lineWrap = true
isEditable = false
isFocusable = false
font = UIManager.getFont("Label.font")
border = EmptyBorder(10, 10, 10, 10)
}
leftComponent = JScrollPane(JTable().apply {
showVerticalLines = false
showHorizontalLines = false
setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
setShowGrid(false)
model = tableModel
columnModel.getColumn(0).resizable = false
columnModel.getColumn(0).preferredWidth = 15
columnModel.getColumn(0).maxWidth = 15
columnModel.getColumn(1).resizable = false
selectionModel.addListSelectionListener {
val i = selectedRow
if (i > -1) {
lblOptionDescription.text = tableModel.getDescription(i)
} else {
lblOptionDescription.text = "Select an option..."
}
}
tableHeader = null
}).apply {
viewport.background = UIManager.getColor("List.background")
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
}
rightComponent = JScrollPane(lblOptionDescription)
})
add(JPanel().apply {
layout = FlowLayout(FlowLayout.RIGHT)
add(JButton("OK").apply {
actionCommand = "OK"
addActionListener(this@OptionsSelectWindow)
this@OptionsSelectWindow.rootPane.defaultButton = this
})
add(JButton("Cancel").apply {
actionCommand = "Cancel"
addActionListener(this@OptionsSelectWindow)
})
}, BorderLayout.SOUTH)
}, BorderLayout.CENTER)
}
addWindowListener(object : WindowAdapter() {
override fun windowClosing(e: WindowEvent) {
future.complete(true)
}
override fun windowClosed(e: WindowEvent) {
// Just in case closing didn't get triggered - if something else called dispose() the
// future will have already completed
future.complete(true)
}
})
}
}

View File

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

View File

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

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More