mirror of
https://github.com/packwiz/packwiz-installer.git
synced 2025-10-16 16:04:32 +02:00
Compare commits
62 Commits
v0.0.4-pre
...
v0.2.9
Author | SHA1 | Date | |
---|---|---|---|
|
a368268038 | ||
|
8beded7b41 | ||
|
91060dcd54 | ||
|
e06ee21f3b | ||
|
b3370739a5 | ||
|
ecc6f0440a | ||
|
92b44352b3 | ||
|
1d5a787b02 | ||
|
b5983800e8 | ||
|
4b3c279e71 | ||
|
b413371306 | ||
|
1d2ec61232 | ||
|
a0da889a02 | ||
|
432bb4e25f | ||
|
c89d3b1e47 | ||
|
e8538c22bc | ||
|
a15489f5e4 | ||
|
9d3587c72e | ||
|
bead683b7c | ||
|
0770029dc6 | ||
|
ecaab219c2 | ||
|
b45a2983e7 | ||
|
c0c318772b | ||
|
580408b92a | ||
|
dbdd1fb9f3 | ||
|
79a983bc2f | ||
|
0cba5ba17b | ||
|
ce60cdc385 | ||
|
b314fc8e0b | ||
|
ca4a13589d | ||
|
d21668afa6 | ||
|
7946377159 | ||
|
5a54a90f59 | ||
|
465e4973ba | ||
|
ea60175514 | ||
|
78f5d76fe9 | ||
|
452ab15cc7 | ||
|
87b00f316a | ||
|
1623c0f880 | ||
|
46bbc9b82e | ||
|
02b50be782 | ||
|
e6637b9af8 | ||
|
5fc7d6382d | ||
|
afd8e85754 | ||
|
ecbf0b9eba | ||
|
37a1464e11 | ||
|
54fd84a6d8 | ||
|
dcf8d21aad | ||
|
eaed3b2187 | ||
|
b22edf920e | ||
|
4d8e695fc4 | ||
|
a9bd83e96b | ||
|
ae085743be | ||
|
ad79cb3b21 | ||
|
320e56e74e | ||
|
bd95bc15ad | ||
|
794b817eff | ||
|
a5ff63c587 | ||
|
34a86ffb7d | ||
|
780efe2c9f | ||
|
d1647764c4 | ||
|
12bf090895 |
127
.gitignore
vendored
127
.gitignore
vendored
@@ -1,74 +1,95 @@
|
|||||||
# Created by https://www.gitignore.io/api/java,gradle,eclipse
|
|
||||||
# Edit at https://www.gitignore.io/?templates=java,gradle,eclipse
|
|
||||||
|
|
||||||
### Eclipse ###
|
# Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij+all
|
||||||
.metadata
|
# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij+all
|
||||||
bin/
|
|
||||||
tmp/
|
|
||||||
*.tmp
|
|
||||||
*.bak
|
|
||||||
*.swp
|
|
||||||
*~.nib
|
|
||||||
local.properties
|
|
||||||
.settings/
|
|
||||||
.loadpath
|
|
||||||
.recommenders
|
|
||||||
|
|
||||||
# External tool builders
|
### Intellij+all ###
|
||||||
.externalToolBuilders/
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
# Locally stored "Eclipse launch configurations"
|
# User-specific stuff
|
||||||
*.launch
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
# PyDev specific (Python IDE for Eclipse)
|
# Generated files
|
||||||
*.pydevproject
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
# CDT-specific (C/C++ Development Tooling)
|
# Sensitive or high-churn files
|
||||||
.cproject
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
# CDT- autotools
|
# Gradle
|
||||||
.autotools
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
# Java annotation processor (APT)
|
# Gradle and Maven with auto-import
|
||||||
.factorypath
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
# PDT-specific (PHP Development Tools)
|
# CMake
|
||||||
.buildpath
|
cmake-build-*/
|
||||||
|
|
||||||
# sbteclipse plugin
|
# Mongo Explorer plugin
|
||||||
.target
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
# Tern plugin
|
# File-based project format
|
||||||
.tern-project
|
*.iws
|
||||||
|
|
||||||
# TeXlipse plugin
|
# IntelliJ
|
||||||
.texlipse
|
out/
|
||||||
|
|
||||||
# STS (Spring Tool Suite)
|
# mpeltonen/sbt-idea plugin
|
||||||
.springBeans
|
.idea_modules/
|
||||||
|
|
||||||
# Code Recommenders
|
# JIRA plugin
|
||||||
.recommenders/
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
# Annotation Processing
|
# Cursive Clojure plugin
|
||||||
.apt_generated/
|
.idea/replstate.xml
|
||||||
|
|
||||||
# Scala IDE specific (Scala & Java development for Eclipse)
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
.cache-main
|
com_crashlytics_export_strings.xml
|
||||||
.scala_dependencies
|
crashlytics.properties
|
||||||
.worksheet
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
### Eclipse Patch ###
|
# Editor-based Rest Client
|
||||||
# Eclipse Core
|
.idea/httpRequests
|
||||||
.project
|
|
||||||
|
|
||||||
# JDT-specific (Eclipse Java Development Tools)
|
# Android studio 3.1+ serialized cache file
|
||||||
.classpath
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
# Annotation Processing
|
### Intellij+all Patch ###
|
||||||
.apt_generated
|
# Ignores the whole .idea folder and all .iml files
|
||||||
|
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
||||||
|
|
||||||
.sts4-cache/
|
.idea/
|
||||||
|
|
||||||
|
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
||||||
|
|
||||||
|
*.iml
|
||||||
|
modules.xml
|
||||||
|
.idea/misc.xml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
# Sonarlint plugin
|
||||||
|
.idea/sonarlint
|
||||||
|
|
||||||
### Java ###
|
### Java ###
|
||||||
# Compiled class file
|
# Compiled class file
|
||||||
@@ -114,4 +135,4 @@ gradle-app.setting
|
|||||||
### Gradle Patch ###
|
### Gradle Patch ###
|
||||||
**/build/
|
**/build/
|
||||||
|
|
||||||
# End of https://www.gitignore.io/api/java,gradle,eclipse
|
# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij+all
|
||||||
|
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"files.exclude": {
|
|
||||||
"**/.classpath": true,
|
|
||||||
"**/.project": true,
|
|
||||||
"**/.settings": true,
|
|
||||||
"**/.factorypath": true
|
|
||||||
},
|
|
||||||
"java.configuration.updateBuildConfiguration": "interactive"
|
|
||||||
}
|
|
@@ -1,2 +1,2 @@
|
|||||||
# packwiz-installer
|
# packwiz-installer
|
||||||
An installer for launching packwiz modpacks with MultiMC.
|
An installer for launching packwiz modpacks with MultiMC. You'll need [the bootstrapper](https://github.com/comp500/packwiz-installer-bootstrap/releases) to actually use this.
|
||||||
|
48
build.gradle
48
build.gradle
@@ -1,48 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'java'
|
|
||||||
id 'application'
|
|
||||||
id 'com.github.johnrengelman.shadow' version '5.0.0'
|
|
||||||
id 'com.palantir.git-version' version '0.11.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'commons-cli:commons-cli:1.4'
|
|
||||||
implementation 'com.moandjiezana.toml:toml4j:0.7.2'
|
|
||||||
// TODO: Implement tests
|
|
||||||
//testImplementation 'junit:junit:4.12'
|
|
||||||
implementation 'com.google.code.gson:gson:2.8.1'
|
|
||||||
implementation 'com.squareup.okio:okio:2.2.2'
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
jcenter()
|
|
||||||
}
|
|
||||||
|
|
||||||
mainClassName = 'link.infra.packwiz.installer.RequiresBootstrap'
|
|
||||||
version gitVersion()
|
|
||||||
|
|
||||||
jar {
|
|
||||||
manifest {
|
|
||||||
attributes(
|
|
||||||
'Main-Class': 'link.infra.packwiz.installer.RequiresBootstrap',
|
|
||||||
'Implementation-Version': project.version
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
|
|
||||||
shadowJar {
|
|
||||||
dependencies {
|
|
||||||
exclude(dependency('commons-cli:commons-cli:1.4'))
|
|
||||||
exclude(dependency('com.eclipsesource.minimal-json:minimal-json:0.9.5'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used for vscode launch.json
|
|
||||||
task copyJar(type: Copy) {
|
|
||||||
from shadowJar
|
|
||||||
rename "packwiz-installer-(.*)\\.jar", "packwiz-installer.jar"
|
|
||||||
into "build/libs/"
|
|
||||||
}
|
|
||||||
|
|
||||||
build.dependsOn copyJar
|
|
88
build.gradle.kts
Normal file
88
build.gradle.kts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClassName = "link.infra.packwiz.installer.RequiresBootstrap"
|
||||||
|
}
|
||||||
|
|
||||||
|
val gitVersion: groovy.lang.Closure<*> by extra
|
||||||
|
version = gitVersion()
|
||||||
|
|
||||||
|
tasks.jar {
|
||||||
|
manifest {
|
||||||
|
attributes["Main-Class"] = "link.infra.packwiz.installer.RequiresBootstrap"
|
||||||
|
attributes["Implementation-Version"] = project.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
|
||||||
|
tasks.shadowJar {
|
||||||
|
dependencies {
|
||||||
|
exclude(dependency("commons-cli:commons-cli:1.4"))
|
||||||
|
exclude(dependency("com.eclipsesource.minimal-json:minimal-json:0.9.5"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for vscode launch.json
|
||||||
|
tasks.register<Copy>("copyJar") {
|
||||||
|
from(tasks.shadowJar)
|
||||||
|
rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar")
|
||||||
|
into("build/libs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.build {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.githubRelease {
|
||||||
|
dependsOn(tasks.build)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.compileKotlin {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
freeCompilerArgs = listOf("-Xjvm-default=enable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.compileTestKotlin {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
freeCompilerArgs = listOf("-Xjvm-default=enable")
|
||||||
|
}
|
||||||
|
}
|
@@ -1,159 +0,0 @@
|
|||||||
package link.infra.packwiz.installer;
|
|
||||||
|
|
||||||
import java.awt.EventQueue;
|
|
||||||
import java.awt.GraphicsEnvironment;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
|
|
||||||
import javax.swing.JOptionPane;
|
|
||||||
import javax.swing.UIManager;
|
|
||||||
|
|
||||||
import org.apache.commons.cli.CommandLine;
|
|
||||||
import org.apache.commons.cli.CommandLineParser;
|
|
||||||
import org.apache.commons.cli.DefaultParser;
|
|
||||||
import org.apache.commons.cli.Options;
|
|
||||||
import org.apache.commons.cli.ParseException;
|
|
||||||
|
|
||||||
import link.infra.packwiz.installer.ui.CLIHandler;
|
|
||||||
import link.infra.packwiz.installer.ui.IUserInterface;
|
|
||||||
import link.infra.packwiz.installer.ui.InstallWindow;
|
|
||||||
|
|
||||||
public class Main {
|
|
||||||
|
|
||||||
// Actual main() is in RequiresBootstrap!
|
|
||||||
|
|
||||||
public Main(String[] args) {
|
|
||||||
// Big overarching try/catch just in case everything breaks
|
|
||||||
try {
|
|
||||||
this.startup(args);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
EventQueue.invokeLater(new Runnable() {
|
|
||||||
public void run() {
|
|
||||||
JOptionPane.showMessageDialog(null,
|
|
||||||
"A fatal error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(),
|
|
||||||
"packwiz-installer", JOptionPane.ERROR_MESSAGE);
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// In case the eventqueue is broken, exit after 1 minute
|
|
||||||
try {
|
|
||||||
Thread.sleep(60 * 1000);
|
|
||||||
} catch (InterruptedException e1) {
|
|
||||||
// Good, it was already called?
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void startup(String[] args) {
|
|
||||||
Options options = new Options();
|
|
||||||
addNonBootstrapOptions(options);
|
|
||||||
addBootstrapOptions(options);
|
|
||||||
|
|
||||||
CommandLineParser parser = new DefaultParser();
|
|
||||||
CommandLine cmd = null;
|
|
||||||
try {
|
|
||||||
cmd = parser.parse(options, args);
|
|
||||||
} catch (ParseException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
try {
|
|
||||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
|
||||||
} catch (Exception e1) {
|
|
||||||
// Ignore the exceptions, just continue using the ugly L&F
|
|
||||||
}
|
|
||||||
JOptionPane.showMessageDialog(null, e.getMessage(), "packwiz-installer", JOptionPane.ERROR_MESSAGE);
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
IUserInterface ui;
|
|
||||||
// if "headless", GUI creation will fail anyway!
|
|
||||||
if (cmd.hasOption("no-gui") || GraphicsEnvironment.isHeadless()) {
|
|
||||||
ui = new CLIHandler();
|
|
||||||
} else {
|
|
||||||
ui = new InstallWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] unparsedArgs = cmd.getArgs();
|
|
||||||
if (unparsedArgs.length > 1) {
|
|
||||||
ui.handleExceptionAndExit(new RuntimeException("Too many arguments specified!"));
|
|
||||||
return;
|
|
||||||
} else if (unparsedArgs.length < 1) {
|
|
||||||
ui.handleExceptionAndExit(new RuntimeException("URI to install from must be specified!"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String title = cmd.getOptionValue("title");
|
|
||||||
if (title != null) {
|
|
||||||
ui.setTitle(title);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.show();
|
|
||||||
|
|
||||||
UpdateManager.Options uOptions = new UpdateManager.Options();
|
|
||||||
|
|
||||||
String side = cmd.getOptionValue("side");
|
|
||||||
if (side != null) {
|
|
||||||
uOptions.side = UpdateManager.Options.Side.from(side);
|
|
||||||
}
|
|
||||||
|
|
||||||
String packFolder = cmd.getOptionValue("pack-folder");
|
|
||||||
if (packFolder != null) {
|
|
||||||
uOptions.packFolder = packFolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
String metaFile = cmd.getOptionValue("meta-file");
|
|
||||||
if (metaFile != null) {
|
|
||||||
uOptions.manifestFile = metaFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
uOptions.downloadURI = new URI(unparsedArgs[0]);
|
|
||||||
} catch (URISyntaxException e) {
|
|
||||||
// TODO: better error message?
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start update process!
|
|
||||||
// TODO: start in SwingWorker?
|
|
||||||
try {
|
|
||||||
ui.executeManager(new Runnable(){
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
new UpdateManager(uOptions, ui);
|
|
||||||
} catch (Exception e) {
|
|
||||||
// TODO: better error message?
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
// TODO: better error message?
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called by packwiz-installer-bootstrap to set up the help command
|
|
||||||
public static void addNonBootstrapOptions(Options options) {
|
|
||||||
options.addOption("s", "side", true, "Side to install mods from (client/server, defaults to client)");
|
|
||||||
options.addOption(null, "title", true, "Title of the installer window");
|
|
||||||
options.addOption(null, "pack-folder", true, "Folder to install the pack to (defaults to the JAR directory)");
|
|
||||||
options.addOption(null, "meta-file", true, "JSON file to store pack metadata, relative to the pack folder (defaults to packwiz.json)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: link these somehow so they're only defined once?
|
|
||||||
private static void addBootstrapOptions(Options options) {
|
|
||||||
options.addOption(null, "bootstrap-update-url", true, "Github API URL for checking for updates");
|
|
||||||
options.addOption(null, "bootstrap-update-token", true, "Github API Access Token, for private repositories");
|
|
||||||
options.addOption(null, "bootstrap-no-update", false, "Don't update packwiz-installer");
|
|
||||||
options.addOption(null, "bootstrap-main-jar", true, "Location of the packwiz-installer JAR file");
|
|
||||||
options.addOption("g", "no-gui", false, "Don't display a GUI to show update progress");
|
|
||||||
options.addOption("h", "help", false, "Display this message"); // Implemented in packwiz-installer-bootstrap!
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,10 +1,8 @@
|
|||||||
package link.infra.packwiz.installer;
|
package link.infra.packwiz.installer;
|
||||||
|
|
||||||
|
import javax.swing.*;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
import javax.swing.JOptionPane;
|
|
||||||
import javax.swing.UIManager;
|
|
||||||
|
|
||||||
public class RequiresBootstrap {
|
public class RequiresBootstrap {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
@@ -15,10 +13,10 @@ public class RequiresBootstrap {
|
|||||||
if (Arrays.stream(args).map(str -> {
|
if (Arrays.stream(args).map(str -> {
|
||||||
if (str == null) return "";
|
if (str == null) return "";
|
||||||
if (str.startsWith("--")) {
|
if (str.startsWith("--")) {
|
||||||
return str.substring(2, str.length());
|
return str.substring(2);
|
||||||
}
|
}
|
||||||
if (str.startsWith("-")) {
|
if (str.startsWith("-")) {
|
||||||
return str.substring(1, str.length());
|
return str.substring(1);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}).anyMatch(str -> str.equals("g") || str.equals("no-gui"))) {
|
}).anyMatch(str -> str.equals("g") || str.equals("no-gui"))) {
|
||||||
|
@@ -1,364 +0,0 @@
|
|||||||
package link.infra.packwiz.installer;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.FileReader;
|
|
||||||
import java.io.FileWriter;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.Writer;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.Callable;
|
|
||||||
import java.util.concurrent.CompletionService;
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
import java.util.concurrent.ExecutorCompletionService;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonIOException;
|
|
||||||
import com.google.gson.JsonSyntaxException;
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
import com.moandjiezana.toml.Toml;
|
|
||||||
|
|
||||||
import link.infra.packwiz.installer.metadata.IndexFile;
|
|
||||||
import link.infra.packwiz.installer.metadata.ManifestFile;
|
|
||||||
import link.infra.packwiz.installer.metadata.PackFile;
|
|
||||||
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
|
|
||||||
import link.infra.packwiz.installer.metadata.hash.HashUtils;
|
|
||||||
import link.infra.packwiz.installer.request.HandlerManager;
|
|
||||||
import link.infra.packwiz.installer.ui.IUserInterface;
|
|
||||||
import link.infra.packwiz.installer.ui.InstallProgress;
|
|
||||||
import okio.Buffer;
|
|
||||||
import okio.Okio;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public class UpdateManager {
|
|
||||||
|
|
||||||
public final Options opts;
|
|
||||||
public final IUserInterface ui;
|
|
||||||
|
|
||||||
public static class Options {
|
|
||||||
public URI downloadURI = null;
|
|
||||||
public String manifestFile = "packwiz.json"; // TODO: make configurable
|
|
||||||
public String packFolder = ".";
|
|
||||||
public Side side = Side.CLIENT;
|
|
||||||
|
|
||||||
public static enum Side {
|
|
||||||
@SerializedName("client")
|
|
||||||
CLIENT("client"), @SerializedName("server")
|
|
||||||
SERVER("server"), @SerializedName("both")
|
|
||||||
BOTH("both", new Side[] { CLIENT, SERVER });
|
|
||||||
|
|
||||||
private final String sideName;
|
|
||||||
private final Side[] depSides;
|
|
||||||
|
|
||||||
Side(String sideName) {
|
|
||||||
this.sideName = sideName.toLowerCase();
|
|
||||||
this.depSides = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Side(String sideName, Side[] depSides) {
|
|
||||||
this.sideName = sideName.toLowerCase();
|
|
||||||
this.depSides = depSides;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return this.sideName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasSide(Side tSide) {
|
|
||||||
if (this.equals(tSide)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (this.depSides != null) {
|
|
||||||
for (int i = 0; i < this.depSides.length; i++) {
|
|
||||||
if (this.depSides[i].equals(tSide)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Side from(String name) {
|
|
||||||
String lowerName = name.toLowerCase();
|
|
||||||
for (Side side : Side.values()) {
|
|
||||||
if (side.sideName == lowerName) {
|
|
||||||
return side;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public UpdateManager(Options opts, IUserInterface ui) {
|
|
||||||
this.opts = opts;
|
|
||||||
this.ui = ui;
|
|
||||||
this.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void start() {
|
|
||||||
this.checkOptions();
|
|
||||||
|
|
||||||
ui.submitProgress(new InstallProgress("Loading manifest file..."));
|
|
||||||
Gson gson = new Gson();
|
|
||||||
ManifestFile manifest;
|
|
||||||
try {
|
|
||||||
manifest = gson.fromJson(new FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
|
|
||||||
ManifestFile.class);
|
|
||||||
} catch (FileNotFoundException e) {
|
|
||||||
manifest = new ManifestFile();
|
|
||||||
} catch (JsonSyntaxException | JsonIOException e) {
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.submitProgress(new InstallProgress("Loading pack file..."));
|
|
||||||
GeneralHashingSource packFileSource;
|
|
||||||
try {
|
|
||||||
Source src = HandlerManager.getFileSource(opts.downloadURI);
|
|
||||||
packFileSource = HashUtils.getHasher("sha256").getHashingSource(src);
|
|
||||||
} catch (Exception e) {
|
|
||||||
// TODO: still launch the game if updating doesn't work?
|
|
||||||
// TODO: ask user if they want to launch the game, exit(1) if they don't
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
PackFile pf;
|
|
||||||
try {
|
|
||||||
pf = new Toml().read(Okio.buffer(packFileSource).inputStream()).to(PackFile.class);
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest.packFileHash != null && packFileSource.hashIsEqual(manifest.packFileHash)) {
|
|
||||||
System.out.println("Hash already up to date!");
|
|
||||||
// WOOO it's already up to date
|
|
||||||
// todo: --force?
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println(pf.name);
|
|
||||||
|
|
||||||
try {
|
|
||||||
processIndex(HandlerManager.getNewLoc(opts.downloadURI, pf.index.file),
|
|
||||||
HashUtils.getHash(pf.index.hashFormat, pf.index.hash), pf.index.hashFormat, manifest);
|
|
||||||
} catch (Exception e1) {
|
|
||||||
ui.handleExceptionAndExit(e1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When successfully updated
|
|
||||||
manifest.packFileHash = packFileSource.getHash();
|
|
||||||
// update other hashes
|
|
||||||
// TODO: don't do this on failure?
|
|
||||||
try (Writer writer = new FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString())) {
|
|
||||||
gson.toJson(manifest, writer);
|
|
||||||
} catch (IOException e) {
|
|
||||||
// TODO: add message?
|
|
||||||
ui.handleException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void checkOptions() {
|
|
||||||
// TODO: implement
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void processIndex(URI indexUri, Object indexHash, String hashFormat, ManifestFile manifest) {
|
|
||||||
GeneralHashingSource indexFileSource;
|
|
||||||
try {
|
|
||||||
Source src = HandlerManager.getFileSource(indexUri);
|
|
||||||
indexFileSource = HashUtils.getHasher(hashFormat).getHashingSource(src);
|
|
||||||
} catch (Exception e) {
|
|
||||||
// TODO: still launch the game if updating doesn't work?
|
|
||||||
// TODO: ask user if they want to launch the game, exit(1) if they don't
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
IndexFile indexFile;
|
|
||||||
try {
|
|
||||||
indexFile = new Toml().read(Okio.buffer(indexFileSource).inputStream()).to(IndexFile.class);
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
ui.handleExceptionAndExit(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!indexFileSource.hashIsEqual(indexHash)) {
|
|
||||||
System.out.println("Hash problems!!!!!!!");
|
|
||||||
System.out.println(indexHash);
|
|
||||||
System.out.println(indexFileSource.getHash());
|
|
||||||
// TODO: throw exception
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest.cachedFiles == null) {
|
|
||||||
manifest.cachedFiles = new HashMap<URI, ManifestFile.File>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: progress bar
|
|
||||||
ConcurrentLinkedQueue<Exception> exceptionQueue = new ConcurrentLinkedQueue<Exception>();
|
|
||||||
List<IndexFile.File> newFiles = indexFile.files.stream().map(f -> {
|
|
||||||
if (f.hashFormat == null || f.hashFormat.length() == 0) {
|
|
||||||
f.hashFormat = indexFile.hashFormat;
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}).filter(f -> {
|
|
||||||
ManifestFile.File cachedFile = manifest.cachedFiles.get(f.file);
|
|
||||||
Object newHash;
|
|
||||||
try {
|
|
||||||
newHash = HashUtils.getHash(f.hashFormat, f.hash);
|
|
||||||
} catch (Exception e) {
|
|
||||||
exceptionQueue.add(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return cachedFile == null || !newHash.equals(cachedFile.hash);
|
|
||||||
}).parallel().map(f -> {
|
|
||||||
try {
|
|
||||||
f.downloadMeta(indexFile, indexUri);
|
|
||||||
} catch (Exception e) {
|
|
||||||
exceptionQueue.add(e);
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
|
|
||||||
for (Exception e : exceptionQueue) {
|
|
||||||
// TODO: collect all exceptions, present in one dialog
|
|
||||||
ui.handleException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: present options
|
|
||||||
// TODO: all options should be presented, not just new files!!!!!!!
|
|
||||||
// and options should be readded to newFiles after option -> true
|
|
||||||
newFiles.stream().filter(f -> f.linkedFile != null).filter(f -> f.linkedFile.option != null).map(f -> {
|
|
||||||
return "option: " + (f.linkedFile.option.description == null ? "null" : f.linkedFile.option.description);
|
|
||||||
}).forEachOrdered(desc -> {
|
|
||||||
System.out.println(desc);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: different thread pool type?
|
|
||||||
ExecutorService threadPool = Executors.newFixedThreadPool(10);
|
|
||||||
CompletionService<DownloadCompletion> completionService = new ExecutorCompletionService<DownloadCompletion>(
|
|
||||||
threadPool);
|
|
||||||
|
|
||||||
for (IndexFile.File f : newFiles) {
|
|
||||||
ManifestFile.File cachedFile = manifest.cachedFiles.get(f.file);
|
|
||||||
completionService.submit(new Callable<DownloadCompletion>() {
|
|
||||||
public DownloadCompletion call() {
|
|
||||||
DownloadCompletion dc = new DownloadCompletion();
|
|
||||||
dc.file = f;
|
|
||||||
|
|
||||||
if (cachedFile != null && cachedFile.linkedFileHash != null && f.linkedFile != null) {
|
|
||||||
try {
|
|
||||||
if (cachedFile.linkedFileHash.equals(f.linkedFile.getHash())) {
|
|
||||||
// Do nothing, the file didn't change
|
|
||||||
// TODO: but if the hash of the metafile changed, what did change?????
|
|
||||||
// should this be checked somehow??
|
|
||||||
return dc;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Object hash;
|
|
||||||
String fileHashFormat;
|
|
||||||
if (f.linkedFile != null) {
|
|
||||||
hash = f.linkedFile.getHash();
|
|
||||||
fileHashFormat = f.linkedFile.download.hashFormat;
|
|
||||||
} else {
|
|
||||||
hash = f.getHash();
|
|
||||||
fileHashFormat = f.hashFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
Source src = f.getSource(indexUri);
|
|
||||||
GeneralHashingSource fileSource = HashUtils.getHasher(fileHashFormat).getHashingSource(src);
|
|
||||||
Buffer data = new Buffer();
|
|
||||||
Okio.buffer(fileSource).readAll(data);
|
|
||||||
|
|
||||||
if (fileSource.hashIsEqual(hash)) {
|
|
||||||
Files.createDirectories(Paths.get(opts.packFolder, f.getDestURI().toString()).getParent());
|
|
||||||
Files.copy(data.inputStream(), Paths.get(opts.packFolder, f.getDestURI().toString()), StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
} else {
|
|
||||||
System.out.println("Invalid hash for " + f.getDestURI().toString());
|
|
||||||
System.out.println("Calculated: " + fileSource.getHash());
|
|
||||||
System.out.println("Expected: " + hash);
|
|
||||||
dc.err = new Exception("Hash invalid!");
|
|
||||||
}
|
|
||||||
|
|
||||||
return dc;
|
|
||||||
} catch (Exception e) {
|
|
||||||
dc.err = e;
|
|
||||||
return dc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < newFiles.size(); i++) {
|
|
||||||
DownloadCompletion ret;
|
|
||||||
try {
|
|
||||||
ret = completionService.take().get();
|
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
|
||||||
// TODO: collect all exceptions, present in one dialog
|
|
||||||
ui.handleException(e);
|
|
||||||
ret = null;
|
|
||||||
}
|
|
||||||
// Update manifest
|
|
||||||
if (ret != null && ret.err == null && ret.file != null) {
|
|
||||||
ManifestFile.File newCachedFile = new ManifestFile.File();
|
|
||||||
try {
|
|
||||||
newCachedFile.hash = ret.file.getHash();
|
|
||||||
if (newCachedFile.hash == null) {
|
|
||||||
throw new Exception("Invalid hash!");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
ret.err = e;
|
|
||||||
}
|
|
||||||
if (ret.file.metafile && ret.file.linkedFile != null) {
|
|
||||||
newCachedFile.isOptional = ret.file.linkedFile.isOptional();
|
|
||||||
if (newCachedFile.isOptional) {
|
|
||||||
newCachedFile.optionValue = ret.file.optionValue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
newCachedFile.linkedFileHash = ret.file.linkedFile.getHash();
|
|
||||||
} catch (Exception e) {
|
|
||||||
ret.err = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
manifest.cachedFiles.put(ret.file.file, newCachedFile);
|
|
||||||
}
|
|
||||||
// TODO: show errors properly?
|
|
||||||
String progress;
|
|
||||||
if (ret != null) {
|
|
||||||
if (ret.err != null) {
|
|
||||||
if (ret.file != null) {
|
|
||||||
progress = "Failed to download " + ret.file.getName() + ": " + ret.err.getMessage();
|
|
||||||
} else {
|
|
||||||
progress = "Failed to download: " + ret.err.getMessage();
|
|
||||||
}
|
|
||||||
ret.err.printStackTrace();
|
|
||||||
} else if (ret.file != null) {
|
|
||||||
progress = "Downloaded " + ret.file.getName();
|
|
||||||
} else {
|
|
||||||
progress = "Failed to download, unknown reason";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
progress = "Failed to download, unknown reason";
|
|
||||||
}
|
|
||||||
ui.submitProgress(new InstallProgress(progress, i + 1, newFiles.size()));
|
|
||||||
}
|
|
||||||
// option = false file hashes should be stored to disk, but not downloaded
|
|
||||||
// TODO: don't include optional files in progress????
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DownloadCompletion {
|
|
||||||
Exception err;
|
|
||||||
IndexFile.File file;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,106 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import com.google.gson.annotations.JsonAdapter;
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
import com.moandjiezana.toml.Toml;
|
|
||||||
|
|
||||||
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
|
|
||||||
import link.infra.packwiz.installer.metadata.hash.HashUtils;
|
|
||||||
import link.infra.packwiz.installer.request.HandlerManager;
|
|
||||||
import okio.Okio;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public class IndexFile {
|
|
||||||
@SerializedName("hash-format")
|
|
||||||
public String hashFormat;
|
|
||||||
public List<File> files;
|
|
||||||
|
|
||||||
public static class File {
|
|
||||||
@JsonAdapter(SpaceSafeURIParser.class)
|
|
||||||
public URI file;
|
|
||||||
@SerializedName("hash-format")
|
|
||||||
public String hashFormat;
|
|
||||||
public String hash;
|
|
||||||
// TODO: implement
|
|
||||||
public String alias;
|
|
||||||
public boolean metafile;
|
|
||||||
// TODO: implement
|
|
||||||
public boolean preserve;
|
|
||||||
|
|
||||||
public transient ModFile linkedFile;
|
|
||||||
public transient URI linkedFileURI;
|
|
||||||
public transient boolean optionValue = true;
|
|
||||||
|
|
||||||
public void downloadMeta(IndexFile parentIndexFile, URI indexUri) throws Exception {
|
|
||||||
if (!metafile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hashFormat == null || hashFormat.length() == 0) {
|
|
||||||
hashFormat = parentIndexFile.hashFormat;
|
|
||||||
}
|
|
||||||
Object fileHash = HashUtils.getHash(hashFormat, hash);
|
|
||||||
linkedFileURI = HandlerManager.getNewLoc(indexUri, file);
|
|
||||||
Source src = HandlerManager.getFileSource(linkedFileURI);
|
|
||||||
GeneralHashingSource fileStream = HashUtils.getHasher(hashFormat).getHashingSource(src);
|
|
||||||
|
|
||||||
linkedFile = new Toml().read(Okio.buffer(fileStream).inputStream()).to(ModFile.class);
|
|
||||||
if (!fileStream.hashIsEqual(fileHash)) {
|
|
||||||
throw new Exception("Invalid mod file hash");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Source getSource(URI indexUri) throws Exception {
|
|
||||||
if (metafile) {
|
|
||||||
if (linkedFile == null) {
|
|
||||||
throw new Exception("Linked file doesn't exist!");
|
|
||||||
}
|
|
||||||
return linkedFile.getSource(linkedFileURI);
|
|
||||||
} else {
|
|
||||||
URI newLoc = HandlerManager.getNewLoc(indexUri, file);
|
|
||||||
if (newLoc == null) {
|
|
||||||
throw new Exception("Index file URI is invalid");
|
|
||||||
}
|
|
||||||
return HandlerManager.getFileSource(newLoc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Object getHash() throws Exception {
|
|
||||||
if (hash == null) {
|
|
||||||
throw new Exception("Index file doesn't have a hash");
|
|
||||||
}
|
|
||||||
if (hashFormat == null) {
|
|
||||||
throw new Exception("Index file doesn't have a hash format");
|
|
||||||
}
|
|
||||||
return HashUtils.getHash(hashFormat, hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
|
||||||
if (metafile) {
|
|
||||||
if (linkedFile != null) {
|
|
||||||
if (linkedFile.name != null) {
|
|
||||||
return linkedFile.name;
|
|
||||||
} else if (linkedFile.filename != null) {
|
|
||||||
return linkedFile.filename;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (file != null) {
|
|
||||||
return Paths.get(file.getPath()).getFileName().toString();
|
|
||||||
}
|
|
||||||
return file.getPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public URI getDestURI() {
|
|
||||||
if (metafile && linkedFile != null) {
|
|
||||||
// TODO: URIs are bad
|
|
||||||
return file.resolve(linkedFile.filename.replace(" ", "%20"));
|
|
||||||
} else {
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,18 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class ManifestFile {
|
|
||||||
|
|
||||||
public Object packFileHash = null;
|
|
||||||
public Object indexFileHash = null;
|
|
||||||
public Map<URI, File> cachedFiles;
|
|
||||||
|
|
||||||
public static class File {
|
|
||||||
public Object hash = null;
|
|
||||||
public boolean isOptional = false;
|
|
||||||
public boolean optionValue = true;
|
|
||||||
public Object linkedFileHash = null;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,73 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import com.google.gson.annotations.JsonAdapter;
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
|
|
||||||
import link.infra.packwiz.installer.UpdateManager.Options.Side;
|
|
||||||
import link.infra.packwiz.installer.metadata.hash.HashUtils;
|
|
||||||
import link.infra.packwiz.installer.request.HandlerManager;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public class ModFile {
|
|
||||||
public String name;
|
|
||||||
public String filename;
|
|
||||||
public Side side;
|
|
||||||
|
|
||||||
public Download download;
|
|
||||||
public static class Download {
|
|
||||||
@JsonAdapter(SpaceSafeURIParser.class)
|
|
||||||
public URI url;
|
|
||||||
@SerializedName("hash-format")
|
|
||||||
public String hashFormat;
|
|
||||||
public String hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> update;
|
|
||||||
|
|
||||||
public Option option;
|
|
||||||
public static class Option {
|
|
||||||
public boolean optional;
|
|
||||||
public String description;
|
|
||||||
@SerializedName("default")
|
|
||||||
public boolean defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Source getSource(URI baseLoc) throws Exception {
|
|
||||||
if (download == null) {
|
|
||||||
throw new Exception("Metadata file doesn't have download");
|
|
||||||
}
|
|
||||||
if (download.url == null) {
|
|
||||||
throw new Exception("Metadata file doesn't have a download URI");
|
|
||||||
}
|
|
||||||
URI newLoc = HandlerManager.getNewLoc(baseLoc, download.url);
|
|
||||||
if (newLoc == null) {
|
|
||||||
throw new Exception("Metadata file URI is invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
return HandlerManager.getFileSource(newLoc);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Object getHash() throws Exception {
|
|
||||||
if (download == null) {
|
|
||||||
throw new Exception("Metadata file doesn't have download");
|
|
||||||
}
|
|
||||||
if (download.hash == null) {
|
|
||||||
throw new Exception("Metadata file doesn't have a hash");
|
|
||||||
}
|
|
||||||
if (download.hashFormat == null) {
|
|
||||||
throw new Exception("Metadata file doesn't have a hash format");
|
|
||||||
}
|
|
||||||
return HashUtils.getHash(download.hashFormat, download.hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isOptional() {
|
|
||||||
if (option != null) {
|
|
||||||
return option.optional;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,24 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import com.google.gson.annotations.JsonAdapter;
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
|
|
||||||
public class PackFile {
|
|
||||||
public String name;
|
|
||||||
|
|
||||||
public IndexFileLoc index;
|
|
||||||
public static class IndexFileLoc {
|
|
||||||
@JsonAdapter(SpaceSafeURIParser.class)
|
|
||||||
public URI file;
|
|
||||||
@SerializedName("hash-format")
|
|
||||||
public String hashFormat;
|
|
||||||
public String hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, String> versions;
|
|
||||||
public Map<String, Object> client;
|
|
||||||
public Map<String, Object> server;
|
|
||||||
}
|
|
@@ -1,31 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata;
|
|
||||||
|
|
||||||
import java.lang.reflect.Type;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
|
|
||||||
import com.google.gson.JsonDeserializationContext;
|
|
||||||
import com.google.gson.JsonDeserializer;
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import com.google.gson.JsonParseException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class encodes spaces before parsing the URI, so the URI can actually be
|
|
||||||
* parsed.
|
|
||||||
*/
|
|
||||||
class SpaceSafeURIParser implements JsonDeserializer<URI> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public URI deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
|
|
||||||
throws JsonParseException {
|
|
||||||
String uriString = json.getAsString().replace(" ", "%20");
|
|
||||||
try {
|
|
||||||
return new URI(uriString);
|
|
||||||
} catch (URISyntaxException e) {
|
|
||||||
throw new JsonParseException("Failed to parse URI", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: replace this with a better solution?
|
|
||||||
|
|
||||||
}
|
|
@@ -1,18 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata.hash;
|
|
||||||
|
|
||||||
import okio.ForwardingSource;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public abstract class GeneralHashingSource extends ForwardingSource {
|
|
||||||
|
|
||||||
public GeneralHashingSource(Source delegate) {
|
|
||||||
super(delegate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract Object getHash();
|
|
||||||
|
|
||||||
public boolean hashIsEqual(Object compareTo) {
|
|
||||||
return compareTo.equals(getHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,29 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata.hash;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class HashUtils {
|
|
||||||
private static final Map<String, IHasher> hashTypeConversion = new HashMap<String, IHasher>();
|
|
||||||
static {
|
|
||||||
hashTypeConversion.put("sha256", new HashingSourceHasher("sha256"));
|
|
||||||
hashTypeConversion.put("murmur2", new Murmur2Hasher());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IHasher getHasher(String type) throws Exception {
|
|
||||||
IHasher hasher = hashTypeConversion.get(type);
|
|
||||||
if (hasher == null) {
|
|
||||||
throw new Exception("Hash type not supported: " + type);
|
|
||||||
}
|
|
||||||
return hasher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Object getHash(String type, String value) throws Exception {
|
|
||||||
if (hashTypeConversion.containsKey(type)) {
|
|
||||||
return hashTypeConversion.get(type).getHash(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception("Hash type not supported: " + type);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,76 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata.hash;
|
|
||||||
|
|
||||||
import okio.HashingSource;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public class HashingSourceHasher implements IHasher {
|
|
||||||
String type;
|
|
||||||
|
|
||||||
public HashingSourceHasher(String type) {
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// i love naming things
|
|
||||||
private class HashingSourceGeneralHashingSource extends GeneralHashingSource {
|
|
||||||
HashingSource delegateHashing;
|
|
||||||
HashingSourceHash value;
|
|
||||||
|
|
||||||
public HashingSourceGeneralHashingSource(HashingSource delegate) {
|
|
||||||
super(delegate);
|
|
||||||
delegateHashing = delegate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object getHash() {
|
|
||||||
if (value == null) {
|
|
||||||
value = new HashingSourceHash(delegateHashing.hash().hex());
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// this some funky inner class stuff
|
|
||||||
// each of these classes is specific to the instance of the HasherHashingSource
|
|
||||||
// therefore HashingSourceHashes from different parent instances will be not instanceof each other
|
|
||||||
private class HashingSourceHash {
|
|
||||||
String value;
|
|
||||||
private HashingSourceHash(String value) {
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object obj) {
|
|
||||||
if (!(obj instanceof HashingSourceHash)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
HashingSourceHash objHash = (HashingSourceHash) obj;
|
|
||||||
if (value != null) {
|
|
||||||
return value.equals(objHash.value);
|
|
||||||
} else {
|
|
||||||
return objHash.value == null ? true : false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return type + ": " + value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public GeneralHashingSource getHashingSource(Source delegate) {
|
|
||||||
switch (type) {
|
|
||||||
case "sha256":
|
|
||||||
return new HashingSourceGeneralHashingSource(HashingSource.sha256(delegate));
|
|
||||||
// TODO: support other hash types
|
|
||||||
}
|
|
||||||
throw new RuntimeException("Invalid hash type provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object getHash(String value) {
|
|
||||||
return new HashingSourceHash(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,8 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata.hash;
|
|
||||||
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public interface IHasher {
|
|
||||||
public GeneralHashingSource getHashingSource(Source delegate);
|
|
||||||
public Object getHash(String value);
|
|
||||||
}
|
|
@@ -1,94 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.metadata.hash;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
import okio.Buffer;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public class Murmur2Hasher implements IHasher {
|
|
||||||
private class Murmur2GeneralHashingSource extends GeneralHashingSource {
|
|
||||||
Murmur2Hash value;
|
|
||||||
Buffer internalBuffer = new Buffer();
|
|
||||||
Buffer tempBuffer = new Buffer();
|
|
||||||
Source delegate;
|
|
||||||
|
|
||||||
public Murmur2GeneralHashingSource(Source delegate) {
|
|
||||||
super(delegate);
|
|
||||||
this.delegate = delegate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long read(Buffer sink, long byteCount) throws IOException {
|
|
||||||
long out = delegate.read(tempBuffer, byteCount);
|
|
||||||
if (out > -1) {
|
|
||||||
sink.write(tempBuffer.clone(), out);
|
|
||||||
internalBuffer.write(tempBuffer, out);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object getHash() {
|
|
||||||
if (value == null) {
|
|
||||||
byte[] data = computeNormalizedArray(internalBuffer.readByteArray());
|
|
||||||
value = new Murmur2Hash(Murmur2Lib.hash32(data, data.length, 1));
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
|
|
||||||
private byte[] computeNormalizedArray(byte[] input) {
|
|
||||||
byte[] output = new byte[input.length];
|
|
||||||
int num = 0;
|
|
||||||
for (int i = 0; i < input.length; i++) {
|
|
||||||
byte b = input[i];
|
|
||||||
if (!(b == 9 || b == 10 || b == 13 || b == 32)) {
|
|
||||||
output[num] = b;
|
|
||||||
num++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
byte[] outputTrimmed = new byte[num];
|
|
||||||
System.arraycopy(output, 0, outputTrimmed, 0, num);
|
|
||||||
return outputTrimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Murmur2Hash {
|
|
||||||
int value;
|
|
||||||
private Murmur2Hash(String value) {
|
|
||||||
// Parsing as long then casting to int converts values gt int max value but lt uint max value
|
|
||||||
// into negatives. I presume this is how the murmur2 code handles this.
|
|
||||||
this.value = (int)Long.parseLong(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Murmur2Hash(int value) {
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object obj) {
|
|
||||||
if (!(obj instanceof Murmur2Hash)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Murmur2Hash objHash = (Murmur2Hash) obj;
|
|
||||||
return value == objHash.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "murmur2: " + value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public GeneralHashingSource getHashingSource(Source delegate) {
|
|
||||||
return new Murmur2GeneralHashingSource(delegate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object getHash(String value) {
|
|
||||||
return new Murmur2Hash(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -74,13 +74,13 @@ public class Murmur2Lib {
|
|||||||
int left = length - len_m;
|
int left = length - len_m;
|
||||||
if (left != 0) {
|
if (left != 0) {
|
||||||
if (left >= 3) {
|
if (left >= 3) {
|
||||||
h ^= (int) data[length - 3] << 16;
|
h ^= (int) data[length - (left - 2)] << 16;
|
||||||
}
|
}
|
||||||
if (left >= 2) {
|
if (left >= 2) {
|
||||||
h ^= (int) data[length - 2] << 8;
|
h ^= (int) data[length - (left - 1)] << 8;
|
||||||
}
|
}
|
||||||
if (left >= 1) {
|
if (left >= 1) {
|
||||||
h ^= (int) data[length - 1];
|
h ^= data[length - left];
|
||||||
}
|
}
|
||||||
|
|
||||||
h *= M_32;
|
h *= M_32;
|
||||||
@@ -152,7 +152,7 @@ public class Murmur2Lib {
|
|||||||
case 2:
|
case 2:
|
||||||
h ^= (long) (data[tailStart + 1] & 0xff) << 8;
|
h ^= (long) (data[tailStart + 1] & 0xff) << 8;
|
||||||
case 1:
|
case 1:
|
||||||
h ^= (long) (data[tailStart] & 0xff);
|
h ^= data[tailStart] & 0xff;
|
||||||
h *= M_64;
|
h *= M_64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,59 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.request;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import link.infra.packwiz.installer.request.handlers.RequestHandlerGithub;
|
|
||||||
import link.infra.packwiz.installer.request.handlers.RequestHandlerHTTP;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public abstract class HandlerManager {
|
|
||||||
|
|
||||||
public static List<IRequestHandler> handlers = new ArrayList<IRequestHandler>();
|
|
||||||
|
|
||||||
static {
|
|
||||||
handlers.add(new RequestHandlerGithub());
|
|
||||||
handlers.add(new RequestHandlerHTTP());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static URI getNewLoc(URI base, URI loc) {
|
|
||||||
if (loc == null) return null;
|
|
||||||
if (base != null) {
|
|
||||||
loc = base.resolve(loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (IRequestHandler handler : handlers) {
|
|
||||||
if (handler.matchesHandler(loc)) {
|
|
||||||
return handler.getNewLoc(loc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: What if files are read multiple times??
|
|
||||||
// Zip handler discards once read, requesting multiple times on other handlers would cause multiple downloads
|
|
||||||
// Caching system? Copy from already downloaded files?
|
|
||||||
|
|
||||||
public static Source getFileSource(URI loc) throws Exception {
|
|
||||||
for (IRequestHandler handler : handlers) {
|
|
||||||
if (handler.matchesHandler(loc)) {
|
|
||||||
Source src = handler.getFileSource(loc);
|
|
||||||
if (src == null) {
|
|
||||||
throw new Exception("Couldn't find URI: " + loc.toString());
|
|
||||||
} else {
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: specialised exception classes??
|
|
||||||
throw new Exception("No handler available for URI: " + loc.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// github toml resolution
|
|
||||||
// e.g. https://github.com/comp500/Demagnetize -> demagnetize.toml
|
|
||||||
// https://github.com/comp500/Demagnetize/blob/master/demagnetize.toml
|
|
||||||
|
|
||||||
// To handle "progress", just count tasks, rather than individual progress
|
|
||||||
// It'll look bad, especially for zip-based things, but it should work fine
|
|
||||||
}
|
|
@@ -1,27 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.request;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IRequestHandler handles requests for locations specified in modpack metadata.
|
|
||||||
*/
|
|
||||||
public interface IRequestHandler {
|
|
||||||
|
|
||||||
public boolean matchesHandler(URI loc);
|
|
||||||
|
|
||||||
public default URI getNewLoc(URI loc) {
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the Source for a location. Must be threadsafe.
|
|
||||||
* It is assumed that each location is read only once for the duration of an IRequestHandler.
|
|
||||||
* @param loc The location to be read
|
|
||||||
* @return The Source containing the data of the file
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public Source getFileSource(URI loc) throws Exception;
|
|
||||||
|
|
||||||
}
|
|
@@ -1,83 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.request.handlers;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
public class RequestHandlerGithub extends RequestHandlerZip {
|
|
||||||
|
|
||||||
public RequestHandlerGithub() {
|
|
||||||
super(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public URI getNewLoc(URI loc) {
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: is caching really needed, if HTTPURLConnection follows redirects correctly?
|
|
||||||
private Map<String, URI> zipUriMap = new HashMap<String, URI>();
|
|
||||||
final ReentrantReadWriteLock zipUriLock = new ReentrantReadWriteLock();
|
|
||||||
private static Pattern repoMatcherPattern = Pattern.compile("/([\\w.-]+/[\\w.-]+).*");
|
|
||||||
|
|
||||||
private String getRepoName(URI loc) {
|
|
||||||
Matcher matcher = repoMatcherPattern.matcher(loc.getPath());
|
|
||||||
matcher.matches();
|
|
||||||
return matcher.group(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected URI getZipUri(URI loc) throws Exception {
|
|
||||||
String repoName = getRepoName(loc);
|
|
||||||
String branchName = getBranch(loc);
|
|
||||||
zipUriLock.readLock().lock();
|
|
||||||
URI zipUri = zipUriMap.get(repoName + "/" + branchName);
|
|
||||||
zipUriLock.readLock().unlock();
|
|
||||||
if (zipUri != null) {
|
|
||||||
return zipUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
zipUri = new URI("https://api.github.com/repos/" + repoName + "/zipball/" + branchName);
|
|
||||||
|
|
||||||
zipUriLock.writeLock().lock();
|
|
||||||
// If another thread sets the value concurrently, use the value of the
|
|
||||||
// thread that first acquired the lock.
|
|
||||||
URI zipUriInserted = zipUriMap.putIfAbsent(repoName + "/" + branchName, zipUri);
|
|
||||||
if (zipUriInserted != null) {
|
|
||||||
zipUri = zipUriInserted;
|
|
||||||
}
|
|
||||||
zipUriLock.writeLock().unlock();
|
|
||||||
return zipUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Pattern branchMatcherPattern = Pattern.compile("/[\\w.-]+/[\\w.-]+/blob/([\\w.-]+).*");
|
|
||||||
|
|
||||||
private String getBranch(URI loc) {
|
|
||||||
Matcher matcher = branchMatcherPattern.matcher(loc.getPath());
|
|
||||||
matcher.matches();
|
|
||||||
return matcher.group(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected URI getLocationInZip(URI loc) throws Exception {
|
|
||||||
String path = "/" + getRepoName(loc) + "/blob/" + getBranch(loc);
|
|
||||||
return new URI(loc.getScheme(), loc.getAuthority(), path, null, null).relativize(loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean matchesHandler(URI loc) {
|
|
||||||
String scheme = loc.getScheme();
|
|
||||||
if (!("http".equals(scheme) || "https".equals(scheme))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!"github.com".equals(loc.getHost())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// TODO: sanity checks, support for more github urls
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,29 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.request.handlers;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLConnection;
|
|
||||||
|
|
||||||
import link.infra.packwiz.installer.request.IRequestHandler;
|
|
||||||
import okio.Okio;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public class RequestHandlerHTTP implements IRequestHandler {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean matchesHandler(URI loc) {
|
|
||||||
String scheme = loc.getScheme();
|
|
||||||
return "http".equals(scheme) || "https".equals(scheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Source getFileSource(URI loc) throws Exception {
|
|
||||||
URLConnection conn = loc.toURL().openConnection();
|
|
||||||
// TODO: when do we send specific headers??? should there be a way to signal this?
|
|
||||||
// github *sometimes* requires it, sometimes not!
|
|
||||||
//conn.addRequestProperty("Accept", "application/octet-stream");
|
|
||||||
// 30 second read timeout
|
|
||||||
conn.setReadTimeout(30 * 1000);
|
|
||||||
return Okio.source(conn.getInputStream());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,172 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.request.handlers;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
|
||||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
|
||||||
import java.util.function.Predicate;
|
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import java.util.zip.ZipInputStream;
|
|
||||||
|
|
||||||
import okio.Buffer;
|
|
||||||
import okio.BufferedSource;
|
|
||||||
import okio.Okio;
|
|
||||||
import okio.Source;
|
|
||||||
|
|
||||||
public abstract class RequestHandlerZip extends RequestHandlerHTTP {
|
|
||||||
|
|
||||||
protected final boolean modeHasFolder;
|
|
||||||
|
|
||||||
public RequestHandlerZip(boolean modeHasFolder) {
|
|
||||||
this.modeHasFolder = modeHasFolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String removeFolder(String name) {
|
|
||||||
if (modeHasFolder) {
|
|
||||||
return name.substring(name.indexOf("/")+1);
|
|
||||||
} else {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ZipReader {
|
|
||||||
|
|
||||||
private final ZipInputStream zis;
|
|
||||||
private final Map<URI, Buffer> readFiles = new HashMap<URI, Buffer>();
|
|
||||||
// Write lock implies access to ZipInputStream - only 1 thread must read at a time!
|
|
||||||
final ReentrantLock filesLock = new ReentrantLock();
|
|
||||||
private ZipEntry entry;
|
|
||||||
|
|
||||||
private final BufferedSource zipSource;
|
|
||||||
|
|
||||||
public ZipReader(Source zip) {
|
|
||||||
zis = new ZipInputStream(Okio.buffer(zip).inputStream());
|
|
||||||
zipSource = Okio.buffer(Okio.source(zis));
|
|
||||||
}
|
|
||||||
|
|
||||||
// File lock must be obtained before calling this function
|
|
||||||
private Buffer readCurrFile() throws IOException {
|
|
||||||
Buffer fileBuffer = new Buffer();
|
|
||||||
zipSource.readFully(fileBuffer, entry.getSize());
|
|
||||||
return fileBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// File lock must be obtained before calling this function
|
|
||||||
private Buffer findFile(URI loc) throws IOException, URISyntaxException {
|
|
||||||
while (true) {
|
|
||||||
entry = zis.getNextEntry();
|
|
||||||
if (entry == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Buffer data = readCurrFile();
|
|
||||||
URI fileLoc = new URI(removeFolder(entry.getName()));
|
|
||||||
if (loc.equals(fileLoc)) {
|
|
||||||
return data;
|
|
||||||
} else {
|
|
||||||
readFiles.put(fileLoc, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Source getFileSource(URI loc) throws Exception {
|
|
||||||
filesLock.lock();
|
|
||||||
// Assume files are only read once, allow GC by removing
|
|
||||||
Buffer file = readFiles.remove(loc);
|
|
||||||
if (file != null) {
|
|
||||||
filesLock.unlock();
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
file = findFile(loc);
|
|
||||||
filesLock.unlock();
|
|
||||||
if (file != null) {
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public URI findInZip(Predicate<URI> matches) throws Exception {
|
|
||||||
filesLock.lock();
|
|
||||||
for (URI file : readFiles.keySet()) {
|
|
||||||
if (matches.test(file)) {
|
|
||||||
filesLock.unlock();
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
entry = zis.getNextEntry();
|
|
||||||
if (entry == null) {
|
|
||||||
filesLock.unlock();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Buffer data = readCurrFile();
|
|
||||||
URI fileLoc = new URI(removeFolder(entry.getName()));
|
|
||||||
readFiles.put(fileLoc, data);
|
|
||||||
if (matches.test(fileLoc)) {
|
|
||||||
filesLock.unlock();
|
|
||||||
return fileLoc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Map<URI, ZipReader> cache = new HashMap<URI, ZipReader>();
|
|
||||||
final ReentrantReadWriteLock cacheLock = new ReentrantReadWriteLock();
|
|
||||||
|
|
||||||
protected abstract URI getZipUri(URI loc) throws Exception;
|
|
||||||
|
|
||||||
protected abstract URI getLocationInZip(URI loc) throws Exception;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public abstract boolean matchesHandler(URI loc);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Source getFileSource(URI loc) throws Exception {
|
|
||||||
URI zipUri = getZipUri(loc);
|
|
||||||
cacheLock.readLock().lock();
|
|
||||||
ZipReader zr = cache.get(zipUri);
|
|
||||||
cacheLock.readLock().unlock();
|
|
||||||
if (zr == null) {
|
|
||||||
cacheLock.writeLock().lock();
|
|
||||||
// Recheck, because unlocking read lock allows another thread to modify it
|
|
||||||
zr = cache.get(zipUri);
|
|
||||||
if (zr == null) {
|
|
||||||
Source src = super.getFileSource(zipUri);
|
|
||||||
if (src == null) {
|
|
||||||
cacheLock.writeLock().unlock();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
zr = new ZipReader(src);
|
|
||||||
cache.put(zipUri, zr);
|
|
||||||
}
|
|
||||||
cacheLock.writeLock().unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
return zr.getFileSource(getLocationInZip(loc));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected URI findInZip(URI loc, Predicate<URI> matches) throws Exception {
|
|
||||||
URI zipUri = getZipUri(loc);
|
|
||||||
cacheLock.readLock().lock();
|
|
||||||
ZipReader zr = cache.get(zipUri);
|
|
||||||
cacheLock.readLock().unlock();
|
|
||||||
if (zr == null) {
|
|
||||||
cacheLock.writeLock().lock();
|
|
||||||
// Recheck, because unlocking read lock allows another thread to modify it
|
|
||||||
zr = cache.get(zipUri);
|
|
||||||
if (zr == null) {
|
|
||||||
zr = new ZipReader(super.getFileSource(zipUri));
|
|
||||||
cache.put(zipUri, zr);
|
|
||||||
}
|
|
||||||
cacheLock.writeLock().unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
return zr.findInZip(matches);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,32 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.ui;
|
|
||||||
|
|
||||||
public class CLIHandler implements IUserInterface {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleException(Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void show() {}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void submitProgress(InstallProgress progress) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
if (progress.hasProgress) {
|
|
||||||
sb.append('(');
|
|
||||||
sb.append(progress.progress);
|
|
||||||
sb.append('/');
|
|
||||||
sb.append(progress.progressTotal);
|
|
||||||
sb.append(") ");
|
|
||||||
}
|
|
||||||
sb.append(progress.message);
|
|
||||||
System.out.println(sb.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void executeManager(Runnable task) {
|
|
||||||
task.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,23 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.ui;
|
|
||||||
|
|
||||||
public interface IUserInterface {
|
|
||||||
|
|
||||||
public void show();
|
|
||||||
|
|
||||||
public void handleException(Exception e);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This might not exit straight away, return after calling this!
|
|
||||||
*/
|
|
||||||
public default void handleExceptionAndExit(Exception e) {
|
|
||||||
handleException(e);
|
|
||||||
System.exit(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
public default void setTitle(String title) {};
|
|
||||||
|
|
||||||
public void submitProgress(InstallProgress progress);
|
|
||||||
|
|
||||||
public void executeManager(Runnable task);
|
|
||||||
|
|
||||||
}
|
|
@@ -1,22 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.ui;
|
|
||||||
|
|
||||||
public class InstallProgress {
|
|
||||||
public final String message;
|
|
||||||
public final boolean hasProgress;
|
|
||||||
public final int progress;
|
|
||||||
public final int progressTotal;
|
|
||||||
|
|
||||||
public InstallProgress(String message) {
|
|
||||||
this.message = message;
|
|
||||||
hasProgress = false;
|
|
||||||
progress = 0;
|
|
||||||
progressTotal = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public InstallProgress(String message, int progress, int progressTotal) {
|
|
||||||
this.message = message;
|
|
||||||
hasProgress = true;
|
|
||||||
this.progress = progress;
|
|
||||||
this.progressTotal = progressTotal;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,189 +0,0 @@
|
|||||||
package link.infra.packwiz.installer.ui;
|
|
||||||
|
|
||||||
import java.awt.BorderLayout;
|
|
||||||
import java.awt.Component;
|
|
||||||
import java.awt.EventQueue;
|
|
||||||
import java.awt.GridBagConstraints;
|
|
||||||
import java.awt.GridBagLayout;
|
|
||||||
import java.awt.event.ActionEvent;
|
|
||||||
import java.awt.event.ActionListener;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
import javax.swing.JButton;
|
|
||||||
import javax.swing.JFrame;
|
|
||||||
import javax.swing.JLabel;
|
|
||||||
import javax.swing.JOptionPane;
|
|
||||||
import javax.swing.JPanel;
|
|
||||||
import javax.swing.JProgressBar;
|
|
||||||
import javax.swing.UIManager;
|
|
||||||
import javax.swing.border.EmptyBorder;
|
|
||||||
|
|
||||||
public class InstallWindow implements IUserInterface {
|
|
||||||
|
|
||||||
private JFrame frmPackwizlauncher;
|
|
||||||
private JLabel lblProgresslabel;
|
|
||||||
private JProgressBar progressBar;
|
|
||||||
|
|
||||||
private String title = "Updating modpack...";
|
|
||||||
private SwingWorkerButWithPublicPublish<Void, InstallProgress> worker;
|
|
||||||
private AtomicBoolean aboutToCrash = new AtomicBoolean();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void show() {
|
|
||||||
EventQueue.invokeLater(new Runnable() {
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
|
||||||
InstallWindow.this.initialize();
|
|
||||||
InstallWindow.this.frmPackwizlauncher.setVisible(true);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the contents of the frame.
|
|
||||||
*/
|
|
||||||
private void initialize() {
|
|
||||||
frmPackwizlauncher = new JFrame();
|
|
||||||
frmPackwizlauncher.setTitle(title);
|
|
||||||
frmPackwizlauncher.setBounds(100, 100, 493, 95);
|
|
||||||
frmPackwizlauncher.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
|
||||||
frmPackwizlauncher.setLocationRelativeTo(null);
|
|
||||||
|
|
||||||
JPanel panel = new JPanel();
|
|
||||||
panel.setBorder(new EmptyBorder(10, 10, 10, 10));
|
|
||||||
frmPackwizlauncher.getContentPane().add(panel, BorderLayout.CENTER);
|
|
||||||
panel.setLayout(new BorderLayout(0, 0));
|
|
||||||
|
|
||||||
progressBar = new JProgressBar();
|
|
||||||
progressBar.setIndeterminate(true);
|
|
||||||
panel.add(progressBar, BorderLayout.CENTER);
|
|
||||||
|
|
||||||
lblProgresslabel = new JLabel("Loading...");
|
|
||||||
panel.add(lblProgresslabel, BorderLayout.SOUTH);
|
|
||||||
|
|
||||||
JPanel panel_1 = new JPanel();
|
|
||||||
panel_1.setBorder(new EmptyBorder(0, 5, 0, 5));
|
|
||||||
frmPackwizlauncher.getContentPane().add(panel_1, BorderLayout.EAST);
|
|
||||||
GridBagLayout gbl_panel_1 = new GridBagLayout();
|
|
||||||
panel_1.setLayout(gbl_panel_1);
|
|
||||||
|
|
||||||
JButton btnOptions = new JButton("Options...");
|
|
||||||
btnOptions.setAlignmentX(Component.CENTER_ALIGNMENT);
|
|
||||||
GridBagConstraints gbc_btnOptions = new GridBagConstraints();
|
|
||||||
gbc_btnOptions.gridx = 0;
|
|
||||||
gbc_btnOptions.gridy = 0;
|
|
||||||
panel_1.add(btnOptions, gbc_btnOptions);
|
|
||||||
|
|
||||||
JButton btnCancel = new JButton("Cancel");
|
|
||||||
btnCancel.addActionListener(new ActionListener() {
|
|
||||||
public void actionPerformed(ActionEvent arg0) {
|
|
||||||
if (worker != null) {
|
|
||||||
worker.cancel(true);
|
|
||||||
}
|
|
||||||
frmPackwizlauncher.dispose();
|
|
||||||
// TODO: show window to ask user what to do
|
|
||||||
System.out.println("Update process cancelled by user!");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
btnCancel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
|
||||||
GridBagConstraints gbc_btnCancel = new GridBagConstraints();
|
|
||||||
gbc_btnCancel.gridx = 0;
|
|
||||||
gbc_btnCancel.gridy = 1;
|
|
||||||
panel_1.add(btnCancel, gbc_btnCancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleException(Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
EventQueue.invokeLater(new Runnable() {
|
|
||||||
public void run() {
|
|
||||||
JOptionPane.showMessageDialog(null, "An error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(), title, JOptionPane.ERROR_MESSAGE);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleExceptionAndExit(Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
// Used to prevent the done() handler of SwingWorker executing if the invokeLater hasn't happened yet
|
|
||||||
aboutToCrash.set(true);
|
|
||||||
EventQueue.invokeLater(new Runnable() {
|
|
||||||
public void run() {
|
|
||||||
JOptionPane.showMessageDialog(null, "A fatal error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(), title, JOptionPane.ERROR_MESSAGE);
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setTitle(String title) {
|
|
||||||
this.title = title;
|
|
||||||
if (frmPackwizlauncher != null) {
|
|
||||||
EventQueue.invokeLater(new Runnable() {
|
|
||||||
public void run() {
|
|
||||||
InstallWindow.this.frmPackwizlauncher.setTitle(title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void submitProgress(InstallProgress progress) {
|
|
||||||
if (worker != null) {
|
|
||||||
worker.publishPublic(progress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void executeManager(Runnable task) {
|
|
||||||
EventQueue.invokeLater(new Runnable() {
|
|
||||||
public void run() {
|
|
||||||
worker = new SwingWorkerButWithPublicPublish<Void, InstallProgress>() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground() throws Exception {
|
|
||||||
task.run();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void process(List<InstallProgress> chunks) {
|
|
||||||
// Only process last chunk
|
|
||||||
if (chunks.size() > 0) {
|
|
||||||
InstallProgress prog = chunks.get(chunks.size() - 1);
|
|
||||||
if (prog.hasProgress) {
|
|
||||||
progressBar.setIndeterminate(false);
|
|
||||||
progressBar.setValue(prog.progress);
|
|
||||||
progressBar.setMaximum(prog.progressTotal);
|
|
||||||
} else {
|
|
||||||
progressBar.setIndeterminate(true);
|
|
||||||
progressBar.setValue(0);
|
|
||||||
}
|
|
||||||
lblProgresslabel.setText(prog.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void done() {
|
|
||||||
if (aboutToCrash.get()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// TODO: a better way to do this?
|
|
||||||
frmPackwizlauncher.dispose();
|
|
||||||
System.out.println("Finished successfully!");
|
|
||||||
System.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
worker.execute();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -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
|
|
||||||
public abstract class SwingWorkerButWithPublicPublish<T,V> extends SwingWorker<T,V> {
|
|
||||||
@SafeVarargs
|
|
||||||
public final void publishPublic(V... chunks) {
|
|
||||||
publish(chunks);
|
|
||||||
}
|
|
||||||
}
|
|
244
src/main/kotlin/link/infra/packwiz/installer/DownloadTask.kt
Normal file
244
src/main/kotlin/link/infra/packwiz/installer/DownloadTask.kt
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package link.infra.packwiz.installer
|
||||||
|
|
||||||
|
import link.infra.packwiz.installer.metadata.IndexFile
|
||||||
|
import link.infra.packwiz.installer.metadata.ManifestFile
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||||
|
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
|
||||||
|
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
|
||||||
|
import link.infra.packwiz.installer.ui.ExceptionDetails
|
||||||
|
import link.infra.packwiz.installer.ui.IOptionDetails
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.HashingSink
|
||||||
|
import okio.buffer
|
||||||
|
import 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 {
|
||||||
|
var cachedFile: ManifestFile.File? = null
|
||||||
|
|
||||||
|
private var err: Exception? = null
|
||||||
|
val exceptionDetails get() = err?.let { e -> ExceptionDetails(name, e) }
|
||||||
|
|
||||||
|
fun failed() = err != null
|
||||||
|
|
||||||
|
private var alreadyUpToDate = false
|
||||||
|
private var metadataRequired = true
|
||||||
|
private var invalidated = false
|
||||||
|
// If file is new or isOptional changed to true, the option needs to be presented again
|
||||||
|
private var newOptional = true
|
||||||
|
|
||||||
|
val isOptional get() = metadata.linkedFile?.isOptional ?: false
|
||||||
|
|
||||||
|
fun isNewOptional() = isOptional && newOptional
|
||||||
|
|
||||||
|
fun correctSide() = metadata.linkedFile?.side?.hasSide(downloadSide) ?: true
|
||||||
|
|
||||||
|
override val name get() = metadata.name
|
||||||
|
|
||||||
|
// Ensure that an update is done if it changes from false to true, or from true to false
|
||||||
|
override var optionValue: Boolean
|
||||||
|
get() = cachedFile?.optionValue ?: true
|
||||||
|
set(value) {
|
||||||
|
if (value && !optionValue) { // Ensure that an update is done if it changes from false to true, or from true to false
|
||||||
|
alreadyUpToDate = false
|
||||||
|
}
|
||||||
|
cachedFile?.optionValue = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override val optionDescription get() = metadata.linkedFile?.option?.description ?: ""
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (metadata.hashFormat?.isEmpty() != false) {
|
||||||
|
metadata.hashFormat = defaultFormat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidate() {
|
||||||
|
invalidated = true
|
||||||
|
alreadyUpToDate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFromCache(cachedFile: ManifestFile.File?) {
|
||||||
|
if (err != null) return
|
||||||
|
|
||||||
|
if (cachedFile == null) {
|
||||||
|
this.cachedFile = ManifestFile.File()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.cachedFile = cachedFile
|
||||||
|
if (!invalidated) {
|
||||||
|
val currHash = try {
|
||||||
|
getHash(metadata.hashFormat!!, metadata.hash!!)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
err = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currHash == cachedFile.hash) { // Already up to date
|
||||||
|
alreadyUpToDate = true
|
||||||
|
metadataRequired = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cachedFile.isOptional) {
|
||||||
|
// Because option selection dialog might set this task to true/false, metadata is always needed to download
|
||||||
|
// the file, and to show the description and name
|
||||||
|
metadataRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadMetadata(parentIndexFile: IndexFile, indexUri: SpaceSafeURI) {
|
||||||
|
if (err != null) return
|
||||||
|
|
||||||
|
if (metadataRequired) {
|
||||||
|
try {
|
||||||
|
// Retrieve the linked metadata file
|
||||||
|
metadata.downloadMeta(parentIndexFile, indexUri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
err = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedFile.isOptional = isOptional
|
||||||
|
cachedFile.onlyOtherSide = !correctSide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(packFolder: String, indexUri: SpaceSafeURI) {
|
||||||
|
if (err != null) return
|
||||||
|
|
||||||
|
// Ensure it is removed
|
||||||
|
cachedFile?.let {
|
||||||
|
if (!it.optionValue || !correctSide()) {
|
||||||
|
if (it.cachedLocation == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(Paths.get(packFolder, it.cachedLocation))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// TODO: how much of a problem is this? use log4j/other log library to show warning?
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
it.cachedLocation = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alreadyUpToDate) return
|
||||||
|
|
||||||
|
// TODO: should I be validating JSON properly, or this fine!!!!!!!??
|
||||||
|
assert(metadata.destURI != null)
|
||||||
|
val destPath = Paths.get(packFolder, metadata.destURI.toString())
|
||||||
|
|
||||||
|
// Don't update files marked with preserve if they already exist on disk
|
||||||
|
if (metadata.preserve) {
|
||||||
|
if (destPath.toFile().exists()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val hash: Hash
|
||||||
|
val fileHashFormat: String
|
||||||
|
val linkedFile = metadata.linkedFile
|
||||||
|
|
||||||
|
if (linkedFile != null) {
|
||||||
|
hash = linkedFile.hash
|
||||||
|
fileHashFormat = Objects.requireNonNull(Objects.requireNonNull(linkedFile.download)!!.hashFormat)!!
|
||||||
|
} else {
|
||||||
|
hash = metadata.getHashObj()
|
||||||
|
fileHashFormat = Objects.requireNonNull(metadata.hashFormat)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
val src = metadata.getSource(indexUri)
|
||||||
|
val fileSource = getHasher(fileHashFormat).getHashingSource(src)
|
||||||
|
val data = Buffer()
|
||||||
|
|
||||||
|
// Read all the data into a buffer (very inefficient for large files! but allows rollback if hash check fails)
|
||||||
|
// TODO: should we instead rename the existing file, then stream straight to the file and rollback from the renamed file?
|
||||||
|
fileSource.buffer().use {
|
||||||
|
it.readAll(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSource.hashIsEqual(hash)) {
|
||||||
|
// isDirectory follows symlinks, but createDirectories doesn't
|
||||||
|
if (Files.isDirectory(destPath.parent)) {
|
||||||
|
Files.createDirectories(destPath.parent)
|
||||||
|
}
|
||||||
|
Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
data.clear()
|
||||||
|
} else {
|
||||||
|
// TODO: no more PRINTLN!!!!!!!!!
|
||||||
|
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())
|
||||||
|
data.readAll(sha256)
|
||||||
|
println("SHA256 hash value: " + sha256.hash)
|
||||||
|
err = Exception("Hash invalid!")
|
||||||
|
data.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cachedFile?.cachedLocation?.let {
|
||||||
|
if (destPath != Paths.get(packFolder, it)) {
|
||||||
|
// Delete old file if location changes
|
||||||
|
try {
|
||||||
|
Files.delete(Paths.get(packFolder, cachedFile!!.cachedLocation))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// Continue, as it was probably already deleted?
|
||||||
|
// TODO: log it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
err = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the manifest file
|
||||||
|
cachedFile = (cachedFile ?: ManifestFile.File()).also {
|
||||||
|
try {
|
||||||
|
it.hash = metadata.getHashObj()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
err = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it.isOptional = isOptional
|
||||||
|
it.cachedLocation = metadata.destURI.toString()
|
||||||
|
metadata.linkedFile?.let { linked ->
|
||||||
|
try {
|
||||||
|
it.linkedFileHash = linked.hash
|
||||||
|
} catch (e: Exception) {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun createTasksFromIndex(index: IndexFile, defaultFormat: String, downloadSide: UpdateManager.Options.Side): List<DownloadTask> {
|
||||||
|
val tasks = ArrayList<DownloadTask>()
|
||||||
|
for (file in Objects.requireNonNull(index.files)) {
|
||||||
|
tasks.add(DownloadTask(file, defaultFormat, downloadSide))
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
135
src/main/kotlin/link/infra/packwiz/installer/Main.kt
Normal file
135
src/main/kotlin/link/infra/packwiz/installer/Main.kt
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
@file:JvmName("Main")
|
||||||
|
|
||||||
|
package link.infra.packwiz.installer
|
||||||
|
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import link.infra.packwiz.installer.ui.CLIHandler
|
||||||
|
import link.infra.packwiz.installer.ui.InputStateHandler
|
||||||
|
import link.infra.packwiz.installer.ui.InstallWindow
|
||||||
|
import 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 javax.swing.JOptionPane
|
||||||
|
import javax.swing.UIManager
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
class Main(args: Array<String>) {
|
||||||
|
// Don't attempt to start a GUI if we are headless
|
||||||
|
var guiEnabled = !GraphicsEnvironment.isHeadless()
|
||||||
|
|
||||||
|
private fun startup(args: Array<String>) {
|
||||||
|
val options = Options()
|
||||||
|
addNonBootstrapOptions(options)
|
||||||
|
addBootstrapOptions(options)
|
||||||
|
|
||||||
|
val parser = DefaultParser()
|
||||||
|
val cmd = try {
|
||||||
|
parser.parse(options, args)
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
if (guiEnabled) {
|
||||||
|
EventQueue.invokeAndWait {
|
||||||
|
try {
|
||||||
|
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
// Ignore the exceptions, just continue using the ugly L&F
|
||||||
|
}
|
||||||
|
JOptionPane.showMessageDialog(null, e.message, "packwiz-installer", JOptionPane.ERROR_MESSAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guiEnabled && cmd.hasOption("no-gui")) {
|
||||||
|
guiEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val ui = if (guiEnabled) InstallWindow() else CLIHandler()
|
||||||
|
|
||||||
|
val unparsedArgs = cmd.args
|
||||||
|
if (unparsedArgs.size > 1) {
|
||||||
|
ui.handleExceptionAndExit(RuntimeException("Too many arguments specified!"))
|
||||||
|
} else if (unparsedArgs.isEmpty()) {
|
||||||
|
ui.handleExceptionAndExit(RuntimeException("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
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uOptions.downloadURI = SpaceSafeURI(unparsedArgs[0])
|
||||||
|
} catch (e: URISyntaxException) {
|
||||||
|
// TODO: better error message?
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start update process!
|
||||||
|
// TODO: start in SwingWorker?
|
||||||
|
try {
|
||||||
|
ui.executeManager {
|
||||||
|
try {
|
||||||
|
UpdateManager(uOptions, ui, inputStateHandler)
|
||||||
|
} catch (e: Exception) { // TODO: better error message?
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { // TODO: better error message?
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Called by packwiz-installer-bootstrap to set up the help command
|
||||||
|
@JvmStatic
|
||||||
|
fun addNonBootstrapOptions(options: Options) {
|
||||||
|
options.addOption("s", "side", true, "Side to install mods from (client/server, defaults to client)")
|
||||||
|
options.addOption(null, "title", true, "Title of the installer window")
|
||||||
|
options.addOption(null, "pack-folder", true, "Folder to install the pack to (defaults to the JAR directory)")
|
||||||
|
options.addOption(null, "meta-file", true, "JSON file to store pack metadata, relative to the pack folder (defaults to packwiz.json)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: link these somehow so they're only defined once?
|
||||||
|
@JvmStatic
|
||||||
|
private fun addBootstrapOptions(options: Options) {
|
||||||
|
options.addOption(null, "bootstrap-update-url", true, "Github API URL for checking for updates")
|
||||||
|
options.addOption(null, "bootstrap-update-token", true, "Github API Access Token, for private repositories")
|
||||||
|
options.addOption(null, "bootstrap-no-update", false, "Don't update packwiz-installer")
|
||||||
|
options.addOption(null, "bootstrap-main-jar", true, "Location of the packwiz-installer JAR file")
|
||||||
|
options.addOption("g", "no-gui", false, "Don't display a GUI to show update progress")
|
||||||
|
options.addOption("h", "help", false, "Display this message") // Implemented in packwiz-installer-bootstrap!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual main() is in RequiresBootstrap!
|
||||||
|
init {
|
||||||
|
// Big overarching try/catch just in case everything breaks
|
||||||
|
try {
|
||||||
|
startup(args)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
if (guiEnabled) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
JOptionPane.showMessageDialog(null,
|
||||||
|
"A fatal error occurred: \n" + e.javaClass.canonicalName + ": " + e.message,
|
||||||
|
"packwiz-installer", JOptionPane.ERROR_MESSAGE)
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In case the EventQueue is broken, exit after 1 minute
|
||||||
|
Thread.sleep(60 * 1000.toLong())
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
495
src/main/kotlin/link/infra/packwiz/installer/UpdateManager.kt
Normal file
495
src/main/kotlin/link/infra/packwiz/installer/UpdateManager.kt
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
package link.infra.packwiz.installer
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.JsonIOException
|
||||||
|
import com.google.gson.JsonSyntaxException
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.moandjiezana.toml.Toml
|
||||||
|
import link.infra.packwiz.installer.DownloadTask.Companion.createTasksFromIndex
|
||||||
|
import link.infra.packwiz.installer.metadata.IndexFile
|
||||||
|
import link.infra.packwiz.installer.metadata.ManifestFile
|
||||||
|
import link.infra.packwiz.installer.metadata.PackFile
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import link.infra.packwiz.installer.metadata.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.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 okio.buffer
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.FileReader
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.io.IOException
|
||||||
|
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) {
|
||||||
|
private var cancelled = false
|
||||||
|
private var cancelledStartGame = false
|
||||||
|
private var errorsOccurred = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
checkOptions()
|
||||||
|
|
||||||
|
ui.submitProgress(InstallProgress("Loading manifest file..."))
|
||||||
|
val gson = GsonBuilder().registerTypeAdapter(Hash::class.java, Hash.TypeHandler()).create()
|
||||||
|
val manifest = try {
|
||||||
|
gson.fromJson(FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
|
||||||
|
ManifestFile::class.java)
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
ManifestFile()
|
||||||
|
} catch (e: JsonSyntaxException) {
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
return
|
||||||
|
} catch (e: JsonIOException) {
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
showCancellationDialog()
|
||||||
|
handleCancellation()
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.submitProgress(InstallProgress("Loading pack file..."))
|
||||||
|
val packFileSource = try {
|
||||||
|
val src = getFileSource(opts.downloadURI!!)
|
||||||
|
getHasher("sha256").getHashingSource(src)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// TODO: run cancellation window?
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val pf = packFileSource.buffer().use {
|
||||||
|
try {
|
||||||
|
Toml().read(it.inputStream()).to(PackFile::class.java)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
invalid = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if cachedLocation == null, should probably be installed!!
|
||||||
|
invalid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (invalid) {
|
||||||
|
println("File $fileUri invalidated, marked for redownloading")
|
||||||
|
invalidatedUris.add(fileUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.packFileHash?.let { packFileSource.hashIsEqual(it) } == true && invalidatedUris.isEmpty()) {
|
||||||
|
println("Modpack is already up to date!")
|
||||||
|
// todo: --force?
|
||||||
|
if (!stateHandler.optionsButton) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Modpack name: " + pf.name)
|
||||||
|
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e1: Exception) {
|
||||||
|
ui.handleExceptionAndExit(e1)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancellation()
|
||||||
|
|
||||||
|
// TODO: update MMC params, java args etc
|
||||||
|
|
||||||
|
// If there were errors, don't write the manifest/index hashes, to ensure they are rechecked later
|
||||||
|
if (errorsOccurred) {
|
||||||
|
manifest.indexFileHash = null
|
||||||
|
manifest.packFileHash = null
|
||||||
|
} else {
|
||||||
|
manifest.packFileHash = packFileSource.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.cachedSide = opts.side
|
||||||
|
try {
|
||||||
|
FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString()).use { writer -> gson.toJson(manifest, writer) }
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// TODO: add message?
|
||||||
|
ui.handleException(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest.indexFileHash = indexHash
|
||||||
|
|
||||||
|
val indexFileSource = try {
|
||||||
|
val src = getFileSource(indexUri)
|
||||||
|
getHasher(hashFormat).getHashingSource(src)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// TODO: run cancellation window?
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val indexFile = try {
|
||||||
|
Toml().read(indexFileSource.buffer().inputStream()).to(IndexFile::class.java)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!indexFileSource.hashIsEqual(indexHash)) {
|
||||||
|
ui.handleExceptionAndExit(RuntimeException("Your index hash is invalid! Please run packwiz refresh on the pack again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
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()
|
||||||
|
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) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
showCancellationDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.submitProgress(InstallProgress("Comparing new files..."))
|
||||||
|
|
||||||
|
// TODO: progress bar?
|
||||||
|
if (indexFile.files.isEmpty()) {
|
||||||
|
println("Warning: Index is empty!")
|
||||||
|
}
|
||||||
|
val tasks = createTasksFromIndex(indexFile, indexFile.hashFormat, opts.side)
|
||||||
|
// If the side changes, invalidate EVERYTHING just in case
|
||||||
|
// Might not be needed, but done just to be safe
|
||||||
|
val invalidateAll = opts.side != manifest.cachedSide
|
||||||
|
if (invalidateAll) {
|
||||||
|
println("Side changed, invalidating all mods")
|
||||||
|
}
|
||||||
|
tasks.forEach{ f ->
|
||||||
|
// TODO: should linkedfile be checked as well? should this be done in the download section?
|
||||||
|
if (invalidateAll) {
|
||||||
|
f.invalidate()
|
||||||
|
} else if (invalidatedUris.contains(f.metadata.file)) {
|
||||||
|
f.invalidate()
|
||||||
|
}
|
||||||
|
val file = manifest.cachedFiles[f.metadata.file]
|
||||||
|
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
|
||||||
|
file?.backup()
|
||||||
|
// If it is null, the DownloadTask will make a new empty cachedFile
|
||||||
|
f.updateFromCache(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
showCancellationDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's hope downloadMetadata is a pure function!!!
|
||||||
|
tasks.parallelStream().forEach { f -> f.downloadMetadata(indexFile, indexUri) }
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ExceptionListResult.CONTINUE -> {}
|
||||||
|
ExceptionListResult.CANCEL -> {
|
||||||
|
cancelled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ExceptionListResult.IGNORE -> {
|
||||||
|
cancelledStartGame = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateHandler.cancelButton) {
|
||||||
|
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??
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
// Interrupted means cancelled???
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
} catch (e: ExecutionException) {
|
||||||
|
ui.handleExceptionAndExit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.disableOptionsButton()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i in tasks.indices) {
|
||||||
|
var task: DownloadTask?
|
||||||
|
task = try {
|
||||||
|
completionService.take().get()
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
ui.handleException(e)
|
||||||
|
null
|
||||||
|
} catch (e: ExecutionException) {
|
||||||
|
ui.handleException(e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// Update manifest - If there were no errors cachedFile has already been modified in place (good old pass by reference)
|
||||||
|
task?.cachedFile?.let { file ->
|
||||||
|
if (task.failed()) {
|
||||||
|
val oldFile = file.revert
|
||||||
|
if (oldFile != null) {
|
||||||
|
task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, oldFile) }
|
||||||
|
} else { null }
|
||||||
|
} else {
|
||||||
|
task.metadata.file?.let { uri -> manifest.cachedFiles.putIfAbsent(uri, 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}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progress = "Failed to download, unknown reason"
|
||||||
|
}
|
||||||
|
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!)
|
||||||
|
threadPool.shutdown()
|
||||||
|
cancelled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shut down the thread pool when the update is done
|
||||||
|
threadPool.shutdown()
|
||||||
|
|
||||||
|
val failedTasks2ElectricBoogaloo = nonFailedFirstTasks.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) {
|
||||||
|
ExceptionListResult.CONTINUE -> {}
|
||||||
|
ExceptionListResult.CANCEL -> cancelled = true
|
||||||
|
ExceptionListResult.IGNORE -> cancelledStartGame = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
when (cancellationResult) {
|
||||||
|
CancellationResult.QUIT -> cancelled = true
|
||||||
|
CancellationResult.CONTINUE -> cancelledStartGame = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCancellation() {
|
||||||
|
if (cancelled) {
|
||||||
|
println("Update cancelled by user!")
|
||||||
|
exitProcess(1)
|
||||||
|
} else if (cancelledStartGame) {
|
||||||
|
println("Update cancelled by user! Continuing to start game...")
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,27 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata
|
||||||
|
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonToken
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class EfficientBooleanAdapter : TypeAdapter<Boolean?>() {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, value: Boolean?) {
|
||||||
|
if (value == null || !value) {
|
||||||
|
out.nullValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.value(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): Boolean? {
|
||||||
|
if (reader.peek() == JsonToken.NULL) {
|
||||||
|
reader.nextNull()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return reader.nextBoolean()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,99 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.moandjiezana.toml.Toml
|
||||||
|
import link.infra.packwiz.installer.metadata.hash.Hash
|
||||||
|
import link.infra.packwiz.installer.metadata.hash.HashUtils.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 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
|
||||||
|
var linkedFile: ModFile? = null
|
||||||
|
@Transient
|
||||||
|
var linkedFileURI: SpaceSafeURI? = null
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun downloadMeta(parentIndexFile: IndexFile, indexUri: SpaceSafeURI?) {
|
||||||
|
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)) {
|
||||||
|
throw Exception("Invalid mod file hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun getSource(indexUri: SpaceSafeURI?): Source {
|
||||||
|
return if (metafile) {
|
||||||
|
if (linkedFile == null) {
|
||||||
|
throw Exception("Linked file doesn't exist!")
|
||||||
|
}
|
||||||
|
linkedFile!!.getSource(linkedFileURI)
|
||||||
|
} else {
|
||||||
|
val newLoc = getNewLoc(indexUri, file) ?: throw Exception("Index file URI is invalid")
|
||||||
|
getFileSource(newLoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun getHashObj(): Hash {
|
||||||
|
if (hash == null) { // TODO: should these be more specific exceptions (e.g. IndexFileException?!)
|
||||||
|
throw Exception("Index file doesn't have a hash")
|
||||||
|
}
|
||||||
|
if (hashFormat == null) {
|
||||||
|
throw Exception("Index file doesn't have a hash format")
|
||||||
|
}
|
||||||
|
return getHash(hashFormat!!, hash!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: throw some kind of exception?
|
||||||
|
val name: String
|
||||||
|
get() {
|
||||||
|
if (metafile) {
|
||||||
|
return linkedFile?.name ?: linkedFile?.filename ?:
|
||||||
|
file?.run { Paths.get(path).fileName.toString() } ?: "Invalid file"
|
||||||
|
}
|
||||||
|
return file?.run { Paths.get(path).fileName.toString() } ?: "Invalid file"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: URIs are bad
|
||||||
|
val destURI: SpaceSafeURI?
|
||||||
|
get() {
|
||||||
|
if (alias != null) {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
return if (metafile && linkedFile != null) {
|
||||||
|
linkedFile?.filename?.let { file?.resolve(it) }
|
||||||
|
} else {
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class ManifestFile {
|
||||||
|
var packFileHash: Hash? = null
|
||||||
|
var indexFileHash: Hash? = null
|
||||||
|
var cachedFiles: MutableMap<SpaceSafeURI, File> = HashMap()
|
||||||
|
// If the side changes, EVERYTHING invalidates. FUN!!!
|
||||||
|
var cachedSide = UpdateManager.Options.Side.CLIENT
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
@JsonAdapter(EfficientBooleanAdapter::class)
|
||||||
|
var isOptional = false
|
||||||
|
var optionValue = true
|
||||||
|
|
||||||
|
@JsonAdapter(EfficientBooleanAdapter::class)
|
||||||
|
var onlyOtherSide = false
|
||||||
|
|
||||||
|
// When an error occurs, the state needs to be reverted. To do this, I have a crude revert system.
|
||||||
|
fun backup() {
|
||||||
|
revert = File().also {
|
||||||
|
it.hash = hash
|
||||||
|
it.linkedFileHash = linkedFileHash
|
||||||
|
it.cachedLocation = cachedLocation
|
||||||
|
it.isOptional = isOptional
|
||||||
|
it.optionValue = optionValue
|
||||||
|
it.onlyOtherSide = onlyOtherSide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,57 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import link.infra.packwiz.installer.UpdateManager
|
||||||
|
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 okio.Source
|
||||||
|
|
||||||
|
class ModFile {
|
||||||
|
var name: String? = null
|
||||||
|
var filename: String? = null
|
||||||
|
var side: UpdateManager.Options.Side? = null
|
||||||
|
var download: Download? = null
|
||||||
|
|
||||||
|
class Download {
|
||||||
|
var url: SpaceSafeURI? = null
|
||||||
|
@SerializedName("hash-format")
|
||||||
|
var hashFormat: String? = null
|
||||||
|
var hash: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
var update: Map<String, Any>? = null
|
||||||
|
var option: Option? = null
|
||||||
|
|
||||||
|
class Option {
|
||||||
|
var optional = false
|
||||||
|
var description: String? = null
|
||||||
|
@SerializedName("default")
|
||||||
|
var defaultValue = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun getSource(baseLoc: SpaceSafeURI?): Source {
|
||||||
|
download?.let {
|
||||||
|
if (it.url == null) {
|
||||||
|
throw Exception("Metadata file doesn't have a download URI")
|
||||||
|
}
|
||||||
|
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid")
|
||||||
|
return getFileSource(newLoc)
|
||||||
|
} ?: throw Exception("Metadata file doesn't have download")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 isOptional: Boolean get() = option?.optional ?: false
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions: Map<String, String>? = null
|
||||||
|
var client: Map<String, Any>? = null
|
||||||
|
var server: Map<String, Any>? = null
|
||||||
|
}
|
@@ -0,0 +1,61 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter
|
||||||
|
import java.io.Serializable
|
||||||
|
import java.net.MalformedURLException
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
// The world's worst URI wrapper
|
||||||
|
@JsonAdapter(SpaceSafeURIParser::class)
|
||||||
|
class SpaceSafeURI : Comparable<SpaceSafeURI>, Serializable {
|
||||||
|
private val u: URI
|
||||||
|
|
||||||
|
@Throws(URISyntaxException::class)
|
||||||
|
constructor(str: String) {
|
||||||
|
u = URI(str.replace(" ", "%20"))
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(uri: URI) {
|
||||||
|
u = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(URISyntaxException::class)
|
||||||
|
constructor(scheme: String?, authority: String?, path: String?, query: String?, fragment: String?) { // TODO: do all components need to be replaced?
|
||||||
|
u = URI(
|
||||||
|
scheme?.replace(" ", "%20"),
|
||||||
|
authority?.replace(" ", "%20"),
|
||||||
|
path?.replace(" ", "%20"),
|
||||||
|
query?.replace(" ", "%20"),
|
||||||
|
fragment?.replace(" ", "%20")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val path: String get() = u.path.replace("%20", " ")
|
||||||
|
|
||||||
|
override fun toString(): String = u.toString().replace("%20", " ")
|
||||||
|
|
||||||
|
fun resolve(path: String): SpaceSafeURI = SpaceSafeURI(u.resolve(path.replace(" ", "%20")))
|
||||||
|
|
||||||
|
fun resolve(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.resolve(loc.u))
|
||||||
|
|
||||||
|
fun relativize(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.relativize(loc.u))
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return if (other is SpaceSafeURI) {
|
||||||
|
u == other.u
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = u.hashCode()
|
||||||
|
|
||||||
|
override fun compareTo(other: SpaceSafeURI): Int = u.compareTo(other.u)
|
||||||
|
|
||||||
|
val scheme: String get() = u.scheme
|
||||||
|
val authority: String get() = u.authority
|
||||||
|
val host: String get() = u.host
|
||||||
|
|
||||||
|
@Throws(MalformedURLException::class)
|
||||||
|
fun toURL(): URL = u.toURL()
|
||||||
|
}
|
@@ -0,0 +1,25 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata
|
||||||
|
|
||||||
|
import com.google.gson.JsonDeserializationContext
|
||||||
|
import com.google.gson.JsonDeserializer
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import com.google.gson.JsonParseException
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class encodes spaces before parsing the URI, so the URI can actually be
|
||||||
|
* parsed.
|
||||||
|
*/
|
||||||
|
internal class SpaceSafeURIParser : JsonDeserializer<SpaceSafeURI> {
|
||||||
|
@Throws(JsonParseException::class)
|
||||||
|
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): SpaceSafeURI {
|
||||||
|
return try {
|
||||||
|
SpaceSafeURI(json.asString)
|
||||||
|
} catch (e: URISyntaxException) {
|
||||||
|
throw JsonParseException("Failed to parse URI", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: replace this with a better solution?
|
||||||
|
}
|
@@ -0,0 +1,10 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata.hash
|
||||||
|
|
||||||
|
import okio.ForwardingSource
|
||||||
|
import okio.Source
|
||||||
|
|
||||||
|
abstract class GeneralHashingSource(delegate: Source) : ForwardingSource(delegate) {
|
||||||
|
abstract val hash: Hash
|
||||||
|
|
||||||
|
fun hashIsEqual(compareTo: Any) = compareTo == hash
|
||||||
|
}
|
@@ -0,0 +1,34 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata.hash
|
||||||
|
|
||||||
|
import com.google.gson.*
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
|
abstract class Hash {
|
||||||
|
protected abstract val stringValue: String
|
||||||
|
protected abstract val type: String
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(JsonParseException::class)
|
||||||
|
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash {
|
||||||
|
val obj = json.asJsonObject
|
||||||
|
val type: String
|
||||||
|
val value: String
|
||||||
|
try {
|
||||||
|
type = obj["type"].asString
|
||||||
|
value = obj["value"].asString
|
||||||
|
} catch (e: NullPointerException) {
|
||||||
|
throw JsonParseException("Invalid hash JSON data")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
HashUtils.getHash(type, value)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw JsonParseException("Failed to create hash object", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,21 @@
|
|||||||
|
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,45 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,8 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata.hash
|
||||||
|
|
||||||
|
import okio.Source
|
||||||
|
|
||||||
|
interface IHasher {
|
||||||
|
fun getHashingSource(delegate: Source): GeneralHashingSource
|
||||||
|
fun getHash(value: String): Hash
|
||||||
|
}
|
@@ -0,0 +1,91 @@
|
|||||||
|
package link.infra.packwiz.installer.metadata.hash
|
||||||
|
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.Source
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class Murmur2Hasher : IHasher {
|
||||||
|
private inner class Murmur2GeneralHashingSource(delegate: Source) : GeneralHashingSource(delegate) {
|
||||||
|
val internalBuffer = Buffer()
|
||||||
|
val tempBuffer = Buffer()
|
||||||
|
|
||||||
|
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
val data = internalBuffer.readByteArray()
|
||||||
|
Murmur2Hash(Murmur2Lib.hash32(data, data.size, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||||
|
val out = delegate.read(tempBuffer, byteCount)
|
||||||
|
if (out > -1) {
|
||||||
|
sink.write(tempBuffer.clone(), out)
|
||||||
|
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
|
||||||
|
// private fun computeNormalizedArray(input: ByteArray): ByteArray {
|
||||||
|
// val output = ByteArray(input.size)
|
||||||
|
// var index = 0
|
||||||
|
// for (b in input) {
|
||||||
|
// when (b) {
|
||||||
|
// 9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
|
||||||
|
// else -> {
|
||||||
|
// output[index] = b
|
||||||
|
// index++
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// val outputTrimmed = ByteArray(index)
|
||||||
|
// System.arraycopy(output, 0, outputTrimmed, 0, index)
|
||||||
|
// return outputTrimmed
|
||||||
|
// }
|
||||||
|
|
||||||
|
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
|
||||||
|
var index = 0
|
||||||
|
val arr = input.readByteArray()
|
||||||
|
for (b in arr) {
|
||||||
|
when (b) {
|
||||||
|
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
|
||||||
|
else -> {
|
||||||
|
arr[index] = b
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.write(arr, 0, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Murmur2Hash : Hash {
|
||||||
|
val value: Int
|
||||||
|
|
||||||
|
constructor(value: String) {
|
||||||
|
// Parsing as long then casting to int converts values gt int max value but lt uint max value
|
||||||
|
// into negatives. I presume this is how the murmur2 code handles this.
|
||||||
|
this.value = value.toLong().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(value: Int) {
|
||||||
|
this.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override val stringValue get() = value.toString()
|
||||||
|
override val type get() = "murmur2"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is Murmur2Hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return value == other.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "murmur2: $value"
|
||||||
|
override fun hashCode(): Int = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getHashingSource(delegate: Source): GeneralHashingSource = Murmur2GeneralHashingSource(delegate)
|
||||||
|
override fun getHash(value: String): Hash = Murmur2Hash(value)
|
||||||
|
}
|
@@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
|
}
|
@@ -0,0 +1,24 @@
|
|||||||
|
package link.infra.packwiz.installer.request
|
||||||
|
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import okio.Source
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IRequestHandler handles requests for locations specified in modpack metadata.
|
||||||
|
*/
|
||||||
|
interface IRequestHandler {
|
||||||
|
fun matchesHandler(loc: SpaceSafeURI): Boolean
|
||||||
|
|
||||||
|
fun getNewLoc(loc: SpaceSafeURI): SpaceSafeURI {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Source for a location. Must be threadsafe.
|
||||||
|
* It is assumed that each location is read only once for the duration of an IRequestHandler.
|
||||||
|
* @param loc The location to be read
|
||||||
|
* @return The Source containing the data of the file
|
||||||
|
* @throws Exception Exception if it failed to download a file!!!
|
||||||
|
*/
|
||||||
|
fun getFileSource(loc: SpaceSafeURI): Source?
|
||||||
|
}
|
@@ -0,0 +1,18 @@
|
|||||||
|
package link.infra.packwiz.installer.request.handlers
|
||||||
|
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import link.infra.packwiz.installer.request.IRequestHandler
|
||||||
|
import okio.Source
|
||||||
|
import okio.source
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
open class RequestHandlerFile : IRequestHandler {
|
||||||
|
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
|
||||||
|
return "file" == loc.scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFileSource(loc: SpaceSafeURI): Source? {
|
||||||
|
val path = Paths.get(loc.toURL().toURI())
|
||||||
|
return path.source()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,71 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,29 @@
|
|||||||
|
package link.infra.packwiz.installer.request.handlers
|
||||||
|
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import link.infra.packwiz.installer.request.IRequestHandler
|
||||||
|
import okio.Source
|
||||||
|
import okio.source
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
|
open class RequestHandlerHTTP : IRequestHandler {
|
||||||
|
override fun matchesHandler(loc: SpaceSafeURI): Boolean {
|
||||||
|
val scheme = loc.scheme
|
||||||
|
return "http" == scheme || "https" == scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFileSource(loc: SpaceSafeURI): Source? {
|
||||||
|
val conn = loc.toURL().openConnection() as HttpURLConnection
|
||||||
|
// TODO: when do we send specific headers??? should there be a way to signal this?
|
||||||
|
conn.addRequestProperty("Accept", "application/octet-stream")
|
||||||
|
// TODO: include version?
|
||||||
|
conn.addRequestProperty("User-Agent", "packwiz-installer")
|
||||||
|
|
||||||
|
conn.apply {
|
||||||
|
// 30 second read timeout
|
||||||
|
readTimeout = 30 * 1000
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
return conn.inputStream.source()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,123 @@
|
|||||||
|
package link.infra.packwiz.installer.request.handlers
|
||||||
|
|
||||||
|
import link.infra.packwiz.installer.metadata.SpaceSafeURI
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.Source
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
import java.util.function.Predicate
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import kotlin.concurrent.read
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
import kotlin.concurrent.write
|
||||||
|
|
||||||
|
abstract class RequestHandlerZip(private val modeHasFolder: Boolean) : RequestHandlerHTTP() {
|
||||||
|
private fun removeFolder(name: String): String {
|
||||||
|
return if (modeHasFolder) {
|
||||||
|
// TODO: replace with proper path checks once switched to Path??
|
||||||
|
name.substring(name.indexOf("/") + 1)
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ZipReader 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,47 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,6 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
data class ExceptionDetails(
|
||||||
|
val name: String,
|
||||||
|
val exception: Exception
|
||||||
|
)
|
@@ -0,0 +1,151 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
import java.awt.BorderLayout
|
||||||
|
import java.awt.Desktop
|
||||||
|
import java.awt.event.WindowAdapter
|
||||||
|
import java.awt.event.WindowEvent
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import javax.swing.*
|
||||||
|
import javax.swing.border.EmptyBorder
|
||||||
|
|
||||||
|
class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFuture<IUserInterface.ExceptionListResult>, numTotal: Int, allowsIgnore: Boolean, parentWindow: JFrame?) : JDialog(parentWindow, "Failed file downloads", true) {
|
||||||
|
private val lblExceptionStacktrace: JTextArea
|
||||||
|
|
||||||
|
private class ExceptionListModel internal constructor(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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the dialog.
|
||||||
|
*/
|
||||||
|
init {
|
||||||
|
setBounds(100, 100, 540, 340)
|
||||||
|
setLocationRelativeTo(parentWindow)
|
||||||
|
|
||||||
|
contentPane.apply {
|
||||||
|
layout = BorderLayout()
|
||||||
|
|
||||||
|
// Error panel
|
||||||
|
add(JPanel().apply {
|
||||||
|
add(JLabel("One or more errors were encountered while installing the modpack!").apply {
|
||||||
|
icon = UIManager.getIcon("OptionPane.warningIcon")
|
||||||
|
})
|
||||||
|
}, BorderLayout.NORTH)
|
||||||
|
|
||||||
|
// Content panel
|
||||||
|
add(JPanel().apply {
|
||||||
|
border = EmptyBorder(5, 5, 5, 5)
|
||||||
|
layout = BorderLayout(0, 0)
|
||||||
|
|
||||||
|
add(JSplitPane().apply {
|
||||||
|
resizeWeight = 0.3
|
||||||
|
|
||||||
|
lblExceptionStacktrace = JTextArea("Select a file")
|
||||||
|
lblExceptionStacktrace.background = UIManager.getColor("List.background")
|
||||||
|
lblExceptionStacktrace.isOpaque = true
|
||||||
|
lblExceptionStacktrace.wrapStyleWord = true
|
||||||
|
lblExceptionStacktrace.lineWrap = true
|
||||||
|
lblExceptionStacktrace.isEditable = false
|
||||||
|
lblExceptionStacktrace.isFocusable = true
|
||||||
|
lblExceptionStacktrace.font = UIManager.getFont("Label.font")
|
||||||
|
lblExceptionStacktrace.border = EmptyBorder(5, 5, 5, 5)
|
||||||
|
|
||||||
|
rightComponent = JScrollPane(lblExceptionStacktrace)
|
||||||
|
|
||||||
|
leftComponent = JScrollPane(JList<String>().apply {
|
||||||
|
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||||
|
border = EmptyBorder(5, 5, 5, 5)
|
||||||
|
val listModel = ExceptionListModel(eList)
|
||||||
|
model = listModel
|
||||||
|
addListSelectionListener {
|
||||||
|
val i = selectedIndex
|
||||||
|
if (i > -1) {
|
||||||
|
val sw = StringWriter()
|
||||||
|
listModel.getExceptionAt(i).printStackTrace(PrintWriter(sw))
|
||||||
|
lblExceptionStacktrace.text = sw.toString()
|
||||||
|
// Scroll to the top
|
||||||
|
lblExceptionStacktrace.caretPosition = 0
|
||||||
|
} else {
|
||||||
|
lblExceptionStacktrace.text = "Select a file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, BorderLayout.CENTER)
|
||||||
|
|
||||||
|
// Button pane
|
||||||
|
add(JPanel().apply {
|
||||||
|
layout = BorderLayout(0, 0)
|
||||||
|
|
||||||
|
// Right buttons
|
||||||
|
add(JPanel().apply {
|
||||||
|
add(JButton("Continue").apply {
|
||||||
|
toolTipText = "Attempt to continue installing, excluding the failed downloads"
|
||||||
|
addActionListener {
|
||||||
|
future.complete(IUserInterface.ExceptionListResult.CONTINUE)
|
||||||
|
this@ExceptionListWindow.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
add(JButton("Cancel launch").apply {
|
||||||
|
toolTipText = "Stop launching the game"
|
||||||
|
addActionListener {
|
||||||
|
future.complete(IUserInterface.ExceptionListResult.CANCEL)
|
||||||
|
this@ExceptionListWindow.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
add(JButton("Ignore update").apply {
|
||||||
|
toolTipText = "Start the game without attempting to update"
|
||||||
|
isEnabled = allowsIgnore
|
||||||
|
addActionListener {
|
||||||
|
future.complete(IUserInterface.ExceptionListResult.IGNORE)
|
||||||
|
this@ExceptionListWindow.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, BorderLayout.EAST)
|
||||||
|
|
||||||
|
// Errored label
|
||||||
|
add(JLabel(eList.size.toString() + "/" + numTotal + " errored").apply {
|
||||||
|
horizontalAlignment = SwingConstants.CENTER
|
||||||
|
}, BorderLayout.CENTER)
|
||||||
|
|
||||||
|
// Left buttons
|
||||||
|
add(JPanel().apply {
|
||||||
|
add(JButton("Report issue").apply {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, BorderLayout.WEST)
|
||||||
|
}, BorderLayout.SOUTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
addWindowListener(object : WindowAdapter() {
|
||||||
|
override fun windowClosing(e: WindowEvent) {
|
||||||
|
future.complete(IUserInterface.ExceptionListResult.CANCEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun windowClosed(e: WindowEvent) {
|
||||||
|
// Just in case closing didn't get triggered - if something else called dispose() the
|
||||||
|
// future will have already completed
|
||||||
|
future.complete(IUserInterface.ExceptionListResult.CANCEL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,7 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
interface IOptionDetails {
|
||||||
|
val name: String
|
||||||
|
var optionValue: Boolean
|
||||||
|
val optionDescription: String
|
||||||
|
}
|
@@ -0,0 +1,41 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.Future
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
interface IUserInterface {
|
||||||
|
fun show(handler: InputStateHandler)
|
||||||
|
fun handleException(e: Exception)
|
||||||
|
@JvmDefault
|
||||||
|
fun handleExceptionAndExit(e: Exception) {
|
||||||
|
handleException(e)
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmDefault
|
||||||
|
fun setTitle(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 showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): Future<ExceptionListResult>
|
||||||
|
@JvmDefault
|
||||||
|
fun disableOptionsButton() {}
|
||||||
|
|
||||||
|
@JvmDefault
|
||||||
|
fun showCancellationDialog(): Future<CancellationResult> {
|
||||||
|
return CompletableFuture<CancellationResult>().apply {
|
||||||
|
complete(CancellationResult.QUIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ExceptionListResult {
|
||||||
|
CONTINUE, CANCEL, IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CancellationResult {
|
||||||
|
QUIT, CONTINUE
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,12 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
data class InstallProgress(
|
||||||
|
val message: String,
|
||||||
|
val hasProgress: Boolean = false,
|
||||||
|
val progress: Int = 0,
|
||||||
|
val progressTotal: Int = 0
|
||||||
|
) {
|
||||||
|
constructor(message: String, progress: Int, progressTotal: Int) : this(message, true, progress, progressTotal)
|
||||||
|
|
||||||
|
constructor(message: String) : this(message, false)
|
||||||
|
}
|
228
src/main/kotlin/link/infra/packwiz/installer/ui/InstallWindow.kt
Normal file
228
src/main/kotlin/link/infra/packwiz/installer/ui/InstallWindow.kt
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
// Serves as a proxy for IOptionDetails, so that setOptionValue isn't called until OK is clicked
|
||||||
|
internal class OptionTempHandler(private val opt: IOptionDetails) : IOptionDetails {
|
||||||
|
override var optionValue = opt.optionValue
|
||||||
|
|
||||||
|
override val name get() = opt.name
|
||||||
|
override val optionDescription get() = opt.optionDescription
|
||||||
|
|
||||||
|
fun finalise() {
|
||||||
|
opt.optionValue = optionValue
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,166 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
import java.awt.BorderLayout
|
||||||
|
import java.awt.FlowLayout
|
||||||
|
import java.awt.event.ActionEvent
|
||||||
|
import java.awt.event.ActionListener
|
||||||
|
import java.awt.event.WindowAdapter
|
||||||
|
import java.awt.event.WindowEvent
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import javax.swing.*
|
||||||
|
import javax.swing.border.EmptyBorder
|
||||||
|
import javax.swing.event.TableModelListener
|
||||||
|
import javax.swing.table.TableModel
|
||||||
|
|
||||||
|
class OptionsSelectWindow internal constructor(optList: List<IOptionDetails>, future: CompletableFuture<Boolean>, parentWindow: JFrame?) : JDialog(parentWindow, "Select optional mods...", true), ActionListener {
|
||||||
|
private val lblOptionDescription: JTextArea
|
||||||
|
private val tableModel: OptionTableModel
|
||||||
|
private val future: CompletableFuture<Boolean>
|
||||||
|
|
||||||
|
private class OptionTableModel internal constructor(givenOpts: List<IOptionDetails>) : TableModel {
|
||||||
|
private val opts: List<OptionTempHandler>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val mutOpts = ArrayList<OptionTempHandler>()
|
||||||
|
for (opt in givenOpts) {
|
||||||
|
mutOpts.add(OptionTempHandler(opt))
|
||||||
|
}
|
||||||
|
opts = mutOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRowCount() = opts.size
|
||||||
|
override fun getColumnCount() = 2
|
||||||
|
|
||||||
|
private val columnNames = arrayOf("Enabled", "Mod name")
|
||||||
|
private val columnTypes = arrayOf(Boolean::class.javaObjectType, String::class.java)
|
||||||
|
private val columnEditables = booleanArrayOf(true, false)
|
||||||
|
|
||||||
|
override fun getColumnName(columnIndex: Int) = columnNames[columnIndex]
|
||||||
|
override fun getColumnClass(columnIndex: Int) = columnTypes[columnIndex]
|
||||||
|
override fun isCellEditable(rowIndex: Int, columnIndex: Int) = columnEditables[columnIndex]
|
||||||
|
|
||||||
|
override fun getValueAt(rowIndex: Int, columnIndex: Int): Any {
|
||||||
|
val opt = opts[rowIndex]
|
||||||
|
return if (columnIndex == 0) opt.optionValue else opt.name
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setValueAt(aValue: Any, rowIndex: Int, columnIndex: Int) {
|
||||||
|
if (columnIndex == 0) {
|
||||||
|
val opt = opts[rowIndex]
|
||||||
|
opt.optionValue = aValue as Boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noop, the table model doesn't change!
|
||||||
|
override fun addTableModelListener(l: TableModelListener) {}
|
||||||
|
override fun removeTableModelListener(l: TableModelListener) {}
|
||||||
|
|
||||||
|
fun getDescription(rowIndex: Int) = opts[rowIndex].optionDescription
|
||||||
|
|
||||||
|
fun finalise() {
|
||||||
|
for (opt in opts) {
|
||||||
|
opt.finalise()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
if (e.actionCommand == "OK") {
|
||||||
|
tableModel.finalise()
|
||||||
|
future.complete(false)
|
||||||
|
dispose()
|
||||||
|
} else if (e.actionCommand == "Cancel") {
|
||||||
|
future.complete(true)
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the dialog.
|
||||||
|
*/
|
||||||
|
init {
|
||||||
|
tableModel = OptionTableModel(optList)
|
||||||
|
this.future = future
|
||||||
|
|
||||||
|
setBounds(100, 100, 450, 300)
|
||||||
|
setLocationRelativeTo(parentWindow)
|
||||||
|
|
||||||
|
contentPane.apply {
|
||||||
|
layout = BorderLayout()
|
||||||
|
add(JPanel().apply {
|
||||||
|
border = EmptyBorder(5, 5, 5, 5)
|
||||||
|
layout = BorderLayout(0, 0)
|
||||||
|
|
||||||
|
add(JSplitPane().apply {
|
||||||
|
resizeWeight = 0.5
|
||||||
|
|
||||||
|
lblOptionDescription = JTextArea("Select an option...").apply {
|
||||||
|
background = UIManager.getColor("List.background")
|
||||||
|
isOpaque = true
|
||||||
|
wrapStyleWord = true
|
||||||
|
lineWrap = true
|
||||||
|
isEditable = false
|
||||||
|
isFocusable = false
|
||||||
|
font = UIManager.getFont("Label.font")
|
||||||
|
border = EmptyBorder(10, 10, 10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftComponent = JScrollPane(JTable().apply {
|
||||||
|
showVerticalLines = false
|
||||||
|
showHorizontalLines = false
|
||||||
|
setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
|
||||||
|
setShowGrid(false)
|
||||||
|
model = tableModel
|
||||||
|
columnModel.getColumn(0).resizable = false
|
||||||
|
columnModel.getColumn(0).preferredWidth = 15
|
||||||
|
columnModel.getColumn(0).maxWidth = 15
|
||||||
|
columnModel.getColumn(1).resizable = false
|
||||||
|
selectionModel.addListSelectionListener {
|
||||||
|
val i = selectedRow
|
||||||
|
if (i > -1) {
|
||||||
|
lblOptionDescription.text = tableModel.getDescription(i)
|
||||||
|
} else {
|
||||||
|
lblOptionDescription.text = "Select an option..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tableHeader = null
|
||||||
|
}).apply {
|
||||||
|
viewport.background = UIManager.getColor("List.background")
|
||||||
|
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||||
|
}
|
||||||
|
|
||||||
|
rightComponent = JScrollPane(lblOptionDescription)
|
||||||
|
})
|
||||||
|
|
||||||
|
add(JPanel().apply {
|
||||||
|
layout = FlowLayout(FlowLayout.RIGHT)
|
||||||
|
|
||||||
|
add(JButton("OK").apply {
|
||||||
|
actionCommand = "OK"
|
||||||
|
addActionListener(this@OptionsSelectWindow)
|
||||||
|
|
||||||
|
this@OptionsSelectWindow.rootPane.defaultButton = this
|
||||||
|
})
|
||||||
|
|
||||||
|
add(JButton("Cancel").apply {
|
||||||
|
actionCommand = "Cancel"
|
||||||
|
addActionListener(this@OptionsSelectWindow)
|
||||||
|
})
|
||||||
|
}, BorderLayout.SOUTH)
|
||||||
|
}, BorderLayout.CENTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
addWindowListener(object : WindowAdapter() {
|
||||||
|
override fun windowClosing(e: WindowEvent) {
|
||||||
|
future.complete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun windowClosed(e: WindowEvent) {
|
||||||
|
// Just in case closing didn't get triggered - if something else called dispose() the
|
||||||
|
// future will have already completed
|
||||||
|
future.complete(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
package link.infra.packwiz.installer.ui
|
||||||
|
|
||||||
|
import javax.swing.SwingWorker
|
||||||
|
|
||||||
|
// Q: AAA WHAT HAVE YOU DONE THIS IS DISGUSTING
|
||||||
|
// A: it just makes things easier, so i can easily have one interface for CLI/GUI
|
||||||
|
// if someone has a better way to do this please PR it
|
||||||
|
abstract class SwingWorkerButWithPublicPublish<T, V> : SwingWorker<T, V>() {
|
||||||
|
@SafeVarargs
|
||||||
|
fun publishPublic(vararg chunks: V) {
|
||||||
|
publish(*chunks)
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user