mirror of
https://github.com/packwiz/packwiz-installer.git
synced 2025-04-19 21:16:30 +02:00
Compare commits
71 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
7420866dfc | ||
|
1ebb28c3cc | ||
|
c9543f74ee | ||
|
b2421cfea7 | ||
|
6f05ac6bf0 | ||
|
7b6daaf7e5 | ||
|
758385c225 | ||
|
304fb802ed | ||
|
cc063773d8 | ||
|
1deed7dd0d | ||
|
ad951b9b44 | ||
|
4e415c1e1a | ||
|
84bbbe0770 | ||
|
fa9fe18215 | ||
|
01dcc09a78 | ||
|
a8f8444d45 | ||
|
d98baaf832 | ||
|
783e35cf73 | ||
|
ca172bdefc | ||
|
b8cb9cc1aa | ||
|
6f0beac1a1 | ||
|
f7257f4266 | ||
|
9c475cba85 | ||
|
f1ba5e4343 | ||
|
43873ac7f9 | ||
|
d83c4f1abc | ||
|
db304f9d00 | ||
|
6bb360f8e3 | ||
|
4115ea2a3a | ||
|
d4e41ad85e | ||
|
fcf249166c | ||
|
66bc4c3e29 | ||
|
02b01b90d7 | ||
|
ab3de9a246 | ||
|
53286871e6 | ||
|
610aeeb166 | ||
|
5e39907fae | ||
|
d2556c4b4a | ||
|
858fd17f3e | ||
|
c2ee6fca8b | ||
|
73d21a475a | ||
|
7568770078 | ||
|
3d1d6db9b4 | ||
|
c6e304bc7f | ||
|
92d6f68f1d | ||
|
07af6046c1 | ||
|
89bdfd9c98 | ||
|
f4dd4fa866 | ||
|
6db8422c87 | ||
|
7d6346c088 | ||
|
aff921f67e | ||
|
afb574d82d | ||
|
8635906b1c | ||
|
bf95f03a18 | ||
|
bca2d758e1 | ||
|
46771ce870 | ||
|
b143f67acd | ||
|
03b0f1b09b | ||
|
6c6a0100fd | ||
|
6d47c0d61f | ||
|
226e754547 | ||
|
2c02703101 | ||
|
81a60cc759 | ||
|
92afa93fd7 | ||
|
0858c90079 | ||
|
1d4c94f5b6 | ||
|
74ddca5d54 | ||
|
0df48d19a9 | ||
|
f5b22f37a4 | ||
|
f52cd19ad4 | ||
|
60887a4312 |
27
.github/workflows/pr.yml
vendored
Normal file
27
.github/workflows/pr.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
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
|
29
.github/workflows/release.yml
vendored
Normal file
29
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
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
|
29
.github/workflows/snapshot.yml
vendored
Normal file
29
.github/workflows/snapshot.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
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
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019
|
||||
Copyright (c) 2021 comp500
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
194
build.gradle.kts
194
build.gradle.kts
@ -1,32 +1,50 @@
|
||||
plugins {
|
||||
java
|
||||
application
|
||||
id("com.github.johnrengelman.shadow") version "5.0.0"
|
||||
id("com.palantir.git-version") version "0.11.0"
|
||||
id("com.github.breadmoirai.github-release") version "2.2.9"
|
||||
kotlin("jvm") version "1.3.61"
|
||||
id("com.github.johnrengelman.shadow") version "7.1.2"
|
||||
id("com.palantir.git-version") version "0.13.0"
|
||||
id("com.github.breadmoirai.github-release") version "2.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
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("commons-cli:commons-cli:1.4")
|
||||
implementation("com.moandjiezana.toml:toml4j:0.7.2")
|
||||
// TODO: Implement tests
|
||||
//testImplementation "junit:junit:4.12"
|
||||
implementation("com.google.code.gson:gson:2.8.1")
|
||||
implementation("com.squareup.okio:okio:2.2.2")
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
maven {
|
||||
url = uri("https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
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 {
|
||||
mainClassName = "link.infra.packwiz.installer.RequiresBootstrap"
|
||||
mainClass.set("link.infra.packwiz.installer.RequiresBootstrap")
|
||||
}
|
||||
|
||||
val gitVersion: groovy.lang.Closure<*> by extra
|
||||
@ -39,50 +57,150 @@ tasks.jar {
|
||||
}
|
||||
}
|
||||
|
||||
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
|
||||
licenseReport {
|
||||
renderers = arrayOf<com.github.jk1.license.render.ReportRenderer>(
|
||||
com.github.jk1.license.render.InventoryMarkdownReportRenderer("licenses.md", "packwiz-installer")
|
||||
)
|
||||
filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer())
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
dependencies {
|
||||
exclude(dependency("commons-cli:commons-cli:1.4"))
|
||||
exclude(dependency("com.eclipsesource.minimal-json:minimal-json:0.9.5"))
|
||||
// 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
|
||||
tasks.register<Copy>("copyJar") {
|
||||
from(tasks.shadowJar)
|
||||
val copyJar by tasks.registering(Copy::class) {
|
||||
from(distJar)
|
||||
rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar")
|
||||
into("build/libs/")
|
||||
into(layout.buildDirectory.dir("dist"))
|
||||
outputs.file(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") })
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn("copyJar")
|
||||
dependsOn(copyJar)
|
||||
}
|
||||
|
||||
if (project.hasProperty("github.token")) {
|
||||
githubRelease {
|
||||
owner("comp500")
|
||||
repo("packwiz-installer")
|
||||
tagName("${project.version}")
|
||||
releaseName("Release ${project.version}")
|
||||
draft(true)
|
||||
token(findProperty("github.token") as String? ?: "")
|
||||
releaseAssets(tasks.jar.get().destinationDirectory.file("packwiz-installer.jar").get())
|
||||
}
|
||||
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(tasks.build)
|
||||
}
|
||||
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=enable")
|
||||
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=enable")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
272
gradlew
vendored
Normal file → Executable file
272
gradlew
vendored
Normal file → Executable file
@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@ -17,78 +17,113 @@
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# 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
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# 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"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
@ -97,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
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.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
@ -105,84 +140,95 @@ location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
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" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# 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" )
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
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
|
||||
# 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
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# 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" "$@"
|
||||
|
27
gradlew.bat
vendored
27
gradlew.bat
vendored
@ -5,7 +5,7 @@
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem http://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@ -29,6 +29,9 @@ if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@ -37,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
@ -51,7 +54,7 @@ goto fail
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
@ -61,28 +64,14 @@ echo location of your Java installation.
|
||||
|
||||
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
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
|
@ -8,8 +8,6 @@ public class RequiresBootstrap {
|
||||
public static void main(String[] args) {
|
||||
// Very small CLI implementation, because Commons CLI complains on unexpected
|
||||
// options
|
||||
// Also so that Commons CLI can be excluded from the shaded JAR, as it is
|
||||
// included in the bootstrap
|
||||
if (Arrays.stream(args).map(str -> {
|
||||
if (str == null) return "";
|
||||
if (str.startsWith("--")) {
|
||||
|
5
src/main/kotlin/link/infra/packwiz/installer/DevMain.kt
Normal file
5
src/main/kotlin/link/infra/packwiz/installer/DevMain.kt
Normal file
@ -0,0 +1,5 @@
|
||||
package link.infra.packwiz.installer
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
Main(args)
|
||||
}
|
@ -2,40 +2,57 @@ package link.infra.packwiz.installer
|
||||
|
||||
import link.infra.packwiz.installer.metadata.IndexFile
|
||||
import link.infra.packwiz.installer.metadata.ManifestFile
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||
import link.infra.packwiz.installer.ui.ExceptionDetails
|
||||
import link.infra.packwiz.installer.ui.IOptionDetails
|
||||
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.Paths
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.*
|
||||
|
||||
internal class DownloadTask private constructor(val metadata: IndexFile.File, defaultFormat: String, private val downloadSide: UpdateManager.Options.Side) : IOptionDetails {
|
||||
internal class DownloadTask private constructor(val metadata: IndexFile.File, 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
|
||||
|
||||
private var alreadyUpToDate = false
|
||||
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
|
||||
|
||||
val isOptional get() = metadata.linkedFile?.isOptional ?: false
|
||||
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?.hasSide(downloadSide) ?: true
|
||||
fun correctSide() = metadata.linkedFile?.side?.let { downloadSide.hasSide(it) } ?: true
|
||||
|
||||
override val name get() = metadata.name
|
||||
|
||||
@ -51,12 +68,6 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
|
||||
override val optionDescription get() = metadata.linkedFile?.option?.description ?: ""
|
||||
|
||||
init {
|
||||
if (metadata.hashFormat?.isEmpty() != false) {
|
||||
metadata.hashFormat = defaultFormat
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
invalidated = true
|
||||
alreadyUpToDate = false
|
||||
@ -72,7 +83,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
this.cachedFile = cachedFile
|
||||
if (!invalidated) {
|
||||
val currHash = try {
|
||||
getHash(metadata.hashFormat!!, metadata.hash!!)
|
||||
metadata.getHashObj(index)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
@ -80,6 +91,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
if (currHash == cachedFile.hash) { // Already up to date
|
||||
alreadyUpToDate = true
|
||||
metadataRequired = false
|
||||
completionStatus = CompletionStatus.ALREADY_EXISTS_CACHED
|
||||
}
|
||||
}
|
||||
if (cachedFile.isOptional) {
|
||||
@ -89,13 +101,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadMetadata(parentIndexFile: IndexFile, indexUri: SpaceSafeURI) {
|
||||
fun downloadMetadata(clientHolder: ClientHolder) {
|
||||
if (err != null) return
|
||||
|
||||
if (metadataRequired) {
|
||||
try {
|
||||
// Retrieve the linked metadata file
|
||||
metadata.downloadMeta(parentIndexFile, indexUri)
|
||||
metadata.downloadMeta(index, clientHolder)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
@ -103,70 +115,134 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
cachedFile?.let { cachedFile ->
|
||||
val linkedFile = metadata.linkedFile
|
||||
if (linkedFile != null) {
|
||||
linkedFile.option?.let { opt ->
|
||||
if (opt.optional) {
|
||||
if (cachedFile.isOptional) {
|
||||
// isOptional didn't change
|
||||
newOptional = false
|
||||
} else {
|
||||
// isOptional false -> true, set option to it's default value
|
||||
// TODO: preserve previous option value, somehow??
|
||||
cachedFile.optionValue = opt.defaultValue
|
||||
}
|
||||
if (linkedFile.option.optional) {
|
||||
if (cachedFile.isOptional) {
|
||||
// isOptional didn't change
|
||||
newOptional = false
|
||||
} else {
|
||||
// isOptional false -> true, set option to it's default value
|
||||
// TODO: preserve previous option value, somehow??
|
||||
cachedFile.optionValue = linkedFile.option.defaultValue
|
||||
}
|
||||
}
|
||||
cachedFile.isOptional = isOptional
|
||||
cachedFile.onlyOtherSide = !correctSide()
|
||||
}
|
||||
cachedFile.isOptional = isOptional
|
||||
cachedFile.onlyOtherSide = !correctSide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun download(packFolder: String, indexUri: SpaceSafeURI) {
|
||||
/**
|
||||
* Check if the file in the destination location is already valid
|
||||
* Must be done after metadata retrieval
|
||||
*/
|
||||
fun validateExistingFile(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
|
||||
if (!alreadyUpToDate) {
|
||||
try {
|
||||
// TODO: only do this for files that didn't exist before or have been modified since last full update?
|
||||
val destPath = metadata.destURI.rebase(packFolder)
|
||||
destPath.source(clientHolder).use { src ->
|
||||
// TODO: clean up duplicated code
|
||||
val hash: Hash<*>
|
||||
val fileHashFormat: HashFormat<*>
|
||||
val linkedFile = metadata.linkedFile
|
||||
|
||||
if (linkedFile != null) {
|
||||
hash = linkedFile.hash
|
||||
fileHashFormat = linkedFile.download.hashFormat
|
||||
} else {
|
||||
hash = metadata.getHashObj(index)
|
||||
fileHashFormat = metadata.hashFormat(index)
|
||||
}
|
||||
|
||||
val fileSource = fileHashFormat.source(src)
|
||||
fileSource.buffer().readAll(blackholeSink())
|
||||
if (hash == fileSource.hash) {
|
||||
alreadyUpToDate = true
|
||||
completionStatus = CompletionStatus.ALREADY_EXISTS_VALIDATED
|
||||
|
||||
// Update the manifest file
|
||||
cachedFile = (cachedFile ?: ManifestFile.File()).also {
|
||||
try {
|
||||
it.hash = metadata.getHashObj(index)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
it.isOptional = isOptional
|
||||
it.cachedLocation = metadata.destURI.rebase(packFolder)
|
||||
metadata.linkedFile?.let { linked ->
|
||||
try {
|
||||
it.linkedFileHash = linked.hash
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: RequestException) {
|
||||
// Ignore exceptions; if the file doesn't exist we'll be downloading it
|
||||
} catch (e: IOException) {
|
||||
// Ignore exceptions; if the file doesn't exist we'll be downloading it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun download(packFolder: PackwizFilePath, clientHolder: ClientHolder) {
|
||||
if (err != null) return
|
||||
|
||||
// Ensure it is removed
|
||||
// Exclude wrong-side and optional false files
|
||||
cachedFile?.let {
|
||||
if (!it.optionValue || !correctSide()) {
|
||||
if (it.cachedLocation == null) return
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(packFolder, it.cachedLocation))
|
||||
} catch (e: IOException) {
|
||||
// TODO: how much of a problem is this? use log4j/other log library to show warning?
|
||||
e.printStackTrace()
|
||||
if ((it.isOptional && !it.optionValue) || !correctSide()) {
|
||||
if (it.cachedLocation != null) {
|
||||
// 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
|
||||
|
||||
// TODO: should I be validating JSON properly, or this fine!!!!!!!??
|
||||
assert(metadata.destURI != null)
|
||||
val destPath = Paths.get(packFolder, metadata.destURI.toString())
|
||||
val destPath = metadata.destURI.rebase(packFolder)
|
||||
|
||||
// Don't update files marked with preserve if they already exist on disk
|
||||
if (metadata.preserve) {
|
||||
if (destPath.toFile().exists()) {
|
||||
if (destPath.nioPath.toFile().exists()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add .disabled support?
|
||||
|
||||
try {
|
||||
val hash: Hash
|
||||
val fileHashFormat: String
|
||||
val hash: Hash<*>
|
||||
val fileHashFormat: HashFormat<*>
|
||||
val linkedFile = metadata.linkedFile
|
||||
|
||||
if (linkedFile != null) {
|
||||
hash = linkedFile.hash
|
||||
fileHashFormat = Objects.requireNonNull(Objects.requireNonNull(linkedFile.download)!!.hashFormat)!!
|
||||
fileHashFormat = linkedFile.download.hashFormat
|
||||
} else {
|
||||
hash = metadata.getHashObj()
|
||||
fileHashFormat = Objects.requireNonNull(metadata.hashFormat)!!
|
||||
hash = metadata.getHashObj(index)
|
||||
fileHashFormat = metadata.hashFormat(index)
|
||||
}
|
||||
|
||||
val src = metadata.getSource(indexUri)
|
||||
val fileSource = getHasher(fileHashFormat).getHashingSource(src)
|
||||
val src = metadata.getSource(clientHolder)
|
||||
val fileSource = fileHashFormat.source(src)
|
||||
val data = Buffer()
|
||||
|
||||
// Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails)
|
||||
@ -175,20 +251,24 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
it.readAll(data)
|
||||
}
|
||||
|
||||
if (fileSource.hashIsEqual(hash)) {
|
||||
if (hash == fileSource.hash) {
|
||||
// isDirectory follows symlinks, but createDirectories doesn't
|
||||
if (Files.isDirectory(destPath.parent)) {
|
||||
Files.createDirectories(destPath.parent)
|
||||
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, StandardCopyOption.REPLACE_EXISTING)
|
||||
Files.copy(data.inputStream(), destPath.nioPath, StandardCopyOption.REPLACE_EXISTING)
|
||||
data.clear()
|
||||
} else {
|
||||
// TODO: no more PRINTLN!!!!!!!!!
|
||||
// TODO: move println to something visible in the error window
|
||||
println("Invalid hash for " + metadata.destURI.toString())
|
||||
println("Calculated: " + fileSource.hash)
|
||||
println("Expected: $hash")
|
||||
// Attempt to get the SHA256 hash
|
||||
val sha256 = HashingSink.sha256(okio.blackholeSink())
|
||||
val sha256 = HashingSink.sha256(blackholeSink())
|
||||
data.readAll(sha256)
|
||||
println("SHA256 hash value: " + sha256.hash)
|
||||
err = Exception("Hash invalid!")
|
||||
@ -196,10 +276,10 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
return
|
||||
}
|
||||
cachedFile?.cachedLocation?.let {
|
||||
if (destPath != Paths.get(packFolder, it)) {
|
||||
if (destPath != it) {
|
||||
// Delete old file if location changes
|
||||
try {
|
||||
Files.delete(Paths.get(packFolder, cachedFile!!.cachedLocation))
|
||||
Files.delete(cachedFile!!.cachedLocation!!.nioPath)
|
||||
} catch (e: IOException) {
|
||||
// Continue, as it was probably already deleted?
|
||||
// TODO: log it
|
||||
@ -214,13 +294,13 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
// Update the manifest file
|
||||
cachedFile = (cachedFile ?: ManifestFile.File()).also {
|
||||
try {
|
||||
it.hash = metadata.getHashObj()
|
||||
it.hash = metadata.getHashObj(index)
|
||||
} catch (e: Exception) {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
it.isOptional = isOptional
|
||||
it.cachedLocation = metadata.destURI.toString()
|
||||
it.cachedLocation = metadata.destURI.rebase(packFolder)
|
||||
metadata.linkedFile?.let { linked ->
|
||||
try {
|
||||
it.linkedFileHash = linked.hash
|
||||
@ -229,14 +309,15 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, de
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completionStatus = CompletionStatus.DOWNLOADED
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createTasksFromIndex(index: IndexFile, defaultFormat: String, downloadSide: UpdateManager.Options.Side): List<DownloadTask> {
|
||||
fun createTasksFromIndex(index: IndexFile, downloadSide: Side): MutableList<DownloadTask> {
|
||||
val tasks = ArrayList<DownloadTask>()
|
||||
for (file in Objects.requireNonNull(index.files)) {
|
||||
tasks.add(DownloadTask(file, defaultFormat, downloadSide))
|
||||
for (file in index.files) {
|
||||
tasks.add(DownloadTask(file, index, downloadSide))
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
142
src/main/kotlin/link/infra/packwiz/installer/LauncherUtils.kt
Normal file
142
src/main/kotlin/link/infra/packwiz/installer/LauncherUtils.kt
Normal file
@ -0,0 +1,142 @@
|
||||
package link.infra.packwiz.installer
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonIOException
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import link.infra.packwiz.installer.metadata.PackFile
|
||||
import link.infra.packwiz.installer.ui.IUserInterface
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import kotlin.io.path.reader
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
class LauncherUtils internal constructor(private val opts: UpdateManager.Options, val ui: IUserInterface) {
|
||||
enum class LauncherStatus {
|
||||
SUCCESSFUL,
|
||||
NO_CHANGES,
|
||||
CANCELLED,
|
||||
NOT_FOUND, // When there is no mmc-pack.json file found (i.e. MultiMC is not being used)
|
||||
}
|
||||
|
||||
fun handleMultiMC(pf: PackFile, gson: Gson): LauncherStatus {
|
||||
// MultiMC MC and loader version checker
|
||||
val manifestPath = opts.multimcFolder / "mmc-pack.json"
|
||||
|
||||
if (!manifestPath.nioPath.toFile().exists()) {
|
||||
return LauncherStatus.NOT_FOUND
|
||||
}
|
||||
|
||||
val multimcManifest = manifestPath.nioPath.reader().use {
|
||||
try {
|
||||
JsonParser.parseReader(it)
|
||||
} catch (e: JsonIOException) {
|
||||
throw Exception("Cannot read the MultiMC pack file", e)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
throw Exception("Invalid MultiMC pack file", e)
|
||||
}.asJsonObject
|
||||
}
|
||||
|
||||
Log.info("Loaded MultiMC config")
|
||||
|
||||
// We only support format 1, if it gets updated in the future we'll have to handle that
|
||||
// There's only version 1 for now tho, so that's good
|
||||
if (multimcManifest["formatVersion"]?.asInt != 1) {
|
||||
throw Exception("Unsupported MultiMC format version ${multimcManifest["formatVersion"]}")
|
||||
}
|
||||
|
||||
var manifestModified = false
|
||||
val modLoaders = hashMapOf(
|
||||
"net.minecraft" to "minecraft",
|
||||
"net.minecraftforge" to "forge",
|
||||
"net.neoforged" to "neoforge",
|
||||
"net.fabricmc.fabric-loader" to "fabric",
|
||||
"org.quiltmc.quilt-loader" to "quilt",
|
||||
"com.mumfrey.liteloader" to "liteloader"
|
||||
)
|
||||
// MultiMC requires components to be sorted; this is defined in the MultiMC meta repo, but they seem to
|
||||
// be the same for every version so they are just used directly here
|
||||
val componentOrders = mapOf(
|
||||
"net.minecraft" to -2,
|
||||
"org.lwjgl" to -1,
|
||||
"org.lwjgl3" to -1,
|
||||
"net.minecraftforge" to 5,
|
||||
"net.neoforged" to 5,
|
||||
"net.fabricmc.fabric-loader" to 10,
|
||||
"org.quiltmc.quilt-loader" to 10,
|
||||
"com.mumfrey.liteloader" to 10,
|
||||
"net.fabricmc.intermediary" to 11
|
||||
)
|
||||
val modLoadersClasses = modLoaders.entries.associate{(k,v)-> v to k}
|
||||
val loaderVersionsFound = HashMap<String, String?>()
|
||||
val outdatedLoaders = mutableSetOf<String>()
|
||||
val components = multimcManifest["components"]?.asJsonArray ?: throw Exception("Invalid mmc-pack.json: no components key")
|
||||
components.removeAll {
|
||||
val component = it.asJsonObject
|
||||
|
||||
val version = component["version"]?.asString
|
||||
// If we find any of the modloaders we support, we save it and check the version
|
||||
if (modLoaders.containsKey(component["uid"]?.asString)) {
|
||||
val modLoader = modLoaders.getValue(component["uid"]!!.asString)
|
||||
loaderVersionsFound[modLoader] = version
|
||||
if (version != pf.versions[modLoader]) {
|
||||
outdatedLoaders.add(modLoader)
|
||||
true // Delete component; cached metadata is invalid and will be re-added
|
||||
} else {
|
||||
false // Already up to date; cached metadata is valid
|
||||
}
|
||||
} else { false } // Not a known loader / MC
|
||||
}
|
||||
|
||||
for ((_, loader) in modLoaders
|
||||
.filter {
|
||||
(!loaderVersionsFound.containsKey(it.value) || outdatedLoaders.contains(it.value)) && pf.versions.containsKey(it.value)
|
||||
}
|
||||
) {
|
||||
manifestModified = true
|
||||
components.add(gson.toJsonTree(
|
||||
hashMapOf("uid" to modLoadersClasses[loader], "version" to pf.versions[loader]))
|
||||
)
|
||||
}
|
||||
|
||||
// If inconsistent Intermediary mappings version is found, delete it - MultiMC will add and re-dl the correct one
|
||||
components.find { it.isJsonObject && it.asJsonObject["uid"]?.asString == "net.fabricmc.intermediary" }?.let {
|
||||
if (it.asJsonObject["version"]?.asString != pf.versions["minecraft"]) {
|
||||
components.remove(it)
|
||||
manifestModified = true
|
||||
}
|
||||
}
|
||||
|
||||
if (manifestModified) {
|
||||
// Sort manifest by component order
|
||||
val sortedComponents = components.sortedWith(nullsLast(compareBy {
|
||||
if (it.isJsonObject) {
|
||||
componentOrders[it.asJsonObject["uid"]?.asString]
|
||||
} else { null }
|
||||
}))
|
||||
components.removeAll { true }
|
||||
sortedComponents.forEach { components.add(it) }
|
||||
|
||||
// The manifest has been modified, so before saving it we'll ask the user
|
||||
// if they wanna update it, continue without updating it, or exit
|
||||
val oldVers = loaderVersionsFound.map { Pair(it.key, it.value) }
|
||||
val newVers = pf.versions.map { Pair(it.key, it.value) }
|
||||
|
||||
when (ui.showUpdateConfirmationDialog(oldVers, newVers)) {
|
||||
IUserInterface.UpdateConfirmationResult.CANCELLED -> {
|
||||
return LauncherStatus.CANCELLED
|
||||
}
|
||||
IUserInterface.UpdateConfirmationResult.CONTINUE -> {
|
||||
return LauncherStatus.SUCCESSFUL
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
manifestPath.nioPath.writeText(gson.toJson(multimcManifest))
|
||||
Log.info("Successfully updated mmc-pack.json based on version metadata")
|
||||
|
||||
return LauncherStatus.SUCCESSFUL
|
||||
}
|
||||
|
||||
return LauncherStatus.NO_CHANGES
|
||||
}
|
||||
}
|
@ -2,16 +2,23 @@
|
||||
|
||||
package link.infra.packwiz.installer
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.ui.CLIHandler
|
||||
import link.infra.packwiz.installer.ui.InputStateHandler
|
||||
import link.infra.packwiz.installer.ui.InstallWindow
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.HttpUrlPath
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
import link.infra.packwiz.installer.ui.cli.CLIHandler
|
||||
import link.infra.packwiz.installer.ui.gui.GUIHandler
|
||||
import link.infra.packwiz.installer.ui.wrap
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Path.Companion.toPath
|
||||
import org.apache.commons.cli.DefaultParser
|
||||
import org.apache.commons.cli.Options
|
||||
import org.apache.commons.cli.ParseException
|
||||
import java.awt.EventQueue
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URI
|
||||
import java.nio.file.Paths
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.UIManager
|
||||
import kotlin.system.exitProcess
|
||||
@ -19,7 +26,7 @@ import kotlin.system.exitProcess
|
||||
@Suppress("unused")
|
||||
class Main(args: Array<String>) {
|
||||
// Don't attempt to start a GUI if we are headless
|
||||
var guiEnabled = !GraphicsEnvironment.isHeadless()
|
||||
private var guiEnabled = !GraphicsEnvironment.isHeadless()
|
||||
|
||||
private fun startup(args: Array<String>) {
|
||||
val options = Options()
|
||||
@ -30,7 +37,7 @@ class Main(args: Array<String>) {
|
||||
val cmd = try {
|
||||
parser.parse(options, args)
|
||||
} catch (e: ParseException) {
|
||||
e.printStackTrace()
|
||||
Log.fatal("Failed to parse command line arguments", e)
|
||||
if (guiEnabled) {
|
||||
EventQueue.invokeAndWait {
|
||||
try {
|
||||
@ -38,7 +45,8 @@ class Main(args: Array<String>) {
|
||||
} catch (ignored: Exception) {
|
||||
// Ignore the exceptions, just continue using the ugly L&F
|
||||
}
|
||||
JOptionPane.showMessageDialog(null, e.message, "packwiz-installer", JOptionPane.ERROR_MESSAGE)
|
||||
JOptionPane.showMessageDialog(null, "Failed to parse command line arguments: $e",
|
||||
"packwiz-installer", JOptionPane.ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
exitProcess(1)
|
||||
@ -48,46 +56,65 @@ class Main(args: Array<String>) {
|
||||
guiEnabled = false
|
||||
}
|
||||
|
||||
val ui = if (guiEnabled) InstallWindow() else CLIHandler()
|
||||
val ui = if (guiEnabled) GUIHandler() else CLIHandler()
|
||||
|
||||
val unparsedArgs = cmd.args
|
||||
if (unparsedArgs.size > 1) {
|
||||
ui.handleExceptionAndExit(RuntimeException("Too many arguments specified!"))
|
||||
ui.showErrorAndExit("Too many arguments specified!")
|
||||
} else if (unparsedArgs.isEmpty()) {
|
||||
ui.handleExceptionAndExit(RuntimeException("URI to install from must be specified!"))
|
||||
ui.showErrorAndExit("pack.toml URI to install from must be specified!")
|
||||
}
|
||||
|
||||
cmd.getOptionValue("title")?.also(ui::setTitle)
|
||||
|
||||
val inputStateHandler = InputStateHandler()
|
||||
ui.show(inputStateHandler)
|
||||
|
||||
val uOptions = UpdateManager.Options().apply {
|
||||
side = cmd.getOptionValue("side")?.let((UpdateManager.Options.Side)::from) ?: side
|
||||
packFolder = cmd.getOptionValue("pack-folder") ?: packFolder
|
||||
manifestFile = cmd.getOptionValue("meta-file") ?: manifestFile
|
||||
val title = cmd.getOptionValue("title")
|
||||
if (title != null) {
|
||||
ui.title = title
|
||||
}
|
||||
|
||||
try {
|
||||
uOptions.downloadURI = SpaceSafeURI(unparsedArgs[0])
|
||||
} catch (e: URISyntaxException) {
|
||||
// TODO: better error message?
|
||||
ui.handleExceptionAndExit(e)
|
||||
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!
|
||||
// TODO: start in SwingWorker?
|
||||
try {
|
||||
ui.executeManager {
|
||||
try {
|
||||
UpdateManager(uOptions, ui, inputStateHandler)
|
||||
} catch (e: Exception) { // TODO: better error message?
|
||||
ui.handleExceptionAndExit(e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) { // TODO: better error message?
|
||||
ui.handleExceptionAndExit(e)
|
||||
UpdateManager(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 {
|
||||
@ -97,7 +124,9 @@ class Main(args: Array<String>) {
|
||||
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?
|
||||
@ -110,6 +139,12 @@ class Main(args: Array<String>) {
|
||||
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!
|
||||
@ -118,17 +153,17 @@ class Main(args: Array<String>) {
|
||||
try {
|
||||
startup(args)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.fatal("Error from main", e)
|
||||
if (guiEnabled) {
|
||||
EventQueue.invokeLater {
|
||||
JOptionPane.showMessageDialog(null,
|
||||
"A fatal error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
|
||||
"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())
|
||||
}
|
||||
// In case the EventQueue is broken, exit after 1 minute
|
||||
Thread.sleep(60 * 1000.toLong())
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
@ -1,40 +1,39 @@
|
||||
package link.infra.packwiz.installer
|
||||
|
||||
import cc.ekblad.toml.decode
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonIOException
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.moandjiezana.toml.Toml
|
||||
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex
|
||||
import link.infra.packwiz.installer.metadata.DownloadMode
|
||||
import link.infra.packwiz.installer.metadata.IndexFile
|
||||
import link.infra.packwiz.installer.metadata.ManifestFile
|
||||
import link.infra.packwiz.installer.metadata.PackFile
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.metadata.curseforge.resolveCfMetadata
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.ui.IUserInterface
|
||||
import link.infra.packwiz.installer.ui.IUserInterface.CancellationResult
|
||||
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
|
||||
import link.infra.packwiz.installer.ui.InputStateHandler
|
||||
import link.infra.packwiz.installer.ui.InstallProgress
|
||||
import link.infra.packwiz.installer.ui.data.InstallProgress
|
||||
import link.infra.packwiz.installer.util.Log
|
||||
import okio.buffer
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileReader
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletionService
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ExecutorCompletionService
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class UpdateManager internal constructor(private val opts: Options, val ui: IUserInterface, private val stateHandler: InputStateHandler) {
|
||||
class UpdateManager internal constructor(private val opts: Options, val ui: IUserInterface) {
|
||||
private var cancelled = false
|
||||
private var cancelledStartGame = false
|
||||
private var errorsOccurred = false
|
||||
@ -44,171 +43,153 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
}
|
||||
|
||||
data class Options(
|
||||
var downloadURI: SpaceSafeURI? = null,
|
||||
var manifestFile: String = "packwiz.json", // TODO: make configurable
|
||||
var packFolder: String = ".",
|
||||
var side: Side = Side.CLIENT
|
||||
) {
|
||||
enum class Side {
|
||||
@SerializedName("client")
|
||||
CLIENT("client"),
|
||||
@SerializedName("server")
|
||||
SERVER("server"),
|
||||
@SerializedName("both")
|
||||
@Suppress("unused")
|
||||
BOTH("both", arrayOf(CLIENT, SERVER));
|
||||
|
||||
private val sideName: String
|
||||
private val depSides: Array<Side>?
|
||||
|
||||
constructor(sideName: String) {
|
||||
this.sideName = sideName.toLowerCase()
|
||||
depSides = null
|
||||
}
|
||||
|
||||
constructor(sideName: String, depSides: Array<Side>) {
|
||||
this.sideName = sideName.toLowerCase()
|
||||
this.depSides = depSides
|
||||
}
|
||||
|
||||
override fun toString() = sideName
|
||||
|
||||
fun hasSide(tSide: Side): Boolean {
|
||||
if (this == tSide) {
|
||||
return true
|
||||
}
|
||||
if (depSides != null) {
|
||||
for (depSide in depSides) {
|
||||
if (depSide == tSide) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(name: String): Side? {
|
||||
val lowerName = name.toLowerCase()
|
||||
for (side in values()) {
|
||||
if (side.sideName == lowerName) {
|
||||
return side
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
checkOptions()
|
||||
val clientHolder = ClientHolder()
|
||||
ui.cancelCallback = {
|
||||
clientHolder.close()
|
||||
}
|
||||
|
||||
ui.submitProgress(InstallProgress("Loading manifest file..."))
|
||||
val gson = GsonBuilder().registerTypeAdapter(Hash::class.java, Hash.TypeHandler()).create()
|
||||
val gson = GsonBuilder()
|
||||
.registerTypeAdapter(Hash::class.java, Hash.TypeHandler())
|
||||
.registerTypeAdapter(PackwizFilePath::class.java, PackwizPath.adapterRelativeTo(opts.packFolder))
|
||||
.enableComplexMapKeySerialization()
|
||||
.create()
|
||||
val manifest = try {
|
||||
gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
|
||||
ManifestFile::class.java)
|
||||
} catch (e: FileNotFoundException) {
|
||||
// TODO: kotlinx.serialisation?
|
||||
InputStreamReader(opts.manifestFile.source(clientHolder).inputStream(), StandardCharsets.UTF_8).use { reader ->
|
||||
gson.fromJson(reader, ManifestFile::class.java)
|
||||
}
|
||||
} catch (e: RequestException.Response.File.FileNotFound) {
|
||||
ui.firstInstall = true
|
||||
ManifestFile()
|
||||
} catch (e: JsonSyntaxException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
ui.showErrorAndExit("Invalid local manifest file, try deleting ${opts.manifestFile}", e)
|
||||
} catch (e: JsonIOException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
ui.showErrorAndExit("Failed to read local manifest file, try deleting ${opts.manifestFile}", e)
|
||||
}
|
||||
|
||||
if (stateHandler.cancelButton) {
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
handleCancellation()
|
||||
}
|
||||
|
||||
ui.submitProgress(InstallProgress("Loading pack file..."))
|
||||
val packFileSource = try {
|
||||
val src = getFileSource(opts.downloadURI!!)
|
||||
getHasher("sha256").getHashingSource(src)
|
||||
val src = opts.packFile.source(clientHolder)
|
||||
HashFormat.SHA256.source(src)
|
||||
} catch (e: Exception) {
|
||||
// TODO: run cancellation window?
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
// TODO: ensure suppressed/caused exceptions are shown?
|
||||
ui.showErrorAndExit("Failed to download pack.toml", e)
|
||||
}
|
||||
val pf = packFileSource.buffer().use {
|
||||
try {
|
||||
Toml().read(it.inputStream()).to(PackFile::class.java)
|
||||
PackFile.mapper(opts.packFile).decode<PackFile>(it.inputStream())
|
||||
} catch (e: IllegalStateException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
ui.showErrorAndExit("Failed to parse pack.toml", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (stateHandler.cancelButton) {
|
||||
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..."))
|
||||
|
||||
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked
|
||||
val invalidatedUris: MutableList<SpaceSafeURI> = ArrayList()
|
||||
for ((fileUri, file) in manifest.cachedFiles) {
|
||||
// ignore onlyOtherSide files
|
||||
if (file.onlyOtherSide) {
|
||||
continue
|
||||
}
|
||||
// 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 (!Paths.get(opts.packFolder, file.cachedLocation).toFile().exists()) {
|
||||
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
|
||||
}
|
||||
} 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 (invalid) {
|
||||
println("File $fileUri 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
|
||||
println("Modpack is already up to date!")
|
||||
// todo: --force?
|
||||
if (!stateHandler.optionsButton) {
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.info("Modpack name: ${pf.name}")
|
||||
|
||||
println("Modpack name: " + pf.name)
|
||||
|
||||
if (stateHandler.cancelButton) {
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
handleCancellation()
|
||||
}
|
||||
try {
|
||||
val index = pf.index!!
|
||||
getNewLoc(opts.downloadURI, index.file)?.let { newLoc ->
|
||||
index.hashFormat?.let { hashFormat ->
|
||||
processIndex(
|
||||
newLoc,
|
||||
getHash(index.hashFormat!!, index.hash!!),
|
||||
hashFormat,
|
||||
manifest,
|
||||
invalidatedUris
|
||||
)
|
||||
}
|
||||
}
|
||||
processIndex(
|
||||
pf.index.file,
|
||||
pf.index.hashFormat.fromString(pf.index.hash),
|
||||
pf.index.hashFormat,
|
||||
manifest,
|
||||
invalidatedUris,
|
||||
invalidateAll,
|
||||
clientHolder
|
||||
)
|
||||
} catch (e1: Exception) {
|
||||
ui.handleExceptionAndExit(e1)
|
||||
ui.showErrorAndExit("Failed to process index file", 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) {
|
||||
@ -220,82 +201,69 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
|
||||
manifest.cachedSide = opts.side
|
||||
try {
|
||||
FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString()).use { writer -> gson.toJson(manifest, writer) }
|
||||
Files.newBufferedWriter(opts.manifestFile.nioPath, StandardCharsets.UTF_8).use { writer -> gson.toJson(manifest, writer) }
|
||||
} catch (e: IOException) {
|
||||
// TODO: add message?
|
||||
ui.handleException(e)
|
||||
ui.showErrorAndExit("Failed to save local manifest file", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkOptions() {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
private fun processIndex(indexUri: SpaceSafeURI, indexHash: Hash, hashFormat: String, manifest: ManifestFile, invalidatedUris: List<SpaceSafeURI>) {
|
||||
if (manifest.indexFileHash == indexHash && invalidatedUris.isEmpty()) {
|
||||
println("Modpack files are already up to date!")
|
||||
if (!stateHandler.optionsButton) {
|
||||
return
|
||||
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 = getFileSource(indexUri)
|
||||
getHasher(hashFormat).getHashingSource(src)
|
||||
val src = indexUri.source(clientHolder)
|
||||
hashFormat.source(src)
|
||||
} catch (e: Exception) {
|
||||
// TODO: run cancellation window?
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
ui.showErrorAndExit("Failed to download index file", e)
|
||||
}
|
||||
|
||||
val indexFile = try {
|
||||
Toml().read(indexFileSource.buffer().inputStream()).to(IndexFile::class.java)
|
||||
IndexFile.mapper(indexUri).decode<IndexFile>(indexFileSource.buffer().inputStream())
|
||||
} catch (e: IllegalStateException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
ui.showErrorAndExit("Failed to parse index file", e)
|
||||
}
|
||||
if (!indexFileSource.hashIsEqual(indexHash)) {
|
||||
ui.handleExceptionAndExit(RuntimeException("Your index hash is invalid! Please run packwiz refresh on the pack again"))
|
||||
return
|
||||
if (indexHash != indexFileSource.hash) {
|
||||
ui.showErrorAndExit("Your index file hash is invalid! The pack developer should packwiz refresh on the pack again")
|
||||
}
|
||||
if (stateHandler.cancelButton) {
|
||||
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
return
|
||||
}
|
||||
|
||||
ui.submitProgress(InstallProgress("Checking local files..."))
|
||||
// TODO: use kotlin filtering/FP rather than an iterator?
|
||||
val it: MutableIterator<Map.Entry<SpaceSafeURI, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
|
||||
val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val (uri, file) = it.next()
|
||||
if (file.cachedLocation != null) {
|
||||
var alreadyDeleted = false
|
||||
// Delete if option value has been set to false
|
||||
if (file.isOptional && !file.optionValue) {
|
||||
if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
|
||||
Files.deleteIfExists(file.cachedLocation!!.nioPath)
|
||||
} catch (e: IOException) {
|
||||
// TODO: should this be shown to the user in some way?
|
||||
e.printStackTrace()
|
||||
}
|
||||
// Set to null, as it doesn't exist anymore
|
||||
file.cachedLocation = null
|
||||
alreadyDeleted = true
|
||||
}
|
||||
if (indexFile.files.none { it.file == uri }) { // File has been removed from the index
|
||||
if (!alreadyDeleted) {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(opts.packFolder, file.cachedLocation))
|
||||
} catch (e: IOException) { // TODO: should this be shown to the user in some way?
|
||||
e.printStackTrace()
|
||||
}
|
||||
Log.warn("Failed to delete file removed from index", e)
|
||||
}
|
||||
Log.info("Deleted ${file.cachedLocation!!.filename} (removed from pack)")
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stateHandler.cancelButton) {
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
return
|
||||
}
|
||||
@ -303,51 +271,38 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
|
||||
// TODO: progress bar?
|
||||
if (indexFile.files.isEmpty()) {
|
||||
println("Warning: Index is empty!")
|
||||
Log.warn("Index is empty!")
|
||||
}
|
||||
val tasks = createTasksFromIndex(indexFile, indexFile.hashFormat, opts.side)
|
||||
// If the side changes, invalidate EVERYTHING just in case
|
||||
// Might not be needed, but done just to be safe
|
||||
val invalidateAll = opts.side != manifest.cachedSide
|
||||
val tasks = createTasksFromIndex(indexFile, opts.side)
|
||||
if (invalidateAll) {
|
||||
println("Side changed, invalidating all mods")
|
||||
Log.info("Side changed, invalidating all mods")
|
||||
}
|
||||
tasks.forEach{ f ->
|
||||
// TODO: should linkedfile be checked as well? should this be done in the download section?
|
||||
if (invalidateAll) {
|
||||
f.invalidate()
|
||||
} else if (invalidatedUris.contains(f.metadata.file)) {
|
||||
} else if (invalidatedFiles.contains(f.metadata.file.rebase(opts.packFolder))) {
|
||||
f.invalidate()
|
||||
}
|
||||
val file = manifest.cachedFiles[f.metadata.file]
|
||||
val file = manifest.cachedFiles[f.metadata.file.rebase(opts.packFolder)]
|
||||
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
|
||||
file?.backup()
|
||||
// If it is null, the DownloadTask will make a new empty cachedFile
|
||||
f.updateFromCache(file)
|
||||
}
|
||||
|
||||
if (stateHandler.cancelButton) {
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
return
|
||||
}
|
||||
|
||||
// Let's hope downloadMetadata is a pure function!!!
|
||||
tasks.parallelStream().forEach { f -> f.downloadMetadata(indexFile, indexUri) }
|
||||
tasks.parallelStream().forEach { f -> f.downloadMetadata(clientHolder) }
|
||||
|
||||
val failedTaskDetails = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
|
||||
if (failedTaskDetails.isNotEmpty()) {
|
||||
errorsOccurred = true
|
||||
val exceptionListResult: ExceptionListResult
|
||||
exceptionListResult = try {
|
||||
ui.showExceptions(failedTaskDetails, tasks.size, true).get()
|
||||
} catch (e: InterruptedException) { // Interrupted means cancelled???
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
} catch (e: ExecutionException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
}
|
||||
when (exceptionListResult) {
|
||||
when (ui.showExceptions(failedTaskDetails, tasks.size, true)) {
|
||||
ExceptionListResult.CONTINUE -> {}
|
||||
ExceptionListResult.CANCEL -> {
|
||||
cancelled = true
|
||||
@ -360,80 +315,94 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
}
|
||||
}
|
||||
|
||||
if (stateHandler.cancelButton) {
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: task failed function?
|
||||
val nonFailedFirstTasks = tasks.filter { t -> !t.failed() }.toList()
|
||||
val optionTasks = nonFailedFirstTasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
|
||||
// If options changed, present all options again
|
||||
if (stateHandler.optionsButton || optionTasks.any(DownloadTask::isNewOptional)) {
|
||||
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
|
||||
val cancelledResult = ui.showOptions(ArrayList(optionTasks))
|
||||
try {
|
||||
if (cancelledResult.get()) {
|
||||
cancelled = true
|
||||
// TODO: Should the UI be closed somehow??
|
||||
tasks.removeAll { it.failed() }
|
||||
val optionTasks = tasks.filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).toList()
|
||||
val optionsChanged = optionTasks.any(DownloadTask::isNewOptional)
|
||||
if (optionTasks.isNotEmpty() && !optionsChanged) {
|
||||
if (!ui.optionsButtonPressed) {
|
||||
// TODO: this is so ugly
|
||||
ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1))
|
||||
ui.awaitOptionalButton(true, opts.timeout)
|
||||
if (ui.cancelButtonPressed) {
|
||||
showCancellationDialog()
|
||||
return
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
// Interrupted means cancelled???
|
||||
ui.handleExceptionAndExit(e)
|
||||
} catch (e: ExecutionException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
}
|
||||
}
|
||||
ui.disableOptionsButton()
|
||||
// If options changed, present all options again
|
||||
if (ui.optionsButtonPressed || optionsChanged) {
|
||||
// new ArrayList is required so it's an IOptionDetails rather than a DownloadTask list
|
||||
if (ui.showOptions(ArrayList(optionTasks))) {
|
||||
cancelled = true
|
||||
handleCancellation()
|
||||
}
|
||||
}
|
||||
// TODO: keep this enabled? then apply changes after download process?
|
||||
ui.disableOptionsButton(optionTasks.isNotEmpty())
|
||||
|
||||
while (true) {
|
||||
when (validateAndResolve(tasks, 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, indexUri)
|
||||
t.download(opts.packFolder, clientHolder)
|
||||
t
|
||||
}
|
||||
}
|
||||
for (i in tasks.indices) {
|
||||
var task: DownloadTask?
|
||||
task = try {
|
||||
val task: DownloadTask = try {
|
||||
completionService.take().get()
|
||||
} catch (e: InterruptedException) {
|
||||
ui.handleException(e)
|
||||
null
|
||||
ui.showErrorAndExit("Interrupted when consuming download tasks", e)
|
||||
} catch (e: ExecutionException) {
|
||||
ui.handleException(e)
|
||||
null
|
||||
ui.showErrorAndExit("Failed to execute download task", e)
|
||||
}
|
||||
// Update manifest - If there were no errors cachedFile has already been modified in place (good old pass by reference)
|
||||
task?.cachedFile?.let { file ->
|
||||
task.cachedFile?.let { file ->
|
||||
if (task.failed()) {
|
||||
val oldFile = file.revert
|
||||
if (oldFile != null) {
|
||||
task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, oldFile) }
|
||||
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), oldFile)
|
||||
} else { null }
|
||||
} else {
|
||||
task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, file) }
|
||||
manifest.cachedFiles.putIfAbsent(task.metadata.file.rebase(opts.packFolder), file)
|
||||
}
|
||||
}
|
||||
|
||||
var progress: String
|
||||
if (task != null) {
|
||||
val exDetails = task.exceptionDetails
|
||||
if (exDetails != null) {
|
||||
progress = "Failed to download ${exDetails.name}: ${exDetails.exception.message}"
|
||||
exDetails.exception.printStackTrace()
|
||||
} else {
|
||||
progress = "Downloaded ${task.name}"
|
||||
}
|
||||
val exDetails = task.exceptionDetails
|
||||
val progress = if (exDetails != null) {
|
||||
"Failed to download ${exDetails.name}: ${exDetails.exception.message}"
|
||||
} else {
|
||||
progress = "Failed to download, unknown reason"
|
||||
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 (stateHandler.cancelButton) { // Stop all tasks, don't launch the game (it's in an invalid state!)
|
||||
if (ui.cancelButtonPressed) { // Stop all tasks, don't launch the game (it's in an invalid state!)
|
||||
// TODO: close client holder in more places?
|
||||
clientHolder.close()
|
||||
threadPool.shutdown()
|
||||
cancelled = true
|
||||
return
|
||||
@ -443,21 +412,10 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
// Shut down the thread pool when the update is done
|
||||
threadPool.shutdown()
|
||||
|
||||
val failedTasks2ElectricBoogaloo = nonFailedFirstTasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
|
||||
val failedTasks2ElectricBoogaloo = tasks.asSequence().map(DownloadTask::exceptionDetails).filterNotNull().toList()
|
||||
if (failedTasks2ElectricBoogaloo.isNotEmpty()) {
|
||||
errorsOccurred = true
|
||||
val exceptionListResult: ExceptionListResult
|
||||
exceptionListResult = try {
|
||||
ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false).get()
|
||||
} catch (e: InterruptedException) {
|
||||
// Interrupted means cancelled???
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
} catch (e: ExecutionException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
}
|
||||
when (exceptionListResult) {
|
||||
when (ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size, false)) {
|
||||
ExceptionListResult.CONTINUE -> {}
|
||||
ExceptionListResult.CANCEL -> cancelled = true
|
||||
ExceptionListResult.IGNORE -> cancelledStartGame = true
|
||||
@ -465,24 +423,57 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCancellationDialog() {
|
||||
val cancellationResult: CancellationResult
|
||||
cancellationResult = try {
|
||||
ui.showCancellationDialog().get()
|
||||
} catch (e: InterruptedException) {
|
||||
// Interrupted means cancelled???
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
} catch (e: ExecutionException) {
|
||||
ui.handleExceptionAndExit(e)
|
||||
return
|
||||
enum class ResolveResult {
|
||||
RETRY,
|
||||
QUIT,
|
||||
SUCCESS;
|
||||
}
|
||||
|
||||
private fun validateAndResolve(nonFailedFirstTasks: List<DownloadTask>, clientHolder: ClientHolder): ResolveResult {
|
||||
ui.submitProgress(InstallProgress("Validating existing files..."))
|
||||
|
||||
// Validate existing files
|
||||
for (downloadTask in nonFailedFirstTasks.filter(DownloadTask::correctSide)) {
|
||||
downloadTask.validateExistingFile(opts.packFolder, clientHolder)
|
||||
}
|
||||
when (cancellationResult) {
|
||||
|
||||
// Resolve CurseForge metadata
|
||||
val cfFiles = nonFailedFirstTasks.asSequence().filter { !it.alreadyUpToDate }
|
||||
.filter(DownloadTask::correctSide)
|
||||
.map { it.metadata }
|
||||
.filter { it.linkedFile != null }
|
||||
.filter { it.linkedFile!!.download.mode == 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!")
|
||||
@ -492,4 +483,5 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import cc.ekblad.toml.model.TomlValue
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
|
||||
enum class DownloadMode {
|
||||
URL,
|
||||
CURSEFORGE;
|
||||
|
||||
companion object {
|
||||
fun mapper() = tomlMapper {
|
||||
decoder { it: TomlValue.String -> when (it.value) {
|
||||
"", "url" -> URL
|
||||
"metadata:curseforge" -> CURSEFORGE
|
||||
else -> throw Exception("Unsupported download mode ${it.value}")
|
||||
} }
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ class EfficientBooleanAdapter : TypeAdapter<Boolean?>() {
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): Boolean? {
|
||||
override fun read(reader: JsonReader): Boolean {
|
||||
if (reader.peek() == JsonToken.NULL) {
|
||||
reader.nextNull()
|
||||
return false
|
||||
|
@ -1,99 +1,97 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.moandjiezana.toml.Toml
|
||||
import cc.ekblad.toml.decode
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.util.delegateTransitive
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.nio.file.Paths
|
||||
|
||||
class IndexFile {
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String = "sha-256"
|
||||
var files: MutableList<File> = ArrayList()
|
||||
|
||||
class File {
|
||||
var file: SpaceSafeURI? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hash: String? = null
|
||||
var alias: SpaceSafeURI? = null
|
||||
var metafile = false
|
||||
var preserve = false
|
||||
|
||||
@Transient
|
||||
data class IndexFile(
|
||||
val hashFormat: HashFormat<*>,
|
||||
val files: List<File> = listOf()
|
||||
) {
|
||||
data class File(
|
||||
val file: PackwizPath<*>,
|
||||
private val hashFormat: HashFormat<*>? = null,
|
||||
val hash: String,
|
||||
val alias: PackwizPath<*>?,
|
||||
val metafile: Boolean = false,
|
||||
val preserve: Boolean = false,
|
||||
) {
|
||||
var linkedFile: ModFile? = null
|
||||
@Transient
|
||||
var linkedFileURI: SpaceSafeURI? = null
|
||||
|
||||
fun hashFormat(index: IndexFile) = hashFormat ?: index.hashFormat
|
||||
@Throws(Exception::class)
|
||||
fun getHashObj(index: IndexFile): Hash<*> {
|
||||
// TODO: more specific exceptions?
|
||||
return hashFormat(index).fromString(hash)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun downloadMeta(parentIndexFile: IndexFile, indexUri: SpaceSafeURI?) {
|
||||
fun downloadMeta(index: IndexFile, clientHolder: ClientHolder) {
|
||||
if (!metafile) {
|
||||
return
|
||||
}
|
||||
if (hashFormat?.length ?: 0 == 0) {
|
||||
hashFormat = parentIndexFile.hashFormat
|
||||
}
|
||||
// TODO: throw a proper exception instead of allowing NPE?
|
||||
val fileHash = getHash(hashFormat!!, hash!!)
|
||||
linkedFileURI = getNewLoc(indexUri, file)
|
||||
val src = getFileSource(linkedFileURI!!)
|
||||
val fileStream = getHasher(hashFormat!!).getHashingSource(src)
|
||||
linkedFile = Toml().read(fileStream.buffer().inputStream()).to(ModFile::class.java)
|
||||
if (!fileStream.hashIsEqual(fileHash)) {
|
||||
val fileHash = getHashObj(index)
|
||||
val src = file.source(clientHolder)
|
||||
val fileStream = hashFormat(index).source(src)
|
||||
linkedFile = ModFile.mapper(file).decode<ModFile>(fileStream.buffer().inputStream())
|
||||
if (fileHash != fileStream.hash) {
|
||||
// TODO: propagate details about hash, and show better error!
|
||||
throw Exception("Invalid mod file hash")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getSource(indexUri: SpaceSafeURI?): Source {
|
||||
fun getSource(clientHolder: ClientHolder): Source {
|
||||
return if (metafile) {
|
||||
if (linkedFile == null) {
|
||||
throw Exception("Linked file doesn't exist!")
|
||||
}
|
||||
linkedFile!!.getSource(linkedFileURI)
|
||||
linkedFile!!.getSource(clientHolder)
|
||||
} else {
|
||||
val newLoc = getNewLoc(indexUri, file) ?: throw Exception("Index file URI is invalid")
|
||||
getFileSource(newLoc)
|
||||
file.source(clientHolder)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getHashObj(): Hash {
|
||||
if (hash == null) { // TODO: should these be more specific exceptions (e.g. IndexFileException?!)
|
||||
throw Exception("Index file doesn't have a hash")
|
||||
}
|
||||
if (hashFormat == null) {
|
||||
throw Exception("Index file doesn't have a hash format")
|
||||
}
|
||||
return getHash(hashFormat!!, hash!!)
|
||||
}
|
||||
|
||||
// TODO: throw some kind of exception?
|
||||
val name: String
|
||||
get() {
|
||||
if (metafile) {
|
||||
return linkedFile?.name ?: linkedFile?.filename ?:
|
||||
file?.run { Paths.get(path).fileName.toString() } ?: "Invalid file"
|
||||
return linkedFile?.name ?: file.filename
|
||||
}
|
||||
return file?.run { Paths.get(path).fileName.toString() } ?: "Invalid file"
|
||||
return file.filename
|
||||
}
|
||||
|
||||
// TODO: URIs are bad
|
||||
val destURI: SpaceSafeURI?
|
||||
val destURI: PackwizPath<*>
|
||||
get() {
|
||||
if (alias != null) {
|
||||
return alias
|
||||
}
|
||||
return if (metafile && linkedFile != null) {
|
||||
linkedFile?.filename?.let { file?.resolve(it) }
|
||||
return if (metafile) {
|
||||
linkedFile!!.filename
|
||||
} else {
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun mapper(base: PackwizPath<*>) = tomlMapper {
|
||||
mapping<File>("hash-format" to "hashFormat")
|
||||
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
|
||||
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun mapper(base: PackwizPath<*>) = tomlMapper {
|
||||
mapping<IndexFile>("hash-format" to "hashFormat")
|
||||
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
|
||||
delegateTransitive<File>(File.mapper(base))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,25 +1,25 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter
|
||||
import link.infra.packwiz.installer.UpdateManager
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.PackwizFilePath
|
||||
|
||||
class ManifestFile {
|
||||
var packFileHash: Hash? = null
|
||||
var indexFileHash: Hash? = null
|
||||
var cachedFiles: MutableMap<SpaceSafeURI, File> = HashMap()
|
||||
var packFileHash: Hash<*>? = null
|
||||
var indexFileHash: Hash<*>? = null
|
||||
var cachedFiles: MutableMap<PackwizFilePath, File> = HashMap()
|
||||
// If the side changes, EVERYTHING invalidates. FUN!!!
|
||||
var cachedSide = UpdateManager.Options.Side.CLIENT
|
||||
var cachedSide = Side.CLIENT
|
||||
|
||||
// TODO: switch to Kotlin-friendly JSON/TOML libs?
|
||||
class File {
|
||||
@Transient
|
||||
var revert: File? = null
|
||||
private set
|
||||
|
||||
var hash: Hash? = null
|
||||
var linkedFileHash: Hash? = null
|
||||
var cachedLocation: String? = null
|
||||
var hash: Hash<*>? = null
|
||||
var linkedFileHash: Hash<*>? = null
|
||||
var cachedLocation: PackwizFilePath? = null
|
||||
|
||||
@JsonAdapter(EfficientBooleanAdapter::class)
|
||||
var isOptional = false
|
||||
|
@ -1,57 +1,96 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import link.infra.packwiz.installer.UpdateManager
|
||||
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.HashUtils.getHash
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
|
||||
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import link.infra.packwiz.installer.target.Side
|
||||
import link.infra.packwiz.installer.target.path.HttpUrlPath
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.util.delegateTransitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.Source
|
||||
import kotlin.reflect.KType
|
||||
|
||||
class ModFile {
|
||||
var name: String? = null
|
||||
var filename: String? = null
|
||||
var side: UpdateManager.Options.Side? = null
|
||||
var download: Download? = null
|
||||
data class ModFile(
|
||||
val name: String,
|
||||
val filename: PackwizPath<*>,
|
||||
val side: Side = Side.BOTH,
|
||||
val download: Download,
|
||||
val update: Map<String, UpdateData> = mapOf(),
|
||||
val option: Option = Option(false)
|
||||
) {
|
||||
data class Download(
|
||||
val url: PackwizPath<*>?,
|
||||
val hashFormat: HashFormat<*>,
|
||||
val hash: String,
|
||||
val mode: DownloadMode = DownloadMode.URL
|
||||
) {
|
||||
companion object {
|
||||
fun mapper() = tomlMapper {
|
||||
decoder<TomlValue.String, PackwizPath<*>> { it -> HttpUrlPath(it.value.toHttpUrl()) }
|
||||
mapping<Download>("hash-format" to "hashFormat")
|
||||
|
||||
class Download {
|
||||
var url: SpaceSafeURI? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hash: String? = null
|
||||
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
|
||||
delegate<DownloadMode>(DownloadMode.mapper())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var update: Map<String, Any>? = null
|
||||
var option: Option? = null
|
||||
@Transient
|
||||
val resolvedUpdateData = mutableMapOf<String, PackwizPath<*>>()
|
||||
|
||||
class Option {
|
||||
var optional = false
|
||||
var description: String? = null
|
||||
@SerializedName("default")
|
||||
var defaultValue = false
|
||||
data class Option(
|
||||
val optional: Boolean,
|
||||
val description: String = "",
|
||||
val defaultValue: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun mapper() = tomlMapper {
|
||||
mapping<Option>("default" to "defaultValue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getSource(baseLoc: SpaceSafeURI?): Source {
|
||||
download?.let {
|
||||
if (it.url == null) {
|
||||
throw Exception("Metadata file doesn't have a download URI")
|
||||
fun getSource(clientHolder: ClientHolder): Source {
|
||||
return when (download.mode) {
|
||||
DownloadMode.URL -> {
|
||||
(download.url ?: throw Exception("No download URL provided")).source(clientHolder)
|
||||
}
|
||||
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid")
|
||||
return getFileSource(newLoc)
|
||||
} ?: throw Exception("Metadata file doesn't have download")
|
||||
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?.let {
|
||||
return getHash(
|
||||
it.hashFormat ?: throw Exception("Metadata file doesn't have a hash format"),
|
||||
it.hash ?: throw Exception("Metadata file doesn't have a hash")
|
||||
)
|
||||
} ?: throw Exception("Metadata file doesn't have download")
|
||||
}
|
||||
val hash: Hash<*>
|
||||
get() = download.hashFormat.fromString(download.hash)
|
||||
|
||||
val isOptional: Boolean get() = option?.optional ?: false
|
||||
companion object {
|
||||
fun mapper(base: PackwizPath<*>) = tomlMapper {
|
||||
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
|
||||
|
||||
delegateTransitive<Option>(Option.mapper())
|
||||
delegateTransitive<Download>(Download.mapper())
|
||||
|
||||
delegateTransitive<Side>(Side.mapper())
|
||||
|
||||
val updateDataMapper = UpdateData.mapper()
|
||||
decoder { type: KType, it: TomlValue.Map ->
|
||||
if (type.arguments[1].type?.classifier == UpdateData::class) {
|
||||
updateDataMapper.decode<Map<String, UpdateData>>(it)
|
||||
} else {
|
||||
pass()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,19 +1,37 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import cc.ekblad.toml.model.TomlValue
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import link.infra.packwiz.installer.metadata.hash.HashFormat
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
import link.infra.packwiz.installer.util.delegateTransitive
|
||||
|
||||
class PackFile {
|
||||
var name: String? = null
|
||||
var index: IndexFileLoc? = null
|
||||
|
||||
class IndexFileLoc {
|
||||
var file: SpaceSafeURI? = null
|
||||
@SerializedName("hash-format")
|
||||
var hashFormat: String? = null
|
||||
var hash: String? = null
|
||||
data class PackFile(
|
||||
val name: String,
|
||||
val packFormat: PackFormat = PackFormat.DEFAULT,
|
||||
val index: IndexFileLoc,
|
||||
val versions: Map<String, String> = mapOf()
|
||||
) {
|
||||
data class IndexFileLoc(
|
||||
val file: PackwizPath<*>,
|
||||
val hashFormat: HashFormat<*>,
|
||||
val hash: String,
|
||||
) {
|
||||
companion object {
|
||||
fun mapper(base: PackwizPath<*>) = tomlMapper {
|
||||
mapping<IndexFileLoc>("hash-format" to "hashFormat")
|
||||
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
|
||||
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var versions: Map<String, String>? = null
|
||||
var client: Map<String, Any>? = null
|
||||
var server: Map<String, Any>? = null
|
||||
companion object {
|
||||
fun mapper(base: PackwizPath<*>) = tomlMapper {
|
||||
mapping<PackFile>("pack-format" to "packFormat")
|
||||
decoder { it: TomlValue.String -> PackFormat(it.value) }
|
||||
encoder { it: PackFormat -> TomlValue.String(it.format) }
|
||||
delegateTransitive<IndexFileLoc>(IndexFileLoc.mapper(base))
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
@JvmInline
|
||||
value class PackFormat(val format: String) {
|
||||
companion object {
|
||||
val DEFAULT = PackFormat("packwiz:1.0.0")
|
||||
}
|
||||
|
||||
// TODO: implement validation, errors for too new / invalid versions
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter
|
||||
import java.io.Serializable
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
|
||||
// The world's worst URI wrapper
|
||||
@JsonAdapter(SpaceSafeURIParser::class)
|
||||
class SpaceSafeURI : Comparable<SpaceSafeURI>, Serializable {
|
||||
private val u: URI
|
||||
|
||||
@Throws(URISyntaxException::class)
|
||||
constructor(str: String) {
|
||||
u = URI(str.replace(" ", "%20"))
|
||||
}
|
||||
|
||||
constructor(uri: URI) {
|
||||
u = uri
|
||||
}
|
||||
|
||||
@Throws(URISyntaxException::class)
|
||||
constructor(scheme: String?, authority: String?, path: String?, query: String?, fragment: String?) { // TODO: do all components need to be replaced?
|
||||
u = URI(
|
||||
scheme?.replace(" ", "%20"),
|
||||
authority?.replace(" ", "%20"),
|
||||
path?.replace(" ", "%20"),
|
||||
query?.replace(" ", "%20"),
|
||||
fragment?.replace(" ", "%20")
|
||||
)
|
||||
}
|
||||
|
||||
val path: String get() = u.path.replace("%20", " ")
|
||||
|
||||
override fun toString(): String = u.toString().replace("%20", " ")
|
||||
|
||||
fun resolve(path: String): SpaceSafeURI = SpaceSafeURI(u.resolve(path.replace(" ", "%20")))
|
||||
|
||||
fun resolve(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.resolve(loc.u))
|
||||
|
||||
fun relativize(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.relativize(loc.u))
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return if (other is SpaceSafeURI) {
|
||||
u == other.u
|
||||
} else false
|
||||
}
|
||||
|
||||
override fun hashCode() = u.hashCode()
|
||||
|
||||
override fun compareTo(other: SpaceSafeURI): Int = u.compareTo(other.u)
|
||||
|
||||
val scheme: String get() = u.scheme
|
||||
val authority: String get() = u.authority
|
||||
val host: String get() = u.host
|
||||
|
||||
@Throws(MalformedURLException::class)
|
||||
fun toURL(): URL = u.toURL()
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import java.lang.reflect.Type
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* This class encodes spaces before parsing the URI, so the URI can actually be
|
||||
* parsed.
|
||||
*/
|
||||
internal class SpaceSafeURIParser : JsonDeserializer<SpaceSafeURI> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): SpaceSafeURI {
|
||||
return try {
|
||||
SpaceSafeURI(json.asString)
|
||||
} catch (e: URISyntaxException) {
|
||||
throw JsonParseException("Failed to parse URI", e)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: replace this with a better solution?
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
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
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package link.infra.packwiz.installer.metadata.curseforge
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class UpdateDeserializer: JsonDeserializer<Map<String, UpdateData>> {
|
||||
override fun deserialize(
|
||||
json: JsonElement?,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): Map<String, UpdateData> {
|
||||
val out = mutableMapOf<String, UpdateData>()
|
||||
for ((k, v) in json!!.asJsonObject.entrySet()) {
|
||||
if (k == "curseforge") {
|
||||
out[k] = context!!.deserialize(v, CurseForgeUpdateData::class.java)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
|
||||
abstract class GeneralHashingSource(delegate: Source) : ForwardingSource(delegate) {
|
||||
abstract val hash: Hash
|
||||
|
||||
fun hashIsEqual(compareTo: Any) = compareTo == hash
|
||||
}
|
@ -1,20 +1,62 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import com.google.gson.*
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.ForwardingSource
|
||||
import okio.HashingSource
|
||||
import okio.Source
|
||||
import java.lang.reflect.Type
|
||||
|
||||
abstract class Hash {
|
||||
protected abstract val stringValue: String
|
||||
protected abstract val type: String
|
||||
data class Hash<T>(val type: HashFormat<T>, val value: T) {
|
||||
interface Encoding<T> {
|
||||
fun encodeToString(value: T): String
|
||||
fun decodeFromString(str: String): T
|
||||
|
||||
class TypeHandler : JsonDeserializer<Hash>, JsonSerializer<Hash> {
|
||||
override fun serialize(src: Hash, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = JsonObject().apply {
|
||||
add("type", JsonPrimitive(src.type))
|
||||
add("value", JsonPrimitive(src.stringValue))
|
||||
object Hex: Encoding<ByteString> {
|
||||
override fun encodeToString(value: ByteString) = value.hex()
|
||||
override fun decodeFromString(str: String) = str.decodeHex()
|
||||
}
|
||||
|
||||
object UInt: Encoding<kotlin.UInt> {
|
||||
override fun encodeToString(value: kotlin.UInt) = value.toString()
|
||||
override fun decodeFromString(str: String) =
|
||||
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 {
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash<*> {
|
||||
val obj = json.asJsonObject
|
||||
val type: String
|
||||
val value: String
|
||||
@ -25,7 +67,7 @@ abstract class Hash {
|
||||
throw JsonParseException("Invalid hash JSON data")
|
||||
}
|
||||
return try {
|
||||
HashUtils.getHash(type, value)
|
||||
(HashFormat.fromName(type) ?: throw JsonParseException("Unknown hash type $type")).fromString(value)
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Failed to create hash object", e)
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import cc.ekblad.toml.model.TomlValue
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash.Encoding
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider.Companion.fromOkio
|
||||
import okio.ByteString
|
||||
import okio.Source
|
||||
import okio.HashingSource.Companion as OkHashes
|
||||
|
||||
sealed class HashFormat<T>(val formatName: String): Encoding<T>, SourceProvider<T> {
|
||||
object SHA1: HashFormat<ByteString>("sha1"),
|
||||
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha1)
|
||||
object SHA256: HashFormat<ByteString>("sha256"),
|
||||
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha256)
|
||||
object SHA512: HashFormat<ByteString>("sha512"),
|
||||
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha512)
|
||||
object MD5: HashFormat<ByteString>("md5"),
|
||||
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::md5)
|
||||
object MURMUR2: HashFormat<UInt>("murmur2"),
|
||||
Encoding<UInt> by Encoding.UInt, SourceProvider<UInt> by SourceProvider(::Murmur2HasherSource)
|
||||
|
||||
fun source(delegate: Source): HasherSource<T> = source(this, delegate)
|
||||
fun fromString(str: String) = Hash(this, decodeFromString(str))
|
||||
override fun toString() = formatName
|
||||
|
||||
companion object {
|
||||
// lazy used to prevent initialisation issues!
|
||||
private val values by lazy { listOf(SHA1, SHA256, SHA512, MD5, MURMUR2) }
|
||||
fun fromName(formatName: String) = values.find { formatName == it.formatName }
|
||||
|
||||
fun mapper() = tomlMapper {
|
||||
// TODO: better exception?
|
||||
decoder { it: TomlValue.String -> fromName(it.value) ?: throw Exception("Hash format ${it.value} not supported") }
|
||||
encoder { it: HashFormat<*> -> TomlValue.String(it.formatName) }
|
||||
}
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
object HashUtils {
|
||||
private val hashTypeConversion: Map<String, IHasher> = mapOf(
|
||||
"sha256" to HashingSourceHasher("sha256"),
|
||||
"sha512" to HashingSourceHasher("sha512"),
|
||||
"murmur2" to Murmur2Hasher()
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun getHasher(type: String): IHasher {
|
||||
return hashTypeConversion[type] ?: throw Exception("Hash type not supported: $type")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun getHash(type: String, value: String): Hash {
|
||||
return hashTypeConversion[type]?.getHash(value) ?: throw Exception("Hash type not supported: $type")
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.Source
|
||||
|
||||
interface HasherSource<T>: Source {
|
||||
val hash: Hash<T>
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.HashingSource
|
||||
import okio.Source
|
||||
|
||||
class HashingSourceHasher internal constructor(private val type: String) : IHasher {
|
||||
// i love naming things
|
||||
private inner class HashingSourceGeneralHashingSource internal constructor(val delegateHashing: HashingSource) : GeneralHashingSource(delegateHashing) {
|
||||
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
|
||||
HashingSourceHash(delegateHashing.hash.hex())
|
||||
}
|
||||
}
|
||||
|
||||
// this some funky inner class stuff
|
||||
// each of these classes is specific to the instance of the HasherHashingSource
|
||||
// therefore HashingSourceHashes from different parent instances will be not instanceof each other
|
||||
private inner class HashingSourceHash(val value: String) : Hash() {
|
||||
override val stringValue get() = value
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is HashingSourceHash) {
|
||||
return false
|
||||
}
|
||||
return stringValue.equals(other.stringValue, ignoreCase = true)
|
||||
}
|
||||
|
||||
override fun toString(): String = "$type: $stringValue"
|
||||
override fun hashCode(): Int = value.hashCode()
|
||||
|
||||
override val type: String get() = this@HashingSourceHasher.type
|
||||
}
|
||||
|
||||
override fun getHashingSource(delegate: Source): GeneralHashingSource {
|
||||
when (type) {
|
||||
"md5" -> return HashingSourceGeneralHashingSource(HashingSource.md5(delegate))
|
||||
"sha256" -> return HashingSourceGeneralHashingSource(HashingSource.sha256(delegate))
|
||||
"sha512" -> return HashingSourceGeneralHashingSource(HashingSource.sha512(delegate))
|
||||
}
|
||||
throw RuntimeException("Invalid hash type provided")
|
||||
}
|
||||
|
||||
override fun getHash(value: String): Hash {
|
||||
return HashingSourceHash(value)
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.Source
|
||||
|
||||
interface IHasher {
|
||||
fun getHashingSource(delegate: Source): GeneralHashingSource
|
||||
fun getHash(value: String): Hash
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.Buffer
|
||||
import okio.Source
|
||||
import java.io.IOException
|
||||
|
||||
class Murmur2Hasher : IHasher {
|
||||
private inner class Murmur2GeneralHashingSource(delegate: Source) : GeneralHashingSource(delegate) {
|
||||
val internalBuffer = Buffer()
|
||||
val tempBuffer = Buffer()
|
||||
|
||||
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
|
||||
val data = internalBuffer.readByteArray()
|
||||
Murmur2Hash(Murmur2Lib.hash32(data, data.size, 1))
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val out = delegate.read(tempBuffer, byteCount)
|
||||
if (out > -1) {
|
||||
sink.write(tempBuffer.clone(), out)
|
||||
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
|
||||
// private fun computeNormalizedArray(input: ByteArray): ByteArray {
|
||||
// val output = ByteArray(input.size)
|
||||
// var index = 0
|
||||
// for (b in input) {
|
||||
// when (b) {
|
||||
// 9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
|
||||
// else -> {
|
||||
// output[index] = b
|
||||
// index++
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// val outputTrimmed = ByteArray(index)
|
||||
// System.arraycopy(output, 0, outputTrimmed, 0, index)
|
||||
// return outputTrimmed
|
||||
// }
|
||||
|
||||
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
|
||||
var index = 0
|
||||
val arr = input.readByteArray()
|
||||
for (b in arr) {
|
||||
when (b) {
|
||||
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
|
||||
else -> {
|
||||
arr[index] = b
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
output.write(arr, 0, index)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class Murmur2Hash : Hash {
|
||||
val value: Int
|
||||
|
||||
constructor(value: String) {
|
||||
// Parsing as long then casting to int converts values gt int max value but lt uint max value
|
||||
// into negatives. I presume this is how the murmur2 code handles this.
|
||||
this.value = value.toLong().toInt()
|
||||
}
|
||||
|
||||
constructor(value: Int) {
|
||||
this.value = value
|
||||
}
|
||||
|
||||
override val stringValue get() = value.toString()
|
||||
override val type get() = "murmur2"
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is Murmur2Hash) {
|
||||
return false
|
||||
}
|
||||
return value == other.value
|
||||
}
|
||||
|
||||
override fun toString(): String = "murmur2: $value"
|
||||
override fun hashCode(): Int = value
|
||||
}
|
||||
|
||||
override fun getHashingSource(delegate: Source): GeneralHashingSource = Murmur2GeneralHashingSource(delegate)
|
||||
override fun getHash(value: String): Hash = Murmur2Hash(value)
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package link.infra.packwiz.installer.metadata.hash
|
||||
|
||||
import okio.Buffer
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import java.io.IOException
|
||||
|
||||
class Murmur2HasherSource(type: HashFormat<UInt>, delegate: Source) : ForwardingSource(delegate), HasherSource<UInt> {
|
||||
private val internalBuffer = Buffer()
|
||||
private val tempBuffer = Buffer()
|
||||
|
||||
override val hash: Hash<UInt> by lazy(LazyThreadSafetyMode.NONE) {
|
||||
// TODO: remove internal buffering?
|
||||
val data = internalBuffer.readByteArray()
|
||||
Hash(type, Murmur2Lib.hash32(data, data.size, 1).toUInt())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val out = delegate.read(tempBuffer, byteCount)
|
||||
if (out > -1) {
|
||||
sink.write(tempBuffer.clone(), out)
|
||||
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
|
||||
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
|
||||
var index = 0
|
||||
val arr = input.readByteArray()
|
||||
for (b in arr) {
|
||||
when (b) {
|
||||
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
|
||||
else -> {
|
||||
arr[index] = b
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
output.write(arr, 0, index)
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
package link.infra.packwiz.installer.request
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.request.handlers.RequestHandlerFile
|
||||
import link.infra.packwiz.installer.request.handlers.RequestHandlerGithub
|
||||
import link.infra.packwiz.installer.request.handlers.RequestHandlerHTTP
|
||||
import okio.Source
|
||||
|
||||
object HandlerManager {
|
||||
|
||||
private val handlers: List<IRequestHandler> = listOf(
|
||||
RequestHandlerGithub(),
|
||||
RequestHandlerHTTP(),
|
||||
RequestHandlerFile()
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun getNewLoc(base: SpaceSafeURI?, loc: SpaceSafeURI?): SpaceSafeURI? {
|
||||
if (loc == null) {
|
||||
return null
|
||||
}
|
||||
val dest = base?.run { resolve(loc) } ?: loc
|
||||
for (handler in handlers) with (handler) {
|
||||
if (matchesHandler(dest)) {
|
||||
return getNewLoc(dest)
|
||||
}
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// TODO: What if files are read multiple times??
|
||||
// Zip handler discards once read, requesting multiple times on other handlers would cause multiple downloads
|
||||
// Caching system? Copy from already downloaded files?
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun getFileSource(loc: SpaceSafeURI): Source {
|
||||
for (handler in handlers) {
|
||||
if (handler.matchesHandler(loc)) {
|
||||
return handler.getFileSource(loc) ?: throw Exception("Couldn't find URI: $loc")
|
||||
}
|
||||
}
|
||||
throw Exception("No handler available for URI: $loc")
|
||||
}
|
||||
|
||||
// TODO: github toml resolution?
|
||||
// e.g. https://github.com/comp500/Demagnetize -> demagnetize.toml
|
||||
// https://github.com/comp500/Demagnetize/blob/master/demagnetize.toml
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
package link.infra.packwiz.installer.request
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import okio.Source
|
||||
|
||||
/**
|
||||
* IRequestHandler handles requests for locations specified in modpack metadata.
|
||||
*/
|
||||
interface IRequestHandler {
|
||||
fun matchesHandler(loc: SpaceSafeURI): Boolean
|
||||
|
||||
fun getNewLoc(loc: SpaceSafeURI): SpaceSafeURI {
|
||||
return loc
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Source for a location. Must be threadsafe.
|
||||
* It is assumed that each location is read only once for the duration of an IRequestHandler.
|
||||
* @param loc The location to be read
|
||||
* @return The Source containing the data of the file
|
||||
* @throws Exception Exception if it failed to download a file!!!
|
||||
*/
|
||||
fun getFileSource(loc: SpaceSafeURI): Source?
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
package link.infra.packwiz.installer.request.handlers
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.request.IRequestHandler
|
||||
import okio.Source
|
||||
import okio.source
|
||||
import java.nio.file.Paths
|
||||
|
||||
open class RequestHandlerFile : IRequestHandler {
|
||||
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
|
||||
return "file" == loc.scheme
|
||||
}
|
||||
|
||||
override fun getFileSource(loc: SpaceSafeURI): Source? {
|
||||
val path = Paths.get(loc.toURL().toURI())
|
||||
return path.source()
|
||||
}
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
package link.infra.packwiz.installer.request.handlers
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
|
||||
class RequestHandlerGithub : RequestHandlerZip(true) {
|
||||
override fun getNewLoc(loc: SpaceSafeURI): SpaceSafeURI {
|
||||
return loc
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val repoMatcherPattern = Pattern.compile("/([\\w.-]+/[\\w.-]+).*")
|
||||
private val branchMatcherPattern = Pattern.compile("/[\\w.-]+/[\\w.-]+/blob/([\\w.-]+).*")
|
||||
}
|
||||
|
||||
// TODO: is caching really needed, if HTTPURLConnection follows redirects correctly?
|
||||
private val zipUriMap: MutableMap<String, SpaceSafeURI> = HashMap()
|
||||
private val zipUriLock = ReentrantReadWriteLock()
|
||||
private fun getRepoName(loc: SpaceSafeURI): String? {
|
||||
val matcher = repoMatcherPattern.matcher(loc.path)
|
||||
return if (matcher.matches()) {
|
||||
matcher.group(1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getZipUri(loc: SpaceSafeURI): SpaceSafeURI {
|
||||
val repoName = getRepoName(loc)
|
||||
val branchName = getBranch(loc)
|
||||
|
||||
zipUriLock.read {
|
||||
zipUriMap["$repoName/$branchName"]
|
||||
}?.let { return it }
|
||||
|
||||
var zipUri = SpaceSafeURI("https://api.github.com/repos/$repoName/zipball/$branchName")
|
||||
zipUriLock.write {
|
||||
// If another thread sets the value concurrently, use the existing value from the
|
||||
// thread that first acquired the lock.
|
||||
zipUri = zipUriMap.putIfAbsent("$repoName/$branchName", zipUri) ?: zipUri
|
||||
}
|
||||
return zipUri
|
||||
}
|
||||
|
||||
private fun getBranch(loc: SpaceSafeURI): String? {
|
||||
val matcher = branchMatcherPattern.matcher(loc.path)
|
||||
return if (matcher.matches()) {
|
||||
matcher.group(1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLocationInZip(loc: SpaceSafeURI): SpaceSafeURI {
|
||||
val path = "/" + getRepoName(loc) + "/blob/" + getBranch(loc)
|
||||
return SpaceSafeURI(loc.scheme, loc.authority, path, null, null).relativize(loc)
|
||||
}
|
||||
|
||||
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
|
||||
val scheme = loc.scheme
|
||||
if (!("http" == scheme || "https" == scheme)) {
|
||||
return false
|
||||
}
|
||||
// TODO: more match testing?
|
||||
return "github.com" == loc.host && branchMatcherPattern.matcher(loc.path).matches()
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
package link.infra.packwiz.installer.request.handlers
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import link.infra.packwiz.installer.request.IRequestHandler
|
||||
import okio.Source
|
||||
import okio.source
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
open class RequestHandlerHTTP : IRequestHandler {
|
||||
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
|
||||
val scheme = loc.scheme
|
||||
return "http" == scheme || "https" == scheme
|
||||
}
|
||||
|
||||
override fun getFileSource(loc: SpaceSafeURI): Source? {
|
||||
val conn = loc.toURL().openConnection() as HttpURLConnection
|
||||
// TODO: when do we send specific headers??? should there be a way to signal this?
|
||||
conn.addRequestProperty("Accept", "application/octet-stream")
|
||||
// TODO: include version?
|
||||
conn.addRequestProperty("User-Agent", "packwiz-installer")
|
||||
|
||||
conn.apply {
|
||||
// 30 second read timeout
|
||||
readTimeout = 30 * 1000
|
||||
requestMethod = "GET"
|
||||
}
|
||||
return conn.inputStream.source()
|
||||
}
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
package link.infra.packwiz.installer.request.handlers
|
||||
|
||||
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||
import okio.Buffer
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import java.util.function.Predicate
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.concurrent.write
|
||||
|
||||
abstract class RequestHandlerZip(private val modeHasFolder: Boolean) : RequestHandlerHTTP() {
|
||||
private fun removeFolder(name: String): String {
|
||||
return if (modeHasFolder) {
|
||||
// TODO: replace with proper path checks once switched to Path??
|
||||
name.substring(name.indexOf("/") + 1)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ZipReader internal constructor(zip: Source) {
|
||||
private val zis = ZipInputStream(zip.buffer().inputStream())
|
||||
private val readFiles: MutableMap<SpaceSafeURI, Buffer> = HashMap()
|
||||
// Write lock implies access to ZipInputStream - only 1 thread must read at a time!
|
||||
val filesLock = ReentrantLock()
|
||||
private var entry: ZipEntry? = null
|
||||
|
||||
private val zipSource = zis.source().buffer()
|
||||
|
||||
// File lock must be obtained before calling this function
|
||||
private fun readCurrFile(): Buffer {
|
||||
val fileBuffer = Buffer()
|
||||
zipSource.readFully(fileBuffer, entry!!.size)
|
||||
return fileBuffer
|
||||
}
|
||||
|
||||
// File lock must be obtained before calling this function
|
||||
private fun findFile(loc: SpaceSafeURI): Buffer? {
|
||||
while (true) {
|
||||
entry = zis.nextEntry
|
||||
entry?.also {
|
||||
val data = readCurrFile()
|
||||
val fileLoc = SpaceSafeURI(removeFolder(it.name))
|
||||
if (loc == fileLoc) {
|
||||
return data
|
||||
} else {
|
||||
readFiles[fileLoc] = data
|
||||
}
|
||||
} ?: return null
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileSource(loc: SpaceSafeURI): Source? {
|
||||
filesLock.withLock {
|
||||
// Assume files are only read once, allow GC by removing
|
||||
readFiles.remove(loc)?.also { return it }
|
||||
return findFile(loc)
|
||||
}
|
||||
}
|
||||
|
||||
fun findInZip(matches: Predicate<SpaceSafeURI>): SpaceSafeURI? {
|
||||
filesLock.withLock {
|
||||
readFiles.keys.find { matches.test(it) }?.let { return it }
|
||||
|
||||
do {
|
||||
val entry = zis.nextEntry?.also {
|
||||
val data = readCurrFile()
|
||||
val fileLoc = SpaceSafeURI(removeFolder(it.name))
|
||||
readFiles[fileLoc] = data
|
||||
if (matches.test(fileLoc)) {
|
||||
return fileLoc
|
||||
}
|
||||
}
|
||||
} while (entry != null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cache: MutableMap<SpaceSafeURI, ZipReader> = HashMap()
|
||||
private val cacheLock = ReentrantReadWriteLock()
|
||||
|
||||
protected abstract fun getZipUri(loc: SpaceSafeURI): SpaceSafeURI
|
||||
protected abstract fun getLocationInZip(loc: SpaceSafeURI): SpaceSafeURI
|
||||
abstract override fun matchesHandler(loc: SpaceSafeURI): Boolean
|
||||
|
||||
override fun getFileSource(loc: SpaceSafeURI): Source? {
|
||||
val zipUri = getZipUri(loc)
|
||||
var zr = cacheLock.read { cache[zipUri] }
|
||||
if (zr == null) {
|
||||
cacheLock.write {
|
||||
// Recheck, because unlocking read lock allows another thread to modify it
|
||||
zr = cache[zipUri]
|
||||
|
||||
if (zr == null) {
|
||||
val src = super.getFileSource(zipUri) ?: return null
|
||||
zr = ZipReader(src).also { cache[zipUri] = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
return zr?.getFileSource(getLocationInZip(loc))
|
||||
}
|
||||
|
||||
protected fun findInZip(loc: SpaceSafeURI, matches: Predicate<SpaceSafeURI>): SpaceSafeURI? {
|
||||
val zipUri = getZipUri(loc)
|
||||
return (cacheLock.read { cache[zipUri] } ?: cacheLock.write {
|
||||
// Recheck, because unlocking read lock allows another thread to modify it
|
||||
cache[zipUri] ?: run {
|
||||
// Create the ZipReader if it doesn't exist, return null if getFileSource returns null
|
||||
super.getFileSource(zipUri)?.let { ZipReader(it) }
|
||||
?.also { cache[zipUri] = it }
|
||||
}
|
||||
})?.findInZip(matches)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import java.nio.file.Path
|
||||
|
||||
data class CachedTarget(
|
||||
/**
|
||||
* @see Target.name
|
||||
*/
|
||||
val name: String,
|
||||
/**
|
||||
* The location where the target was last downloaded to.
|
||||
* This is used for removing old files when the destination path changes.
|
||||
* This shouldn't be set to the .disabled path (that is manually appended and checked)
|
||||
*/
|
||||
val cachedLocation: Path,
|
||||
val enabled: Boolean,
|
||||
val hash: Hash<*>,
|
||||
/**
|
||||
* For detecting when a target transitions non-optional -> optional and showing the option selection screen
|
||||
*/
|
||||
val isOptional: Boolean
|
||||
)
|
@ -0,0 +1,95 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import java.io.IOException
|
||||
import java.nio.file.*
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import kotlin.io.path.relativeTo
|
||||
|
||||
data class CachedTargetStatus(val target: CachedTarget, var isValid: Boolean, var markDisabled: Boolean)
|
||||
|
||||
fun validate(targets: List<CachedTarget>, baseDir: Path) = runCatching {
|
||||
val results = targets.map {
|
||||
CachedTargetStatus(it, isValid = false, markDisabled = false)
|
||||
}
|
||||
val tree = buildTree(results, baseDir)
|
||||
|
||||
// Efficient file exists checking using directory listing, several orders of magnitude faster than Files.exists calls
|
||||
Files.walkFileTree(baseDir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, object : FileVisitor<Path> {
|
||||
var currentNode: PathNode<CachedTargetStatus> = tree
|
||||
|
||||
override fun preVisitDirectory(dir: Path?, attrs: BasicFileAttributes?): FileVisitResult {
|
||||
if (dir == null) {
|
||||
return FileVisitResult.SKIP_SUBTREE
|
||||
}
|
||||
val subdirNode = currentNode.subdirs[dir.getName(dir.nameCount - 1)]
|
||||
return if (subdirNode != null) {
|
||||
currentNode = subdirNode
|
||||
FileVisitResult.CONTINUE
|
||||
} else {
|
||||
FileVisitResult.SKIP_SUBTREE
|
||||
}
|
||||
}
|
||||
|
||||
override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult {
|
||||
if (file == null) {
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
// TODO: these are relative paths to baseDir
|
||||
// TODO: strip the .disabled for lookup
|
||||
val target = currentNode.files[file.getName(file.nameCount - 1)]
|
||||
if (target != null) {
|
||||
val disabledFile = file.endsWith(".disabled")
|
||||
// If a .disabled file and the actual file both exist, mark as invalid if the target is disabled
|
||||
if ((disabledFile )) {
|
||||
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun visitFileFailed(file: Path?, exc: IOException?): FileVisitResult {
|
||||
if (exc != null) {
|
||||
throw exc
|
||||
}
|
||||
throw IOException("visitFileFailed called with no exception")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun postVisitDirectory(dir: Path?, exc: IOException?): FileVisitResult {
|
||||
if (exc != null) {
|
||||
throw exc
|
||||
} else {
|
||||
val parent = currentNode.parent
|
||||
if (parent != null) {
|
||||
currentNode = parent
|
||||
} else {
|
||||
throw IOException("Invalid visitor tree structure")
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fun buildTree(targets: List<CachedTargetStatus>, baseDir: Path): PathNode<CachedTargetStatus> {
|
||||
val root = PathNode<CachedTargetStatus>()
|
||||
for (target in targets) {
|
||||
val relPath = target.target.cachedLocation.relativeTo(baseDir)
|
||||
var node = root
|
||||
// Traverse all the directory components, except for the last one
|
||||
for (i in 0 until (relPath.nameCount - 1)) {
|
||||
node = node.createSubdir(relPath.getName(i))
|
||||
}
|
||||
node.files[relPath.getName(relPath.nameCount - 1)] = target
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
data class PathNode<T>(val subdirs: MutableMap<Path, PathNode<T>>, val files: MutableMap<Path, T>, val parent: PathNode<T>?) {
|
||||
constructor() : this(mutableMapOf(), mutableMapOf(), null)
|
||||
|
||||
fun createSubdir(nextComponent: Path) = subdirs.getOrPut(nextComponent, { PathNode(mutableMapOf(), mutableMapOf(), this) })
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
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()
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
enum class OverwriteMode {
|
||||
/**
|
||||
* Overwrite the destination with the source file, if the source file has changed.
|
||||
*/
|
||||
IF_SRC_CHANGED,
|
||||
|
||||
/**
|
||||
* Never overwrite the destination; if it exists, it should not be written to.
|
||||
*/
|
||||
NEVER
|
||||
}
|
48
src/main/kotlin/link/infra/packwiz/installer/target/Side.kt
Normal file
48
src/main/kotlin/link/infra/packwiz/installer/target/Side.kt
Normal file
@ -0,0 +1,48 @@
|
||||
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}") }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
|
||||
// TODO: rename to avoid conflicting with @Target
|
||||
interface Target {
|
||||
val src: PackwizPath<*>
|
||||
val dest: PackwizPath<*>
|
||||
val validityToken: ValidityToken
|
||||
|
||||
/**
|
||||
* Token interface for types used to compare Target identity. Implementations should use equals to indicate that
|
||||
* these tokens represent the same file; used to preserve optional target choices between file renames.
|
||||
*/
|
||||
interface IdentityToken
|
||||
|
||||
/**
|
||||
* Default implementation of IdentityToken that assumes files are not renamed; so optional choices do not need to
|
||||
* be preserved across renames.
|
||||
*/
|
||||
@JvmInline
|
||||
value class PathIdentityToken(val path: 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
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
|
||||
/**
|
||||
* A token used to determine if the source or destination file is changed, and ensure that the destination file is valid.
|
||||
*/
|
||||
interface ValidityToken {
|
||||
// TODO: functions to allow validating this from file metadata, from file, or during the download process
|
||||
|
||||
/**
|
||||
* Default implementation of ValidityToken based on a single hash.
|
||||
*/
|
||||
@JvmInline
|
||||
value class HashValidityToken(val hash: Hash<*>): ValidityToken
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package link.infra.packwiz.installer.target.path
|
||||
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
|
||||
class HttpUrlPath(private val url: HttpUrl, path: String? = null): PackwizPath<HttpUrlPath>(path) {
|
||||
private fun build() = if (path == null) { url } else { url.newBuilder().addPathSegments(path).build() }
|
||||
|
||||
@Throws(RequestException::class)
|
||||
override fun source(clientHolder: ClientHolder): BufferedSource {
|
||||
val req = Request.Builder()
|
||||
.url(build())
|
||||
.header("Accept", "application/octet-stream")
|
||||
.header("User-Agent", "packwiz-installer")
|
||||
.get()
|
||||
.build()
|
||||
try {
|
||||
val res = clientHolder.okHttpClient.newCall(req).execute()
|
||||
// Can't use .use since it would close the response body before returning it to the caller
|
||||
try {
|
||||
if (!res.isSuccessful) {
|
||||
throw RequestException.Response.HTTP.ErrorCode(req, res)
|
||||
}
|
||||
|
||||
val body = res.body ?: throw RequestException.Internal.HTTP.NoResponseBody()
|
||||
return body.source()
|
||||
} catch (e: Exception) {
|
||||
// If an exception is thrown, close the response and rethrow
|
||||
res.close()
|
||||
throw e
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw RequestException.Internal.HTTP.RequestFailed(e)
|
||||
} catch (e: IllegalStateException) {
|
||||
throw RequestException.Internal.HTTP.IllegalState(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun construct(path: String): HttpUrlPath = HttpUrlPath(url, path)
|
||||
|
||||
override val folder: Boolean
|
||||
get() = pathFolder ?: (url.pathSegments.last() == "")
|
||||
override val filename: String
|
||||
get() = pathFilename ?: url.pathSegments.last()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as HttpUrlPath
|
||||
|
||||
if (url != other.url) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + url.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString() = build().toString()
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package link.infra.packwiz.installer.target.path
|
||||
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import okio.*
|
||||
|
||||
class PackwizFilePath(private val base: Path, path: String? = null): PackwizPath<PackwizFilePath>(path) {
|
||||
@Throws(RequestException::class)
|
||||
override fun source(clientHolder: ClientHolder): BufferedSource {
|
||||
val resolved = if (path == null) { base } else { this.base.resolve(path, true) }
|
||||
try {
|
||||
return clientHolder.fileSystem.source(resolved).buffer()
|
||||
} catch (e: FileNotFoundException) {
|
||||
throw RequestException.Response.File.FileNotFound(resolved.toString())
|
||||
} catch (e: IOException) {
|
||||
throw RequestException.Response.File.Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
val nioPath: java.nio.file.Path get() {
|
||||
val resolved = if (path == null) { base } else { this.base.resolve(path, true) }
|
||||
return resolved.toNioPath()
|
||||
}
|
||||
|
||||
override fun construct(path: String): PackwizFilePath = PackwizFilePath(base, path)
|
||||
|
||||
override val folder: Boolean
|
||||
get() = pathFolder ?: (base.segments.last() == "")
|
||||
override val filename: String
|
||||
get() = pathFilename ?: base.segments.last()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as PackwizFilePath
|
||||
|
||||
if (base != other.base) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + base.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString() = nioPath.toString()
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
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"
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package link.infra.packwiz.installer.task
|
||||
|
||||
data class CacheKey<T>(val key: String, val version: Int)
|
@ -0,0 +1,22 @@
|
||||
package link.infra.packwiz.installer.task
|
||||
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class CacheManager {
|
||||
class CacheValue<T> {
|
||||
operator fun getValue(thisVal: Any?, property: KProperty<*>): T {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
operator fun setValue(thisVal: Any?, property: KProperty<*>, value: T) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
operator fun <T> get(cacheKey: CacheKey<T>): CacheValue<T> {
|
||||
return CacheValue()
|
||||
}
|
||||
|
||||
|
||||
}
|
20
src/main/kotlin/link/infra/packwiz/installer/task/Task.kt
Normal file
20
src/main/kotlin/link/infra/packwiz/installer/task/Task.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package link.infra.packwiz.installer.task
|
||||
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
// TODO: task processing on 1 background thread; actual resolving of values calls out to a thread group
|
||||
// TODO: progress bar is updated from each of these tasks
|
||||
// TODO: have everything be lazy so there's no need to determine task ordering upfront? a bit like rust async - task results must be queried to occur
|
||||
|
||||
abstract class Task<T>(protected val ctx: TaskContext): TaskInput<T> {
|
||||
// TODO: lazy wrapper for fallible results
|
||||
// TODO: multithreaded fanout subclass/helper
|
||||
|
||||
protected fun <T> wasUpdated(value: KMutableProperty0<T>, newValue: T): Boolean {
|
||||
if (value.get() == newValue) {
|
||||
return false
|
||||
}
|
||||
value.set(newValue)
|
||||
return true
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package link.infra.packwiz.installer.task
|
||||
|
||||
/**
|
||||
* An object for storing results where result and upToDate are calculated simultaneously
|
||||
*/
|
||||
data class TaskCombinedResult<T>(val result: T, val upToDate: Boolean)
|
@ -0,0 +1,12 @@
|
||||
package link.infra.packwiz.installer.task
|
||||
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
|
||||
class TaskContext {
|
||||
// TODO: thread pools, protocol roots
|
||||
// TODO: cache management
|
||||
|
||||
val cache = CacheManager()
|
||||
|
||||
val clients = ClientHolder()
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package link.infra.packwiz.installer.task
|
||||
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
interface TaskInput<T> {
|
||||
/**
|
||||
* The value of this task input. May be lazily evaluated; must be threadsafe.
|
||||
*/
|
||||
val value: T
|
||||
|
||||
/**
|
||||
* True if the effective value of this input has changed since the task was last run.
|
||||
* Doesn't require evaluation of the input value; should use cached data if possible.
|
||||
* May be lazily evaluated; must be threadsafe.
|
||||
*/
|
||||
val upToDate: Boolean
|
||||
|
||||
operator fun getValue(thisVal: Any?, property: KProperty<*>): T = value
|
||||
|
||||
companion object {
|
||||
fun <T> raw(value: T): TaskInput<T> {
|
||||
return object: TaskInput<T> {
|
||||
override val value = value
|
||||
override val upToDate: Boolean
|
||||
get() = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package link.infra.packwiz.installer.task.formats.packwizv1
|
||||
|
||||
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||
import link.infra.packwiz.installer.target.path.PackwizPath
|
||||
|
||||
data class PackwizV1PackFile(val name: String, val indexPath: PackwizPath<*>, val indexHash: Hash<*>)
|
@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
|
||||
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.Future
|
||||
|
||||
class CLIHandler : IUserInterface {
|
||||
override fun handleException(e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
override fun show(handler: InputStateHandler) {}
|
||||
override fun submitProgress(progress: InstallProgress) {
|
||||
val sb = StringBuilder()
|
||||
if (progress.hasProgress) {
|
||||
sb.append('(')
|
||||
sb.append(progress.progress)
|
||||
sb.append('/')
|
||||
sb.append(progress.progressTotal)
|
||||
sb.append(") ")
|
||||
}
|
||||
sb.append(progress.message)
|
||||
println(sb.toString())
|
||||
}
|
||||
|
||||
override fun executeManager(task: () -> Unit) {
|
||||
task()
|
||||
println("Finished successfully!")
|
||||
}
|
||||
|
||||
override fun showOptions(options: List<IOptionDetails>): Future<Boolean> {
|
||||
for (opt in options) {
|
||||
opt.optionValue = true
|
||||
// TODO: implement option choice in the CLI?
|
||||
println("Warning: accepting option " + opt.name + " as option choosing is not implemented in the CLI")
|
||||
}
|
||||
return CompletableFuture<Boolean>().apply {
|
||||
complete(false) // Can't be cancelled!
|
||||
}
|
||||
}
|
||||
|
||||
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult> {
|
||||
val future = CompletableFuture<ExceptionListResult>()
|
||||
future.complete(ExceptionListResult.CANCEL)
|
||||
return future
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
|
||||
data class ExceptionDetails(
|
||||
val name: String,
|
||||
val exception: Exception
|
||||
)
|
@ -1,36 +1,35 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.Future
|
||||
import kotlin.system.exitProcess
|
||||
import link.infra.packwiz.installer.ui.data.ExceptionDetails
|
||||
import link.infra.packwiz.installer.ui.data.IOptionDetails
|
||||
import link.infra.packwiz.installer.ui.data.InstallProgress
|
||||
|
||||
interface IUserInterface {
|
||||
fun show(handler: InputStateHandler)
|
||||
fun handleException(e: Exception)
|
||||
@JvmDefault
|
||||
fun handleExceptionAndExit(e: Exception) {
|
||||
handleException(e)
|
||||
exitProcess(1)
|
||||
}
|
||||
fun show()
|
||||
fun dispose()
|
||||
|
||||
@JvmDefault
|
||||
fun setTitle(title: String) {}
|
||||
fun showErrorAndExit(message: String): Nothing {
|
||||
showErrorAndExit(message, null)
|
||||
}
|
||||
fun showErrorAndExit(message: String, e: Exception?): Nothing
|
||||
|
||||
var title: String
|
||||
fun submitProgress(progress: InstallProgress)
|
||||
fun executeManager(task: () -> Unit)
|
||||
// Return true if the installation was cancelled!
|
||||
fun showOptions(options: List<IOptionDetails>): Future<Boolean>
|
||||
fun showOptions(options: List<IOptionDetails>): Boolean
|
||||
|
||||
fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult>
|
||||
@JvmDefault
|
||||
fun disableOptionsButton() {}
|
||||
fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): ExceptionListResult
|
||||
fun disableOptionsButton(hasOptions: Boolean) {}
|
||||
|
||||
@JvmDefault
|
||||
fun showCancellationDialog(): Future<CancellationResult> {
|
||||
return CompletableFuture<CancellationResult>().apply {
|
||||
complete(CancellationResult.QUIT)
|
||||
}
|
||||
fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT
|
||||
|
||||
fun 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
|
||||
}
|
||||
@ -38,4 +37,23 @@ interface IUserInterface {
|
||||
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)
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
|
||||
class InputStateHandler {
|
||||
// TODO: convert to coroutines/locks?
|
||||
@get:Synchronized
|
||||
var optionsButton = false
|
||||
private set
|
||||
@get:Synchronized
|
||||
var cancelButton = false
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
fun pressCancelButton() {
|
||||
cancelButton = true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pressOptionsButton() {
|
||||
optionsButton = true
|
||||
}
|
||||
}
|
@ -1,228 +0,0 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
|
||||
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
|
||||
import java.awt.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
import javax.swing.border.EmptyBorder
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class InstallWindow : IUserInterface {
|
||||
private lateinit var frmPackwizlauncher: JFrame
|
||||
private lateinit var lblProgresslabel: JLabel
|
||||
private lateinit var progressBar: JProgressBar
|
||||
private lateinit var btnOptions: JButton
|
||||
|
||||
private var inputStateHandler: InputStateHandler? = null
|
||||
private var title = "Updating modpack..."
|
||||
private var worker: SwingWorkerButWithPublicPublish<Unit, InstallProgress>? = null
|
||||
private val aboutToCrash = AtomicBoolean()
|
||||
|
||||
// TODO: separate JFrame junk from IUserInterface junk?
|
||||
|
||||
init {
|
||||
EventQueue.invokeAndWait {
|
||||
frmPackwizlauncher = JFrame().apply {
|
||||
title = this@InstallWindow.title
|
||||
setBounds(100, 100, 493, 95)
|
||||
defaultCloseOperation = JFrame.EXIT_ON_CLOSE
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
// Progress bar and loading text
|
||||
add(JPanel().apply {
|
||||
border = EmptyBorder(10, 10, 10, 10)
|
||||
layout = BorderLayout(0, 0)
|
||||
|
||||
progressBar = JProgressBar().apply {
|
||||
isIndeterminate = true
|
||||
}
|
||||
add(progressBar, BorderLayout.CENTER)
|
||||
|
||||
lblProgresslabel = JLabel("Loading...")
|
||||
add(lblProgresslabel, BorderLayout.SOUTH)
|
||||
}, BorderLayout.CENTER)
|
||||
|
||||
// Buttons
|
||||
add(JPanel().apply {
|
||||
border = EmptyBorder(0, 5, 0, 5)
|
||||
layout = GridBagLayout()
|
||||
|
||||
btnOptions = JButton("Optional mods...").apply {
|
||||
alignmentX = Component.CENTER_ALIGNMENT
|
||||
|
||||
addActionListener {
|
||||
text = "Loading..."
|
||||
isEnabled = false
|
||||
inputStateHandler?.pressOptionsButton()
|
||||
}
|
||||
}
|
||||
add(btnOptions, GridBagConstraints().apply {
|
||||
gridx = 0
|
||||
gridy = 0
|
||||
})
|
||||
|
||||
add(JButton("Cancel").apply {
|
||||
addActionListener {
|
||||
isEnabled = false
|
||||
inputStateHandler?.pressCancelButton()
|
||||
}
|
||||
}, GridBagConstraints().apply {
|
||||
gridx = 0
|
||||
gridy = 1
|
||||
})
|
||||
}, BorderLayout.EAST)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun show(handler: InputStateHandler) {
|
||||
inputStateHandler = handler
|
||||
EventQueue.invokeLater {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
||||
frmPackwizlauncher.isVisible = true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleException(e: Exception) {
|
||||
e.printStackTrace()
|
||||
EventQueue.invokeLater {
|
||||
JOptionPane.showMessageDialog(null,
|
||||
"An error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
|
||||
title, JOptionPane.ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleExceptionAndExit(e: Exception) {
|
||||
e.printStackTrace()
|
||||
// TODO: Fix this mess
|
||||
// Used to prevent the done() handler of SwingWorker executing if the invokeLater hasn't happened yet
|
||||
aboutToCrash.set(true)
|
||||
EventQueue.invokeLater {
|
||||
JOptionPane.showMessageDialog(null,
|
||||
"A fatal error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
|
||||
title, JOptionPane.ERROR_MESSAGE)
|
||||
exitProcess(1)
|
||||
}
|
||||
// Pause forever, so it blocks while we wait for System.exit to take effect
|
||||
try {
|
||||
Thread.currentThread().join()
|
||||
} catch (ex: InterruptedException) { // no u
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTitle(title: String) {
|
||||
this.title = title
|
||||
frmPackwizlauncher.let { frame ->
|
||||
EventQueue.invokeLater { frame.title = title }
|
||||
}
|
||||
}
|
||||
|
||||
override fun submitProgress(progress: InstallProgress) {
|
||||
val sb = StringBuilder()
|
||||
if (progress.hasProgress) {
|
||||
sb.append('(')
|
||||
sb.append(progress.progress)
|
||||
sb.append('/')
|
||||
sb.append(progress.progressTotal)
|
||||
sb.append(") ")
|
||||
}
|
||||
sb.append(progress.message)
|
||||
// TODO: better logging library?
|
||||
println(sb.toString())
|
||||
worker?.publishPublic(progress)
|
||||
}
|
||||
|
||||
override fun executeManager(task: Function0<Unit>) {
|
||||
EventQueue.invokeLater {
|
||||
// TODO: rewrite this stupidity to use channels??!!!
|
||||
worker = object : SwingWorkerButWithPublicPublish<Unit, InstallProgress>() {
|
||||
override fun doInBackground() {
|
||||
task.invoke()
|
||||
}
|
||||
|
||||
override fun process(chunks: List<InstallProgress>) {
|
||||
// Only process last chunk
|
||||
if (chunks.isNotEmpty()) {
|
||||
val (message, hasProgress, progress, progressTotal) = chunks[chunks.size - 1]
|
||||
if (hasProgress) {
|
||||
progressBar.isIndeterminate = false
|
||||
progressBar.value = progress
|
||||
progressBar.maximum = progressTotal
|
||||
} else {
|
||||
progressBar.isIndeterminate = true
|
||||
progressBar.value = 0
|
||||
}
|
||||
lblProgresslabel.text = message
|
||||
}
|
||||
}
|
||||
|
||||
override fun done() {
|
||||
if (aboutToCrash.get()) {
|
||||
return
|
||||
}
|
||||
// TODO: a better way to do this?
|
||||
frmPackwizlauncher.dispose()
|
||||
println("Finished successfully!")
|
||||
exitProcess(0)
|
||||
}
|
||||
}.also {
|
||||
it.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun showOptions(options: List<IOptionDetails>): Future<Boolean> {
|
||||
val future = CompletableFuture<Boolean>()
|
||||
EventQueue.invokeLater {
|
||||
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
|
||||
}
|
||||
|
||||
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult> {
|
||||
val future = CompletableFuture<ExceptionListResult>()
|
||||
EventQueue.invokeLater {
|
||||
ExceptionListWindow(exceptions, future, numTotal, allowsIgnore, frmPackwizlauncher).apply {
|
||||
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
|
||||
isVisible = true
|
||||
}
|
||||
}
|
||||
return future
|
||||
}
|
||||
|
||||
override fun disableOptionsButton() {
|
||||
btnOptions.apply {
|
||||
text = "No optional mods"
|
||||
isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun showCancellationDialog(): Future<IUserInterface.CancellationResult> {
|
||||
val future = CompletableFuture<IUserInterface.CancellationResult>()
|
||||
EventQueue.invokeLater {
|
||||
val buttons = arrayOf("Quit", "Ignore")
|
||||
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
|
||||
"The installation was cancelled. Would you like to quit the game, or ignore the update and start the game?",
|
||||
"Cancelled installation",
|
||||
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, buttons, buttons[0])
|
||||
future.complete(if (result == 0) IUserInterface.CancellationResult.QUIT else IUserInterface.CancellationResult.CONTINUE)
|
||||
}
|
||||
return future
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
|
||||
import javax.swing.SwingWorker
|
||||
|
||||
// Q: AAA WHAT HAVE YOU DONE THIS IS DISGUSTING
|
||||
// A: it just makes things easier, so i can easily have one interface for CLI/GUI
|
||||
// if someone has a better way to do this please PR it
|
||||
abstract class SwingWorkerButWithPublicPublish<T, V> : SwingWorker<T, V>() {
|
||||
@SafeVarargs
|
||||
fun publishPublic(vararg chunks: V) {
|
||||
publish(*chunks)
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package link.infra.packwiz.installer.ui.data
|
||||
|
||||
data class ExceptionDetails(
|
||||
val name: String,
|
||||
val exception: Exception,
|
||||
val modUrl: String? = null
|
||||
)
|
@ -1,4 +1,4 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
package link.infra.packwiz.installer.ui.data
|
||||
|
||||
interface IOptionDetails {
|
||||
val name: String
|
@ -1,4 +1,4 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
package link.infra.packwiz.installer.ui.data
|
||||
|
||||
data class InstallProgress(
|
||||
val message: String,
|
@ -1,5 +1,8 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
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
|
||||
@ -16,12 +19,30 @@ import javax.swing.border.EmptyBorder
|
||||
class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFuture<IUserInterface.ExceptionListResult>, numTotal: Int, allowsIgnore: Boolean, parentWindow: JFrame?) : JDialog(parentWindow, "Failed file downloads", true) {
|
||||
private val lblExceptionStacktrace: JTextArea
|
||||
|
||||
private class ExceptionListModel internal constructor(private val details: List<ExceptionDetails>) : AbstractListModel<String>() {
|
||||
private class ExceptionListModel(private val details: List<ExceptionDetails>) : AbstractListModel<String>() {
|
||||
override fun getSize() = details.size
|
||||
override fun getElementAt(index: Int) = details[index].name
|
||||
fun getExceptionAt(index: Int) = details[index].exception
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@ -110,6 +131,19 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
|
||||
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
|
||||
@ -120,16 +154,8 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
|
||||
// Left buttons
|
||||
add(JPanel().apply {
|
||||
add(JButton("Report issue").apply {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
addActionListener {
|
||||
try {
|
||||
Desktop.getDesktop().browse(URI("https://github.com/comp500/packwiz-installer/issues/new"))
|
||||
} catch (e: IOException) {
|
||||
// lol the button just won't work i guess
|
||||
} catch (e: URISyntaxException) {}
|
||||
}
|
||||
} else {
|
||||
isEnabled = false
|
||||
addActionListener {
|
||||
openUrl("https://github.com/packwiz/packwiz-installer/issues/new")
|
||||
}
|
||||
})
|
||||
}, BorderLayout.WEST)
|
@ -0,0 +1,251 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
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)"
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
package link.infra.packwiz.installer.ui.gui
|
||||
|
||||
import link.infra.packwiz.installer.ui.data.IOptionDetails
|
||||
|
||||
// Serves as a proxy for IOptionDetails, so that setOptionValue isn't called until OK is clicked
|
||||
internal class OptionTempHandler(private val opt: IOptionDetails) : IOptionDetails {
|
@ -1,5 +1,6 @@
|
||||
package link.infra.packwiz.installer.ui
|
||||
package link.infra.packwiz.installer.ui.gui
|
||||
|
||||
import link.infra.packwiz.installer.ui.data.IOptionDetails
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.event.ActionEvent
|
||||
@ -18,7 +19,7 @@ class OptionsSelectWindow internal constructor(optList: List<IOptionDetails>, fu
|
||||
private val tableModel: OptionTableModel
|
||||
private val future: CompletableFuture<Boolean>
|
||||
|
||||
private class OptionTableModel internal constructor(givenOpts: List<IOptionDetails>) : TableModel {
|
||||
private class OptionTableModel(givenOpts: List<IOptionDetails>) : TableModel {
|
||||
private val opts: List<OptionTempHandler>
|
||||
|
||||
init {
|
@ -0,0 +1,10 @@
|
||||
package link.infra.packwiz.installer.util
|
||||
|
||||
import cc.ekblad.toml.TomlMapper
|
||||
import cc.ekblad.toml.configuration.TomlMapperConfigurator
|
||||
import cc.ekblad.toml.model.TomlValue
|
||||
|
||||
inline fun <reified T: Any> TomlMapperConfigurator.delegateTransitive(mapper: TomlMapper) {
|
||||
decoder { it: TomlValue -> mapper.decode<T>(it) }
|
||||
encoder { it: T -> mapper.encode(it) }
|
||||
}
|
16
src/main/kotlin/link/infra/packwiz/installer/util/Log.kt
Normal file
16
src/main/kotlin/link/infra/packwiz/installer/util/Log.kt
Normal file
@ -0,0 +1,16 @@
|
||||
package link.infra.packwiz.installer.util
|
||||
|
||||
object Log {
|
||||
fun info(message: String) = println(message)
|
||||
|
||||
fun warn(message: String) = println("[Warning] $message")
|
||||
fun warn(message: String, exception: Exception) = println("[Warning] $message: $exception")
|
||||
|
||||
fun fatal(message: String) {
|
||||
println("[FATAL] $message")
|
||||
}
|
||||
fun fatal(message: String, exception: Exception) {
|
||||
println("[FATAL] $message: ")
|
||||
exception.printStackTrace()
|
||||
}
|
||||
}
|
19
src/main/proguard.txt
Normal file
19
src/main/proguard.txt
Normal file
@ -0,0 +1,19 @@
|
||||
-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
|
264
src/main/resources/META-INF/LICENSES.md
Normal file
264
src/main/resources/META-INF/LICENSES.md
Normal file
@ -0,0 +1,264 @@
|
||||
# Licenses
|
||||
|
||||
packwiz-installer itself is under the MIT license ([Source](https://github.com/packwiz/packwiz-installer)), except for Murmur2Lib and bundled dependencies as follows:
|
||||
|
||||
- Murmur2Lib: Apache 2.0 ([Source](https://github.com/prasanthj/hasher/blob/master/src/main/java/hasher/Murmur2.java))
|
||||
- Copyright 2014 Prasanth Jayachandran
|
||||
- Google Gson 2.9.0: Apache 2.0 ([Source](https://github.com/google/gson))
|
||||
- Copyright 2008 Google Inc.
|
||||
- Okio 3.1.0: Apache 2.0 ([Source](https://github.com/square/okio/))
|
||||
- Copyright 2013 Square, Inc.
|
||||
- Commons CLI 1.5: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/))
|
||||
- Jetbrains Annotations 13.0: Apache 2.0 ([Source](https://github.com/JetBrains/java-annotations))
|
||||
- Copyright 2000-2016 JetBrains s.r.o.
|
||||
- Kotlin Standard Library 1.7.10: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin))
|
||||
- Copyright 2010-2020 JetBrains s.r.o and respective authors and developers
|
||||
- 4koma 1.1.0: MIT ([Source](https://github.com/valderman/4koma))
|
||||
- Copyright (c) 2021 Anton Ekblad
|
||||
|
||||
## Associated notices
|
||||
|
||||
### Commons CLI
|
||||
Apache Commons CLI
|
||||
Copyright 2001-2017 The Apache Software Foundation
|
||||
|
||||
This product includes software developed at
|
||||
The Apache Software Foundation (http://www.apache.org/).
|
||||
|
||||
## Full license texts
|
||||
|
||||
### MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### Apache 2.0
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
### ISC
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
Loading…
x
Reference in New Issue
Block a user