Compare commits

...

78 Commits

Author SHA1 Message Date
comp500
1d5a787b02 Add JvmStatic to fix --help command (bootstrapper calls these) 2020-06-16 04:05:02 +01:00
comp500
b5983800e8 Update README.md 2020-05-11 17:48:41 +01:00
comp500
4b3c279e71 Add support for loading from file:// URIs 2020-05-08 22:57:03 +01:00
comp500
b413371306 Fix --help command 2020-05-08 18:08:53 +01:00
comp500
1d2ec61232 Fix disgusting getNewLoc call (!! already checks null!!) 2020-02-07 03:12:52 +00:00
comp500
a0da889a02 Optimise memory usage while computing Murmur2 2019-12-23 16:31:36 +00:00
comp500
432bb4e25f Fix Murmur2 hash implementation 2019-12-23 16:20:38 +00:00
comp500
c89d3b1e47 Calculate SHA256 hash for debugging 2019-12-23 01:05:15 +00:00
comp500
e8538c22bc Fix exception when old file doesn't exist 2019-12-23 00:36:54 +00:00
comp500
a15489f5e4 Complete Kotlin port 2019-12-21 02:04:10 +00:00
comp500
9d3587c72e Rename .java to .kt 2019-12-21 02:04:09 +00:00
comp500
bead683b7c Port UI to Kotlin 2019-12-20 23:20:25 +00:00
comp500
0770029dc6 Port metadata code to Kotlin 2019-12-19 21:11:47 +00:00
comp500
ecaab219c2 Port hashing stuff to Kotlin 2019-12-19 14:58:07 +00:00
comp500
b45a2983e7 Start porting to Kotlin 2019-12-19 12:25:20 +00:00
comp500
c0c318772b Improve file check speed, apparently Files.exists is slow 2019-09-04 03:03:29 +01:00
comp500
580408b92a Accept optional mods on the server rather than throwing an exception 2019-08-30 14:52:06 +01:00
comp500
dbdd1fb9f3 Ensure CLI is closed when update is done 2019-08-30 13:57:43 +01:00
comp500
79a983bc2f Don't invalidate files that aren't on the current side 2019-08-30 03:39:15 +01:00
comp500
0cba5ba17b Ensure index and pack files are rechecked after errors 2019-08-12 02:17:28 +01:00
comp500
ce60cdc385 Automagic github release creation 2019-08-12 02:06:49 +01:00
comp500
b314fc8e0b Fix case-sensitivity for standard hashes, add more hash support 2019-08-12 01:37:02 +01:00
comp500
ca4a13589d Fix crash on empty index file 2019-08-11 22:37:42 +01:00
comp500
d21668afa6 ALWAYS IMPLEMENT .hashCode() if its gonna be in a map 2019-08-11 18:08:40 +01:00
comp500
7946377159 Implement optional mods and cancel buttons 2019-08-11 17:49:56 +01:00
comp500
5a54a90f59 Create a wrapper around URIs to fix issues with spaces 2019-08-11 14:36:08 +01:00
comp500
465e4973ba Use ExceptionListWindow to present errors 2019-08-11 01:45:39 +01:00
comp500
ea60175514 Make handleExceptionAndExit better, fix some stuff 2019-08-11 01:14:39 +01:00
comp500
78f5d76fe9 Add showExceptions 2019-08-11 01:01:52 +01:00
comp500
452ab15cc7 Add new GUI code from windowbuilder, fix a thing 2019-08-11 00:53:29 +01:00
comp500
87b00f316a Fix ordering of locationing 2019-08-10 20:13:07 +01:00
comp500
1623c0f880 Correct dialog parentage 2019-08-10 20:08:02 +01:00
comp500
46bbc9b82e Enable text wrapping on the option description 2019-08-10 19:52:19 +01:00
comp500
02b50be782 Ensure files are removed when they need to be 2019-08-10 19:43:41 +01:00
comp500
e6637b9af8 Wrong use of boolean, should be different 2019-08-10 19:37:14 +01:00
comp500
5fc7d6382d Make intellij happy again 2019-08-10 19:31:20 +01:00
comp500
afd8e85754 Change text 2019-08-10 19:30:39 +01:00
comp500
ecbf0b9eba Clean up gitignore mess 2019-08-10 18:46:59 +01:00
comp500
37a1464e11 Make intellij happy 2019-08-10 18:31:12 +01:00
comp500
54fd84a6d8 Add proper options dialog (I do GUI in Windowbuilder usually) 2019-08-10 18:29:40 +01:00
comp500
dcf8d21aad Whoops, I am bad programmer 2019-08-10 02:18:02 +01:00
comp500
eaed3b2187 whoops i java 11'd again 2019-08-10 01:37:39 +01:00
comp500
b22edf920e whoops i java 11'd 2019-08-10 01:26:35 +01:00
comp500
4d8e695fc4 Add cancellation from options list 2019-08-09 20:17:01 +01:00
comp500
a9bd83e96b Implement some stuff, edge case checks, intellij optimisations 2019-08-09 18:15:47 +01:00
comp500
ae085743be Finally, there are no more errors in UpdateManager. Now to actually make it work 2019-08-09 18:03:34 +01:00
comp500
ad79cb3b21 Very fun indeed 2019-08-09 16:31:29 +01:00
comp500
320e56e74e Start rewrite of downloading system, THIS IS SCARY HELP 2019-08-09 15:40:18 +01:00
comp500
bd95bc15ad Thanks IntelliJ. ThintelliJ. 2019-08-07 13:21:15 +01:00
comp500
794b817eff Check for cachedFiles existing 2019-07-04 01:08:24 +01:00
comp500
a5ff63c587 Correctly handle file invalidation 2019-07-03 23:12:11 +01:00
comp500
34a86ffb7d Handle renamed files correctly, log invalidation 2019-07-03 22:49:08 +01:00
comp500
780efe2c9f Remove files correctly, redownload when deleted 2019-07-02 23:32:00 +01:00
comp500
d1647764c4 Implement preserve and alias in index 2019-06-24 17:47:17 +01:00
comp500
12bf090895 Fix hash serialisation in JSON, check index hash 2019-06-24 17:27:28 +01:00
comp500
533c7a3ed5 Gson isn't broken, I'm broken 2019-06-24 03:41:33 +01:00
comp500
d18e134140 Better error reporting, fix NPE (stupid Gson) 2019-06-24 03:38:19 +01:00
comp500
165c8cc172 Add murmur2 support 2019-06-24 03:23:26 +01:00
comp500
d986b39aa5 Fix URI parsing, hash checking, NPEs, create dirs 2019-06-24 02:09:19 +01:00
comp500
040bb955ec Download the right file 2019-06-21 16:36:39 +01:00
comp500
022af4b5c5 whoops, forgot to remove that one 2019-06-21 16:17:38 +01:00
comp500
442fb93ca8 Rewrite *everything* to use Okio 2019-06-21 16:14:25 +01:00
comp500
81c1ebaa15 Commit WIP downloading process 2019-06-21 14:28:41 +01:00
comp500
917d10c448 Add more deserialisation classes 2019-06-20 17:08:30 +01:00
comp500
26c3261848 uhhhhhhhhh Added IndexFile i guess 2019-06-20 14:22:15 +01:00
comp500
fbd54b4604 Redo hashing sytem, pack file reading, write json 2019-06-12 14:28:38 +01:00
comp500
8be5cb8e60 Null safety, better exception handling, check strm 2019-06-08 14:31:52 +01:00
comp500
c181a36edc Manifest file loading, hashing, pack file loading 2019-06-08 14:22:30 +01:00
comp500
e32d98fb98 Refactor handlers into ui package 2019-06-08 02:13:52 +01:00
comp500
e65d20be79 Add some command line params 2019-06-08 02:05:43 +01:00
comp500
3d28f0a674 Better exception handling, progress system 2019-06-06 23:25:22 +01:00
comp500
905630cb2a Revamp UI system, improve UpdateManager 2019-06-05 20:06:09 +01:00
comp500
fd87edd6ca Make github request handling work, fix some things 2019-06-01 16:26:15 +01:00
comp500
2118a8fda1 Notes 2019-06-01 12:41:33 +01:00
comp500
86c2349fd3 All cases mutate, so R/W lock is redundant and complicates things 2019-06-01 12:37:01 +01:00
comp500
f76a3d2d62 Implement Zip request handler 2019-06-01 12:33:28 +01:00
comp500
72d27715f8 Commit this WIP code because I might change my mind 2019-05-31 22:20:08 +01:00
comp500
26b8e1de86 Close input stream (ProgressMonitor) after copying 2019-05-21 01:19:39 +01:00
50 changed files with 2820 additions and 264 deletions

119
.gitignore vendored
View File

@@ -1,74 +1,87 @@
# Created by https://www.gitignore.io/api/java,gradle,eclipse
# Edit at https://www.gitignore.io/?templates=java,gradle,eclipse
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
# Created by https://www.gitignore.io/api/java,gradle,intellij
# Edit at https://www.gitignore.io/?templates=java,gradle,intellij
# External tool builders
.externalToolBuilders/
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# Locally stored "Eclipse launch configurations"
*.launch
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# PyDev specific (Python IDE for Eclipse)
*.pydevproject
# Generated files
.idea/**/contentModel.xml
# CDT-specific (C/C++ Development Tooling)
.cproject
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# CDT- autotools
.autotools
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Java annotation processor (APT)
.factorypath
# Gradle and Maven with auto-import
# 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/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# PDT-specific (PHP Development Tools)
.buildpath
# CMake
cmake-build-*/
# sbteclipse plugin
.target
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# Tern plugin
.tern-project
# File-based project format
*.iws
# TeXlipse plugin
.texlipse
# IntelliJ
out/
# STS (Spring Tool Suite)
.springBeans
# mpeltonen/sbt-idea plugin
.idea_modules/
# Code Recommenders
.recommenders/
# JIRA plugin
atlassian-ide-plugin.xml
# Annotation Processing
.apt_generated/
# Cursive Clojure plugin
.idea/replstate.xml
# Scala IDE specific (Scala & Java development for Eclipse)
.cache-main
.scala_dependencies
.worksheet
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### Eclipse Patch ###
# Eclipse Core
.project
# Editor-based Rest Client
.idea/httpRequests
# JDT-specific (Eclipse Java Development Tools)
.classpath
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# Annotation Processing
.apt_generated
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
.sts4-cache/
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
.idea/sonarlint
### Java ###
# Compiled class file
@@ -114,4 +127,4 @@ gradle-app.setting
### Gradle Patch ###
**/build/
# End of https://www.gitignore.io/api/java,gradle,eclipse
# End of https://www.gitignore.io/api/java,gradle,intellij

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

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

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

View File

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

20
.idea/jarRepositories.xml generated Normal file
View File

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

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

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

2
.idea/packwiz-installer.iml generated Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4" />

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

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

View File

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

View File

@@ -1,34 +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'
//testImplementation 'junit:junit:4.12'
}
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 is already included in packwiz-installer-bootstrap
shadowJar {
dependencies {
exclude(dependency('commons-cli:commons-cli:1.4'))
}
}

88
build.gradle.kts Normal file
View 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")
}
}

View File

@@ -1,99 +0,0 @@
package link.infra.packwiz.installer;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.UIManager;
import javax.swing.border.EmptyBorder;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
public class InstallWindow {
// TODO: move to seperate file, make usable without GUI
private JFrame frmPackwizlauncher;
private UpdateManager updateManager = new UpdateManager();
/**
* Launch the application.
*/
public static void main(String[] args) {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
InstallWindow window = new InstallWindow();
window.frmPackwizlauncher.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Create the application.
*/
public InstallWindow() {
initialize();
}
/**
* Initialize the contents of the frame.
*/
private void initialize() {
frmPackwizlauncher = new JFrame();
frmPackwizlauncher.setTitle("Updating modpack...");
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));
JProgressBar progressBar = new JProgressBar();
progressBar.setValue(50);
panel.add(progressBar, BorderLayout.CENTER);
JLabel 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) {
updateManager.cleanup();
frmPackwizlauncher.dispose();
}
});
btnCancel.setAlignmentX(Component.CENTER_ALIGNMENT);
GridBagConstraints gbc_btnCancel = new GridBagConstraints();
gbc_btnCancel.gridx = 0;
gbc_btnCancel.gridy = 1;
panel_1.add(btnCancel, gbc_btnCancel);
}
}

View File

@@ -1,53 +0,0 @@
package link.infra.packwiz.installer;
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;
public class Main {
// Actual main() is in RequiresBootstrap!
public Main(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);
}
System.out.println("Hello World!");
}
// Called by packwiz-installer-bootstrap to set up the help command
public static void addNonBootstrapOptions(Options options) {
//options.addOption("w", "welp", false, "Testing options");
}
// 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-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");
}
}

View File

@@ -1,10 +1,8 @@
package link.infra.packwiz.installer;
import javax.swing.*;
import java.util.Arrays;
import javax.swing.JOptionPane;
import javax.swing.UIManager;
public class RequiresBootstrap {
public static void main(String[] args) {
@@ -15,10 +13,10 @@ public class RequiresBootstrap {
if (Arrays.stream(args).map(str -> {
if (str == null) return "";
if (str.startsWith("--")) {
return str.substring(2, str.length());
return str.substring(2);
}
if (str.startsWith("-")) {
return str.substring(1, str.length());
return str.substring(1);
}
return "";
}).anyMatch(str -> str.equals("g") || str.equals("no-gui"))) {

View File

@@ -1,9 +0,0 @@
package link.infra.packwiz.installer;
public class UpdateManager {
Thread updateThread = new Thread(new UpdateThread());
public void cleanup() {
}
}

View File

@@ -1,10 +0,0 @@
package link.infra.packwiz.installer;
public class UpdateThread implements Runnable {
@Override
public void run() {
}
}

View File

@@ -0,0 +1,166 @@
// Obtained from https://github.com/prasanthj/hasher/blob/master/src/main/java/hasher/Murmur2.java
/**
* Copyright 2014 Prasanth Jayachandran
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package link.infra.packwiz.installer.metadata.hash;
/**
* Murmur2 32 and 64 bit variants.
* 32-bit Java port of https://code.google.com/p/smhasher/source/browse/trunk/MurmurHash2.cpp#37
* 64-bit Java port of https://code.google.com/p/smhasher/source/browse/trunk/MurmurHash2.cpp#96
*/
public class Murmur2Lib {
// Constants for 32-bit variant
private static final int M_32 = 0x5bd1e995;
private static final int R_32 = 24;
// Constants for 64-bit variant
private static final long M_64 = 0xc6a4a7935bd1e995L;
private static final int R_64 = 47;
private static final int DEFAULT_SEED = 0;
/**
* Murmur2 32-bit variant.
*
* @param data - input byte array
* @return - hashcode
*/
public static int hash32(byte[] data) {
return hash32(data, data.length, DEFAULT_SEED);
}
/**
* Murmur2 32-bit variant.
*
* @param data - input byte array
* @param length - length of array
* @param seed - seed. (default 0)
* @return - hashcode
*/
public static int hash32(byte[] data, int length, int seed) {
int h = seed ^ length;
int len_4 = length >> 2;
// body
for (int i = 0; i < len_4; i++) {
int i_4 = i << 2;
int k = (data[i_4] & 0xff)
| ((data[i_4 + 1] & 0xff) << 8)
| ((data[i_4 + 2] & 0xff) << 16)
| ((data[i_4 + 3] & 0xff) << 24);
// mix functions
k *= M_32;
k ^= k >>> R_32;
k *= M_32;
h *= M_32;
h ^= k;
}
// tail
int len_m = len_4 << 2;
int left = length - len_m;
if (left != 0) {
if (left >= 3) {
h ^= (int) data[length - (left - 2)] << 16;
}
if (left >= 2) {
h ^= (int) data[length - (left - 1)] << 8;
}
if (left >= 1) {
h ^= data[length - left];
}
h *= M_32;
}
// finalization
h ^= h >>> 13;
h *= M_32;
h ^= h >>> 15;
return h;
}
/**
* Murmur2 64-bit variant.
*
* @param data - input byte array
* @return - hashcode
*/
public static long hash64(final byte[] data) {
return hash64(data, data.length, DEFAULT_SEED);
}
/**
* Murmur2 64-bit variant.
*
* @param data - input byte array
* @param length - length of array
* @param seed - seed. (default 0)
* @return - hashcode
*/
public static long hash64(final byte[] data, int length, int seed) {
long h = (seed & 0xffffffffl) ^ (length * M_64);
int length8 = length >> 3;
// body
for (int i = 0; i < length8; i++) {
final int i8 = i << 3;
long k = ((long) data[i8] & 0xff)
| (((long) data[i8 + 1] & 0xff) << 8)
| (((long) data[i8 + 2] & 0xff) << 16)
| (((long) data[i8 + 3] & 0xff) << 24)
| (((long) data[i8 + 4] & 0xff) << 32)
| (((long) data[i8 + 5] & 0xff) << 40)
| (((long) data[i8 + 6] & 0xff) << 48)
| (((long) data[i8 + 7] & 0xff) << 56);
// mix functions
k *= M_64;
k ^= k >>> R_64;
k *= M_64;
h ^= k;
h *= M_64;
}
// tail
int tailStart = length8 << 3;
switch (length - tailStart) {
case 7:
h ^= (long) (data[tailStart + 6] & 0xff) << 48;
case 6:
h ^= (long) (data[tailStart + 5] & 0xff) << 40;
case 5:
h ^= (long) (data[tailStart + 4] & 0xff) << 32;
case 4:
h ^= (long) (data[tailStart + 3] & 0xff) << 24;
case 3:
h ^= (long) (data[tailStart + 2] & 0xff) << 16;
case 2:
h ^= (long) (data[tailStart + 1] & 0xff) << 8;
case 1:
h ^= data[tailStart] & 0xff;
h *= M_64;
}
// finalization
h ^= h >>> R_64;
h *= M_64;
h ^= h >>> R_64;
return h;
}
}

View File

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

View File

@@ -0,0 +1,127 @@
@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>) {
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()
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (e1: Exception) {
// Ignore the exceptions, just continue using the ugly L&F
}
JOptionPane.showMessageDialog(null, e.message, "packwiz-installer", JOptionPane.ERROR_MESSAGE)
exitProcess(1)
}
// if "headless", GUI creation will fail anyway!
val ui = if (cmd.hasOption("no-gui") || GraphicsEnvironment.isHeadless()) {
CLIHandler()
} else InstallWindow()
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(it)
}
val inputStateHandler = InputStateHandler()
ui.show(inputStateHandler)
val uOptions = UpdateManager.Options().apply {
side = cmd.getOptionValue("side")?.let { UpdateManager.Options.Side.from(it) } ?: side
packFolder = cmd.getOptionValue("pack-folder") ?: packFolder
manifestFile = cmd.getOptionValue("meta-file") ?: manifestFile
}
try {
uOptions.downloadURI = SpaceSafeURI(unparsedArgs[0])
} 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()
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)
}
}
}

View File

@@ -0,0 +1,496 @@
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)) {
// TODO: throw exception
println("I was meant to put an error message here but I'll do that later")
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)
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
return "github.com" == loc.host
// TODO: sanity checks, support for more github urls
}
}

View File

@@ -0,0 +1,25 @@
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
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()
// TODO: when do we send specific headers??? should there be a way to signal this?
// github *sometimes* requires it, sometimes not!
//conn.addRequestProperty("Accept", "application/octet-stream");
conn.apply {
// 30 second read timeout
readTimeout = 30 * 1000
}
return conn.getInputStream().source()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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